Conversation
LFDanLu
left a comment
There was a problem hiding this comment.
Thanks for all your hard work on the RFC! The team was able to take a look at it briefly today (and will hopefully carve out some more time to read through it again and discuss more deeply) and the preliminary feedback/gut feelings is as follows:
- Patterns like Tabs and GridList/ListBox are featured heavily, perhaps we can extend those hooks instead of making a separate carousel package?
- However, from my reading of this RFC, there are considerations to be made when binding the various extra controls to the carousel's elements as well as considerations for announcements for when elements are scrolled into/out of view for presentational carousels
- Have you been able to or tried to build this Carousel yourself already with the existing hooks/components (Tabs/GridList/CardView/etc)? I think from previous conversations in related issue point to yes (especially since you've highlighted difficulties with getting infinite looping to work), but its a bit hard to tell across the disparate conversations haha. I think it would be helpful if you could add some of the pain points/related discussions as supplements for why some of the additional hooks/delegates/etc should be created if possible, if only to make it less theoretical sounding if that makes sense.
- If we were to only support "Tabbed" for now, what could be dropped for a first pass? Adding this incrementally would be ideal and easier to digest for the team
|
|
||
| ## Features | ||
|
|
||
| A carousel can be built using `<div role="tab">` and `<div role="tabpanel">` HTML elements, but this only supports one perceivable element at a time. Carousel helps you build accessible multi-view rotation components that can be styled as needed. |
There was a problem hiding this comment.
something that isn't quite clear to me is whether or not a "presentational" carousel is truly a carousel or just a horizontally scrolling cardview/listbox/gridlist. I suppose this is where the screenreader announcements and support for the various carousel controls may play a role in differentiating them, but I'll need to check the aria pattern.
There was a problem hiding this comment.
Hopefully answered by the other comments, but TLDR; A presentational carousel is basically just a useSelectableCollection which has given up control over its scroll offset to ScrollManager.
ARIA semantics of the controlled collection are largely left untouched, depending on how we decide to model the relationship between picker dot and controlled item/scroll target. As you already suspected, the essence of a carousel lies in the ARIA semantics of controls and their announcement of scroll/visibility state.
| - **Single-view transitions** — only one slide is visually emphasized at a time. This may be implemented with fading, crossfading, direct replacement, or even a scroll-based presentation where multiple slides are force-mounted, but only the currently selected slide is highlighted. | ||
|
|
||
| - **Multi-view transitions** — multiple slides are perceivable at once, typically arranged in a scroll container where adjacent slides remain partially or fully visible. |
There was a problem hiding this comment.
similar to as mentioned above, these experiences feel like behaviors covered in part by existing components (tabs/listbox/gridlist/etc) so I wonder if they can be extensions of those components somehow.
A RAC level Carousel component seems reasonable, but maybe we could extend the existing hooks for those patterns rather than create new carousel hooks?
There was a problem hiding this comment.
Just to be sure, are you referencing carousel hooks or rotator hooks? The following TLDR; will hopefully make it easier to reason about their distinct scopes:
-
@react-aria/rotator-> A more advancedScrollView, with built-inscrollIntoView& a playback delegate to answer "which scroll target comes next when i press the next button?" -
@react-aria/carousel-> W3C spec, event wiring for playback controls & a live announcer for the active target.
Now I don’t think we would want to put an entirely different W3C spec inside of another package, so I figure you are referring to the rotation part? In any scenario,the existing listbox and gridlist hooks continue to be run next to the ones we add here, and we do rely on them. Hopefully I can make this clearer by providing the hook representation for a virtualized presentational carousel:
<useCarousel & useListState & useScrollManagerState> // => <Carousel />
<useButton slot="previous" />
<useButton slot="next" />
<useSlidePickerList> // => <SlidePickerList />
<useSlidePicker id="1" />
<useSlidePicker id="2" />
<useSlidePicker id="3" />
</>
<useVirtualizerState> // => <Virtualizer />
<useSelectableCollection & useListState & useScrollState> // => <ListBox />
<useSelectableItem & useScrollTarget id="1" />
<useSelectableItem & useScrollTarget id="2" />
<useSelectableItem & useScrollTarget id="3" />
</>
</>
</>
The only reason for why useTab can not also be re-used here for pickers is because it's not really built for virtualization of panels, enforces a specific JSX placement and requires a tab list state instead of a list state.
I could only see this integrate even deeper if the @react-aria/selection package were to internalize the @react-aria/rotator package, replacing the functionality of scrollIntoView. I purposefully decided not to do that, because I felt like the selection package is already complicated enough as is.
For further clarification, please also reference these sections in the RFC (copy & paste the links, click doesnt work for highlight)
There was a problem hiding this comment.
gotcha, I guess would the Carousel provide some/all of the carousel item props via context or would there be a useCarouselItem hook here? My initial thought would be that we could consider adding carousel specific behaviors/aria attributes into the listbox/gridview hooks if those behaviors/aria attributes were "lightweight" per say, but that doesn't seem to be the case
There was a problem hiding this comment.
The W3C Spec for the Carousel Pattern has surprisingly little requirements for carousel items, aka. Slide elements. In fact, the only requirement is for them to carry a valid ARIA role and unique accessible name. Since we can guarantuee listbox/gridview items to already meet this criteria, the only case where useSlide would likely actually be leveraged is in a tabbed carousel w/ SlideShow.
Otherwise, there is useScrollTarget which is coming from the @react-aria/rotator package and is used to report scroll area data into its ScrollDelegate. Similarly to useVirtualizerItem, this hook is invoked for each item via a collection renderer - for which #8523 (comment) is the blocker.
In case there are certain accessibility properties i missed out on during exploration, they could probably be internalized within the listbox/gridview hooks like you mentioned 👍
|
|
||
| ### Scroll containers | ||
|
|
||
| At a high-level, `@react-stately/rotator` will implement a lightweight scroll and snapping observer based on the CSS Overflow Module Level 3 and CSS Scroll Snap Module Level 1 specification. Since scroll destinations are tightly coupled to layout information, the package's architecture will feel similar to the existing `Layout` and `Virtualizer` implementation. More specifically, the RFC proposes the addition of the following new classes: |
There was a problem hiding this comment.
So these below are all mainly to support snap scrolling for a virtualized experience correct?
There was a problem hiding this comment.
Primarily yes, although they also take on a couple other major responsibilities:
- determining when rotation control state is updated (eager or lazy)
- calculating the active scroll target(s) based on layout and collected
ScrollAreadata - determining the playback order of targets when controls are engaged, aka. "which item do we scroll to when hitting next/previous"
- allowing for easy extension/customization, similar to different layouts
The classes are designed to be very computationally light, since they do not stay in sync with layout, but rather just call the layout for the current state when rotation is scheduled or observed. Extension capabilities are especially useful for a component to customize its own rotation behavior, e.g. for when it wants certain items to be skipped w/o the user having to exclude them via #9084 (comment).
For an alternative, I also considered pushing the active target into persisted keys before performing the scroll, but the information of just the target element is often times not enough to reliably scroll to a "stable" position, when snap properties are not uniformly applied. It's better to have items self report scroll & snap properties, similar to how we update their actual size in a virtualizer.
Without these classes, all remaining responsibilities would still have to be managed somehow, which I think we would always do in the state layer thereby requiring some form of abstraction from the DOM. This insight made scroll observation largely appear similar to virtualizer to me, hence why i modeled after it. One determines which items are in view, the other determines which of the items in view are currently active.
|
|
||
| </div> | ||
|
|
||
| With this infrastructure in place, developers can leverage native CSS properties — `scroll-behavior`, `scroll-padding`, `scroll-margin`, `scroll-snap-align` and `scroll-snap-type` — to customize scroll targets and define how their areas shall align and be scrolled into view when selected. Each time the scroll offset is changed, the default algorithm of a `ScrollDelegate` will determine the active target by performing a nearest area search for the current scroll offset. When multiple targets are equally aligned to their target position (e.g. a section and its first/last child), the rotation controls will display **multiple** targets as selected. |
There was a problem hiding this comment.
Just to clarify, this means the scroll delegate will essentially report the section and its first/last child as the current "in view" items in the context of a carosel?
There was a problem hiding this comment.
Yes, although rather "active" than "in view". "Active" meaning the picker controls would show their dots as selected, if present in the picker collection.
The display of multiple active targets can also be approached in different ways. Either we carry a multi select list state for rotation controls or only report the "deepest" collection item as active and then simply render parents as selected if a child is active. I think a multi select list state kind of makes more sense, because ScrollDelegate could theoretically report a different active target for each axis.
|
|
||
| </div> | ||
|
|
||
| To wrap this section up, here is an explanatory diagram to recap the functionality of the `@react-stately/rotator` package. It displays a carousel with a subset of collection keys as possible scroll targets (`slide-1`, `section-2`, `slide-4`) and a custom area on the current target. Once again, note that rotation controls, such as the picker and buttons, may **always** act on available targets, rather than items of the controlled collection. |
There was a problem hiding this comment.
I'm not quite sure I understand the nuance between the available targets versus the items of the controlled collection here, mind expanding on this?
There was a problem hiding this comment.
With the RAC API proposed here, we got 2 collections being hoisted up and synchronized:
- the controlled collection (aka. listbox or slideshow)
- the picker control collection.
The later is being used to filter the controlled, which results in our "available targets" collection (if picker is not present, the entire controlled collection is forwarded).
This "available targets" collection is what constructs the ListState in the outer Carousel, which is passed as the delegate to ScrollManager, resulting in all rotation controls only acting on these targets.
Selecting a subset of items for rotation controls can simply be done by rendering certain ids only in the picker. The next/previous buttons will be disabled as soon as the last/first available target is determined as the active target by the ScrollDelegate. This is super helpful when only wanting to rotate on either sections or items for example, or when wanting to skip certain items in rotation (e.g section headers or disabled items).
It also solves a common problem in multi-view carousels, in which we might have targets that can never be scrolled to (because min/max scroll offset is already reached). Since we wouldn't want these items to appear as available dots, we can simply measure the items, determine which ones can never be reached, and remove them from the available targets collection.
There was a problem hiding this comment.
I see, thanks for the explanation. Having the picker collection filter the listbox/slideshow is interesting and certainly gives the user a great deal of flexibility in how they'd like the slide show controls to work and what slides they'd want to actually expose to the user regardless of what the listbox/slideshow actually contains.
However, in practice would these primarily always be section headers/disabled items like you mentioned? If so, would we need this degree of flexibility or could be have a single collection and have those section header/disabled item nodes contain information within themselves that the picker could then use to create a filtered subcollection? I'm primarily concerned about the possible complexity with synchronizing those 2 collections.
It also solves a common problem in multi-view carousels, in which we might have targets that can never be scrolled to (because min/max scroll offset is already reached)
I'm not 100% sure what this means, does this imply that there might be additional slides beyond the "last" one offered by the picker that the user shouldn't be able to get to?
There was a problem hiding this comment.
However, in practice would these primarily always be section headers/disabled items like you mentioned?
Certain node types could definitely self-opt-out from being included in the picker collection. I'm thinking custom node types, loaders, section headers, and basically anything that can be statically analyzed. Where I'm thinking this could be difficult is with a more dynamic state that doesn't live on the node type, as is the case with disabled.
Additionally, a user may select to show either only items, or only sections, which may make it difficult to filter statically, since both node types are valid candidates for certain use cases.
If so, would we need this degree of flexibility
Furthermore to the use cases above, a user may want to configure his multi-view carousel to scroll "one page" at a time. Having very granular control of picker elements, means we can dynamically measure how many items are in a page and then offer one dot per "visual" page - even when items are of different sizes.
Maybe we can find a way to cut down on the exposed flexibility, while remaining with granular control internally though. I will put some thought into that 👍
does this imply that there might be additional slides beyond the "last" one offered by the picker that the user shouldn't be able to get to?
Imagine a multi-view carousel (e.g. 3 items in visible rect), which is scrolled to 100%. When determining the active target based on start alignment, the active target will always be the item at n-2, while item n-1 and n can never be active. Showing these items as picker dots may only make sense if there is further scroll offset after latest item (e.g. scroll-padding) so that they could become the active.
This flexibility would allow us to dynamically measure and remove these items from the picker collection.
I'm primarily concerned about the possible complexity with synchronizing those 2 collections.
Maybe my wording in the comment above was unfortunate. It is of course only one collection being hoisted up by CollectionBuilder, which is then split into the 2 required collections based on the internal prefix described in the RFC. This has worked pretty straightforward so far in the exploration, but let me know if you or the team got any specific concerns.
| <SlidePicker id="4"><Dot size={8}/></SlidePicker> | ||
| </SlidePickerList> | ||
|
|
||
| <SlideShow shouldForceMount style={{overflow: 'scroll'}}> |
There was a problem hiding this comment.
Interesting, our swippable tabs example has this shouldForceMount applied on each TabPanel individually via a dynamic renderer, but I suppose a API like this enforces the all or nothing distinction you make below.
There was a problem hiding this comment.
Yep, exactly. I figured that a differentiation on an individual basis would not really make sense in a carousel, although its definitely not exclusive.
The API design effectively forces you to use SlideShow for when you want a tabbed carousel, regardless of whether or not you (force)mount into a scroll container. Only when you want a presentational carousel is when you switch to a ListBox or GridList.
|
|
||
| For our final design section, we turn to the most ambitious feature of a native carousel implementation: **Infinite mode**. This mode, commonly seen on streaming platforms and hero banners, is used to continuously showcase new or dynamic content and primarily ships in two layout styles: | ||
|
|
||
| - **Unordered** - An endlessly revolving list of slides, where the user can scroll seamlessly and the collection appears to wrap at its respective boundaries. |
There was a problem hiding this comment.
just to clarify, is this describing infinite scroll + wrapping where hitting the end of your data will loop back to the first slide? If yes, then I'm not quite sure the difference between this and "hierarchical".
There was a problem hiding this comment.
Oh never mind, I think I understand from the diagram below.
- unordered => it always gives the impression of a loop
- hierachical => it initially appears to be like a list (defined start) but then transitions to the infinite loop upon hitting the last bit of data
There was a problem hiding this comment.
Yep :) Although it doesn't have to be the last bit of data. Netflix for example does this in their "Top 10" display, where they want you to explore at least one page before transitioning to unordered infinite.
My ideal way to implement this would be with a sentinel, but that may be challenging to place in JSX.
There was a problem hiding this comment.
@LFDanLu Thank you and the entire team so much for carving out some time, especially when extra low on spare cycles!
I hope my answers cover the reasons as to why these extra delegates are necessary, but feel free to reach out again once you've given it another pass. To address the two remaining questions of how a minimal implementation could look like and how much I implemented of this already:
As you may be able to imagine, writing this detailed of an RFC required me to explore implementation already, so yes - i've given pretty much all of what is proposed here a rough pass already. These explorations have produced the following PR's for the roadblocks I've encountered. I've started to split these out and have polished them, as they should be universally applicable, regardless of this RFC:
Additionally, I've singled out some of the difficulties of implementing this RFC in the "Backwards compatibility" section, of which the DOMLayoutDelegate problem is by far the most difficult, because of the way the dependencies are structured (my hope is this will be rendered easier with the dependency RFC).
In general, what I've figured from my first implementation pass is that this RFC does not really require modifications to the existing codebase and can largely built on top of what is there today, given a few distinct adjustments. What guided the RFC here is rather my take on solving the following requirements:
- Support for multi-view, interactive card carousels
- Support for virtualization and async loading
- Support for infinite looping
- Must have low impact on bundle size
- Must be accessible
Prior to this RFC, we've basically implemented our way through 2 iterations of a carousel, which you've picked up on in some of the things I've shared.
The first one was from scratch using a custom fork of useSelectableCollection, which has prompted the discovery of the event leaks. This solution duplicated a lot of code and would've been hard to maintain. It also only supported the presentational style, while we've had design constraints to support both styles.
The second was based on the tabs pattern, but expanded for infinite looping via fake DOM elements inserted through a custom renderer, which caused focus problems. It also had issues with ARIA semantics, especially in virtualized scenarios, effectively only supporting tabbed style.
The third iteration is now based on virtualizer and layout, which was the groundwork to this RFC. It is written to sit on top of the entire existing collection logic and was designed to be very easy to up/downgrade, while supporting all constraints and use-cases. All ARIA semantics of gridlist and listbox as well as their focus restoration is maintained, thereby not prompting any new accessibility patterns besides for controls.
A migration between cross fading and a scroll container is effectively just:
Carousel.tsx (pseudo-code, tabbed)
let state = useListState();
<button slot="next" onClick={() => state.selectNext()} />Carousel.tsx (pseudo-code, tabbed, scroll container)
let state = useListState();
let scrollManager = useScrollManagerState({ delegate: state.selectionManager });
<button slot="next" onClick={() => scrollManager.scrollNext()} />To answer which parts could be dropped when only supporting tabbed, I need to know whether that shall include support for mounting into a scroll container or not. As long as scroll containers shall be supported, then it doesn't make much of a difference as far as the core implementation goes, because the entire @react-aria/rotator package would have to be implemented either way.
If that‘s not the case, then we can skip the rotator package for a first pass. Of course features like infinite mode could always be dropped also. As part of this RFC, I already scoped out a rough roadmap, which you find at the bottom.
| - **Single-view transitions** — only one slide is visually emphasized at a time. This may be implemented with fading, crossfading, direct replacement, or even a scroll-based presentation where multiple slides are force-mounted, but only the currently selected slide is highlighted. | ||
|
|
||
| - **Multi-view transitions** — multiple slides are perceivable at once, typically arranged in a scroll container where adjacent slides remain partially or fully visible. |
There was a problem hiding this comment.
Just to be sure, are you referencing carousel hooks or rotator hooks? The following TLDR; will hopefully make it easier to reason about their distinct scopes:
-
@react-aria/rotator-> A more advancedScrollView, with built-inscrollIntoView& a playback delegate to answer "which scroll target comes next when i press the next button?" -
@react-aria/carousel-> W3C spec, event wiring for playback controls & a live announcer for the active target.
Now I don’t think we would want to put an entirely different W3C spec inside of another package, so I figure you are referring to the rotation part? In any scenario,the existing listbox and gridlist hooks continue to be run next to the ones we add here, and we do rely on them. Hopefully I can make this clearer by providing the hook representation for a virtualized presentational carousel:
<useCarousel & useListState & useScrollManagerState> // => <Carousel />
<useButton slot="previous" />
<useButton slot="next" />
<useSlidePickerList> // => <SlidePickerList />
<useSlidePicker id="1" />
<useSlidePicker id="2" />
<useSlidePicker id="3" />
</>
<useVirtualizerState> // => <Virtualizer />
<useSelectableCollection & useListState & useScrollState> // => <ListBox />
<useSelectableItem & useScrollTarget id="1" />
<useSelectableItem & useScrollTarget id="2" />
<useSelectableItem & useScrollTarget id="3" />
</>
</>
</>
The only reason for why useTab can not also be re-used here for pickers is because it's not really built for virtualization of panels, enforces a specific JSX placement and requires a tab list state instead of a list state.
I could only see this integrate even deeper if the @react-aria/selection package were to internalize the @react-aria/rotator package, replacing the functionality of scrollIntoView. I purposefully decided not to do that, because I felt like the selection package is already complicated enough as is.
For further clarification, please also reference these sections in the RFC (copy & paste the links, click doesnt work for highlight)
|
|
||
| ### Scroll containers | ||
|
|
||
| At a high-level, `@react-stately/rotator` will implement a lightweight scroll and snapping observer based on the CSS Overflow Module Level 3 and CSS Scroll Snap Module Level 1 specification. Since scroll destinations are tightly coupled to layout information, the package's architecture will feel similar to the existing `Layout` and `Virtualizer` implementation. More specifically, the RFC proposes the addition of the following new classes: |
There was a problem hiding this comment.
Primarily yes, although they also take on a couple other major responsibilities:
- determining when rotation control state is updated (eager or lazy)
- calculating the active scroll target(s) based on layout and collected
ScrollAreadata - determining the playback order of targets when controls are engaged, aka. "which item do we scroll to when hitting next/previous"
- allowing for easy extension/customization, similar to different layouts
The classes are designed to be very computationally light, since they do not stay in sync with layout, but rather just call the layout for the current state when rotation is scheduled or observed. Extension capabilities are especially useful for a component to customize its own rotation behavior, e.g. for when it wants certain items to be skipped w/o the user having to exclude them via #9084 (comment).
For an alternative, I also considered pushing the active target into persisted keys before performing the scroll, but the information of just the target element is often times not enough to reliably scroll to a "stable" position, when snap properties are not uniformly applied. It's better to have items self report scroll & snap properties, similar to how we update their actual size in a virtualizer.
Without these classes, all remaining responsibilities would still have to be managed somehow, which I think we would always do in the state layer thereby requiring some form of abstraction from the DOM. This insight made scroll observation largely appear similar to virtualizer to me, hence why i modeled after it. One determines which items are in view, the other determines which of the items in view are currently active.
|
|
||
| </div> | ||
|
|
||
| With this infrastructure in place, developers can leverage native CSS properties — `scroll-behavior`, `scroll-padding`, `scroll-margin`, `scroll-snap-align` and `scroll-snap-type` — to customize scroll targets and define how their areas shall align and be scrolled into view when selected. Each time the scroll offset is changed, the default algorithm of a `ScrollDelegate` will determine the active target by performing a nearest area search for the current scroll offset. When multiple targets are equally aligned to their target position (e.g. a section and its first/last child), the rotation controls will display **multiple** targets as selected. |
There was a problem hiding this comment.
Yes, although rather "active" than "in view". "Active" meaning the picker controls would show their dots as selected, if present in the picker collection.
The display of multiple active targets can also be approached in different ways. Either we carry a multi select list state for rotation controls or only report the "deepest" collection item as active and then simply render parents as selected if a child is active. I think a multi select list state kind of makes more sense, because ScrollDelegate could theoretically report a different active target for each axis.
|
|
||
| </div> | ||
|
|
||
| To wrap this section up, here is an explanatory diagram to recap the functionality of the `@react-stately/rotator` package. It displays a carousel with a subset of collection keys as possible scroll targets (`slide-1`, `section-2`, `slide-4`) and a custom area on the current target. Once again, note that rotation controls, such as the picker and buttons, may **always** act on available targets, rather than items of the controlled collection. |
There was a problem hiding this comment.
With the RAC API proposed here, we got 2 collections being hoisted up and synchronized:
- the controlled collection (aka. listbox or slideshow)
- the picker control collection.
The later is being used to filter the controlled, which results in our "available targets" collection (if picker is not present, the entire controlled collection is forwarded).
This "available targets" collection is what constructs the ListState in the outer Carousel, which is passed as the delegate to ScrollManager, resulting in all rotation controls only acting on these targets.
Selecting a subset of items for rotation controls can simply be done by rendering certain ids only in the picker. The next/previous buttons will be disabled as soon as the last/first available target is determined as the active target by the ScrollDelegate. This is super helpful when only wanting to rotate on either sections or items for example, or when wanting to skip certain items in rotation (e.g section headers or disabled items).
It also solves a common problem in multi-view carousels, in which we might have targets that can never be scrolled to (because min/max scroll offset is already reached). Since we wouldn't want these items to appear as available dots, we can simply measure the items, determine which ones can never be reached, and remove them from the available targets collection.
| <SlidePicker id="4"><Dot size={8}/></SlidePicker> | ||
| </SlidePickerList> | ||
|
|
||
| <SlideShow shouldForceMount style={{overflow: 'scroll'}}> |
There was a problem hiding this comment.
Yep, exactly. I figured that a differentiation on an individual basis would not really make sense in a carousel, although its definitely not exclusive.
The API design effectively forces you to use SlideShow for when you want a tabbed carousel, regardless of whether or not you (force)mount into a scroll container. Only when you want a presentational carousel is when you switch to a ListBox or GridList.
|
|
||
| For our final design section, we turn to the most ambitious feature of a native carousel implementation: **Infinite mode**. This mode, commonly seen on streaming platforms and hero banners, is used to continuously showcase new or dynamic content and primarily ships in two layout styles: | ||
|
|
||
| - **Unordered** - An endlessly revolving list of slides, where the user can scroll seamlessly and the collection appears to wrap at its respective boundaries. |
There was a problem hiding this comment.
Yep :) Although it doesn't have to be the last bit of data. Netflix for example does this in their "Top 10" display, where they want you to explore at least one page before transitioning to unordered infinite.
My ideal way to implement this would be with a sentinel, but that may be challenging to place in JSX.
|
|
||
| ## Features | ||
|
|
||
| A carousel can be built using `<div role="tab">` and `<div role="tabpanel">` HTML elements, but this only supports one perceivable element at a time. Carousel helps you build accessible multi-view rotation components that can be styled as needed. |
There was a problem hiding this comment.
Hopefully answered by the other comments, but TLDR; A presentational carousel is basically just a useSelectableCollection which has given up control over its scroll offset to ScrollManager.
ARIA semantics of the controlled collection are largely left untouched, depending on how we decide to model the relationship between picker dot and controlled item/scroll target. As you already suspected, the essence of a carousel lies in the ARIA semantics of controls and their announcement of scroll/visibility state.
|
|
||
| For screen readers and keyboard users, the **tabbed** implementation strategy of a carousel is mostly straightforward, as it is largely covered by the guidelines of the [ARIA Carousel Pattern](https://www.w3.org/WAI/ARIA/apg/patterns/tabs/) and [ARIA Tabs Pattern](https://www.w3.org/WAI/ARIA/apg/patterns/tabs/). It does also not matter whether we present a carousel in a **multi-view** animation model or not, as long as only one slide is active at a time and it's ensured to be persisted in the DOM (e.g. when virtualized). | ||
|
|
||
| Only minor adjustments to this pattern are required to support optional picker elements and prevent duplicate announcements within the slideshow’s live region. Both issues are addressed by loosening the strict labeling relationship between picker and panel, instead labeling pickers with their corresponding panels - an approach already validated by the [APG Tabbed Carousel Example](https://www.w3.org/WAI/ARIA/apg/patterns/carousel/examples/carousel-2-tablist/). |
There was a problem hiding this comment.
@snowystinger Re: #8630 (review)
TLDR; I'm not entirely sure whether or not I will need #8630 for Carousel, since its use is dependent on the following implementation decision. The latest exploration has rendered it obsolete, but that is to be taken with a grain of salt, as implementation hasn't finalized.
I was hoping to discuss this with the team in a draft PR, once and if the RFC is accepted in the first place. My first exploration made use of #8630, although I've since found alternative approaches.
The design challenge for a Carousel is to establish the following ARIA relationship (red). This is to be done not only for aria-controls, which we discussed in #8699, but also for aria-label/aria-labeledby purposes of each picker <-> slide pair in both tabbed and presentational scenarios.
For reference, here is the relevant excerpt from the W3C Carousel pattern spec for Picker elements:
The accessible name of each picker indicates which slide it will display by including the name or number of the slide, e.g., "Slide 3". Slide names are preferable if each slide has a unique name.
So far, I have explored 3 different implementation strategies, of which the last one is the most recent. I will try to note key findings of each strategy down here for later reference:
-
Link each pair directly, which requires us to determine the id of any given node in a collection, e.g.
ListBoxorGridList, without access to its internals. This strategy prompted me to explore a rework of the id generation of collection components, in which I deprecated theidparameter of collection item hooks and used thecollectionIdinstead. This presented a challenge foruseMenuItemanduseSubmenuTrigger, which rely on theidparameter and don't share collection state. -
Instead of linking pairs directly, we point to a
presentationwrapper rendered around each slide by carousels collection renderer. Since this strategy liftsidgeneration out of the controlled collection, we no longer need to rework theidgeneration of items, but instead we may now have to deal with focus issues as JAWS users could try to follow thearia-controlsrelationship. -
In the filtering phase to split our hoisted collection into
slideandpickercollection, we may attach theslidenode as context to thepickernode. This allows us to render both nodes independently, either withSlide <index>as the accessible name of the picker or optionally with a synchronizedtextValue/aria-label, pulled from our context node. This has proven the most straightforward solution so far, only having to deal with possible double announcements.
|
Hi @nwidynski thanks for writing this up and for your patience while we review it! As there is significant complexity here, I want to understand the use cases that led to this design as well as what we're missing in existing components. From my perspective, there are a few different UIs represented here:
The parts we're missing seem to be:
I'm open to adding new components to simplify the API, but it would be nice to reuse as much as possible from our existing components internally. What do you think would be the minimal set of additions to make this work? A lot of the RFC is about new types of collection components, and new internals that feel quite similar to Virtualizer. Do you think that's required or could we build on top of Tabs/ListBox/GridList/Virtualizer with a few additions like I mentioned above? |
|
@devongovett Hey, thanks for taking the time, and don't worry about the wait! Also, excuse this RFC being a bit more implementation-oriented than it probably should be. It was requested as a way to move forward with the bunch of open PRs I have pending, so I felt it needed to be more detailed than high-level. As a result, I think some of these questions have come up with Dan before as well and were already answered, but since all of this information by now is fairly scattered, I’ll attempt to provide a high-level view here so we’re on the same page. First up, to answer your questions about the use case behind this design: our design sheet defines a bi-directionally scroll-/swipe-able single- AND/OR multi-card view with interactive content, virtualization, infinite/repeating layouts, and support for both picker and button controls. If you’re asking for a visual example, I think the product recommendation carousels on https://adidas.com come fairly close, but I will include specific design diagrams for how these variants will primarily play out in our UI - both presentational (img 1) and tabbed (img 2 & 3) variants - including a scrollbar for whenever swipe interactions shall be supported. As you already noted, we can set aside auto-rotation and button controls for the sake of this discussion - although they do introduce more complexity than might first appear (e.g., handling timer interruptions on interactions). That said, these controls are mostly just syntactic sugar for a picker, with some additional ARIA requirements. The general premise is that once random access is solved, sequential access is trivial. When abstracted, a picker compatible with all these UI variants, will always require to maintain an "active item" state that supports mutation via random key access. This state-keeping logic, as well as the associated controls used to mutate that state, are the essence of what this RFC defines as a Carousel. In other words, it is not defined by how items are displayed (single vs. multiple) or by the animation model used to transition between active items (visibility vs. scroll container), if that makes sense. In line with that idea, the W3C Spec for the Carousel pattern largely defines requirements for control elements, not for slides or the slide container. For instance, the sole requirement for a slide is that it provide a valid ARIA role and unique accessible name - something we can already guarantee with every tabpanel as well as listbox & gridlist item. This means, that for the remaining interactivity and accessibility requirements of slides themselves, the Carousel pattern primarily defers to an underlying collection pattern - most commonly the Unfortunately, most of the complexity comes exactly from designing a picker state layer that is able to both observe and control a scroll containers offset. This is what the proposed Supporting a tabs-like UI based on let state = useListState();
<button slot="next" onClick={() => state.selectNext()} />
<div id="slideshow">
// foreach slide in slides
<div id=`slide-{node.key}` style={{display: node.key !== state.selectedKey ? 'none' : undefined}} />
</div>let state = useListState();
let scrollManager = useScrollManagerState({ delegate: state.selectionManager });
<button slot="next" onClick={() => scrollManager.scrollNext()} />
<div id="slideshow" style={{overflow: 'scroll'}}>
// foreach slide in slides
<div id=`slide-{node.key}` inert={node.key !== state.selectedKey} />
</div>After that, the only remaining work is to make controls ARIA-spec compliant, which is what the None of the proposed hooks aim to re-implement any part of the existing pattern(s) - they solely aim to supplement and hand off to them once a desired scroll or visibility state is reached. The majority of these hooks will either go onto control elements, or will be injected onto the underlying collection items via Carousel's collection renderer, in a similar fashion to I hope that answers the bigger part of your questions in terms of how this changeset makes use of what is there today. Before going into what is currently missing, just a quick recap on the UI variants you mentioned: those are mostly correct, though they do miss a couple permutations. The key misconceptions, from what I take away, is that: 1) a picker is supposed to be supported in all of these variants and not only in the first. Now as to the remaining part of your question for what is missing today, apart from the proposed packages in the RFC and what you've already described as missing. Here are the specific requirements, including a small description:
Lastly, to explain how a minimal implementation of this RFC would look like, I will simply quote what I shared with Dan before, if that's fine:
PS: Infinite/Repeating layouts would be shipped through an extended The only real challenge is in how to deal with collection nodes possibly showing up multiple times in the DOM, which I guess we would face regardless of whether or not this is being done through Virtualizer or not. |
|
Thank you for the consolidated reply, it was very helpful summarizing all the bits and pieces from disparate conversations. Just to clarify the following:
If we weren't to include support for mounting into a scroll container (aka supporting the in-place slide replacement model only), the first pass would omit both the Personally, I'd prefer this first pass approach, even though it would greatly reduce the breadth of support (scrolling carousels definitely seem to be the majority of use cases in the wild). My own hang up currently is trying to digest the "whys" that necessitate the bulk of the changes. I feel like I get the complexity arising from needing to support tracking/updating the active item based on scroll position on top of the tabs like control scheme based of your explanation, but I lack the "implementation experience" that came from your own implementation of this component pattern to be fully convinced of the path forward. I feel it will be beneficial to the team and I to have the base carousel w/ ARIA implemented to play around with before drilling down to the complexities with virtualization and scrolling. That being said, again, I'm not quite sure just how much that first pass approach I mentioned would actually help haha. It might yield something too straightforward/too divergent from the implementation with the rotator layer, but hopefully it helps move us along. |
|
@LFDanLu Great to hear it did help!
Yes! Also everything required for auto play, e.g.
As you've already noted, it would exclude a large part of the audience, but if that's what it takes to get this through you guys in increments, then that's what i'll do. As an upside, I think it wont diverge too far from the rotator layer, if I'm thoughtful about how to extract that version from my exploration. There will still remain a couple intricacies worth discussing in that part of the implementation (e.g., the picker filtering we talked about). I suggest we wait until Devon is back from vacation to give his opinion on the matter, before I tackle that though. The majority of my next week is already assigned on another task anyways. In the meantime, I would really appreciate, if we could possibly tackle some of the mentioned PRs, since juggling these constantly throughout my explorations is becoming a bit of a pain. With my latest comment, it should be apparent as to where these come in and what specific issues they address. Even if the rotator package never sees the light of day in the react-aria codebase, these changes would still enable my team to build what this RFC proposes, without maintaining a bunch of patches. Especially the |





View Rendered RFC
Related discussions:
useTab()does not adhere to W3C ARIA APG regardingaria-controls#8699useId#8630✅ Pull Request Checklist:
📝 Test Instructions:
🧢 Your Project: