@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.
Files changed (226) hide show
  1. package/LICENSE +105 -0
  2. package/dist/chunk-IKAX76MB.js +113 -0
  3. package/dist/chunk-IKAX76MB.js.map +1 -0
  4. package/dist/chunk-MWAXK66H.cjs +119 -0
  5. package/dist/chunk-MWAXK66H.cjs.map +1 -0
  6. package/dist/index.cjs +6246 -0
  7. package/dist/index.cjs.map +1 -0
  8. package/dist/index.d.cts +2126 -0
  9. package/dist/index.d.ts +2126 -0
  10. package/dist/index.js +6069 -0
  11. package/dist/index.js.map +1 -0
  12. package/dist/pdf-view-3ZMMS6ML.cjs +95 -0
  13. package/dist/pdf-view-3ZMMS6ML.cjs.map +1 -0
  14. package/dist/pdf-view-BV5ZD6OC.js +93 -0
  15. package/dist/pdf-view-BV5ZD6OC.js.map +1 -0
  16. package/package.json +96 -0
  17. package/src/components/attachment-chip/attachment-chip.tsx +129 -0
  18. package/src/components/attachment-chip/index.ts +2 -0
  19. package/src/components/attribute-type-icon/attribute-type-icon.stories.tsx +70 -0
  20. package/src/components/attribute-type-icon/attribute-type-icon.tsx +125 -0
  21. package/src/components/attribute-type-icon/index.ts +8 -0
  22. package/src/components/avatar/avatar.stories.tsx +42 -0
  23. package/src/components/avatar/avatar.tsx +48 -0
  24. package/src/components/avatar/index.ts +2 -0
  25. package/src/components/avatar-group/avatar-group.test.tsx +38 -0
  26. package/src/components/avatar-group/avatar-group.tsx +60 -0
  27. package/src/components/avatar-group/index.ts +2 -0
  28. package/src/components/badge/badge.stories.tsx +62 -0
  29. package/src/components/badge/badge.test.tsx +36 -0
  30. package/src/components/badge/badge.tsx +54 -0
  31. package/src/components/badge/index.ts +2 -0
  32. package/src/components/badge-multi-select/badge-multi-select.stories.tsx +177 -0
  33. package/src/components/badge-multi-select/badge-multi-select.test.tsx +155 -0
  34. package/src/components/badge-multi-select/badge-multi-select.tsx +272 -0
  35. package/src/components/badge-multi-select/index.ts +2 -0
  36. package/src/components/breadcrumbs/breadcrumbs.stories.tsx +43 -0
  37. package/src/components/breadcrumbs/breadcrumbs.tsx +86 -0
  38. package/src/components/breadcrumbs/index.ts +2 -0
  39. package/src/components/button/button.stories.tsx +290 -0
  40. package/src/components/button/button.test.tsx +59 -0
  41. package/src/components/button/button.tsx +86 -0
  42. package/src/components/button/index.ts +2 -0
  43. package/src/components/card/card.stories.tsx +351 -0
  44. package/src/components/card/card.test.tsx +141 -0
  45. package/src/components/card/card.tsx +173 -0
  46. package/src/components/card/index.ts +13 -0
  47. package/src/components/checkbox/checkbox-group.tsx +52 -0
  48. package/src/components/checkbox/checkbox.stories.tsx +48 -0
  49. package/src/components/checkbox/checkbox.test.tsx +50 -0
  50. package/src/components/checkbox/checkbox.tsx +71 -0
  51. package/src/components/checkbox/index.ts +4 -0
  52. package/src/components/combobox/combobox.stories.tsx +97 -0
  53. package/src/components/combobox/combobox.test.tsx +123 -0
  54. package/src/components/combobox/combobox.tsx +239 -0
  55. package/src/components/combobox/index.ts +2 -0
  56. package/src/components/command/command-hint.tsx +46 -0
  57. package/src/components/command/command.stories.tsx +88 -0
  58. package/src/components/command/command.test.tsx +102 -0
  59. package/src/components/command/command.tsx +76 -0
  60. package/src/components/command/index.ts +16 -0
  61. package/src/components/currency-input/currency-input.tsx +192 -0
  62. package/src/components/currency-input/index.ts +6 -0
  63. package/src/components/data-table/cells.stories.tsx +120 -0
  64. package/src/components/data-table/cells.tsx +165 -0
  65. package/src/components/data-table/data-table.stories.tsx +371 -0
  66. package/src/components/data-table/data-table.tsx +556 -0
  67. package/src/components/data-table/index.ts +22 -0
  68. package/src/components/data-table/make-actions-column.ts +30 -0
  69. package/src/components/data-table/states/data-table-empty.tsx +40 -0
  70. package/src/components/data-table/states/data-table-error.tsx +30 -0
  71. package/src/components/data-table/states/data-table-skeleton.tsx +42 -0
  72. package/src/components/data-table/use-infinite-scroll-sentinel.ts +51 -0
  73. package/src/components/date-picker/calendar-internals.tsx +138 -0
  74. package/src/components/date-picker/date-picker.stories.tsx +78 -0
  75. package/src/components/date-picker/date-picker.test.tsx +71 -0
  76. package/src/components/date-picker/date-picker.tsx +140 -0
  77. package/src/components/date-picker/index.ts +2 -0
  78. package/src/components/date-time-picker/date-time-picker.stories.tsx +96 -0
  79. package/src/components/date-time-picker/date-time-picker.test.tsx +87 -0
  80. package/src/components/date-time-picker/date-time-picker.tsx +203 -0
  81. package/src/components/date-time-picker/index.ts +2 -0
  82. package/src/components/empty-state/empty-state.tsx +63 -0
  83. package/src/components/empty-state/index.ts +2 -0
  84. package/src/components/error-animation/error-animation.stories.tsx +50 -0
  85. package/src/components/error-animation/error-animation.test.tsx +22 -0
  86. package/src/components/error-animation/error-animation.tsx +26 -0
  87. package/src/components/error-animation/index.ts +2 -0
  88. package/src/components/field/field.tsx +142 -0
  89. package/src/components/field/index.ts +8 -0
  90. package/src/components/file-field/file-field.tsx +285 -0
  91. package/src/components/file-field/index.ts +2 -0
  92. package/src/components/file-viewer/fallback-card.tsx +68 -0
  93. package/src/components/file-viewer/file-viewer.stories.tsx +67 -0
  94. package/src/components/file-viewer/file-viewer.test.tsx +81 -0
  95. package/src/components/file-viewer/file-viewer.tsx +116 -0
  96. package/src/components/file-viewer/image-view.tsx +38 -0
  97. package/src/components/file-viewer/index.ts +7 -0
  98. package/src/components/file-viewer/pdf-view.tsx +122 -0
  99. package/src/components/file-viewer/video-view.tsx +16 -0
  100. package/src/components/image-attachment/image-attachment.stories.tsx +39 -0
  101. package/src/components/image-attachment/image-attachment.test.tsx +54 -0
  102. package/src/components/image-attachment/image-attachment.tsx +77 -0
  103. package/src/components/image-attachment/index.ts +3 -0
  104. package/src/components/image-attachment/is-previewable-image-name.ts +14 -0
  105. package/src/components/image-lightbox/image-lightbox.stories.tsx +38 -0
  106. package/src/components/image-lightbox/image-lightbox.test.tsx +72 -0
  107. package/src/components/image-lightbox/image-lightbox.tsx +92 -0
  108. package/src/components/image-lightbox/index.ts +2 -0
  109. package/src/components/input/index.ts +2 -0
  110. package/src/components/input/input.stories.tsx +71 -0
  111. package/src/components/input/input.test.tsx +36 -0
  112. package/src/components/input/input.tsx +76 -0
  113. package/src/components/kbd/index.ts +2 -0
  114. package/src/components/kbd/kbd.stories.tsx +34 -0
  115. package/src/components/kbd/kbd.tsx +27 -0
  116. package/src/components/menu/index.ts +2 -0
  117. package/src/components/menu/menu.tsx +121 -0
  118. package/src/components/modal/index.ts +10 -0
  119. package/src/components/modal/modal.stories.tsx +153 -0
  120. package/src/components/modal/modal.test.tsx +93 -0
  121. package/src/components/modal/modal.tsx +149 -0
  122. package/src/components/multi-select/index.ts +2 -0
  123. package/src/components/multi-select/multi-select.stories.tsx +211 -0
  124. package/src/components/multi-select/multi-select.test.tsx +151 -0
  125. package/src/components/multi-select/multi-select.tsx +339 -0
  126. package/src/components/nav/index.ts +7 -0
  127. package/src/components/nav/nav.stories.tsx +139 -0
  128. package/src/components/nav/nav.tsx +246 -0
  129. package/src/components/popover/index.ts +8 -0
  130. package/src/components/popover/popover.stories.tsx +92 -0
  131. package/src/components/popover/popover.tsx +135 -0
  132. package/src/components/progress-bar/index.ts +2 -0
  133. package/src/components/progress-bar/progress-bar.stories.tsx +75 -0
  134. package/src/components/progress-bar/progress-bar.test.tsx +29 -0
  135. package/src/components/progress-bar/progress-bar.tsx +57 -0
  136. package/src/components/radio-group/index.ts +2 -0
  137. package/src/components/radio-group/radio-group.stories.tsx +113 -0
  138. package/src/components/radio-group/radio-group.test.tsx +87 -0
  139. package/src/components/radio-group/radio-group.tsx +104 -0
  140. package/src/components/record-filter/attribute-picker.tsx +74 -0
  141. package/src/components/record-filter/chip-value.tsx +171 -0
  142. package/src/components/record-filter/filter-row.tsx +262 -0
  143. package/src/components/record-filter/filter-value-input.tsx +351 -0
  144. package/src/components/record-filter/group-add-controls.tsx +77 -0
  145. package/src/components/record-filter/group-body.tsx +173 -0
  146. package/src/components/record-filter/group-mutations.test.ts +104 -0
  147. package/src/components/record-filter/group-mutations.ts +116 -0
  148. package/src/components/record-filter/index.ts +58 -0
  149. package/src/components/record-filter/nested-group.tsx +138 -0
  150. package/src/components/record-filter/operator-picker.tsx +62 -0
  151. package/src/components/record-filter/operators.test.ts +105 -0
  152. package/src/components/record-filter/operators.ts +97 -0
  153. package/src/components/record-filter/record-filter-builder-panel.tsx +236 -0
  154. package/src/components/record-filter/record-filter-button.tsx +126 -0
  155. package/src/components/record-filter/record-filter-chips.tsx +527 -0
  156. package/src/components/record-filter/record-filter-editor.tsx +91 -0
  157. package/src/components/record-filter/record-filter.stories.tsx +196 -0
  158. package/src/components/record-filter/record-filter.test.tsx +437 -0
  159. package/src/components/record-filter/show-details-store.ts +72 -0
  160. package/src/components/record-filter/types.ts +150 -0
  161. package/src/components/segmented-control/index.ts +6 -0
  162. package/src/components/segmented-control/segmented-control.stories.tsx +67 -0
  163. package/src/components/segmented-control/segmented-control.test.tsx +47 -0
  164. package/src/components/segmented-control/segmented-control.tsx +105 -0
  165. package/src/components/select/index.ts +2 -0
  166. package/src/components/select/select.stories.tsx +76 -0
  167. package/src/components/select/select.test.tsx +56 -0
  168. package/src/components/select/select.tsx +159 -0
  169. package/src/components/separator/index.ts +2 -0
  170. package/src/components/separator/separator.stories.tsx +73 -0
  171. package/src/components/separator/separator.test.tsx +29 -0
  172. package/src/components/separator/separator.tsx +51 -0
  173. package/src/components/skeleton/index.ts +2 -0
  174. package/src/components/skeleton/skeleton.stories.tsx +68 -0
  175. package/src/components/skeleton/skeleton.test.tsx +38 -0
  176. package/src/components/skeleton/skeleton.tsx +43 -0
  177. package/src/components/success-animation/index.ts +2 -0
  178. package/src/components/success-animation/success-animation.stories.tsx +50 -0
  179. package/src/components/success-animation/success-animation.test.tsx +22 -0
  180. package/src/components/success-animation/success-animation.tsx +26 -0
  181. package/src/components/switch/index.ts +2 -0
  182. package/src/components/switch/switch.stories.tsx +60 -0
  183. package/src/components/switch/switch.test.tsx +50 -0
  184. package/src/components/switch/switch.tsx +49 -0
  185. package/src/components/table/index.ts +25 -0
  186. package/src/components/table/table.stories.tsx +306 -0
  187. package/src/components/table/table.test.tsx +115 -0
  188. package/src/components/table/table.tsx +543 -0
  189. package/src/components/tabs/index.ts +8 -0
  190. package/src/components/tabs/tabs.tsx +141 -0
  191. package/src/components/tag-input/index.ts +2 -0
  192. package/src/components/tag-input/tag-input.stories.tsx +73 -0
  193. package/src/components/tag-input/tag-input.test.tsx +80 -0
  194. package/src/components/tag-input/tag-input.tsx +193 -0
  195. package/src/components/textarea/index.ts +2 -0
  196. package/src/components/textarea/textarea.stories.tsx +75 -0
  197. package/src/components/textarea/textarea.test.tsx +41 -0
  198. package/src/components/textarea/textarea.tsx +86 -0
  199. package/src/components/time-field/index.ts +2 -0
  200. package/src/components/time-field/time-field.stories.tsx +76 -0
  201. package/src/components/time-field/time-field.test.tsx +51 -0
  202. package/src/components/time-field/time-field.tsx +98 -0
  203. package/src/components/tooltip/index.ts +2 -0
  204. package/src/components/tooltip/tooltip.stories.tsx +176 -0
  205. package/src/components/tooltip/tooltip.test.tsx +52 -0
  206. package/src/components/tooltip/tooltip.tsx +56 -0
  207. package/src/index.ts +47 -0
  208. package/src/lib/cn.ts +6 -0
  209. package/src/lib/commands/discovery-store.test.tsx +76 -0
  210. package/src/lib/commands/discovery-store.ts +110 -0
  211. package/src/lib/commands/index.ts +9 -0
  212. package/src/lib/commands/match-event.test.ts +99 -0
  213. package/src/lib/commands/match-event.ts +31 -0
  214. package/src/lib/commands/parse-combo.test.ts +65 -0
  215. package/src/lib/commands/parse-combo.ts +48 -0
  216. package/src/lib/commands/platform.ts +27 -0
  217. package/src/lib/commands/protected-combos.test.ts +18 -0
  218. package/src/lib/commands/protected-combos.ts +38 -0
  219. package/src/lib/commands/registry.test.ts +97 -0
  220. package/src/lib/commands/registry.ts +59 -0
  221. package/src/lib/commands/use-command.test.tsx +91 -0
  222. package/src/lib/commands/use-command.ts +32 -0
  223. package/src/lib/commands/use-escape-key.test.tsx +97 -0
  224. package/src/lib/commands/use-escape-key.ts +62 -0
  225. package/src/styles.css +321 -0
  226. 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,2 @@
1
+ export type { KbdProps } from './kbd'
2
+ export { Kbd } from './kbd'
@@ -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,2 @@
1
+ export type { MenuItemProps, MenuPopoverProps, MenuProps } from './menu'
2
+ export { Menu, MenuItem, MenuPopover, MenuTrigger, SubmenuTrigger } from './menu'
@@ -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,10 @@
1
+ export type { ModalContentProps } from './modal'
2
+ export {
3
+ Modal,
4
+ ModalBody,
5
+ ModalContent,
6
+ ModalDescription,
7
+ ModalFooter,
8
+ ModalHeader,
9
+ ModalTitle,
10
+ } from './modal'
@@ -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
+ }
@@ -0,0 +1,2 @@
1
+ export type { MultiSelectOption, MultiSelectProps } from './multi-select'
2
+ export { MultiSelect } from './multi-select'