[spike] Modular SelectPanel with tabs (layered)#7966
Conversation
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>
|
|
|
🤖 Lint issues have been automatically fixed and committed to this PR. |
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>
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>
…ed 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>
|
🤖 Lint issues have been automatically fixed and committed to this PR. |
Previously the styled parts bound directly to the useSelectPanel hook (L0), running parallel to the unstyled foundation (L1) rather than wrapping it — so L1 was orphaned and never dogfooded by our own styled layer. This makes L2 genuinely wrap L1 on Primer's existing `as` convention: - Add `as` polymorphism to the foundation Anchor (-> Button) and Input (-> TextInput), and forwardRef to every foundation part, so the styled layer can swap the element and reach the DOM nodes it needs. - Expose `useSelectPanelFoundation()` so the styled Root wraps the foundation Root and every styled part reuses the single behaviour instance. - Add `clearActiveDescendant()` to useSelectPanel and reset the active option on tab change in the example, fixing a dangling aria-activedescendant after a tab switch. - Add keyboard/focus integration tests (real Chromium) covering arrow nav via aria-activedescendant, Enter-to-select, Escape-close focus restore, the non-focusable tabpanel, focus-return on tab switch, and cross-tab navigation. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
|
🤖 Lint issues have been automatically fixed and committed to this PR. |
Draft — not for merge. Part of a spike comparing how a tabbed SelectPanel (Branches / Tags, one shared search) gets built across Primer's APIs. Same task, same model (Opus 4.8); the API is the only variable:
SelectPanel, pragmatic (tabs work visually but aren't ARIA-associated)SelectPanel, ARIA correctness forced (the component gets abandoned)SelectPanel(composes correctly, ~35 lines of glue)SelectPanel(composes correctly, zero glue)This branch decomposes
SelectPanelinto the 4-layer modular architecture so a consumer can compose a tabbed picker — and it is the hand-refined canonical example of the intended implementation (it may differ from a one-shot agent output; that's deliberate).Design: compose, don't depend. SelectPanel does not own tabs and does not import
Tabs. It provides the listbox-in-a-dialog machinery; the consumer brings the genericTabsprimitive and composes it. A tabbed picker is a consumer composition, not a component.Layers (refined model):
@primer/react/hooks/experimental) —useFilter,useSelectionState,useAsyncList: generic, consumer-owned state, shareable across tabs.useSelectPanel(/foundations/experimental): prop-getters, all behaviour + ARIA, no markup. Outer popup isrole="dialog", withrole="listbox"scoped inside the active tab panel./foundations/experimental).SelectPanel.*(no tab parts):Root/Anchor/Overlay/Header/Title/Input/List/Option/Empty/Footer.Accessibility model for the tabbed composition (see
SelectPanelParts.stories.tsx): the tabpanel is non-focusable, the list is driven by the input'saria-activedescendant, the tabs are a roving-tabindex zone, and switching a tab returns focus to the input — so there is no keyboard dead-end. A shared composite-focus utility (Ariakitcombobox-tabsstyle) is noted as a future enhancement, not required for correctness.Start with
SelectPanel.spec.mdand the "Branches / Tags tabbed picker (styled)" story. This is the zero-glue end of the comparison.Note surfaced by this work: the experimental
Tabsprimitive exports only hooks, notTab/TabList/TabPanelcomponents — the recipe wraps them locally; ideally Tabs would export them.