From 4ce5ba8acfb04790acab72f7e002762355cbc506 Mon Sep 17 00:00:00 2001 From: Josh Farrant Date: Tue, 9 Jun 2026 06:44:44 +0100 Subject: [PATCH 1/3] feat(SelectPanel): add RefPicker example with branches/tags tabs Build a reusable git-style reference picker on top of the experimental composable SelectPanel (v2) and the experimental Tabs primitive. One shared search box filters the active tab's list, each tab shows a live result count, and single selection closes the panel and updates the anchor button. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../SelectPanel/examples/RefPicker.module.css | 45 +++++ .../examples/RefPicker.stories.tsx | 24 +++ .../SelectPanel/examples/RefPicker.test.tsx | 63 +++++++ .../src/SelectPanel/examples/RefPicker.tsx | 165 ++++++++++++++++++ .../SelectPanel/examples/ref-picker-data.ts | 72 ++++++++ 5 files changed, 369 insertions(+) create mode 100644 packages/react/src/SelectPanel/examples/RefPicker.module.css create mode 100644 packages/react/src/SelectPanel/examples/RefPicker.stories.tsx create mode 100644 packages/react/src/SelectPanel/examples/RefPicker.test.tsx create mode 100644 packages/react/src/SelectPanel/examples/RefPicker.tsx create mode 100644 packages/react/src/SelectPanel/examples/ref-picker-data.ts diff --git a/packages/react/src/SelectPanel/examples/RefPicker.module.css b/packages/react/src/SelectPanel/examples/RefPicker.module.css new file mode 100644 index 00000000000..028bedc8c2c --- /dev/null +++ b/packages/react/src/SelectPanel/examples/RefPicker.module.css @@ -0,0 +1,45 @@ +.TabList { + display: flex; + gap: var(--stack-gap-condensed, 0.5rem); + padding-inline: var(--base-size-4, 0.25rem); + border-bottom: var(--borderWidth-thin, 1px) solid var(--borderColor-muted, var(--borderColor-default)); +} + +.Tab { + display: inline-flex; + align-items: center; + gap: var(--stack-gap-condensed, 0.5rem); + padding-block: var(--control-medium-paddingBlock, 0.375rem); + padding-inline: var(--control-medium-paddingInline-condensed, 0.5rem); + margin-bottom: calc(-1 * var(--borderWidth-thin, 1px)); + border: 0; + border-bottom: var(--borderWidth-thick, 2px) solid transparent; + background-color: transparent; + color: var(--fgColor-muted); + font: inherit; + font-size: var(--text-body-size-medium, 0.875rem); + line-height: 1.5; + cursor: pointer; +} + +.Tab:hover { + color: var(--fgColor-default); +} + +.Tab[aria-selected='true'] { + color: var(--fgColor-default); + border-bottom-color: var(--underlineNav-borderColor-active, var(--fgColor-accent, var(--color-accent-fg))); + font-weight: var(--base-text-weight-semibold, 600); +} + +.Tab:focus-visible { + outline: var(--borderWidth-thick, 2px) solid var(--fgColor-accent, var(--color-accent-fg)); + outline-offset: -2px; + border-radius: var(--borderRadius-medium, 0.375rem); +} + +.TabPanel { + display: flex; + min-height: 0; + flex-direction: column; +} diff --git a/packages/react/src/SelectPanel/examples/RefPicker.stories.tsx b/packages/react/src/SelectPanel/examples/RefPicker.stories.tsx new file mode 100644 index 00000000000..6210197544e --- /dev/null +++ b/packages/react/src/SelectPanel/examples/RefPicker.stories.tsx @@ -0,0 +1,24 @@ +import React from 'react' +import {RefPicker, type SelectedRef} from './RefPicker' + +export default { + title: 'Components/SelectPanel/Examples/RefPicker', + component: RefPicker, +} + +export const Default = () => { + return +} + +export const Controlled = () => { + const [selected, setSelected] = React.useState({type: 'branches', name: 'main'}) + + return ( +
+ +

+ Selected ref: {selected ? `${selected.name} (${selected.type})` : 'none'} +

