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
8 changes: 8 additions & 0 deletions packages/react/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,14 @@
"types": "./dist/experimental/index.d.ts",
"default": "./dist/experimental/index.js"
},
"./foundations/experimental": {
"types": "./dist/foundations/experimental/index.d.ts",
"default": "./dist/foundations/experimental/index.js"
},
"./hooks/experimental": {
"types": "./dist/hooks/experimental/index.d.ts",
"default": "./dist/hooks/experimental/index.js"
},
"./deprecated": {
"types": "./dist/deprecated/index.d.ts",
"default": "./dist/deprecated/index.js"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import {useState} from 'react'
import type {Meta, StoryObj} from '@storybook/react-vite'
import {SelectPanel} from './ReadyMadeSelectPanel'
import {branches} from './mock-refs'

/**
* Layer 3 — Ready-made (no-tabs).
*
* The props-based drop-in for the simple case: pass `items`, get a filterable
* single-select panel. Tabbed pickers are deliberately out of scope here — use
* the Layer 2 Parts + `Tabs` for those (see the Parts story).
*/
const meta: Meta = {
title: 'Experimental/SelectPanel (Modular)/Ready-made',
parameters: {controls: {expanded: true}},
}
export default meta

const items = branches.map(b => ({id: b.id, text: b.name}))

export const SingleSelect: StoryObj = {
render: () => {
const [open, setOpen] = useState(false)
const [selected, setSelected] = useState<Set<string>>(() => new Set(['branch-main']))
const selectedName = items.find(i => selected.has(i.id))?.text ?? 'main'

return (
<SelectPanel
open={open}
onOpenChange={setOpen}
title="Switch branches"
anchor={`Branch: ${selectedName}`}
items={items}
selectionVariant="single"
selectedKeys={selected}
onSelectionChange={setSelected}
placeholder="Filter branches"
/>
)
},
}

export const MultiSelect: StoryObj = {
render: () => {
const [open, setOpen] = useState(false)
const [selected, setSelected] = useState<Set<string>>(() => new Set())

return (
<SelectPanel
open={open}
onOpenChange={setOpen}
title="Select branches"
anchor={`Branches selected: ${selected.size}`}
items={items}
selectionVariant="multiple"
selectedKeys={selected}
onSelectionChange={setSelected}
placeholder="Filter branches"
/>
)
},
}
127 changes: 127 additions & 0 deletions packages/react/src/experimental/SelectPanel/ReadyMadeSelectPanel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
import React, {useMemo, useRef} from 'react'
import {SelectPanelParts as Parts} from './SelectPanel'
import {useFilter, useSelectionState} from '../../hooks/experimental'
import type {SelectionVariant} from '../../hooks/experimental/useSelectionState'
import type {SelectPanelGesture} from '../../foundations/experimental/SelectPanel'

export interface SelectPanelItem {
/** Stable identifier used as the selection key. */
id: string
/** Visible, searchable label. */
text: string
disabled?: boolean
}

export interface SelectPanelProps {
/** Whether the panel is open. */
open: boolean
/** Called when the panel requests to open or close. */
onOpenChange: (open: boolean, gesture: SelectPanelGesture) => void
/** Visible title for the panel. */
title: React.ReactNode
/** Content rendered inside the anchor button. */
anchor: React.ReactNode
/** The selectable items. */
items: SelectPanelItem[]
/** @default 'single' */
selectionVariant?: SelectionVariant
/** Controlled selected keys. */
selectedKeys?: Iterable<string>
/** Initial selected keys (uncontrolled). */
defaultSelectedKeys?: Iterable<string>
/** Called when the selection changes. */
onSelectionChange?: (keys: Set<string>) => void
/** Placeholder for the search input. */
placeholder?: string
/** Width of the overlay. */
width?: 'small' | 'medium' | 'large'
className?: string
}

/**
* Layer 3 — Ready-made SelectPanel for the simple, **no-tabs** case.
*
* This is the drop-in props API for the 80% scenario: pass `items`, get a
* filterable single- or multi-select panel. It composes the Layer 2 Parts
* internally and owns its own filter state.
*
* ⚠️ Tabbed / compositional use cases are intentionally **not** supported here —
* adding a `tabs` prop would re-introduce exactly the config-creep this
* architecture exists to avoid. For tabs, compose the Layer 2 `SelectPanelParts`
* with the `Tabs` primitive directly (see the spec and the Parts story).
*/
export const SelectPanel = React.forwardRef<HTMLDivElement, SelectPanelProps>(function SelectPanel(
{
open,
onOpenChange,
title,
anchor,
items,
selectionVariant = 'single',
selectedKeys,
defaultSelectedKeys,
onSelectionChange,
placeholder = 'Filter…',
width = 'medium',
className,
},
ref,
) {
const anchorRef = useRef<HTMLButtonElement>(null)
const filter = useFilter()
const selection = useSelectionState({
selectionVariant,
selectedKeys,
defaultSelectedKeys,
onSelectionChange,
})

const filteredItems = useMemo(() => filter.filter(items, item => item.text), [filter, items])

return (
<Parts
ref={ref}
open={open}
onOpenChange={onOpenChange}
selectionVariant={selectionVariant}
returnFocusRef={anchorRef}
className={className}
>
<Parts.Anchor ref={anchorRef}>{anchor}</Parts.Anchor>
<Parts.Overlay width={width}>
<Parts.Header>
<Parts.Title>{title}</Parts.Title>
<Parts.Input
aria-label={typeof title === 'string' ? title : 'Filter'}
placeholder={placeholder}
value={filter.query}
onChange={e => filter.setQuery(e.target.value)}
/>
</Parts.Header>
{filteredItems.length === 0 ? (
<Parts.Empty>No matches</Parts.Empty>
) : (
<Parts.List aria-label="Results">
{filteredItems.map(item => (
<Parts.Option
key={item.id}
id={item.id}
disabled={item.disabled}
selected={selection.isSelected(item.id)}
onClick={() => {
if (item.disabled) return
selection.toggle(item.id)
if (selectionVariant === 'single') onOpenChange(false, 'selection')
}}
>
{item.text}
</Parts.Option>
))}
</Parts.List>
)}
</Parts.Overlay>
</Parts>
)
})

SelectPanel.displayName = 'SelectPanel'
161 changes: 161 additions & 0 deletions packages/react/src/experimental/SelectPanel/SelectPanel.module.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
/* Layer 2: Parts — Primer-styled modular SelectPanel */

.Root {
position: relative;
display: inline-block;
}

.Overlay {
position: absolute;
top: 0;
left: 0;
z-index: 1;
display: flex;
flex-direction: column;
inline-size: 320px;
max-inline-size: calc(100vw - var(--base-size-32));
overflow: hidden;
color: var(--fgColor-default);
background-color: var(--overlay-bgColor);
border: var(--borderWidth-thin) solid var(--borderColor-default);
border-radius: var(--borderRadius-large);
box-shadow: var(--shadow-floating-small);

&:where([data-width='small']) {
inline-size: 256px;
}

&:where([data-width='large']) {
inline-size: 480px;
}
}

.Header {
display: flex;
flex-direction: column;
gap: var(--base-size-8);
padding: var(--base-size-12);
border-block-end: var(--borderWidth-thin) solid var(--borderColor-default);
flex-shrink: 0;
}

.Title {
margin: 0;
font-size: var(--text-body-size-medium);
font-weight: var(--text-title-weight-large);
}

.Input {
inline-size: 100%;
}

.TabList {
display: flex;
gap: var(--base-size-4);
padding-inline: var(--base-size-12);
padding-block-start: var(--base-size-8);
border-block-end: var(--borderWidth-thin) solid var(--borderColor-muted);
flex-shrink: 0;
}

.Tab {
display: inline-flex;
align-items: center;
gap: var(--base-size-4);
padding-block: var(--base-size-6);
padding-inline: var(--base-size-8);
font-size: var(--text-body-size-small);
font-weight: var(--base-text-weight-normal);
color: var(--fgColor-muted);
cursor: pointer;
background: transparent;
border: none;
border-block-end: var(--borderWidth-thick) solid transparent;
/* stylelint-disable-next-line primer/spacing */
margin-block-end: calc(-1 * var(--borderWidth-thin));

&:where([aria-selected='true']) {
font-weight: var(--base-text-weight-semibold);
color: var(--fgColor-default);
border-block-end-color: var(--underlineNav-borderColor-active, var(--fgColor-accent));
}

&:where(:focus-visible) {
outline: var(--borderWidth-thick) solid var(--fgColor-accent);
outline-offset: -2px;
border-radius: var(--borderRadius-small);
}
}

.TabCount {
font-size: var(--text-body-size-small);
color: var(--fgColor-muted);
}

.Panel {
display: flex;
flex-direction: column;
min-block-size: 0;
flex-grow: 1;
}

.List {
margin: 0;
padding: var(--base-size-8);
overflow-y: auto;
max-block-size: 240px;
list-style: none;
flex-grow: 1;
}

.Option {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--base-size-8);
padding-block: var(--base-size-6);
padding-inline: var(--base-size-8);
font-size: var(--text-body-size-medium);
color: var(--fgColor-default);
cursor: pointer;
border-radius: var(--borderRadius-medium);

&:where(:hover) {
background-color: var(--control-transparent-bgColor-hover);
}

/* Active descendant (keyboard) highlight */
&:where([data-active-descendant]) {
background-color: var(--control-transparent-bgColor-active, var(--bgColor-accent-muted));
}

&:where([aria-disabled='true']) {
color: var(--fgColor-disabled);
cursor: not-allowed;
}
}

.OptionCheck {
color: var(--fgColor-accent);
visibility: hidden;
}

.Option:where([aria-selected='true']) .OptionCheck {
visibility: visible;
}

.Footer {
display: flex;
justify-content: flex-end;
gap: var(--base-size-8);
padding: var(--base-size-12);
border-block-start: var(--borderWidth-thin) solid var(--borderColor-default);
flex-shrink: 0;
}

.Empty {
padding: var(--base-size-16);
font-size: var(--text-body-size-small);
color: var(--fgColor-muted);
text-align: center;
}
Loading
Loading