-
Notifications
You must be signed in to change notification settings - Fork 249
docs(swc): convert 2nd-gen pattern and controller story JSDoc to per-unit MDX #6351
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
Open
caseyisonit
wants to merge
27
commits into
main
Choose a base branch
from
caseyisonit/docs-mdx-patterns
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from 26 commits
Commits
Show all changes
27 commits
Select commit
Hold shift + click to select a range
6a462d9
feat(storybook): add DocsHeader and DocsFooter blocks for per-unit MDX
caseyisonit 90ab5ce
docs(badge): convert story JSDoc to badge.mdx
caseyisonit 9b4040c
docs(avatar): convert story JSDoc to avatar.mdx
caseyisonit 9f7cc1e
docs(color-loupe): convert story JSDoc to color-loupe.mdx
caseyisonit 76556ee
docs(divider): convert story JSDoc to divider.mdx
caseyisonit a2b9381
docs(progress-circle): convert story JSDoc to progress-circle.mdx
caseyisonit 2a864d5
docs(status-light): convert story JSDoc to status-light.mdx
caseyisonit e80c509
docs(typography): convert story JSDoc to typography.mdx
caseyisonit 83b3737
docs(illustrated-message): convert story JSDoc to illustrated-message…
caseyisonit 4cc3c14
docs(button): convert story JSDoc to button.mdx
caseyisonit afdbcf4
docs(tabs): convert story JSDoc to tabs.mdx
caseyisonit 3f49ffc
docs(asset,icon): convert internal story JSDoc to internal MDX
caseyisonit 6271d31
chore(storybook): refresh contributor-docs sidebar registrations
caseyisonit 6366838
docs(system-message): convert story JSDoc to system-message.mdx
caseyisonit 12c49a0
docs(suggestion-item): convert story JSDoc to suggestion-item.mdx
caseyisonit 2dca221
docs(conversation-turn): convert story JSDoc to conversation-turn.mdx
caseyisonit ffb1df2
docs(message-sources): convert story JSDoc to message-sources.mdx
caseyisonit c221437
docs(upload-artifact): convert story JSDoc to upload-artifact.mdx
caseyisonit 6b288a2
docs(user-message): convert story JSDoc to user-message.mdx
caseyisonit 93d6f05
docs(suggestion-group): convert story JSDoc to suggestion-group.mdx
caseyisonit fcb347f
docs(message-feedback): convert story JSDoc to message-feedback.mdx
caseyisonit efa3e9b
docs(response-status): convert story JSDoc to response-status.mdx
caseyisonit 700344a
docs(prompt-field): convert story JSDoc to prompt-field.mdx
caseyisonit 80764da
docs(conversation-thread): convert story JSDoc to conversation-thread…
caseyisonit cb4fda3
docs(focusgroup-navigation-controller): convert story JSDoc to focusg…
caseyisonit d48d771
Merge remote-tracking branch 'origin/main' into caseyisonit/docs-mdx-…
caseyisonit 375d83f
fix(storybook): omit API section heading for controllers in DocsFooter
caseyisonit File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
293 changes: 293 additions & 0 deletions
293
...ntrollers/focusgroup-navigation-controller/focusgroup-navigation-controller.mdx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,293 @@ | ||
| import { Canvas, Meta } from '@storybook/addon-docs/blocks'; | ||
| import { DocsFooter, DocsHeader } from '../../../swc/.storybook/blocks'; | ||
|
|
||
| import * as FocusgroupNavigationControllerStories from './stories/focusgroup-navigation-controller.stories'; | ||
|
|
||
| <Meta of={FocusgroupNavigationControllerStories} /> | ||
|
|
||
| <DocsHeader /> | ||
|
|
||
| ## What it does | ||
|
|
||
| ### Navigation | ||
|
|
||
| - Collapses the tab sequence to **one** tab stop by setting `tabindex="0"` on the active item and `tabindex="-1"` on all others. | ||
| - **Arrow** keys move focus according to `direction`: horizontal (inline axis), vertical (block axis), **both** (all four arrows on one linear order), or **grid** (rows and columns from layout). | ||
| - **Home** / **End** jump to the first or last item (row-major order for `grid`). | ||
| - **Ctrl+Home** / **Ctrl+End** (`grid` only) jump to the first cell of the first row or the last cell of the last row. | ||
| - **Page Up** / **Page Down** move `pageStep` items (linear modes) or rows (`grid`) when `pageStep` is set. | ||
|
|
||
| ### Configuration | ||
|
|
||
| - **`wrap`**: end wraps to start (and vice versa), similar to `wrap` concepts in the `focusgroup` proposal. | ||
| - **`memory`**: Tab returns to the last focused item instead of resetting, similar to the `nomemory` concepts in the `focusgroup` proposal. | ||
| - **`skipDisabled`**: when `true`, elements with `disabled` or `aria-disabled="true"` are excluded from the roving tab stop and arrow navigation. | ||
| - **`pageStep`**: non-zero positive integer enables Page Up / Page Down movement. | ||
|
|
||
| ### Programmatic API | ||
|
|
||
| - **`setActiveItem(element)`**: sets roving `tabindex` to a chosen eligible item (does **not** call `focus()`; call `getActiveItem()?.focus()` afterward). | ||
| - **`focusFirstItemByTextPrefix(prefix)`**: sets roving `tabindex` to the first eligible item whose label starts with `prefix` (case-insensitive). Does **not** call `focus()`. | ||
|
|
||
| ## Basic usage | ||
|
|
||
| 1. Construct the controller in your element's `constructor`, passing `getItems` and `direction`. | ||
| 2. Ensure `getItems` returns live `HTMLElement` references (for example from `this.renderRoot` or slotted content). | ||
| 3. After the first render, if items live in shadow DOM, call **`refresh()`** from `firstUpdated` (or after slotting) so roving tabindex can run once nodes exist. | ||
| 4. Provide appropriate **roles** and **labels** on the host and items (the controller does not set ARIA roles). | ||
|
|
||
| ```typescript | ||
| import { LitElement, html, css } from 'lit'; | ||
| import { customElement } from 'lit/decorators.js'; | ||
| import { FocusgroupNavigationController } from '@spectrum-web-components/core/controllers/focusgroup-navigation-controller.js'; | ||
|
|
||
| @customElement('my-format-toolbar') | ||
| export class MyFormatToolbar extends LitElement { | ||
| static styles = css` | ||
| :host { | ||
| display: flex; | ||
| gap: 4px; | ||
| } | ||
| `; | ||
|
|
||
| private readonly navigation = new FocusgroupNavigationController(this, { | ||
| direction: 'horizontal', | ||
| wrap: true, | ||
| getItems: () => | ||
| Array.from(this.renderRoot.querySelectorAll<HTMLElement>('button')), | ||
| }); | ||
|
|
||
| protected override firstUpdated(): void { | ||
| super.firstUpdated(); | ||
| this.navigation.refresh(); | ||
| } | ||
|
|
||
| protected override render() { | ||
| return html` | ||
| <button type="button">Bold</button> | ||
| <button type="button">Italic</button> | ||
| <button type="button">Underline</button> | ||
| `; | ||
| } | ||
| } | ||
| ``` | ||
|
|
||
| ## Behaviors | ||
|
|
||
| ### Horizontal Toolbar | ||
|
|
||
| Use `direction: 'horizontal'` for inline-axis arrow navigation. **ArrowLeft** and **ArrowRight** move between controls (respecting `dir` for RTL); **Tab** yields one stop for the entire group. | ||
|
|
||
| ```typescript | ||
| this.navigation = new FocusgroupNavigationController(this, { | ||
| direction: 'horizontal', | ||
| wrap: true, | ||
| getItems: () => | ||
| Array.from(this.renderRoot.querySelectorAll<HTMLElement>('button')), | ||
| }); | ||
| ``` | ||
|
|
||
| <Canvas of={FocusgroupNavigationControllerStories.HorizontalToolbar} /> | ||
|
|
||
| ### Both Axes Linear | ||
|
|
||
| Use `direction: 'both'` when controls are laid out in a line (or any single sequence) but you want **ArrowUp** / **ArrowDown** to move focus as well as **ArrowLeft** / **ArrowRight**. Inline arrows follow `dir` like `horizontal`; **ArrowUp** / **ArrowDown** step backward / forward in `getItems()` order. | ||
|
|
||
| ```typescript | ||
| this.navigation = new FocusgroupNavigationController(this, { | ||
| direction: 'both', | ||
| wrap: true, | ||
| getItems: () => | ||
| Array.from(this.renderRoot.querySelectorAll<HTMLElement>('button')), | ||
| }); | ||
| ``` | ||
|
|
||
| <Canvas of={FocusgroupNavigationControllerStories.BothAxesLinear} /> | ||
|
|
||
| ### Vertical Menu | ||
|
|
||
| Use `direction: 'vertical'` for block-axis arrow navigation in menus and lists. **ArrowDown** / **ArrowUp** traverse items; **Page Up** / **Page Down** skip multiple items when `pageStep` is set (in this demo, `pageStep: 2`). | ||
|
|
||
| One control uses `aria-disabled="true"` instead of native `disabled` so it stays focusable while arrow keys move through the list: native `disabled` removes focusability and would block reaching items after it. | ||
|
|
||
| ```typescript | ||
| this.navigation = new FocusgroupNavigationController(this, { | ||
| direction: 'vertical', | ||
| wrap: true, | ||
| pageStep: 2, | ||
| skipDisabled: false, | ||
| getItems: () => | ||
| Array.from(this.renderRoot.querySelectorAll<HTMLElement>('button')), | ||
| }); | ||
| ``` | ||
|
|
||
| <Canvas of={FocusgroupNavigationControllerStories.VerticalMenu} /> | ||
|
|
||
| ### Skip Disabled Menu | ||
|
|
||
| With `skipDisabled: true`, items stay in the DOM (for layout or screen-reader context), but both native **`disabled`** and **`aria-disabled="true"`** items are removed from the roving tab stop and from arrow movement. In this demo, **Save** (`disabled`) and **Close** (`aria-disabled="true"`) are skipped; the arrow sequence is **New → Open → Print → Help**. | ||
|
|
||
| ```typescript | ||
| this.navigation = new FocusgroupNavigationController(this, { | ||
| direction: 'vertical', | ||
| wrap: true, | ||
| skipDisabled: true, | ||
| getItems: () => | ||
| Array.from(this.renderRoot.querySelectorAll<HTMLElement>('button')), | ||
| }); | ||
| ``` | ||
|
|
||
| ```html | ||
| <button type="button">New</button> | ||
| <button type="button">Open</button> | ||
| <button type="button" disabled>Save</button> | ||
| <button type="button">Print</button> | ||
| <button type="button" aria-disabled="true">Close</button> | ||
| <button type="button">Help</button> | ||
| ``` | ||
|
|
||
| <Canvas of={FocusgroupNavigationControllerStories.SkipDisabledMenu} /> | ||
|
|
||
| ### Grid | ||
|
|
||
| Use `direction: 'grid'` when items are laid out in rows (for example CSS Grid). The controller groups items into rows using bounding rectangles, then maps Arrow keys to cell movement. | ||
|
|
||
| - **Home** / **End** use visual row-major order (first and last item in that flattened sequence). | ||
| - **Ctrl+Home** / **Ctrl+End** jump to the first cell of the top row or the last cell of the bottom row, which matches rectangular grids and differs from plain **End** only when the last row has fewer cells than earlier rows. | ||
| - **Page Up** / **Page Down** move `pageStep` rows at a time (in this demo, `pageStep: 2`); the focused column index is clamped when a row has fewer cells (same rule as **ArrowUp** / **ArrowDown**). | ||
|
|
||
| ```typescript | ||
| this.navigation = new FocusgroupNavigationController(this, { | ||
| direction: 'grid', | ||
| wrap: false, | ||
| pageStep: 2, | ||
| getItems: () => | ||
| Array.from(this.renderRoot.querySelectorAll<HTMLElement>('.grid button')), | ||
| }); | ||
| ``` | ||
|
|
||
| <Canvas of={FocusgroupNavigationControllerStories.Grid} /> | ||
|
|
||
| ### Programmatic Focus | ||
|
|
||
| **`setActiveItem(element)`** updates roving `tabindex` to a chosen eligible item only; it does **not** call `focus()`. Returns `false` if the element is not eligible (not in `getItems()` or skipped by `skipDisabled`). Call **`getActiveItem()?.focus()`** afterward to move focus. | ||
|
|
||
| When invoked from a trigger `click`, defer `focus()` with **`queueMicrotask`** so the browser does not move focus back to the clicked element after your handler returns: | ||
|
|
||
| ```typescript | ||
| const el = this.renderRoot.querySelector<HTMLElement>('[data-item="c"]'); | ||
| if (el && this.navigation.setActiveItem(el)) { | ||
| queueMicrotask(() => { | ||
| el.focus(); | ||
| }); | ||
| } | ||
| ``` | ||
|
|
||
| <Canvas of={FocusgroupNavigationControllerStories.ProgrammaticFocus} /> | ||
|
|
||
| ### Text Prefix Focus | ||
|
|
||
| **`focusFirstItemByTextPrefix(prefix)`** updates roving `tabindex` to the first eligible item whose typeahead label starts with `prefix` (case-insensitive), in `getItems()` order. Matching uses each item's typeahead label: trimmed **`aria-label`** if set, otherwise text from **`aria-labelledby`** references (in order), otherwise trimmed **`textContent`**. Only **eligible** items are considered (respects **`skipDisabled`**). The first match in `getItems()` order becomes the roving tab stop; **`focus()` is not called** by the controller. | ||
|
|
||
| Move focus yourself on **`getActiveItem()`**. From a **`click`** handler on another control, defer `focus()` with **`queueMicrotask`** (or similar) so the browser does not move focus back to the clicked element after your handler returns. | ||
|
|
||
| ```typescript | ||
| // Example: after the user types into your menu search buffer `buffer` | ||
| if (this.navigation.focusFirstItemByTextPrefix(buffer)) { | ||
| queueMicrotask(() => { | ||
| this.navigation.getActiveItem()?.focus(); | ||
| }); | ||
| } | ||
| ``` | ||
|
|
||
| <Canvas of={FocusgroupNavigationControllerStories.TextPrefixFocus} /> | ||
|
|
||
| ## Accessibility | ||
|
|
||
| ### Features | ||
|
|
||
| The `FocusgroupNavigationController` implements several accessibility features: | ||
|
|
||
| #### Roving tabindex | ||
|
|
||
| Collapses a composite widget to a single Tab stop by managing `tabindex="0"` on the active item and `tabindex="-1"` on all others. This follows the [APG roving tabindex pattern](https://www.w3.org/WAI/ARIA/apg/practices/keyboard-interface/#managingfocuswithincomponentsusingarovingtabindex). | ||
|
|
||
| #### Keyboard navigation | ||
|
|
||
| - <kbd>ArrowLeft</kbd> / <kbd>ArrowRight</kbd>: move focus in horizontal and | ||
| both modes | ||
| - <kbd>ArrowUp</kbd> / <kbd>ArrowDown</kbd>: move focus in vertical, both, and | ||
| grid modes | ||
| - <kbd>Home</kbd>: jump to first item (row-major order for grid) | ||
| - <kbd>End</kbd>: jump to last item (row-major order for grid) | ||
| - <kbd>Ctrl+Home</kbd>: first cell of first row (grid only) | ||
| - <kbd>Ctrl+End</kbd>: last cell of last row (grid only) | ||
| - <kbd>Page Up</kbd> / <kbd>Page Down</kbd>: move `pageStep` items or rows | ||
|
|
||
| #### Disabled item handling | ||
|
|
||
| - With `skipDisabled: false` (default): disabled items remain focusable so keyboard users can discover them | ||
| - With `skipDisabled: true`: disabled items are excluded from navigation entirely | ||
|
|
||
| #### RTL and writing modes | ||
|
|
||
| - For `horizontal`, **ArrowLeft** / **ArrowRight** follow the host's resolved `dir` (`rtl` swaps forward/back). | ||
| - For `both`, **ArrowLeft** / **ArrowRight** follow `dir` the same way, while **ArrowUp** / **ArrowDown** always step backward / forward in `getItems()` order. | ||
| - In `grid` mode, vertical movement uses row geometry; column movement respects `dir` for left/right. | ||
|
|
||
| ### Best practices | ||
|
|
||
| - Always provide appropriate ARIA `role` on the host (`toolbar`, `menu`, `grid`, `listbox`, etc.): the controller does not set roles | ||
| - Always provide `aria-label` or `aria-labelledby` on the host element | ||
| - Use `aria-disabled="true"` instead of native `disabled` when items should remain focusable for discoverability | ||
| - Use `skipDisabled: true` only when disabled items should be completely unreachable via keyboard | ||
| - Call `refresh()` after any DOM change that adds or removes items from the group | ||
|
|
||
| ## API | ||
|
|
||
| ### Methods | ||
|
|
||
| | Member | Description | | ||
| | ------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------- | | ||
| | `setOptions(partial)` | Merge new options and reapply roving tabindex. | | ||
| | `refresh()` | Re-query items and sync tabindex (call after DOM changes). | | ||
| | `setActiveItem(element)` | Set roving `tabindex` to the given eligible item (does **not** call `focus()`). Returns `false` if ineligible. | | ||
| | `focusFirstItemByTextPrefix(prefix)` | Set roving `tabindex` to the first eligible item matching prefix (case-insensitive). Does **not** call `focus()`. Returns `false` if no match. | | ||
| | `getActiveItem()` | Returns the eligible item with `tabindex="0"`, if any. | | ||
|
|
||
| ### Events | ||
|
|
||
| The controller dispatches **`swc-focusgroup-navigation-active-change`** (`focusgroupNavigationActiveChange`) on the host with `detail: { activeElement }` when the active item changes. The event bubbles and is composed. | ||
|
|
||
| ```typescript | ||
| import { focusgroupNavigationActiveChange } from '@spectrum-web-components/core/controllers/focusgroup-navigation-controller.js'; | ||
|
|
||
| host.addEventListener(focusgroupNavigationActiveChange, (event) => { | ||
| console.log('Active item:', event.detail.activeElement); | ||
| }); | ||
| ``` | ||
|
|
||
| ### Options | ||
|
|
||
| | Option | Type | Default | Description | | ||
| | -------------------- | ------------------------------------------------------ | ---------- | ----------------------------------------------------------------------------------------------------------------------------- | | ||
| | `getItems` | `() => HTMLElement[]` | (required) | Current navigable items. | | ||
| | `direction` | `'horizontal'` \| `'vertical'` \| `'both'` \| `'grid'` | (required) | Arrow-key mode. **`both`**: Left/Right and Up/Down on the same `getItems()` sequence. | | ||
| | `wrap` | `boolean` | `false` | Wrap at ends. | | ||
| | `memory` | `boolean` | `true` | Remember last focused for re-entry via Tab. | | ||
| | `skipDisabled` | `boolean` | `false` | Skip `disabled` / `aria-disabled="true"` items. | | ||
| | `pageStep` | `number` | — | Non-zero: **Page Up** / **Page Down** move this many items (linear) or rows (**grid**). `0` / omitted / non-finite: disabled. | | ||
| | `onActiveItemChange` | `(el) => void` | — | Callback when active item changes. | | ||
|
|
||
| ## Appendix | ||
|
|
||
| ### Relationship to native `focusgroup` | ||
|
|
||
| Native `focusgroup` would supply guaranteed tab stops, memory, and arrow behavior in the browser. This controller provides a **JavaScript** implementation for custom elements: you keep explicit ARIA roles and selection logic, and use the controller for tabindex and arrow-key focus movement. | ||
|
|
||
| ### See also | ||
|
|
||
| - [Keyboard navigation inside components (APG)](https://www.w3.org/WAI/ARIA/apg/practices/keyboard-interface/#keyboardnavigationinsidecomponents) | ||
| - [Focusgroup explainer (Open UI)](https://open-ui.org/components/scoped-focusgroup.explainer/) | ||
|
|
||
| <DocsFooter /> | ||
|
caseyisonit marked this conversation as resolved.
|
||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.