From fbb0d189986ad79c0cb10df59b69e95b0a2c92ca Mon Sep 17 00:00:00 2001 From: Josh Farrant Date: Wed, 10 Jun 2026 14:39:59 +0100 Subject: [PATCH 1/3] Add accessible RefPicker (tabs + shared filter) example Adds RefPickerV1Correct, a reusable git-ref picker: a button opening an anchored panel with two tabs (Branches/Tags), one shared search input that filters the active tab's list, per-tab result counts, and single selection. The stable SelectPanel renders a fixed internal layout (header -> notice -> internally-owned FilteredActionList) with no content slot and an internally generated listbox id, so a correct APG tab -> tabpanel -> listbox association cannot be wired through its closed API. This component is therefore built on the public AnchoredOverlay primitive + useFocusZone, re-implementing the overlay content, shared filter input, listbox and its focus management. ARIA: tab(aria-controls=panel) -> tabpanel(aria-labelledby=tab) -> listbox (aria-labelledby=tab) -> option(aria-selected); the shared combobox input's aria-controls points at the active listbox with focus-zone-managed aria-activedescendant. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../examples/RefPickerV1Correct.data.ts | 52 ++++ .../examples/RefPickerV1Correct.module.css | 138 +++++++++ .../examples/RefPickerV1Correct.stories.tsx | 24 ++ .../examples/RefPickerV1Correct.tsx | 282 ++++++++++++++++++ 4 files changed, 496 insertions(+) create mode 100644 packages/react/src/SelectPanel/examples/RefPickerV1Correct.data.ts create mode 100644 packages/react/src/SelectPanel/examples/RefPickerV1Correct.module.css create mode 100644 packages/react/src/SelectPanel/examples/RefPickerV1Correct.stories.tsx create mode 100644 packages/react/src/SelectPanel/examples/RefPickerV1Correct.tsx diff --git a/packages/react/src/SelectPanel/examples/RefPickerV1Correct.data.ts b/packages/react/src/SelectPanel/examples/RefPickerV1Correct.data.ts new file mode 100644 index 00000000000..37644601acc --- /dev/null +++ b/packages/react/src/SelectPanel/examples/RefPickerV1Correct.data.ts @@ -0,0 +1,52 @@ +// Mock data for the RefPickerV1Correct example. Not part of the component's +// product logic — supplied to the component as props in the Storybook story. + +export const branches: string[] = [ + 'main', + 'develop', + 'release/2.0', + 'release/1.9', + 'feature/select-panel-tabs', + 'feature/ref-picker', + 'feature/virtualized-list', + 'feature/a11y-audit', + 'fix/overlay-focus-trap', + 'fix/listbox-aria', + 'fix/keyboard-nav', + 'chore/bump-deps', + 'chore/storybook-9', + 'docs/contributing', + 'docs/adr-overlay', + 'experiment/css-modules', + 'experiment/sx-removal', + 'hotfix/prod-regression', + 'wip/design-tokens', + 'wip/theme-refactor', + 'renovate/octicons', + 'renovate/typescript-5', + 'dependabot/npm/react-19', + 'gh-pages', +] + +export const tags: string[] = [ + 'v2.0.0', + 'v1.9.4', + 'v1.9.3', + 'v1.9.2', + 'v1.9.1', + 'v1.9.0', + 'v1.8.0', + 'v1.7.2', + 'v1.7.1', + 'v1.7.0', + 'v1.6.0', + 'v1.5.0', + 'v1.4.0', + 'v1.0.0', + 'v0.9.0-rc.2', + 'v0.9.0-rc.1', + 'nightly-2024-06-01', + 'nightly-2024-05-15', + 'release-candidate', + 'stable', +] diff --git a/packages/react/src/SelectPanel/examples/RefPickerV1Correct.module.css b/packages/react/src/SelectPanel/examples/RefPickerV1Correct.module.css new file mode 100644 index 00000000000..9e1bfab8739 --- /dev/null +++ b/packages/react/src/SelectPanel/examples/RefPickerV1Correct.module.css @@ -0,0 +1,138 @@ +.Panel { + display: flex; + flex-direction: column; + width: 100%; + min-height: 0; +} + +.Header { + padding: var(--base-size-12, 12px) var(--base-size-12, 12px) var(--base-size-8, 8px); +} + +.Title { + font-size: var(--text-body-size-medium, 14px); + font-weight: var(--base-text-weight-semibold, 600); + margin: 0; +} + +.Search { + padding: 0 var(--base-size-12, 12px) var(--base-size-8, 8px); +} + +.TabList { + display: flex; + gap: var(--base-size-4, 4px); + padding: 0 var(--base-size-12, 12px); + border-bottom: var(--borderWidth-thin, 1px) solid var(--borderColor-default, #d1d9e0); +} + +.Tab { + appearance: none; + background: transparent; + border: none; + border-bottom: 2px solid transparent; + margin-bottom: -1px; + padding: var(--base-size-8, 8px) var(--base-size-8, 8px); + font: inherit; + font-size: var(--text-body-size-small, 12px); + color: var(--fgColor-muted, #59636e); + cursor: pointer; + display: inline-flex; + align-items: center; + gap: var(--base-size-4, 4px); + border-radius: var(--borderRadius-medium, 6px) var(--borderRadius-medium, 6px) 0 0; +} + +.Tab:hover { + color: var(--fgColor-default, #1f2328); +} + +.Tab[aria-selected='true'] { + color: var(--fgColor-default, #1f2328); + border-bottom-color: var(--underlineNav-borderColor-active, #fd8c73); + font-weight: var(--base-text-weight-semibold, 600); +} + +.Tab:focus-visible { + outline: 2px solid var(--focus-outlineColor, #0969da); + outline-offset: -2px; +} + +.Count { + color: var(--fgColor-muted, #59636e); + font-weight: var(--base-text-weight-normal, 400); +} + +.TabPanel { + display: flex; + flex-direction: column; + min-height: 0; + flex: 1 1 auto; +} + +.TabPanel:focus-visible { + outline: none; +} + +.ListContainer { + overflow-y: auto; + max-height: 280px; + padding: var(--base-size-8, 8px); +} + +.Listbox { + list-style: none; + margin: 0; + padding: 0; +} + +.Option { + display: flex; + align-items: center; + gap: var(--base-size-8, 8px); + padding: var(--base-size-6, 6px) var(--base-size-8, 8px); + border-radius: var(--borderRadius-medium, 6px); + cursor: pointer; + color: var(--fgColor-default, #1f2328); + font-size: var(--text-body-size-medium, 14px); + user-select: none; +} + +.Option:hover { + background-color: var(--control-transparent-bgColor-hover, rgb(101 108 133 / 8%)); +} + +.Option[data-is-active-descendant] { + background-color: var(--control-transparent-bgColor-active, rgb(101 108 133 / 14%)); +} + +.OptionLeading { + display: inline-flex; + color: var(--fgColor-muted, #59636e); + flex-shrink: 0; +} + +.OptionText { + flex: 1 1 auto; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.OptionCheck { + display: inline-flex; + color: var(--fgColor-accent, #0969da); + flex-shrink: 0; + visibility: hidden; +} + +.Option[aria-selected='true'] .OptionCheck { + visibility: visible; +} + +.Empty { + padding: var(--base-size-16, 16px); + text-align: center; + color: var(--fgColor-muted, #59636e); + font-size: var(--text-body-size-small, 12px); +} diff --git a/packages/react/src/SelectPanel/examples/RefPickerV1Correct.stories.tsx b/packages/react/src/SelectPanel/examples/RefPickerV1Correct.stories.tsx new file mode 100644 index 00000000000..310d52adb23 --- /dev/null +++ b/packages/react/src/SelectPanel/examples/RefPickerV1Correct.stories.tsx @@ -0,0 +1,24 @@ +import type {Meta, StoryObj} from '@storybook/react-vite' +import {RefPickerV1Correct} from './RefPickerV1Correct' +import {branches, tags} from './RefPickerV1Correct.data' + +const meta = { + title: 'Components/SelectPanel/Examples/RefPickerV1Correct', + component: RefPickerV1Correct, + args: { + branches, + tags, + }, +} satisfies Meta + +export default meta + +type Story = StoryObj + +export const Default: Story = {} + +export const WithInitialSelection: Story = { + args: { + initialSelected: {kind: 'branches', name: 'main'}, + }, +} diff --git a/packages/react/src/SelectPanel/examples/RefPickerV1Correct.tsx b/packages/react/src/SelectPanel/examples/RefPickerV1Correct.tsx new file mode 100644 index 00000000000..a764323f777 --- /dev/null +++ b/packages/react/src/SelectPanel/examples/RefPickerV1Correct.tsx @@ -0,0 +1,282 @@ +import type React from 'react' +import {useCallback, useMemo, useRef, useState} from 'react' +import {CheckIcon, GitBranchIcon, TagIcon, TriangleDownIcon} from '@primer/octicons-react' +import {AnchoredOverlay} from '../../AnchoredOverlay' +import {Button} from '../../Button' +import TextInput from '../../TextInput' +import {useFocusZone, FocusKeys} from '../../hooks/useFocusZone' +import {useId} from '../../hooks/useId' +import classes from './RefPickerV1Correct.module.css' + +type RefKind = 'branches' | 'tags' + +export type SelectedRef = {kind: RefKind; name: string} + +export type RefPickerV1CorrectProps = { + branches: string[] + tags: string[] + initialSelected?: SelectedRef + onSelect?: (selected: SelectedRef) => void +} + +const TABS: Array<{kind: RefKind; label: string; icon: React.ComponentType}> = [ + {kind: 'branches', label: 'Branches', icon: GitBranchIcon}, + {kind: 'tags', label: 'Tags', icon: TagIcon}, +] + +function filterRefs(values: string[], query: string): string[] { + const q = query.trim().toLowerCase() + if (!q) return values + return values.filter(value => value.toLowerCase().includes(q)) +} + +/** + * A reference picker (choose a git ref) built as a self-contained component. + * + * Why this is NOT built on the stable `SelectPanel`: + * The stable `SelectPanel` renders a fixed internal layout — title/subtitle → + * notice → an internally-owned `FilteredActionList` — and exposes no content slot + * between the header and the list. The `FilteredActionList`'s `role="listbox"` is + * created internally, its id is generated internally (not exposed), and its + * `aria-labelledby` is hardwired to the panel title. There is therefore no way to + * (a) insert a `role="tablist"` of tabs, (b) wrap the listbox in a `role="tabpanel"`, + * or (c) point a tab's `aria-controls` at the listbox / panel. Meeting the + * APG Tabs accessibility-correctness requirement is impossible through that closed + * API, so the overlay, shared filter input, listbox and its focus management are + * re-implemented here on the public `AnchoredOverlay` primitive + `useFocusZone`. + * + * The tab → tabpanel → listbox relationship is fully wired: + * - tab: role="tab", id=tabId, aria-controls=panelId, aria-selected + * - tabpanel: role="tabpanel", id=panelId, aria-labelledby=tabId + * - listbox: role="listbox", id=listboxId, aria-labelledby=tabId, inside the tabpanel + * - options: role="option", id=optionId, aria-selected + * - the shared filter input is a role="combobox" whose aria-controls points at the + * active tab's listbox, with aria-activedescendant managed by the focus zone. + */ +export function RefPickerV1Correct({branches, tags, initialSelected, onSelect}: RefPickerV1CorrectProps) { + const [open, setOpen] = useState(false) + const [activeTab, setActiveTab] = useState('branches') + const [query, setQuery] = useState('') + const [selected, setSelected] = useState(initialSelected) + + const inputRef = useRef(null) + const [listContainerElement, setListContainerElement] = useState(null) + const activeOptionNameRef = useRef(undefined) + + const baseId = useId() + const titleId = `${baseId}-title` + const tabId = (kind: RefKind) => `${baseId}-tab-${kind}` + const panelId = (kind: RefKind) => `${baseId}-panel-${kind}` + const listboxId = (kind: RefKind) => `${baseId}-listbox-${kind}` + const optionId = (kind: RefKind, index: number) => `${baseId}-option-${kind}-${index}` + + const filtered = useMemo( + () => ({ + branches: filterRefs(branches, query), + tags: filterRefs(tags, query), + }), + [branches, tags, query], + ) + + const activeItems = filtered[activeTab] + + // Re-implemented listbox focus management: keeps DOM focus on the shared filter + // input while moving aria-activedescendant across the active listbox's options. + useFocusZone( + { + containerRef: {current: listContainerElement}, + bindKeys: FocusKeys.ArrowVertical | FocusKeys.HomeAndEnd | FocusKeys.PageUpDown, + focusOutBehavior: 'wrap', + activeDescendantFocus: inputRef, + focusInStrategy: 'first', + focusableElementFilter: element => !(element instanceof HTMLInputElement), + onActiveDescendantChanged: current => { + activeOptionNameRef.current = current?.getAttribute('data-ref-name') ?? undefined + }, + }, + [listContainerElement, activeTab], + ) + + const commitSelection = useCallback( + (name: string) => { + const next: SelectedRef = {kind: activeTab, name} + setSelected(next) + onSelect?.(next) + setOpen(false) + }, + [activeTab, onSelect], + ) + + const onInputKeyDown = useCallback( + (event: React.KeyboardEvent) => { + if (event.key === 'Enter') { + const name = activeOptionNameRef.current ?? activeItems[0] + if (name) { + event.preventDefault() + commitSelection(name) + } + } + }, + [activeItems, commitSelection], + ) + + const selectTab = useCallback((kind: RefKind) => { + setActiveTab(kind) + activeOptionNameRef.current = undefined + inputRef.current?.focus() + }, []) + + const onTabListKeyDown = useCallback( + (event: React.KeyboardEvent) => { + const order = TABS.map(tab => tab.kind) + const currentIndex = order.indexOf(activeTab) + let nextIndex: number + switch (event.key) { + case 'ArrowRight': + case 'ArrowDown': + nextIndex = (currentIndex + 1) % order.length + break + case 'ArrowLeft': + case 'ArrowUp': + nextIndex = (currentIndex - 1 + order.length) % order.length + break + case 'Home': + nextIndex = 0 + break + case 'End': + nextIndex = order.length - 1 + break + default: + return + } + event.preventDefault() + selectTab(order[nextIndex]) + }, + [activeTab, selectTab], + ) + + const anchorIcon = selected?.kind === 'tags' ? TagIcon : GitBranchIcon + const anchorLabel = selected ? selected.name : 'Select a ref' + + return ( + setOpen(true)} + onClose={() => setOpen(false)} + width="small" + focusTrapSettings={{initialFocusRef: inputRef}} + overlayProps={{role: 'dialog', 'aria-labelledby': titleId}} + renderAnchor={anchorProps => ( + + )} + > +
+
+

+ Switch branches/tags +

+
+ +
+ { + setQuery(event.target.value) + activeOptionNameRef.current = undefined + }} + onKeyDown={onInputKeyDown} + leadingVisual={GitBranchIcon} + placeholder="Filter branches and tags" + aria-label="Filter branches and tags" + role="combobox" + aria-expanded + aria-autocomplete="list" + aria-controls={listboxId(activeTab)} + block + /> +
+ + {} +
+ {TABS.map(({kind, label, icon: Icon}) => { + const isActive = kind === activeTab + return ( + + ) + })} +
+ + {TABS.map(({kind}) => { + const isActive = kind === activeTab + const items = filtered[kind] + return ( + + ) + })} +
+
+ ) +} From 3f9374c19ef6ed23bfbef28a52e720fd2c83288b Mon Sep 17 00:00:00 2001 From: joshfarrant <6840861+joshfarrant@users.noreply.github.com> Date: Wed, 10 Jun 2026 14:21:24 +0000 Subject: [PATCH 2/3] chore: auto-fix lint and formatting issues --- .../examples/RefPickerV1Correct.module.css | 26 +++++++++---------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/packages/react/src/SelectPanel/examples/RefPickerV1Correct.module.css b/packages/react/src/SelectPanel/examples/RefPickerV1Correct.module.css index 9e1bfab8739..c6fa4ed7a50 100644 --- a/packages/react/src/SelectPanel/examples/RefPickerV1Correct.module.css +++ b/packages/react/src/SelectPanel/examples/RefPickerV1Correct.module.css @@ -6,7 +6,7 @@ } .Header { - padding: var(--base-size-12, 12px) var(--base-size-12, 12px) var(--base-size-8, 8px); + padding: var(--base-size-12, var(--base-size-12)) var(--base-size-12, var(--base-size-12)) var(--base-size-8, var(--base-size-8)); } .Title { @@ -16,23 +16,23 @@ } .Search { - padding: 0 var(--base-size-12, 12px) var(--base-size-8, 8px); + padding: 0 var(--base-size-12, var(--base-size-12)) var(--base-size-8, var(--base-size-8)); } .TabList { display: flex; gap: var(--base-size-4, 4px); - padding: 0 var(--base-size-12, 12px); - border-bottom: var(--borderWidth-thin, 1px) solid var(--borderColor-default, #d1d9e0); + padding: 0 var(--base-size-12, var(--base-size-12)); + border-bottom: var(--borderWidth-thin, var(--borderWidth-thin)) solid var(--borderColor-default, #d1d9e0); } .Tab { appearance: none; background: transparent; border: none; - border-bottom: 2px solid transparent; + border-bottom: var(--borderWidth-thick) solid transparent; margin-bottom: -1px; - padding: var(--base-size-8, 8px) var(--base-size-8, 8px); + padding: var(--base-size-8, var(--base-size-8)) var(--base-size-8, var(--base-size-8)); font: inherit; font-size: var(--text-body-size-small, 12px); color: var(--fgColor-muted, #59636e); @@ -40,7 +40,7 @@ display: inline-flex; align-items: center; gap: var(--base-size-4, 4px); - border-radius: var(--borderRadius-medium, 6px) var(--borderRadius-medium, 6px) 0 0; + border-radius: var(--borderRadius-medium, var(--borderRadius-default)) var(--borderRadius-medium, var(--borderRadius-default)) 0 0; } .Tab:hover { @@ -77,7 +77,7 @@ .ListContainer { overflow-y: auto; max-height: 280px; - padding: var(--base-size-8, 8px); + padding: var(--base-size-8, var(--base-size-8)); } .Listbox { @@ -90,8 +90,8 @@ display: flex; align-items: center; gap: var(--base-size-8, 8px); - padding: var(--base-size-6, 6px) var(--base-size-8, 8px); - border-radius: var(--borderRadius-medium, 6px); + padding: var(--base-size-6, var(--base-size-6)) var(--base-size-8, var(--base-size-8)); + border-radius: var(--borderRadius-medium, var(--borderRadius-default)); cursor: pointer; color: var(--fgColor-default, #1f2328); font-size: var(--text-body-size-medium, 14px); @@ -99,11 +99,11 @@ } .Option:hover { - background-color: var(--control-transparent-bgColor-hover, rgb(101 108 133 / 8%)); + background-color: var(--control-transparent-bgColor-hover, rgb(101 108 133 / 0.08)); } .Option[data-is-active-descendant] { - background-color: var(--control-transparent-bgColor-active, rgb(101 108 133 / 14%)); + background-color: var(--control-transparent-bgColor-active, rgb(101 108 133 / 0.14)); } .OptionLeading { @@ -131,7 +131,7 @@ } .Empty { - padding: var(--base-size-16, 16px); + padding: var(--base-size-16, var(--base-size-16)); text-align: center; color: var(--fgColor-muted, #59636e); font-size: var(--text-body-size-small, 12px); From e98a441bb5f59d00636295a49eedfc5f6d7446b7 Mon Sep 17 00:00:00 2001 From: "primer[bot]" <119360173+primer[bot]@users.noreply.github.com> Date: Wed, 10 Jun 2026 14:28:25 +0000 Subject: [PATCH 3/3] chore: auto-fix lint and formatting issues --- .../src/SelectPanel/examples/RefPickerV1Correct.module.css | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/react/src/SelectPanel/examples/RefPickerV1Correct.module.css b/packages/react/src/SelectPanel/examples/RefPickerV1Correct.module.css index c6fa4ed7a50..30cf8fd3aef 100644 --- a/packages/react/src/SelectPanel/examples/RefPickerV1Correct.module.css +++ b/packages/react/src/SelectPanel/examples/RefPickerV1Correct.module.css @@ -6,7 +6,8 @@ } .Header { - padding: var(--base-size-12, var(--base-size-12)) var(--base-size-12, var(--base-size-12)) var(--base-size-8, var(--base-size-8)); + padding: var(--base-size-12, var(--base-size-12)) var(--base-size-12, var(--base-size-12)) + var(--base-size-8, var(--base-size-8)); } .Title { @@ -40,7 +41,8 @@ display: inline-flex; align-items: center; gap: var(--base-size-4, 4px); - border-radius: var(--borderRadius-medium, var(--borderRadius-default)) var(--borderRadius-medium, var(--borderRadius-default)) 0 0; + border-radius: var(--borderRadius-medium, var(--borderRadius-default)) + var(--borderRadius-medium, var(--borderRadius-default)) 0 0; } .Tab:hover {