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..30cf8fd3aef --- /dev/null +++ b/packages/react/src/SelectPanel/examples/RefPickerV1Correct.module.css @@ -0,0 +1,140 @@ +.Panel { + display: flex; + flex-direction: column; + width: 100%; + min-height: 0; +} + +.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)); +} + +.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, 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, 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: var(--borderWidth-thick) solid transparent; + margin-bottom: -1px; + 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); + cursor: pointer; + 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; +} + +.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, var(--base-size-8)); +} + +.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, 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); + user-select: none; +} + +.Option:hover { + 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 / 0.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, var(--base-size-16)); + 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 ( + + ) + })} +
+
+ ) +}