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

chore: Refactor S2 tabs to fix accessibility issues #7600

Open
wants to merge 19 commits into
base: main
Choose a base branch
from

Conversation

devongovett
Copy link
Member

Collapsed S2 tabs currently has a Picker inside a TabList, which is invalid aria according to axe. This refactors it to swap between RAC tabs and a simple picker + Group, which is similar to what we had in v3.

Probably some stuff still broken here, just a first pass.

@rspbot
Copy link

rspbot commented Jan 11, 2025

@devongovett devongovett mentioned this pull request Jan 12, 2025
5 tasks
Copy link
Member

@snowystinger snowystinger left a comment

Choose a reason for hiding this comment

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

Is this going to be confusing to users, swapping between two completely different components?
Especially if focus is lost, it may be non-obvious that you're back on the tab group because when it collapses, it no longer announces as a tab group. Maybe we can improve it by adding that to the Select's label.

I'm fine with the approach, I assume it aligns with the collapse work you were doing before across all components? you'd said you were able to get rid of the custom renderer there as well.

packages/@react-spectrum/s2/src/Tabs.tsx Outdated Show resolved Hide resolved
packages/@react-spectrum/s2/src/Tabs.tsx Show resolved Hide resolved
packages/@react-spectrum/s2/src/Tabs.tsx Outdated Show resolved Hide resolved
}
prevFocused.current = state?.selectionManager.isFocused;
}, [state?.selectionManager.isFocused, state?.selectionManager.focusedKey, showItems]);
// let prevFocused = useRef<boolean | undefined>(false);
Copy link
Member

Choose a reason for hiding this comment

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

Noticed that both in the previous implementation and this one, swapping between the components will lose focus. I swear I had that addressed at one point, but we should probably include something so that doesn't occur. I suspect it was linked with this section of code originally

Copy link
Member

Choose a reason for hiding this comment

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

It was definitely this code, however, it didn't restore back when the picker expanded back to the tabs, so I've omitted it for now to simplify getting the PR in
We can add that behavior later

@devongovett
Copy link
Member Author

Is this going to be confusing to users, swapping between two completely different components?

That's pretty much what v3 does right? When it's collapsed the tablist is removed from the a11y tree. And it seemed kinda weird to have a tabpanel without a tablist so group seems better?

@snowystinger
Copy link
Member

That's pretty much what v3 does right? When it's collapsed the tablist is removed from the a11y tree. And it seemed kinda weird to have a tabpanel without a tablist so group seems better?

Ok, yep, dang my recollection of v3 was bad on this one. Silly vacation-addled brain.
I think we can improve it with the addition of a label to the Picker saying "Tabs" somewhere in it, but I'm fine for that to come later.

# Conflicts:
#	packages/@react-spectrum/s2/src/Tabs.tsx
@rspbot
Copy link

rspbot commented Jan 14, 2025

}})({density})}>
<Picker
id={id}
aria-label={props['aria-label'] ?? stringFormatter.format('tabs.selectorLabel')}
Copy link
Member

Choose a reason for hiding this comment

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

Re-added a default here since aria-label isn't required for Tabs, but it is required for a picker that has no visible label.

Copy link
Member Author

Choose a reason for hiding this comment

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

hmm should it be required for Tabs too?

Copy link
Member

@snowystinger snowystinger Jan 22, 2025

Choose a reason for hiding this comment

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

That's a possible solution. We haven't required that in the past.
That said, it seems like a nice thing to do for users.

Looks like the aria patterns page has this to say

If the tab list has a visible label, the element with role tablist has aria-labelledby set to a value that refers to the labelling element. Otherwise, the tablist element has a label provided by aria-label.

So that seems to say we should require either aria-labelledby or aria-label

