From 5ae7f9c028e2daee88ee3016055b2563e76b8de8 Mon Sep 17 00:00:00 2001 From: Josh Farrant Date: Wed, 10 Jun 2026 10:46:09 +0100 Subject: [PATCH 1/4] Add modular SelectPanel (4-layer) with tabs support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Decomposes SelectPanel into the modular architecture (hooks -> foundations -> parts -> ready-made) so consumers can compose a tabbed picker (e.g. Branches / Tags) with one shared search input — not possible with today's prop-based API. - L0 hooks: useFilter, useSelectionState, useAsyncList (consumer-owned, reusable) - L1 foundation: useSelectPanel + unstyled components (dialog/combobox/listbox/ tablist ARIA; outer role=dialog, listbox scoped to the active tab panel) - L2 parts: Primer-styled SelectPanel.* incl. TabList/Tab(count)/Panel - L3 ready-made: thin no-tabs wrapper (tabbed use is L2-only by design) - Per-layer stories, spec.md, and tests Branched off main; contains only the SelectPanel work. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- packages/react/package.json | 8 + .../ReadyMadeSelectPanel.stories.tsx | 62 ++++ .../SelectPanel/ReadyMadeSelectPanel.tsx | 127 +++++++ .../SelectPanel/SelectPanel.module.css | 161 +++++++++ .../SelectPanel/SelectPanel.spec.md | 325 +++++++++++++++++ .../experimental/SelectPanel/SelectPanel.tsx | 320 +++++++++++++++++ .../SelectPanelFoundation.stories.tsx | 178 +++++++++ .../SelectPanelFoundationHook.stories.tsx | 93 +++++ .../SelectPanel/SelectPanelParts.stories.tsx | 187 ++++++++++ .../__tests__/ReadyMadeSelectPanel.test.tsx | 59 +++ .../__tests__/SelectPanel.parts.test.tsx | 136 +++++++ .../src/experimental/SelectPanel/index.ts | 9 + .../src/experimental/SelectPanel/mock-refs.ts | 33 ++ .../experimental/SelectPanel/SelectPanel.tsx | 163 +++++++++ .../SelectPanel/SelectPanelFoundation.css | 11 + .../__tests__/SelectPanel.test.tsx | 50 +++ .../__tests__/useSelectPanel.test.tsx | 189 ++++++++++ .../experimental/SelectPanel/index.ts | 4 + .../SelectPanel/useSelectPanel.ts | 338 ++++++++++++++++++ .../src/foundations/experimental/index.ts | 9 + .../__tests__/useAsyncList.test.tsx | 49 +++ .../experimental/__tests__/useFilter.test.tsx | 41 +++ .../__tests__/useSelectionState.test.tsx | 62 ++++ .../react/src/hooks/experimental/index.ts | 14 + .../src/hooks/experimental/useAsyncList.ts | 106 ++++++ .../react/src/hooks/experimental/useFilter.ts | 78 ++++ .../hooks/experimental/useSelectionState.ts | 104 ++++++ 27 files changed, 2916 insertions(+) create mode 100644 packages/react/src/experimental/SelectPanel/ReadyMadeSelectPanel.stories.tsx create mode 100644 packages/react/src/experimental/SelectPanel/ReadyMadeSelectPanel.tsx create mode 100644 packages/react/src/experimental/SelectPanel/SelectPanel.module.css create mode 100644 packages/react/src/experimental/SelectPanel/SelectPanel.spec.md create mode 100644 packages/react/src/experimental/SelectPanel/SelectPanel.tsx create mode 100644 packages/react/src/experimental/SelectPanel/SelectPanelFoundation.stories.tsx create mode 100644 packages/react/src/experimental/SelectPanel/SelectPanelFoundationHook.stories.tsx create mode 100644 packages/react/src/experimental/SelectPanel/SelectPanelParts.stories.tsx create mode 100644 packages/react/src/experimental/SelectPanel/__tests__/ReadyMadeSelectPanel.test.tsx create mode 100644 packages/react/src/experimental/SelectPanel/__tests__/SelectPanel.parts.test.tsx create mode 100644 packages/react/src/experimental/SelectPanel/index.ts create mode 100644 packages/react/src/experimental/SelectPanel/mock-refs.ts create mode 100644 packages/react/src/foundations/experimental/SelectPanel/SelectPanel.tsx create mode 100644 packages/react/src/foundations/experimental/SelectPanel/SelectPanelFoundation.css create mode 100644 packages/react/src/foundations/experimental/SelectPanel/__tests__/SelectPanel.test.tsx create mode 100644 packages/react/src/foundations/experimental/SelectPanel/__tests__/useSelectPanel.test.tsx create mode 100644 packages/react/src/foundations/experimental/SelectPanel/index.ts create mode 100644 packages/react/src/foundations/experimental/SelectPanel/useSelectPanel.ts create mode 100644 packages/react/src/foundations/experimental/index.ts create mode 100644 packages/react/src/hooks/experimental/__tests__/useAsyncList.test.tsx create mode 100644 packages/react/src/hooks/experimental/__tests__/useFilter.test.tsx create mode 100644 packages/react/src/hooks/experimental/__tests__/useSelectionState.test.tsx create mode 100644 packages/react/src/hooks/experimental/index.ts create mode 100644 packages/react/src/hooks/experimental/useAsyncList.ts create mode 100644 packages/react/src/hooks/experimental/useFilter.ts create mode 100644 packages/react/src/hooks/experimental/useSelectionState.ts diff --git a/packages/react/package.json b/packages/react/package.json index 78df4dae326..47405336ee9 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -14,6 +14,14 @@ "types": "./dist/experimental/index.d.ts", "default": "./dist/experimental/index.js" }, + "./foundations/experimental": { + "types": "./dist/foundations/experimental/index.d.ts", + "default": "./dist/foundations/experimental/index.js" + }, + "./hooks/experimental": { + "types": "./dist/hooks/experimental/index.d.ts", + "default": "./dist/hooks/experimental/index.js" + }, "./deprecated": { "types": "./dist/deprecated/index.d.ts", "default": "./dist/deprecated/index.js" diff --git a/packages/react/src/experimental/SelectPanel/ReadyMadeSelectPanel.stories.tsx b/packages/react/src/experimental/SelectPanel/ReadyMadeSelectPanel.stories.tsx new file mode 100644 index 00000000000..0e93553639b --- /dev/null +++ b/packages/react/src/experimental/SelectPanel/ReadyMadeSelectPanel.stories.tsx @@ -0,0 +1,62 @@ +import {useState} from 'react' +import type {Meta, StoryObj} from '@storybook/react-vite' +import {SelectPanel} from './ReadyMadeSelectPanel' +import {branches} from './mock-refs' + +/** + * Layer 3 — Ready-made (no-tabs). + * + * The props-based drop-in for the simple case: pass `items`, get a filterable + * single-select panel. Tabbed pickers are deliberately out of scope here — use + * the Layer 2 Parts + `Tabs` for those (see the Parts story). + */ +const meta: Meta = { + title: 'Experimental/SelectPanel (Modular)/Ready-made', + parameters: {controls: {expanded: true}}, +} +export default meta + +const items = branches.map(b => ({id: b.id, text: b.name})) + +export const SingleSelect: StoryObj = { + render: () => { + const [open, setOpen] = useState(false) + const [selected, setSelected] = useState>(() => new Set(['branch-main'])) + const selectedName = items.find(i => selected.has(i.id))?.text ?? 'main' + + return ( + + ) + }, +} + +export const MultiSelect: StoryObj = { + render: () => { + const [open, setOpen] = useState(false) + const [selected, setSelected] = useState>(() => new Set()) + + return ( + + ) + }, +} diff --git a/packages/react/src/experimental/SelectPanel/ReadyMadeSelectPanel.tsx b/packages/react/src/experimental/SelectPanel/ReadyMadeSelectPanel.tsx new file mode 100644 index 00000000000..bfd670fb0b1 --- /dev/null +++ b/packages/react/src/experimental/SelectPanel/ReadyMadeSelectPanel.tsx @@ -0,0 +1,127 @@ +import React, {useMemo, useRef} from 'react' +import {SelectPanelParts as Parts} from './SelectPanel' +import {useFilter, useSelectionState} from '../../hooks/experimental' +import type {SelectionVariant} from '../../hooks/experimental/useSelectionState' +import type {SelectPanelGesture} from '../../foundations/experimental/SelectPanel' + +export interface SelectPanelItem { + /** Stable identifier used as the selection key. */ + id: string + /** Visible, searchable label. */ + text: string + disabled?: boolean +} + +export interface SelectPanelProps { + /** Whether the panel is open. */ + open: boolean + /** Called when the panel requests to open or close. */ + onOpenChange: (open: boolean, gesture: SelectPanelGesture) => void + /** Visible title for the panel. */ + title: React.ReactNode + /** Content rendered inside the anchor button. */ + anchor: React.ReactNode + /** The selectable items. */ + items: SelectPanelItem[] + /** @default 'single' */ + selectionVariant?: SelectionVariant + /** Controlled selected keys. */ + selectedKeys?: Iterable + /** Initial selected keys (uncontrolled). */ + defaultSelectedKeys?: Iterable + /** Called when the selection changes. */ + onSelectionChange?: (keys: Set) => void + /** Placeholder for the search input. */ + placeholder?: string + /** Width of the overlay. */ + width?: 'small' | 'medium' | 'large' + className?: string +} + +/** + * Layer 3 — Ready-made SelectPanel for the simple, **no-tabs** case. + * + * This is the drop-in props API for the 80% scenario: pass `items`, get a + * filterable single- or multi-select panel. It composes the Layer 2 Parts + * internally and owns its own filter state. + * + * ⚠️ Tabbed / compositional use cases are intentionally **not** supported here — + * adding a `tabs` prop would re-introduce exactly the config-creep this + * architecture exists to avoid. For tabs, compose the Layer 2 `SelectPanelParts` + * with the `Tabs` primitive directly (see the spec and the Parts story). + */ +export const SelectPanel = React.forwardRef(function SelectPanel( + { + open, + onOpenChange, + title, + anchor, + items, + selectionVariant = 'single', + selectedKeys, + defaultSelectedKeys, + onSelectionChange, + placeholder = 'Filter…', + width = 'medium', + className, + }, + ref, +) { + const anchorRef = useRef(null) + const filter = useFilter() + const selection = useSelectionState({ + selectionVariant, + selectedKeys, + defaultSelectedKeys, + onSelectionChange, + }) + + const filteredItems = useMemo(() => filter.filter(items, item => item.text), [filter, items]) + + return ( + + {anchor} + + + {title} + filter.setQuery(e.target.value)} + /> + + {filteredItems.length === 0 ? ( + No matches + ) : ( + + {filteredItems.map(item => ( + { + if (item.disabled) return + selection.toggle(item.id) + if (selectionVariant === 'single') onOpenChange(false, 'selection') + }} + > + {item.text} + + ))} + + )} + + + ) +}) + +SelectPanel.displayName = 'SelectPanel' diff --git a/packages/react/src/experimental/SelectPanel/SelectPanel.module.css b/packages/react/src/experimental/SelectPanel/SelectPanel.module.css new file mode 100644 index 00000000000..7135a78b382 --- /dev/null +++ b/packages/react/src/experimental/SelectPanel/SelectPanel.module.css @@ -0,0 +1,161 @@ +/* Layer 2: Parts — Primer-styled modular SelectPanel */ + +.Root { + position: relative; + display: inline-block; +} + +.Overlay { + position: absolute; + top: 0; + left: 0; + z-index: 1; + display: flex; + flex-direction: column; + inline-size: 320px; + max-inline-size: calc(100vw - var(--base-size-32)); + overflow: hidden; + color: var(--fgColor-default); + background-color: var(--overlay-bgColor); + border: var(--borderWidth-thin) solid var(--borderColor-default); + border-radius: var(--borderRadius-large); + box-shadow: var(--shadow-floating-small); + + &:where([data-width='small']) { + inline-size: 256px; + } + + &:where([data-width='large']) { + inline-size: 480px; + } +} + +.Header { + display: flex; + flex-direction: column; + gap: var(--base-size-8); + padding: var(--base-size-12); + border-block-end: var(--borderWidth-thin) solid var(--borderColor-default); + flex-shrink: 0; +} + +.Title { + margin: 0; + font-size: var(--text-body-size-medium); + font-weight: var(--text-title-weight-large); +} + +.Input { + inline-size: 100%; +} + +.TabList { + display: flex; + gap: var(--base-size-4); + padding-inline: var(--base-size-12); + padding-block-start: var(--base-size-8); + border-block-end: var(--borderWidth-thin) solid var(--borderColor-muted); + flex-shrink: 0; +} + +.Tab { + display: inline-flex; + align-items: center; + gap: var(--base-size-4); + padding-block: var(--base-size-6); + padding-inline: var(--base-size-8); + font-size: var(--text-body-size-small); + font-weight: var(--base-text-weight-normal); + color: var(--fgColor-muted); + cursor: pointer; + background: transparent; + border: none; + border-block-end: var(--borderWidth-thick) solid transparent; + /* stylelint-disable-next-line primer/spacing */ + margin-block-end: calc(-1 * var(--borderWidth-thin)); + + &:where([aria-selected='true']) { + font-weight: var(--base-text-weight-semibold); + color: var(--fgColor-default); + border-block-end-color: var(--underlineNav-borderColor-active, var(--fgColor-accent)); + } + + &:where(:focus-visible) { + outline: var(--borderWidth-thick) solid var(--fgColor-accent); + outline-offset: -2px; + border-radius: var(--borderRadius-small); + } +} + +.TabCount { + font-size: var(--text-body-size-small); + color: var(--fgColor-muted); +} + +.Panel { + display: flex; + flex-direction: column; + min-block-size: 0; + flex-grow: 1; +} + +.List { + margin: 0; + padding: var(--base-size-8); + overflow-y: auto; + max-block-size: 240px; + list-style: none; + flex-grow: 1; +} + +.Option { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--base-size-8); + padding-block: var(--base-size-6); + padding-inline: var(--base-size-8); + font-size: var(--text-body-size-medium); + color: var(--fgColor-default); + cursor: pointer; + border-radius: var(--borderRadius-medium); + + &:where(:hover) { + background-color: var(--control-transparent-bgColor-hover); + } + + /* Active descendant (keyboard) highlight */ + &:where([data-active-descendant]) { + background-color: var(--control-transparent-bgColor-active, var(--bgColor-accent-muted)); + } + + &:where([aria-disabled='true']) { + color: var(--fgColor-disabled); + cursor: not-allowed; + } +} + +.OptionCheck { + color: var(--fgColor-accent); + visibility: hidden; +} + +.Option:where([aria-selected='true']) .OptionCheck { + visibility: visible; +} + +.Footer { + display: flex; + justify-content: flex-end; + gap: var(--base-size-8); + padding: var(--base-size-12); + border-block-start: var(--borderWidth-thin) solid var(--borderColor-default); + flex-shrink: 0; +} + +.Empty { + padding: var(--base-size-16); + font-size: var(--text-body-size-small); + color: var(--fgColor-muted); + text-align: center; +} diff --git a/packages/react/src/experimental/SelectPanel/SelectPanel.spec.md b/packages/react/src/experimental/SelectPanel/SelectPanel.spec.md new file mode 100644 index 00000000000..96ea594f8ca --- /dev/null +++ b/packages/react/src/experimental/SelectPanel/SelectPanel.spec.md @@ -0,0 +1,325 @@ +# SelectPanel — 4-Layer Component Spec + +> **Status:** Draft (spike) +> **Scenario:** Decompose SelectPanel so a consumer can **add tabs** (Branches / Tags +> picker) by composition instead of forking. Feeds ADR-024 (Design System Spectrum). +> **Reference implementation:** the Dialog 4-layer stack on this branch. + +## Overview + +This document defines a **modular, tab-capable** SelectPanel across the four layers of the +[modular component architecture](https://github.com/github/primer/issues/6546): + +| Layer | Name | What it provides | +| ----- | --------------- | ------------------------------------------------------------------------------ | +| 0 | **Hooks** | Consumer-owned behavioural state — selection, filter, async list | +| 1 | **Foundations** | Compound hook (`useSelectPanel`) with prop-getters + unstyled accessible parts | +| 2 | **Parts** | Primer-styled compositional components (`SelectPanelParts.*`) | +| 3 | **Ready-made** | Props-based API for the simple **no-tabs** case | + +The leverage for tabs is **L1 + L0**, not L2. The new value is a standalone, placeable +listbox you can put inside a tab panel, plus decoupled hooks the consumer owns (selection +shareable across tabs, one filter across datasets, per-tab counts, async lists). The styled +Parts (L2) are a thin wrapper over that foundation. + +The canonical use case — a Branches / Tags picker where tabs switch which filterable list is +shown inside **one** panel with **one** shared search input — is impossible with today's +prop-based `items` API, forcing consumers to fork the whole component. The real-world fork +this replaces is `github/github-ui` `packages/ref-selector/RefSelectorV1.tsx` (561 LOC); the +composed version is a fraction of that. + +--- + +## Web Standards Baseline + +A tabbed SelectPanel is **Combobox + Listbox + Tabs composed inside a Dialog popup**. It is +grounded in four ARIA APG patterns. The reference, screen-reader-tested composition is +Ariakit's [`combobox-tabs`](https://ariakit.com/examples/combobox-tabs). + +### ⚠️ Load-bearing role decision: the popup is `role="dialog"`, not `role="listbox"` + +Today's stable SelectPanel wraps everything in a single `role="listbox"`. That makes +`role="tab"` an **invalid child**, so tabs are impossible without forking. The modular +SelectPanel instead uses: + +| Element | Role | Notes | +| ----------------------- | ---------- | ----------------------------------------------------------------------------- | +| Popup container | `dialog` | Outer shell. **Not** a listbox. | +| Search input | `combobox` | `aria-expanded`, `aria-controls` → active listbox, `aria-autocomplete="list"` | +| Tab strip | `tablist` | Reuses the `Tabs` primitive | +| Each tab | `tab` | `aria-selected`, `aria-controls` → panel | +| The (single) panel | `tabpanel` | `aria-labelledby` tracks the **active** tab | +| The list (in the panel) | `listbox` | Scoped **inside** the active tab panel | +| Each item | `option` | `aria-selected` | + +`role="listbox"` / `role="option"` are **scoped to the active tab panel**. The input's +`aria-activedescendant` may reference **either** a tab or an option — all major screen readers +support this. + +### Single dynamic panel (the Ariakit trick) + +There is **one** `tabpanel` hosting **one** `listbox`, whose items come from the active tab's +data — not one panel/listbox per tab. This keeps the combobox's `aria-controls` stable and the +ARIA always matching the active tab. The active panel re-labels itself (`aria-labelledby`) as +the tab changes. + +--- + +## Layer 0: Hooks (consumer-owned state) + +These live in `@primer/react/hooks/experimental`. They are deliberately decoupled from any +component tree, so the **same** state can be shared across tabs. + +### `useSelectionState` + +```ts +const selection = useSelectionState({selectionVariant: 'single' | 'multiple', ...}) +// → { selectedKeys, isSelected, toggle, setSelection, clear } +``` + +One selection model that two tabs (Branches / Tags) can share. Single-select replaces; +multi-select toggles. Controlled (`selectedKeys`) or uncontrolled (`defaultSelectedKeys`). + +### `useFilter` + +```ts +const filter = useFilter() +// → { query, setQuery, matches, filter(items, getText), count(items, getText) } +``` + +One shared query across tabs. `filter()` filters any dataset; `count()` produces per-tab match +counts for badges — without re-implementing the matching logic. + +### `useAsyncList` + +```ts +const branches = useAsyncList({filterText, load: ({filterText, cursor, signal}) => fetch(...)}) +// → { items, loadingState, error, hasMore, loadMore, reload } +``` + +Thin async-list capability with cursor pagination and `AbortSignal` cancellation. Each tab can +own its own instance, so switching tabs / typing fetches that tab's data independently and +stale responses are discarded. + +These resolve documented gaps: selection logic not reusable outside the panel, no filter reuse +across datasets, no async/cursor pagination. + +--- + +## Layer 1: Foundations + +`@primer/react/foundations/experimental` → `useSelectPanel` + unstyled `SelectPanel.*`. + +### `useSelectPanel` + +```ts +const panel = useSelectPanel({ + open: boolean, + onOpenChange: (open, gesture) => void, // controlled; this is a request, not a command + 'aria-label'?: string, + id?: string, + returnFocusRef?: RefObject, +}) +``` + +Returns prop-getters: + +| Getter | Element | Wires | +| ---------------------- | ------- | --------------------------------------------------------------------------------------------------------------- | +| `getAnchorProps()` | trigger | `aria-haspopup="dialog"`, `aria-expanded`, `aria-controls`, toggle | +| `getOverlayProps()` | popup | `role="dialog"`, `aria-labelledby`/`aria-label`, ref | +| `getTitleProps()` | title | `id` (→ dialog `aria-labelledby`) | +| `getInputProps()` | search | `role="combobox"`, `aria-expanded`, `aria-controls`, `aria-autocomplete`, `aria-activedescendant`, keyboard nav | +| `getListProps()` | list | `role="listbox"`, `id`, optional `aria-multiselectable` | +| `getPanelProps(tabId)` | panel | `role="tabpanel"`, `aria-labelledby` (the active tab) | +| `getOptionProps()` | option | `role="option"`, `aria-selected`, `aria-disabled`, active marker | + +Also: `isOpen`, `open()`, `close(gesture)`, `activeDescendantId`. + +**Behaviour the foundation owns:** open/close as a controlled contract, Escape to close +(`useOnEscapePress`), outside-click to close, focus save/restore (to `returnFocusRef` or the +previously-focused element), combobox keyboard navigation over options +(ArrowUp/Down/Enter, wrapping, scroll-into-view, `aria-activedescendant`), and a dev-mode +warning when no accessible name is provided. + +**Tab strip:** the foundation deliberately **does not re-implement tabs**. The tab strip +reuses the existing `Tabs` primitive (`useTab` / `useTabList` / `useTabPanel`), and the +foundation provides only the single dynamic `Panel` region (`getPanelProps`) that hosts the +listbox. This honours the "reuse, don't rebuild" rule. + +### Unstyled components + +`SelectPanel.Root / .Anchor / .Overlay / .Title / .Input / .Panel / .List / .Option` — wrap +the hook, wire ARIA via context, add no visual styling (foundation CSS reset only). `Overlay` +renders only while open. Consumers bring their own markup for the tab strip via the `Tabs` +primitive. + +--- + +## Layer 2: Parts + +`@primer/react/experimental` → `SelectPanelParts`. + +``` +SelectPanelParts (= Root) +├── .Anchor — Primer Button, wires getAnchorProps +├── .Overlay — role=dialog popup, anchored, Primer surface tokens +│ ├── .Header +│ │ ├── .Title +│ │ └── .Input — Primer TextInput (role=combobox), shared search +│ ├── (the Tabs primitive provides tab state/roving) +│ │ ├── .TabList — reuses useTabList +│ │ │ └── .Tab — reuses useTab; renders a per-tab count badge +│ │ └── .Panel — single dynamic tabpanel (reuses useTabPanel) +│ │ └── .List +│ │ └── .Option +│ ├── .Empty +│ └── .Footer +``` + +- Every Part carries a `data-component="SelectPanel[.Part]"` selector (the search input reuses + Primer's `TextInput`, which carries its own `data-component`; its `role="combobox"` is the + stable selector). +- CSS Modules + Primer design tokens; `:where()` for variant selectors. +- `TabList` / `Tab` / `Panel` reuse the `Tabs` primitive hooks rather than re-implementing tab + ARIA or roving focus. +- **Anchored positioning:** the `Overlay` positions against the trigger via Primer's + `useAnchoredPosition` (`outside-bottom` / `start` by default, configurable with `side`/`align` + props). It flips when there's no room and re-positions on scroll/resize. The `Root` is the + positioned parent (the overlay is `position: absolute` with computed `top`/`left`), so no + portal is required. + +### Adding tabs (the headline) + +```tsx + + Switch ref: {selected} + + + Switch branches/tags + filter.setQuery(e.target.value)} /> + + setActiveTab(value)}> + + + Branches + + + Tags + + + + + {activeItems.map(i => ( + selection.toggle(i.id)} + > + {i.name} + + ))} + + + + + +``` + +One shared search (`useFilter`), one selection model (`useSelectionState`) shared across tabs, +per-tab counts, a single listbox that swaps data by active tab. No fork. + +--- + +## Layer 3: Ready-made — and why it is **no-tabs only** + +`@primer/react/experimental` → `SelectPanel` (props-based). + +```tsx + +``` + +It composes the L2 Parts internally and owns its own filter state — the drop-in for the 80% +case. + +> **Decision (surfaced, not silent):** the Ready-made layer **intentionally does not support +> tabs**. The agent's own guidance names SelectPanel as the cautionary L3/config example: a +> `tabs` prop would re-introduce the config-creep (unwieldy prop/type surface) that the layered +> model exists to prevent. **Tabbed / compositional use cases are Layer 2 Parts only.** L3 +> exists purely for API continuity on the simple, single-list case. + +--- + +## Accessibility Requirements + +### Responsibility matrix + +| Requirement | L0 (Hooks) | L1 (Foundations) | L2 (Parts) | L3 (Ready-made) | +| ------------------------------------------- | ---------------- | ------------------------- | ---------------- | ---------------- | +| Popup `role="dialog"` (not listbox) | Consumer sets | ✅ Automatic | ✅ Inherited | ✅ Inherited | +| `aria-labelledby` → title / `aria-label` | Consumer wires | ✅ Auto-wired via context | ✅ Inherited | ✅ From `title` | +| Input `role="combobox"` + `aria-controls` | Consumer sets | ✅ Automatic | ✅ Inherited | ✅ Inherited | +| `aria-activedescendant` (tab **or** option) | Consumer manages | ✅ Options (keyboard nav) | ✅ Inherited | ✅ Inherited | +| `tablist` / `tab` / `tabpanel` | Consumer sets | ✅ via `Tabs` primitive | ✅ Inherited | n/a (no tabs) | +| `listbox` scoped to active panel | Consumer sets | ✅ Automatic | ✅ Inherited | ✅ Inherited | +| `option` + `aria-selected` | Consumer sets | ✅ Automatic | ✅ Inherited | ✅ From state | +| Escape closes | Consumer handles | ✅ Automatic | ✅ Inherited | ✅ Inherited | +| Outside-click closes | Consumer handles | ✅ Automatic | ✅ Inherited | ✅ Inherited | +| Focus returns on close | Consumer manages | ✅ Automatic | ✅ Inherited | ✅ Inherited | +| Arrow-key option navigation | Consumer handles | ✅ Automatic | ✅ Inherited | ✅ Inherited | +| Tab roving focus / Home/End | Consumer handles | ✅ via `Tabs` primitive | ✅ Inherited | n/a | +| Anchored positioning / visible surface | Consumer styles | ⚠️ Consumer must style | ✅ Primer tokens | ✅ Primer tokens | +| Colour contrast | Consumer ensures | ⚠️ Consumer must ensure | ✅ Primer tokens | ✅ Primer tokens | + +### Keyboard + +- **Input:** ArrowDown/ArrowUp move the active option (wrapping); Enter activates it; Escape + closes the panel. +- **Tabs:** ArrowLeft/ArrowRight move between tabs; Home/End jump to first/last (from the `Tabs` + primitive). + +--- + +## Deviations & Notes + +- **Non-modal popup.** Unlike Dialog, the SelectPanel popup is an anchored, non-modal combobox + popup — `aria-modal` is **not** set, and it does not use native ``/`showModal()`. + Dismissal is via Escape and outside-click. `useDialog` was evaluated for overlay/focus reuse + but its modal, centered `` semantics don't fit an anchored combobox popup; the + foundation composes the lighter `useOnEscapePress` + focus-return logic instead. +- **DOM-based option navigation.** The foundation discovers options by `role="option"` in the + active listbox rather than maintaining an option registry, mirroring the existing + SelectPanel2 approach. This keeps the single-dynamic-panel model simple. +- **Active-descendant scope (stubbed for full parity — deferred future work).** + `aria-activedescendant` currently tracks options for keyboard navigation. A unified composite + store letting the input's active-descendant move seamlessly between the **tab strip** and the + **option list** (Ariakit `TabStore.composite`) is the harder, future retrofit; tab roving is + presently handled by the `Tabs` primitive's own focus model (focus physically moves to the + tabs). Implementing the composite store would mean keeping DOM focus permanently on the input + and driving tabs via `aria-activedescendant` instead of roving tabindex — i.e. **not** reusing + the Tabs primitive's focus model. This was deliberately deferred during hardening to avoid + destabilising the shipped, tested behaviour; the keyboard model today (Arrow/Enter over + options on the input; Arrow/Home/End over tabs when a tab is focused) is fully accessible. + +--- + +## Open Questions + +1. Should the placeable list be `SelectPanel.List` (as here) or a **shared Base `Listbox`** + that both SelectPanel and other components consume? (Affects reuse outside the panel.) +2. **Shared vs per-tab search** — this spike shares one query (matching RefSelector); is that + always right? +3. Adopt an **Ariakit-style composite store** so the tab strip and list share one + `aria-activedescendant` model, rather than delegating tab focus to the `Tabs` primitive? +4. Graduate `useSelectionState` / `useFilter` / `useAsyncList` from experimental independently + of the SelectPanel foundation? diff --git a/packages/react/src/experimental/SelectPanel/SelectPanel.tsx b/packages/react/src/experimental/SelectPanel/SelectPanel.tsx new file mode 100644 index 00000000000..68447129a14 --- /dev/null +++ b/packages/react/src/experimental/SelectPanel/SelectPanel.tsx @@ -0,0 +1,320 @@ +import React, {createContext, useContext, useMemo, useRef} from 'react' +import {clsx} from 'clsx' +import type {AnchorAlignment, AnchorSide} from '@primer/behaviors' +import {CheckIcon, SearchIcon} from '@primer/octicons-react' +import { + useSelectPanel, + type UseSelectPanelOptions, + type UseSelectPanelReturn, +} from '../../foundations/experimental/SelectPanel' +import {useTab, useTabList, useTabPanel} from '../Tabs' +import {useAnchoredPosition} from '../../hooks/useAnchoredPosition' +import {Button} from '../../Button' +import TextInput from '../../TextInput' +import type {SelectionVariant} from '../../hooks/experimental/useSelectionState' + +import classes from './SelectPanel.module.css' + +// --- Context (internal only) --- + +interface SelectPanelContextValue { + foundation: UseSelectPanelReturn + selectionVariant: SelectionVariant + anchorRef: React.MutableRefObject +} + +const SelectPanelContext = createContext(null) + +function useSelectPanelContext(): SelectPanelContextValue { + const ctx = useContext(SelectPanelContext) + if (!ctx) { + throw new Error('SelectPanel compound components must be used within ') + } + return ctx +} + +// --- SelectPanel.Root --- + +interface SelectPanelRootProps extends UseSelectPanelOptions { + /** @default 'single' */ + selectionVariant?: SelectionVariant + children: React.ReactNode + className?: string +} + +const Root = React.forwardRef(function SelectPanelRoot( + {selectionVariant = 'single', children, className, ...options}, + forwardedRef, +) { + const foundation = useSelectPanel(options) + const anchorRef = useRef(null) + const ctx = useMemo(() => ({foundation, selectionVariant, anchorRef}), [foundation, selectionVariant]) + + return ( + +
+ {children} +
+
+ ) +}) + +// --- SelectPanel.Anchor --- + +interface SelectPanelAnchorProps { + children: React.ReactNode + className?: string +} + +const Anchor = React.forwardRef(function SelectPanelAnchor( + {children, className}, + forwardedRef, +) { + const {foundation, anchorRef} = useSelectPanelContext() + const anchorProps = foundation.getAnchorProps() + + const mergedRef = React.useCallback( + (node: HTMLButtonElement | null) => { + anchorProps.ref(node) + anchorRef.current = node + if (typeof forwardedRef === 'function') forwardedRef(node) + else if (forwardedRef) forwardedRef.current = node + }, + [anchorProps, anchorRef, forwardedRef], + ) + + return ( + + ) +}) + +// --- SelectPanel.Overlay --- + +interface SelectPanelOverlayProps extends React.ComponentProps<'div'> { + width?: 'small' | 'medium' | 'large' + /** Which side of the anchor the overlay opens against. @default 'outside-bottom' */ + side?: AnchorSide + /** How the overlay aligns to the anchor. @default 'start' */ + align?: AnchorAlignment +} + +function Overlay({ + width = 'medium', + side = 'outside-bottom', + align = 'start', + className, + style, + children, + ...props +}: SelectPanelOverlayProps) { + const {foundation, anchorRef} = useSelectPanelContext() + const {ref: foundationOverlayRef, ...overlayProps} = foundation.getOverlayProps() + const floatingRef = useRef(null) + + const {position} = useAnchoredPosition({anchorElementRef: anchorRef, floatingElementRef: floatingRef, side, align}, [ + foundation.isOpen, + side, + align, + ]) + + const mergedRef = React.useCallback( + (node: HTMLDivElement | null) => { + floatingRef.current = node + foundationOverlayRef(node) + }, + [foundationOverlayRef], + ) + + if (!foundation.isOpen) return null + + return ( +
+ {children} +
+ ) +} +Overlay.displayName = 'SelectPanel.Overlay' + +// --- SelectPanel.Header --- + +function Header({className, ...props}: React.ComponentProps<'div'>) { + return
+} +Header.displayName = 'SelectPanel.Header' + +// --- SelectPanel.Title --- + +function Title({className, ...props}: React.ComponentProps<'h2'>) { + const {foundation} = useSelectPanelContext() + const titleProps = foundation.getTitleProps() + return

