@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,46 @@
1
+ import { cn } from '../../lib/cn'
2
+ import { parseCombo } from '../../lib/commands/parse-combo'
3
+ import { getPlatform } from '../../lib/commands/platform'
4
+ import { Kbd } from '../kbd'
5
+
6
+ /* =========================================================================
7
+ CommandHint — visual representation of a `mod+<key>` combo. Renders a
8
+ single `<Kbd>` so it slots in where the cosmetic `<Kbd>⌘K</Kbd>` lived
9
+ before. On macOS the modifier is shown as `⌘` glued directly to the key
10
+ (`⌘K`); on other platforms it reads `Ctrl+K`. Pure presentation — no
11
+ binding, no listeners.
12
+
13
+ `tone="prominent"` flips the cap to a high-contrast surface for use on
14
+ the discovery overlay where it needs to stand out against the backdrop.
15
+ ========================================================================= */
16
+
17
+ export type CommandHintTone = 'subtle' | 'prominent'
18
+
19
+ export type CommandHintProps = {
20
+ combo: string
21
+ tone?: CommandHintTone
22
+ className?: string
23
+ }
24
+
25
+ export function CommandHint({ combo, tone = 'subtle', className }: CommandHintProps) {
26
+ const parsed = parseCombo(combo)
27
+ const platform = getPlatform()
28
+ const modSymbol = platform === 'mac' ? '⌘' : 'Ctrl'
29
+ const keyLabel = parsed.isLetter ? parsed.key.toUpperCase() : prettifyKey(parsed.key)
30
+ const label = platform === 'mac' ? `${modSymbol}${keyLabel}` : `${modSymbol}+${keyLabel}`
31
+ return (
32
+ <Kbd
33
+ className={cn(
34
+ tone === 'prominent' && 'bg-ink text-surface border-transparent',
35
+ className,
36
+ )}
37
+ >
38
+ {label}
39
+ </Kbd>
40
+ )
41
+ }
42
+
43
+ function prettifyKey(key: string): string {
44
+ if (key.length === 0) return key
45
+ return key[0]!.toUpperCase() + key.slice(1)
46
+ }
@@ -0,0 +1,88 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite'
2
+ import { useState } from 'react'
3
+
4
+ import { Button } from '../button'
5
+
6
+ import { Command } from './command'
7
+ import { CommandHint } from './command-hint'
8
+
9
+ const meta: Meta<typeof Command> = {
10
+ title: 'Components/Command',
11
+ component: Command,
12
+ tags: ['autodocs'],
13
+ parameters: {
14
+ docs: {
15
+ description: {
16
+ component: [
17
+ 'Discovery-aware wrapper that binds a `mod+<key>` combo to a child',
18
+ 'button and reveals the shortcut on Cmd+Shift hold. Note: browser',
19
+ 'default combos like `mod+S` are technically overridable, but you',
20
+ 'should make that a deliberate choice — most productivity apps only',
21
+ 'override them inside document-editor contexts.',
22
+ ].join(' '),
23
+ },
24
+ },
25
+ },
26
+ }
27
+ export default meta
28
+ type Story = StoryObj<typeof Command>
29
+
30
+ export const Basic: Story = {
31
+ parameters: { controls: { hideNoControlsWarning: true } },
32
+ render: () => {
33
+ function Demo() {
34
+ const [count, setCount] = useState(0)
35
+ return (
36
+ <div className="flex flex-col items-start gap-3">
37
+ <p className="text-[13px] text-ink-3">
38
+ Press <strong>Cmd+J</strong> (or hold <strong>Cmd+Shift</strong> to peek the
39
+ shortcut, then press <strong>J</strong>).
40
+ </p>
41
+ <Command combo="mod+j">
42
+ <Button onPress={() => setCount((c) => c + 1)}>Increment ({count})</Button>
43
+ </Command>
44
+ </div>
45
+ )
46
+ }
47
+ return <Demo />
48
+ },
49
+ }
50
+
51
+ export const DiscoveryFlow: Story = {
52
+ parameters: {
53
+ controls: { hideNoControlsWarning: true },
54
+ docs: {
55
+ description: {
56
+ story:
57
+ 'Hold Cmd+Shift to reveal the bound shortcut as an overlay chip over each `<Command>` on the page. Press the bound key to fire the action.',
58
+ },
59
+ },
60
+ },
61
+ render: () => (
62
+ <div className="flex items-start gap-3 p-6 bg-surface-2 rounded-md">
63
+ <Command combo="mod+s">
64
+ <Button>Save</Button>
65
+ </Command>
66
+ <Command combo="mod+d">
67
+ <Button variant="subtle">Duplicate</Button>
68
+ </Command>
69
+ <Command combo="mod+e">
70
+ <Button variant="ghost">Export</Button>
71
+ </Command>
72
+ </div>
73
+ ),
74
+ }
75
+
76
+ export const Hint: Story = {
77
+ parameters: { controls: { hideNoControlsWarning: true } },
78
+ render: () => (
79
+ <div className="flex flex-col gap-2 text-[13px] text-ink-3">
80
+ <div className="flex items-center gap-2">
81
+ Open the palette: <CommandHint combo="mod+k" />
82
+ </div>
83
+ <div className="flex items-center gap-2">
84
+ Prominent variant: <CommandHint combo="mod+k" tone="prominent" />
85
+ </div>
86
+ </div>
87
+ ),
88
+ }
@@ -0,0 +1,102 @@
1
+ import { act, render, screen } from '@testing-library/react'
2
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
3
+
4
+ import { Button } from '../button'
5
+ import { _resetDiscovery } from '../../lib/commands/discovery-store'
6
+ import { _setPlatformForTests } from '../../lib/commands/platform'
7
+ import { _resetRegistry } from '../../lib/commands/registry'
8
+
9
+ import { Command } from './command'
10
+
11
+ function dispatchModS() {
12
+ window.dispatchEvent(
13
+ new KeyboardEvent('keydown', {
14
+ bubbles: true,
15
+ cancelable: true,
16
+ metaKey: true,
17
+ code: 'KeyS',
18
+ key: 's',
19
+ }),
20
+ )
21
+ }
22
+
23
+ function holdCmdShift() {
24
+ act(() => {
25
+ window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Shift', shiftKey: true }))
26
+ window.dispatchEvent(
27
+ new KeyboardEvent('keydown', { key: 'Meta', shiftKey: true, metaKey: true }),
28
+ )
29
+ })
30
+ }
31
+
32
+ describe('Command', () => {
33
+ beforeEach(() => {
34
+ _setPlatformForTests('mac')
35
+ _resetRegistry()
36
+ _resetDiscovery()
37
+ })
38
+ afterEach(() => {
39
+ _setPlatformForTests(undefined)
40
+ })
41
+
42
+ it('fires the wrapped Button.onPress when the combo is pressed', () => {
43
+ const onPress = vi.fn()
44
+ render(
45
+ <Command combo="mod+s">
46
+ <Button onPress={onPress}>Save</Button>
47
+ </Command>,
48
+ )
49
+ dispatchModS()
50
+ expect(onPress).toHaveBeenCalledTimes(1)
51
+ })
52
+
53
+ it('shows the discovery hint only while Cmd+Shift is held', () => {
54
+ render(
55
+ <Command combo="mod+s">
56
+ <Button>Save</Button>
57
+ </Command>,
58
+ )
59
+ expect(screen.queryByText('⌘S')).toBeNull()
60
+ holdCmdShift()
61
+ expect(screen.getByText('⌘S')).toBeInTheDocument()
62
+ })
63
+
64
+ it('cleans up on unmount', () => {
65
+ const onPress = vi.fn()
66
+ const { unmount } = render(
67
+ <Command combo="mod+s">
68
+ <Button onPress={onPress}>Save</Button>
69
+ </Command>,
70
+ )
71
+ unmount()
72
+ dispatchModS()
73
+ expect(onPress).not.toHaveBeenCalled()
74
+ })
75
+
76
+ it('targets a descendant when `target` is provided (used by RecordView for the filter)', () => {
77
+ const onPress = vi.fn()
78
+ render(
79
+ <Command combo="mod+s" target="[data-test-trigger]">
80
+ <span>
81
+ <Button>Decoy</Button>
82
+ <Button data-test-trigger onPress={onPress}>
83
+ Save
84
+ </Button>
85
+ </span>
86
+ </Command>,
87
+ )
88
+ dispatchModS()
89
+ expect(onPress).toHaveBeenCalledTimes(1)
90
+ })
91
+
92
+ it('does nothing when `target` matches no descendant', () => {
93
+ const onPress = vi.fn()
94
+ render(
95
+ <Command combo="mod+s" target="[data-missing]">
96
+ <Button onPress={onPress}>Save</Button>
97
+ </Command>,
98
+ )
99
+ dispatchModS()
100
+ expect(onPress).not.toHaveBeenCalled()
101
+ })
102
+ })
@@ -0,0 +1,76 @@
1
+ 'use client'
2
+
3
+ import { type ReactNode, useRef } from 'react'
4
+
5
+ import { cn } from '../../lib/cn'
6
+ import { useCommand } from '../../lib/commands/use-command'
7
+ import { useDiscoveryActive } from '../../lib/commands/discovery-store'
8
+
9
+ import { CommandHint } from './command-hint'
10
+
11
+ /* =========================================================================
12
+ Command — discovery-aware wrapper around an interactive element. Binds
13
+ `combo` to a synthetic click on the wrapped child (RAC `Button`'s
14
+ `onPress` listens for native click events, so this fires the user's
15
+ handler), and reveals a hint chip while Cmd+Shift is held so users can
16
+ learn the bound shortcut.
17
+
18
+ <Command combo="mod+s">
19
+ <Button onPress={onSave}>Save</Button>
20
+ </Command>
21
+
22
+ When the trigger isn't the immediate child (e.g. wrapped in a `<span>`
23
+ for popover anchoring, or buried inside a list), pass `target` — a CSS
24
+ selector resolved against the wrapper. The first matching descendant
25
+ receives the synthetic click.
26
+
27
+ <Command combo="mod+f" target="[data-filter-trigger]">
28
+ <RecordFilterButton … />
29
+ <RecordFilterChips … />
30
+ </Command>
31
+
32
+ `className` overrides the wrapper layout (default `inline-block`) for
33
+ call sites where the wrapper needs to participate in a flex/grid layout.
34
+ The wrapper stays `relative` either way so the discovery overlay can
35
+ absolute-position against it.
36
+ ========================================================================= */
37
+
38
+ export type CommandProps = {
39
+ combo: string
40
+ /** CSS selector for the click target. Defaults to the wrapper's first
41
+ * element child — fine when that child IS the interactive control. */
42
+ target?: string
43
+ /** Layout overrides for the wrapper. `relative` is always applied. */
44
+ className?: string
45
+ children: ReactNode
46
+ }
47
+
48
+ export function Command({ combo, target, className, children }: CommandProps) {
49
+ const wrapperRef = useRef<HTMLDivElement>(null)
50
+ const discoveryActive = useDiscoveryActive()
51
+
52
+ useCommand(combo, () => {
53
+ const root = wrapperRef.current
54
+ if (!root) return
55
+ const el = target
56
+ ? root.querySelector<HTMLElement>(target)
57
+ : (root.firstElementChild as HTMLElement | null)
58
+ el?.click()
59
+ })
60
+
61
+ return (
62
+ <div ref={wrapperRef} className={cn('relative inline-block', className)}>
63
+ {children}
64
+ {discoveryActive ? (
65
+ <div
66
+ aria-hidden
67
+ className="absolute -inset-2 rounded-md bg-[color-mix(in_oklab,var(--color-ink)_28%,transparent)] pointer-events-none transition-opacity z-40"
68
+ >
69
+ <span className="absolute -top-2.5 -left-2 pointer-events-none z-50">
70
+ <CommandHint combo={combo} tone="prominent" />
71
+ </span>
72
+ </div>
73
+ ) : null}
74
+ </div>
75
+ )
76
+ }
@@ -0,0 +1,16 @@
1
+ export type { CommandProps } from './command'
2
+ export { Command } from './command'
3
+ export type { CommandHintProps, CommandHintTone } from './command-hint'
4
+ export { CommandHint } from './command-hint'
5
+
6
+ // Re-export the command-system primitives so consumers can `import { useCommand,
7
+ // useEscapeKey, parseCombo, useDiscoveryActive } from '@proteos/ui'`.
8
+ export type { KeyCombo, UseCommandOptions, UseEscapeKeyOptions } from '../../lib/commands'
9
+ export {
10
+ isEditableTarget,
11
+ parseCombo,
12
+ PROTECTED_COMBOS,
13
+ useCommand,
14
+ useDiscoveryActive,
15
+ useEscapeKey,
16
+ } from '../../lib/commands'
@@ -0,0 +1,192 @@
1
+ import { formatAmount, parseAmount } from '@proteos/sdk'
2
+ import { ChevronDown } from 'lucide-react'
3
+ import { type ReactNode, useEffect, useState } from 'react'
4
+ import {
5
+ Button as AriaSelectTrigger,
6
+ ListBox,
7
+ ListBoxItem,
8
+ Popover,
9
+ Select as AriaSelect,
10
+ } from 'react-aria-components'
11
+
12
+ import { cn } from '../../lib/cn'
13
+ import { type FieldAppearance, fieldControlStyles, fieldErrorStyles, type FieldSize } from '../field/field'
14
+
15
+ /* =========================================================================
16
+ CurrencyInput — a single money field: an amount input plus a trailing
17
+ currency-symbol button that opens a symbol+code dropdown (the same shape as
18
+ the date-picker's trailing calendar trigger).
19
+
20
+ The amount is held and emitted as a canonical decimal STRING (never
21
+ parseFloat'd) so precision and trailing zeros survive; it is *displayed* in
22
+ the viewer's locale (grouping + decimal separator) and parsed back from that
23
+ notation on input. The selectable currencies are passed in via
24
+ `currencyOptions` so the lib stays free of any ISO/Intl dataset; the locale
25
+ number helpers come from the SDK so there's one well-defined implementation.
26
+ ========================================================================= */
27
+
28
+ export interface CurrencyInputValue {
29
+ amount: string
30
+ currency_code: string
31
+ }
32
+
33
+ export interface CurrencyInputOption {
34
+ code: string
35
+ /** The currency's symbol (e.g. "$", "€"); falls back to the code. */
36
+ symbol?: string
37
+ }
38
+
39
+ export interface CurrencyInputProps {
40
+ value: CurrencyInputValue | null
41
+ onChange: (next: CurrencyInputValue | null) => void
42
+ currencyOptions: ReadonlyArray<CurrencyInputOption>
43
+ /** Locale for the amount's grouping + decimal separators. Defaults to runtime. */
44
+ locale?: string
45
+ isReadOnly?: boolean
46
+ isInvalid?: boolean
47
+ errorMessage?: ReactNode
48
+ size?: FieldSize
49
+ appearance?: FieldAppearance
50
+ placeholder?: string
51
+ autoFocus?: boolean
52
+ 'aria-label'?: string
53
+ onBlur?: () => void
54
+ className?: string
55
+ }
56
+
57
+ export function CurrencyInput({
58
+ value,
59
+ onChange,
60
+ currencyOptions,
61
+ locale,
62
+ isReadOnly,
63
+ isInvalid,
64
+ errorMessage,
65
+ size,
66
+ appearance,
67
+ placeholder,
68
+ autoFocus,
69
+ 'aria-label': ariaLabel,
70
+ onBlur,
71
+ className,
72
+ }: CurrencyInputProps) {
73
+ const amount = value?.amount ?? ''
74
+ const code = value?.currency_code ?? ''
75
+
76
+ // `draft` is the text shown in the input. It's grouped (e.g. "1.234,56") when
77
+ // the field is idle and ungrouped ("1234,56") while editing, so grouping
78
+ // doesn't fight the caret mid-type. The canonical value still flows out via
79
+ // onChange on every keystroke.
80
+ const [draft, setDraft] = useState(() => formatAmount(amount, locale, { grouped: true }))
81
+ const [isEditing, setIsEditing] = useState(false)
82
+
83
+ useEffect(() => {
84
+ if (!isEditing) setDraft(formatAmount(amount, locale, { grouped: true }))
85
+ }, [amount, locale, isEditing])
86
+
87
+ const emit = (nextAmount: string, nextCode: string) => {
88
+ if (nextAmount === '' && nextCode === '') {
89
+ onChange(null)
90
+ return
91
+ }
92
+ onChange({ amount: nextAmount, currency_code: nextCode })
93
+ }
94
+
95
+ const selectedSymbol = currencyOptions.find((option) => option.code === code)?.symbol ?? code
96
+
97
+ const styles = fieldControlStyles({ size, appearance, ...(isInvalid ? { invalid: true } : {}) })
98
+ const focusWithin =
99
+ appearance === 'cell'
100
+ ? 'focus-within:border-accent focus-within:shadow-none'
101
+ : 'focus-within:border-accent focus-within:shadow-[var(--shadow-focus)]'
102
+
103
+ return (
104
+ <div className={cn('grid grid-cols-1 gap-1.5', className)}>
105
+ <div className={cn(styles.box(), 'flex items-center gap-1 pr-1', focusWithin)}>
106
+ <input
107
+ className={cn(
108
+ 'min-w-0 flex-1 bg-transparent text-ink outline-none tabular-nums',
109
+ styles.content(),
110
+ // Sit a touch lower than a standard field by trimming the vertical
111
+ // padding (cell mode keeps the fixed 29px row-editor height).
112
+ appearance === 'cell' ? '' : size === 'sm' ? 'py-1' : 'py-1.5',
113
+ )}
114
+ inputMode="decimal"
115
+ value={draft}
116
+ onChange={(event) => {
117
+ setDraft(event.target.value)
118
+ emit(parseAmount(event.target.value, locale), code)
119
+ }}
120
+ onFocus={() => {
121
+ setIsEditing(true)
122
+ setDraft(formatAmount(amount, locale, { grouped: false }))
123
+ }}
124
+ onBlur={() => {
125
+ setIsEditing(false)
126
+ const canonical = parseAmount(draft, locale)
127
+ setDraft(formatAmount(canonical, locale, { grouped: true }))
128
+ emit(canonical, code)
129
+ onBlur?.()
130
+ }}
131
+ readOnly={isReadOnly ?? false}
132
+ autoFocus={autoFocus ?? false}
133
+ placeholder={placeholder ?? ''}
134
+ aria-label={ariaLabel ? `${ariaLabel} amount` : 'Amount'}
135
+ />
136
+
137
+ <AriaSelect
138
+ aria-label={ariaLabel ? `${ariaLabel} currency` : 'Currency'}
139
+ selectedKey={code === '' ? null : code}
140
+ onSelectionChange={(key) => emit(amount, key == null ? '' : String(key))}
141
+ isDisabled={isReadOnly ?? false}
142
+ >
143
+ <AriaSelectTrigger
144
+ className={cn(
145
+ 'inline-flex h-6 shrink-0 items-center gap-0.5 rounded-md px-1.5',
146
+ 'text-ink-2 outline-none hover:bg-surface-2 hover:text-ink',
147
+ 'cursor-pointer focus-visible:shadow-[var(--shadow-focus)]',
148
+ 'data-[disabled]:cursor-not-allowed data-[disabled]:opacity-45',
149
+ )}
150
+ >
151
+ <span className="min-w-[1ch] text-center tabular-nums">{selectedSymbol || '¤'}</span>
152
+ <ChevronDown className="size-3 opacity-60" aria-hidden />
153
+ </AriaSelectTrigger>
154
+
155
+ <Popover
156
+ offset={6}
157
+ className={cn(
158
+ 'min-w-[9rem] rounded-md border border-rule bg-surface p-1',
159
+ 'shadow-[var(--shadow-raised)]',
160
+ 'entering:animate-in entering:fade-in-0 entering:slide-in-from-top-1',
161
+ 'exiting:animate-out exiting:fade-out-0 exiting:slide-out-to-top-1',
162
+ )}
163
+ >
164
+ <ListBox className="max-h-72 overflow-auto outline-none">
165
+ {currencyOptions.map((option) => (
166
+ <ListBoxItem
167
+ key={option.code}
168
+ id={option.code}
169
+ textValue={`${option.symbol ?? ''} ${option.code}`}
170
+ className={cn(
171
+ 'flex items-center gap-2 rounded-[6px] px-2.5 py-1.5 text-[14px] text-ink-2',
172
+ 'cursor-pointer select-none outline-none',
173
+ 'data-[hovered]:bg-surface-2 data-[hovered]:text-ink',
174
+ 'data-[focused]:bg-surface-2 data-[focused]:text-ink',
175
+ 'data-[selected]:bg-surface-3 data-[selected]:text-ink',
176
+ )}
177
+ >
178
+ <span className="w-5 shrink-0 text-ink-3 tabular-nums">{option.symbol ?? ''}</span>
179
+ <span className="font-medium">{option.code}</span>
180
+ </ListBoxItem>
181
+ ))}
182
+ </ListBox>
183
+ </Popover>
184
+ </AriaSelect>
185
+ </div>
186
+
187
+ {errorMessage && appearance !== 'cell' ? (
188
+ <div className={fieldErrorStyles}>{errorMessage}</div>
189
+ ) : null}
190
+ </div>
191
+ )
192
+ }
@@ -0,0 +1,6 @@
1
+ export type {
2
+ CurrencyInputOption,
3
+ CurrencyInputProps,
4
+ CurrencyInputValue,
5
+ } from './currency-input'
6
+ export { CurrencyInput } from './currency-input'
@@ -0,0 +1,120 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite'
2
+
3
+ import {
4
+ Table,
5
+ TableBody,
6
+ TableCell,
7
+ TableHeader,
8
+ TableHeaderCell,
9
+ TableHeaderSpacer,
10
+ TableRow,
11
+ } from '../table'
12
+ import { BooleanCell, DateCell, DateTimeCell, LinkCell, TruncateCell } from './cells'
13
+
14
+ /* =========================================================================
15
+ Stories for the attribute-driven cell helpers. These are intentionally
16
+ small — they ship in the lib only because RecordTable dispatches on
17
+ AttributeType at runtime. Anything domain-specific (status pills, route
18
+ pairs, deltas, avatars+name) is consumer JSX inside `column.cell`.
19
+ ========================================================================= */
20
+
21
+ const meta: Meta = {
22
+ title: 'Components/DataTable/Cells',
23
+ parameters: { layout: 'centered' },
24
+ tags: ['autodocs'],
25
+ }
26
+ export default meta
27
+
28
+ type Story = StoryObj
29
+
30
+ const COL_WIDTHS = { label: 180, value: 240 } as const
31
+
32
+ function Showcase({ rows }: { rows: ReadonlyArray<{ label: string; value: React.ReactNode }> }) {
33
+ return (
34
+ <div className="w-[480px]">
35
+ <Table aria-label="Cell helpers">
36
+ <TableHeader>
37
+ <TableHeaderCell width={COL_WIDTHS.label}>Cell</TableHeaderCell>
38
+ <TableHeaderCell width={COL_WIDTHS.value}>Renders</TableHeaderCell>
39
+ <TableHeaderSpacer />
40
+ </TableHeader>
41
+ <TableBody>
42
+ {rows.map((row, i) => (
43
+ <TableRow key={row.label} alt={i % 2 === 1} style={{ height: 36 }}>
44
+ <TableCell width={COL_WIDTHS.label} mono>
45
+ {row.label}
46
+ </TableCell>
47
+ <TableCell width={COL_WIDTHS.value}>{row.value}</TableCell>
48
+ </TableRow>
49
+ ))}
50
+ </TableBody>
51
+ </Table>
52
+ </div>
53
+ )
54
+ }
55
+
56
+ export const DateCells: Story = {
57
+ render: () => (
58
+ <Showcase
59
+ rows={[
60
+ { label: 'DateCell · ISO', value: <DateCell value="2026-04-28" /> },
61
+ { label: 'DateCell · null', value: <DateCell value={null} /> },
62
+ { label: 'DateTimeCell', value: <DateTimeCell value="2026-04-28T13:42:00Z" /> },
63
+ { label: 'DateTimeCell · invalid', value: <DateTimeCell value="not-a-date" /> },
64
+ ]}
65
+ />
66
+ ),
67
+ }
68
+
69
+ export const Booleans: Story = {
70
+ render: () => (
71
+ <Showcase
72
+ rows={[
73
+ { label: 'BooleanCell · true', value: <BooleanCell value={true} /> },
74
+ { label: 'BooleanCell · false', value: <BooleanCell value={false} /> },
75
+ { label: 'BooleanCell · null', value: <BooleanCell value={null} /> },
76
+ ]}
77
+ />
78
+ ),
79
+ }
80
+
81
+ export const Truncation: Story = {
82
+ render: () => (
83
+ <Showcase
84
+ rows={[
85
+ {
86
+ label: 'TruncateCell · short',
87
+ value: <TruncateCell value="Atlanta, GA" />,
88
+ },
89
+ {
90
+ label: 'TruncateCell · long',
91
+ value: (
92
+ <TruncateCell value="Industrial chemicals, hazmat class 3, 18,420 lb, refrigeration not required" />
93
+ ),
94
+ },
95
+ { label: 'TruncateCell · empty', value: <TruncateCell value={null} /> },
96
+ ]}
97
+ />
98
+ ),
99
+ }
100
+
101
+ export const Links: Story = {
102
+ render: () => (
103
+ <Showcase
104
+ rows={[
105
+ {
106
+ label: 'LinkCell',
107
+ value: <LinkCell href="#shipment-10482">SH-10482</LinkCell>,
108
+ },
109
+ {
110
+ label: 'LinkCell · external',
111
+ value: (
112
+ <LinkCell href="https://example.com/SH-10482" target="_blank" rel="noreferrer">
113
+ View on partner ↗
114
+ </LinkCell>
115
+ ),
116
+ },
117
+ ]}
118
+ />
119
+ ),
120
+ }