const tabPanel = style({
display: 'flex',
marginTop: 4,
marginX: -4,
Copy link
Member

Choose a reason for hiding this comment

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

i adjusted some styles in here to make it easier to create a panel that scrolls for overflow and compensates for our focus halos automatically

it also automatically lays out in such a way that users can place a child which will stretch to the fill the space because it's now default display: flex

let me know if this is too controversial

another option was to use allowedOverrides for styles and add display/padding to it

@snowystinger snowystinger marked this pull request as ready for review January 14, 2025 04:08
@snowystinger snowystinger changed the title wip: Refactor S2 tabs to fix accessibility issues chore: Refactor S2 tabs to fix accessibility issues Jan 14, 2025
@rspbot
Copy link

rspbot commented Jan 14, 2025

@rspbot
Copy link

rspbot commented Jan 14, 2025

@rspbot
Copy link

rspbot commented Jan 14, 2025

@rspbot
Copy link

rspbot commented Jan 14, 2025

@rspbot
Copy link

rspbot commented Jan 14, 2025

@@ -25,6 +25,7 @@
"table.sortAscending": "Sort Ascending",
"table.sortDescending": "Sort Descending",
"table.resizeColumn": "Resize column",
"tabs.selectorLabel": "Tab selector",
Copy link
Member Author

Choose a reason for hiding this comment

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

Do we need these additional strings? We didn't have these in v3. Is the label provided by the app enough?

Copy link
Member

Choose a reason for hiding this comment

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

linking to discussion about this #7600 (comment)

packages/@react-spectrum/s2/src/Tabs.tsx Show resolved Hide resolved
packages/@react-spectrum/s2/src/Tabs.tsx Outdated Show resolved Hide resolved
packages/@react-spectrum/s2/src/Tabs.tsx Outdated Show resolved Hide resolved
}})({density})}>
<Picker
id={id}
aria-label={props['aria-label'] ?? stringFormatter.format('tabs.selectorLabel')}
Copy link
Member Author

Choose a reason for hiding this comment

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

hmm should it be required for Tabs too?

packages/@react-spectrum/s2/stories/Tabs.stories.tsx Outdated Show resolved Hide resolved
Copy link
Member

@LFDanLu LFDanLu left a comment

Choose a reason for hiding this comment

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

Initial review, will wrap up tmrw. Behavior looks good overall

packages/@react-spectrum/s2/src/Tabs.tsx Outdated Show resolved Hide resolved
# Conflicts:
#	packages/@react-spectrum/s2/package.json
#	yarn.lock
@rspbot
Copy link

rspbot commented Jan 22, 2025

);
export const Example = {
render: (args: any) => (
<Tabs {...args} styles={style({width: 450, height: 256})} aria-label="History of Ancient Rome">
Copy link
Member

Choose a reason for hiding this comment

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

opinion, I changed S2 Tabs to require an aria-label or aria-labelledby following #7600 (comment)

I needed to move it to the Tabs component so that I could propagate it to the TabList OR the TabMenu. It's also important that the element is always in the DOM and the TabList isn't when we're collapsed.

Should I push some form of this change down to RAC Tabs? If so, what? I could remove the label props, but then extending them is weird.

We currently accept both label props on both Tabs and TabList, though putting it on RAC Tabs actually won't do anything for accessibility.

Do I remove them all (label/described/etc) from the S2 TabList?

Copy link
Member

Choose a reason for hiding this comment

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

IMO I think it is fine to have the aria-label/labelledby props at the top level for S2 Tabs even if it differs from RAC Tabs. As for whether or not to change RAC Tabs, I guess that is dependent on whether we want to make re-creating this kind of collapsable behavior easier for end users (though they can implement a context shuttling down the aria-label/labelledby to their TabList/Tabpicker relatively easily I suppose).

We currently accept both label props on both Tabs and TabList, though putting it on RAC Tabs actually won't do anything for accessibility.

Not sure I follow this, looks like you omit those label props from S2 TabList? Unless you are referencing RAC Tabs but RAC Tabs doesn't take the label props in that case.

Copy link
Member

Choose a reason for hiding this comment

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

Referring to RAC Tabs, looks like it takes label props?
Screenshot 2025-01-23 at 6 22 06 am

Copy link
Member

@LFDanLu LFDanLu Jan 22, 2025

Choose a reason for hiding this comment

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

oh hm, well at the moment providing an aria-label to Tabs doesn't apply it anywhere I believe because

{...filterDOMProps(props as any)}
doesn't have labelable, which on a 2nd read is what I guess you were saying with though putting it on RAC Tabs actually won't do anything for accessibility....

Actually looks like a bunch of our components (ComboBox, etc) that have a outer div wrapper have this mismatch, not sure we actually want to allow the container to have those label props...

Copy link
Member

Choose a reason for hiding this comment

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

Ah looks like they propagate those label props to the inner input/etc so might be good to do the same for RAC Tabs

Copy link
Member

Choose a reason for hiding this comment

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

Do we remove the labelable props on the TabList then? that'd technically be breaking

Copy link
Member

@LFDanLu LFDanLu left a comment

Choose a reason for hiding this comment

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

Tested locally on Android, iPad, and desktop. Left some additional comments about some behavioral differences when the tabs are collapsed vs not

isQuiet,
density
})}>
<SelectValue id={valueId} className={valueStyles + ' ' + raw('&> * {display: none;}')}>
Copy link
Member

