diff --git a/packages/react/src/SelectPanel/examples/RefPickerV1.module.css b/packages/react/src/SelectPanel/examples/RefPickerV1.module.css new file mode 100644 index 00000000000..58a3385e016 --- /dev/null +++ b/packages/react/src/SelectPanel/examples/RefPickerV1.module.css @@ -0,0 +1,45 @@ +.TabList { + display: flex; + gap: var(--base-size-4, 4px); + padding: 0 var(--base-size-8, var(--base-size-8)); + border-bottom: var(--borderWidth-thin, var(--borderWidth-thin)) solid var(--borderColor-default, #d1d9e0); +} + +.Tab { + appearance: none; + display: inline-flex; + align-items: center; + gap: var(--base-size-6, 6px); + padding: var(--base-size-8, var(--base-size-8)) var(--base-size-8, var(--base-size-8)); + margin-bottom: -1px; + border: 0; + border-bottom: var(--borderWidth-thick, var(--borderWidth-thick)) solid transparent; + background-color: transparent; + color: var(--fgColor-muted, #59636e); + font: inherit; + font-size: var(--text-body-size-small, 12px); + font-weight: var(--base-text-weight-medium, 500); + line-height: var(--base-text-lineHeight-normal); + cursor: pointer; +} + +.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: var(--borderWidth-thick, 2px) solid var(--focus-outlineColor, #0969da); + outline-offset: -2px; + border-radius: var(--borderRadius-small, var(--borderRadius-small)); +} + +.Count { + color: var(--fgColor-muted, #59636e); + font-weight: var(--base-text-weight-normal, 400); +} diff --git a/packages/react/src/SelectPanel/examples/RefPickerV1.stories.tsx b/packages/react/src/SelectPanel/examples/RefPickerV1.stories.tsx new file mode 100644 index 00000000000..3d3a5fcbba7 --- /dev/null +++ b/packages/react/src/SelectPanel/examples/RefPickerV1.stories.tsx @@ -0,0 +1,36 @@ +import type {Meta, StoryObj} from '@storybook/react-vite' +import {RefPickerV1} from './RefPickerV1' +import {branches, tags} from './refPickerMockData' + +const meta = { + title: 'Components/SelectPanel/Examples/RefPickerV1', + component: RefPickerV1, +} satisfies Meta + +export default meta + +type Story = StoryObj + +export const Default: Story = { + args: { + branches, + tags, + initialSelection: {type: 'branches', name: 'main'}, + }, +} + +export const StartOnTags: Story = { + args: { + branches, + tags, + initialTab: 'tags', + initialSelection: {type: 'tags', name: 'v2.0.0'}, + }, +} + +export const NoInitialSelection: Story = { + args: { + branches, + tags, + }, +} diff --git a/packages/react/src/SelectPanel/examples/RefPickerV1.tsx b/packages/react/src/SelectPanel/examples/RefPickerV1.tsx new file mode 100644 index 00000000000..c8a3b01cdee --- /dev/null +++ b/packages/react/src/SelectPanel/examples/RefPickerV1.tsx @@ -0,0 +1,168 @@ +import type React from 'react' +import {useMemo, useRef, useState} from 'react' +import {GitBranchIcon, TagIcon, TriangleDownIcon} from '@primer/octicons-react' +import {Button} from '../../Button' +import {Tabs, useTab, useTabList} from '../../experimental' +import type {ItemInput} from '../../FilteredActionList' +import {SelectPanel} from '../SelectPanel' +import classes from './RefPickerV1.module.css' + +type RefType = 'branches' | 'tags' + +export type RefSelection = { + type: RefType + name: string +} + +export type RefPickerV1Props = { + /** Branch names to show in the "Branches" tab. */ + branches: string[] + /** Tag names to show in the "Tags" tab. */ + tags: string[] + /** Tab shown when the panel first opens. */ + initialTab?: RefType + /** Pre-selected ref. */ + initialSelection?: RefSelection + /** Called whenever the user picks a ref. */ + onSelect?: (selection: RefSelection) => void +} + +// Encode the tab into the item id so we can recover it from a selected item +// without keeping a parallel lookup table. +const makeId = (type: RefType, name: string) => `${type}:${name}` +const tabFromId = (id: string): RefType => (id.startsWith('tags:') ? 'tags' : 'branches') +const nameFromId = (id: string) => id.slice(id.indexOf(':') + 1) + +const toItem = (type: RefType, name: string): ItemInput => ({ + id: makeId(type, name), + text: name, + leadingVisual: type === 'branches' ? GitBranchIcon : TagIcon, +}) + +const filterNames = (names: string[], query: string) => { + const q = query.trim().toLowerCase() + if (q === '') return names + return names.filter(name => name.toLowerCase().includes(q)) +} + +/** + * A single tab button wired up with the headless `useTab` hook so we get correct + * `role="tab"`, `aria-selected`, roving tabindex and activation behaviour. + */ +function RefTab({ + value, + icon: Icon, + label, + count, +}: { + value: RefType + icon: React.ElementType + label: string + count: number +}) { + const {tabProps} = useTab({value}) + return ( + + ) +} + +/** + * Tablist rendered into the stable SelectPanel's `subtitle` slot. The stable + * SelectPanel exposes no general content slot between its header and its + * filtered list, so the tabs live in the only ReactElement slot available. + */ +function RefTabs({ + activeTab, + onTabChange, + branchCount, + tagCount, +}: { + activeTab: RefType + onTabChange: (tab: RefType) => void + branchCount: number + tagCount: number +}) { + const {tabListProps} = useTabList({'aria-label': 'Reference type'}) + return ( + onTabChange(value as RefType)}> + {/* @ts-expect-error tabListProps.ref is typed as RefObject */} +
+ + +
+
+ ) +} + +export function RefPickerV1({branches, tags, initialTab = 'branches', initialSelection, onSelect}: RefPickerV1Props) { + const [open, setOpen] = useState(false) + const [filter, setFilter] = useState('') + const [activeTab, setActiveTab] = useState(initialTab) + const [selected, setSelected] = useState(() => + initialSelection ? toItem(initialSelection.type, initialSelection.name) : undefined, + ) + const anchorRef = useRef(null) + + const filteredBranches = useMemo(() => filterNames(branches, filter), [branches, filter]) + const filteredTags = useMemo(() => filterNames(tags, filter), [tags, filter]) + + const activeNames = activeTab === 'branches' ? filteredBranches : filteredTags + const items = useMemo(() => activeNames.map(name => toItem(activeTab, name)), [activeNames, activeTab]) + + const handleSelectedChange = (item: ItemInput | undefined) => { + setSelected(item) + if (item && typeof item.id === 'string') { + onSelect?.({type: tabFromId(item.id), name: nameFromId(item.id)}) + } + } + + const buttonLabel = selected?.text ?? (activeTab === 'tags' ? 'Select a tag' : 'Select a branch') + const ButtonIcon = + selected && typeof selected.id === 'string' && tabFromId(selected.id) === 'tags' ? TagIcon : GitBranchIcon + + const tabs = ( + + ) + + const noResults = + activeNames.length === 0 + ? { + variant: 'empty' as const, + title: `No ${activeTab} found`, + body: filter ? `No ${activeTab} match “${filter}”.` : `There are no ${activeTab} to show.`, + } + : undefined + + return ( + ( + + )} + anchorRef={anchorRef} + placeholder={buttonLabel} + placeholderText="Filter by name" + inputLabel={`Filter ${activeTab}`} + open={open} + onOpenChange={setOpen} + items={items} + selected={selected} + onSelectedChange={handleSelectedChange} + filterValue={filter} + onFilterChange={setFilter} + message={noResults} + overlayProps={{width: 'small', height: 'medium'}} + /> + ) +} diff --git a/packages/react/src/SelectPanel/examples/refPickerMockData.ts b/packages/react/src/SelectPanel/examples/refPickerMockData.ts new file mode 100644 index 00000000000..06a64bf2bdd --- /dev/null +++ b/packages/react/src/SelectPanel/examples/refPickerMockData.ts @@ -0,0 +1,43 @@ +export const branches = [ + 'main', + 'develop', + 'release/2.0', + 'release/1.9', + 'feature/select-panel-tabs', + 'feature/ref-picker', + 'feature/keyboard-nav', + 'feature/dark-mode', + 'fix/overlay-focus-trap', + 'fix/filter-debounce', + 'fix/listbox-aria', + 'chore/bump-deps', + 'chore/eslint-config', + 'docs/storybook-examples', + 'experiment/virtualized-list', + 'hotfix/login-redirect', + 'renovate/octicons', + 'wip/new-button-api', + 'spike/rsc-support', + 'staging', +] + +export const tags = [ + '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.0.0', + 'v0.9.0-beta.2', + 'v0.9.0-beta.1', + 'nightly', + 'latest', + 'canary', +]