Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions packages/react/src/SelectPanel/examples/RefPicker.module.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
.TabList {
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));
}

.Tab {
display: inline-flex;
align-items: center;
gap: var(--stack-gap-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, 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: var(--base-text-lineHeight-normal);
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, var(--borderRadius-default));
}

.TabPanel {
display: flex;
min-height: 0;
flex-direction: column;
}
24 changes: 24 additions & 0 deletions packages/react/src/SelectPanel/examples/RefPicker.stories.tsx
Original file line number Diff line number Diff line change
@@ -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 <RefPicker />
}

export const Controlled = () => {
const [selected, setSelected] = React.useState<SelectedRef | undefined>({type: 'branches', name: 'main'})

return (
<div>
<RefPicker value={selected} onChange={setSelected} />
<p>
Selected ref: <strong>{selected ? `${selected.name} (${selected.type})` : 'none'}</strong>
</p>
</div>
)
}
63 changes: 63 additions & 0 deletions packages/react/src/SelectPanel/examples/RefPicker.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<RefPicker />)
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(<RefPicker />)
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()
})
})
165 changes: 165 additions & 0 deletions packages/react/src/SelectPanel/examples/RefPicker.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLDivElement>(rest)
const {ref, ...listProps} = tabListProps
return (
<div ref={ref as React.Ref<HTMLDivElement>} {...listProps} className={classes.TabList}>
{children}
</div>
)
}

type RefTabProps = {value: RefType; count: number; icon: Icon; children: React.ReactNode}

function RefTab({value, count, icon: TabIcon, children}: RefTabProps) {
const {tabProps} = useTab<HTMLButtonElement>({value})
return (
<button type="button" {...tabProps} className={classes.Tab}>
<TabIcon size={16} />
<span>{children}</span>
<CounterLabel>{count}</CounterLabel>
</button>
)
}

type RefTabPanelProps = {value: RefType; children: React.ReactNode}

function RefTabPanel({value, children}: RefTabPanelProps) {
const {tabPanelProps} = useTabPanel<HTMLDivElement>({value})
return (
<div {...tabPanelProps} className={classes.TabPanel}>
{children}
</div>
)
}

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<SelectedRef | undefined>(undefined)
const selected = value ?? internalSelected

const [activeTab, setActiveTab] = React.useState<RefType>('branches')
const [query, setQuery] = React.useState('')

const filtered = React.useMemo<Record<RefType, GitRef[]>>(
() => ({
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 (
<SelectPanel.Message variant="empty" title={`No ${type} found`}>
{query ? `No ${type} match "${query}".` : `There are no ${type} to show.`}
</SelectPanel.Message>
)
}
return (
<ActionList>
{refs.map(ref => (
<ActionList.Item
key={ref.id}
onSelect={() => onSelectRef(ref)}
selected={selected?.type === type && selected.name === ref.name}
>
<ActionList.LeadingVisual>{type === 'branches' ? <GitBranchIcon /> : <TagIcon />}</ActionList.LeadingVisual>
{ref.name}
</ActionList.Item>
))}
</ActionList>
)
}

const anchorIcon = selected?.type === 'tags' ? TagIcon : GitBranchIcon

return (
<Tabs value={activeTab} onValueChange={({value: nextValue}) => setActiveTab(nextValue as RefType)}>
<SelectPanel
title="Switch branches/tags"
selectionVariant="instant"
onSubmit={onClosePanel}
onCancel={onClosePanel}
>
<SelectPanel.Button leadingVisual={anchorIcon}>{selected ? selected.name : placeholder}</SelectPanel.Button>

<SelectPanel.Header>
<SelectPanel.SearchInput
aria-label="Filter branches and tags"
value={query}
onChange={event => setQuery(event.currentTarget.value)}
/>
<RefTabList aria-label="Reference type">
{tabConfig.map(tab => (
<RefTab key={tab.value} value={tab.value} count={filtered[tab.value].length} icon={tab.icon}>
{tab.label}
</RefTab>
))}
</RefTabList>
</SelectPanel.Header>

<RefTabPanel value="branches">
{activeTab === 'branches' ? renderList(filtered.branches, 'branches') : null}
</RefTabPanel>
<RefTabPanel value="tags">{activeTab === 'tags' ? renderList(filtered.tags, 'tags') : null}</RefTabPanel>
</SelectPanel>
</Tabs>
)
}
72 changes: 72 additions & 0 deletions packages/react/src/SelectPanel/examples/ref-picker-data.ts
Original file line number Diff line number Diff line change
@@ -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<string>): GitRef[] => names.map(name => ({id: name, name}))

export const branches: GitRef[] = toRefs(branchNames)
export const tags: GitRef[] = toRefs(tagNames)

export const refData: Record<RefType, GitRef[]> = {
branches,
tags,
}
Loading