+} +Title.displayName = 'SelectPanel.Title' + +// --- SelectPanel.Input (shared search) --- + +type SelectPanelInputProps = Omit, 'role'> + +function Input({className, onKeyDown, ...props}: SelectPanelInputProps) { + const {foundation} = useSelectPanelContext() + const inputProps = foundation.getInputProps() + return ( + ) => { + onKeyDown?.(event) + if (!event.defaultPrevented) inputProps.onKeyDown(event) + }} + className={clsx(className, classes.Input)} + data-component="SelectPanel.Input" + {...props} + /> + ) +} +Input.displayName = 'SelectPanel.Input' + +// --- SelectPanel.TabList (reuses the Tabs primitive) --- + +interface SelectPanelTabListProps extends React.HTMLAttributes { + 'aria-label': string +} + +function TabList({className, children, ...props}: SelectPanelTabListProps) { + const {tabListProps} = useTabList(props) + return ( + // @ts-expect-error Tabs primitive expects a non-nullable ref +
+ {children} +
+ ) +} +TabList.displayName = 'SelectPanel.TabList' + +// --- SelectPanel.Tab (reuses the Tabs primitive) --- + +interface SelectPanelTabProps { + value: string + disabled?: boolean + count?: number + children: React.ReactNode + className?: string +} + +function Tab({value, disabled, count, children, className}: SelectPanelTabProps) { + const {tabProps} = useTab({value, disabled}) + return ( + + ) +} +Tab.displayName = 'SelectPanel.Tab' + +// --- SelectPanel.Panel (single dynamic tab panel hosting the active list) --- + +interface SelectPanelPanelProps extends React.ComponentProps<'div'> { + /** The active tab value. The panel re-labels itself as the active tab changes. */ + value: string +} + +function Panel({value, className, children, ...props}: SelectPanelPanelProps) { + const {tabPanelProps} = useTabPanel({value}) + return ( +
+ {children} +
+ ) +} +Panel.displayName = 'SelectPanel.Panel' + +// --- SelectPanel.List --- + +function List({className, ...props}: React.ComponentProps<'ul'>) { + const {foundation, selectionVariant} = useSelectPanelContext() + const listProps = foundation.getListProps({multiselectable: selectionVariant === 'multiple'}) + return
    +} +List.displayName = 'SelectPanel.List' + +// --- SelectPanel.Option --- + +interface SelectPanelOptionProps extends Omit, 'id'> { + id: string + selected?: boolean + disabled?: boolean +} + +function Option({id, selected, disabled, className, children, ...props}: SelectPanelOptionProps) { + const {foundation} = useSelectPanelContext() + const optionProps = foundation.getOptionProps({id, selected, disabled}) + return ( +
  • + {children} + +
  • + ) +} +Option.displayName = 'SelectPanel.Option' + +// --- SelectPanel.Empty --- + +function Empty({className, ...props}: React.ComponentProps<'div'>) { + return
    +} +Empty.displayName = 'SelectPanel.Empty' + +// --- SelectPanel.Footer --- + +function Footer({className, ...props}: React.ComponentProps<'div'>) { + return
    +} +Footer.displayName = 'SelectPanel.Footer' + +// --- Compose --- + +export const SelectPanelParts = Object.assign(Root, { + Root, + Anchor, + Overlay, + Header, + Title, + Input, + TabList, + Tab, + Panel, + List, + Option, + Empty, + Footer, +}) + +export type { + SelectPanelRootProps, + SelectPanelOverlayProps, + SelectPanelTabProps, + SelectPanelPanelProps as SelectPanelPanelPartProps, +} diff --git a/packages/react/src/experimental/SelectPanel/SelectPanelFoundation.stories.tsx b/packages/react/src/experimental/SelectPanel/SelectPanelFoundation.stories.tsx new file mode 100644 index 00000000000..fb32a0b0150 --- /dev/null +++ b/packages/react/src/experimental/SelectPanel/SelectPanelFoundation.stories.tsx @@ -0,0 +1,178 @@ +import {useMemo, useRef, useState} from 'react' +import type {Meta, StoryObj} from '@storybook/react-vite' +import {SelectPanel} from '../../foundations/experimental/SelectPanel' +import {Tabs, useTab, useTabList, useTabPanel} from '../Tabs' +import {useFilter, useSelectionState} from '../../hooks/experimental' +import {branches, tags, type Ref} from './mock-refs' + +/** + * Headline story — a Branches / Tags tabbed picker built by composing the + * **unstyled** SelectPanel foundation with the existing `Tabs` primitive and + * consumer-owned L0 hooks (`useFilter`, `useSelectionState`). + * + * This proves the thesis: tabs become possible the moment the list is a placeable + * part and selection/filter state are hooks the consumer owns — no fork required. + * The real-world fork this replaces (`github/github-ui` RefSelectorV1) is 561 LOC. + */ +const meta: Meta = { + title: 'Experimental/SelectPanel (Modular)/Foundation', + parameters: {controls: {expanded: true}}, +} +export default meta + +// --- Minimal inline styles (foundation ships no visual opinion) --- + +const overlayStyle: React.CSSProperties = { + position: 'absolute', + marginTop: 4, + width: 320, + border: '1px solid #d1d9e0', + borderRadius: 12, + background: 'white', + boxShadow: '0 8px 24px rgba(0,0,0,0.2)', + overflow: 'hidden', +} +const headerStyle: React.CSSProperties = {padding: 12, borderBottom: '1px solid #d1d9e0'} +const inputStyle: React.CSSProperties = {width: '100%', padding: '6px 8px', boxSizing: 'border-box'} +const tabListStyle: React.CSSProperties = {display: 'flex', gap: 4, padding: '8px 12px 0'} +const listStyle: React.CSSProperties = {listStyle: 'none', margin: 0, padding: 4, maxHeight: 240, overflowY: 'auto'} + +function tabButtonStyle(selected: boolean): React.CSSProperties { + return { + appearance: 'none', + border: 'none', + background: 'transparent', + padding: '6px 8px', + borderBottom: selected ? '2px solid #0969da' : '2px solid transparent', + fontWeight: selected ? 600 : 400, + cursor: 'pointer', + } +} +function optionStyle(active: boolean, selected: boolean): React.CSSProperties { + return { + display: 'flex', + justifyContent: 'space-between', + padding: '6px 8px', + borderRadius: 6, + cursor: 'pointer', + background: active ? '#ddf4ff' : 'transparent', + fontWeight: selected ? 600 : 400, + } +} + +// --- Tab strip built on the Tabs primitive's hooks (no tabs rebuilt) --- + +function Tab({value, count, children}: {value: string; count: number; children: React.ReactNode}) { + const {tabProps} = useTab({value}) + return ( + + ) +} + +function TabStrip({children}: {children: React.ReactNode}) { + const {tabListProps} = useTabList({'aria-label': 'Ref type'}) + return ( + // @ts-expect-error Tabs primitive expects a non-nullable ref +
    + {children} +
    + ) +} + +// Single dynamic tab panel — one panel, aria-labelledby tracks the active tab. +function Panel({value, children}: {value: string; children: React.ReactNode}) { + const {tabPanelProps} = useTabPanel({value}) + return
    {children}
    +} + +// --- The list region (re-used for whichever tab is active) --- + +function RefList({items, selection}: {items: Ref[]; selection: ReturnType}) { + return ( + + {items.length === 0 ? ( +
  • + No matches +
  • + ) : ( + items.map(item => { + const selected = selection.isSelected(item.id) + return ( + selection.toggle(item.id)} + style={optionStyle(false, selected)} + > + {item.name} + {selected ? : null} + + ) + }) + )} +
    + ) +} + +export const BranchesAndTags: StoryObj = { + name: 'Branches / Tags tabbed picker', + render: () => { + const [open, setOpen] = useState(false) + const [activeTab, setActiveTab] = useState('branches') + const anchorRef = useRef(null) + + // One shared search query across both tabs. + const filter = useFilter() + // One selection model shared across both tabs (single-select ref). + const selection = useSelectionState({selectionVariant: 'single'}) + + const filteredBranches = useMemo(() => filter.filter(branches, b => b.name), [filter]) + const filteredTags = useMemo(() => filter.filter(tags, t => t.name), [filter]) + const activeItems = activeTab === 'branches' ? filteredBranches : filteredTags + + const selectedName = useMemo(() => { + const id = [...selection.selectedKeys][0] + return [...branches, ...tags].find(r => r.id === id)?.name ?? 'main' + }, [selection.selectedKeys]) + + return ( + setOpen(next)} returnFocusRef={anchorRef}> +
    + Switch ref: {selectedName} + + +
    + + Switch branches/tags + + filter.setQuery(e.target.value)} + style={inputStyle} + /> +
    + + setActiveTab(value)}> + + + Branches + + + Tags + + + + + + +
    +
    +
    + ) + }, +} diff --git a/packages/react/src/experimental/SelectPanel/SelectPanelFoundationHook.stories.tsx b/packages/react/src/experimental/SelectPanel/SelectPanelFoundationHook.stories.tsx new file mode 100644 index 00000000000..2bf5c22d1bf --- /dev/null +++ b/packages/react/src/experimental/SelectPanel/SelectPanelFoundationHook.stories.tsx @@ -0,0 +1,93 @@ +import {useMemo, useState} from 'react' +import type {Meta, StoryObj} from '@storybook/react-vite' +import {useSelectPanel} from '../../foundations/experimental/SelectPanel' +import {useFilter, useSelectionState} from '../../hooks/experimental' +import {branches, type Ref} from './mock-refs' + +/** + * Layer 1 — compound hook story. + * + * Demonstrates `useSelectPanel` with **consumer-owned markup**: the hook returns + * prop-getters that wire up the dialog/combobox/listbox ARIA, keyboard navigation + * and lifecycle, while the consumer renders (and styles) every element itself. + * This is the escape hatch for full markup control. + */ +const meta: Meta = { + title: 'Experimental/SelectPanel (Modular)/Foundation Hook', + parameters: {controls: {expanded: true}}, +} +export default meta + +const overlayStyle: React.CSSProperties = { + position: 'absolute', + marginTop: 4, + width: 300, + border: '1px solid #d1d9e0', + borderRadius: 12, + background: 'white', + boxShadow: '0 8px 24px rgba(0,0,0,0.2)', + padding: 8, +} + +export const WithPropGetters: StoryObj = { + render: () => { + const [open, setOpen] = useState(false) + const filter = useFilter() + const selection = useSelectionState({selectionVariant: 'single'}) + + const panel = useSelectPanel({open, onOpenChange: next => setOpen(next)}) + const anchorProps = panel.getAnchorProps() + const overlayProps = panel.getOverlayProps() + const titleProps = panel.getTitleProps() + const inputProps = panel.getInputProps() + const listProps = panel.getListProps() + + const items: Ref[] = useMemo(() => filter.filter(branches, b => b.name), [filter]) + + return ( +
    + + {panel.isOpen ? ( +
    } style={overlayStyle}> +

    + Branches +

    + filter.setQuery(e.target.value)} + style={{width: '100%', boxSizing: 'border-box', padding: '6px 8px'}} + /> +
      + {items.map(item => { + const optionProps = panel.getOptionProps({id: item.id, selected: selection.isSelected(item.id)}) + return ( +
    • selection.toggle(item.id)} + style={{ + padding: '6px 8px', + borderRadius: 6, + cursor: 'pointer', + background: optionProps['data-active-descendant'] !== undefined ? '#ddf4ff' : 'transparent', + }} + > + {item.name} +
    • + ) + })} +
    +
    + ) : null} +
    + ) + }, +} diff --git a/packages/react/src/experimental/SelectPanel/SelectPanelParts.stories.tsx b/packages/react/src/experimental/SelectPanel/SelectPanelParts.stories.tsx new file mode 100644 index 00000000000..eed29a44a87 --- /dev/null +++ b/packages/react/src/experimental/SelectPanel/SelectPanelParts.stories.tsx @@ -0,0 +1,187 @@ +import {useMemo, useRef, useState} from 'react' +import type {Meta, StoryObj} from '@storybook/react-vite' +import {SelectPanelParts as SelectPanel} from './SelectPanel' +import {Tabs} from '../Tabs' +import {Button} from '../../Button' +import {useFilter, useSelectionState} from '../../hooks/experimental' +import {branches, tags, type Ref} from './mock-refs' + +/** + * Layer 2 — Primer-styled Parts. + * + * A Branches / Tags tabbed picker composed entirely from Primer-styled + * `SelectPanel.*` parts + the `Tabs` primitive. This is the headline artifact: + * it proves a consumer can **add tabs** to SelectPanel by composition — one + * shared search input, per-tab counts, a single dynamic panel, and a selection + * model that persists across tabs — instead of forking the whole component. + */ +const meta: Meta = { + title: 'Experimental/SelectPanel (Modular)/Parts', + parameters: {controls: {expanded: true}}, + decorators: [ + Story => ( +
    + +
    + ), + ], +} +export default meta + +const allRefs = [...branches, ...tags] +const nameOf = (id: string | undefined) => allRefs.find(r => r.id === id)?.name + +function RefOptions({items, selection}: {items: Ref[]; selection: ReturnType}) { + if (items.length === 0) return No matches + return ( + + {items.map(item => ( + selection.toggle(item.id)} + > + {item.name} + + ))} + + ) +} + +/** + * Single-select — the canonical `ref-selector` use case. Selecting a ref keeps + * the panel open so you can switch tabs; the chosen ref stays checked when you + * navigate away and back. + */ +export const TabbedBranchesAndTags: StoryObj = { + name: 'Branches / Tags tabbed picker (styled)', + render: () => { + const [open, setOpen] = useState(false) + const [activeTab, setActiveTab] = useState('branches') + const anchorRef = useRef(null) + + // One shared search query and one selection model, both owned by the consumer + // and shared across the two tabs. + const filter = useFilter() + const selection = useSelectionState({selectionVariant: 'single', defaultSelectedKeys: ['branch-main']}) + + const filteredBranches = useMemo(() => filter.filter(branches, b => b.name), [filter]) + const filteredTags = useMemo(() => filter.filter(tags, t => t.name), [filter]) + const activeItems = activeTab === 'branches' ? filteredBranches : filteredTags + + const selectedName = useMemo(() => nameOf([...selection.selectedKeys][0]) ?? 'main', [selection.selectedKeys]) + + return ( + + Switch ref: {selectedName} + + + + Switch branches/tags + filter.setQuery(e.target.value)} + /> + + + setActiveTab(value)}> + + + Branches + + + Tags + + + + + + + + + + + + + + ) + }, +} + +/** + * Multi-select — makes "one selection model across tabs" obvious: pick branches + * **and** tags, switch tabs, and every choice stays checked. The footer counts + * the combined selection drawn from both tabs. + */ +export const MultiSelectAcrossTabs: StoryObj = { + name: 'Selection persists across tabs (multi-select)', + render: () => { + const [open, setOpen] = useState(false) + const [activeTab, setActiveTab] = useState('branches') + const anchorRef = useRef(null) + + const filter = useFilter() + const selection = useSelectionState({selectionVariant: 'multiple'}) + + const filteredBranches = useMemo(() => filter.filter(branches, b => b.name), [filter]) + const filteredTags = useMemo(() => filter.filter(tags, t => t.name), [filter]) + const activeItems = activeTab === 'branches' ? filteredBranches : filteredTags + + const selectedNames = useMemo( + () => [...selection.selectedKeys].map(nameOf).filter(Boolean).join(', '), + [selection.selectedKeys], + ) + + return ( + + Refs selected: {selection.selectedKeys.size} + + + + Select branches/tags + filter.setQuery(e.target.value)} + /> + + + setActiveTab(value)}> + + + Branches + + + Tags + + + + + + + + + + + {selectedNames || 'Nothing selected'} + + + + + + + ) + }, +} diff --git a/packages/react/src/experimental/SelectPanel/__tests__/ReadyMadeSelectPanel.test.tsx b/packages/react/src/experimental/SelectPanel/__tests__/ReadyMadeSelectPanel.test.tsx new file mode 100644 index 00000000000..f93408101fa --- /dev/null +++ b/packages/react/src/experimental/SelectPanel/__tests__/ReadyMadeSelectPanel.test.tsx @@ -0,0 +1,59 @@ +import {useState} from 'react' +import {render, fireEvent, screen} from '@testing-library/react' +import {describe, expect, it} from 'vitest' +import {SelectPanel} from '../ReadyMadeSelectPanel' + +const items = [ + {id: 'main', text: 'main'}, + {id: 'develop', text: 'develop'}, + {id: 'feat', text: 'feature/tabs'}, +] + +function Example(props: {selectionVariant?: 'single' | 'multiple'}) { + const [open, setOpen] = useState(false) + const [selected, setSelected] = useState>(() => new Set()) + return ( + + ) +} + +describe('Ready-made SelectPanel', () => { + it('composes Parts into a role=dialog popup with a listbox', () => { + render() + fireEvent.click(screen.getByRole('button', {name: 'Open'})) + expect(screen.getByRole('dialog')).toBeInTheDocument() + expect(screen.getByRole('listbox')).toBeInTheDocument() + expect(screen.getAllByRole('option')).toHaveLength(3) + }) + + it('filters items via the shared search input', () => { + render() + fireEvent.click(screen.getByRole('button', {name: 'Open'})) + fireEvent.change(screen.getByRole('combobox'), {target: {value: 'feat'}}) + expect(screen.getAllByRole('option')).toHaveLength(1) + expect(screen.getByText('feature/tabs')).toBeInTheDocument() + }) + + it('single-select closes the panel after a choice', () => { + render() + fireEvent.click(screen.getByRole('button', {name: 'Open'})) + fireEvent.click(screen.getByText('develop')) + expect(screen.queryByRole('dialog')).not.toBeInTheDocument() + }) + + it('shows an empty state when nothing matches', () => { + render() + fireEvent.click(screen.getByRole('button', {name: 'Open'})) + fireEvent.change(screen.getByRole('combobox'), {target: {value: 'zzz'}}) + expect(screen.getByText('No matches')).toBeInTheDocument() + }) +}) diff --git a/packages/react/src/experimental/SelectPanel/__tests__/SelectPanel.parts.test.tsx b/packages/react/src/experimental/SelectPanel/__tests__/SelectPanel.parts.test.tsx new file mode 100644 index 00000000000..1d88724556e --- /dev/null +++ b/packages/react/src/experimental/SelectPanel/__tests__/SelectPanel.parts.test.tsx @@ -0,0 +1,136 @@ +import {useState, type ComponentType} from 'react' +import {render, fireEvent, screen, within} from '@testing-library/react' +import {describe, expect, it} from 'vitest' +import {SelectPanelParts as SelectPanel} from '../SelectPanel' +import {Tabs} from '../../Tabs' +import {MultiSelectAcrossTabs} from '../SelectPanelParts.stories' + +function Example() { + const [open, setOpen] = useState(false) + const [active, setActive] = useState('branches') + const items = active === 'branches' ? ['main', 'develop'] : ['v1.0', 'v2.0'] + + return ( + + Open + + + Switch ref + + + setActive(value)}> + + + Branches + + + Tags + + + + + {items.map(name => ( + + {name} + + ))} + + + + + + ) +} + +describe('SelectPanel Parts', () => { + it('renders stable data-component selectors', () => { + render() + expect(document.querySelector('[data-component="SelectPanel"]')).toBeInTheDocument() + expect(document.querySelector('[data-component="SelectPanel.Anchor"]')).toBeInTheDocument() + + fireEvent.click(screen.getByRole('button', {name: 'Open'})) + for (const part of [ + 'SelectPanel.Overlay', + 'SelectPanel.Header', + 'SelectPanel.Title', + 'SelectPanel.TabList', + 'SelectPanel.Tab', + 'SelectPanel.Panel', + 'SelectPanel.List', + 'SelectPanel.Option', + ]) { + expect(document.querySelector(`[data-component="${part}"]`)).toBeInTheDocument() + } + // The search input reuses Primer's TextInput; its combobox role is the stable selector. + expect(screen.getByRole('combobox')).toBeInTheDocument() + }) + + it('uses role=dialog popup with a tablist and listbox scoped inside it', () => { + render() + fireEvent.click(screen.getByRole('button', {name: 'Open'})) + + const dialog = screen.getByRole('dialog') + expect(within(dialog).getByRole('tablist')).toBeInTheDocument() + expect(within(dialog).getAllByRole('tab')).toHaveLength(2) + expect(within(dialog).getByRole('tabpanel')).toBeInTheDocument() + expect(within(dialog).getByRole('listbox')).toBeInTheDocument() + }) + + it('anchors the overlay against the trigger (inline top/left applied)', () => { + render() + fireEvent.click(screen.getByRole('button', {name: 'Open'})) + const dialog = screen.getByRole('dialog') + // useAnchoredPosition writes absolute coordinates as inline styles. + expect(getComputedStyle(dialog).position).toBe('absolute') + expect(dialog.style.top).not.toBe('') + expect(dialog.style.left).not.toBe('') + }) + + it('switches the active list when a tab is selected', () => { + render() + fireEvent.click(screen.getByRole('button', {name: 'Open'})) + expect(screen.getByText('main')).toBeInTheDocument() + + fireEvent.mouseDown(screen.getByRole('tab', {name: /Tags/})) + expect(screen.getByText('v1.0')).toBeInTheDocument() + expect(screen.queryByText('main')).not.toBeInTheDocument() + }) + + it('the single tabpanel re-labels itself as the active tab changes', () => { + render() + fireEvent.click(screen.getByRole('button', {name: 'Open'})) + const branchesTab = screen.getByRole('tab', {name: /Branches/}) + expect(screen.getByRole('tabpanel')).toHaveAttribute('aria-labelledby', branchesTab.id) + + fireEvent.mouseDown(screen.getByRole('tab', {name: /Tags/})) + const tagsTab = screen.getByRole('tab', {name: /Tags/}) + expect(screen.getByRole('tabpanel')).toHaveAttribute('aria-labelledby', tagsTab.id) + }) +}) + +describe('SelectPanel headline story (MultiSelectAcrossTabs)', () => { + it('persists one selection model across both tabs', () => { + const Demo = MultiSelectAcrossTabs.render as ComponentType + render() + + fireEvent.click(screen.getByRole('button', {name: /Refs selected/})) + + const list = () => screen.getByRole('listbox') + + // Select a branch. + fireEvent.click(within(list()).getByText('main')) + expect(within(list()).getByText('main').closest('[role="option"]')).toHaveAttribute('aria-selected', 'true') + + // Switch to Tags and select a tag — the branch selection must survive. + fireEvent.mouseDown(screen.getByRole('tab', {name: /Tags/})) + fireEvent.click(within(list()).getByText('v1.0.0')) + expect(within(list()).getByText('v1.0.0').closest('[role="option"]')).toHaveAttribute('aria-selected', 'true') + + // The anchor reflects the combined count from both tabs. + expect(screen.getByRole('button', {name: /Refs selected: 2/})).toBeInTheDocument() + + // Back on Branches, the earlier selection is still checked. + fireEvent.mouseDown(screen.getByRole('tab', {name: /Branches/})) + expect(within(list()).getByText('main').closest('[role="option"]')).toHaveAttribute('aria-selected', 'true') + }) +}) diff --git a/packages/react/src/experimental/SelectPanel/index.ts b/packages/react/src/experimental/SelectPanel/index.ts new file mode 100644 index 00000000000..280307cc7a2 --- /dev/null +++ b/packages/react/src/experimental/SelectPanel/index.ts @@ -0,0 +1,9 @@ +export {SelectPanelParts} from './SelectPanel' +export type { + SelectPanelRootProps, + SelectPanelOverlayProps, + SelectPanelTabProps, + SelectPanelPanelPartProps, +} from './SelectPanel' +export {SelectPanel} from './ReadyMadeSelectPanel' +export type {SelectPanelProps, SelectPanelItem} from './ReadyMadeSelectPanel' diff --git a/packages/react/src/experimental/SelectPanel/mock-refs.ts b/packages/react/src/experimental/SelectPanel/mock-refs.ts new file mode 100644 index 00000000000..7305b6f3bc9 --- /dev/null +++ b/packages/react/src/experimental/SelectPanel/mock-refs.ts @@ -0,0 +1,33 @@ +/** + * Mock data for the tabbed Branches / Tags SelectPanel stories. + * Mirrors the real-world `github/github-ui` ref-selector use case. + */ + +export interface Ref { + id: string + name: string +} + +export const branches: Ref[] = [ + {id: 'branch-main', name: 'main'}, + {id: 'branch-develop', name: 'develop'}, + {id: 'branch-release-1', name: 'release/1.0'}, + {id: 'branch-release-2', name: 'release/2.0'}, + {id: 'branch-feat-tabs', name: 'feature/select-panel-tabs'}, + {id: 'branch-feat-async', name: 'feature/async-list'}, + {id: 'branch-fix-a11y', name: 'fix/listbox-roles'}, + {id: 'branch-fix-focus', name: 'fix/focus-return'}, + {id: 'branch-docs', name: 'docs/adr-024'}, + {id: 'branch-chore-deps', name: 'chore/bump-deps'}, +] + +export const tags: Ref[] = [ + {id: 'tag-v3-0-0', name: 'v3.0.0'}, + {id: 'tag-v2-5-1', name: 'v2.5.1'}, + {id: 'tag-v2-5-0', name: 'v2.5.0'}, + {id: 'tag-v2-4-0', name: 'v2.4.0'}, + {id: 'tag-v2-0-0', name: 'v2.0.0'}, + {id: 'tag-v1-9-0', name: 'v1.9.0'}, + {id: 'tag-v1-0-0', name: 'v1.0.0'}, + {id: 'tag-beta', name: 'v3.0.0-beta.1'}, +] diff --git a/packages/react/src/foundations/experimental/SelectPanel/SelectPanel.tsx b/packages/react/src/foundations/experimental/SelectPanel/SelectPanel.tsx new file mode 100644 index 00000000000..ac82ea34c01 --- /dev/null +++ b/packages/react/src/foundations/experimental/SelectPanel/SelectPanel.tsx @@ -0,0 +1,163 @@ +import type React from 'react' +import {createContext, useContext, useMemo} from 'react' +import { + useSelectPanel, + type UseSelectPanelOptions, + type UseSelectPanelReturn, + type OptionDescriptor, +} from './useSelectPanel' + +// --- Context (internal only) --- + +interface SelectPanelFoundationContextValue { + foundation: UseSelectPanelReturn +} + +const SelectPanelFoundationContext = createContext(null) + +function useSelectPanelFoundationContext(): SelectPanelFoundationContextValue { + const ctx = useContext(SelectPanelFoundationContext) + if (!ctx) { + throw new Error('SelectPanel foundation components must be used within ') + } + return ctx +} + +// --- Root --- + +interface RootProps extends UseSelectPanelOptions { + children: React.ReactNode +} + +function Root({children, ...options}: RootProps) { + const foundation = useSelectPanel(options) + const ctx = useMemo(() => ({foundation}), [foundation]) + + return {children} +} + +// --- Anchor --- + +function Anchor({children, className, ...props}: React.ComponentProps<'button'>) { + const {foundation} = useSelectPanelFoundationContext() + const {ref: anchorRef, ...anchorProps} = foundation.getAnchorProps() + + return ( + + ) +} + +// --- Overlay (renders only while open) --- + +function Overlay({children, className, ...props}: React.ComponentProps<'div'>) { + const {foundation} = useSelectPanelFoundationContext() + const {ref: overlayRef, ...overlayProps} = foundation.getOverlayProps() + + if (!foundation.isOpen) return null + + return ( +
    } className={className} {...props}> + {children} +
    + ) +} + +// --- Title --- + +function Title({children, className, ...props}: React.ComponentProps<'h2'>) { + const {foundation} = useSelectPanelFoundationContext() + const titleProps = foundation.getTitleProps() + return ( +

    + {children} +

    + ) +} + +// --- Input --- + +function Input({className, onKeyDown, ...props}: React.ComponentProps<'input'>) { + const {foundation} = useSelectPanelFoundationContext() + const inputProps = foundation.getInputProps() + return ( + { + onKeyDown?.(event) + if (!event.defaultPrevented) inputProps.onKeyDown(event) + }} + className={className} + {...props} + /> + ) +} + +// --- Panel (single dynamic tab panel hosting the active list) --- + +interface PanelProps extends React.ComponentProps<'div'> { + /** The id of the active tab this panel is labelled by. */ + tabId: string +} + +function Panel({tabId, children, className, ...props}: PanelProps) { + const {foundation} = useSelectPanelFoundationContext() + const panelProps = foundation.getPanelProps(tabId) + return ( +
    + {children} +
    + ) +} + +// --- List --- + +interface ListProps extends React.ComponentProps<'ul'> { + multiselectable?: boolean +} + +function List({multiselectable, children, className, ...props}: ListProps) { + const {foundation} = useSelectPanelFoundationContext() + const listProps = foundation.getListProps({multiselectable}) + return ( +
      + {children} +
    + ) +} + +// --- Option --- + +interface OptionComponentProps extends Omit, 'id'>, OptionDescriptor {} + +function Option({id, selected, disabled, children, className, ...props}: OptionComponentProps) { + const {foundation} = useSelectPanelFoundationContext() + const optionProps = foundation.getOptionProps({id, selected, disabled}) + return ( +
  • + {children} +
  • + ) +} + +// --- Compose --- + +export const SelectPanel = Object.assign(Root, { + Root, + Anchor, + Overlay, + Title, + Input, + Panel, + List, + Option, +}) + +export type {RootProps as SelectPanelRootProps, PanelProps as SelectPanelPanelProps} diff --git a/packages/react/src/foundations/experimental/SelectPanel/SelectPanelFoundation.css b/packages/react/src/foundations/experimental/SelectPanel/SelectPanelFoundation.css new file mode 100644 index 00000000000..604119690cb --- /dev/null +++ b/packages/react/src/foundations/experimental/SelectPanel/SelectPanelFoundation.css @@ -0,0 +1,11 @@ +/* Foundation reset — no visual opinion. + * Removes browser defaults that interfere with correct behaviour. + * Uses :where() for zero specificity so Layer 2 styles always win. */ + +:where([data-select-panel-foundation]) { + margin: 0; + padding: 0; + border: none; + background: transparent; + color: inherit; +} diff --git a/packages/react/src/foundations/experimental/SelectPanel/__tests__/SelectPanel.test.tsx b/packages/react/src/foundations/experimental/SelectPanel/__tests__/SelectPanel.test.tsx new file mode 100644 index 00000000000..71d060d647f --- /dev/null +++ b/packages/react/src/foundations/experimental/SelectPanel/__tests__/SelectPanel.test.tsx @@ -0,0 +1,50 @@ +import {useState} from 'react' +import {render, fireEvent, screen, within} from '@testing-library/react' +import {describe, expect, it, vi} from 'vitest' +import {SelectPanel} from '../SelectPanel' + +function Example() { + const [open, setOpen] = useState(false) + return ( + + Open + + Title + + + + One + + + + + ) +} + +describe('SelectPanel foundation components', () => { + it('only renders the overlay while open', () => { + render() + expect(screen.queryByRole('dialog')).not.toBeInTheDocument() + fireEvent.click(screen.getByRole('button', {name: 'Open'})) + expect(screen.getByRole('dialog')).toBeInTheDocument() + }) + + it('wires the dialog/combobox/listbox/tabpanel structure via context', () => { + render() + fireEvent.click(screen.getByRole('button', {name: 'Open'})) + const dialog = screen.getByRole('dialog') + expect(within(dialog).getByRole('combobox')).toBeInTheDocument() + expect(within(dialog).getByRole('listbox')).toBeInTheDocument() + expect(within(dialog).getByRole('option')).toBeInTheDocument() + const panel = within(dialog).getByRole('tabpanel') + expect(panel).toHaveAttribute('aria-labelledby', 'my-tab') + }) + + it('throws when a sub-component is used outside Root', () => { + const spy = vi.spyOn(console, 'error').mockImplementation(() => {}) + expect(() => render(Orphan)).toThrow( + /must be used within /, + ) + spy.mockRestore() + }) +}) diff --git a/packages/react/src/foundations/experimental/SelectPanel/__tests__/useSelectPanel.test.tsx b/packages/react/src/foundations/experimental/SelectPanel/__tests__/useSelectPanel.test.tsx new file mode 100644 index 00000000000..4569f3fd5b4 --- /dev/null +++ b/packages/react/src/foundations/experimental/SelectPanel/__tests__/useSelectPanel.test.tsx @@ -0,0 +1,189 @@ +import {useRef, useState, type Ref} from 'react' +import {render, fireEvent, screen, within} from '@testing-library/react' +import {describe, expect, it, vi} from 'vitest' +import {useSelectPanel, type UseSelectPanelOptions} from '..' + +function TestPanel(props: Partial & {options?: {id: string; label: string}[]}) { + const { + options = [ + {id: 'opt-1', label: 'One'}, + {id: 'opt-2', label: 'Two'}, + ], + ...rest + } = props + const [open, setOpen] = useState(rest.open ?? false) + const [selected, setSelected] = useState(null) + const anchorRef = useRef(null) + + const foundation = useSelectPanel({ + open, + onOpenChange: (next, gesture) => { + rest.onOpenChange?.(next, gesture) + setOpen(next) + }, + returnFocusRef: anchorRef, + ...rest, + }) + + const {ref: anchorRefCb, ...anchorProps} = foundation.getAnchorProps() + const {ref: overlayRefCb, ...overlayProps} = foundation.getOverlayProps() + const titleProps = foundation.getTitleProps() + const inputProps = foundation.getInputProps() + const listProps = foundation.getListProps() + + return ( +
    + + {foundation.isOpen ? ( +
    }> +

    Title

    + +
      + {options.map(o => { + const optionProps = foundation.getOptionProps({id: o.id, selected: selected === o.id}) + return ( +
    • setSelected(o.id)}> + {o.label} +
    • + ) + })} +
    +
    + ) : null} +
    + ) +} + +describe('useSelectPanel', () => { + it('anchor wires aria-haspopup, aria-expanded and aria-controls', () => { + render() + const anchor = screen.getByRole('button', {name: 'Open'}) + expect(anchor).toHaveAttribute('aria-haspopup', 'dialog') + expect(anchor).toHaveAttribute('aria-expanded', 'false') + expect(anchor).toHaveAttribute('aria-controls') + }) + + it('opens on anchor click and renders a role="dialog" popup (not a listbox)', () => { + render() + fireEvent.click(screen.getByRole('button', {name: 'Open'})) + const dialog = screen.getByRole('dialog') + expect(dialog).toBeInTheDocument() + // The popup is a dialog; the listbox is scoped inside it. + expect(dialog).not.toHaveAttribute('role', 'listbox') + expect(within(dialog).getByRole('listbox')).toBeInTheDocument() + expect(screen.getByRole('button', {name: 'Open'})).toHaveAttribute('aria-expanded', 'true') + }) + + it('wires the dialog aria-labelledby to the title', () => { + render() + const dialog = screen.getByRole('dialog') + const title = screen.getByText('Title') + expect(dialog.getAttribute('aria-labelledby')).toBe(title.id) + }) + + it('input is a combobox controlling the listbox', () => { + render() + const input = screen.getByRole('combobox') + const list = screen.getByRole('listbox') + expect(input).toHaveAttribute('aria-controls', list.id) + expect(input).toHaveAttribute('aria-autocomplete', 'list') + }) + + it('ArrowDown sets aria-activedescendant to the first option', () => { + render() + const input = screen.getByRole('combobox') + expect(input).not.toHaveAttribute('aria-activedescendant') + fireEvent.keyDown(input, {key: 'ArrowDown'}) + expect(input).toHaveAttribute('aria-activedescendant', 'opt-1') + fireEvent.keyDown(input, {key: 'ArrowDown'}) + expect(input).toHaveAttribute('aria-activedescendant', 'opt-2') + }) + + it('ArrowUp from the top wraps to the last option', () => { + render() + const input = screen.getByRole('combobox') + fireEvent.keyDown(input, {key: 'ArrowUp'}) + expect(input).toHaveAttribute('aria-activedescendant', 'opt-2') + }) + + it('Enter activates (clicks) the active option', () => { + render() + const input = screen.getByRole('combobox') + fireEvent.keyDown(input, {key: 'ArrowDown'}) + fireEvent.keyDown(input, {key: 'Enter'}) + expect(screen.getByText('One').closest('[role="option"]')).toHaveAttribute('aria-selected', 'true') + }) + + it('option exposes aria-selected', () => { + render() + fireEvent.click(screen.getByText('Two')) + expect(screen.getByText('Two').closest('[role="option"]')).toHaveAttribute('aria-selected', 'true') + expect(screen.getByText('One').closest('[role="option"]')).toHaveAttribute('aria-selected', 'false') + }) + + it('Escape requests close', () => { + const onOpenChange = vi.fn() + render() + fireEvent.keyDown(document, {key: 'Escape'}) + expect(onOpenChange).toHaveBeenCalledWith(false, 'escape') + }) + + it('outside click requests close', () => { + const onOpenChange = vi.fn() + render() + fireEvent.mouseDown(document.body) + expect(onOpenChange).toHaveBeenCalledWith(false, 'outside-click') + }) + + it('returns focus to the trigger when the panel closes', () => { + render() + const anchor = screen.getByRole('button', {name: 'Open'}) + anchor.focus() + fireEvent.click(anchor) + expect(screen.getByRole('dialog')).toBeInTheDocument() + + fireEvent.keyDown(document, {key: 'Escape'}) + expect(screen.queryByRole('dialog')).not.toBeInTheDocument() + expect(anchor).toHaveFocus() + }) + + it('resets aria-activedescendant when the panel closes and reopens', () => { + render() + fireEvent.click(screen.getByRole('button', {name: 'Open'})) + const input = screen.getByRole('combobox') + fireEvent.keyDown(input, {key: 'ArrowDown'}) + expect(input).toHaveAttribute('aria-activedescendant', 'opt-1') + + fireEvent.keyDown(document, {key: 'Escape'}) + fireEvent.click(screen.getByRole('button', {name: 'Open'})) + expect(screen.getByRole('combobox')).not.toHaveAttribute('aria-activedescendant') + }) + + it('warns in dev mode when no accessible name is provided', async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + function NoName() { + const foundation = useSelectPanel({open: true, onOpenChange: () => {}}) + const {ref: overlayRefCb, ...overlayProps} = foundation.getOverlayProps() + return ( +
    }> + content +
    + ) + } + + render() + await new Promise(resolve => setTimeout(resolve, 10)) + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('No accessible name provided')) + warnSpy.mockRestore() + }) +}) diff --git a/packages/react/src/foundations/experimental/SelectPanel/index.ts b/packages/react/src/foundations/experimental/SelectPanel/index.ts new file mode 100644 index 00000000000..9b38e700f6a --- /dev/null +++ b/packages/react/src/foundations/experimental/SelectPanel/index.ts @@ -0,0 +1,4 @@ +export {useSelectPanel} from './useSelectPanel' +export type {UseSelectPanelOptions, UseSelectPanelReturn, SelectPanelGesture, OptionDescriptor} from './useSelectPanel' +export {SelectPanel} from './SelectPanel' +export type {SelectPanelRootProps, SelectPanelPanelProps} from './SelectPanel' diff --git a/packages/react/src/foundations/experimental/SelectPanel/useSelectPanel.ts b/packages/react/src/foundations/experimental/SelectPanel/useSelectPanel.ts new file mode 100644 index 00000000000..bd2d67f8fd9 --- /dev/null +++ b/packages/react/src/foundations/experimental/SelectPanel/useSelectPanel.ts @@ -0,0 +1,338 @@ +import {useCallback, useEffect, useId, useRef, useState} from 'react' +import {useOnEscapePress} from '../../../hooks/useOnEscapePress' +import './SelectPanelFoundation.css' + +// --- Types --- + +/** How a close/open transition was requested. */ +export type SelectPanelGesture = 'anchor-click' | 'escape' | 'outside-click' | 'close-button' | 'selection' + +export interface UseSelectPanelOptions { + /** Whether the panel is open. Controlled. */ + open: boolean + + /** + * Called when the panel requests to open or close. The panel does NOT change + * until `open` is updated — this is a request, not a command. + */ + onOpenChange: (open: boolean, gesture: SelectPanelGesture) => void + + /** Accessible name for the popup when no visible title is wired. */ + 'aria-label'?: string + + /** Stable id base for the panel's generated ids. */ + id?: string + + /** Element to return focus to when the panel closes. Defaults to the anchor. */ + returnFocusRef?: React.RefObject +} + +interface AnchorProps { + ref: React.RefCallback + 'aria-haspopup': 'dialog' + 'aria-expanded': boolean + 'aria-controls': string + onClick: () => void +} + +interface OverlayProps { + ref: React.RefCallback + id: string + role: 'dialog' + 'aria-labelledby'?: string + 'aria-label'?: string + 'data-select-panel-foundation': '' +} + +interface TitleProps { + id: string +} + +interface InputProps { + role: 'combobox' + 'aria-expanded': boolean + 'aria-controls': string + 'aria-autocomplete': 'list' + 'aria-activedescendant'?: string + onKeyDown: (event: React.KeyboardEvent) => void +} + +interface ListProps { + id: string + role: 'listbox' + 'aria-multiselectable'?: boolean +} + +interface PanelProps { + role: 'tabpanel' + 'aria-labelledby': string +} + +export interface OptionDescriptor { + /** Stable id for the option. Referenced by `aria-activedescendant`. */ + id: string + /** Whether the option is currently selected. */ + selected?: boolean + /** Whether the option is disabled. */ + disabled?: boolean +} + +interface OptionProps { + id: string + role: 'option' + 'aria-selected': boolean + 'aria-disabled'?: boolean + 'data-active-descendant'?: '' +} + +export interface UseSelectPanelReturn { + /** Props for the trigger that opens the panel. */ + getAnchorProps: () => AnchorProps + /** Props for the popup container (`role="dialog"`). */ + getOverlayProps: () => OverlayProps + /** Props for a visible title element (auto-wires `aria-labelledby`). */ + getTitleProps: () => TitleProps + /** Props for the shared search input (`role="combobox"`). */ + getInputProps: () => InputProps + /** + * Props for a listbox region (`role="listbox"`). With tabs, exactly one list + * is rendered (the active tab's), following the single-dynamic-panel model. + */ + getListProps: (opts?: {multiselectable?: boolean}) => ListProps + /** Props for the single tab panel that hosts the active list. */ + getPanelProps: (activeTabId: string) => PanelProps + /** Props for an option within the listbox. */ + getOptionProps: (option: OptionDescriptor) => OptionProps + /** Whether the panel is currently open. */ + isOpen: boolean + /** Request the panel to open. */ + open: () => void + /** Request the panel to close. */ + close: (gesture: SelectPanelGesture) => void + /** The id currently referenced by the input's `aria-activedescendant`. */ + activeDescendantId: string | undefined +} + +// --- Hook --- + +export function useSelectPanel(options: UseSelectPanelOptions): UseSelectPanelReturn { + const {open, onOpenChange, 'aria-label': ariaLabel, id: providedId, returnFocusRef} = options + + const generatedId = useId() + const baseId = providedId ?? generatedId + const overlayId = `${baseId}--overlay` + const listId = `${baseId}--list` + const titleId = `${baseId}--title` + + const anchorRef = useRef(null) + const overlayRef = useRef(null) + const previousFocusRef = useRef(null) + const titleUsed = useRef(false) + + const [activeDescendantId, setActiveDescendantId] = useState(undefined) + + const requestOpen = useCallback(() => onOpenChange(true, 'anchor-click'), [onOpenChange]) + const requestClose = useCallback((gesture: SelectPanelGesture) => onOpenChange(false, gesture), [onOpenChange]) + + // Reset the active option whenever the panel toggles. + useEffect(() => { + if (!open) setActiveDescendantId(undefined) + }, [open]) + + // Escape closes the panel. + useOnEscapePress( + (event: KeyboardEvent) => { + if (open) { + event.preventDefault() + requestClose('escape') + } + }, + [open, requestClose], + ) + + // Outside-click closes the panel. + useEffect(() => { + if (!open) return + const handler = (event: MouseEvent) => { + const target = event.target as Node + if (overlayRef.current?.contains(target)) return + if (anchorRef.current?.contains(target)) return + requestClose('outside-click') + } + document.addEventListener('mousedown', handler) + return () => document.removeEventListener('mousedown', handler) + }, [open, requestClose]) + + // Focus lifecycle: save focus on open, restore on close. + useEffect(() => { + if (open) { + previousFocusRef.current = document.activeElement + } else if (previousFocusRef.current) { + const target = returnFocusRef?.current ?? (previousFocusRef.current as HTMLElement) + if (target instanceof HTMLElement) target.focus() + previousFocusRef.current = null + } + }, [open, returnFocusRef]) + + // Dev-mode accessible-name check. + useEffect(() => { + if (process.env.NODE_ENV !== 'production' && open) { + queueMicrotask(() => { + if (!titleUsed.current && !ariaLabel) { + // eslint-disable-next-line no-console + console.warn( + 'SelectPanel: No accessible name provided. Use getTitleProps() on a title element, or pass aria-label to useSelectPanel().', + ) + } + }) + } + }, [open, ariaLabel]) + + // Keyboard navigation across options in the active listbox. Uses the DOM so the + // hook does not need an option registry — options are discovered by role. + const getOptions = useCallback((): HTMLElement[] => { + const list = overlayRef.current?.querySelector(`#${CSS.escape(listId)}`) + if (!list) return [] + return Array.from(list.querySelectorAll('[role="option"]:not([aria-disabled="true"])')) + }, [listId]) + + const moveActiveDescendant = useCallback( + (direction: 1 | -1) => { + const opts = getOptions() + if (opts.length === 0) return + const currentIndex = opts.findIndex(el => el.id === activeDescendantId) + let nextIndex: number + if (currentIndex === -1) { + nextIndex = direction === 1 ? 0 : opts.length - 1 + } else { + nextIndex = (currentIndex + direction + opts.length) % opts.length + } + const next = opts[nextIndex] + setActiveDescendantId(next.id) + next.scrollIntoView({block: 'nearest'}) + }, + [getOptions, activeDescendantId], + ) + + const onInputKeyDown = useCallback( + (event: React.KeyboardEvent) => { + switch (event.key) { + case 'ArrowDown': + event.preventDefault() + moveActiveDescendant(1) + break + case 'ArrowUp': + event.preventDefault() + moveActiveDescendant(-1) + break + case 'Enter': { + if (!activeDescendantId) return + event.preventDefault() + const active = getOptions().find(el => el.id === activeDescendantId) + active?.click() + break + } + default: + break + } + }, + [moveActiveDescendant, activeDescendantId, getOptions], + ) + + // --- Ref callbacks --- + + const anchorRefCallback = useCallback((node: HTMLElement | null) => { + anchorRef.current = node + }, []) + + const overlayRefCallback = useCallback((node: HTMLElement | null) => { + overlayRef.current = node + }, []) + + // --- Prop getters --- + + const getAnchorProps = useCallback( + (): AnchorProps => ({ + ref: anchorRefCallback, + 'aria-haspopup': 'dialog', + 'aria-expanded': open, + 'aria-controls': overlayId, + onClick: () => (open ? requestClose('anchor-click') : requestOpen()), + }), + [anchorRefCallback, open, overlayId, requestOpen, requestClose], + ) + + const getOverlayProps = useCallback((): OverlayProps => { + const props: OverlayProps = { + ref: overlayRefCallback, + id: overlayId, + role: 'dialog', + 'aria-labelledby': titleId, + 'data-select-panel-foundation': '', + } + if (ariaLabel) props['aria-label'] = ariaLabel + return props + }, [overlayRefCallback, overlayId, titleId, ariaLabel]) + + const getTitleProps = useCallback((): TitleProps => { + titleUsed.current = true + return {id: titleId} + }, [titleId]) + + const getInputProps = useCallback( + (): InputProps => ({ + role: 'combobox', + 'aria-expanded': open, + 'aria-controls': listId, + 'aria-autocomplete': 'list', + 'aria-activedescendant': activeDescendantId, + onKeyDown: onInputKeyDown, + }), + [open, listId, activeDescendantId, onInputKeyDown], + ) + + const getListProps = useCallback( + (opts?: {multiselectable?: boolean}): ListProps => { + const props: ListProps = {id: listId, role: 'listbox'} + if (opts?.multiselectable) props['aria-multiselectable'] = true + return props + }, + [listId], + ) + + const getPanelProps = useCallback( + (activeTabId: string): PanelProps => ({ + role: 'tabpanel', + 'aria-labelledby': activeTabId, + }), + [], + ) + + const getOptionProps = useCallback( + (option: OptionDescriptor): OptionProps => { + const props: OptionProps = { + id: option.id, + role: 'option', + 'aria-selected': Boolean(option.selected), + } + if (option.disabled) props['aria-disabled'] = true + if (option.id === activeDescendantId) props['data-active-descendant'] = '' + return props + }, + [activeDescendantId], + ) + + return { + getAnchorProps, + getOverlayProps, + getTitleProps, + getInputProps, + getListProps, + getPanelProps, + getOptionProps, + isOpen: open, + open: requestOpen, + close: requestClose, + activeDescendantId, + } +} diff --git a/packages/react/src/foundations/experimental/index.ts b/packages/react/src/foundations/experimental/index.ts new file mode 100644 index 00000000000..fc83601a730 --- /dev/null +++ b/packages/react/src/foundations/experimental/index.ts @@ -0,0 +1,9 @@ +export {useSelectPanel, SelectPanel} from './SelectPanel' +export type { + UseSelectPanelOptions, + UseSelectPanelReturn, + SelectPanelGesture, + OptionDescriptor, + SelectPanelRootProps, + SelectPanelPanelProps, +} from './SelectPanel' diff --git a/packages/react/src/hooks/experimental/__tests__/useAsyncList.test.tsx b/packages/react/src/hooks/experimental/__tests__/useAsyncList.test.tsx new file mode 100644 index 00000000000..e99c663e3ec --- /dev/null +++ b/packages/react/src/hooks/experimental/__tests__/useAsyncList.test.tsx @@ -0,0 +1,49 @@ +import {renderHook, act, waitFor} from '@testing-library/react' +import {describe, expect, it, vi} from 'vitest' +import {useAsyncList} from '../useAsyncList' + +describe('useAsyncList', () => { + it('loads items on mount', async () => { + const {result} = renderHook(() => useAsyncList({load: async () => ({items: ['a', 'b']})})) + await waitFor(() => expect(result.current.items).toEqual(['a', 'b'])) + expect(result.current.loadingState).toBe('idle') + expect(result.current.hasMore).toBe(false) + }) + + it('re-loads (replacing items) when filterText changes', async () => { + const load = vi.fn(async ({filterText}: {filterText: string}) => ({items: [filterText || 'all']})) + const {result, rerender} = renderHook(({filterText}) => useAsyncList({load, filterText}), { + initialProps: {filterText: ''}, + }) + await waitFor(() => expect(result.current.items).toEqual(['all'])) + + rerender({filterText: 'main'}) + await waitFor(() => expect(result.current.items).toEqual(['main'])) + }) + + it('paginates with a cursor via loadMore', async () => { + const load = vi.fn(async ({cursor}: {cursor?: string}) => { + if (cursor === undefined) return {items: ['a', 'b'], cursor: 'page2'} + return {items: ['c', 'd']} + }) + const {result} = renderHook(() => useAsyncList({load})) + await waitFor(() => expect(result.current.items).toEqual(['a', 'b'])) + expect(result.current.hasMore).toBe(true) + + act(() => result.current.loadMore()) + await waitFor(() => expect(result.current.items).toEqual(['a', 'b', 'c', 'd'])) + expect(result.current.hasMore).toBe(false) + }) + + it('surfaces errors', async () => { + const {result} = renderHook(() => + useAsyncList({ + load: async () => { + throw new Error('boom') + }, + }), + ) + await waitFor(() => expect(result.current.loadingState).toBe('error')) + expect((result.current.error as Error).message).toBe('boom') + }) +}) diff --git a/packages/react/src/hooks/experimental/__tests__/useFilter.test.tsx b/packages/react/src/hooks/experimental/__tests__/useFilter.test.tsx new file mode 100644 index 00000000000..02b04be8fe8 --- /dev/null +++ b/packages/react/src/hooks/experimental/__tests__/useFilter.test.tsx @@ -0,0 +1,41 @@ +import {renderHook, act} from '@testing-library/react' +import {describe, expect, it} from 'vitest' +import {useFilter} from '../useFilter' + +const branches = [{name: 'main'}, {name: 'develop'}, {name: 'feature/tabs'}] +const tags = [{name: 'v1.0'}, {name: 'v2.0'}] + +describe('useFilter', () => { + it('matches case-insensitively by substring', () => { + const {result} = renderHook(() => useFilter()) + act(() => result.current.setQuery('FEAT')) + expect(result.current.matches('feature/tabs')).toBe(true) + expect(result.current.matches('main')).toBe(false) + }) + + it('an empty query matches everything', () => { + const {result} = renderHook(() => useFilter()) + expect(result.current.matches('anything')).toBe(true) + }) + + it('one query filters multiple datasets', () => { + const {result} = renderHook(() => useFilter()) + act(() => result.current.setQuery('v')) + expect(result.current.filter(branches, b => b.name).map(b => b.name)).toEqual(['develop']) + expect(result.current.filter(tags, t => t.name).map(t => t.name)).toEqual(['v1.0', 'v2.0']) + }) + + it('count returns per-dataset match counts (for tab badges)', () => { + const {result} = renderHook(() => useFilter()) + act(() => result.current.setQuery('e')) + expect(result.current.count(branches, b => b.name)).toBe(2) // develop, feature/tabs + expect(result.current.count(tags, t => t.name)).toBe(0) + }) + + it('supports a controlled query', () => { + const {result, rerender} = renderHook(({q}) => useFilter({query: q}), {initialProps: {q: 'ma'}}) + expect(result.current.matches('main')).toBe(true) + rerender({q: 'zzz'}) + expect(result.current.matches('main')).toBe(false) + }) +}) diff --git a/packages/react/src/hooks/experimental/__tests__/useSelectionState.test.tsx b/packages/react/src/hooks/experimental/__tests__/useSelectionState.test.tsx new file mode 100644 index 00000000000..3cecbe777d5 --- /dev/null +++ b/packages/react/src/hooks/experimental/__tests__/useSelectionState.test.tsx @@ -0,0 +1,62 @@ +import {renderHook, act} from '@testing-library/react' +import {describe, expect, it, vi} from 'vitest' +import {useSelectionState} from '../useSelectionState' + +describe('useSelectionState', () => { + it('single-select replaces the previous selection', () => { + const {result} = renderHook(() => useSelectionState({selectionVariant: 'single'})) + + act(() => result.current.toggle('a')) + expect([...result.current.selectedKeys]).toEqual(['a']) + + act(() => result.current.toggle('b')) + expect([...result.current.selectedKeys]).toEqual(['b']) + }) + + it('single-select toggles off when re-selected', () => { + const {result} = renderHook(() => useSelectionState({selectionVariant: 'single'})) + act(() => result.current.toggle('a')) + act(() => result.current.toggle('a')) + expect(result.current.selectedKeys.size).toBe(0) + }) + + it('multi-select accumulates and removes keys', () => { + const {result} = renderHook(() => useSelectionState({selectionVariant: 'multiple'})) + act(() => result.current.toggle('a')) + act(() => result.current.toggle('b')) + expect([...result.current.selectedKeys].sort()).toEqual(['a', 'b']) + + act(() => result.current.toggle('a')) + expect([...result.current.selectedKeys]).toEqual(['b']) + }) + + it('isSelected reflects membership; clear empties', () => { + const {result} = renderHook(() => useSelectionState({selectionVariant: 'multiple', defaultSelectedKeys: ['x']})) + expect(result.current.isSelected('x')).toBe(true) + act(() => result.current.clear()) + expect(result.current.selectedKeys.size).toBe(0) + }) + + it('fires onSelectionChange', () => { + const onSelectionChange = vi.fn() + const {result} = renderHook(() => useSelectionState({onSelectionChange})) + act(() => result.current.toggle('a')) + expect(onSelectionChange).toHaveBeenCalledWith(new Set(['a'])) + }) + + it('supports a controlled selection', () => { + const onSelectionChange = vi.fn() + const {result, rerender} = renderHook(({keys}) => useSelectionState({selectedKeys: keys, onSelectionChange}), { + initialProps: {keys: ['a'] as string[]}, + }) + expect(result.current.isSelected('a')).toBe(true) + + // toggling does not mutate internal state when controlled, but notifies + act(() => result.current.toggle('b')) + expect(onSelectionChange).toHaveBeenCalled() + expect(result.current.isSelected('b')).toBe(false) + + rerender({keys: ['a', 'b']}) + expect(result.current.isSelected('b')).toBe(true) + }) +}) diff --git a/packages/react/src/hooks/experimental/index.ts b/packages/react/src/hooks/experimental/index.ts new file mode 100644 index 00000000000..ae253a0f825 --- /dev/null +++ b/packages/react/src/hooks/experimental/index.ts @@ -0,0 +1,14 @@ +export {useSelectionState} from './useSelectionState' +export type {UseSelectionStateOptions, UseSelectionStateReturn, SelectionVariant} from './useSelectionState' + +export {useFilter} from './useFilter' +export type {UseFilterOptions, UseFilterReturn} from './useFilter' + +export {useAsyncList} from './useAsyncList' +export type { + UseAsyncListOptions, + UseAsyncListReturn, + AsyncListLoadParams, + AsyncListLoadResult, + AsyncListLoadingState, +} from './useAsyncList' diff --git a/packages/react/src/hooks/experimental/useAsyncList.ts b/packages/react/src/hooks/experimental/useAsyncList.ts new file mode 100644 index 00000000000..feb4f192a8c --- /dev/null +++ b/packages/react/src/hooks/experimental/useAsyncList.ts @@ -0,0 +1,106 @@ +import {useCallback, useEffect, useRef, useState} from 'react' + +export type AsyncListLoadingState = 'idle' | 'loading' | 'loadingMore' | 'error' + +export interface AsyncListLoadParams { + /** The current filter text driving the load. */ + filterText: string + /** Cursor returned by the previous load, when paginating. */ + cursor?: string + /** Abort signal — cancelled when a newer load supersedes this one. */ + signal: AbortSignal +} + +export interface AsyncListLoadResult { + items: T[] + /** Cursor to pass to the next `loadMore` call. Omit when there are no more pages. */ + cursor?: string +} + +export interface UseAsyncListOptions { + /** Loader invoked on mount, when `filterText` changes, and when paginating. */ + load: (params: AsyncListLoadParams) => Promise> + + /** Current filter text. Changing it triggers a fresh (non-paginated) load. */ + filterText?: string +} + +export interface UseAsyncListReturn { + items: T[] + loadingState: AsyncListLoadingState + error: unknown + /** Whether another page is available (a cursor was returned). */ + hasMore: boolean + /** Load the next page, appending to `items`. */ + loadMore: () => void + /** Re-run the initial load, replacing `items`. */ + reload: () => void +} + +/** + * A thin async-list capability with cursor pagination and request cancellation. + * + * Each tab in a SelectPanel can own its own `useAsyncList`, so switching tabs or + * typing in the shared search input fetches that tab's data independently. Stale + * responses are discarded via `AbortSignal`, so the most-recent load always wins. + */ +export function useAsyncList(options: UseAsyncListOptions): UseAsyncListReturn { + const {load, filterText = ''} = options + + const [items, setItems] = useState([]) + const [loadingState, setLoadingState] = useState('idle') + const [error, setError] = useState(null) + const [cursor, setCursor] = useState(undefined) + + const loadRef = useRef(load) + useEffect(() => { + loadRef.current = load + }, [load]) + const abortRef = useRef(null) + + const run = useCallback( + async (mode: 'replace' | 'append', currentCursor: string | undefined, currentFilter: string) => { + abortRef.current?.abort() + const controller = new AbortController() + abortRef.current = controller + + setLoadingState(mode === 'append' ? 'loadingMore' : 'loading') + setError(null) + + try { + const result = await loadRef.current({ + filterText: currentFilter, + cursor: currentCursor, + signal: controller.signal, + }) + if (controller.signal.aborted) return + + setItems(prev => (mode === 'append' ? [...prev, ...result.items] : result.items)) + setCursor(result.cursor) + setLoadingState('idle') + } catch (err) { + if (controller.signal.aborted || (err instanceof Error && err.name === 'AbortError')) return + setError(err) + setLoadingState('error') + } + }, + [], + ) + + // Fresh load on mount and whenever the filter text changes. + useEffect(() => { + run('replace', undefined, filterText) + return () => abortRef.current?.abort() + }, [filterText, run]) + + const loadMore = useCallback(() => { + if (cursor === undefined || loadingState === 'loading' || loadingState === 'loadingMore') return + run('append', cursor, filterText) + }, [cursor, loadingState, filterText, run]) + + const reload = useCallback(() => { + run('replace', undefined, filterText) + }, [filterText, run]) + + return {items, loadingState, error, hasMore: cursor !== undefined, loadMore, reload} +} diff --git a/packages/react/src/hooks/experimental/useFilter.ts b/packages/react/src/hooks/experimental/useFilter.ts new file mode 100644 index 00000000000..2514b5ee01a --- /dev/null +++ b/packages/react/src/hooks/experimental/useFilter.ts @@ -0,0 +1,78 @@ +import {useCallback, useMemo, useState} from 'react' + +export interface UseFilterOptions { + /** Initial query (uncontrolled). */ + defaultQuery?: string + + /** Controlled query value. */ + query?: string + + /** Called whenever the query changes. */ + onQueryChange?: (query: string) => void + + /** + * Custom predicate used to test an item's searchable text against the query. + * Defaults to a case-insensitive substring match. + */ + contains?: (itemText: string, query: string) => boolean +} + +export interface UseFilterReturn { + /** The current query string. */ + query: string + /** Update the query string. */ + setQuery: (query: string) => void + /** Test whether a single piece of text matches the current query. */ + matches: (itemText: string) => boolean + /** + * Filter an arbitrary dataset by deriving searchable text from each item. + * The same hook instance (one shared query) can filter many datasets — this + * is what lets one search input drive several tabs. + */ + filter: (items: readonly T[], getText: (item: T) => string) => T[] + /** Count of items in a dataset that match the current query (for tab badges). */ + count: (items: readonly T[], getText: (item: T) => string) => number +} + +const defaultContains = (itemText: string, query: string) => itemText.toLowerCase().includes(query.toLowerCase()) + +/** + * A single, shareable text filter. The query is decoupled from any particular + * dataset, so one `useFilter` instance can filter several arrays — e.g. one + * search input filtering both a Branches list and a Tags list, and producing + * per-tab match counts without re-implementing the matching logic. + */ +export function useFilter(options: UseFilterOptions = {}): UseFilterReturn { + const {defaultQuery = '', query: controlledQuery, onQueryChange, contains = defaultContains} = options + + const [internalQuery, setInternalQuery] = useState(defaultQuery) + const isControlled = controlledQuery !== undefined + const query = isControlled ? controlledQuery : internalQuery + + const setQuery = useCallback( + (next: string) => { + if (!isControlled) { + setInternalQuery(next) + } + onQueryChange?.(next) + }, + [isControlled, onQueryChange], + ) + + const matches = useCallback( + (itemText: string) => query.trim() === '' || contains(itemText, query.trim()), + [query, contains], + ) + + const filter = useCallback( + (items: readonly T[], getText: (item: T) => string): T[] => items.filter(item => matches(getText(item))), + [matches], + ) + + const count = useCallback( + (items: readonly T[], getText: (item: T) => string): number => filter(items, getText).length, + [filter], + ) + + return useMemo(() => ({query, setQuery, matches, filter, count}), [query, setQuery, matches, filter, count]) +} diff --git a/packages/react/src/hooks/experimental/useSelectionState.ts b/packages/react/src/hooks/experimental/useSelectionState.ts new file mode 100644 index 00000000000..a96ebe9177b --- /dev/null +++ b/packages/react/src/hooks/experimental/useSelectionState.ts @@ -0,0 +1,104 @@ +import {useCallback, useMemo, useState} from 'react' + +/** + * The selection behaviour of a list. + * + * - `single` — at most one item selected at a time. Selecting a new item + * replaces the previous selection. + * - `multiple` — any number of items selected. Selecting toggles membership. + */ +export type SelectionVariant = 'single' | 'multiple' + +export interface UseSelectionStateOptions { + /** @default 'single' */ + selectionVariant?: SelectionVariant + + /** Initial selected keys (uncontrolled). */ + defaultSelectedKeys?: Iterable + + /** Controlled selected keys. When provided, the hook does not own the state. */ + selectedKeys?: Iterable + + /** Called whenever the selection changes. */ + onSelectionChange?: (keys: Set) => void +} + +export interface UseSelectionStateReturn { + /** The currently-selected keys. */ + selectedKeys: Set + /** Whether a given key is selected. */ + isSelected: (key: string) => boolean + /** Toggle a key according to the selection variant. */ + toggle: (key: string) => void + /** Replace the entire selection. */ + setSelection: (keys: Iterable) => void + /** Clear all selected keys. */ + clear: () => void +} + +/** + * Decoupled selection state for a list of items, owned by the consumer rather + * than the component that renders the list. + * + * Because the state lives outside any component tree, the **same** selection can + * be shared across multiple lists — e.g. two tabs (Branches / Tags) in a single + * SelectPanel that contribute to one selection. Keys are opaque strings. + */ +export function useSelectionState(options: UseSelectionStateOptions = {}): UseSelectionStateReturn { + const {selectionVariant = 'single', defaultSelectedKeys, selectedKeys: controlledKeys, onSelectionChange} = options + + const [internalKeys, setInternalKeys] = useState>(() => new Set(defaultSelectedKeys ?? [])) + + const isControlled = controlledKeys !== undefined + const selectedKeys = useMemo( + () => (isControlled ? new Set(controlledKeys) : internalKeys), + [isControlled, controlledKeys, internalKeys], + ) + + const commit = useCallback( + (next: Set) => { + if (!isControlled) { + setInternalKeys(next) + } + onSelectionChange?.(next) + }, + [isControlled, onSelectionChange], + ) + + const isSelected = useCallback((key: string) => selectedKeys.has(key), [selectedKeys]) + + const toggle = useCallback( + (key: string) => { + const next = new Set(selectedKeys) + if (selectionVariant === 'single') { + if (next.has(key)) { + next.clear() + } else { + next.clear() + next.add(key) + } + } else { + if (next.has(key)) { + next.delete(key) + } else { + next.add(key) + } + } + commit(next) + }, + [selectedKeys, selectionVariant, commit], + ) + + const setSelection = useCallback( + (keys: Iterable) => { + commit(new Set(keys)) + }, + [commit], + ) + + const clear = useCallback(() => { + commit(new Set()) + }, [commit]) + + return {selectedKeys, isSelected, toggle, setSelection, clear} +} From f3571af354d5b3dec4d846e4b97dcb2b43ad1e39 Mon Sep 17 00:00:00 2001 From: Josh Farrant Date: Wed, 10 Jun 2026 17:15:50 +0100 Subject: [PATCH 2/4] Remove tab ownership from modular SelectPanel SelectPanel no longer depends on the Tabs primitive. Remove the TabList/Tab/Panel parts (L2), the Panel foundation component (L1), and getPanelProps from useSelectPanel, along with their now-unused type exports. A tabbed picker is now a consumer composition of SelectPanel parts + the generic Tabs primitive. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../experimental/SelectPanel/SelectPanel.tsx | 70 +------------------ .../src/experimental/SelectPanel/index.ts | 7 +- .../experimental/SelectPanel/SelectPanel.tsx | 20 +----- .../experimental/SelectPanel/index.ts | 2 +- .../SelectPanel/useSelectPanel.ts | 20 +----- 5 files changed, 6 insertions(+), 113 deletions(-) diff --git a/packages/react/src/experimental/SelectPanel/SelectPanel.tsx b/packages/react/src/experimental/SelectPanel/SelectPanel.tsx index 68447129a14..ad3e5a1646d 100644 --- a/packages/react/src/experimental/SelectPanel/SelectPanel.tsx +++ b/packages/react/src/experimental/SelectPanel/SelectPanel.tsx @@ -7,7 +7,6 @@ import { type UseSelectPanelOptions, type UseSelectPanelReturn, } from '../../foundations/experimental/SelectPanel' -import {useTab, useTabList, useTabPanel} from '../Tabs' import {useAnchoredPosition} from '../../hooks/useAnchoredPosition' import {Button} from '../../Button' import TextInput from '../../TextInput' @@ -192,65 +191,6 @@ function Input({className, onKeyDown, ...props}: SelectPanelInputProps) { } Input.displayName = 'SelectPanel.Input' -// --- SelectPanel.TabList (reuses the Tabs primitive) --- - -interface SelectPanelTabListProps extends React.HTMLAttributes { - 'aria-label': string -} - -function TabList({className, children, ...props}: SelectPanelTabListProps) { - const {tabListProps} = useTabList(props) - return ( - // @ts-expect-error Tabs primitive expects a non-nullable ref -
    - {children} -
    - ) -} -TabList.displayName = 'SelectPanel.TabList' - -// --- SelectPanel.Tab (reuses the Tabs primitive) --- - -interface SelectPanelTabProps { - value: string - disabled?: boolean - count?: number - children: React.ReactNode - className?: string -} - -function Tab({value, disabled, count, children, className}: SelectPanelTabProps) { - const {tabProps} = useTab({value, disabled}) - return ( - - ) -} -Tab.displayName = 'SelectPanel.Tab' - -// --- SelectPanel.Panel (single dynamic tab panel hosting the active list) --- - -interface SelectPanelPanelProps extends React.ComponentProps<'div'> { - /** The active tab value. The panel re-labels itself as the active tab changes. */ - value: string -} - -function Panel({value, className, children, ...props}: SelectPanelPanelProps) { - const {tabPanelProps} = useTabPanel({value}) - return ( -
    - {children} -
    - ) -} -Panel.displayName = 'SelectPanel.Panel' - // --- SelectPanel.List --- function List({className, ...props}: React.ComponentProps<'ul'>) { @@ -303,18 +243,10 @@ export const SelectPanelParts = Object.assign(Root, { Header, Title, Input, - TabList, - Tab, - Panel, List, Option, Empty, Footer, }) -export type { - SelectPanelRootProps, - SelectPanelOverlayProps, - SelectPanelTabProps, - SelectPanelPanelProps as SelectPanelPanelPartProps, -} +export type {SelectPanelRootProps, SelectPanelOverlayProps} diff --git a/packages/react/src/experimental/SelectPanel/index.ts b/packages/react/src/experimental/SelectPanel/index.ts index 280307cc7a2..ed94b4c5517 100644 --- a/packages/react/src/experimental/SelectPanel/index.ts +++ b/packages/react/src/experimental/SelectPanel/index.ts @@ -1,9 +1,4 @@ export {SelectPanelParts} from './SelectPanel' -export type { - SelectPanelRootProps, - SelectPanelOverlayProps, - SelectPanelTabProps, - SelectPanelPanelPartProps, -} from './SelectPanel' +export type {SelectPanelRootProps, SelectPanelOverlayProps} from './SelectPanel' export {SelectPanel} from './ReadyMadeSelectPanel' export type {SelectPanelProps, SelectPanelItem} from './ReadyMadeSelectPanel' diff --git a/packages/react/src/foundations/experimental/SelectPanel/SelectPanel.tsx b/packages/react/src/foundations/experimental/SelectPanel/SelectPanel.tsx index ac82ea34c01..e95fefe515e 100644 --- a/packages/react/src/foundations/experimental/SelectPanel/SelectPanel.tsx +++ b/packages/react/src/foundations/experimental/SelectPanel/SelectPanel.tsx @@ -100,23 +100,6 @@ function Input({className, onKeyDown, ...props}: React.ComponentProps<'input'>) ) } -// --- Panel (single dynamic tab panel hosting the active list) --- - -interface PanelProps extends React.ComponentProps<'div'> { - /** The id of the active tab this panel is labelled by. */ - tabId: string -} - -function Panel({tabId, children, className, ...props}: PanelProps) { - const {foundation} = useSelectPanelFoundationContext() - const panelProps = foundation.getPanelProps(tabId) - return ( -
    - {children} -
    - ) -} - // --- List --- interface ListProps extends React.ComponentProps<'ul'> { @@ -155,9 +138,8 @@ export const SelectPanel = Object.assign(Root, { Overlay, Title, Input, - Panel, List, Option, }) -export type {RootProps as SelectPanelRootProps, PanelProps as SelectPanelPanelProps} +export type {RootProps as SelectPanelRootProps} diff --git a/packages/react/src/foundations/experimental/SelectPanel/index.ts b/packages/react/src/foundations/experimental/SelectPanel/index.ts index 9b38e700f6a..de7a015a775 100644 --- a/packages/react/src/foundations/experimental/SelectPanel/index.ts +++ b/packages/react/src/foundations/experimental/SelectPanel/index.ts @@ -1,4 +1,4 @@ export {useSelectPanel} from './useSelectPanel' export type {UseSelectPanelOptions, UseSelectPanelReturn, SelectPanelGesture, OptionDescriptor} from './useSelectPanel' export {SelectPanel} from './SelectPanel' -export type {SelectPanelRootProps, SelectPanelPanelProps} from './SelectPanel' +export type {SelectPanelRootProps} from './SelectPanel' diff --git a/packages/react/src/foundations/experimental/SelectPanel/useSelectPanel.ts b/packages/react/src/foundations/experimental/SelectPanel/useSelectPanel.ts index bd2d67f8fd9..922991694d5 100644 --- a/packages/react/src/foundations/experimental/SelectPanel/useSelectPanel.ts +++ b/packages/react/src/foundations/experimental/SelectPanel/useSelectPanel.ts @@ -63,11 +63,6 @@ interface ListProps { 'aria-multiselectable'?: boolean } -interface PanelProps { - role: 'tabpanel' - 'aria-labelledby': string -} - export interface OptionDescriptor { /** Stable id for the option. Referenced by `aria-activedescendant`. */ id: string @@ -95,12 +90,10 @@ export interface UseSelectPanelReturn { /** Props for the shared search input (`role="combobox"`). */ getInputProps: () => InputProps /** - * Props for a listbox region (`role="listbox"`). With tabs, exactly one list - * is rendered (the active tab's), following the single-dynamic-panel model. + * Props for a listbox region (`role="listbox"`). When composed with tabs, + * exactly one list is rendered (the active tab's). */ getListProps: (opts?: {multiselectable?: boolean}) => ListProps - /** Props for the single tab panel that hosts the active list. */ - getPanelProps: (activeTabId: string) => PanelProps /** Props for an option within the listbox. */ getOptionProps: (option: OptionDescriptor) => OptionProps /** Whether the panel is currently open. */ @@ -300,14 +293,6 @@ export function useSelectPanel(options: UseSelectPanelOptions): UseSelectPanelRe [listId], ) - const getPanelProps = useCallback( - (activeTabId: string): PanelProps => ({ - role: 'tabpanel', - 'aria-labelledby': activeTabId, - }), - [], - ) - const getOptionProps = useCallback( (option: OptionDescriptor): OptionProps => { const props: OptionProps = { @@ -328,7 +313,6 @@ export function useSelectPanel(options: UseSelectPanelOptions): UseSelectPanelRe getTitleProps, getInputProps, getListProps, - getPanelProps, getOptionProps, isOpen: open, open: requestOpen, From f3e2819233fa909a324a737d9cff569f6c2c09e1 Mon Sep 17 00:00:00 2001 From: Josh Farrant Date: Wed, 10 Jun 2026 17:25:53 +0100 Subject: [PATCH 3/4] Compose tabs via Tabs primitive in SelectPanel recipe + spec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rewrite the headline Parts story to build the Branches/Tags tabbed picker by composing SelectPanel parts with the generic Tabs primitive via local RefTabList/RefTab/RefTabPanel wrappers (Tabs exports only hooks). The tabpanel is non-focusable, the list is input-driven via aria-activedescendant, tabs are a roving-tabindex zone, and switching a tab returns focus to the input — no keyboard dead-end. Update the SelectPanel tests to compose with Tabs and drop the removed Panel assertions, and rewrite the spec around the compose-don't-depend model. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../SelectPanel/SelectPanel.spec.md | 142 ++++++++++----- .../SelectPanel/SelectPanelParts.stories.tsx | 168 +++++++++++++++--- .../__tests__/SelectPanel.parts.test.tsx | 53 ++++-- .../__tests__/SelectPanel.test.tsx | 12 +- .../src/foundations/experimental/index.ts | 1 - 5 files changed, 278 insertions(+), 98 deletions(-) diff --git a/packages/react/src/experimental/SelectPanel/SelectPanel.spec.md b/packages/react/src/experimental/SelectPanel/SelectPanel.spec.md index 96ea594f8ca..7443e06a92d 100644 --- a/packages/react/src/experimental/SelectPanel/SelectPanel.spec.md +++ b/packages/react/src/experimental/SelectPanel/SelectPanel.spec.md @@ -4,10 +4,20 @@ > **Scenario:** Decompose SelectPanel so a consumer can **add tabs** (Branches / Tags > picker) by composition instead of forking. Feeds ADR-024 (Design System Spectrum). > **Reference implementation:** the Dialog 4-layer stack on this branch. +> +> **Note:** this branch is a **hand-refined canonical example** of the intended +> implementation. It is the reference for "what good looks like" and may differ from +> a one-shot agent output. ## Overview -This document defines a **modular, tab-capable** SelectPanel across the four layers of the +SelectPanel **does not own tabs**. A tabbed picker is a **consumer composition** of +SelectPanel's parts (overlay / search / list / options) + the generic experimental +`Tabs` primitive. This is the "compose, don't depend" rule: SelectPanel provides the +listbox-in-a-dialog machinery and the consumer brings `Tabs` — SelectPanel never +imports or re-exports tab components. + +This document defines a **modular** SelectPanel across the four layers of the [modular component architecture](https://github.com/github/primer/issues/6546): | Layer | Name | What it provides | @@ -17,10 +27,11 @@ This document defines a **modular, tab-capable** SelectPanel across the four lay | 2 | **Parts** | Primer-styled compositional components (`SelectPanelParts.*`) | | 3 | **Ready-made** | Props-based API for the simple **no-tabs** case | -The leverage for tabs is **L1 + L0**, not L2. The new value is a standalone, placeable -listbox you can put inside a tab panel, plus decoupled hooks the consumer owns (selection -shareable across tabs, one filter across datasets, per-tab counts, async lists). The styled -Parts (L2) are a thin wrapper over that foundation. +The leverage for tabs is **L1 + L0**, not a `tabs` feature on SelectPanel. The new value is +a standalone, placeable listbox you can put inside a tab panel, plus decoupled hooks the +consumer owns (selection shareable across tabs, one filter across datasets, per-tab counts, +async lists). The consumer wires the tab strip with the generic `Tabs` primitive. The styled +Parts (L2) are a thin wrapper over that foundation, and they too contain **no tab code**. The canonical use case — a Branches / Tags picker where tabs switch which filterable list is shown inside **one** panel with **one** shared search input — is impossible with today's @@ -46,7 +57,7 @@ SelectPanel instead uses: | ----------------------- | ---------- | ----------------------------------------------------------------------------- | | Popup container | `dialog` | Outer shell. **Not** a listbox. | | Search input | `combobox` | `aria-expanded`, `aria-controls` → active listbox, `aria-autocomplete="list"` | -| Tab strip | `tablist` | Reuses the `Tabs` primitive | +| Tab strip | `tablist` | Consumer-composed with the `Tabs` primitive | | Each tab | `tab` | `aria-selected`, `aria-controls` → panel | | The (single) panel | `tabpanel` | `aria-labelledby` tracks the **active** tab | | The list (in the panel) | `listbox` | Scoped **inside** the active tab panel | @@ -131,7 +142,6 @@ Returns prop-getters: | `getTitleProps()` | title | `id` (→ dialog `aria-labelledby`) | | `getInputProps()` | search | `role="combobox"`, `aria-expanded`, `aria-controls`, `aria-autocomplete`, `aria-activedescendant`, keyboard nav | | `getListProps()` | list | `role="listbox"`, `id`, optional `aria-multiselectable` | -| `getPanelProps(tabId)` | panel | `role="tabpanel"`, `aria-labelledby` (the active tab) | | `getOptionProps()` | option | `role="option"`, `aria-selected`, `aria-disabled`, active marker | Also: `isOpen`, `open()`, `close(gesture)`, `activeDescendantId`. @@ -142,17 +152,17 @@ previously-focused element), combobox keyboard navigation over options (ArrowUp/Down/Enter, wrapping, scroll-into-view, `aria-activedescendant`), and a dev-mode warning when no accessible name is provided. -**Tab strip:** the foundation deliberately **does not re-implement tabs**. The tab strip -reuses the existing `Tabs` primitive (`useTab` / `useTabList` / `useTabPanel`), and the -foundation provides only the single dynamic `Panel` region (`getPanelProps`) that hosts the -listbox. This honours the "reuse, don't rebuild" rule. +**No tabs.** The foundation owns **no tab code** — there is no `getPanelProps`, no `Panel` +component, and no dependency on the `Tabs` primitive. A tabbed picker is composed by the +consumer: they wrap the listbox in a `tabpanel` and render a tab strip using the generic +`Tabs` primitive (`useTab` / `useTabList` / `useTabPanel`). See "Composing tabs" below. ### Unstyled components -`SelectPanel.Root / .Anchor / .Overlay / .Title / .Input / .Panel / .List / .Option` — wrap +`SelectPanel.Root / .Anchor / .Overlay / .Title / .Input / .List / .Option` — wrap the hook, wire ARIA via context, add no visual styling (foundation CSS reset only). `Overlay` -renders only while open. Consumers bring their own markup for the tab strip via the `Tabs` -primitive. +renders only while open. Consumers bring their own markup for the tab strip and tab panel via +the `Tabs` primitive. --- @@ -167,12 +177,8 @@ SelectPanelParts (= Root) │ ├── .Header │ │ ├── .Title │ │ └── .Input — Primer TextInput (role=combobox), shared search -│ ├── (the Tabs primitive provides tab state/roving) -│ │ ├── .TabList — reuses useTabList -│ │ │ └── .Tab — reuses useTab; renders a per-tab count badge -│ │ └── .Panel — single dynamic tabpanel (reuses useTabPanel) -│ │ └── .List -│ │ └── .Option +│ ├── .List — placeable listbox (drop it directly, or inside a tabpanel) +│ │ └── .Option │ ├── .Empty │ └── .Footer ``` @@ -181,15 +187,22 @@ SelectPanelParts (= Root) Primer's `TextInput`, which carries its own `data-component`; its `role="combobox"` is the stable selector). - CSS Modules + Primer design tokens; `:where()` for variant selectors. -- `TabList` / `Tab` / `Panel` reuse the `Tabs` primitive hooks rather than re-implementing tab - ARIA or roving focus. +- **No tab parts.** `SelectPanelParts` ships **no** `TabList` / `Tab` / `Panel` components and + does not import `Tabs`. A tabbed picker is composed by the consumer (see below). - **Anchored positioning:** the `Overlay` positions against the trigger via Primer's `useAnchoredPosition` (`outside-bottom` / `start` by default, configurable with `side`/`align` props). It flips when there's no room and re-positions on scroll/resize. The `Root` is the positioned parent (the overlay is `position: absolute` with computed `top`/`left`), so no portal is required. -### Adding tabs (the headline) +### Composing tabs (the headline) — compose, don't depend + +A Branches / Tags tabbed picker is built by composing SelectPanel parts with the generic +`Tabs` primitive. Because `Tabs` exports only the `useTab` / `useTabList` / `useTabPanel` +hooks, the consumer defines small local wrappers (`RefTabList` / `RefTab` / `RefTabPanel`) that +can reuse SelectPanel's `.TabList` / `.Tab` / `.TabCount` CSS. (Ideally the `Tabs` primitive +would export `Tab` / `TabList` / `TabPanel` convenience components — see Open Questions — which +would remove this glue.) ```tsx @@ -197,18 +210,26 @@ SelectPanelParts (= Root) Switch branches/tags + {/* one shared search input */} filter.setQuery(e.target.value)} /> - setActiveTab(value)}> - - + { + setActiveTab(value) + inputRef.current?.focus() // return focus to the input — no dead-end + }} + > + + Branches - - + + Tags - - - + + + {/* NON-focusable tabpanel: do NOT set tabIndex={0} */} + {activeItems.map(i => ( ))} - + ``` +### The accessible composition pattern + +The composition above is accessible **by construction**, with no keyboard dead-end: + +- **Non-focusable tabpanel.** The `tabpanel` is **not** in the tab order — `useTabPanel` + imposes no `tabIndex`, and the consumer must **not** add `tabIndex={0}`. The listbox inside + is never reached by Tab, so the panel is never a focus trap. +- **Input-driven list.** The list is driven by the input's `aria-activedescendant`. Focus + stays on the `combobox` input; ArrowUp/Down/Enter move and activate the active option (the + existing `useSelectPanel` input keyboard handling), so the list needs no tab stop. +- **Roving-tabindex tabs.** The tab strip is a roving-tabindex zone from `useTabList` / `useTab` + (ArrowLeft/Right + Home/End move between tabs; only the selected tab is tabbable). +- **Focus returns to the input on tab switch.** `onValueChange` calls `inputRef.current?.focus()` + (or a DOM query for `[role="combobox"]`) so that after switching tabs the user is back on the + input and can immediately keep arrowing through the new tab's options. +- **One shared search, per-tab counts.** A single `useFilter` query filters the active tab's + data; each tab shows its own match count. + +**No keyboard dead-end:** a user can focus the input and arrow through options, `Tab` to the +tablist and switch tabs (focus returns to the input), and never get trapped inside the panel. + One shared search (`useFilter`), one selection model (`useSelectionState`) shared across tabs, per-tab counts, a single listbox that swaps data by active tab. No fork. @@ -270,14 +312,15 @@ case. | `aria-labelledby` → title / `aria-label` | Consumer wires | ✅ Auto-wired via context | ✅ Inherited | ✅ From `title` | | Input `role="combobox"` + `aria-controls` | Consumer sets | ✅ Automatic | ✅ Inherited | ✅ Inherited | | `aria-activedescendant` (tab **or** option) | Consumer manages | ✅ Options (keyboard nav) | ✅ Inherited | ✅ Inherited | -| `tablist` / `tab` / `tabpanel` | Consumer sets | ✅ via `Tabs` primitive | ✅ Inherited | n/a (no tabs) | +| `tablist` / `tab` / `tabpanel` | Consumer sets | Consumer composes `Tabs` | Consumer composes `Tabs` | n/a (no tabs) | | `listbox` scoped to active panel | Consumer sets | ✅ Automatic | ✅ Inherited | ✅ Inherited | | `option` + `aria-selected` | Consumer sets | ✅ Automatic | ✅ Inherited | ✅ From state | | Escape closes | Consumer handles | ✅ Automatic | ✅ Inherited | ✅ Inherited | | Outside-click closes | Consumer handles | ✅ Automatic | ✅ Inherited | ✅ Inherited | | Focus returns on close | Consumer manages | ✅ Automatic | ✅ Inherited | ✅ Inherited | | Arrow-key option navigation | Consumer handles | ✅ Automatic | ✅ Inherited | ✅ Inherited | -| Tab roving focus / Home/End | Consumer handles | ✅ via `Tabs` primitive | ✅ Inherited | n/a | +| Non-focusable tabpanel / focus-return-to-input | Consumer wires | Consumer composes `Tabs` | Consumer composes `Tabs` | n/a | +| Tab roving focus / Home/End | Consumer handles | Consumer composes `Tabs` | Consumer composes `Tabs` | n/a | | Anchored positioning / visible surface | Consumer styles | ⚠️ Consumer must style | ✅ Primer tokens | ✅ Primer tokens | | Colour contrast | Consumer ensures | ⚠️ Consumer must ensure | ✅ Primer tokens | ✅ Primer tokens | @@ -300,16 +343,20 @@ case. - **DOM-based option navigation.** The foundation discovers options by `role="option"` in the active listbox rather than maintaining an option registry, mirroring the existing SelectPanel2 approach. This keeps the single-dynamic-panel model simple. -- **Active-descendant scope (stubbed for full parity — deferred future work).** - `aria-activedescendant` currently tracks options for keyboard navigation. A unified composite - store letting the input's active-descendant move seamlessly between the **tab strip** and the - **option list** (Ariakit `TabStore.composite`) is the harder, future retrofit; tab roving is - presently handled by the `Tabs` primitive's own focus model (focus physically moves to the - tabs). Implementing the composite store would mean keeping DOM focus permanently on the input - and driving tabs via `aria-activedescendant` instead of roving tabindex — i.e. **not** reusing - the Tabs primitive's focus model. This was deliberately deferred during hardening to avoid - destabilising the shipped, tested behaviour; the keyboard model today (Arrow/Enter over - options on the input; Arrow/Home/End over tabs when a tab is focused) is fully accessible. +- **Composite-focus utility (future enhancement, not required for correctness).** A shared + composite-focus utility (Ariakit [`combobox-tabs`](https://ariakit.com/examples/combobox-tabs) + style) where **one** controller drives `aria-activedescendant` across **both** the tab strip + and the option list — keeping DOM focus permanently on the input and driving tabs via + `aria-activedescendant` instead of roving tabindex — is a **future enhancement** for premium + UX. The composition documented here is already fully accessible without it: the tabpanel is + non-focusable, the list is input-driven via `aria-activedescendant`, the tabs are a + roving-tabindex zone, and focus returns to the input on tab switch — so there is no keyboard + dead-end. The composite store is therefore an enhancement, **not** a correctness requirement. +- **Tabs convenience components (recommended follow-up).** The `Tabs` primitive currently + exports only the `useTab` / `useTabList` / `useTabPanel` hooks, so the recipe defines local + `RefTabList` / `RefTab` / `RefTabPanel` wrappers. Exporting styleable `Tab` / `TabList` / + `TabPanel` convenience components from the `Tabs` primitive would remove this per-consumer + glue while keeping SelectPanel free of any tab dependency. --- @@ -320,6 +367,9 @@ case. 2. **Shared vs per-tab search** — this spike shares one query (matching RefSelector); is that always right? 3. Adopt an **Ariakit-style composite store** so the tab strip and list share one - `aria-activedescendant` model, rather than delegating tab focus to the `Tabs` primitive? -4. Graduate `useSelectionState` / `useFilter` / `useAsyncList` from experimental independently + `aria-activedescendant` model (one controller across tabs + options), rather than the + roving-tabindex tabs + input-driven list composition documented here? +4. Export `Tab` / `TabList` / `TabPanel` **convenience components** from the `Tabs` primitive so + consumers don't need local tab wrappers, without re-introducing a tab dependency in SelectPanel? +5. Graduate `useSelectionState` / `useFilter` / `useAsyncList` from experimental independently of the SelectPanel foundation? diff --git a/packages/react/src/experimental/SelectPanel/SelectPanelParts.stories.tsx b/packages/react/src/experimental/SelectPanel/SelectPanelParts.stories.tsx index eed29a44a87..ac95f526d03 100644 --- a/packages/react/src/experimental/SelectPanel/SelectPanelParts.stories.tsx +++ b/packages/react/src/experimental/SelectPanel/SelectPanelParts.stories.tsx @@ -1,19 +1,23 @@ +import type React from 'react' import {useMemo, useRef, useState} from 'react' import type {Meta, StoryObj} from '@storybook/react-vite' import {SelectPanelParts as SelectPanel} from './SelectPanel' -import {Tabs} from '../Tabs' +import {Tabs, useTab, useTabList, useTabPanel} from '../Tabs' import {Button} from '../../Button' import {useFilter, useSelectionState} from '../../hooks/experimental' import {branches, tags, type Ref} from './mock-refs' +import classes from './SelectPanel.module.css' /** * Layer 2 — Primer-styled Parts. * * A Branches / Tags tabbed picker composed entirely from Primer-styled - * `SelectPanel.*` parts + the `Tabs` primitive. This is the headline artifact: - * it proves a consumer can **add tabs** to SelectPanel by composition — one - * shared search input, per-tab counts, a single dynamic panel, and a selection - * model that persists across tabs — instead of forking the whole component. + * `SelectPanel.*` parts + the generic experimental `Tabs` primitive. This is the + * headline artifact: it proves the "compose, don't depend" thesis. SelectPanel no + * longer owns tabs — it provides the overlay/search/list/options, and the consumer + * brings `Tabs`. One shared search input, per-tab counts, a single dynamic panel, + * and a selection model that persists across tabs — instead of forking the whole + * component. */ const meta: Meta = { title: 'Experimental/SelectPanel (Modular)/Parts', @@ -31,6 +35,67 @@ export default meta const allRefs = [...branches, ...tags] const nameOf = (id: string | undefined) => allRefs.find(r => r.id === id)?.name +// --- Local Tabs convenience components --- +// +// The `Tabs` primitive exports only the `useTab`/`useTabList`/`useTabPanel` hooks, +// not ready-made `Tab`/`TabList`/`TabPanel` components, so we wrap them here. These +// small wrappers are the only "glue" the recipe needs; ideally the Tabs primitive +// would export styleable `Tab`/`TabList`/`TabPanel` convenience components (a real +// follow-up) which would remove this local boilerplate. They reuse SelectPanel's +// existing `.TabList`/`.Tab`/`.TabCount` CSS so the strip matches the panel. + +interface RefTabListProps extends React.HTMLAttributes { + 'aria-label': string +} + +function RefTabList({className, children, ...props}: RefTabListProps) { + const {tabListProps} = useTabList(props) + return ( + // @ts-expect-error Tabs primitive expects a non-nullable ref +
    + {children} +
    + ) +} + +interface RefTabProps { + value: string + count?: number + disabled?: boolean + children: React.ReactNode +} + +function RefTab({value, count, disabled, children}: RefTabProps) { + const {tabProps} = useTab({value, disabled}) + return ( + + ) +} + +interface RefTabPanelProps { + value: string + children: React.ReactNode +} + +// Single dynamic tab panel. Deliberately NON-focusable: we do not set tabIndex +// (useTabPanel imposes none), so the list inside is reached via the input's +// aria-activedescendant rather than the panel being a keyboard dead-end. +function RefTabPanel({value, children}: RefTabPanelProps) { + const {tabPanelProps} = useTabPanel({value}) + return ( +
    + {children} +
    + ) +} + function RefOptions({items, selection}: {items: Ref[]; selection: ReturnType}) { if (items.length === 0) return No matches return ( @@ -50,9 +115,17 @@ function RefOptions({items, selection}: {items: Ref[]; selection: ReturnType(null) + const rootRef = useRef(null) + + // Return focus to the shared search input so the user never gets trapped in + // the (non-focusable) panel after switching tabs. + const focusInput = () => { + rootRef.current?.querySelector('[role="combobox"]')?.focus() + } // One shared search query and one selection model, both owned by the consumer // and shared across the two tabs. @@ -73,7 +153,13 @@ export const TabbedBranchesAndTags: StoryObj = { const selectedName = useMemo(() => nameOf([...selection.selectedKeys][0]) ?? 'main', [selection.selectedKeys]) return ( - + Switch ref: {selectedName} @@ -88,19 +174,25 @@ export const TabbedBranchesAndTags: StoryObj = { /> - setActiveTab(value)}> - - + { + setActiveTab(value) + focusInput() + }} + > + + Branches - - + + Tags - - + + - + - + @@ -117,7 +209,8 @@ export const TabbedBranchesAndTags: StoryObj = { /** * Multi-select — makes "one selection model across tabs" obvious: pick branches * **and** tags, switch tabs, and every choice stays checked. The footer counts - * the combined selection drawn from both tabs. + * the combined selection drawn from both tabs. Composed the same way: SelectPanel + * parts + the `Tabs` primitive, with `selectionVariant="multiple"`. */ export const MultiSelectAcrossTabs: StoryObj = { name: 'Selection persists across tabs (multi-select)', @@ -125,6 +218,11 @@ export const MultiSelectAcrossTabs: StoryObj = { const [open, setOpen] = useState(false) const [activeTab, setActiveTab] = useState('branches') const anchorRef = useRef(null) + const rootRef = useRef(null) + + const focusInput = () => { + rootRef.current?.querySelector('[role="combobox"]')?.focus() + } const filter = useFilter() const selection = useSelectionState({selectionVariant: 'multiple'}) @@ -139,7 +237,13 @@ export const MultiSelectAcrossTabs: StoryObj = { ) return ( - + Refs selected: {selection.selectedKeys.size} @@ -154,19 +258,25 @@ export const MultiSelectAcrossTabs: StoryObj = { /> - setActiveTab(value)}> - - + { + setActiveTab(value) + focusInput() + }} + > + + Branches - - + + Tags - - + + - + - + diff --git a/packages/react/src/experimental/SelectPanel/__tests__/SelectPanel.parts.test.tsx b/packages/react/src/experimental/SelectPanel/__tests__/SelectPanel.parts.test.tsx index 1d88724556e..69f616abe84 100644 --- a/packages/react/src/experimental/SelectPanel/__tests__/SelectPanel.parts.test.tsx +++ b/packages/react/src/experimental/SelectPanel/__tests__/SelectPanel.parts.test.tsx @@ -2,9 +2,37 @@ import {useState, type ComponentType} from 'react' import {render, fireEvent, screen, within} from '@testing-library/react' import {describe, expect, it} from 'vitest' import {SelectPanelParts as SelectPanel} from '../SelectPanel' -import {Tabs} from '../../Tabs' +import {Tabs, useTab, useTabList, useTabPanel} from '../../Tabs' import {MultiSelectAcrossTabs} from '../SelectPanelParts.stories' +// Local Tabs convenience wrappers, mirroring the recipe story: the Tabs primitive +// exports only hooks, so a tabbed picker is composed from SelectPanel parts + these +// thin wrappers. SelectPanel itself no longer owns tabs. +function RefTabList({children, ...props}: {'aria-label': string; children: React.ReactNode}) { + const {tabListProps} = useTabList(props) + return ( + // @ts-expect-error Tabs primitive expects a non-nullable ref +
    + {children} +
    + ) +} + +function RefTab({value, count, children}: {value: string; count?: number; children: React.ReactNode}) { + const {tabProps} = useTab({value}) + return ( + + ) +} + +function RefTabPanel({value, children}: {value: string; children: React.ReactNode}) { + const {tabPanelProps} = useTabPanel({value}) + return
    {children}
    +} + function Example() { const [open, setOpen] = useState(false) const [active, setActive] = useState('branches') @@ -18,16 +46,16 @@ function Example() { Switch ref - setActive(value)}> - - + setActive(value)}> + + Branches - - + + Tags - - - + + + {items.map(name => ( @@ -35,7 +63,7 @@ function Example() { ))} - +
    @@ -53,9 +81,6 @@ describe('SelectPanel Parts', () => { 'SelectPanel.Overlay', 'SelectPanel.Header', 'SelectPanel.Title', - 'SelectPanel.TabList', - 'SelectPanel.Tab', - 'SelectPanel.Panel', 'SelectPanel.List', 'SelectPanel.Option', ]) { @@ -65,7 +90,7 @@ describe('SelectPanel Parts', () => { expect(screen.getByRole('combobox')).toBeInTheDocument() }) - it('uses role=dialog popup with a tablist and listbox scoped inside it', () => { + it('composes the Tabs primitive: a tablist and listbox scoped inside the role=dialog popup', () => { render() fireEvent.click(screen.getByRole('button', {name: 'Open'})) diff --git a/packages/react/src/foundations/experimental/SelectPanel/__tests__/SelectPanel.test.tsx b/packages/react/src/foundations/experimental/SelectPanel/__tests__/SelectPanel.test.tsx index 71d060d647f..bda4c5a2962 100644 --- a/packages/react/src/foundations/experimental/SelectPanel/__tests__/SelectPanel.test.tsx +++ b/packages/react/src/foundations/experimental/SelectPanel/__tests__/SelectPanel.test.tsx @@ -11,11 +11,9 @@ function Example() { Title - - - One - - + + One +
    ) @@ -29,15 +27,13 @@ describe('SelectPanel foundation components', () => { expect(screen.getByRole('dialog')).toBeInTheDocument() }) - it('wires the dialog/combobox/listbox/tabpanel structure via context', () => { + it('wires the dialog/combobox/listbox structure via context', () => { render() fireEvent.click(screen.getByRole('button', {name: 'Open'})) const dialog = screen.getByRole('dialog') expect(within(dialog).getByRole('combobox')).toBeInTheDocument() expect(within(dialog).getByRole('listbox')).toBeInTheDocument() expect(within(dialog).getByRole('option')).toBeInTheDocument() - const panel = within(dialog).getByRole('tabpanel') - expect(panel).toHaveAttribute('aria-labelledby', 'my-tab') }) it('throws when a sub-component is used outside Root', () => { diff --git a/packages/react/src/foundations/experimental/index.ts b/packages/react/src/foundations/experimental/index.ts index fc83601a730..81bbe9be145 100644 --- a/packages/react/src/foundations/experimental/index.ts +++ b/packages/react/src/foundations/experimental/index.ts @@ -5,5 +5,4 @@ export type { SelectPanelGesture, OptionDescriptor, SelectPanelRootProps, - SelectPanelPanelProps, } from './SelectPanel' From 50bc9ea6e5bc0f0818d101e6b7149e017b5c8eea Mon Sep 17 00:00:00 2001 From: Josh Farrant Date: Wed, 10 Jun 2026 17:32:47 +0100 Subject: [PATCH 4/4] Polish: forward Input ref + clean focus-return; align spec with refined layer model - SelectPanel.Input forwards its ref, so the tabbed recipe returns focus via a real inputRef instead of a DOM querySelector hack - spec.md: relabel to the refined model (useSelectPanel = L0, unstyled = L1, the generic state hooks are Utilities outside the model) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../SelectPanel/SelectPanel.spec.md | 63 +++++++++---------- .../experimental/SelectPanel/SelectPanel.tsx | 8 ++- .../SelectPanel/SelectPanelParts.stories.tsx | 30 +++------ 3 files changed, 45 insertions(+), 56 deletions(-) diff --git a/packages/react/src/experimental/SelectPanel/SelectPanel.spec.md b/packages/react/src/experimental/SelectPanel/SelectPanel.spec.md index 7443e06a92d..9bf4a54bad5 100644 --- a/packages/react/src/experimental/SelectPanel/SelectPanel.spec.md +++ b/packages/react/src/experimental/SelectPanel/SelectPanel.spec.md @@ -76,10 +76,9 @@ the tab changes. --- -## Layer 0: Hooks (consumer-owned state) +## Utilities — consumer-owned state hooks (outside the layer model) -These live in `@primer/react/hooks/experimental`. They are deliberately decoupled from any -component tree, so the **same** state can be shared across tabs. +These live in `@primer/react/hooks/experimental`. They are generic, component-agnostic behaviour hooks — **not a layer** — deliberately decoupled from any component tree, so the **same** state can be shared across tabs. ### `useSelectionState` @@ -117,11 +116,11 @@ across datasets, no async/cursor pagination. --- -## Layer 1: Foundations +## Layer 0 & 1: Foundations -`@primer/react/foundations/experimental` → `useSelectPanel` + unstyled `SelectPanel.*`. +`@primer/react/foundations/experimental` → the Layer 0 compound hook (`useSelectPanel`) + the Layer 1 unstyled `SelectPanel.*` components that wrap it. Both ship from the `/foundations` entry point. -### `useSelectPanel` +### Layer 0 — `useSelectPanel` (compound hook) ```ts const panel = useSelectPanel({ @@ -135,14 +134,14 @@ const panel = useSelectPanel({ Returns prop-getters: -| Getter | Element | Wires | -| ---------------------- | ------- | --------------------------------------------------------------------------------------------------------------- | -| `getAnchorProps()` | trigger | `aria-haspopup="dialog"`, `aria-expanded`, `aria-controls`, toggle | -| `getOverlayProps()` | popup | `role="dialog"`, `aria-labelledby`/`aria-label`, ref | -| `getTitleProps()` | title | `id` (→ dialog `aria-labelledby`) | -| `getInputProps()` | search | `role="combobox"`, `aria-expanded`, `aria-controls`, `aria-autocomplete`, `aria-activedescendant`, keyboard nav | -| `getListProps()` | list | `role="listbox"`, `id`, optional `aria-multiselectable` | -| `getOptionProps()` | option | `role="option"`, `aria-selected`, `aria-disabled`, active marker | +| Getter | Element | Wires | +| ------------------- | ------- | --------------------------------------------------------------------------------------------------------------- | +| `getAnchorProps()` | trigger | `aria-haspopup="dialog"`, `aria-expanded`, `aria-controls`, toggle | +| `getOverlayProps()` | popup | `role="dialog"`, `aria-labelledby`/`aria-label`, ref | +| `getTitleProps()` | title | `id` (→ dialog `aria-labelledby`) | +| `getInputProps()` | search | `role="combobox"`, `aria-expanded`, `aria-controls`, `aria-autocomplete`, `aria-activedescendant`, keyboard nav | +| `getListProps()` | list | `role="listbox"`, `id`, optional `aria-multiselectable` | +| `getOptionProps()` | option | `role="option"`, `aria-selected`, `aria-disabled`, active marker | Also: `isOpen`, `open()`, `close(gesture)`, `activeDescendantId`. @@ -157,7 +156,7 @@ component, and no dependency on the `Tabs` primitive. A tabbed picker is compose consumer: they wrap the listbox in a `tabpanel` and render a tab strip using the generic `Tabs` primitive (`useTab` / `useTabList` / `useTabPanel`). See "Composing tabs" below. -### Unstyled components +### Layer 1 — unstyled components (wrap the L0 hook) `SelectPanel.Root / .Anchor / .Overlay / .Title / .Input / .List / .Option` — wrap the hook, wire ARIA via context, add no visual styling (foundation CSS reset only). `Overlay` @@ -306,23 +305,23 @@ case. ### Responsibility matrix -| Requirement | L0 (Hooks) | L1 (Foundations) | L2 (Parts) | L3 (Ready-made) | -| ------------------------------------------- | ---------------- | ------------------------- | ---------------- | ---------------- | -| Popup `role="dialog"` (not listbox) | Consumer sets | ✅ Automatic | ✅ Inherited | ✅ Inherited | -| `aria-labelledby` → title / `aria-label` | Consumer wires | ✅ Auto-wired via context | ✅ Inherited | ✅ From `title` | -| Input `role="combobox"` + `aria-controls` | Consumer sets | ✅ Automatic | ✅ Inherited | ✅ Inherited | -| `aria-activedescendant` (tab **or** option) | Consumer manages | ✅ Options (keyboard nav) | ✅ Inherited | ✅ Inherited | -| `tablist` / `tab` / `tabpanel` | Consumer sets | Consumer composes `Tabs` | Consumer composes `Tabs` | n/a (no tabs) | -| `listbox` scoped to active panel | Consumer sets | ✅ Automatic | ✅ Inherited | ✅ Inherited | -| `option` + `aria-selected` | Consumer sets | ✅ Automatic | ✅ Inherited | ✅ From state | -| Escape closes | Consumer handles | ✅ Automatic | ✅ Inherited | ✅ Inherited | -| Outside-click closes | Consumer handles | ✅ Automatic | ✅ Inherited | ✅ Inherited | -| Focus returns on close | Consumer manages | ✅ Automatic | ✅ Inherited | ✅ Inherited | -| Arrow-key option navigation | Consumer handles | ✅ Automatic | ✅ Inherited | ✅ Inherited | -| Non-focusable tabpanel / focus-return-to-input | Consumer wires | Consumer composes `Tabs` | Consumer composes `Tabs` | n/a | -| Tab roving focus / Home/End | Consumer handles | Consumer composes `Tabs` | Consumer composes `Tabs` | n/a | -| Anchored positioning / visible surface | Consumer styles | ⚠️ Consumer must style | ✅ Primer tokens | ✅ Primer tokens | -| Colour contrast | Consumer ensures | ⚠️ Consumer must ensure | ✅ Primer tokens | ✅ Primer tokens | +| Requirement | L0 (Hooks) | L1 (Foundations) | L2 (Parts) | L3 (Ready-made) | +| ---------------------------------------------- | ---------------- | ------------------------- | ------------------------ | ---------------- | +| Popup `role="dialog"` (not listbox) | Consumer sets | ✅ Automatic | ✅ Inherited | ✅ Inherited | +| `aria-labelledby` → title / `aria-label` | Consumer wires | ✅ Auto-wired via context | ✅ Inherited | ✅ From `title` | +| Input `role="combobox"` + `aria-controls` | Consumer sets | ✅ Automatic | ✅ Inherited | ✅ Inherited | +| `aria-activedescendant` (tab **or** option) | Consumer manages | ✅ Options (keyboard nav) | ✅ Inherited | ✅ Inherited | +| `tablist` / `tab` / `tabpanel` | Consumer sets | Consumer composes `Tabs` | Consumer composes `Tabs` | n/a (no tabs) | +| `listbox` scoped to active panel | Consumer sets | ✅ Automatic | ✅ Inherited | ✅ Inherited | +| `option` + `aria-selected` | Consumer sets | ✅ Automatic | ✅ Inherited | ✅ From state | +| Escape closes | Consumer handles | ✅ Automatic | ✅ Inherited | ✅ Inherited | +| Outside-click closes | Consumer handles | ✅ Automatic | ✅ Inherited | ✅ Inherited | +| Focus returns on close | Consumer manages | ✅ Automatic | ✅ Inherited | ✅ Inherited | +| Arrow-key option navigation | Consumer handles | ✅ Automatic | ✅ Inherited | ✅ Inherited | +| Non-focusable tabpanel / focus-return-to-input | Consumer wires | Consumer composes `Tabs` | Consumer composes `Tabs` | n/a | +| Tab roving focus / Home/End | Consumer handles | Consumer composes `Tabs` | Consumer composes `Tabs` | n/a | +| Anchored positioning / visible surface | Consumer styles | ⚠️ Consumer must style | ✅ Primer tokens | ✅ Primer tokens | +| Colour contrast | Consumer ensures | ⚠️ Consumer must ensure | ✅ Primer tokens | ✅ Primer tokens | ### Keyboard diff --git a/packages/react/src/experimental/SelectPanel/SelectPanel.tsx b/packages/react/src/experimental/SelectPanel/SelectPanel.tsx index ad3e5a1646d..e1a2b16c499 100644 --- a/packages/react/src/experimental/SelectPanel/SelectPanel.tsx +++ b/packages/react/src/experimental/SelectPanel/SelectPanel.tsx @@ -172,11 +172,15 @@ Title.displayName = 'SelectPanel.Title' type SelectPanelInputProps = Omit, 'role'> -function Input({className, onKeyDown, ...props}: SelectPanelInputProps) { +const Input = React.forwardRef(function SelectPanelInput( + {className, onKeyDown, ...props}, + forwardedRef, +) { const {foundation} = useSelectPanelContext() const inputProps = foundation.getInputProps() return ( ) => { @@ -188,7 +192,7 @@ function Input({className, onKeyDown, ...props}: SelectPanelInputProps) { {...props} /> ) -} +}) Input.displayName = 'SelectPanel.Input' // --- SelectPanel.List --- diff --git a/packages/react/src/experimental/SelectPanel/SelectPanelParts.stories.tsx b/packages/react/src/experimental/SelectPanel/SelectPanelParts.stories.tsx index ac95f526d03..7944e7721a8 100644 --- a/packages/react/src/experimental/SelectPanel/SelectPanelParts.stories.tsx +++ b/packages/react/src/experimental/SelectPanel/SelectPanelParts.stories.tsx @@ -133,13 +133,11 @@ export const TabbedBranchesAndTags: StoryObj = { const [open, setOpen] = useState(false) const [activeTab, setActiveTab] = useState('branches') const anchorRef = useRef(null) - const rootRef = useRef(null) + const inputRef = useRef(null) // Return focus to the shared search input so the user never gets trapped in // the (non-focusable) panel after switching tabs. - const focusInput = () => { - rootRef.current?.querySelector('[role="combobox"]')?.focus() - } + const focusInput = () => inputRef.current?.focus() // One shared search query and one selection model, both owned by the consumer // and shared across the two tabs. @@ -153,19 +151,14 @@ export const TabbedBranchesAndTags: StoryObj = { const selectedName = useMemo(() => nameOf([...selection.selectedKeys][0]) ?? 'main', [selection.selectedKeys]) return ( - + Switch ref: {selectedName} Switch branches/tags (null) - const rootRef = useRef(null) + const inputRef = useRef(null) - const focusInput = () => { - rootRef.current?.querySelector('[role="combobox"]')?.focus() - } + const focusInput = () => inputRef.current?.focus() const filter = useFilter() const selection = useSelectionState({selectionVariant: 'multiple'}) @@ -237,19 +228,14 @@ export const MultiSelectAcrossTabs: StoryObj = { ) return ( - + Refs selected: {selection.selectedKeys.size} Select branches/tags