+
+ ) +} diff --git a/packages/react/src/SelectPanel/examples/RefPicker.test.tsx b/packages/react/src/SelectPanel/examples/RefPicker.test.tsx new file mode 100644 index 00000000000..ee1c4c65716 --- /dev/null +++ b/packages/react/src/SelectPanel/examples/RefPicker.test.tsx @@ -0,0 +1,63 @@ +import React from 'react' +import {describe, expect, it} from 'vitest' +import {render} from '@testing-library/react' +import userEvent from '@testing-library/user-event' + +import {RefPicker} from './RefPicker' +import {branches, tags} from './ref-picker-data' + +const openPanel = async () => { + const view = render() + const user = userEvent.setup() + await user.click(view.getByRole('button', {name: /switch branches\/tags/i})) + return {view, user} +} + +describe('RefPicker', () => { + it('renders an anchor button with the placeholder', () => { + const view = render() + expect(view.getByRole('button', {name: /switch branches\/tags/i})).toBeInTheDocument() + expect(view.queryByRole('dialog')).toBeNull() + }) + + it('opens a panel with two tabs that show per-tab counts', async () => { + const {view} = await openPanel() + + expect(view.getByRole('dialog')).toBeInTheDocument() + + const branchesTab = view.getByRole('tab', {name: new RegExp(`Branches\\s*\\(${branches.length}\\)`)}) + const tagsTab = view.getByRole('tab', {name: new RegExp(`Tags\\s*\\(${tags.length}\\)`)}) + + expect(branchesTab).toHaveAttribute('aria-selected', 'true') + expect(tagsTab).toHaveAttribute('aria-selected', 'false') + }) + + it('filters the active tab with the shared search box and keeps the query when switching tabs', async () => { + const {view, user} = await openPanel() + + const search = view.getByRole('textbox', {name: /filter branches and tags/i}) + await user.type(search, 'release') + + // Branches list reflects the query. + const matchingBranches = branches.filter(b => b.name.includes('release')) + expect(view.getByRole('option', {name: matchingBranches[0].name})).toBeInTheDocument() + expect( + view.getByRole('tab', {name: new RegExp(`Branches\\s*\\(${matchingBranches.length}\\)`)}), + ).toBeInTheDocument() + + // Switching to Tags keeps the query and re-filters that tab's data (no "release" tags). + await user.click(view.getByRole('tab', {name: /Tags/})) + expect((search as HTMLInputElement).value).toBe('release') + expect(view.getByRole('tab', {name: /Tags\s*\(0\)/})).toBeInTheDocument() + expect(view.getByText(/No tags match "release"/i)).toBeInTheDocument() + }) + + it('selects an item, closes the panel and reflects the selection on the button', async () => { + const {view, user} = await openPanel() + + await user.click(view.getByRole('option', {name: 'develop'})) + + expect(view.queryByRole('dialog')).toBeNull() + expect(view.getByRole('button', {name: /develop/})).toBeInTheDocument() + }) +}) diff --git a/packages/react/src/SelectPanel/examples/RefPicker.tsx b/packages/react/src/SelectPanel/examples/RefPicker.tsx new file mode 100644 index 00000000000..b79b144cdff --- /dev/null +++ b/packages/react/src/SelectPanel/examples/RefPicker.tsx @@ -0,0 +1,165 @@ +import React from 'react' +import {GitBranchIcon, TagIcon, type Icon} from '@primer/octicons-react' + +import {SelectPanel, Tabs, useTab, useTabList, useTabPanel} from '../../experimental' +import {ActionList, CounterLabel} from '../../index' +import classes from './RefPicker.module.css' +import {branches, tags, type GitRef, type RefType} from './ref-picker-data' + +/** + * A reusable reference picker (think: choosing a git ref). + * + * A button opens a panel with two tabs — "Branches" and "Tags". A single shared + * search box at the top filters whichever tab is active; switching tabs keeps the + * query and re-filters that tab's data. Each tab shows a live result count. + * Single selection: picking an item closes the panel and updates the button. + * + * Built on the experimental, composable `SelectPanel` (v2) for the anchor button, + * overlay, shared search input and listbox, and on the experimental `Tabs` + * primitive (via its `useTabList` / `useTab` / `useTabPanel` hooks) for the + * accessible tablist that lives between the search box and the list. + */ + +const tabConfig: Array<{value: RefType; label: string; icon: Icon}> = [ + {value: 'branches', label: 'Branches', icon: GitBranchIcon}, + {value: 'tags', label: 'Tags', icon: TagIcon}, +] + +const filterRefs = (refs: GitRef[], query: string): GitRef[] => { + const normalized = query.trim().toLowerCase() + if (normalized === '') return refs + return refs.filter(ref => ref.name.toLowerCase().includes(normalized)) +} + +type RefTabListProps = {'aria-label': string; children: React.ReactNode} + +function RefTabList({children, ...rest}: RefTabListProps) { + const {tabListProps} = useTabList(rest) + const {ref, ...listProps} = tabListProps + return ( +
} {...listProps} className={classes.TabList}> + {children} +
+ ) +} + +type RefTabProps = {value: RefType; count: number; icon: Icon; children: React.ReactNode} + +function RefTab({value, count, icon: TabIcon, children}: RefTabProps) { + const {tabProps} = useTab({value}) + return ( + + ) +} + +type RefTabPanelProps = {value: RefType; children: React.ReactNode} + +function RefTabPanel({value, children}: RefTabPanelProps) { + const {tabPanelProps} = useTabPanel({value}) + return ( +
+ {children} +
+ ) +} + +export type SelectedRef = {type: RefType; name: string} + +export type RefPickerProps = { + /** Controlled selection. */ + value?: SelectedRef + /** Called when the user picks a ref. */ + onChange?: (selected: SelectedRef) => void + /** Label shown on the anchor button when nothing is selected. */ + placeholder?: string +} + +export function RefPicker({value, onChange, placeholder = 'Switch branches/tags'}: RefPickerProps) { + const [internalSelected, setInternalSelected] = React.useState(undefined) + const selected = value ?? internalSelected + + const [activeTab, setActiveTab] = React.useState('branches') + const [query, setQuery] = React.useState('') + + const filtered = React.useMemo>( + () => ({ + branches: filterRefs(branches, query), + tags: filterRefs(tags, query), + }), + [query], + ) + + const onSelectRef = (ref: GitRef) => { + const next: SelectedRef = {type: activeTab, name: ref.name} + setInternalSelected(next) + onChange?.(next) + } + + const onClosePanel = () => { + // Reset the shared query so the next open starts fresh. + setQuery('') + } + + const renderList = (refs: GitRef[], type: RefType) => { + if (refs.length === 0) { + return ( + + {query ? `No ${type} match "${query}".` : `There are no ${type} to show.`} + + ) + } + return ( + + {refs.map(ref => ( + onSelectRef(ref)} + selected={selected?.type === type && selected.name === ref.name} + > + {type === 'branches' ? : } + {ref.name} + + ))} + + ) + } + + const anchorIcon = selected?.type === 'tags' ? TagIcon : GitBranchIcon + + return ( + setActiveTab(nextValue as RefType)}> + + {selected ? selected.name : placeholder} + + + setQuery(event.currentTarget.value)} + /> + + {tabConfig.map(tab => ( + + {tab.label} + + ))} + + + + + {activeTab === 'branches' ? renderList(filtered.branches, 'branches') : null} + + {activeTab === 'tags' ? renderList(filtered.tags, 'tags') : null} + + + ) +} diff --git a/packages/react/src/SelectPanel/examples/ref-picker-data.ts b/packages/react/src/SelectPanel/examples/ref-picker-data.ts new file mode 100644 index 00000000000..2e452337d3b --- /dev/null +++ b/packages/react/src/SelectPanel/examples/ref-picker-data.ts @@ -0,0 +1,72 @@ +export type RefType = 'branches' | 'tags' + +export type GitRef = { + /** Unique id, used as the ActionList.Item key and selection identifier */ + id: string + /** The ref name, e.g. `main` or `v1.2.3` */ + name: string +} + +const branchNames = [ + 'main', + 'develop', + 'release/8.0', + 'release/7.4', + 'feat/select-panel-tabs', + 'feat/ref-picker', + 'feat/action-list-virtualization', + 'fix/overlay-focus-trap', + 'fix/tooltip-aria-describedby', + 'chore/bump-deps', + 'docs/storybook-upgrade', + 'experiment/css-modules', + 'experiment/treeview-async', + 'hotfix/security-patch', + 'renovate/octicons', + 'spike/server-components', + 'wip/data-table-sorting', + 'wip/anchored-position', + 'staging', + 'production', + 'next', + 'canary', + 'jdoe/playground', + 'asmith/banner-redesign', +] + +const tagNames = [ + 'v8.0.0', + 'v8.0.0-rc.1', + 'v7.4.2', + 'v7.4.1', + 'v7.4.0', + 'v7.3.0', + 'v7.2.1', + 'v7.2.0', + 'v7.1.0', + 'v7.0.0', + 'v6.5.0', + 'v6.4.0', + 'v6.3.1', + 'v6.0.0', + 'v5.9.0', + 'v5.0.0', + 'v4.2.0', + 'v4.0.0', + 'v3.1.0', + 'v2.0.0', + 'v1.0.0', + 'latest', + 'stable', + 'nightly', +] + +const toRefs = (names: ReadonlyArray): GitRef[] => names.map(name => ({id: name, name})) + +export const branches: GitRef[] = toRefs(branchNames) +export const tags: GitRef[] = toRefs(tagNames) + +export const refData: Record = { + branches, + tags, +} From aab6d72d5325bf97bbaa39626822c67abd8ce945 Mon Sep 17 00:00:00 2001 From: joshfarrant <6840861+joshfarrant@users.noreply.github.com> Date: Wed, 10 Jun 2026 13:11:21 +0000 Subject: [PATCH 2/3] chore: auto-fix lint and formatting issues --- .../src/SelectPanel/examples/RefPicker.module.css | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/react/src/SelectPanel/examples/RefPicker.module.css b/packages/react/src/SelectPanel/examples/RefPicker.module.css index 028bedc8c2c..9d07fbea80c 100644 --- a/packages/react/src/SelectPanel/examples/RefPicker.module.css +++ b/packages/react/src/SelectPanel/examples/RefPicker.module.css @@ -1,24 +1,24 @@ .TabList { display: flex; gap: var(--stack-gap-condensed, 0.5rem); - padding-inline: var(--base-size-4, 0.25rem); - border-bottom: var(--borderWidth-thin, 1px) solid var(--borderColor-muted, var(--borderColor-default)); + padding-inline: var(--base-size-4, var(--base-size-4)); + border-bottom: var(--borderWidth-thin, var(--borderWidth-thin)) solid var(--borderColor-muted, var(--borderColor-default)); } .Tab { display: inline-flex; align-items: center; gap: var(--stack-gap-condensed, 0.5rem); - padding-block: var(--control-medium-paddingBlock, 0.375rem); - padding-inline: var(--control-medium-paddingInline-condensed, 0.5rem); + padding-block: var(--control-medium-paddingBlock, var(--base-size-6)); + padding-inline: var(--control-medium-paddingInline-condensed, var(--base-size-8)); margin-bottom: calc(-1 * var(--borderWidth-thin, 1px)); border: 0; - border-bottom: var(--borderWidth-thick, 2px) solid transparent; + border-bottom: var(--borderWidth-thick, var(--borderWidth-thick)) solid transparent; background-color: transparent; color: var(--fgColor-muted); font: inherit; font-size: var(--text-body-size-medium, 0.875rem); - line-height: 1.5; + line-height: var(--base-text-lineHeight-normal); cursor: pointer; } @@ -35,7 +35,7 @@ .Tab:focus-visible { outline: var(--borderWidth-thick, 2px) solid var(--fgColor-accent, var(--color-accent-fg)); outline-offset: -2px; - border-radius: var(--borderRadius-medium, 0.375rem); + border-radius: var(--borderRadius-medium, var(--borderRadius-default)); } .TabPanel { From 826713ae14f51e202bc1f40ac8f14c33488add34 Mon Sep 17 00:00:00 2001 From: "primer[bot]" <119360173+primer[bot]@users.noreply.github.com> Date: Wed, 10 Jun 2026 13:18:40 +0000 Subject: [PATCH 3/3] chore: auto-fix lint and formatting issues --- packages/react/src/SelectPanel/examples/RefPicker.module.css | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/react/src/SelectPanel/examples/RefPicker.module.css b/packages/react/src/SelectPanel/examples/RefPicker.module.css index 9d07fbea80c..50f07f445d5 100644 --- a/packages/react/src/SelectPanel/examples/RefPicker.module.css +++ b/packages/react/src/SelectPanel/examples/RefPicker.module.css @@ -2,7 +2,8 @@ display: flex; gap: var(--stack-gap-condensed, 0.5rem); padding-inline: var(--base-size-4, var(--base-size-4)); - border-bottom: var(--borderWidth-thin, var(--borderWidth-thin)) solid var(--borderColor-muted, var(--borderColor-default)); + border-bottom: var(--borderWidth-thin, var(--borderWidth-thin)) solid + var(--borderColor-muted, var(--borderColor-default)); } .Tab {