Skip to content

Commit bc49f6e

Browse files
committed
Refactor SelectPanel behind feature flag
1 parent 2755680 commit bc49f6e

19 files changed

+4180
-2367
lines changed

packages/react/src/FeatureFlags/DefaultFeatureFlags.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import {FeatureFlagScope} from './FeatureFlagScope'
33
export const DefaultFeatureFlags = FeatureFlagScope.create({
44
primer_react_breadcrumbs_overflow_menu: false,
55
primer_react_css_anchor_positioning: false,
6+
primer_react_select_panel_next: false,
67
primer_react_select_panel_fullscreen_on_narrow: false,
78
primer_react_select_panel_order_selected_at_top: false,
89
primer_react_select_panel_remove_active_descendant: false,

packages/react/src/SelectPanel/SelectPanel.features.stories.tsx

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import React, {useState, useRef, useEffect} from 'react'
22
import type {Meta, StoryObj} from '@storybook/react-vite'
33
import {Button} from '../Button'
4+
import {FeatureFlags} from '../FeatureFlags'
45
import type {ItemInput, GroupedListProps} from '.'
56
import Link from '../Link'
67
import {SelectPanel, type SelectPanelProps} from './SelectPanel'
@@ -333,6 +334,42 @@ export const WithSecondaryActionLink = () => {
333334
)
334335
}
335336

337+
export const NextArchitecturePreview = () => {
338+
const [selected, setSelected] = useState<ItemInput[]>(items.slice(1, 3))
339+
const [filter, setFilter] = useState('')
340+
const filteredItems = items.filter(item => item.text.toLowerCase().startsWith(filter.toLowerCase()))
341+
const [open, setOpen] = useState(false)
342+
343+
return (
344+
<FeatureFlags flags={{primer_react_select_panel_next: true}}>
345+
<FormControl>
346+
<FormControl.Label>Labels</FormControl.Label>
347+
<SelectPanel
348+
title="Select labels"
349+
subtitle="Preview the new SelectPanel architecture behind its feature flag"
350+
placeholder="Select labels"
351+
renderAnchor={({children, ...anchorProps}) => (
352+
<Button trailingAction={TriangleDownIcon} {...anchorProps} aria-haspopup="dialog">
353+
{children}
354+
</Button>
355+
)}
356+
open={open}
357+
onOpenChange={setOpen}
358+
items={filteredItems}
359+
selected={selected}
360+
onSelectedChange={setSelected}
361+
onFilterChange={setFilter}
362+
width="medium"
363+
onCancel={() => setOpen(false)}
364+
secondaryAction={<SelectPanel.SecondaryActionButton>Edit labels</SelectPanel.SecondaryActionButton>}
365+
notice={{text: 'This story is rendered with primer_react_select_panel_next enabled.', variant: 'info'}}
366+
message={filteredItems.length === 0 ? NoResultsMessage(filter) : undefined}
367+
/>
368+
</FormControl>
369+
</FeatureFlags>
370+
)
371+
}
372+
336373
export const WithNotice = () => {
337374
const [selected, setSelected] = useState<ItemInput[]>(items.slice(1, 3))
338375
const [filter, setFilter] = useState('')
Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
1+
import {TriangleDownIcon, type IconProps} from '@primer/octicons-react'
2+
import {announce} from '@primer/live-region-element'
3+
import type React from 'react'
4+
5+
import type {AnchoredOverlayProps} from '../AnchoredOverlay'
6+
import type {AnchoredOverlayWrapperAnchorProps} from '../AnchoredOverlay/AnchoredOverlay'
7+
import {Button, LinkButton} from '../Button'
8+
import type {ButtonProps, LinkButtonProps} from '../Button/types'
9+
import type {FilteredActionListProps, ItemInput} from '../FilteredActionList'
10+
import type {FocusZoneHookSettings} from '../hooks/useFocusZone'
11+
import type {OverlayProps} from '../Overlay'
12+
import {SelectPanelMessage} from './SelectPanelMessage'
13+
14+
export const SHORT_DELAY_MS = 500
15+
export const LONG_DELAY_MS = 1000
16+
17+
const EMPTY_MESSAGE = {
18+
title: 'No items available',
19+
description: '',
20+
}
21+
22+
export const DefaultEmptyMessage = (
23+
<SelectPanelMessage variant="empty" title={EMPTY_MESSAGE.title} key="empty-message">
24+
{EMPTY_MESSAGE.description}
25+
</SelectPanelMessage>
26+
)
27+
28+
async function announceText(text: string, delayMs = SHORT_DELAY_MS) {
29+
const liveRegion = document.querySelector('live-region')
30+
31+
liveRegion?.clear()
32+
33+
await announce(text, {
34+
delayMs,
35+
from: liveRegion || undefined,
36+
})
37+
}
38+
39+
export async function announceLoading() {
40+
await announceText('Loading.')
41+
}
42+
43+
export interface SelectPanelSingleSelection {
44+
selected: ItemInput | undefined
45+
onSelectedChange: (selected: ItemInput | undefined) => void
46+
}
47+
48+
export interface SelectPanelMultiSelection {
49+
selected: ItemInput[]
50+
onSelectedChange: (selected: ItemInput[]) => void
51+
}
52+
53+
export type InitialLoadingType = 'spinner' | 'skeleton'
54+
55+
export function SecondaryActionButton(props: ButtonProps) {
56+
return <Button block {...props} />
57+
}
58+
59+
export function SecondaryActionLink(props: LinkButtonProps & ButtonProps) {
60+
return <LinkButton {...props} variant="invisible" block />
61+
}
62+
63+
export type SelectPanelSecondaryAction =
64+
| React.ReactElement<typeof SecondaryActionButton>
65+
| React.ReactElement<typeof SecondaryActionLink>
66+
67+
export interface SelectPanelBaseProps {
68+
// TODO: Make `title` required in the next major version
69+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
70+
title?: string | React.ReactElement<any>
71+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
72+
subtitle?: string | React.ReactElement<any>
73+
onOpenChange: (
74+
open: boolean,
75+
gesture: 'anchor-click' | 'anchor-key-press' | 'click-outside' | 'escape' | 'selection' | 'cancel',
76+
) => void
77+
secondaryAction?: SelectPanelSecondaryAction
78+
placeholder?: string
79+
// TODO: Make `inputLabel` required in next major version
80+
inputLabel?: string
81+
overlayProps?: Partial<OverlayProps>
82+
initialLoadingType?: InitialLoadingType
83+
className?: string
84+
notice?: {
85+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
86+
text: string | React.ReactElement<any>
87+
variant: 'info' | 'warning' | 'error'
88+
}
89+
message?: {
90+
title: string
91+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
92+
body: string | React.ReactElement<any>
93+
variant: 'empty' | 'error' | 'warning'
94+
icon?: React.ComponentType<IconProps>
95+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
96+
action?: React.ReactElement<any>
97+
}
98+
/**
99+
* @deprecated Use `secondaryAction` instead.
100+
*/
101+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
102+
footer?: string | React.ReactElement<any>
103+
showSelectedOptionsFirst?: boolean
104+
/**
105+
* Whether to disable fullscreen behavior on narrow viewports.
106+
* When `true`, the panel will maintain its anchored position regardless of viewport size.
107+
* When `false`, the panel will go fullscreen on narrow viewports (if feature flag is enabled).
108+
* @default undefined (uses feature flag default)
109+
*/
110+
disableFullscreenOnNarrow?: boolean
111+
showSelectAll?: boolean
112+
/**
113+
* Set to true to allow focus to move to elements that are dynamically prepended to the container.
114+
* Default is false.
115+
*/
116+
focusPrependedElements?: boolean
117+
}
118+
119+
export type SelectPanelListProps = Omit<FilteredActionListProps, 'selectionVariant' | 'variant' | 'message'>
120+
121+
export type SelectPanelVariantProps =
122+
| {variant?: 'anchored'; onCancel?: () => void}
123+
| {variant: 'modal'; onCancel: () => void}
124+
125+
export type SelectPanelProps = SelectPanelBaseProps &
126+
SelectPanelListProps &
127+
Pick<AnchoredOverlayProps, 'open' | 'height' | 'width' | 'align' | 'displayInViewport'> &
128+
AnchoredOverlayWrapperAnchorProps &
129+
(SelectPanelSingleSelection | SelectPanelMultiSelection) &
130+
SelectPanelVariantProps
131+
132+
type SelectPanelPropsWithoutSelection = Omit<
133+
SelectPanelProps,
134+
keyof SelectPanelSingleSelection | keyof SelectPanelMultiSelection
135+
>
136+
137+
export type SelectPanelNextMode = 'single' | 'multi'
138+
139+
export type SelectPanelNextSingleProps = SelectPanelPropsWithoutSelection &
140+
SelectPanelSingleSelection & {
141+
mode: 'single'
142+
}
143+
144+
export type SelectPanelNextMultiProps = SelectPanelPropsWithoutSelection &
145+
SelectPanelMultiSelection & {
146+
mode: 'multi'
147+
}
148+
149+
export type SelectPanelNextProps = SelectPanelNextSingleProps | SelectPanelNextMultiProps
150+
151+
export function isMultiSelectVariant(
152+
selected: SelectPanelSingleSelection['selected'] | SelectPanelMultiSelection['selected'],
153+
): selected is SelectPanelMultiSelection['selected'] {
154+
return Array.isArray(selected)
155+
}
156+
157+
export function toSelectedItemArray(
158+
selected: SelectPanelSingleSelection['selected'] | SelectPanelMultiSelection['selected'],
159+
) {
160+
return Array.isArray(selected) ? selected : selected ? [selected] : []
161+
}
162+
163+
export const focusZoneSettings: Partial<FocusZoneHookSettings> = {
164+
disabled: true,
165+
}
166+
167+
export const closeButtonProps = {'aria-label': 'Cancel and close'}
168+
169+
export const areItemsEqual = (itemA: ItemInput, itemB: ItemInput) => {
170+
if (typeof itemA.id !== 'undefined') {
171+
return itemA.id === itemB.id
172+
}
173+
174+
return itemA === itemB
175+
}
176+
177+
export const doesItemsIncludeItem = (items: ItemInput[], item: ItemInput) => {
178+
return items.some(currentItem => areItemsEqual(currentItem, item))
179+
}
180+
181+
export const defaultRenderAnchor: NonNullable<SelectPanelProps['renderAnchor']> = props => {
182+
const {children, ...rest} = props
183+
184+
return (
185+
<Button trailingAction={TriangleDownIcon} {...rest}>
186+
{children}
187+
</Button>
188+
)
189+
}
190+
191+
export const SELECT_PANEL_SLOT = Symbol('SelectPanel')

0 commit comments

Comments
 (0)