Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(table): Add interactionMode property to control focus behavior #8686

Merged
merged 10 commits into from
Feb 12, 2024
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,5 @@ export const CSS = {
selectedCell: "selected-cell",
assistiveText: "assistive-text",
lastCell: "last-cell",
nonInteractive: "non-interactive",
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nitpick: should this and related styles reflect the updated name?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated to match other css cell modifiers 👍

};
Original file line number Diff line number Diff line change
Expand Up @@ -24,15 +24,18 @@
}

td {
@apply text-start focus-base align-middle text-color-1 whitespace-normal;
@apply text-start align-middle text-color-1 whitespace-normal;
background: var(--calcite-internal-table-cell-background);
font-size: var(--calcite-internal-table-cell-font-size);
border-inline-end: 1px solid var(--calcite-color-border-3);
padding: var(--calcite-internal-table-cell-padding);

&:focus {
@apply focus-inset;
&:not(.non-interactive) {
@apply focus-base;
&:focus {
@apply focus-inset;
}
}
padding: var(--calcite-internal-table-cell-padding);
}

td.last-cell {
Expand All @@ -56,8 +59,11 @@ td.last-cell {
}

.selection-cell {
@apply cursor-pointer text-color-3;
@apply text-color-3;
inset-inline-start: 2rem;
&:not(.footer-cell) {
@apply cursor-pointer;
}
}

.selected-cell:not(.number-cell):not(.footer-cell) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ import {
import { connectLocalized, disconnectLocalized, LocalizedComponent } from "../../utils/locale";
import { TableCellMessages } from "./assets/table-cell/t9n";
import { CSS } from "./resources";
import { RowType } from "../table/interfaces";
import { RowType, TableInteractionMode } from "../table/interfaces";
import { getElementDir } from "../../utils/dom";
import { CSS_UTILITY } from "../../utils/resources";

Expand Down Expand Up @@ -58,6 +58,9 @@ export class TableCell
/** @internal */
@Prop() disabled: boolean;

/** @internal */
@Prop() interactionMode: TableInteractionMode = "interactive";

/** @internal */
@Prop() lastCell: boolean;

Expand Down Expand Up @@ -212,6 +215,10 @@ export class TableCell

render(): VNode {
const dir = getElementDir(this.el);
const nonFocusable =
this.disabled ||
(this.interactionMode === "static" &&
(!this.selectionCell || (this.selectionCell && this.parentRowType === "foot")));

return (
<Host>
Expand All @@ -225,13 +232,14 @@ export class TableCell
[CSS.selectedCell]: this.parentRowIsSelected,
[CSS.lastCell]: this.lastCell,
[CSS_UTILITY.rtl]: dir === "rtl",
[CSS.nonInteractive]: nonFocusable,
}}
colSpan={this.colSpan}
onBlur={this.onContainerBlur}
onFocus={this.onContainerFocus}
role="gridcell"
rowSpan={this.rowSpan}
tabIndex={this.disabled ? -1 : 0}
tabIndex={nonFocusable ? -1 : 0}
// eslint-disable-next-line react/jsx-sort-props -- ref should be last so node attrs/props are in sync (see https://github.com/Esri/calcite-design-system/pull/6530)
ref={(el) => (this.containerEl = el)}
>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,5 @@ export const CSS = {
active: "active",
selectedCell: "selected-cell",
lastCell: "last-cell",
nonInteractive: "non-interactive",
};
Original file line number Diff line number Diff line change
Expand Up @@ -26,15 +26,19 @@
}

th {
@apply text-color-1 focus-base text-start font-medium align-top whitespace-normal;
@apply text-color-1 text-start font-medium align-top whitespace-normal;
font-size: var(--calcite-internal-table-cell-font-size);
border-inline-end: 1px solid var(--calcite-internal-table-header-border-color);
border-block-end: 1px solid var(--calcite-internal-table-header-border-color);
padding-block: calc(var(--calcite-internal-table-cell-padding) * 1.5);
padding-inline: var(--calcite-internal-table-cell-padding);
background-color: var(--calcite-internal-table-header-background);
&:focus-within {
@apply focus-inset;

&:not(.non-interactive) {
@apply focus-base;
&:not(.non-interactive):focus-within {
@apply focus-inset;
}
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import { connectLocalized, disconnectLocalized, LocalizedComponent } from "../..
import { Alignment, Scale, SelectionMode } from "../interfaces";
import { TableHeaderMessages } from "./assets/table-header/t9n";
import { CSS } from "./resources";
import { RowType } from "../table/interfaces";
import { RowType, TableInteractionMode } from "../table/interfaces";
import { getIconScale } from "../../utils/component";

@Component({
Expand Down Expand Up @@ -47,6 +47,9 @@ export class TableHeader implements LocalizedComponent, LoadableComponent, T9nCo
/** Specifies the number of rows the component should span. */
@Prop({ reflect: true }) rowSpan: number;

/** @internal */
@Prop() interactionMode: TableInteractionMode = "interactive";

/** @internal */
@Prop() lastCell: boolean;

Expand Down Expand Up @@ -205,7 +208,7 @@ export class TableHeader implements LocalizedComponent, LoadableComponent, T9nCo

const allSelected = this.selectedRowCount === this.bodyRowCount;
const selectionIcon = allSelected ? "check-square-f" : "check-square";

const nonFocusable = this.interactionMode === "static" && !this.selectionCell;
return (
<Host>
<th
Expand All @@ -217,13 +220,14 @@ export class TableHeader implements LocalizedComponent, LoadableComponent, T9nCo
[CSS.selectionCell]: this.selectionCell,
[CSS.selectedCell]: this.parentRowIsSelected,
[CSS.multipleSelectionCell]: this.selectionMode === "multiple",
[CSS.nonInteractive]: nonFocusable,
[CSS.lastCell]: this.lastCell,
}}
colSpan={this.colSpan}
role="columnheader"
rowSpan={this.rowSpan}
scope={scope}
tabIndex={0}
tabIndex={this.selectionCell ? 0 : nonFocusable ? -1 : 0}
// eslint-disable-next-line react/jsx-sort-props -- ref should be last so node attrs/props are in sync (see https://github.com/Esri/calcite-design-system/pull/6530)
ref={(el) => (this.containerEl = el)}
>
Expand Down
13 changes: 11 additions & 2 deletions packages/calcite-components/src/components/table-row/table-row.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import {
import { LocalizedComponent } from "../../utils/locale";
import { Scale, SelectionMode } from "../interfaces";
import { focusElementInGroup, FocusElementInGroupDestination } from "../../utils/dom";
import { RowType, TableRowFocusEvent } from "../table/interfaces";
import { RowType, TableInteractionMode, TableRowFocusEvent } from "../table/interfaces";
import { isActivationKey } from "../../utils/key";
import {
connectInteractive,
Expand Down Expand Up @@ -51,6 +51,9 @@ export class TableRow implements InteractiveComponent, LocalizedComponent {
/** @internal */
@Prop({ mutable: true }) cellCount: number;

/** @internal */
@Prop() interactionMode: TableInteractionMode = "interactive";

/** @internal */
@Prop() lastVisibleRow: boolean;

Expand Down Expand Up @@ -91,6 +94,7 @@ export class TableRow implements InteractiveComponent, LocalizedComponent {
@Watch("scale")
@Watch("selected")
@Watch("selectedRowCount")
@Watch("interactionMode")
handleCellChanges(): void {
if (this.tableRowEl && this.rowCells.length > 0) {
this.updateCells();
Expand Down Expand Up @@ -284,6 +288,7 @@ export class TableRow implements InteractiveComponent, LocalizedComponent {

if (cells.length > 0) {
cells?.forEach((cell: HTMLCalciteTableCellElement | HTMLCalciteTableHeaderElement, index) => {
cell.interactionMode = this.interactionMode;
cell.positionInRow = index + 1;
cell.parentRowType = this.rowType;
cell.parentRowIsSelected = this.selected;
Expand Down Expand Up @@ -385,7 +390,11 @@ export class TableRow implements InteractiveComponent, LocalizedComponent {
aria-rowindex={this.positionAll + 1}
aria-selected={this.selected}
class={{ [CSS.lastVisibleRow]: this.lastVisibleRow }}
onKeyDown={(event) => this.keyDownHandler(event)}
onKeyDown={(event) => {
if (this.interactionMode === "interactive") {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we push this into the event handler to avoid creating a new anonymous function on each render?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated and will merge in post-Chromatic run. Thanks for review!

this.keyDownHandler(event);
}
}}
// eslint-disable-next-line react/jsx-sort-props -- ref should be last so node attrs/props are in sync (see https://github.com/Esri/calcite-design-system/pull/6530)
ref={(el) => (this.tableRowEl = el)}
>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,5 @@ export interface TableRowFocusEvent {
export type RowType = "head" | "body" | "foot";

export type TableLayout = "auto" | "fixed";

export type TableInteractionMode = "interactive" | "static";
128 changes: 128 additions & 0 deletions packages/calcite-components/src/components/table/table.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -267,6 +267,45 @@ describe("calcite-table", () => {
);
});

describe("is accessible with pagination and interaction mode static", () => {
accessible(
html`<calcite-table page-size="4" caption="Simple table" interaction-mode="static">
<calcite-table-row slot=${SLOTS.tableHeader}>
<calcite-table-header heading="Heading" description="Description"></calcite-table-header>
<calcite-table-header heading="Heading" description="Description"></calcite-table-header>
</calcite-table-row>
<calcite-table-row>
<calcite-table-cell>cell</calcite-table-cell>
<calcite-table-cell>cell</calcite-table-cell>
</calcite-table-row>
<calcite-table-row>
<calcite-table-cell>cell</calcite-table-cell>
<calcite-table-cell>cell</calcite-table-cell>
</calcite-table-row>
<calcite-table-row>
<calcite-table-cell>cell</calcite-table-cell>
<calcite-table-cell>cell</calcite-table-cell>
</calcite-table-row>
<calcite-table-row>
<calcite-table-cell>cell</calcite-table-cell>
<calcite-table-cell>cell</calcite-table-cell>
</calcite-table-row>
<calcite-table-row>
<calcite-table-cell>cell</calcite-table-cell>
<calcite-table-cell>cell</calcite-table-cell>
</calcite-table-row>
<calcite-table-row>
<calcite-table-cell>cell</calcite-table-cell>
<calcite-table-cell>cell</calcite-table-cell>
</calcite-table-row>
<calcite-table-row>
<calcite-table-cell>cell</calcite-table-cell>
<calcite-table-cell>cell</calcite-table-cell>
</calcite-table-row>
</calcite-table>`,
);
});

describe("is accessible with pagination and selection mode", () => {
accessible(
html`<calcite-table page-size="4" selection-mode="multiple" caption="Simple table">
Expand Down Expand Up @@ -2464,6 +2503,95 @@ describe("keyboard navigation", () => {
),
).toEqual({ "0": CELL_CSS.footerCell, "1": CSS.numberCell });
});

it("navigates correctly when number and selection column present numbered and interaction-mode static - only focusing selection cells", async () => {
const page = await newE2EPage();
await page.setContent(
html`<calcite-table numbered selection-mode="multiple" caption="Simple table" interaction-mode="static">
<calcite-table-row id="row-head" slot=${SLOTS.tableHeader}>
<calcite-table-header id="head-1a" heading="Heading" description="Description"></calcite-table-header>
<calcite-table-header id="head-1b" heading="Heading" description="Description"></calcite-table-header>
</calcite-table-row>
<calcite-table-row id="row-1">
<calcite-table-cell id="cell-1a">cell</calcite-table-cell>
<calcite-table-cell id="cell-2b">cell</calcite-table-cell>
</calcite-table-row>
<calcite-table-row id="row-2">
<calcite-table-cell id="cell-2a">cell</calcite-table-cell>
<calcite-table-cell id="cell-2b">cell</calcite-table-cell>
</calcite-table-row>
<calcite-table-row id="row-3">
<calcite-table-cell id="cell-3a">cell</calcite-table-cell>
<calcite-table-cell id="cell-3b">cell</calcite-table-cell>
</calcite-table-row>
<calcite-table-row slot=${SLOTS.tableFooter} id="row-foot">
<calcite-table-cell id="foot-1a">foot</calcite-table-cell>
<calcite-table-cell id="foot-1b">foot</calcite-table-cell>
</calcite-table-row>
</calcite-table>`,
);

const rowHead = await page.find("#row-head");
const row1 = await page.find("#row-1");
const row2 = await page.find("#row-2");
const row3 = await page.find("#row-3");

await page.keyboard.press("Tab");
await page.waitForChanges();

expect(
await page.$eval(
`#${rowHead.id}`,
(el) => el.shadowRoot?.activeElement.shadowRoot?.querySelector("th").classList,
),
).toEqual({ "0": CSS.selectionCell, "1": CSS.multipleSelectionCell });
macandcheese marked this conversation as resolved.
Show resolved Hide resolved

await page.keyboard.press("ArrowRight");
await page.waitForChanges();

await page.keyboard.press("ArrowLeft");
await page.waitForChanges();
expect(
await page.$eval(
`#${rowHead.id}`,
(el) => el.shadowRoot?.activeElement.shadowRoot?.querySelector("th").classList,
),
).toEqual({ "0": CSS.selectionCell, "1": CSS.multipleSelectionCell });

await page.keyboard.press("ArrowRight");
await page.waitForChanges();

expect(
await page.$eval(
`#${rowHead.id}`,
(el) => el.shadowRoot?.activeElement.shadowRoot?.querySelector("th").classList,
),
).toEqual({ "0": CSS.selectionCell, "1": CSS.multipleSelectionCell });

await page.keyboard.press("Tab");
await page.waitForChanges();
expect(
await page.$eval(`#${row1.id}`, (el) => el.shadowRoot?.activeElement.shadowRoot?.querySelector("td").classList),
).toEqual({ "0": CSS.selectionCell });

await page.keyboard.press("Tab");
await page.waitForChanges();
expect(
await page.$eval(`#${row2.id}`, (el) => el.shadowRoot?.activeElement.shadowRoot?.querySelector("td").classList),
).toEqual({ "0": CSS.selectionCell });

await page.keyboard.press("Tab");
await page.waitForChanges();
expect(
await page.$eval(`#${row3.id}`, (el) => el.shadowRoot?.activeElement.shadowRoot?.querySelector("td").classList),
).toEqual({ "0": CSS.selectionCell });

await page.keyboard.press("ArrowUp");
await page.waitForChanges();
expect(
await page.$eval(`#${row3.id}`, (el) => el.shadowRoot?.activeElement.shadowRoot?.querySelector("td").classList),
).toEqual({ "0": CSS.selectionCell });
});
});

// Borrowed from Dropdown until a generic utility is set up.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export default {
export const simple = (): string =>
html`<calcite-table
page-size="${number("page-size", 0)}"
interaction-mode="${select("interaction-mode", ["interactive", "static"], "interactive")}"
selection-mode="${select("selection-mode", ["none", "single", "multiple"], "none")}"
scale="${select("scale", ["s", "m", "l"], "m")}"
layout="${select("layout", ["auto", "fixed"], "auto")}"
Expand Down
7 changes: 6 additions & 1 deletion packages/calcite-components/src/components/table/table.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ import {
numberStringFormatter,
NumberingSystem,
} from "../../utils/locale";
import { TableLayout, TableRowFocusEvent } from "./interfaces";
import { TableInteractionMode, TableLayout, TableRowFocusEvent } from "./interfaces";
import { CSS, SLOTS } from "./resources";
import { TableMessages } from "./assets/table/t9n";
import { getUserAgentString } from "../../utils/browser";
Expand Down Expand Up @@ -65,6 +65,9 @@ export class Table implements LocalizedComponent, LoadableComponent, T9nComponen
/** When `true`, number values are displayed with a group separator corresponding to the language and country format. */
@Prop({ reflect: true }) groupSeparator = false;

/** When `"interactive"`, allows focus and keyboard navigation of `table-header`s and `table-cell`s. When `"static"`, prevents focus and keyboard navigation of `table-header`s and `table-cell`s when assistive technologies are not active. Selection affordances and slotted content within `table-cell`s remain focusable. */
@Prop({ reflect: true }) interactionMode: TableInteractionMode = "interactive";

/** Specifies the layout of the component. */
@Prop({ reflect: true }) layout: TableLayout = "auto";

Expand Down Expand Up @@ -103,6 +106,7 @@ export class Table implements LocalizedComponent, LoadableComponent, T9nComponen
@Prop({ reflect: true }) striped = false;

@Watch("groupSeparator")
@Watch("interactionMode")
@Watch("numbered")
@Watch("numberingSystem")
@Watch("pageSize")
Expand Down Expand Up @@ -328,6 +332,7 @@ export class Table implements LocalizedComponent, LoadableComponent, T9nComponen
});

allRows?.forEach((row) => {
row.interactionMode = this.interactionMode;
row.selectionMode = this.selectionMode;
row.bodyRowCount = bodyRows?.length;
row.positionAll = allRows?.indexOf(row);
Expand Down
Loading
Loading