Choose a reason for hiding this comment

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

Is it possible to attach an aria-label to the Icon only picker defaulting to the Tab's aria-label or the hidden Tab text here? Testing on my phone and it feels a bit awkward only having the overall Tabs aria label announced and then requiring the user to open the dropdown to see what tab is actually selected.

Copy link
Member

Choose a reason for hiding this comment

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

same thing happens to the TabPanel as well in the Icons story, if "labelBehavior" is "hide" and the Tabs have been collapsed then the TabPanel only announces the aria-label passed to Tabs and loses the additional labeling information tied to the selected tab.

Copy link
Member

Choose a reason for hiding this comment

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

ok, I think I've gotten it about as good as I can, there's a slight double announcement on TabPanel, but at least everything is announced

@rspbot
Copy link

rspbot commented Jan 23, 2025

@rspbot
Copy link

rspbot commented Jan 23, 2025

@rspbot
Copy link

rspbot commented Jan 23, 2025

## API Changes

@react-spectrum/s2

/@react-spectrum/s2:Heading

 Heading {
   UNSAFE_className?: string
   UNSAFE_style?: CSSProperties
   children?: ReactNode
+  id?: string
   isHidden?: boolean
   level?: number
   slot?: string | null
   styles?: StyleString

/@react-spectrum/s2:Header

 Header {
   UNSAFE_className?: string
   UNSAFE_style?: CSSProperties
   children?: ReactNode
+  id?: string
   isHidden?: boolean
   slot?: string | null
   styles?: StyleString
 }

/@react-spectrum/s2:Content

 Content {
   UNSAFE_className?: string
   UNSAFE_style?: CSSProperties
   children?: ReactNode
+  id?: string
   isHidden?: boolean
   slot?: string | null
   styles?: StyleString
 }

/@react-spectrum/s2:Footer

 Footer {
   UNSAFE_className?: string
   UNSAFE_style?: CSSProperties
   children?: ReactNode
+  id?: string
   isHidden?: boolean
   slot?: string | null
   styles?: StyleString
 }

/@react-spectrum/s2:Text

 Text {
   UNSAFE_className?: string
   UNSAFE_style?: CSSProperties
   children?: ReactNode
+  id?: string
   isHidden?: boolean
   slot?: string | null
   styles?: StyleString
 }

/@react-spectrum/s2:Keyboard

 Keyboard {
   UNSAFE_className?: string
   UNSAFE_style?: CSSProperties
   children?: ReactNode
+  id?: string
   isHidden?: boolean
   slot?: string | null
   styles?: StyleString
 }

/@react-spectrum/s2:Tabs

 Tabs {
   UNSAFE_className?: string
   UNSAFE_style?: CSSProperties
   aria-describedby?: string
   aria-details?: string
   aria-label?: string
   aria-labelledby?: string
   children?: ReactNode
   defaultSelectedKey?: Key
   density?: 'compact' | 'regular' = 'regular'
   disabledKeys?: Iterable<Key>
   id?: string
   isDisabled?: boolean
   keyboardActivation?: 'automatic' | 'manual' = 'automatic'
+  labelBehavior?: 'show' | 'hide' = 'show'
   onSelectionChange?: (Key) => void
   orientation?: Orientation = 'horizontal'
   selectedKey?: Key | null
   slot?: string | null
 }

/@react-spectrum/s2:TabList

 TabList <T extends {}> {
   UNSAFE_className?: string
   UNSAFE_style?: CSSProperties
   aria-describedby?: string
   aria-details?: string
-  aria-label?: string
-  aria-labelledby?: string
-  children?: ReactNode
+  children?: ReactNode | (T) => ReactNode
   dependencies?: Array<any>
   items?: Iterable<T>
   styles?: StylesProp
 }

/@react-spectrum/s2:Tab

 Tab {
   UNSAFE_className?: string
   UNSAFE_style?: CSSProperties
   aria-describedby?: string
   aria-details?: string
   aria-label?: string
   aria-labelledby?: string
-  children?: ReactNode
+  children: ReactNode
   download?: boolean | string
   href?: Href
   hrefLang?: string
   id?: Key
   onHoverChange?: (boolean) => void
   onHoverEnd?: (HoverEvent) => void
   onHoverStart?: (HoverEvent) => void
   ping?: string
   referrerPolicy?: HTMLAttributeReferrerPolicy
   rel?: string
   routerOptions?: RouterOptions
   styles?: StylesProp
   target?: HTMLAttributeAnchorTarget
 }

/@react-spectrum/s2:TabsProps

 TabsProps {
   UNSAFE_className?: string
   UNSAFE_style?: CSSProperties
   aria-describedby?: string
   aria-details?: string
   aria-label?: string
   aria-labelledby?: string
   children?: ReactNode
   defaultSelectedKey?: Key
   density?: 'compact' | 'regular' = 'regular'
   disabledKeys?: Iterable<Key>
   id?: string
   isDisabled?: boolean
   keyboardActivation?: 'automatic' | 'manual' = 'automatic'
+  labelBehavior?: 'show' | 'hide' = 'show'
   onSelectionChange?: (Key) => void
   orientation?: Orientation = 'horizontal'
   selectedKey?: Key | null
   slot?: string | null
 }

/@react-spectrum/s2:TabProps

 TabProps {
   UNSAFE_className?: string
   UNSAFE_style?: CSSProperties
   aria-describedby?: string
   aria-details?: string
   aria-label?: string
   aria-labelledby?: string
-  children?: ReactNode
+  children: ReactNode
   download?: boolean | string
   href?: Href
   hrefLang?: string
   id?: Key
   onHoverChange?: (boolean) => void
   onHoverEnd?: (HoverEvent) => void
   onHoverStart?: (HoverEvent) => void
   ping?: string
   referrerPolicy?: HTMLAttributeReferrerPolicy
   rel?: string
   routerOptions?: RouterOptions
   styles?: StylesProp
   target?: HTMLAttributeAnchorTarget
 }

/@react-spectrum/s2:TabListProps

 TabListProps <T> {
   UNSAFE_className?: string
   UNSAFE_style?: CSSProperties
   aria-describedby?: string
   aria-details?: string
-  aria-label?: string
-  aria-labelledby?: string
-  children?: ReactNode
+  children?: ReactNode | (T) => ReactNode
   dependencies?: Array<any>
   items?: Iterable<T>
   styles?: StylesProp
 }

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants