Skip to content

Commit

Permalink
fix: make tabs component more accessible, export button staticclasses (
Browse files Browse the repository at this point in the history
…#439)

* pass through `id` to the tab; `aria-selected` always has a value

* Update Tabs.vue

* export StaticClasses from Button

* add `aria-controls` to tab

* add aria-selecteed="false" test

* add tabpanel to tests

* add changelog

* Update App.tsx

* add tab panels to tests

* Update App.tsx

* Update FrameworkSwitch.vue

* add comment explaining required prop `aria-controls`

* Update React readme and add missing `role=tabpanel`

* do not put mock tabpanels in the framework lol it appears on every component -.-

* Update ReadMe.md
  • Loading branch information
rachelruderman committed May 28, 2024
1 parent 1d83c4a commit 5bddc81
Show file tree
Hide file tree
Showing 15 changed files with 276 additions and 59 deletions.
13 changes: 13 additions & 0 deletions .changeset/tab-ariaControls.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
'@cypress-design/constants-tabs': major
'@cypress-design/react-tabs': major
'@cypress-design/vue-tabs': major
'@cypress-design/react-button': patch
'@cypress-design/vue-button': patch

---

- Tabs component now requires `aria-controls` prop
- Tab `id` is now passed through as an `id` attribute on the tab
- Inactive tabs now have `aria-selected=false`
- Button component now exports StaticClasses
1 change: 1 addition & 0 deletions components/Button/react/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
export { default } from './Button'
export {
VariantClassesTable,
StaticClasses,
SizeClassesTable,
type ButtonVariants,
} from '@cypress-design/constants-button'
1 change: 1 addition & 0 deletions components/Button/vue/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,6 @@ export { default } from './Button.vue'
export {
VariantClassesTable,
SizeClassesTable,
StaticClasses,
type ButtonVariants,
} from '@cypress-design/constants-button'
16 changes: 8 additions & 8 deletions components/Tabs/ReadMe.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,17 @@ import Tabs from './vue/Tabs.vue'
import { IconActionPlayVideo, IconActionRecord, IconGeneralCrosshairs, IconSecurityLockLocked } from '@cypress-design/vue-icon'

const demoTabsSmall = [
{ id: 'ov', label: 'Overview', icon: IconActionPlayVideo, active: true },
{ id: 'cl', label: 'Command Log', icon: IconActionRecord },
{ id: 'err', label: 'Errors', iconAfter: IconSecurityLockLocked, tag: '13' },
{ id: 'reco', label: 'Recommendations', icon: IconGeneralCrosshairs },
{ id: 'ov', label: 'Overview', icon: IconActionPlayVideo, active: true, ['aria-controls']: 'tabpanel-id-1' },
{ id: 'cl', label: 'Command Log', icon: IconActionRecord, ['aria-controls']: 'tabpanel-id-2' },
{ id: 'err', label: 'Errors', iconAfter: IconSecurityLockLocked, tag: '13', ['aria-controls']: 'tabpanel-id-3' },
{ id: 'reco', label: 'Recommendations', icon: IconGeneralCrosshairs, ['aria-controls']: 'tabpanel-id-4' },
]

const demoTabsLarge = [
{ id: 'ov', label: 'Overview', active: true },
{ id: 'cl', label: 'Command Log' },
{ id: 'err', label: 'Errors', tag: '13' },
{ id: 'reco', label: 'Recommendations' },
{ id: 'ov', label: 'Overview', active: true, ['aria-controls']: 'tabpanel-id-1' },
{ id: 'cl', label: 'Command Log', ['aria-controls']: 'tabpanel-id-2' },
{ id: 'err', label: 'Errors', tag: '13', ['aria-controls']: 'tabpanel-id-3' },
{ id: 'reco', label: 'Recommendations', ['aria-controls']: 'tabpanel-id-4' },
]

const types = ['default', 'dark-small', 'dark-large', 'underline-small', 'underline-center', 'underline-large']
Expand Down
16 changes: 12 additions & 4 deletions components/Tabs/assertions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,10 @@
import { variants, Tab } from './constants'

const tabs = [
{ id: 'ov', label: 'Overview' },
{ id: 'cl', label: 'Command Log' },
{ id: 'err', label: 'Errors' },
{ id: 'reco', label: 'Recommendations' },
{ id: 'ov', label: 'Overview', ['aria-controls']: 'tabpanel-id-1' },
{ id: 'cl', label: 'Command Log', ['aria-controls']: 'tabpanel-id-2' },
{ id: 'err', label: 'Errors', ['aria-controls']: 'tabpanel-id-3' },
{ id: 'reco', label: 'Recommendations', ['aria-controls']: 'tabpanel-id-4' },
]

export default function assertions(
Expand All @@ -20,12 +20,20 @@ export default function assertions(
describe('Tabs', { viewportHeight: 80 }, () => {
it('renders', () => {
mountStory({ tabs, activeId: 'ov' })
tabs.forEach((tab, i) => {
cy.get(`#${tab.id}`).should(
'have.attr',
'aria-controls',
`tabpanel-id-${i + 1}`,
)
})
})

it('moves to tab on click', () => {
mountStory({ tabs, activeId: 'ov' })
cy.contains('Errors').click()
cy.get('[aria-selected="true"]').should('contain.text', 'Errors')
cy.get('[aria-selected="false"]').should('have.length', 3)
})

it('moves to tab on arrow press', () => {
Expand Down
5 changes: 5 additions & 0 deletions components/Tabs/constants/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,11 @@ export interface Tab {
*/
href?: string
[key: `data-${string}`]: any
/**
* aria-controls attribute is required for accessibility. It should be set to the id of the tab panel that this tab controls
* Further reading: https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Roles/tab_role
*/
['aria-controls']: string
}

export class SwitchEvent {
Expand Down
87 changes: 70 additions & 17 deletions components/Tabs/react/ReadMe.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,17 +21,45 @@ import Tabs from '@cypress-design/react-tabs'
```tsx live
import { IconActionPlayVideo } from '@cypress-design/react-icon'

export default () => (
<Tabs
activeId="ov"
tabs={[
{ id: 'ov', label: 'Overview' },
{ id: 'cl', label: 'Command Log', icon: IconActionPlayVideo },
{ id: 'err', label: 'Errors', href: 'https://www.cypress.io' },
{ id: 'reco', label: 'Recommendations' },
]}
/>
)
export default () => {
const activeId = 'ov'
const tabs = [
{ id: 'ov', label: 'Overview', ['aria-controls']: 'tabpanel-id-1' },
{
id: 'cl',
label: 'Command Log',
icon: IconActionPlayVideo,
['aria-controls']: 'tabpanel-id-2',
},
{
id: 'err',
label: 'Errors',
href: 'https://www.cypress.io',
['aria-controls']: 'tabpanel-id-3',
},
{
id: 'reco',
label: 'Recommendations',
['aria-controls']: 'tabpanel-id-4',
},
]

return (
<div>
<Tabs activeId={activeId} tabs={tabs} />
{tabs.map(({ id, ...rest }, i) => (
<div
key={i}
role="tabpanel"
id={rest['aria-controls']}
style={{ display: activeId === tabId ? 'block' : 'none' }}
>
Tab Panel {i + 1}
</div>
))}
</div>
)
}
```

```tsx live
Expand All @@ -40,6 +68,26 @@ import { IconActionPlayVideo } from '@cypress-design/react-icon'

export default () => {
const [allowMove, setAllowMove] = useState(true)
const tabs = [
{ id: 'ov', label: 'Overview', ['aria-controls']: 'tabpanel-id-1' },
{
id: 'cl',
label: 'Command Log',
icon: IconActionPlayVideo,
['aria-controls']: 'tabpanel-id-2',
},
{
id: 'err',
label: 'Errors',
href: 'https://www.cypress.io',
['aria-controls']: 'tabpanel-id-3',
},
{
id: 'reco',
label: 'Recommendations',
['aria-controls']: 'tabpanel-id-4',
},
]
return (
<>
<fieldset>
Expand All @@ -54,16 +102,21 @@ export default () => {
</fieldset>
<Tabs
activeId="ov"
tabs={[
{ id: 'ov', label: 'Overview' },
{ id: 'cl', label: 'Command Log', icon: IconActionPlayVideo },
{ id: 'err', label: 'Errors', href: 'https://www.cypress.io' },
{ id: 'reco', label: 'Recommendations' },
]}
tabs={tabs}
onSwitch={(_, e) => {
if (!allowMove) e.preventDefault()
}}
/>
{tabs.map(({ id, ...rest }, i) => (
<div
key={i}
role="tabpanel"
id={rest['aria-controls']}
style={{ display: activeId === tabId ? 'block' : 'none' }}
>
Tab Panel {i + 1}
</div>
))}
</>
)
}
Expand Down
12 changes: 7 additions & 5 deletions components/Tabs/react/Tabs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -126,11 +126,12 @@ export const Tabs: React.FC<TabsProps & React.HTMLProps<HTMLDivElement>> = ({
iconAfter: IconAfter,
label,
tag,
...dataAttr
...rest
} = tab
return (
<ButtonTag
key={id}
id={id}
role="tab"
href={href}
className={clsx([
Expand All @@ -141,10 +142,11 @@ export const Tabs: React.FC<TabsProps & React.HTMLProps<HTMLDivElement>> = ({
[classes.inActive]: id !== activeId,
},
])}
// @ts-expect-error React is incapable of typing this kind of ref so we do not add a type
ref={(el) => (el ? ($tab.current[index] = el) : null)}
ref={(el: HTMLButtonElement | HTMLAnchorElement | null) =>
el ? ($tab.current[index] = el) : null
}
tabIndex={id === activeId ? undefined : -1}
aria-selected={id === activeId ? true : undefined}
aria-selected={id === activeId ? true : false}
onClick={(e) => {
if (e.ctrlKey || e.metaKey) return
e.preventDefault()
Expand All @@ -162,7 +164,7 @@ export const Tabs: React.FC<TabsProps & React.HTMLProps<HTMLDivElement>> = ({
navigate(-1)
}
}}
{...dataAttr}
{...rest}
>
{renderTab ? (
renderTab(tab)
Expand Down
56 changes: 49 additions & 7 deletions components/Tabs/react/TabsReact.cy.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,16 @@ describe('Tabs', () => {
mount(
<div className="m-4">
<Tabs {...options} />
{options.tabs.map((tab, i) => (
<div
key={i}
role="tabpanel"
id={`tabpanel-id-${i + 1}`}
style={{ display: options.activeId === tab.id ? 'block' : 'none' }}
>
Tab Panel {i + 1}
</div>
))}
</div>,
)
}
Expand All @@ -32,16 +42,37 @@ describe('Tabs', () => {
<div className="m-4">
<Tabs
tabs={[
{ id: 'ia', label: 'Initial Active' },
{ id: 'fa', label: 'Final Active' },
{
id: 'ia',
label: 'Initial Active',
['aria-controls']: 'tabpanel-id-1',
},
{
id: 'fa',
label: 'Final Active',
['aria-controls']: 'tabpanel-id-2',
},
]}
activeId={activeId}
/>
<div>
<div
role="tabpanel"
id="tabpanel-id-1"
style={{ display: activeId === 'ia' ? 'block' : 'none' }}
>
<button id="change" onClick={() => setActiveId('fa')}>
Change
</button>
</div>
<div
role="tabpanel"
id="tabpanel-id-2"
style={{ display: activeId === 'fa' ? 'block' : 'none' }}
>
<button id="change" onClick={() => setActiveId('ia')}>
Change
</button>
</div>
</div>
)
}
Expand All @@ -55,10 +86,21 @@ describe('Tabs', () => {

it('renders a custom tab', () => {
mount(
<Tabs
tabs={[{ id: 'ia', label: 'Initial Active' }]}
renderTab={(tab) => <div>{tab.label} - Custom Tab</div>}
/>,
<div>
<Tabs
tabs={[
{
id: 'ia',
label: 'Initial Active',
['aria-controls']: 'tabpanel-id-1',
},
]}
renderTab={(tab) => <div>{tab.label} - Custom Tab</div>}
/>
<div role="tabpanel" id="tabpanel-id-1">
Tab Panel 1
</div>
</div>,
)

cy.contains('Custom Tab').should('exist')
Expand Down
48 changes: 40 additions & 8 deletions components/Tabs/vue/ReadMe.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,24 @@ import Tabs from '@cypress-design/vue-tabs'
```vue live
<Tabs
:tabs="[
{ id: 'ov', label: 'Overview', active: true },
{ id: 'cl', label: 'Command Log' },
{ id: 'err', label: 'Errors', href: 'https://www.cypress.io' },
{ id: 'reco', label: 'Recommendations' },
{
id: 'ov',
label: 'Overview',
active: true,
['aria-controls']: 'tabpanel-id-1',
},
{ id: 'cl', label: 'Command Log', ['aria-controls']: 'tabpanel-id-2' },
{
id: 'err',
label: 'Errors',
href: 'https://www.cypress.io',
['aria-controls']: 'tabpanel-id-3',
},
{
id: 'reco',
label: 'Recommendations',
['aria-controls']: 'tabpanel-id-4',
},
]"
/>
```
Expand All @@ -34,10 +48,28 @@ If one blocks switching
```vue live
<Tabs
:tabs="[
{ id: 'ov', label: 'Overview', active: true },
{ id: 'cl', label: 'Command Log' },
{ id: 'err', label: 'Errors', href: 'https://www.cypress.io' },
{ id: 'reco', label: 'Recommendations' },
{
id: 'ov',
label: 'Overview',
active: true,
['aria-controls']: 'tabpanel-id-1',
},
{
id: 'cl',
label: 'Command Log',
['aria-controls']: 'tabpanel-id-2',
},
{
id: 'err',
label: 'Errors',
href: 'https://www.cypress.io',
['aria-controls']: 'tabpanel-id-3',
},
{
id: 'reco',
label: 'Recommendations',
['aria-controls']: 'tabpanel-id-4',
},
]"
@switch="(_, e) => e.preventDefault()"
/>
Expand Down
Loading

0 comments on commit 5bddc81

Please sign in to comment.