diff --git a/.changeset/large-ducks-end.md b/.changeset/large-ducks-end.md new file mode 100644 index 00000000000..ce81a7b38a9 --- /dev/null +++ b/.changeset/large-ducks-end.md @@ -0,0 +1,11 @@ +--- +"@kaizen/components": minor +--- + +Accessibility uplift of Table component + +- Add default focus ring widths and colors to interactive variants of the TableHeaderRowCell and interactive table cards +- Add a checkboxLabel prop as a mean to resolving the unlinked checkbox label in the checkable variant of the TableHeaderRowCell +- Update the TableHeaderRowCell to pass labelText in the an aria-label for the icon variant +- Update the documentation with some clearer guidance on the APIs and sub components +- Add guidance on usage of tooltip on non-interactive headers diff --git a/packages/components/src/Table/Table.module.scss b/packages/components/src/Table/Table.module.scss index 19272ef1306..3c5f729961e 100644 --- a/packages/components/src/Table/Table.module.scss +++ b/packages/components/src/Table/Table.module.scss @@ -20,10 +20,6 @@ $row-height-data-variant: 48px; &:focus { text-decoration: none; } - - &.headerRowCellButtonReversed { - color: $color-white; - } } // Special Table-only button reset @@ -35,6 +31,7 @@ $row-height-data-variant: 48px; margin: 0; padding: 0; transition: none; // override Murmur global styles :( + outline: none; } .container { @@ -80,19 +77,55 @@ $row-height-data-variant: 48px; .headerRowCell .headerRowCellTooltip { display: flex; align-items: stretch; + max-width: 100%; } -// overflow has to be set at this level as well as on the heading for some reason ¯\_(ツ)_/¯ -.headerRowCell.headerRowCellNoWrap .headerRowCellTooltip { - overflow: hidden; +.headerRowCell.headerRowCellNoWrap .headerRowCellContent { + max-width: 100%; } .headerRowCellButton { @include button-reset; @include anchor-reset; + + display: flex; + align-items: stretch; + width: 100%; + // Ensures that the 100% doesn't go outside of the `headerRowCell` width + box-sizing: border-box; + + &:focus-visible { + outline: none; + position: relative; + + &::after { + // This offset provide enough gap on reverse for contrast ratios + $focus-ring-offset: calc((#{$border-focus-ring-border-width} * 2) + 1px); + + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + width: calc(100% + #{$focus-ring-offset}); + height: calc(100% + #{$focus-ring-offset}); + content: ""; + position: absolute; + background: transparent; + border-color: $color-blue-500; + border-width: $border-focus-ring-border-width; + border-style: $border-focus-ring-border-style; + border-radius: $border-focus-ring-border-radius; + } + } +} + +.headerRowCellButtonReversed { + color: $color-white; + + &:focus-visible::after { + border-color: $color-blue-100; + } } -.headerRowCellButton, .headerRowCellNoButton { display: flex; align-items: stretch; @@ -120,6 +153,10 @@ $row-height-data-variant: 48px; .headerRowCellActive & { color: $color-purple-800; } + + .headerRowCellButtonReversed & { + color: $color-white; + } } .card { @@ -154,6 +191,29 @@ $row-height-data-variant: 48px; will-change: box-shadow, border-color, margin, padding, width; } + &:focus-visible { + outline: none; + position: relative; + + &::after { + // This offset provide enough gap on on reverse for contrast ratios + $focus-ring-offset: calc(#{$border-focus-ring-border-width} + 2px); + + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + width: calc(100% + #{$focus-ring-offset}); + height: calc(100% + #{$focus-ring-offset}); + content: ""; + position: absolute; + background: transparent; + border-color: $color-blue-500; + border-width: $border-focus-ring-border-width; + border-style: $border-focus-ring-border-style; + border-radius: inherit; + } + } + &.well { margin-top: $spacing-sm; } diff --git a/packages/components/src/Table/Table.tsx b/packages/components/src/Table/Table.tsx index 3ad24e1cc77..8ad5f0752ba 100644 --- a/packages/components/src/Table/Table.tsx +++ b/packages/components/src/Table/Table.tsx @@ -13,6 +13,7 @@ import styles from "./Table.module.scss" export type TableContainerProps = { children?: React.ReactNode + /** @default "compact" */ variant?: "compact" | "default" | "data" } /** @@ -56,6 +57,14 @@ export type TableHeaderRowProps = { const ratioToPercent = (width?: number): string | number | undefined => width != null ? `${width * 100}%` : width +export type TableHeaderRowCellCheckboxProps = { + checkable?: boolean + checkedStatus?: CheckedStatus + /** This will be passed into the aria-label for the checkbox to provide context to the user */ + checkboxLabel?: string + onCheck?: (event: React.ChangeEvent) => any +} + /** * @param width value between 1 and 0, to be calculated as a percentage * @param flex CSS flex shorthand as a string. Be sure to specify the flex grow, @@ -71,9 +80,6 @@ export type TableHeaderRowCellProps = { flex?: string href?: string icon?: ReactElement - checkable?: boolean - checkedStatus?: CheckedStatus - onCheck?: (event: React.ChangeEvent) => any reversed?: boolean /** * Shows an up or down arrow, to show that the column is sorted. @@ -82,13 +88,19 @@ export type TableHeaderRowCellProps = { wrapping?: "nowrap" | "wrap" align?: "start" | "center" | "end" tooltipInfo?: string + /** If set, this will hide the tooltip exclamation icon. Useful in situations where + the table header does not have enough space. This should be done with caution as tooltips + should have a visual indicator to users */ isTooltipIconHidden?: boolean /** * Specify where the tooltip should be rendered. */ tooltipPortalSelector?: string | undefined + /** If set, this will show the arrow in the direction provided + when the header cell is hovered over. */ sortingArrowsOnHover?: "ascending" | "descending" | undefined -} & OverrideClassName> +} & TableHeaderRowCellCheckboxProps & + OverrideClassName> export const TableHeaderRowCell = ({ labelText, @@ -99,6 +111,7 @@ export const TableHeaderRowCell = ({ icon, checkable, checkedStatus, + checkboxLabel, onCheck, reversed, sorting: sortingRaw, @@ -111,13 +124,8 @@ export const TableHeaderRowCell = ({ wrapping = "nowrap", align = "start", tooltipInfo, - // If set, this will hide the tooltip exclamation icon. Useful in situations where - // the table header does not have enough space. However, we should always show a - // tooltip icon as the default based on design system tooltip guidelines. isTooltipIconHidden = false, tooltipPortalSelector, - // If set, this will show the arrow in the direction provided - // when the header cell is hovered over. sortingArrowsOnHover, classNameOverride, // There aren't any other props in the type definition, so I'm unsure why we @@ -143,12 +151,20 @@ export const TableHeaderRowCell = ({
{icon && ( - {cloneElement(icon, { title: labelText, role: "img" })} + {cloneElement(icon, { + title: labelText, + ["aria-label"]: labelText, // title is unreliable so this is a sensible fallback for tables with icons as headers without aria-labels + role: "img", + })} )} {checkable && (
- +
)} {tooltipInfo != null && !isTooltipIconHidden ? ( diff --git a/packages/components/src/Table/_docs/Table.mdx b/packages/components/src/Table/_docs/Table.mdx index 894af20f255..b216f4bc226 100644 --- a/packages/components/src/Table/_docs/Table.mdx +++ b/packages/components/src/Table/_docs/Table.mdx @@ -23,31 +23,61 @@ A table displays rows of data, including all data in a set, making it efficient -## API +## TableContainer API -### Data +### Variant + +Controls the spacing in each cell within the table. Options available are `compact`, `default` and `data`. + +#### Compact -### Compact - +#### Default + -### LinkVariant - +#### Data + + +## TableHeaderRowCell API + +### Sorting + + +### Checkbox + +To ensure there appropriate context for users of assistive technologies, when using a `checkable` `TableHeaderRowCell`, we advise in the `checkboxLabel`. This will be passed to the a checkbox aria-label and be announce to screen reader users when focused. -### CheckboxVariant -### IconVariant - +### Icon -### Expandable - +When using providing `icon` to `TableHeaderRowCell` the `labelText` will be passed to the `aria-label` of the SVG. + + -### HeaderAlignmentAndWrapping +### Align and wrapping -### Tooltip +### Tooltips + +While Tooltip content can be passed to any table header via the `tooltipInfo` prop, it is strong advised to avoid this if the header is not an interactive element as the tooltip content will be unreadable to keyboard users or those that use assistive technologies. + +You can read more about the Tooltip component and accessibility limitation [here](https://cultureamp.design/?path=/docs/components-tooltip--docs#screen-reader-accessibility). + ### Reversed + +## TableCard API + +### Link + + +### Expandable + +The `expandable` prop introduces known accessibility issues with nesting interactive elements as children of a `button` or `anchor` tag. We recommend avoiding this pattern if possible, or creating a tier 3 component that adheres to correct WCAG hierarchy. + + + + diff --git a/packages/components/src/Table/_docs/Table.stickersheet.stories.tsx b/packages/components/src/Table/_docs/Table.stickersheet.stories.tsx new file mode 100644 index 00000000000..5c8acab5750 --- /dev/null +++ b/packages/components/src/Table/_docs/Table.stickersheet.stories.tsx @@ -0,0 +1,703 @@ +import React from "react" +import { action } from "@storybook/addon-actions" +import { Meta, StoryObj } from "@storybook/react" +import { EffectivenessIcon } from "~components/Icon" +import { Text } from "~components/Text" +import { StickerSheetStory } from "~storybook/components/StickerSheet" +import { + TableCard, + TableContainer, + TableHeader, + TableHeaderRowCell, + TableRow, + TableRowCell, +} from "../index" + +export default { + title: "Components/Table", + parameters: { + chromatic: { disable: false }, + docs: { + source: { type: "dynamic" }, + }, + a11y: { + config: { + rules: [ + { + // Fixing this in a rebuild + id: "nested-interactive", + enabled: false, + }, + { + // Fixing this in a rebuild + id: "aria-required-children", + enabled: false, + }, + { + // Fixing this in a rebuild + id: "aria-required-parent", + enabled: false, + }, + ], + }, + }, + }, + decorators: [ + Story => ( +
+ +
+ ), + ], +} satisfies Meta + +const TableTemplate: StoryObj = { + render: ({ isReversed }) => ( + <> + + TableHeaderRowCell with long titles + + + + + + + + + + + + + + Default + + + + + Data 2 + + + + + Data 3 + + + + + + + TableHeaderRowCell onClick + + + + + + + + + + + + + + Default + + + + + Data 2 + + + + + Data 3 + + + + + + + TableHeaderRowCell icons with onClick + + + + + } + /> + } + /> + } + /> + + + + + + + Default + + + + + Data 2 + + + + + Data 3 + + + + + + + TableHeaderRowCell tooltips + + + + + + + + + + + + + + Default + + + + + Data 2 + + + + + Data 3 + + + + + + + TableHeaderRowCell checkable + + + + + + + + + + + + + + Default + + + + + Data 2 + + + + + Data 3 + + + + + + + TableCard onClick + + + + + + + + + + + + + + Default + + + + + Data 2 + + + + + Data 3 + + + + + + + + + Hover + + + + + Data 2 + + + + + Data 3 + + + + + + + + + Focus + + + + + Data 2 + + + + + Data 3 + + + + + + + TableCard popout + + + + + + + + + + + + + + Default + + + + + Data 2 + + + + + Data 3 + + + + + + + + + Default popout + + + + + Data 2 + + + + + Data 3 + + + + + + + + + Hover + + + + + None + + + + + Data 3 + + + + + + + + + Focus + + + + + None + + + + + Data 3 + + + + + + + TableCard well + + + + + + + + + + + + + + Default + + + + + Data 2 + + + + + Data 3 + + + + + + + + + Default well + + + + + Data 2 + + + + + Data 3 + + + + + + + + + Hover + + + + + None + + + + + Data 3 + + + + + + + + + Focus + + + + + Well + + + + + Data 3 + + + + + + + ), + parameters: { + pseudo: { + hover: [ + '[data-sb-pseudo-styles="hover"]', + '[data-sb-pseudo-styles="hover"] button', + '[data-sb-pseudo-styles="hover"] input', + ], + focus: [ + '[data-sb-pseudo-styles="focus"]', + '[data-sb-pseudo-styles="focus-visible"] button', + '[data-sb-pseudo-styles="focus-visible"] input', + ], + focusVisible: [ + '[data-sb-pseudo-styles="focus-visible"]', + '[data-sb-pseudo-styles="focus-visible"] button', + '[data-sb-pseudo-styles="focus-visible"] input', + ], + }, + }, +} + +export const StickerSheetDefault: StoryObj = { + ...TableTemplate, + name: "Sticker Sheet (Default)", +} + +export const StickerSheetRTL: StoryObj = { + ...TableTemplate, + name: "Sticker Sheet (RTL)", + parameters: { ...TableTemplate.parameters, textDirection: "rtl" }, +} + +export const StickerSheetReversed: StoryObj = { + ...TableTemplate, + name: "Sticker Sheet (Reversed)", + parameters: { + ...TableTemplate.parameters, + backgrounds: { default: "Purple 700" }, + }, + args: { + isReversed: true, + }, +} diff --git a/packages/components/src/Table/_docs/Table.stories.tsx b/packages/components/src/Table/_docs/Table.stories.tsx index 09b6f7c0a3a..7756c865bb5 100644 --- a/packages/components/src/Table/_docs/Table.stories.tsx +++ b/packages/components/src/Table/_docs/Table.stories.tsx @@ -1,8 +1,9 @@ import React from "react" +import { action } from "@storybook/addon-actions" import { Meta, StoryObj } from "@storybook/react" -import { IconButton } from "~components/Button" +import { Checkbox } from "~components/Checkbox" import { Divider } from "~components/Divider" -import { ChevronUpIcon, EffectivenessIcon } from "~components/Icon" +import { EffectivenessIcon } from "~components/Icon" import { Text } from "~components/Text" import { TableCard, @@ -21,127 +22,54 @@ import { export type TableStoryProps = { container?: TableContainerProps row?: TableRowProps - rowCell?: TableRowCellProps - headerRowCell: Partial - card: TableCardProps + rowCells: TableRowCellProps[] + headerRowCells: TableHeaderRowCellProps[] + tableCards: TableCardProps[] } -const Table = ({ - container, - row, - rowCell, - headerRowCell, - card, -}: TableStoryProps): JSX.Element => { - const { expanded, ...restCardProps } = card - const { checkable, ...restHeaderRowCellProps } = headerRowCell - - return ( +const TableTemplate: StoryObj = { + render: ({ container, headerRowCells, tableCards, row, rowCells }) => ( - - - - + {headerRowCells.map((headerRowCellProps, index) => ( + + ))} - undefined : undefined} - > - - - - Resource - - - - - Supplementary - - - - - Today - - - - - 100 - - {expanded && ( - - } - /> + {tableCards.map((tableCardProps, index) => ( + + + {rowCells.map(({ children, ...otherProps }, rowCellIndex) => ( + + + {children} + - )} - - - {expanded && ( - <> - - - Overall progress - - - )} - - - - - - Resource - - - - - Supplementary - - - - - Today - - - - - 100 - - - - + ))} + + {tableCardProps.expanded && ( + <> + + + Overall progress + + + )} + + ))} - ) + ), } -const meta = { +export default { + ...TableTemplate, title: "Components/Table", - component: Table, parameters: { chromatic: { disable: false }, + docs: { + source: { type: "dynamic" }, + }, a11y: { config: { rules: [ @@ -160,18 +88,55 @@ const meta = { id: "aria-required-parent", enabled: false, }, - { - // Fixing this in a rebuild - id: "label", - enabled: false, - }, ], }, }, }, args: { - card: { expanded: false }, - headerRowCell: { checkable: false }, + tableCards: [ + { + expanded: false, + }, + { + expanded: false, + }, + ], + headerRowCells: [ + { + labelText: "Resource name", + width: 3 / 12, + }, + { + labelText: "Supplementary information", + width: 3 / 12, + }, + { + labelText: "Date", + width: 3 / 12, + }, + { + labelText: "Price", + width: 3 / 12, + }, + ], + rowCells: [ + { + width: 3 / 12, + children: "Resource", + }, + { + width: 3 / 12, + children: "Supplementary", + }, + { + width: 3 / 12, + children: "Today", + }, + { + width: 3 / 12, + children: "100", + }, + ], }, decorators: [ Story => ( @@ -180,23 +145,52 @@ const meta = {
), ], -} satisfies Meta - -export default meta +} satisfies Meta -type Story = StoryObj +export const Playground: StoryObj = { + ...TableTemplate, + parameters: { + docs: { + source: { type: "dynamic" }, + }, + }, +} -export const Playground: Story = { - render: Table, +export const Sorting: StoryObj = { + ...TableTemplate, parameters: { docs: { source: { type: "dynamic" }, }, }, + args: { + headerRowCells: [ + { + labelText: "Resource name", + sorting: "ascending", + onClick: action("Sort Resource name"), + width: 3 / 12, + }, + { + labelText: "Supplementary information", + sorting: "descending", + onClick: action("Sort Supplementary information"), + width: 3 / 12, + }, + { + labelText: "Date", + width: 3 / 12, + }, + { + labelText: "Price", + width: 3 / 12, + }, + ], + }, } -export const Data: Story = { - render: Table, +export const Data: StoryObj = { + ...TableTemplate, args: { container: { variant: "data" } }, parameters: { docs: { @@ -205,9 +199,34 @@ export const Data: Story = { }, } -export const Reversed: Story = { - render: Table, - args: { headerRowCell: { reversed: true } }, +export const Reversed: StoryObj = { + ...TableTemplate, + args: { + headerRowCells: [ + { + labelText: "Resource name", + sorting: "ascending", + onClick: action("Sort Resource name by ascending"), + width: 3 / 12, + reversed: true, + }, + { + labelText: "Supplementary information", + width: 3 / 12, + reversed: true, + }, + { + labelText: "Date", + width: 3 / 12, + reversed: true, + }, + { + labelText: "Price", + width: 3 / 12, + reversed: true, + }, + ], + }, parameters: { docs: { source: { type: "dynamic" }, @@ -222,8 +241,8 @@ export const Reversed: Story = { ], } -export const Compact: Story = { - render: Table, +export const Compact: StoryObj = { + ...TableTemplate, args: { container: { variant: "compact" } }, parameters: { docs: { @@ -232,9 +251,9 @@ export const Compact: Story = { }, } -export const CheckboxVariant: Story = { - render: Table, - args: { headerRowCell: { checkable: true } }, +export const Default: StoryObj = { + ...TableTemplate, + args: { container: { variant: "default" } }, parameters: { docs: { source: { type: "dynamic" }, @@ -242,9 +261,54 @@ export const CheckboxVariant: Story = { }, } -export const LinkVariant: Story = { - render: Table, - args: { card: { href: "#?foo=bar" } }, +export const CheckboxVariant: StoryObj = { + ...TableTemplate, + args: { + headerRowCells: [ + { + checkable: true, + onCheck: action("onCheck header-1"), + checkboxLabel: "Select all Employees", + labelText: "Employee", + width: 5 / 12, + }, + { + labelText: "Job title", + width: 3 / 12, + }, + { + labelText: "Date", + width: 2 / 12, + }, + { + labelText: "Score", + width: 2 / 12, + }, + ], + rowCells: [ + { + width: 5 / 12, + children: ( + + + Employee name + + ), + }, + { + width: 3 / 12, + children: "Engineer", + }, + { + width: 2 / 12, + children: "Today", + }, + { + width: 2 / 12, + children: "100", + }, + ], + }, parameters: { docs: { source: { type: "dynamic" }, @@ -252,13 +316,40 @@ export const LinkVariant: Story = { }, } -export const IconVariant: Story = { - render: Table, - args: { - headerRowCell: { - icon: , +export const LinkVariant: StoryObj = { + ...TableTemplate, + args: { tableCards: [{ href: "#?foo=bar" }, { href: "#?bar=foo" }] }, + parameters: { + docs: { + source: { type: "dynamic" }, }, }, +} + +export const IconVariant: StoryObj = { + ...TableTemplate, + args: { + headerRowCells: [ + { + icon: , + labelText: "Resource name", + width: 3 / 12, + }, + { + icon: , + labelText: "Supplementary information", + width: 3 / 12, + }, + { + labelText: "Date", + width: 3 / 12, + }, + { + labelText: "Price", + width: 3 / 12, + }, + ], + }, parameters: { docs: { source: { type: "dynamic" }, @@ -266,13 +357,19 @@ export const IconVariant: Story = { }, } -export const Expandable: Story = { - render: Table, +export const Expandable: StoryObj = { + ...TableTemplate, args: { - card: { - expanded: true, - expandedStyle: "popout", - }, + tableCards: [ + { + expanded: true, + expandedStyle: "popout", + onClick: action("Set expanded to false"), + }, + { + expanded: false, + }, + ], }, parameters: { docs: { @@ -281,14 +378,31 @@ export const Expandable: Story = { }, } -export const HeaderAlignmentAndWrapping: Story = { - render: Table, +export const HeaderAlignmentAndWrapping: StoryObj = { + ...TableTemplate, args: { - headerRowCell: { - labelText: "Right header align with wrapping", - wrapping: "wrap", - align: "end", - }, + headerRowCells: [ + { + labelText: "Header align start with wrapping", + wrapping: "wrap", + align: "start", + }, + { + labelText: "Default alignment", + width: 3 / 12, + }, + { + labelText: "Header center", + align: "center", + width: 3 / 12, + }, + { + labelText: "Header align with end with wrapping", + wrapping: "wrap", + align: "end", + width: 3 / 12, + }, + ], }, parameters: { docs: { @@ -297,12 +411,30 @@ export const HeaderAlignmentAndWrapping: Story = { }, } -export const Tooltip: Story = { - render: Table, +export const Tooltip: StoryObj = { + ...TableTemplate, args: { - headerRowCell: { - tooltipInfo: "This is a tooltip", - }, + headerRowCells: [ + { + labelText: "Resource name", + tooltipInfo: "Sort this by ascending", + sorting: "ascending", + onClick: action("Sort Resource name by ascending"), + width: 3 / 12, + }, + { + labelText: "Supplementary information", + width: 3 / 12, + }, + { + labelText: "Date", + width: 3 / 12, + }, + { + labelText: "Price", + width: 3 / 12, + }, + ], }, parameters: { docs: { diff --git a/packages/components/src/TitleBlockZen/_docs/TitleBlockZen.stories.tsx b/packages/components/src/TitleBlockZen/_docs/TitleBlockZen.stories.tsx index 6d166de47c5..8e66927d9be 100644 --- a/packages/components/src/TitleBlockZen/_docs/TitleBlockZen.stories.tsx +++ b/packages/components/src/TitleBlockZen/_docs/TitleBlockZen.stories.tsx @@ -119,16 +119,24 @@ export const StickerSheetDefault: Story = { render: args => ( - +
+ +
- +
+ +
- +
+ +
- +
+ +
),