@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,73 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite'
2
+ import { useState } from 'react'
3
+
4
+ import { TagInput } from './tag-input'
5
+
6
+ const meta: Meta<typeof TagInput> = {
7
+ title: 'Components/TagInput',
8
+ component: TagInput,
9
+ tags: ['autodocs'],
10
+ decorators: [
11
+ (Story) => (
12
+ <div className="w-[440px]">
13
+ <Story />
14
+ </div>
15
+ ),
16
+ ],
17
+ }
18
+
19
+ export default meta
20
+ type Story = StoryObj<typeof TagInput>
21
+
22
+ export const Empty: Story = {
23
+ render: () => {
24
+ const [value, setValue] = useState<string[]>([])
25
+ return (
26
+ <TagInput label="Tags" placeholder="Type, Enter to add" value={value} onChange={setValue} />
27
+ )
28
+ },
29
+ }
30
+
31
+ export const WithValues: Story = {
32
+ render: () => {
33
+ const [value, setValue] = useState<string[]>(['priority', 'beta', 'enterprise'])
34
+ return (
35
+ <TagInput
36
+ label="Tags"
37
+ description="Press Enter or ',' to add. Backspace on empty input removes the last tag."
38
+ value={value}
39
+ onChange={setValue}
40
+ />
41
+ )
42
+ },
43
+ }
44
+
45
+ export const Numeric: Story = {
46
+ render: () => {
47
+ const [value, setValue] = useState<string[]>(['100', '200', '500'])
48
+ return (
49
+ <TagInput label="Threshold values" inputType="number" value={value} onChange={setValue} />
50
+ )
51
+ },
52
+ }
53
+
54
+ export const Invalid: Story = {
55
+ render: () => {
56
+ const [value, setValue] = useState<string[]>([])
57
+ return (
58
+ <TagInput
59
+ label="Tags"
60
+ value={value}
61
+ onChange={setValue}
62
+ isInvalid
63
+ errorMessage="Add at least one tag."
64
+ />
65
+ )
66
+ },
67
+ }
68
+
69
+ export const Disabled: Story = {
70
+ render: () => (
71
+ <TagInput label="Tags" value={['locked', 'frozen']} onChange={() => {}} isDisabled />
72
+ ),
73
+ }
@@ -0,0 +1,80 @@
1
+ import { render, screen } from '@testing-library/react'
2
+ import userEvent from '@testing-library/user-event'
3
+ import { useState } from 'react'
4
+ import { describe, expect, it, vi } from 'vitest'
5
+
6
+ import { TagInput } from './tag-input'
7
+
8
+ function Harness({
9
+ initial = [],
10
+ onChange,
11
+ }: {
12
+ initial?: string[]
13
+ onChange?: (next: string[]) => void
14
+ }) {
15
+ const [value, setValue] = useState<string[]>(initial)
16
+ return (
17
+ <TagInput
18
+ label="Tags"
19
+ placeholder="Type, Enter to add"
20
+ value={value}
21
+ onChange={(next) => {
22
+ setValue(next)
23
+ onChange?.(next)
24
+ }}
25
+ />
26
+ )
27
+ }
28
+
29
+ describe('TagInput', () => {
30
+ it('commits a tag on Enter', async () => {
31
+ const user = userEvent.setup()
32
+ const onChange = vi.fn()
33
+ render(<Harness onChange={onChange} />)
34
+
35
+ const input = screen.getByPlaceholderText('Type, Enter to add')
36
+ await user.type(input, 'alpha{Enter}')
37
+
38
+ expect(onChange).toHaveBeenLastCalledWith(['alpha'])
39
+ expect(screen.getByText('alpha')).toBeInTheDocument()
40
+ })
41
+
42
+ it('commits on comma', async () => {
43
+ const user = userEvent.setup()
44
+ const onChange = vi.fn()
45
+ render(<Harness onChange={onChange} />)
46
+
47
+ await user.type(screen.getByPlaceholderText('Type, Enter to add'), 'one,')
48
+ expect(onChange).toHaveBeenLastCalledWith(['one'])
49
+ })
50
+
51
+ it('removes the last tag on Backspace when input is empty', async () => {
52
+ const user = userEvent.setup()
53
+ const onChange = vi.fn()
54
+ render(<Harness initial={['one', 'two']} onChange={onChange} />)
55
+
56
+ const input = screen.getByRole('textbox')
57
+ await user.click(input)
58
+ await user.keyboard('{Backspace}')
59
+
60
+ expect(onChange).toHaveBeenLastCalledWith(['one'])
61
+ })
62
+
63
+ it('removes a tag via the × button', async () => {
64
+ const user = userEvent.setup()
65
+ const onChange = vi.fn()
66
+ render(<Harness initial={['alpha', 'beta']} onChange={onChange} />)
67
+
68
+ await user.click(screen.getByRole('button', { name: 'Remove alpha' }))
69
+ expect(onChange).toHaveBeenLastCalledWith(['beta'])
70
+ })
71
+
72
+ it('does not commit duplicate tags', async () => {
73
+ const user = userEvent.setup()
74
+ const onChange = vi.fn()
75
+ render(<Harness initial={['alpha']} onChange={onChange} />)
76
+
77
+ await user.type(screen.getByRole('textbox'), 'alpha{Enter}')
78
+ expect(onChange).not.toHaveBeenCalled()
79
+ })
80
+ })
@@ -0,0 +1,193 @@
1
+ import { type KeyboardEvent, type ReactNode, useEffect, useId, useRef, useState } from 'react'
2
+ import { cn } from '../../lib/cn'
3
+ import {
4
+ type FieldAppearance,
5
+ fieldChipPadding,
6
+ fieldControlStyles,
7
+ fieldErrorStyles,
8
+ fieldHintStyles,
9
+ fieldLabelStyles,
10
+ fieldMinHeight,
11
+ type FieldSize,
12
+ fieldTextStyles,
13
+ } from '../field/field'
14
+
15
+ /* =========================================================================
16
+ TagInput — free-form list of strings rendered as removable chips.
17
+ Used for filter `in`/`not_in` value entry on non-enum types where the
18
+ set of valid values isn't known up front.
19
+
20
+ Behaviour:
21
+ - Type into the trailing input → Enter or `,` commits as a tag.
22
+ - Backspace on empty input → removes the last tag.
23
+ - Click × on a chip → removes it.
24
+
25
+ Controlled API only — `value: string[]` + `onChange`.
26
+ ========================================================================= */
27
+
28
+ export type TagInputProps = {
29
+ label?: ReactNode
30
+ description?: ReactNode
31
+ errorMessage?: ReactNode
32
+ /** Placeholder shown in the trailing input when there are no tags. */
33
+ placeholder?: string
34
+ /** Underlying `<input>` type. Use `'number'` to constrain numeric tags. */
35
+ inputType?: 'text' | 'number'
36
+ value: string[]
37
+ onChange: (next: string[]) => void
38
+ isDisabled?: boolean
39
+ isInvalid?: boolean
40
+ /** Field size. Defaults to `md`. */
41
+ size?: FieldSize
42
+ /** Chrome variant. Defaults to `field`. `cell` flattens the box. */
43
+ appearance?: FieldAppearance
44
+ /** Focus the trailing input on mount. Used by the filter-row chain so
45
+ * picking an operator with arity 'n' (and a non-enum field) lands focus
46
+ * directly on the tag-entry input. */
47
+ autoFocus?: boolean
48
+ /** Visual override for the field wrapper. */
49
+ className?: string
50
+ }
51
+
52
+ export function TagInput({
53
+ label,
54
+ description,
55
+ errorMessage,
56
+ placeholder = 'Type, Enter to add',
57
+ inputType = 'text',
58
+ value,
59
+ onChange,
60
+ isDisabled = false,
61
+ isInvalid = false,
62
+ size = 'md',
63
+ appearance,
64
+ autoFocus = false,
65
+ className,
66
+ }: TagInputProps) {
67
+ const styles = fieldControlStyles({ size, invalid: isInvalid, appearance })
68
+ const focusWithin =
69
+ appearance === 'cell'
70
+ ? 'focus-within:border-accent focus-within:shadow-none'
71
+ : 'focus-within:border-accent focus-within:shadow-[var(--shadow-focus)]'
72
+ const minHeight = appearance === 'cell' ? 'min-h-[29px]' : fieldMinHeight[size]
73
+ const inputRef = useRef<HTMLInputElement>(null)
74
+
75
+ useEffect(() => {
76
+ if (autoFocus) inputRef.current?.focus()
77
+ // mount-only — autoFocus is a one-shot signal, not sustained.
78
+ // eslint-disable-next-line react-hooks/exhaustive-deps
79
+ }, [])
80
+ const inputId = useId()
81
+ const [draft, setDraft] = useState('')
82
+
83
+ const commit = () => {
84
+ const v = draft.trim()
85
+ if (!v) return
86
+ if (value.includes(v)) {
87
+ setDraft('')
88
+ return
89
+ }
90
+ onChange([...value, v])
91
+ setDraft('')
92
+ }
93
+
94
+ const remove = (idx: number) => {
95
+ onChange(value.filter((_, i) => i !== idx))
96
+ }
97
+
98
+ const onKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
99
+ if (e.key === 'Enter' || e.key === ',') {
100
+ e.preventDefault()
101
+ commit()
102
+ return
103
+ }
104
+ if (e.key === 'Backspace' && !draft && value.length > 0) {
105
+ e.preventDefault()
106
+ onChange(value.slice(0, -1))
107
+ }
108
+ }
109
+
110
+ return (
111
+ <div className={cn('grid grid-cols-1 gap-1.5', className)}>
112
+ {label ? (
113
+ <label htmlFor={inputId} className={fieldLabelStyles}>
114
+ {label}
115
+ </label>
116
+ ) : null}
117
+
118
+ <div
119
+ data-invalid={isInvalid || undefined}
120
+ data-disabled={isDisabled || undefined}
121
+ className={cn(
122
+ styles.box(),
123
+ 'flex flex-wrap items-center gap-1',
124
+ minHeight,
125
+ fieldChipPadding[size],
126
+ focusWithin,
127
+ )}
128
+ >
129
+ {value.map((tag, idx) => (
130
+ // tags are kept unique on commit, so the value alone is a stable key
131
+ <span
132
+ key={tag}
133
+ className={cn(
134
+ 'inline-flex items-center gap-1',
135
+ 'px-1.5 py-0.5 rounded-sm bg-surface-3',
136
+ 'font-mono text-[12px] text-ink-2',
137
+ )}
138
+ >
139
+ <span className="max-w-[10rem] truncate">{tag}</span>
140
+ <button
141
+ type="button"
142
+ aria-label={`Remove ${tag}`}
143
+ disabled={isDisabled}
144
+ onClick={() => remove(idx)}
145
+ className={cn(
146
+ 'inline-flex items-center justify-center',
147
+ 'size-3.5 rounded-full text-ink-3 hover:text-ink hover:bg-surface-2',
148
+ 'cursor-pointer outline-none',
149
+ 'focus-visible:shadow-[var(--shadow-focus)]',
150
+ 'disabled:opacity-45 disabled:cursor-not-allowed',
151
+ )}
152
+ >
153
+ <svg aria-hidden viewBox="0 0 10 10" className="size-2" fill="none">
154
+ <path
155
+ d="M2 2l6 6M8 2l-6 6"
156
+ stroke="currentColor"
157
+ strokeWidth="1.4"
158
+ strokeLinecap="round"
159
+ />
160
+ </svg>
161
+ </button>
162
+ </span>
163
+ ))}
164
+
165
+ <input
166
+ ref={inputRef}
167
+ id={inputId}
168
+ type={inputType}
169
+ value={draft}
170
+ onChange={(e) => setDraft(e.target.value)}
171
+ onKeyDown={onKeyDown}
172
+ onBlur={commit}
173
+ disabled={isDisabled}
174
+ placeholder={value.length === 0 ? placeholder : ''}
175
+ className={cn(
176
+ 'flex-1 min-w-[6rem] bg-transparent outline-none',
177
+ 'font-sans text-ink placeholder:text-ink-4',
178
+ fieldTextStyles[size],
179
+ // No vertical padding — wrapper's p-1.5 + min-h-[size] govern the
180
+ // overall height so it matches Input/Select at the same size.
181
+ 'px-1.5',
182
+ 'disabled:cursor-not-allowed disabled:opacity-45',
183
+ )}
184
+ />
185
+ </div>
186
+
187
+ {description ? <p className={fieldHintStyles}>{description}</p> : null}
188
+ {errorMessage && appearance !== 'cell' ? (
189
+ <p className={fieldErrorStyles}>{errorMessage}</p>
190
+ ) : null}
191
+ </div>
192
+ )
193
+ }
@@ -0,0 +1,2 @@
1
+ export type { TextareaProps } from './textarea'
2
+ export { Textarea } from './textarea'
@@ -0,0 +1,75 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite'
2
+
3
+ import { Textarea } from './textarea'
4
+
5
+ const meta: Meta<typeof Textarea> = {
6
+ title: 'Components/Textarea',
7
+ component: Textarea,
8
+ tags: ['autodocs'],
9
+ args: {
10
+ label: 'Describe the system',
11
+ placeholder: 'A mid-size freight broker. Quotes, loads, carriers, lanes…',
12
+ },
13
+ argTypes: {
14
+ isDisabled: { control: { type: 'boolean' } },
15
+ isInvalid: { control: { type: 'boolean' } },
16
+ isReadOnly: { control: { type: 'boolean' } },
17
+ rows: { control: { type: 'number' } },
18
+ },
19
+ decorators: [
20
+ (Story) => (
21
+ <div className="w-[440px]">
22
+ <Story />
23
+ </div>
24
+ ),
25
+ ],
26
+ }
27
+
28
+ export default meta
29
+ type Story = StoryObj<typeof Textarea>
30
+
31
+ export const Playground: Story = {}
32
+
33
+ export const WithDescription: Story = {
34
+ args: {
35
+ description: 'Plain language. Proteos will infer entities, workflows and reports.',
36
+ },
37
+ }
38
+
39
+ export const Invalid: Story = {
40
+ args: {
41
+ isInvalid: true,
42
+ errorMessage: 'Required — tell us what to build.',
43
+ },
44
+ }
45
+
46
+ export const Disabled: Story = {
47
+ args: {
48
+ defaultValue:
49
+ 'A specialty lender. Applications, committee review, servicing, delinquency workflows.',
50
+ isDisabled: true,
51
+ },
52
+ }
53
+
54
+ export const Tall: Story = {
55
+ args: {
56
+ rows: 10,
57
+ description: 'Taller field for longer specs.',
58
+ },
59
+ }
60
+
61
+ export const AllStates: Story = {
62
+ parameters: { controls: { hideNoControlsWarning: true } },
63
+ render: () => (
64
+ <div className="grid gap-5 w-[440px]">
65
+ <Textarea label="Rest" placeholder="Hairline border, paper fill" />
66
+ <Textarea
67
+ label="Filled"
68
+ defaultValue="A specialty lender. Applications, committee review, servicing, delinquency workflows."
69
+ description="Hover or focus to see the indigo shift."
70
+ />
71
+ <Textarea label="Invalid" isInvalid errorMessage="Required — tell us what to build." />
72
+ <Textarea label="Disabled" defaultValue="Can't touch this" isDisabled />
73
+ </div>
74
+ ),
75
+ }
@@ -0,0 +1,41 @@
1
+ import { render, screen } from '@testing-library/react'
2
+ import userEvent from '@testing-library/user-event'
3
+ import { describe, expect, it } from 'vitest'
4
+
5
+ import { Textarea } from './textarea'
6
+
7
+ describe('Textarea', () => {
8
+ it('renders a labelled textarea', () => {
9
+ render(<Textarea label="Describe the system" placeholder="A mid-size freight broker…" />)
10
+ expect(screen.getByLabelText('Describe the system')).toBeInTheDocument()
11
+ })
12
+
13
+ it('accepts multi-line user input', async () => {
14
+ const user = userEvent.setup()
15
+ render(<Textarea label="Describe the system" />)
16
+
17
+ const textarea = screen.getByLabelText('Describe the system')
18
+ await user.type(textarea, 'Line one{Enter}Line two')
19
+ expect(textarea).toHaveValue('Line one\nLine two')
20
+ })
21
+
22
+ it('shows its description slot', () => {
23
+ render(<Textarea label="System" description="Plain language is fine." />)
24
+ expect(screen.getByText('Plain language is fine.')).toBeInTheDocument()
25
+ })
26
+
27
+ it('renders the error message when invalid', () => {
28
+ render(<Textarea label="System" isInvalid errorMessage="Required — tell us what to build." />)
29
+ expect(screen.getByText('Required — tell us what to build.')).toBeInTheDocument()
30
+ })
31
+
32
+ it('forwards the rows prop', () => {
33
+ render(<Textarea label="System" rows={8} />)
34
+ expect(screen.getByLabelText('System')).toHaveAttribute('rows', '8')
35
+ })
36
+
37
+ it('forwards disabled state to the underlying textarea', () => {
38
+ render(<Textarea label="System" isDisabled />)
39
+ expect(screen.getByLabelText('System')).toBeDisabled()
40
+ })
41
+ })
@@ -0,0 +1,86 @@
1
+ import type { ReactNode } from 'react'
2
+ import {
3
+ TextArea as AriaTextArea,
4
+ type TextAreaProps as AriaTextAreaProps,
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 TextareaProps = AriaTextFieldProps & {
22
+ label?: ReactNode
23
+ description?: ReactNode
24
+ errorMessage?: ReactNode
25
+ placeholder?: string
26
+ /** Visible rows. Height can still grow via vertical resize (consumer). */
27
+ rows?: number
28
+ /** Field size. Defaults to `md`. */
29
+ size?: FieldSize
30
+ /** Chrome variant. Defaults to `field`. `cell` flattens the box. */
31
+ appearance?: FieldAppearance
32
+ /** Extra props forwarded to the underlying <textarea>. */
33
+ textareaProps?: Omit<AriaTextAreaProps, 'className'>
34
+ /** Visual override for the textarea control only. */
35
+ textareaClassName?: string
36
+ }
37
+
38
+ export function Textarea({
39
+ label,
40
+ description,
41
+ errorMessage,
42
+ placeholder,
43
+ rows = 4,
44
+ size,
45
+ appearance,
46
+ textareaProps,
47
+ textareaClassName,
48
+ className,
49
+ ...props
50
+ }: TextareaProps) {
51
+ const styles = fieldControlStyles({ size, appearance })
52
+ return (
53
+ <TextField
54
+ {...props}
55
+ className={(renderProps) =>
56
+ cn(
57
+ 'grid grid-cols-1 gap-1.5',
58
+ typeof className === 'function' ? className(renderProps) : className,
59
+ )
60
+ }
61
+ >
62
+ {label ? <Label className={fieldLabelStyles}>{label}</Label> : null}
63
+ <AriaTextArea
64
+ {...textareaProps}
65
+ rows={rows}
66
+ placeholder={placeholder ?? ''}
67
+ className={cn(
68
+ // Same hairline chrome as Input, but allow vertical resize and set a
69
+ // sensible minimum height so short content doesn't collapse.
70
+ styles.box(),
71
+ styles.content(),
72
+ 'min-h-[100px] resize-y',
73
+ textareaClassName,
74
+ )}
75
+ />
76
+ {description ? (
77
+ <Text slot="description" className={fieldHintStyles}>
78
+ {description}
79
+ </Text>
80
+ ) : null}
81
+ {errorMessage && appearance !== 'cell' ? (
82
+ <FieldError className={fieldErrorStyles}>{errorMessage}</FieldError>
83
+ ) : null}
84
+ </TextField>
85
+ )
86
+ }
@@ -0,0 +1,2 @@
1
+ export type { TimeFieldProps } from './time-field'
2
+ export { TimeField } from './time-field'
@@ -0,0 +1,76 @@
1
+ import { parseTime } from '@internationalized/date'
2
+ import type { Meta, StoryObj } from '@storybook/react-vite'
3
+
4
+ import { TimeField } from './time-field'
5
+
6
+ const meta: Meta<typeof TimeField> = {
7
+ title: 'Components/TimeField',
8
+ component: TimeField,
9
+ tags: ['autodocs'],
10
+ args: {
11
+ label: 'Opens at',
12
+ },
13
+ argTypes: {
14
+ isDisabled: { control: { type: 'boolean' } },
15
+ isInvalid: { control: { type: 'boolean' } },
16
+ isReadOnly: { control: { type: 'boolean' } },
17
+ },
18
+ decorators: [
19
+ (Story) => (
20
+ <div className="w-[220px]">
21
+ <Story />
22
+ </div>
23
+ ),
24
+ ],
25
+ }
26
+
27
+ export default meta
28
+ type Story = StoryObj<typeof TimeField>
29
+
30
+ export const Playground: Story = {}
31
+
32
+ export const WithValue: Story = {
33
+ args: {
34
+ defaultValue: parseTime('09:30'),
35
+ description: 'Pre-filled with 9:30 AM.',
36
+ },
37
+ }
38
+
39
+ export const TwentyFourHour: Story = {
40
+ args: {
41
+ defaultValue: parseTime('14:30'),
42
+ hourCycle: 24,
43
+ description: '24-hour clock.',
44
+ },
45
+ }
46
+
47
+ export const SecondPrecision: Story = {
48
+ args: {
49
+ defaultValue: parseTime('14:30:05'),
50
+ granularity: 'second',
51
+ },
52
+ }
53
+
54
+ export const Invalid: Story = {
55
+ args: {
56
+ isInvalid: true,
57
+ errorMessage: 'Pick a time during business hours.',
58
+ },
59
+ }
60
+
61
+ export const Disabled: Story = {
62
+ args: {
63
+ defaultValue: parseTime('09:30'),
64
+ isDisabled: true,
65
+ },
66
+ }
67
+
68
+ export const CellAppearance: Story = {
69
+ args: {
70
+ appearance: 'cell',
71
+ size: 'sm',
72
+ label: undefined,
73
+ 'aria-label': 'Opens at',
74
+ defaultValue: parseTime('09:30'),
75
+ },
76
+ }
@@ -0,0 +1,51 @@
1
+ import { parseTime, type Time } from '@internationalized/date'
2
+ import { render, screen } from '@testing-library/react'
3
+ import userEvent from '@testing-library/user-event'
4
+ import { describe, expect, it, vi } from 'vitest'
5
+
6
+ import { TimeField } from './time-field'
7
+
8
+ describe('TimeField', () => {
9
+ it('renders a labelled time input', () => {
10
+ render(<TimeField label="Opens at" />)
11
+ expect(screen.getByText('Opens at')).toBeInTheDocument()
12
+ })
13
+
14
+ it('renders hour and minute segments', () => {
15
+ render(<TimeField label="Opens at" defaultValue={parseTime('09:30')} />)
16
+ expect(screen.getByRole('spinbutton', { name: /hour/i })).toBeInTheDocument()
17
+ expect(screen.getByRole('spinbutton', { name: /minute/i })).toBeInTheDocument()
18
+ })
19
+
20
+ it('fires onChange when a segment is incremented', async () => {
21
+ const user = userEvent.setup()
22
+ const onChange = vi.fn()
23
+ render(<TimeField label="Opens at" defaultValue={parseTime('09:30')} onChange={onChange} />)
24
+
25
+ const hour = screen.getByRole('spinbutton', { name: /hour/i })
26
+ hour.focus()
27
+ await user.keyboard('{ArrowUp}')
28
+
29
+ expect(onChange).toHaveBeenCalled()
30
+ const called = onChange.mock.calls.at(-1)?.[0] as Time | null
31
+ expect(called?.hour).toBe(10)
32
+ expect(called?.minute).toBe(30)
33
+ })
34
+
35
+ it('renders the error message when invalid', () => {
36
+ render(<TimeField label="Opens at" isInvalid errorMessage="Out of hours." />)
37
+ expect(screen.getByText('Out of hours.')).toBeInTheDocument()
38
+ })
39
+
40
+ it('disables the control when isDisabled is set', () => {
41
+ render(<TimeField label="Opens at" isDisabled defaultValue={parseTime('09:30')} />)
42
+ for (const seg of screen.getAllByRole('spinbutton')) {
43
+ expect(seg).toHaveAttribute('aria-disabled', 'true')
44
+ }
45
+ })
46
+
47
+ it('shows the description slot', () => {
48
+ render(<TimeField label="Opens at" description="Local time." />)
49
+ expect(screen.getByText('Local time.')).toBeInTheDocument()
50
+ })
51
+ })