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
45 changes: 45 additions & 0 deletions packages/react/src/SelectPanel/examples/RefPickerV1.module.css
Original file line number Diff line number Diff line change
@@ -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);
}
36 changes: 36 additions & 0 deletions packages/react/src/SelectPanel/examples/RefPickerV1.stories.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof RefPickerV1>

export default meta

type Story = StoryObj<typeof RefPickerV1>

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,
},
}
168 changes: 168 additions & 0 deletions packages/react/src/SelectPanel/examples/RefPickerV1.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<button type="button" {...tabProps} className={classes.Tab}>
<Icon size={16} />
{label} <span className={classes.Count}>({count})</span>
</button>
)
}

/**
* 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<HTMLDivElement>({'aria-label': 'Reference type'})
return (
<Tabs value={activeTab} onValueChange={({value}) => onTabChange(value as RefType)}>
{/* @ts-expect-error tabListProps.ref is typed as RefObject<HTMLDivElement | null> */}
<div {...tabListProps} className={classes.TabList}>
<RefTab value="branches" icon={GitBranchIcon} label="Branches" count={branchCount} />
<RefTab value="tags" icon={TagIcon} label="Tags" count={tagCount} />
</div>
</Tabs>
)
}

export function RefPickerV1({branches, tags, initialTab = 'branches', initialSelection, onSelect}: RefPickerV1Props) {
const [open, setOpen] = useState(false)
const [filter, setFilter] = useState('')
const [activeTab, setActiveTab] = useState<RefType>(initialTab)
const [selected, setSelected] = useState<ItemInput | undefined>(() =>
initialSelection ? toItem(initialSelection.type, initialSelection.name) : undefined,
)
const anchorRef = useRef<HTMLButtonElement>(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 = (
<RefTabs
activeTab={activeTab}
onTabChange={setActiveTab}
branchCount={filteredBranches.length}
tagCount={filteredTags.length}
/>
)

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 (
<SelectPanel
title="Switch branches/tags"
subtitle={tabs}
renderAnchor={({children, ...anchorProps}) => (
<Button ref={anchorRef} leadingVisual={ButtonIcon} trailingAction={TriangleDownIcon} {...anchorProps}>
{children}
</Button>
)}
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'}}
/>
)
}
43 changes: 43 additions & 0 deletions packages/react/src/SelectPanel/examples/refPickerMockData.ts
Original file line number Diff line number Diff line change
@@ -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',
]
Loading