@proteos/ui 0.31.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +105 -0
- package/dist/chunk-IKAX76MB.js +113 -0
- package/dist/chunk-IKAX76MB.js.map +1 -0
- package/dist/chunk-MWAXK66H.cjs +119 -0
- package/dist/chunk-MWAXK66H.cjs.map +1 -0
- package/dist/index.cjs +6246 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +2126 -0
- package/dist/index.d.ts +2126 -0
- package/dist/index.js +6069 -0
- package/dist/index.js.map +1 -0
- package/dist/pdf-view-3ZMMS6ML.cjs +95 -0
- package/dist/pdf-view-3ZMMS6ML.cjs.map +1 -0
- package/dist/pdf-view-BV5ZD6OC.js +93 -0
- package/dist/pdf-view-BV5ZD6OC.js.map +1 -0
- package/package.json +96 -0
- package/src/components/attachment-chip/attachment-chip.tsx +129 -0
- package/src/components/attachment-chip/index.ts +2 -0
- package/src/components/attribute-type-icon/attribute-type-icon.stories.tsx +70 -0
- package/src/components/attribute-type-icon/attribute-type-icon.tsx +125 -0
- package/src/components/attribute-type-icon/index.ts +8 -0
- package/src/components/avatar/avatar.stories.tsx +42 -0
- package/src/components/avatar/avatar.tsx +48 -0
- package/src/components/avatar/index.ts +2 -0
- package/src/components/avatar-group/avatar-group.test.tsx +38 -0
- package/src/components/avatar-group/avatar-group.tsx +60 -0
- package/src/components/avatar-group/index.ts +2 -0
- package/src/components/badge/badge.stories.tsx +62 -0
- package/src/components/badge/badge.test.tsx +36 -0
- package/src/components/badge/badge.tsx +54 -0
- package/src/components/badge/index.ts +2 -0
- package/src/components/badge-multi-select/badge-multi-select.stories.tsx +177 -0
- package/src/components/badge-multi-select/badge-multi-select.test.tsx +155 -0
- package/src/components/badge-multi-select/badge-multi-select.tsx +272 -0
- package/src/components/badge-multi-select/index.ts +2 -0
- package/src/components/breadcrumbs/breadcrumbs.stories.tsx +43 -0
- package/src/components/breadcrumbs/breadcrumbs.tsx +86 -0
- package/src/components/breadcrumbs/index.ts +2 -0
- package/src/components/button/button.stories.tsx +290 -0
- package/src/components/button/button.test.tsx +59 -0
- package/src/components/button/button.tsx +86 -0
- package/src/components/button/index.ts +2 -0
- package/src/components/card/card.stories.tsx +351 -0
- package/src/components/card/card.test.tsx +141 -0
- package/src/components/card/card.tsx +173 -0
- package/src/components/card/index.ts +13 -0
- package/src/components/checkbox/checkbox-group.tsx +52 -0
- package/src/components/checkbox/checkbox.stories.tsx +48 -0
- package/src/components/checkbox/checkbox.test.tsx +50 -0
- package/src/components/checkbox/checkbox.tsx +71 -0
- package/src/components/checkbox/index.ts +4 -0
- package/src/components/combobox/combobox.stories.tsx +97 -0
- package/src/components/combobox/combobox.test.tsx +123 -0
- package/src/components/combobox/combobox.tsx +239 -0
- package/src/components/combobox/index.ts +2 -0
- package/src/components/command/command-hint.tsx +46 -0
- package/src/components/command/command.stories.tsx +88 -0
- package/src/components/command/command.test.tsx +102 -0
- package/src/components/command/command.tsx +76 -0
- package/src/components/command/index.ts +16 -0
- package/src/components/currency-input/currency-input.tsx +192 -0
- package/src/components/currency-input/index.ts +6 -0
- package/src/components/data-table/cells.stories.tsx +120 -0
- package/src/components/data-table/cells.tsx +165 -0
- package/src/components/data-table/data-table.stories.tsx +371 -0
- package/src/components/data-table/data-table.tsx +556 -0
- package/src/components/data-table/index.ts +22 -0
- package/src/components/data-table/make-actions-column.ts +30 -0
- package/src/components/data-table/states/data-table-empty.tsx +40 -0
- package/src/components/data-table/states/data-table-error.tsx +30 -0
- package/src/components/data-table/states/data-table-skeleton.tsx +42 -0
- package/src/components/data-table/use-infinite-scroll-sentinel.ts +51 -0
- package/src/components/date-picker/calendar-internals.tsx +138 -0
- package/src/components/date-picker/date-picker.stories.tsx +78 -0
- package/src/components/date-picker/date-picker.test.tsx +71 -0
- package/src/components/date-picker/date-picker.tsx +140 -0
- package/src/components/date-picker/index.ts +2 -0
- package/src/components/date-time-picker/date-time-picker.stories.tsx +96 -0
- package/src/components/date-time-picker/date-time-picker.test.tsx +87 -0
- package/src/components/date-time-picker/date-time-picker.tsx +203 -0
- package/src/components/date-time-picker/index.ts +2 -0
- package/src/components/empty-state/empty-state.tsx +63 -0
- package/src/components/empty-state/index.ts +2 -0
- package/src/components/error-animation/error-animation.stories.tsx +50 -0
- package/src/components/error-animation/error-animation.test.tsx +22 -0
- package/src/components/error-animation/error-animation.tsx +26 -0
- package/src/components/error-animation/index.ts +2 -0
- package/src/components/field/field.tsx +142 -0
- package/src/components/field/index.ts +8 -0
- package/src/components/file-field/file-field.tsx +285 -0
- package/src/components/file-field/index.ts +2 -0
- package/src/components/file-viewer/fallback-card.tsx +68 -0
- package/src/components/file-viewer/file-viewer.stories.tsx +67 -0
- package/src/components/file-viewer/file-viewer.test.tsx +81 -0
- package/src/components/file-viewer/file-viewer.tsx +116 -0
- package/src/components/file-viewer/image-view.tsx +38 -0
- package/src/components/file-viewer/index.ts +7 -0
- package/src/components/file-viewer/pdf-view.tsx +122 -0
- package/src/components/file-viewer/video-view.tsx +16 -0
- package/src/components/image-attachment/image-attachment.stories.tsx +39 -0
- package/src/components/image-attachment/image-attachment.test.tsx +54 -0
- package/src/components/image-attachment/image-attachment.tsx +77 -0
- package/src/components/image-attachment/index.ts +3 -0
- package/src/components/image-attachment/is-previewable-image-name.ts +14 -0
- package/src/components/image-lightbox/image-lightbox.stories.tsx +38 -0
- package/src/components/image-lightbox/image-lightbox.test.tsx +72 -0
- package/src/components/image-lightbox/image-lightbox.tsx +92 -0
- package/src/components/image-lightbox/index.ts +2 -0
- package/src/components/input/index.ts +2 -0
- package/src/components/input/input.stories.tsx +71 -0
- package/src/components/input/input.test.tsx +36 -0
- package/src/components/input/input.tsx +76 -0
- package/src/components/kbd/index.ts +2 -0
- package/src/components/kbd/kbd.stories.tsx +34 -0
- package/src/components/kbd/kbd.tsx +27 -0
- package/src/components/menu/index.ts +2 -0
- package/src/components/menu/menu.tsx +121 -0
- package/src/components/modal/index.ts +10 -0
- package/src/components/modal/modal.stories.tsx +153 -0
- package/src/components/modal/modal.test.tsx +93 -0
- package/src/components/modal/modal.tsx +149 -0
- package/src/components/multi-select/index.ts +2 -0
- package/src/components/multi-select/multi-select.stories.tsx +211 -0
- package/src/components/multi-select/multi-select.test.tsx +151 -0
- package/src/components/multi-select/multi-select.tsx +339 -0
- package/src/components/nav/index.ts +7 -0
- package/src/components/nav/nav.stories.tsx +139 -0
- package/src/components/nav/nav.tsx +246 -0
- package/src/components/popover/index.ts +8 -0
- package/src/components/popover/popover.stories.tsx +92 -0
- package/src/components/popover/popover.tsx +135 -0
- package/src/components/progress-bar/index.ts +2 -0
- package/src/components/progress-bar/progress-bar.stories.tsx +75 -0
- package/src/components/progress-bar/progress-bar.test.tsx +29 -0
- package/src/components/progress-bar/progress-bar.tsx +57 -0
- package/src/components/radio-group/index.ts +2 -0
- package/src/components/radio-group/radio-group.stories.tsx +113 -0
- package/src/components/radio-group/radio-group.test.tsx +87 -0
- package/src/components/radio-group/radio-group.tsx +104 -0
- package/src/components/record-filter/attribute-picker.tsx +74 -0
- package/src/components/record-filter/chip-value.tsx +171 -0
- package/src/components/record-filter/filter-row.tsx +262 -0
- package/src/components/record-filter/filter-value-input.tsx +351 -0
- package/src/components/record-filter/group-add-controls.tsx +77 -0
- package/src/components/record-filter/group-body.tsx +173 -0
- package/src/components/record-filter/group-mutations.test.ts +104 -0
- package/src/components/record-filter/group-mutations.ts +116 -0
- package/src/components/record-filter/index.ts +58 -0
- package/src/components/record-filter/nested-group.tsx +138 -0
- package/src/components/record-filter/operator-picker.tsx +62 -0
- package/src/components/record-filter/operators.test.ts +105 -0
- package/src/components/record-filter/operators.ts +97 -0
- package/src/components/record-filter/record-filter-builder-panel.tsx +236 -0
- package/src/components/record-filter/record-filter-button.tsx +126 -0
- package/src/components/record-filter/record-filter-chips.tsx +527 -0
- package/src/components/record-filter/record-filter-editor.tsx +91 -0
- package/src/components/record-filter/record-filter.stories.tsx +196 -0
- package/src/components/record-filter/record-filter.test.tsx +437 -0
- package/src/components/record-filter/show-details-store.ts +72 -0
- package/src/components/record-filter/types.ts +150 -0
- package/src/components/segmented-control/index.ts +6 -0
- package/src/components/segmented-control/segmented-control.stories.tsx +67 -0
- package/src/components/segmented-control/segmented-control.test.tsx +47 -0
- package/src/components/segmented-control/segmented-control.tsx +105 -0
- package/src/components/select/index.ts +2 -0
- package/src/components/select/select.stories.tsx +76 -0
- package/src/components/select/select.test.tsx +56 -0
- package/src/components/select/select.tsx +159 -0
- package/src/components/separator/index.ts +2 -0
- package/src/components/separator/separator.stories.tsx +73 -0
- package/src/components/separator/separator.test.tsx +29 -0
- package/src/components/separator/separator.tsx +51 -0
- package/src/components/skeleton/index.ts +2 -0
- package/src/components/skeleton/skeleton.stories.tsx +68 -0
- package/src/components/skeleton/skeleton.test.tsx +38 -0
- package/src/components/skeleton/skeleton.tsx +43 -0
- package/src/components/success-animation/index.ts +2 -0
- package/src/components/success-animation/success-animation.stories.tsx +50 -0
- package/src/components/success-animation/success-animation.test.tsx +22 -0
- package/src/components/success-animation/success-animation.tsx +26 -0
- package/src/components/switch/index.ts +2 -0
- package/src/components/switch/switch.stories.tsx +60 -0
- package/src/components/switch/switch.test.tsx +50 -0
- package/src/components/switch/switch.tsx +49 -0
- package/src/components/table/index.ts +25 -0
- package/src/components/table/table.stories.tsx +306 -0
- package/src/components/table/table.test.tsx +115 -0
- package/src/components/table/table.tsx +543 -0
- package/src/components/tabs/index.ts +8 -0
- package/src/components/tabs/tabs.tsx +141 -0
- package/src/components/tag-input/index.ts +2 -0
- package/src/components/tag-input/tag-input.stories.tsx +73 -0
- package/src/components/tag-input/tag-input.test.tsx +80 -0
- package/src/components/tag-input/tag-input.tsx +193 -0
- package/src/components/textarea/index.ts +2 -0
- package/src/components/textarea/textarea.stories.tsx +75 -0
- package/src/components/textarea/textarea.test.tsx +41 -0
- package/src/components/textarea/textarea.tsx +86 -0
- package/src/components/time-field/index.ts +2 -0
- package/src/components/time-field/time-field.stories.tsx +76 -0
- package/src/components/time-field/time-field.test.tsx +51 -0
- package/src/components/time-field/time-field.tsx +98 -0
- package/src/components/tooltip/index.ts +2 -0
- package/src/components/tooltip/tooltip.stories.tsx +176 -0
- package/src/components/tooltip/tooltip.test.tsx +52 -0
- package/src/components/tooltip/tooltip.tsx +56 -0
- package/src/index.ts +47 -0
- package/src/lib/cn.ts +6 -0
- package/src/lib/commands/discovery-store.test.tsx +76 -0
- package/src/lib/commands/discovery-store.ts +110 -0
- package/src/lib/commands/index.ts +9 -0
- package/src/lib/commands/match-event.test.ts +99 -0
- package/src/lib/commands/match-event.ts +31 -0
- package/src/lib/commands/parse-combo.test.ts +65 -0
- package/src/lib/commands/parse-combo.ts +48 -0
- package/src/lib/commands/platform.ts +27 -0
- package/src/lib/commands/protected-combos.test.ts +18 -0
- package/src/lib/commands/protected-combos.ts +38 -0
- package/src/lib/commands/registry.test.ts +97 -0
- package/src/lib/commands/registry.ts +59 -0
- package/src/lib/commands/use-command.test.tsx +91 -0
- package/src/lib/commands/use-command.ts +32 -0
- package/src/lib/commands/use-escape-key.test.tsx +97 -0
- package/src/lib/commands/use-escape-key.ts +62 -0
- package/src/styles.css +321 -0
- package/src/test/setup.ts +9 -0
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { ReactNode } from 'react'
|
|
2
|
+
import {
|
|
3
|
+
CheckboxGroup as AriaCheckboxGroup,
|
|
4
|
+
type CheckboxGroupProps as AriaCheckboxGroupProps,
|
|
5
|
+
FieldError,
|
|
6
|
+
Label,
|
|
7
|
+
Text,
|
|
8
|
+
} from 'react-aria-components'
|
|
9
|
+
import { cn } from '../../lib/cn'
|
|
10
|
+
import { fieldErrorStyles, fieldHintStyles, fieldLabelStyles } from '../field/field'
|
|
11
|
+
|
|
12
|
+
/* =========================================================================
|
|
13
|
+
CheckboxGroup — multi-choice field, the checkbox counterpart to RadioGroup.
|
|
14
|
+
Monolithic API to match it: label / description / errorMessage props on the
|
|
15
|
+
group, `<Checkbox value="…">` children. `value` is a string[] of selected
|
|
16
|
+
values (controlled via value/onChange, or defaultValue).
|
|
17
|
+
========================================================================= */
|
|
18
|
+
|
|
19
|
+
export type CheckboxGroupProps = Omit<AriaCheckboxGroupProps, 'children'> & {
|
|
20
|
+
label?: ReactNode
|
|
21
|
+
description?: ReactNode
|
|
22
|
+
errorMessage?: ReactNode
|
|
23
|
+
/** `<Checkbox value="…">` elements. Function-as-children is not supported at the group level. */
|
|
24
|
+
children: ReactNode
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function CheckboxGroup({
|
|
28
|
+
label,
|
|
29
|
+
description,
|
|
30
|
+
errorMessage,
|
|
31
|
+
children,
|
|
32
|
+
className,
|
|
33
|
+
...props
|
|
34
|
+
}: CheckboxGroupProps) {
|
|
35
|
+
return (
|
|
36
|
+
<AriaCheckboxGroup
|
|
37
|
+
{...props}
|
|
38
|
+
className={(renderProps) =>
|
|
39
|
+
cn('grid gap-2', typeof className === 'function' ? className(renderProps) : className)
|
|
40
|
+
}
|
|
41
|
+
>
|
|
42
|
+
{label ? <Label className={fieldLabelStyles}>{label}</Label> : null}
|
|
43
|
+
<div className="grid gap-3">{children}</div>
|
|
44
|
+
{description ? (
|
|
45
|
+
<Text slot="description" className={fieldHintStyles}>
|
|
46
|
+
{description}
|
|
47
|
+
</Text>
|
|
48
|
+
) : null}
|
|
49
|
+
{errorMessage ? <FieldError className={fieldErrorStyles}>{errorMessage}</FieldError> : null}
|
|
50
|
+
</AriaCheckboxGroup>
|
|
51
|
+
)
|
|
52
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react-vite'
|
|
2
|
+
|
|
3
|
+
import { Checkbox } from './checkbox'
|
|
4
|
+
|
|
5
|
+
const meta: Meta<typeof Checkbox> = {
|
|
6
|
+
title: 'Components/Checkbox',
|
|
7
|
+
component: Checkbox,
|
|
8
|
+
tags: ['autodocs'],
|
|
9
|
+
args: {
|
|
10
|
+
children: 'Audit log',
|
|
11
|
+
},
|
|
12
|
+
argTypes: {
|
|
13
|
+
isDisabled: { control: { type: 'boolean' } },
|
|
14
|
+
isSelected: { control: { type: 'boolean' } },
|
|
15
|
+
isIndeterminate: { control: { type: 'boolean' } },
|
|
16
|
+
},
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export default meta
|
|
20
|
+
type Story = StoryObj<typeof Checkbox>
|
|
21
|
+
|
|
22
|
+
export const Playground: Story = {}
|
|
23
|
+
|
|
24
|
+
export const Group: Story = {
|
|
25
|
+
parameters: { controls: { hideNoControlsWarning: true } },
|
|
26
|
+
render: () => (
|
|
27
|
+
<div className="grid gap-3">
|
|
28
|
+
<Checkbox defaultSelected>Audit log</Checkbox>
|
|
29
|
+
<Checkbox defaultSelected>Row-level permissions</Checkbox>
|
|
30
|
+
<Checkbox>Weekly architect review</Checkbox>
|
|
31
|
+
</div>
|
|
32
|
+
),
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export const AllStates: Story = {
|
|
36
|
+
parameters: { controls: { hideNoControlsWarning: true } },
|
|
37
|
+
render: () => (
|
|
38
|
+
<div className="grid gap-3">
|
|
39
|
+
<Checkbox>Unchecked</Checkbox>
|
|
40
|
+
<Checkbox defaultSelected>Checked</Checkbox>
|
|
41
|
+
<Checkbox isIndeterminate>Indeterminate</Checkbox>
|
|
42
|
+
<Checkbox isDisabled>Disabled</Checkbox>
|
|
43
|
+
<Checkbox isDisabled defaultSelected>
|
|
44
|
+
Disabled · checked
|
|
45
|
+
</Checkbox>
|
|
46
|
+
</div>
|
|
47
|
+
),
|
|
48
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { render, screen } from '@testing-library/react'
|
|
2
|
+
import userEvent from '@testing-library/user-event'
|
|
3
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
4
|
+
|
|
5
|
+
import { Checkbox } from './checkbox'
|
|
6
|
+
|
|
7
|
+
describe('Checkbox', () => {
|
|
8
|
+
it('renders its label', () => {
|
|
9
|
+
render(<Checkbox>Audit log</Checkbox>)
|
|
10
|
+
expect(screen.getByRole('checkbox', { name: 'Audit log' })).toBeInTheDocument()
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
it('toggles on click', async () => {
|
|
14
|
+
const user = userEvent.setup()
|
|
15
|
+
const onChange = vi.fn()
|
|
16
|
+
render(<Checkbox onChange={onChange}>Audit log</Checkbox>)
|
|
17
|
+
|
|
18
|
+
await user.click(screen.getByRole('checkbox', { name: 'Audit log' }))
|
|
19
|
+
expect(onChange).toHaveBeenCalledWith(true)
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
it('toggles on keyboard Space', async () => {
|
|
23
|
+
const user = userEvent.setup()
|
|
24
|
+
const onChange = vi.fn()
|
|
25
|
+
render(<Checkbox onChange={onChange}>Audit log</Checkbox>)
|
|
26
|
+
|
|
27
|
+
await user.tab()
|
|
28
|
+
expect(screen.getByRole('checkbox')).toHaveFocus()
|
|
29
|
+
await user.keyboard(' ')
|
|
30
|
+
expect(onChange).toHaveBeenCalledWith(true)
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('does not toggle when disabled', async () => {
|
|
34
|
+
const user = userEvent.setup()
|
|
35
|
+
const onChange = vi.fn()
|
|
36
|
+
render(
|
|
37
|
+
<Checkbox onChange={onChange} isDisabled>
|
|
38
|
+
Audit log
|
|
39
|
+
</Checkbox>,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
await user.click(screen.getByRole('checkbox', { name: 'Audit log' }))
|
|
43
|
+
expect(onChange).not.toHaveBeenCalled()
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('reflects a controlled checked state', () => {
|
|
47
|
+
render(<Checkbox isSelected>Row-level permissions</Checkbox>)
|
|
48
|
+
expect(screen.getByRole('checkbox', { name: 'Row-level permissions' })).toBeChecked()
|
|
49
|
+
})
|
|
50
|
+
})
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import type { ReactNode } from 'react'
|
|
2
|
+
import {
|
|
3
|
+
Checkbox as AriaCheckbox,
|
|
4
|
+
type CheckboxProps as AriaCheckboxProps,
|
|
5
|
+
} from 'react-aria-components'
|
|
6
|
+
|
|
7
|
+
import { cn } from '../../lib/cn'
|
|
8
|
+
|
|
9
|
+
export type CheckboxProps = Omit<AriaCheckboxProps, 'children'> & {
|
|
10
|
+
children?: ReactNode
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function Checkbox({ children, className, ...props }: CheckboxProps) {
|
|
14
|
+
return (
|
|
15
|
+
<AriaCheckbox
|
|
16
|
+
{...props}
|
|
17
|
+
className={(renderProps) =>
|
|
18
|
+
cn(
|
|
19
|
+
'group inline-flex items-center gap-2.5 cursor-pointer',
|
|
20
|
+
'font-sans text-[14px] text-ink-2 select-none',
|
|
21
|
+
'data-[disabled]:opacity-45 data-[disabled]:cursor-not-allowed',
|
|
22
|
+
typeof className === 'function' ? className(renderProps) : className,
|
|
23
|
+
)
|
|
24
|
+
}
|
|
25
|
+
>
|
|
26
|
+
{(renderProps) => (
|
|
27
|
+
<>
|
|
28
|
+
<span
|
|
29
|
+
aria-hidden
|
|
30
|
+
className={cn(
|
|
31
|
+
'relative inline-flex items-center justify-center shrink-0',
|
|
32
|
+
'size-[18px] rounded-[4px] border bg-surface',
|
|
33
|
+
'border-ink-4',
|
|
34
|
+
'transition-[background-color,border-color,box-shadow] duration-150',
|
|
35
|
+
// hovered
|
|
36
|
+
'group-data-[hovered]:border-ink',
|
|
37
|
+
// checked / indeterminate — fill with ink
|
|
38
|
+
'group-data-[selected]:bg-ink group-data-[selected]:border-ink',
|
|
39
|
+
'group-data-[indeterminate]:bg-ink group-data-[indeterminate]:border-ink',
|
|
40
|
+
// focus ring on the box
|
|
41
|
+
'group-data-[focus-visible]:shadow-[var(--shadow-focus)]',
|
|
42
|
+
)}
|
|
43
|
+
>
|
|
44
|
+
{renderProps.isIndeterminate ? (
|
|
45
|
+
<span className="block h-px w-2.5 bg-surface" />
|
|
46
|
+
) : (
|
|
47
|
+
<svg
|
|
48
|
+
viewBox="0 0 12 12"
|
|
49
|
+
className={cn(
|
|
50
|
+
'size-3 text-surface transition-opacity duration-150',
|
|
51
|
+
renderProps.isSelected ? 'opacity-100' : 'opacity-0',
|
|
52
|
+
)}
|
|
53
|
+
aria-hidden
|
|
54
|
+
>
|
|
55
|
+
<path
|
|
56
|
+
d="M2.5 6.25 5 8.75 9.5 3.75"
|
|
57
|
+
stroke="currentColor"
|
|
58
|
+
strokeWidth="1.6"
|
|
59
|
+
strokeLinecap="round"
|
|
60
|
+
strokeLinejoin="round"
|
|
61
|
+
fill="none"
|
|
62
|
+
/>
|
|
63
|
+
</svg>
|
|
64
|
+
)}
|
|
65
|
+
</span>
|
|
66
|
+
{children}
|
|
67
|
+
</>
|
|
68
|
+
)}
|
|
69
|
+
</AriaCheckbox>
|
|
70
|
+
)
|
|
71
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react-vite'
|
|
2
|
+
|
|
3
|
+
import { Combobox } from './combobox'
|
|
4
|
+
|
|
5
|
+
const carriers = [
|
|
6
|
+
{ id: 'blue-ridge', label: 'Blue Ridge', description: 'FTL · 42 trucks' },
|
|
7
|
+
{ id: 'north-star', label: 'North Star', description: 'LTL · 118 trucks' },
|
|
8
|
+
{ id: 'sunline', label: 'Sunline', description: 'FTL · 24 trucks' },
|
|
9
|
+
{ id: 'cascade', label: 'Cascade', description: 'Reefer · 56 trucks' },
|
|
10
|
+
{ id: 'atlantic', label: 'Atlantic', description: 'Intermodal · 81 trucks' },
|
|
11
|
+
{ id: 'pioneer', label: 'Pioneer', description: 'FTL · 17 trucks' },
|
|
12
|
+
{ id: 'meridian', label: 'Meridian', description: 'LTL · 93 trucks' },
|
|
13
|
+
{ id: 'heartland', label: 'Heartland', description: 'FTL · 211 trucks' },
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
const meta: Meta<typeof Combobox> = {
|
|
17
|
+
title: 'Components/Combobox',
|
|
18
|
+
component: Combobox,
|
|
19
|
+
tags: ['autodocs'],
|
|
20
|
+
args: {
|
|
21
|
+
label: 'Carrier',
|
|
22
|
+
placeholder: 'Search carriers…',
|
|
23
|
+
options: carriers,
|
|
24
|
+
},
|
|
25
|
+
argTypes: {
|
|
26
|
+
isDisabled: { control: { type: 'boolean' } },
|
|
27
|
+
isInvalid: { control: { type: 'boolean' } },
|
|
28
|
+
allowsCustomValue: { control: { type: 'boolean' } },
|
|
29
|
+
menuTrigger: {
|
|
30
|
+
control: { type: 'inline-radio' },
|
|
31
|
+
options: ['input', 'focus', 'manual'],
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
decorators: [
|
|
35
|
+
(Story) => (
|
|
36
|
+
<div className="w-[440px]">
|
|
37
|
+
<Story />
|
|
38
|
+
</div>
|
|
39
|
+
),
|
|
40
|
+
],
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export default meta
|
|
44
|
+
type Story = StoryObj<typeof Combobox>
|
|
45
|
+
|
|
46
|
+
export const Playground: Story = {}
|
|
47
|
+
|
|
48
|
+
export const WithDescription: Story = {
|
|
49
|
+
args: {
|
|
50
|
+
description: 'Type to filter the list, or open the menu to browse.',
|
|
51
|
+
},
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export const OpensOnFocus: Story = {
|
|
55
|
+
args: {
|
|
56
|
+
menuTrigger: 'focus',
|
|
57
|
+
description: 'Tab into the field — the menu opens immediately.',
|
|
58
|
+
},
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export const AllowsCustomValue: Story = {
|
|
62
|
+
args: {
|
|
63
|
+
allowsCustomValue: true,
|
|
64
|
+
description: 'Pick an existing carrier, or type a new one. Useful for tag-style entry.',
|
|
65
|
+
},
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export const Invalid: Story = {
|
|
69
|
+
args: {
|
|
70
|
+
isInvalid: true,
|
|
71
|
+
errorMessage: 'Pick a carrier before dispatching.',
|
|
72
|
+
},
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export const Disabled: Story = {
|
|
76
|
+
args: {
|
|
77
|
+
isDisabled: true,
|
|
78
|
+
defaultSelectedKey: 'blue-ridge',
|
|
79
|
+
},
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export const AllStates: Story = {
|
|
83
|
+
parameters: { controls: { hideNoControlsWarning: true } },
|
|
84
|
+
render: () => (
|
|
85
|
+
<div className="grid gap-5 w-[440px]">
|
|
86
|
+
<Combobox label="Rest" options={carriers} />
|
|
87
|
+
<Combobox label="Selected" options={carriers} defaultSelectedKey="north-star" />
|
|
88
|
+
<Combobox
|
|
89
|
+
label="Invalid"
|
|
90
|
+
options={carriers}
|
|
91
|
+
isInvalid
|
|
92
|
+
errorMessage="Pick a carrier before dispatching."
|
|
93
|
+
/>
|
|
94
|
+
<Combobox label="Disabled" options={carriers} defaultSelectedKey="cascade" isDisabled />
|
|
95
|
+
</div>
|
|
96
|
+
),
|
|
97
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { fireEvent, render, screen } from '@testing-library/react'
|
|
2
|
+
import userEvent from '@testing-library/user-event'
|
|
3
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
4
|
+
|
|
5
|
+
import { Combobox } from './combobox'
|
|
6
|
+
|
|
7
|
+
const carriers = [
|
|
8
|
+
{ id: 'blue-ridge', label: 'Blue Ridge' },
|
|
9
|
+
{ id: 'north-star', label: 'North Star' },
|
|
10
|
+
{ id: 'sunline', label: 'Sunline' },
|
|
11
|
+
{ id: 'cascade', label: 'Cascade' },
|
|
12
|
+
{ id: 'atlantic', label: 'Atlantic' },
|
|
13
|
+
] as const
|
|
14
|
+
|
|
15
|
+
describe('Combobox', () => {
|
|
16
|
+
it('renders a labelled combobox input', () => {
|
|
17
|
+
render(<Combobox label="Carrier" options={[...carriers]} />)
|
|
18
|
+
expect(screen.getByRole('combobox', { name: 'Carrier' })).toBeInTheDocument()
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
it('filters options as the user types', async () => {
|
|
22
|
+
const user = userEvent.setup()
|
|
23
|
+
render(<Combobox label="Carrier" options={[...carriers]} />)
|
|
24
|
+
|
|
25
|
+
// Open the menu via the trigger button (deterministic in jsdom),
|
|
26
|
+
// then type into the input to filter the now-visible list.
|
|
27
|
+
await user.click(screen.getByRole('button'))
|
|
28
|
+
await screen.findByRole('listbox')
|
|
29
|
+
|
|
30
|
+
const input = screen.getByRole('combobox', { name: 'Carrier' })
|
|
31
|
+
await user.click(input)
|
|
32
|
+
await user.keyboard('su')
|
|
33
|
+
|
|
34
|
+
expect(await screen.findByRole('option', { name: 'Sunline' })).toBeInTheDocument()
|
|
35
|
+
expect(screen.queryByRole('option', { name: 'Blue Ridge' })).not.toBeInTheDocument()
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('fires onSelectionChange when an option is picked', async () => {
|
|
39
|
+
const user = userEvent.setup()
|
|
40
|
+
const onSelectionChange = vi.fn()
|
|
41
|
+
render(
|
|
42
|
+
<Combobox label="Carrier" options={[...carriers]} onSelectionChange={onSelectionChange} />,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
await user.click(screen.getByRole('button'))
|
|
46
|
+
await user.click(await screen.findByRole('option', { name: 'North Star' }))
|
|
47
|
+
expect(onSelectionChange).toHaveBeenCalledWith('north-star')
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('opens the listbox via the trigger button', async () => {
|
|
51
|
+
const user = userEvent.setup()
|
|
52
|
+
render(<Combobox label="Carrier" options={[...carriers]} />)
|
|
53
|
+
|
|
54
|
+
const trigger = screen.getByRole('button')
|
|
55
|
+
await user.click(trigger)
|
|
56
|
+
|
|
57
|
+
expect(await screen.findByRole('listbox')).toBeInTheDocument()
|
|
58
|
+
expect(screen.getAllByRole('option')).toHaveLength(carriers.length)
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('shows the empty-state message when no option matches', async () => {
|
|
62
|
+
const user = userEvent.setup()
|
|
63
|
+
render(<Combobox label="Carrier" options={[...carriers]} emptyMessage="No carriers match" />)
|
|
64
|
+
|
|
65
|
+
await user.click(screen.getByRole('button'))
|
|
66
|
+
await screen.findByRole('listbox')
|
|
67
|
+
|
|
68
|
+
const input = screen.getByRole('combobox', { name: 'Carrier' })
|
|
69
|
+
await user.click(input)
|
|
70
|
+
await user.keyboard('zzzz')
|
|
71
|
+
|
|
72
|
+
expect(await screen.findByText('No carriers match')).toBeInTheDocument()
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('renders the error message when invalid', () => {
|
|
76
|
+
render(
|
|
77
|
+
<Combobox label="Carrier" options={[...carriers]} isInvalid errorMessage="Pick a carrier" />,
|
|
78
|
+
)
|
|
79
|
+
expect(screen.getByText('Pick a carrier')).toBeInTheDocument()
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it('disables the input when isDisabled is set', () => {
|
|
83
|
+
render(<Combobox label="Carrier" options={[...carriers]} isDisabled />)
|
|
84
|
+
expect(screen.getByRole('combobox', { name: 'Carrier' })).toBeDisabled()
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
it('renders a per-option trailing slot whose clicks do not select the row', async () => {
|
|
88
|
+
const user = userEvent.setup()
|
|
89
|
+
const onTrailing = vi.fn()
|
|
90
|
+
const onSelectionChange = vi.fn()
|
|
91
|
+
render(
|
|
92
|
+
<Combobox
|
|
93
|
+
label="Carrier"
|
|
94
|
+
onSelectionChange={onSelectionChange}
|
|
95
|
+
options={[
|
|
96
|
+
{
|
|
97
|
+
id: 'blue-ridge',
|
|
98
|
+
label: 'Blue Ridge',
|
|
99
|
+
trailing: (
|
|
100
|
+
<button type="button" aria-label="Peek Blue Ridge" onClick={onTrailing}>
|
|
101
|
+
↗
|
|
102
|
+
</button>
|
|
103
|
+
),
|
|
104
|
+
},
|
|
105
|
+
{ id: 'north-star', label: 'North Star' },
|
|
106
|
+
]}
|
|
107
|
+
/>,
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
// Before the popover opens, the trigger is the only button in the DOM.
|
|
111
|
+
await user.click(screen.getByRole('button'))
|
|
112
|
+
// The peek button should be in the DOM (CSS hides it until hover/focus,
|
|
113
|
+
// which jsdom can't simulate; presence is what we assert here).
|
|
114
|
+
const peek = await screen.findByRole('button', { name: 'Peek Blue Ridge' })
|
|
115
|
+
|
|
116
|
+
// userEvent's full pointer-event sequence gets tangled with RAC's
|
|
117
|
+
// listbox press handling in jsdom; a direct synthetic click on the
|
|
118
|
+
// trailing button is enough to verify the wiring + propagation guard.
|
|
119
|
+
fireEvent.click(peek)
|
|
120
|
+
expect(onTrailing).toHaveBeenCalledTimes(1)
|
|
121
|
+
expect(onSelectionChange).not.toHaveBeenCalled()
|
|
122
|
+
})
|
|
123
|
+
})
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
import { type ReactNode, useContext } from 'react'
|
|
2
|
+
import {
|
|
3
|
+
ComboBox as AriaComboBox,
|
|
4
|
+
type ComboBoxProps as AriaComboBoxProps,
|
|
5
|
+
ComboBoxStateContext,
|
|
6
|
+
Button as AriaComboButton,
|
|
7
|
+
Input as AriaInput,
|
|
8
|
+
FieldError,
|
|
9
|
+
type Key,
|
|
10
|
+
Label,
|
|
11
|
+
ListBox,
|
|
12
|
+
ListBoxItem,
|
|
13
|
+
type ListBoxItemProps,
|
|
14
|
+
Popover,
|
|
15
|
+
Text,
|
|
16
|
+
} from 'react-aria-components'
|
|
17
|
+
import { cn } from '../../lib/cn'
|
|
18
|
+
import {
|
|
19
|
+
type FieldAppearance,
|
|
20
|
+
FieldCaret,
|
|
21
|
+
fieldControlStyles,
|
|
22
|
+
fieldErrorStyles,
|
|
23
|
+
type FieldSize,
|
|
24
|
+
fieldHintStyles,
|
|
25
|
+
fieldLabelStyles,
|
|
26
|
+
} from '../field/field'
|
|
27
|
+
|
|
28
|
+
export type ComboboxOption<T extends Key = Key> = {
|
|
29
|
+
id: T
|
|
30
|
+
label: string
|
|
31
|
+
description?: ReactNode
|
|
32
|
+
isDisabled?: boolean
|
|
33
|
+
/**
|
|
34
|
+
* Per-row action UI rendered at the trailing edge of the option. Hidden
|
|
35
|
+
* by default; revealed when the row is hovered or keyboard-focused. The
|
|
36
|
+
* slot intercepts `pointerdown` / `click` propagation so interactions
|
|
37
|
+
* inside it don't trigger option selection — put a real `<button>` /
|
|
38
|
+
* `<Link>` here and it Just Works.
|
|
39
|
+
*/
|
|
40
|
+
trailing?: ReactNode
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export type ComboboxProps<T extends Key = Key> = Omit<
|
|
44
|
+
AriaComboBoxProps<never>,
|
|
45
|
+
'children' | 'items' | 'defaultItems'
|
|
46
|
+
> & {
|
|
47
|
+
label?: ReactNode
|
|
48
|
+
description?: ReactNode
|
|
49
|
+
errorMessage?: ReactNode
|
|
50
|
+
placeholder?: string
|
|
51
|
+
options: ReadonlyArray<ComboboxOption<T>>
|
|
52
|
+
/** Field size. Defaults to `md`. */
|
|
53
|
+
size?: FieldSize
|
|
54
|
+
/** Chrome variant. Defaults to `field`. `cell` flattens the box. */
|
|
55
|
+
appearance?: FieldAppearance
|
|
56
|
+
/** Visual override for the trigger input wrapper only. */
|
|
57
|
+
inputWrapperClassName?: string
|
|
58
|
+
/** Visual override for the inner `<input>` (size, padding, font). */
|
|
59
|
+
inputClassName?: string
|
|
60
|
+
/** Empty-state message shown in the popover when no options match. */
|
|
61
|
+
emptyMessage?: ReactNode
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function Combobox<T extends Key>({
|
|
65
|
+
label,
|
|
66
|
+
description,
|
|
67
|
+
errorMessage,
|
|
68
|
+
placeholder = 'Type to search…',
|
|
69
|
+
options,
|
|
70
|
+
size,
|
|
71
|
+
appearance,
|
|
72
|
+
inputWrapperClassName,
|
|
73
|
+
inputClassName,
|
|
74
|
+
emptyMessage = 'No matches',
|
|
75
|
+
className,
|
|
76
|
+
// Default to keeping the popover open on an empty filter so the
|
|
77
|
+
// `emptyMessage` slot actually renders. Callers can opt out.
|
|
78
|
+
allowsEmptyCollection = true,
|
|
79
|
+
...props
|
|
80
|
+
}: ComboboxProps<T>) {
|
|
81
|
+
const styles = fieldControlStyles({ size, appearance })
|
|
82
|
+
// In cell mode the wrapper's own focus-within halo must go too — only the
|
|
83
|
+
// accent border remains on focus.
|
|
84
|
+
const focusWithin =
|
|
85
|
+
appearance === 'cell'
|
|
86
|
+
? 'focus-within:border-accent focus-within:shadow-none'
|
|
87
|
+
: 'focus-within:border-accent focus-within:shadow-[var(--shadow-focus)]'
|
|
88
|
+
return (
|
|
89
|
+
<AriaComboBox
|
|
90
|
+
{...props}
|
|
91
|
+
allowsEmptyCollection={allowsEmptyCollection}
|
|
92
|
+
className={(renderProps) =>
|
|
93
|
+
cn(
|
|
94
|
+
'grid grid-cols-1 gap-1.5',
|
|
95
|
+
typeof className === 'function' ? className(renderProps) : className,
|
|
96
|
+
)
|
|
97
|
+
}
|
|
98
|
+
>
|
|
99
|
+
{label ? <Label className={fieldLabelStyles}>{label}</Label> : null}
|
|
100
|
+
|
|
101
|
+
<div
|
|
102
|
+
className={cn(
|
|
103
|
+
// Box chrome on the wrapper; the inner <input> carries the padding
|
|
104
|
+
// + font so the chevron button sits inside the same border + ring.
|
|
105
|
+
styles.box(),
|
|
106
|
+
'flex items-center gap-1 pr-2',
|
|
107
|
+
focusWithin,
|
|
108
|
+
inputWrapperClassName,
|
|
109
|
+
)}
|
|
110
|
+
>
|
|
111
|
+
<AriaInput
|
|
112
|
+
placeholder={placeholder}
|
|
113
|
+
className={cn(
|
|
114
|
+
'flex-1 min-w-0 bg-transparent outline-none',
|
|
115
|
+
styles.content(),
|
|
116
|
+
inputClassName,
|
|
117
|
+
)}
|
|
118
|
+
/>
|
|
119
|
+
<AriaComboButton
|
|
120
|
+
className={cn(
|
|
121
|
+
'shrink-0 flex items-center justify-center',
|
|
122
|
+
'size-7 rounded-pill',
|
|
123
|
+
'text-ink-3 hover:text-ink outline-none cursor-pointer',
|
|
124
|
+
)}
|
|
125
|
+
>
|
|
126
|
+
<FieldCaret />
|
|
127
|
+
</AriaComboButton>
|
|
128
|
+
</div>
|
|
129
|
+
|
|
130
|
+
{description ? (
|
|
131
|
+
<Text slot="description" className={fieldHintStyles}>
|
|
132
|
+
{description}
|
|
133
|
+
</Text>
|
|
134
|
+
) : null}
|
|
135
|
+
{errorMessage && appearance !== 'cell' ? (
|
|
136
|
+
<FieldError className={fieldErrorStyles}>{errorMessage}</FieldError>
|
|
137
|
+
) : null}
|
|
138
|
+
|
|
139
|
+
<Popover
|
|
140
|
+
offset={6}
|
|
141
|
+
className={cn(
|
|
142
|
+
'min-w-[var(--trigger-width)]',
|
|
143
|
+
'rounded-md border border-rule bg-surface p-1',
|
|
144
|
+
'shadow-[var(--shadow-raised)]',
|
|
145
|
+
'entering:animate-in entering:fade-in-0 entering:slide-in-from-top-1',
|
|
146
|
+
'exiting:animate-out exiting:fade-out-0 exiting:slide-out-to-top-1',
|
|
147
|
+
)}
|
|
148
|
+
>
|
|
149
|
+
<ListBox
|
|
150
|
+
className="outline-none max-h-72 overflow-auto"
|
|
151
|
+
renderEmptyState={() => (
|
|
152
|
+
<div className={cn('px-3 py-2', fieldHintStyles)}>{emptyMessage}</div>
|
|
153
|
+
)}
|
|
154
|
+
>
|
|
155
|
+
{options.map((option) => (
|
|
156
|
+
<ComboboxItem
|
|
157
|
+
key={option.id}
|
|
158
|
+
id={option.id}
|
|
159
|
+
textValue={option.label}
|
|
160
|
+
{...(option.isDisabled ? { isDisabled: true } : {})}
|
|
161
|
+
>
|
|
162
|
+
<span className="truncate">{option.label}</span>
|
|
163
|
+
{option.description ? (
|
|
164
|
+
<span className="ml-3 shrink-0 font-mono text-[11.5px] text-ink-4">
|
|
165
|
+
{option.description}
|
|
166
|
+
</span>
|
|
167
|
+
) : null}
|
|
168
|
+
{option.trailing ? <TrailingSlot>{option.trailing}</TrailingSlot> : null}
|
|
169
|
+
</ComboboxItem>
|
|
170
|
+
))}
|
|
171
|
+
</ListBox>
|
|
172
|
+
</Popover>
|
|
173
|
+
</AriaComboBox>
|
|
174
|
+
)
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Wraps the per-option trailing UI so that:
|
|
179
|
+
* - The full pointer→click sequence is intercepted (so RAC's listbox
|
|
180
|
+
* press handling on the surrounding ListBoxItem can never claim it
|
|
181
|
+
* and select the row).
|
|
182
|
+
* - On click, after the consumer's own handler runs (bubble order:
|
|
183
|
+
* inner element → this wrapper), the combobox popover closes.
|
|
184
|
+
* The trailing action is by nature "do something with this option" —
|
|
185
|
+
* e.g. open a peek panel — which always takes the user away from the
|
|
186
|
+
* field, so leaving the popover open underneath would be a UX bug.
|
|
187
|
+
*/
|
|
188
|
+
function TrailingSlot({ children }: { children: ReactNode }) {
|
|
189
|
+
const state = useContext(ComboBoxStateContext)
|
|
190
|
+
return (
|
|
191
|
+
<span
|
|
192
|
+
className={cn(
|
|
193
|
+
'ml-auto pl-2 shrink-0 inline-flex items-center',
|
|
194
|
+
// Hidden by default; revealed when the row is hovered or
|
|
195
|
+
// keyboard-focused. `group/combobox-item` is set on ComboboxItem.
|
|
196
|
+
'hidden',
|
|
197
|
+
'group-data-[hovered]/combobox-item:inline-flex',
|
|
198
|
+
'group-data-[focused]/combobox-item:inline-flex',
|
|
199
|
+
'focus-within:inline-flex',
|
|
200
|
+
)}
|
|
201
|
+
// Block the full press lifecycle: `pointerdown` prevents usePress
|
|
202
|
+
// from arming on the option, the rest cover the test-env mousedown
|
|
203
|
+
// path and any third-party handlers.
|
|
204
|
+
onPointerDown={(e) => e.stopPropagation()}
|
|
205
|
+
onPointerUp={(e) => e.stopPropagation()}
|
|
206
|
+
onMouseDown={(e) => e.stopPropagation()}
|
|
207
|
+
onMouseUp={(e) => e.stopPropagation()}
|
|
208
|
+
onClick={(e) => {
|
|
209
|
+
e.stopPropagation()
|
|
210
|
+
// Bubble order means the consumer's onClick already ran; now
|
|
211
|
+
// close the popover so the trailing action's target UI (e.g. a
|
|
212
|
+
// peek modal) isn't sitting on top of an open dropdown.
|
|
213
|
+
state?.close()
|
|
214
|
+
}}
|
|
215
|
+
>
|
|
216
|
+
{children}
|
|
217
|
+
</span>
|
|
218
|
+
)
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function ComboboxItem(props: ListBoxItemProps) {
|
|
222
|
+
return (
|
|
223
|
+
<ListBoxItem
|
|
224
|
+
{...props}
|
|
225
|
+
className={cn(
|
|
226
|
+
// `group/combobox-item` exposes hover/focus state to the trailing
|
|
227
|
+
// slot so it can reveal itself only when the row is engaged.
|
|
228
|
+
'group/combobox-item',
|
|
229
|
+
'flex items-center justify-between gap-3',
|
|
230
|
+
'rounded-[6px] px-3 py-2 text-[14px] text-ink-2',
|
|
231
|
+
'cursor-pointer outline-none select-none',
|
|
232
|
+
'data-[hovered]:bg-surface-2 data-[hovered]:text-ink',
|
|
233
|
+
'data-[focused]:bg-surface-2 data-[focused]:text-ink',
|
|
234
|
+
'data-[selected]:bg-surface-3 data-[selected]:text-ink',
|
|
235
|
+
'data-[disabled]:opacity-45 data-[disabled]:cursor-not-allowed',
|
|
236
|
+
)}
|
|
237
|
+
/>
|
|
238
|
+
)
|
|
239
|
+
}
|