@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,76 @@
|
|
|
1
|
+
import type { ReactNode } from 'react'
|
|
2
|
+
import {
|
|
3
|
+
Input as AriaInput,
|
|
4
|
+
type InputProps as AriaInputProps,
|
|
5
|
+
type TextFieldProps as AriaTextFieldProps,
|
|
6
|
+
FieldError,
|
|
7
|
+
Label,
|
|
8
|
+
Text,
|
|
9
|
+
TextField,
|
|
10
|
+
} from 'react-aria-components'
|
|
11
|
+
import { cn } from '../../lib/cn'
|
|
12
|
+
import {
|
|
13
|
+
type FieldAppearance,
|
|
14
|
+
fieldControlStyles,
|
|
15
|
+
fieldErrorStyles,
|
|
16
|
+
type FieldSize,
|
|
17
|
+
fieldHintStyles,
|
|
18
|
+
fieldLabelStyles,
|
|
19
|
+
} from '../field/field'
|
|
20
|
+
|
|
21
|
+
export type InputProps = AriaTextFieldProps & {
|
|
22
|
+
label?: ReactNode
|
|
23
|
+
description?: ReactNode
|
|
24
|
+
errorMessage?: ReactNode
|
|
25
|
+
placeholder?: string
|
|
26
|
+
/** Field size. Defaults to `md`. Use `sm` for compact rows (filter builder, toolbars). */
|
|
27
|
+
size?: FieldSize
|
|
28
|
+
/** Chrome variant. Defaults to `field`. `cell` flattens the box for
|
|
29
|
+
* spreadsheet-style inline editing (2px radius, no halo / resting shadow). */
|
|
30
|
+
appearance?: FieldAppearance
|
|
31
|
+
/** Extra props forwarded to the underlying <input>. */
|
|
32
|
+
inputProps?: Omit<AriaInputProps, 'className'>
|
|
33
|
+
/** Visual override for the input control only. */
|
|
34
|
+
inputClassName?: string
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function Input({
|
|
38
|
+
label,
|
|
39
|
+
description,
|
|
40
|
+
errorMessage,
|
|
41
|
+
placeholder,
|
|
42
|
+
size,
|
|
43
|
+
appearance,
|
|
44
|
+
inputProps,
|
|
45
|
+
inputClassName,
|
|
46
|
+
className,
|
|
47
|
+
...props
|
|
48
|
+
}: InputProps) {
|
|
49
|
+
const styles = fieldControlStyles({ size, appearance })
|
|
50
|
+
return (
|
|
51
|
+
<TextField
|
|
52
|
+
{...props}
|
|
53
|
+
className={(renderProps) =>
|
|
54
|
+
cn(
|
|
55
|
+
'grid grid-cols-1 gap-1.5',
|
|
56
|
+
typeof className === 'function' ? className(renderProps) : className,
|
|
57
|
+
)
|
|
58
|
+
}
|
|
59
|
+
>
|
|
60
|
+
{label ? <Label className={fieldLabelStyles}>{label}</Label> : null}
|
|
61
|
+
<AriaInput
|
|
62
|
+
{...inputProps}
|
|
63
|
+
placeholder={placeholder ?? ''}
|
|
64
|
+
className={cn(styles.box(), styles.content(), inputClassName)}
|
|
65
|
+
/>
|
|
66
|
+
{description ? (
|
|
67
|
+
<Text slot="description" className={fieldHintStyles}>
|
|
68
|
+
{description}
|
|
69
|
+
</Text>
|
|
70
|
+
) : null}
|
|
71
|
+
{errorMessage && appearance !== 'cell' ? (
|
|
72
|
+
<FieldError className={fieldErrorStyles}>{errorMessage}</FieldError>
|
|
73
|
+
) : null}
|
|
74
|
+
</TextField>
|
|
75
|
+
)
|
|
76
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react-vite'
|
|
2
|
+
|
|
3
|
+
import { Kbd } from './kbd'
|
|
4
|
+
|
|
5
|
+
const meta: Meta<typeof Kbd> = {
|
|
6
|
+
title: 'Components/Kbd',
|
|
7
|
+
component: Kbd,
|
|
8
|
+
tags: ['autodocs'],
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export default meta
|
|
12
|
+
type Story = StoryObj<typeof Kbd>
|
|
13
|
+
|
|
14
|
+
export const Playground: Story = { args: { children: '⌘K' } }
|
|
15
|
+
|
|
16
|
+
export const Inline: Story = {
|
|
17
|
+
parameters: { controls: { hideNoControlsWarning: true } },
|
|
18
|
+
render: () => (
|
|
19
|
+
<p className="text-sm text-ink-3">
|
|
20
|
+
Press <Kbd>⌘</Kbd> + <Kbd>K</Kbd> to open the command palette.
|
|
21
|
+
</p>
|
|
22
|
+
),
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export const Sequence: Story = {
|
|
26
|
+
parameters: { controls: { hideNoControlsWarning: true } },
|
|
27
|
+
render: () => (
|
|
28
|
+
<div className="flex items-center gap-1.5">
|
|
29
|
+
<Kbd>⌘</Kbd>
|
|
30
|
+
<Kbd>⇧</Kbd>
|
|
31
|
+
<Kbd>P</Kbd>
|
|
32
|
+
</div>
|
|
33
|
+
),
|
|
34
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { HTMLAttributes } from 'react'
|
|
2
|
+
|
|
3
|
+
import { cn } from '../../lib/cn'
|
|
4
|
+
|
|
5
|
+
/* =========================================================================
|
|
6
|
+
Kbd — small mono key-cap. Renders a real <kbd> element so screen readers
|
|
7
|
+
announce it as keyboard input. Pure cosmetics; no behavior.
|
|
8
|
+
========================================================================= */
|
|
9
|
+
|
|
10
|
+
export type KbdProps = HTMLAttributes<HTMLElement>
|
|
11
|
+
|
|
12
|
+
export function Kbd({ className, children, ...props }: KbdProps) {
|
|
13
|
+
return (
|
|
14
|
+
<kbd
|
|
15
|
+
{...props}
|
|
16
|
+
className={cn(
|
|
17
|
+
'inline-flex items-center justify-center',
|
|
18
|
+
'font-mono text-[10.5px] leading-none text-ink-4',
|
|
19
|
+
'bg-surface-2 border border-rule rounded-[4px]',
|
|
20
|
+
'px-1.5 py-1',
|
|
21
|
+
className,
|
|
22
|
+
)}
|
|
23
|
+
>
|
|
24
|
+
{children}
|
|
25
|
+
</kbd>
|
|
26
|
+
)
|
|
27
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import type { ReactNode } from 'react'
|
|
2
|
+
import { ChevronRight } from 'lucide-react'
|
|
3
|
+
import {
|
|
4
|
+
Menu as AriaMenu,
|
|
5
|
+
MenuItem as AriaMenuItem,
|
|
6
|
+
type MenuItemProps as AriaMenuItemProps,
|
|
7
|
+
type MenuProps as AriaMenuProps,
|
|
8
|
+
MenuTrigger as AriaMenuTrigger,
|
|
9
|
+
Popover as AriaPopover,
|
|
10
|
+
type PopoverProps as AriaPopoverProps,
|
|
11
|
+
SubmenuTrigger as AriaSubmenuTrigger,
|
|
12
|
+
} from 'react-aria-components'
|
|
13
|
+
|
|
14
|
+
import { cn } from '../../lib/cn'
|
|
15
|
+
|
|
16
|
+
/* =========================================================================
|
|
17
|
+
Menu — a dropdown menu with Windows-style cascading submenus, built on
|
|
18
|
+
React Aria Components (keyboard nav, focus, typeahead, ARIA handled by RAC).
|
|
19
|
+
|
|
20
|
+
<MenuTrigger>
|
|
21
|
+
<Button>Open</Button> // first child = the trigger
|
|
22
|
+
<MenuPopover>
|
|
23
|
+
<Menu>
|
|
24
|
+
<MenuItem onAction={…}>Edit</MenuItem>
|
|
25
|
+
<SubmenuTrigger>
|
|
26
|
+
<MenuItem hasSubmenu>More…</MenuItem> // the parent row
|
|
27
|
+
<MenuPopover placement="end top"> // the flyout
|
|
28
|
+
<Menu>
|
|
29
|
+
<MenuItem onAction={…}>Nested</MenuItem>
|
|
30
|
+
</Menu>
|
|
31
|
+
</MenuPopover>
|
|
32
|
+
</SubmenuTrigger>
|
|
33
|
+
</Menu>
|
|
34
|
+
</MenuPopover>
|
|
35
|
+
</MenuTrigger>
|
|
36
|
+
|
|
37
|
+
MenuTrigger / SubmenuTrigger are RAC primitives re-exported unchanged;
|
|
38
|
+
MenuPopover / Menu / MenuItem carry the design-system chrome.
|
|
39
|
+
========================================================================= */
|
|
40
|
+
|
|
41
|
+
/** Stateful root (RAC). Wraps the trigger element + the MenuPopover. */
|
|
42
|
+
export const MenuTrigger = AriaMenuTrigger
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Submenu wrapper (RAC). Wraps a parent MenuItem and the nested MenuPopover so
|
|
46
|
+
* the flyout opens on hover/right-arrow. Use `placement="end top"` on the nested
|
|
47
|
+
* MenuPopover so it cascades to the side, Windows-style.
|
|
48
|
+
*/
|
|
49
|
+
export const SubmenuTrigger = AriaSubmenuTrigger
|
|
50
|
+
|
|
51
|
+
export type MenuPopoverProps = AriaPopoverProps
|
|
52
|
+
|
|
53
|
+
/** The floating panel. Defaults to opening below-start; pass `placement="end top"` for a submenu flyout. */
|
|
54
|
+
export function MenuPopover({ className, offset = 4, placement = 'bottom start', ...props }: MenuPopoverProps) {
|
|
55
|
+
return (
|
|
56
|
+
<AriaPopover
|
|
57
|
+
offset={offset}
|
|
58
|
+
placement={placement}
|
|
59
|
+
{...props}
|
|
60
|
+
className={(renderProps) =>
|
|
61
|
+
cn(
|
|
62
|
+
'min-w-[200px] p-1 bg-surface border border-rule rounded-md',
|
|
63
|
+
'shadow-[var(--shadow-float)] outline-none z-[1200]',
|
|
64
|
+
'entering:animate-in entering:fade-in-0 entering:zoom-in-95',
|
|
65
|
+
'exiting:animate-out exiting:fade-out-0 exiting:zoom-out-95',
|
|
66
|
+
typeof className === 'function' ? className(renderProps) : className,
|
|
67
|
+
)
|
|
68
|
+
}
|
|
69
|
+
/>
|
|
70
|
+
)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export type MenuProps<T extends object> = AriaMenuProps<T>
|
|
74
|
+
|
|
75
|
+
/** The menu list. Holds MenuItem / SubmenuTrigger children. */
|
|
76
|
+
export function Menu<T extends object>({ className, ...props }: MenuProps<T>) {
|
|
77
|
+
return (
|
|
78
|
+
<AriaMenu
|
|
79
|
+
{...props}
|
|
80
|
+
className={(renderProps) =>
|
|
81
|
+
cn(
|
|
82
|
+
'flex flex-col gap-px outline-none',
|
|
83
|
+
typeof className === 'function' ? className(renderProps) : className,
|
|
84
|
+
)
|
|
85
|
+
}
|
|
86
|
+
/>
|
|
87
|
+
)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export type MenuItemProps = Omit<AriaMenuItemProps, 'children'> & {
|
|
91
|
+
/** Leading icon slot. */
|
|
92
|
+
icon?: ReactNode
|
|
93
|
+
/** Renders a trailing chevron marking this row as a submenu parent. */
|
|
94
|
+
hasSubmenu?: boolean
|
|
95
|
+
children?: ReactNode
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const menuItemStyles = cn(
|
|
99
|
+
'flex items-center gap-2.5 px-2.5 py-1.5 rounded-md cursor-pointer outline-none',
|
|
100
|
+
'font-sans text-[13px] text-ink-2 select-none',
|
|
101
|
+
'hover:bg-surface-2 hover:text-ink focus:bg-surface-2 focus:text-ink',
|
|
102
|
+
'data-[open]:bg-surface-2 data-[open]:text-ink', // submenu-parent stays lit while its flyout is open
|
|
103
|
+
'aria-disabled:opacity-50 aria-disabled:cursor-default aria-disabled:hover:bg-transparent aria-disabled:hover:text-ink-2',
|
|
104
|
+
'[&_svg]:size-3.5 [&_svg]:text-ink-3 hover:[&_svg]:text-ink focus:[&_svg]:text-ink',
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
/** A single menu row. Pass `onAction` to handle selection; `isDisabled` to dim it. */
|
|
108
|
+
export function MenuItem({ icon, hasSubmenu, children, className, ...props }: MenuItemProps) {
|
|
109
|
+
return (
|
|
110
|
+
<AriaMenuItem
|
|
111
|
+
{...props}
|
|
112
|
+
className={(renderProps) =>
|
|
113
|
+
cn(menuItemStyles, typeof className === 'function' ? className(renderProps) : className)
|
|
114
|
+
}
|
|
115
|
+
>
|
|
116
|
+
{icon ? <span className="flex shrink-0 items-center">{icon}</span> : null}
|
|
117
|
+
<span className="flex-1 truncate">{children}</span>
|
|
118
|
+
{hasSubmenu ? <ChevronRight className="ml-2 size-3 shrink-0 text-ink-4" /> : null}
|
|
119
|
+
</AriaMenuItem>
|
|
120
|
+
)
|
|
121
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react-vite'
|
|
2
|
+
|
|
3
|
+
import { Button } from '@/components/button/button'
|
|
4
|
+
import { Input } from '@/components/input/input'
|
|
5
|
+
import { Separator } from '@/components/separator/separator'
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
Modal,
|
|
9
|
+
ModalBody,
|
|
10
|
+
ModalContent,
|
|
11
|
+
ModalDescription,
|
|
12
|
+
ModalFooter,
|
|
13
|
+
ModalHeader,
|
|
14
|
+
ModalTitle,
|
|
15
|
+
} from './modal'
|
|
16
|
+
|
|
17
|
+
const meta: Meta<typeof Modal> = {
|
|
18
|
+
title: 'Components/Modal',
|
|
19
|
+
component: Modal,
|
|
20
|
+
tags: ['autodocs'],
|
|
21
|
+
parameters: {
|
|
22
|
+
// Modal portals its content. `layout: "padded"` gives the trigger some
|
|
23
|
+
// breathing room in the story frame.
|
|
24
|
+
layout: 'padded',
|
|
25
|
+
},
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export default meta
|
|
29
|
+
type Story = StoryObj<typeof Modal>
|
|
30
|
+
|
|
31
|
+
/* =========================================================================
|
|
32
|
+
Basic — trigger opens a modal with title, description, body, and actions.
|
|
33
|
+
========================================================================= */
|
|
34
|
+
|
|
35
|
+
export const Basic: Story = {
|
|
36
|
+
render: () => (
|
|
37
|
+
<Modal>
|
|
38
|
+
<Button>Open modal</Button>
|
|
39
|
+
<ModalContent>
|
|
40
|
+
<ModalHeader>
|
|
41
|
+
<ModalTitle>Compose system</ModalTitle>
|
|
42
|
+
<ModalDescription>
|
|
43
|
+
Describe your business in plain language. Proteos will infer entities, workflows and
|
|
44
|
+
reports.
|
|
45
|
+
</ModalDescription>
|
|
46
|
+
</ModalHeader>
|
|
47
|
+
<ModalBody>
|
|
48
|
+
A mid-size freight broker. Quotes, loads, carriers, lanes. Dispatchers assign drivers
|
|
49
|
+
daily; finance reconciles invoices weekly.
|
|
50
|
+
</ModalBody>
|
|
51
|
+
<Separator />
|
|
52
|
+
<ModalFooter>
|
|
53
|
+
<Button slot="close" variant="ghost">
|
|
54
|
+
Cancel
|
|
55
|
+
</Button>
|
|
56
|
+
<Button variant="accent">Compose</Button>
|
|
57
|
+
</ModalFooter>
|
|
58
|
+
</ModalContent>
|
|
59
|
+
</Modal>
|
|
60
|
+
),
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/* =========================================================================
|
|
64
|
+
Confirmation — compact modal for destructive / binary choices.
|
|
65
|
+
========================================================================= */
|
|
66
|
+
|
|
67
|
+
export const Confirmation: Story = {
|
|
68
|
+
render: () => (
|
|
69
|
+
<Modal>
|
|
70
|
+
<Button variant="secondary">Delete shipment</Button>
|
|
71
|
+
<ModalContent modalClassName="max-w-md">
|
|
72
|
+
<ModalHeader>
|
|
73
|
+
<ModalTitle>Delete SH-10482?</ModalTitle>
|
|
74
|
+
<ModalDescription>
|
|
75
|
+
This removes the shipment and all related events. You can't undo this.
|
|
76
|
+
</ModalDescription>
|
|
77
|
+
</ModalHeader>
|
|
78
|
+
<Separator />
|
|
79
|
+
<ModalFooter>
|
|
80
|
+
<Button slot="close" variant="ghost">
|
|
81
|
+
Keep it
|
|
82
|
+
</Button>
|
|
83
|
+
<Button slot="close" variant="primary">
|
|
84
|
+
Delete
|
|
85
|
+
</Button>
|
|
86
|
+
</ModalFooter>
|
|
87
|
+
</ModalContent>
|
|
88
|
+
</Modal>
|
|
89
|
+
),
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/* =========================================================================
|
|
93
|
+
With form — a Proteos-style input lives inside ModalBody.
|
|
94
|
+
========================================================================= */
|
|
95
|
+
|
|
96
|
+
export const WithForm: Story = {
|
|
97
|
+
render: () => (
|
|
98
|
+
<Modal>
|
|
99
|
+
<Button>New carrier</Button>
|
|
100
|
+
<ModalContent>
|
|
101
|
+
<ModalHeader>
|
|
102
|
+
<ModalTitle>Add carrier</ModalTitle>
|
|
103
|
+
<ModalDescription>Link a new carrier to your dispatch board.</ModalDescription>
|
|
104
|
+
</ModalHeader>
|
|
105
|
+
<ModalBody>
|
|
106
|
+
<Input label="Carrier name" placeholder="Blue Ridge Logistics" />
|
|
107
|
+
<Input label="DOT number" placeholder="USDOT 1234567" />
|
|
108
|
+
<Input
|
|
109
|
+
label="Rate per mile"
|
|
110
|
+
description="In USD. Proteos will use this for margin estimates."
|
|
111
|
+
placeholder="$2.85"
|
|
112
|
+
/>
|
|
113
|
+
</ModalBody>
|
|
114
|
+
<Separator />
|
|
115
|
+
<ModalFooter>
|
|
116
|
+
<Button slot="close" variant="ghost">
|
|
117
|
+
Cancel
|
|
118
|
+
</Button>
|
|
119
|
+
<Button variant="accent">Save carrier</Button>
|
|
120
|
+
</ModalFooter>
|
|
121
|
+
</ModalContent>
|
|
122
|
+
</Modal>
|
|
123
|
+
),
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/* =========================================================================
|
|
127
|
+
Not dismissable — backdrop click and Escape are disabled.
|
|
128
|
+
========================================================================= */
|
|
129
|
+
|
|
130
|
+
export const NotDismissable: Story = {
|
|
131
|
+
render: () => (
|
|
132
|
+
<Modal>
|
|
133
|
+
<Button>Open (must click an action)</Button>
|
|
134
|
+
<ModalContent isDismissable={false}>
|
|
135
|
+
<ModalHeader>
|
|
136
|
+
<ModalTitle>Accept terms</ModalTitle>
|
|
137
|
+
<ModalDescription>
|
|
138
|
+
You can't back out of this dialog — one of the actions below is required.
|
|
139
|
+
</ModalDescription>
|
|
140
|
+
</ModalHeader>
|
|
141
|
+
<Separator />
|
|
142
|
+
<ModalFooter>
|
|
143
|
+
<Button slot="close" variant="ghost">
|
|
144
|
+
Decline
|
|
145
|
+
</Button>
|
|
146
|
+
<Button slot="close" variant="primary">
|
|
147
|
+
Accept
|
|
148
|
+
</Button>
|
|
149
|
+
</ModalFooter>
|
|
150
|
+
</ModalContent>
|
|
151
|
+
</Modal>
|
|
152
|
+
),
|
|
153
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
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 { Button } from '@/components/button/button'
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
Modal,
|
|
9
|
+
ModalBody,
|
|
10
|
+
ModalContent,
|
|
11
|
+
ModalDescription,
|
|
12
|
+
ModalFooter,
|
|
13
|
+
ModalHeader,
|
|
14
|
+
ModalTitle,
|
|
15
|
+
} from './modal'
|
|
16
|
+
|
|
17
|
+
function Harness({ onOpenChange }: { onOpenChange?: (isOpen: boolean) => void }) {
|
|
18
|
+
return (
|
|
19
|
+
<Modal {...(onOpenChange ? { onOpenChange } : {})}>
|
|
20
|
+
<Button>Open</Button>
|
|
21
|
+
<ModalContent>
|
|
22
|
+
<ModalHeader>
|
|
23
|
+
<ModalTitle>Compose system</ModalTitle>
|
|
24
|
+
<ModalDescription>Describe your business in plain language.</ModalDescription>
|
|
25
|
+
</ModalHeader>
|
|
26
|
+
<ModalBody>Body content</ModalBody>
|
|
27
|
+
<ModalFooter>
|
|
28
|
+
<Button slot="close" variant="ghost">
|
|
29
|
+
Cancel
|
|
30
|
+
</Button>
|
|
31
|
+
<Button>Compose</Button>
|
|
32
|
+
</ModalFooter>
|
|
33
|
+
</ModalContent>
|
|
34
|
+
</Modal>
|
|
35
|
+
)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
describe('Modal', () => {
|
|
39
|
+
it('stays closed by default', () => {
|
|
40
|
+
render(<Harness />)
|
|
41
|
+
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it('opens when the trigger is clicked', async () => {
|
|
45
|
+
const user = userEvent.setup()
|
|
46
|
+
render(<Harness />)
|
|
47
|
+
|
|
48
|
+
await user.click(screen.getByRole('button', { name: 'Open' }))
|
|
49
|
+
expect(await screen.findByRole('dialog')).toBeInTheDocument()
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('wires aria-labelledby to the ModalTitle', async () => {
|
|
53
|
+
const user = userEvent.setup()
|
|
54
|
+
render(<Harness />)
|
|
55
|
+
|
|
56
|
+
await user.click(screen.getByRole('button', { name: 'Open' }))
|
|
57
|
+
const dialog = await screen.findByRole('dialog', { name: 'Compose system' })
|
|
58
|
+
expect(dialog).toBeInTheDocument()
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('closes on Escape', async () => {
|
|
62
|
+
const user = userEvent.setup()
|
|
63
|
+
const onOpenChange = vi.fn()
|
|
64
|
+
render(<Harness onOpenChange={onOpenChange} />)
|
|
65
|
+
|
|
66
|
+
await user.click(screen.getByRole('button', { name: 'Open' }))
|
|
67
|
+
expect(await screen.findByRole('dialog')).toBeInTheDocument()
|
|
68
|
+
|
|
69
|
+
await user.keyboard('{Escape}')
|
|
70
|
+
expect(onOpenChange).toHaveBeenCalledWith(false)
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it('closes when a Button with slot="close" is pressed', async () => {
|
|
74
|
+
const user = userEvent.setup()
|
|
75
|
+
const onOpenChange = vi.fn()
|
|
76
|
+
render(<Harness onOpenChange={onOpenChange} />)
|
|
77
|
+
|
|
78
|
+
await user.click(screen.getByRole('button', { name: 'Open' }))
|
|
79
|
+
await screen.findByRole('dialog')
|
|
80
|
+
|
|
81
|
+
await user.click(screen.getByRole('button', { name: 'Cancel' }))
|
|
82
|
+
expect(onOpenChange).toHaveBeenLastCalledWith(false)
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it('renders description and body content', async () => {
|
|
86
|
+
const user = userEvent.setup()
|
|
87
|
+
render(<Harness />)
|
|
88
|
+
|
|
89
|
+
await user.click(screen.getByRole('button', { name: 'Open' }))
|
|
90
|
+
expect(await screen.findByText('Describe your business in plain language.')).toBeInTheDocument()
|
|
91
|
+
expect(screen.getByText('Body content')).toBeInTheDocument()
|
|
92
|
+
})
|
|
93
|
+
})
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import type { HTMLAttributes } from 'react'
|
|
2
|
+
import {
|
|
3
|
+
Dialog as AriaDialog,
|
|
4
|
+
type DialogProps as AriaDialogProps,
|
|
5
|
+
DialogTrigger as AriaDialogTrigger,
|
|
6
|
+
type DialogTriggerProps as AriaDialogTriggerProps,
|
|
7
|
+
Modal as AriaModal,
|
|
8
|
+
ModalOverlay as AriaModalOverlay,
|
|
9
|
+
type ModalOverlayProps as AriaModalOverlayProps,
|
|
10
|
+
Heading,
|
|
11
|
+
type HeadingProps,
|
|
12
|
+
} from 'react-aria-components'
|
|
13
|
+
|
|
14
|
+
import { cn } from '../../lib/cn'
|
|
15
|
+
|
|
16
|
+
/* =========================================================================
|
|
17
|
+
Modal — shadcn-shaped API over RAC's DialogTrigger + ModalOverlay + Modal
|
|
18
|
+
+ Dialog primitives. Composition mirrors:
|
|
19
|
+
|
|
20
|
+
<Modal> // stateful root (uncontrolled or controlled)
|
|
21
|
+
<Button>Open</Button> // first child = trigger
|
|
22
|
+
<ModalContent> // portaled overlay + panel
|
|
23
|
+
<ModalHeader>
|
|
24
|
+
<ModalTitle>…</ModalTitle> // auto-wires aria-labelledby
|
|
25
|
+
<ModalDescription>…</ModalDescription>
|
|
26
|
+
</ModalHeader>
|
|
27
|
+
<ModalBody>…</ModalBody>
|
|
28
|
+
<ModalFooter>
|
|
29
|
+
<Button slot="close" variant="ghost">Cancel</Button>
|
|
30
|
+
<Button>Confirm</Button>
|
|
31
|
+
</ModalFooter>
|
|
32
|
+
</ModalContent>
|
|
33
|
+
</Modal>
|
|
34
|
+
========================================================================= */
|
|
35
|
+
|
|
36
|
+
/** Stateful root. Pass `isOpen` + `onOpenChange` for controlled usage. */
|
|
37
|
+
export function Modal(props: AriaDialogTriggerProps) {
|
|
38
|
+
return <AriaDialogTrigger {...props} />
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export type ModalContentProps = AriaModalOverlayProps & {
|
|
42
|
+
/** Disable backdrop click to close. Default: backdrop closes. */
|
|
43
|
+
isDismissable?: boolean
|
|
44
|
+
/** Children of the Dialog element. Supports render-prop form `({ close }) => …`. */
|
|
45
|
+
children: AriaDialogProps['children']
|
|
46
|
+
/** Visual override for the panel wrapper (affects width, padding, etc.). */
|
|
47
|
+
modalClassName?: string
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Portaled overlay + floating panel + dialog role. Backdrop darkens the page
|
|
52
|
+
* with a warm ink tint; panel uses vellum bg + rule border + the float shadow.
|
|
53
|
+
*/
|
|
54
|
+
export function ModalContent({
|
|
55
|
+
children,
|
|
56
|
+
className,
|
|
57
|
+
modalClassName,
|
|
58
|
+
isDismissable = true,
|
|
59
|
+
...props
|
|
60
|
+
}: ModalContentProps) {
|
|
61
|
+
return (
|
|
62
|
+
<AriaModalOverlay
|
|
63
|
+
isDismissable={isDismissable}
|
|
64
|
+
{...props}
|
|
65
|
+
className={(renderProps) =>
|
|
66
|
+
cn(
|
|
67
|
+
'fixed inset-0 z-50 flex items-center justify-center p-4',
|
|
68
|
+
// Warm ink scrim — stays in the Proteos palette (no pure black).
|
|
69
|
+
'bg-[color-mix(in_oklab,var(--color-ink)_40%,transparent)]',
|
|
70
|
+
'entering:animate-in entering:fade-in-0 entering:duration-150',
|
|
71
|
+
'exiting:animate-out exiting:fade-out-0 exiting:duration-100',
|
|
72
|
+
typeof className === 'function' ? className(renderProps) : className,
|
|
73
|
+
)
|
|
74
|
+
}
|
|
75
|
+
>
|
|
76
|
+
<AriaModal
|
|
77
|
+
className={cn(
|
|
78
|
+
'w-full max-w-lg',
|
|
79
|
+
// Cap the panel at the viewport (minus the overlay's p-4) and lay it
|
|
80
|
+
// out as a column so a tall body scrolls instead of overflowing the
|
|
81
|
+
// screen. Short modals are unaffected — height still tracks content.
|
|
82
|
+
'flex flex-col max-h-[calc(100dvh-2rem)]',
|
|
83
|
+
'bg-surface border border-rule rounded-lg',
|
|
84
|
+
'shadow-[var(--shadow-float)]',
|
|
85
|
+
'entering:animate-in entering:zoom-in-95 entering:slide-in-from-top-2 entering:duration-150',
|
|
86
|
+
'exiting:animate-out exiting:zoom-out-95 exiting:duration-100',
|
|
87
|
+
modalClassName,
|
|
88
|
+
)}
|
|
89
|
+
>
|
|
90
|
+
<AriaDialog className="outline-none flex flex-col min-h-0 overflow-hidden">
|
|
91
|
+
{children}
|
|
92
|
+
</AriaDialog>
|
|
93
|
+
</AriaModal>
|
|
94
|
+
</AriaModalOverlay>
|
|
95
|
+
)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/* ---------- slots (each owns its padding, like Card) ----------------------- */
|
|
99
|
+
|
|
100
|
+
/** Padded top block. Contains `<ModalTitle>` and optionally `<ModalDescription>`. */
|
|
101
|
+
export function ModalHeader({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
|
|
102
|
+
return <div {...props} className={cn('shrink-0 px-5 pt-5 pb-3 flex flex-col gap-1.5', className)} />
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** The modal's accessible name. Uses RAC `<Heading slot="title">` so the
|
|
106
|
+
* Dialog's `aria-labelledby` points here automatically. */
|
|
107
|
+
export function ModalTitle({ className, ...props }: Omit<HeadingProps, 'slot'>) {
|
|
108
|
+
return (
|
|
109
|
+
<Heading
|
|
110
|
+
slot="title"
|
|
111
|
+
{...props}
|
|
112
|
+
className={cn(
|
|
113
|
+
'font-sans font-medium text-[20px] leading-[1.25] tracking-[-0.015em] text-ink',
|
|
114
|
+
className,
|
|
115
|
+
)}
|
|
116
|
+
/>
|
|
117
|
+
)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Supporting copy under the title. */
|
|
121
|
+
export function ModalDescription({ className, ...props }: HTMLAttributes<HTMLParagraphElement>) {
|
|
122
|
+
return <p {...props} className={cn('text-[14px] leading-[1.55] text-ink-3', className)} />
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Padded main content block — put forms, text, lists, etc. here. */
|
|
126
|
+
export function ModalBody({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
|
|
127
|
+
return (
|
|
128
|
+
<div
|
|
129
|
+
{...props}
|
|
130
|
+
className={cn(
|
|
131
|
+
// The scroll region: caps at the panel's spare height and scrolls when
|
|
132
|
+
// the content is taller, leaving header + footer pinned.
|
|
133
|
+
'min-h-0 overflow-y-auto px-5 py-3 flex flex-col gap-3 text-[14px] text-ink-2',
|
|
134
|
+
className,
|
|
135
|
+
)}
|
|
136
|
+
/>
|
|
137
|
+
)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Action row at the bottom. Right-aligned. Place a `<Separator />` above
|
|
141
|
+
* it when you want the dashed divider between body and footer. */
|
|
142
|
+
export function ModalFooter({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
|
|
143
|
+
return (
|
|
144
|
+
<div
|
|
145
|
+
{...props}
|
|
146
|
+
className={cn('shrink-0 flex justify-end items-center gap-2', 'px-5 pt-3 pb-5', className)}
|
|
147
|
+
/>
|
|
148
|
+
)
|
|
149
|
+
}
|