@structuralists/scaffolding 0.0.1

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 (225) hide show
  1. package/.storybook/main.ts +9 -0
  2. package/.storybook/manager.ts +13 -0
  3. package/.storybook/preview.tsx +18 -0
  4. package/CLAUDE.md +30 -0
  5. package/LICENSE +21 -0
  6. package/README.md +7 -0
  7. package/bun.lock +947 -0
  8. package/bunfig.toml +2 -0
  9. package/eslint.config.mjs +106 -0
  10. package/index.ts +1 -0
  11. package/package.json +50 -0
  12. package/src/components/Chat/ChatComposer/ChatComposer.stories.tsx +68 -0
  13. package/src/components/Chat/ChatComposer/index.tsx +74 -0
  14. package/src/components/Chat/ChatComposer/styles.module.css +88 -0
  15. package/src/components/Chat/ChatComposer/types.ts +11 -0
  16. package/src/components/Chat/ChatMessage/ChatMessage.stories.tsx +111 -0
  17. package/src/components/Chat/ChatMessage/index.tsx +42 -0
  18. package/src/components/Chat/ChatMessage/styles.module.css +58 -0
  19. package/src/components/Chat/ChatMessage/types.ts +14 -0
  20. package/src/components/Chat/ChatRecipientsHeader/ChatRecipientsHeader.stories.tsx +145 -0
  21. package/src/components/Chat/ChatRecipientsHeader/index.tsx +29 -0
  22. package/src/components/Chat/ChatRecipientsHeader/styles.module.css +48 -0
  23. package/src/components/Chat/ChatRecipientsHeader/types.ts +26 -0
  24. package/src/components/Chat/ChatShell/ChatShell.stories.tsx +203 -0
  25. package/src/components/Chat/ChatShell/index.tsx +16 -0
  26. package/src/components/Chat/ChatShell/styles.module.css +27 -0
  27. package/src/components/Chat/ChatShell/types.ts +7 -0
  28. package/src/components/Chat/PillCombobox/PillCombobox.stories.tsx +59 -0
  29. package/src/components/Chat/PillCombobox/index.tsx +17 -0
  30. package/src/components/Chat/PillCombobox/styles.module.css +29 -0
  31. package/src/components/Chat/PillCombobox/types.ts +28 -0
  32. package/src/components/Chat/PillComboboxCore/Core.tsx +235 -0
  33. package/src/components/Chat/PillComboboxCore/styles.module.css +79 -0
  34. package/src/components/Chat/index.ts +12 -0
  35. package/src/components/Content/Badge/Badge.stories.tsx +31 -0
  36. package/src/components/Content/Badge/index.tsx +22 -0
  37. package/src/components/Content/Badge/styles.module.css +25 -0
  38. package/src/components/Content/Badge/types.ts +7 -0
  39. package/src/components/Content/Card/Card.stories.tsx +24 -0
  40. package/src/components/Content/Card/index.tsx +21 -0
  41. package/src/components/Content/Card/styles.module.css +13 -0
  42. package/src/components/Content/Card/types.ts +8 -0
  43. package/src/components/Content/EditableMarkdown/EditableMarkdown.stories.tsx +58 -0
  44. package/src/components/Content/EditableMarkdown/index.tsx +140 -0
  45. package/src/components/Content/EditableMarkdown/styles.module.css +221 -0
  46. package/src/components/Content/EditableMarkdown/types.ts +11 -0
  47. package/src/components/Content/Heading/Heading.stories.tsx +26 -0
  48. package/src/components/Content/Heading/index.tsx +20 -0
  49. package/src/components/Content/Heading/styles.module.css +19 -0
  50. package/src/components/Content/Heading/types.ts +8 -0
  51. package/src/components/Content/Link/Link.stories.tsx +21 -0
  52. package/src/components/Content/Link/index.tsx +19 -0
  53. package/src/components/Content/Link/styles.module.css +11 -0
  54. package/src/components/Content/Link/types.ts +8 -0
  55. package/src/components/Content/List/List.stories.tsx +62 -0
  56. package/src/components/Content/List/index.tsx +26 -0
  57. package/src/components/Content/List/styles.module.css +41 -0
  58. package/src/components/Content/List/types.ts +33 -0
  59. package/src/components/Content/LoadingContainer/LoadingContainer.stories.tsx +105 -0
  60. package/src/components/Content/LoadingContainer/index.tsx +36 -0
  61. package/src/components/Content/LoadingContainer/styles.module.css +54 -0
  62. package/src/components/Content/LoadingContainer/types.ts +8 -0
  63. package/src/components/Content/Markdown/Markdown.stories.tsx +39 -0
  64. package/src/components/Content/Markdown/index.tsx +28 -0
  65. package/src/components/Content/Markdown/styles.module.css +79 -0
  66. package/src/components/Content/Markdown/types.ts +8 -0
  67. package/src/components/Content/Menu/Menu.stories.tsx +186 -0
  68. package/src/components/Content/Menu/index.tsx +259 -0
  69. package/src/components/Content/Menu/styles.module.css +103 -0
  70. package/src/components/Content/Menu/types.ts +25 -0
  71. package/src/components/Content/Text/Text.stories.tsx +36 -0
  72. package/src/components/Content/Text/index.tsx +35 -0
  73. package/src/components/Content/Text/styles.module.css +30 -0
  74. package/src/components/Content/Text/types.ts +11 -0
  75. package/src/components/Forms/Button/Button.stories.tsx +40 -0
  76. package/src/components/Forms/Button/index.tsx +43 -0
  77. package/src/components/Forms/Button/styles.module.css +67 -0
  78. package/src/components/Forms/Button/types.ts +16 -0
  79. package/src/components/Forms/ColorInput/index.tsx +22 -0
  80. package/src/components/Forms/ColorInput/styles.module.css +19 -0
  81. package/src/components/Forms/ColorInput/types.ts +12 -0
  82. package/src/components/Forms/Field/Field.stories.tsx +35 -0
  83. package/src/components/Forms/Field/index.tsx +17 -0
  84. package/src/components/Forms/Field/styles.module.css +21 -0
  85. package/src/components/Forms/Field/types.ts +9 -0
  86. package/src/components/Forms/IconButton/IconButton.stories.tsx +91 -0
  87. package/src/components/Forms/IconButton/index.tsx +55 -0
  88. package/src/components/Forms/IconButton/styles.module.css +61 -0
  89. package/src/components/Forms/IconButton/types.ts +23 -0
  90. package/src/components/Forms/Input/Input.stories.tsx +22 -0
  91. package/src/components/Forms/Input/index.tsx +42 -0
  92. package/src/components/Forms/Input/styles.module.css +30 -0
  93. package/src/components/Forms/Input/types.ts +18 -0
  94. package/src/components/Forms/SearchInput/index.tsx +41 -0
  95. package/src/components/Forms/SearchInput/styles.module.css +30 -0
  96. package/src/components/Forms/SearchInput/types.ts +17 -0
  97. package/src/components/Forms/Select/MultiSelect/MultiSelect.stories.tsx +116 -0
  98. package/src/components/Forms/Select/MultiSelect/index.tsx +74 -0
  99. package/src/components/Forms/Select/MultiSelect/types.ts +15 -0
  100. package/src/components/Forms/Select/SingleSelect/SingleSelect.stories.tsx +174 -0
  101. package/src/components/Forms/Select/SingleSelect/index.tsx +62 -0
  102. package/src/components/Forms/Select/SingleSelect/types.ts +12 -0
  103. package/src/components/Forms/Select/index.ts +4 -0
  104. package/src/components/Forms/Select/internal/OptionList.tsx +124 -0
  105. package/src/components/Forms/Select/internal/SelectTrigger.tsx +60 -0
  106. package/src/components/Forms/Select/internal/styles.module.css +122 -0
  107. package/src/components/Forms/Textarea/Textarea.stories.tsx +25 -0
  108. package/src/components/Forms/Textarea/index.tsx +48 -0
  109. package/src/components/Forms/Textarea/styles.module.css +34 -0
  110. package/src/components/Forms/Textarea/types.ts +24 -0
  111. package/src/components/Json/Json/Json.stories.tsx +33 -0
  112. package/src/components/Json/Json/index.tsx +38 -0
  113. package/src/components/Json/Json/types.ts +21 -0
  114. package/src/components/Json/JsonTable/JsonLeafNode.tsx +31 -0
  115. package/src/components/Json/JsonTable/JsonTable.stories.tsx +52 -0
  116. package/src/components/Json/JsonTable/index.tsx +33 -0
  117. package/src/components/Json/JsonTable/types.ts +13 -0
  118. package/src/components/Json/JsonTable/utils.ts +6 -0
  119. package/src/components/Layout/Bar/Bar.stories.tsx +100 -0
  120. package/src/components/Layout/Bar/index.tsx +17 -0
  121. package/src/components/Layout/Bar/styles.module.css +34 -0
  122. package/src/components/Layout/Bar/types.ts +10 -0
  123. package/src/components/Layout/Debug/Debug.stories.tsx +86 -0
  124. package/src/components/Layout/Debug/index.tsx +41 -0
  125. package/src/components/Layout/Debug/styles.module.css +13 -0
  126. package/src/components/Layout/Debug/types.ts +12 -0
  127. package/src/components/Layout/Divider/Divider.stories.tsx +22 -0
  128. package/src/components/Layout/Divider/index.tsx +3 -0
  129. package/src/components/Layout/Divider/styles.module.css +6 -0
  130. package/src/components/Layout/Grid/Grid.stories.tsx +28 -0
  131. package/src/components/Layout/Grid/index.tsx +29 -0
  132. package/src/components/Layout/Grid/styles.module.css +12 -0
  133. package/src/components/Layout/Grid/types.ts +9 -0
  134. package/src/components/Layout/Panels/Panels.stories.tsx +287 -0
  135. package/src/components/Layout/Panels/index.tsx +119 -0
  136. package/src/components/Layout/Panels/styles.module.css +103 -0
  137. package/src/components/Layout/Panels/types.ts +36 -0
  138. package/src/components/Layout/Stack/Stack.stories.tsx +45 -0
  139. package/src/components/Layout/Stack/index.tsx +73 -0
  140. package/src/components/Layout/Stack/styles.module.css +41 -0
  141. package/src/components/Layout/Stack/types.ts +17 -0
  142. package/src/components/Modals/ConfirmModal/ConfirmModal.stories.tsx +73 -0
  143. package/src/components/Modals/ConfirmModal/index.tsx +72 -0
  144. package/src/components/Modals/ConfirmModal/styles.module.css +62 -0
  145. package/src/components/Modals/ConfirmModal/types.ts +14 -0
  146. package/src/components/Modals/LargeModal/LargeModal.stories.tsx +75 -0
  147. package/src/components/Modals/LargeModal/index.tsx +9 -0
  148. package/src/components/Modals/LargeModal/styles.module.css +6 -0
  149. package/src/components/Modals/LargeModal/types.ts +18 -0
  150. package/src/components/Modals/MediumModal/MediumModal.stories.tsx +121 -0
  151. package/src/components/Modals/MediumModal/MediumModal.test.tsx +48 -0
  152. package/src/components/Modals/MediumModal/index.tsx +9 -0
  153. package/src/components/Modals/MediumModal/styles.module.css +5 -0
  154. package/src/components/Modals/MediumModal/types.ts +18 -0
  155. package/src/components/Modals/index.ts +3 -0
  156. package/src/components/Modals/internal/ModalBody.tsx +21 -0
  157. package/src/components/Modals/internal/ModalFooter.tsx +12 -0
  158. package/src/components/Modals/internal/ModalHeader.tsx +27 -0
  159. package/src/components/Modals/internal/ModalShell.tsx +112 -0
  160. package/src/components/Modals/internal/styles.module.css +141 -0
  161. package/src/components/Navigation/TabBar/TabBar.stories.tsx +59 -0
  162. package/src/components/Navigation/TabBar/index.tsx +25 -0
  163. package/src/components/Navigation/TabBar/styles.module.css +32 -0
  164. package/src/components/Navigation/TabBar/types.ts +22 -0
  165. package/src/components/Navigation/VerticalNav/VerticalNav.stories.tsx +41 -0
  166. package/src/components/Navigation/VerticalNav/index.tsx +25 -0
  167. package/src/components/Navigation/VerticalNav/styles.module.css +28 -0
  168. package/src/components/Navigation/VerticalNav/types.ts +19 -0
  169. package/src/components/Overlays/Popover/Popover.stories.tsx +154 -0
  170. package/src/components/Overlays/Popover/index.tsx +175 -0
  171. package/src/components/Overlays/Popover/styles.module.css +59 -0
  172. package/src/components/Overlays/Popover/types.ts +34 -0
  173. package/src/components/Overlays/Tooltip/Tooltip.stories.tsx +41 -0
  174. package/src/components/Overlays/Tooltip/index.tsx +115 -0
  175. package/src/components/Overlays/Tooltip/styles.module.css +25 -0
  176. package/src/components/Overlays/Tooltip/types.ts +15 -0
  177. package/src/components/Primitives/EmptyValue/EmptyValue.stories.tsx +18 -0
  178. package/src/components/Primitives/EmptyValue/index.tsx +3 -0
  179. package/src/components/Primitives/EmptyValue/styles.module.css +3 -0
  180. package/src/components/Primitives/LinedStack/LinedStack.stories.tsx +101 -0
  181. package/src/components/Primitives/LinedStack/index.tsx +41 -0
  182. package/src/components/Primitives/LinedStack/styles.module.css +27 -0
  183. package/src/components/Primitives/LinedStack/types.ts +49 -0
  184. package/src/components/Primitives/LongText/LongText.stories.tsx +72 -0
  185. package/src/components/Primitives/LongText/index.tsx +67 -0
  186. package/src/components/Primitives/LongText/styles.module.css +30 -0
  187. package/src/components/Primitives/LongText/types.ts +4 -0
  188. package/src/components/Primitives/Num/Num.stories.tsx +51 -0
  189. package/src/components/Primitives/Num/index.tsx +37 -0
  190. package/src/components/Primitives/Num/types.ts +19 -0
  191. package/src/components/Primitives/Percent/Percent.stories.tsx +48 -0
  192. package/src/components/Primitives/Percent/index.tsx +15 -0
  193. package/src/components/Primitives/Percent/types.ts +10 -0
  194. package/src/components/Primitives/RelativeTime/RelativeTime.stories.tsx +57 -0
  195. package/src/components/Primitives/RelativeTime/index.tsx +31 -0
  196. package/src/components/Primitives/RelativeTime/types.ts +3 -0
  197. package/src/components/Tables/BigTable/BigTable.stories.tsx +367 -0
  198. package/src/components/Tables/BigTable/CLAUDE.md +118 -0
  199. package/src/components/Tables/BigTable/columnDefs.tsx +208 -0
  200. package/src/components/Tables/BigTable/index.tsx +104 -0
  201. package/src/components/Tables/BigTable/styles.module.css +83 -0
  202. package/src/components/Tables/BigTable/types.ts +20 -0
  203. package/src/components/Tables/QuickTable/CLAUDE.md +118 -0
  204. package/src/components/Tables/QuickTable/QuickTable.stories.tsx +121 -0
  205. package/src/components/Tables/QuickTable/index.tsx +86 -0
  206. package/src/components/Tables/QuickTable/internal.tsx +48 -0
  207. package/src/components/Tables/QuickTable/styles.module.css +65 -0
  208. package/src/components/Tables/QuickTable/types.ts +40 -0
  209. package/src/env.d.ts +4 -0
  210. package/src/index.ts +87 -0
  211. package/src/storybook/CLAUDE.md +35 -0
  212. package/src/storybook/Composition.stories.tsx +269 -0
  213. package/src/storybook/Lorem/index.tsx +54 -0
  214. package/src/storybook/Placeholder/index.tsx +27 -0
  215. package/src/storybook/Placeholder/styles.module.css +20 -0
  216. package/src/storybook/Repeat/index.tsx +23 -0
  217. package/src/storybook/Toggle/index.tsx +29 -0
  218. package/src/storybook/_StoryUtils.stories.tsx +58 -0
  219. package/src/storybook/index.ts +4 -0
  220. package/src/tokens.ts +31 -0
  221. package/src/utils.test.ts +24 -0
  222. package/src/utils.ts +2 -0
  223. package/test-setup.ts +3 -0
  224. package/tokens.css +323 -0
  225. package/tsconfig.json +16 -0
@@ -0,0 +1,174 @@
1
+ import type { Meta, StoryObj } from '@storybook/react';
2
+ import { useState } from 'react';
3
+ import { SingleSelect } from './index';
4
+ import { Button } from '../../Button';
5
+ import { Field } from '../../Field';
6
+ import { MediumModal } from '../../../Modals';
7
+ import { Stack } from '../../../Layout/Stack';
8
+ import { Text } from '../../../Content/Text';
9
+ import { Toggle } from '../../../../storybook';
10
+
11
+ const meta: Meta<typeof SingleSelect> = {
12
+ title: 'Forms/SingleSelect',
13
+ component: SingleSelect,
14
+ };
15
+
16
+ export default meta;
17
+ type Story = StoryObj<typeof SingleSelect>;
18
+
19
+ type Period = 'weekly' | 'monthly' | 'quarterly' | 'annual';
20
+
21
+ const PERIOD_OPTIONS: { value: Period; label: string }[] = [
22
+ { value: 'weekly', label: 'Weekly' },
23
+ { value: 'monthly', label: 'Monthly' },
24
+ { value: 'quarterly', label: 'Quarterly' },
25
+ { value: 'annual', label: 'Annual' },
26
+ ];
27
+
28
+ export const Basic: Story = {
29
+ render: () => {
30
+ const [value, setValue] = useState<Period>('monthly');
31
+ return (
32
+ <div style={{ padding: 32, maxWidth: 360 }}>
33
+ <Field label="Period">
34
+ <SingleSelect<Period>
35
+ options={PERIOD_OPTIONS}
36
+ value={value}
37
+ onChange={setValue}
38
+ />
39
+ </Field>
40
+ <div style={{ marginTop: 16 }}>
41
+ <Text size="small" isMuted>
42
+ Selected: {value}
43
+ </Text>
44
+ </div>
45
+ </div>
46
+ );
47
+ },
48
+ };
49
+
50
+ export const Unselected: Story = {
51
+ render: () => {
52
+ const [value, setValue] = useState<Period | null>(null);
53
+ return (
54
+ <div style={{ padding: 32, maxWidth: 360 }}>
55
+ <Field label="Period">
56
+ <SingleSelect<Period>
57
+ options={PERIOD_OPTIONS}
58
+ value={value}
59
+ onChange={setValue}
60
+ placeholder="Pick a cadence…"
61
+ />
62
+ </Field>
63
+ </div>
64
+ );
65
+ },
66
+ };
67
+
68
+ export const Small: Story = {
69
+ render: () => {
70
+ const [value, setValue] = useState<Period>('weekly');
71
+ return (
72
+ <div style={{ padding: 32, maxWidth: 240 }}>
73
+ <SingleSelect<Period>
74
+ options={PERIOD_OPTIONS}
75
+ value={value}
76
+ onChange={setValue}
77
+ size="small"
78
+ />
79
+ </div>
80
+ );
81
+ },
82
+ };
83
+
84
+ export const Disabled: Story = {
85
+ render: () => (
86
+ <div style={{ padding: 32, maxWidth: 360 }}>
87
+ <SingleSelect<Period>
88
+ options={PERIOD_OPTIONS}
89
+ value="monthly"
90
+ onChange={() => {}}
91
+ isDisabled
92
+ />
93
+ </div>
94
+ ),
95
+ };
96
+
97
+ // Repro for the dropdown-inside-modal bug: the SingleSelect's popover panel
98
+ // is portaled to document.body, but MediumModal uses a native <dialog> opened
99
+ // via showModal(), which renders in the browser's top layer above all normal
100
+ // DOM regardless of z-index. The panel ends up *behind* the dialog and is
101
+ // invisible even though the trigger reads as "open".
102
+ export const InsideModal: Story = {
103
+ render: () => {
104
+ const [value, setValue] = useState<Period | null>(null);
105
+ return (
106
+ <Toggle defaultOn>
107
+ {({ isOn, on, off }) => (
108
+ <>
109
+ <Button onClick={on}>New workspace…</Button>
110
+ <MediumModal
111
+ isOpen={isOn}
112
+ onClose={off}
113
+ title="New workspace"
114
+ footer={
115
+ <>
116
+ <Button onClick={off}>Cancel</Button>
117
+ <Button variant="primary" onClick={off}>Create</Button>
118
+ </>
119
+ }
120
+ >
121
+ <Stack gap={3}>
122
+ <Field label="Definition">
123
+ <SingleSelect<Period>
124
+ options={PERIOD_OPTIONS}
125
+ value={value}
126
+ onChange={setValue}
127
+ placeholder="Pick a def…"
128
+ />
129
+ </Field>
130
+ </Stack>
131
+ </MediumModal>
132
+ </>
133
+ )}
134
+ </Toggle>
135
+ );
136
+ },
137
+ };
138
+
139
+ export const Long: Story = {
140
+ render: () => {
141
+ const [value, setValue] = useState<string>('apple');
142
+ const options = [
143
+ { value: 'apple', label: 'Apple' },
144
+ { value: 'banana', label: 'Banana' },
145
+ { value: 'cherry', label: 'Cherry' },
146
+ { value: 'durian', label: 'Durian' },
147
+ { value: 'elderberry', label: 'Elderberry' },
148
+ { value: 'fig', label: 'Fig' },
149
+ { value: 'grape', label: 'Grape' },
150
+ { value: 'honeydew', label: 'Honeydew' },
151
+ { value: 'jackfruit', label: 'Jackfruit' },
152
+ { value: 'kiwi', label: 'Kiwi' },
153
+ { value: 'lemon', label: 'Lemon' },
154
+ { value: 'mango', label: 'Mango' },
155
+ { value: 'nectarine', label: 'Nectarine' },
156
+ { value: 'orange', label: 'Orange' },
157
+ { value: 'papaya', label: 'Papaya' },
158
+ ];
159
+ return (
160
+ <div style={{ padding: 32, maxWidth: 360 }}>
161
+ <Stack direction="column" gap={2}>
162
+ <Text size="small" isMuted>
163
+ Scrolls internally when options exceed the list's max height.
164
+ </Text>
165
+ <SingleSelect
166
+ options={options}
167
+ value={value}
168
+ onChange={setValue}
169
+ />
170
+ </Stack>
171
+ </div>
172
+ );
173
+ },
174
+ };
@@ -0,0 +1,62 @@
1
+ import { useRef, useState } from 'react';
2
+ import { Popover } from '../../../Overlays/Popover';
3
+ import { OptionList } from '../internal/OptionList';
4
+ import { SelectTrigger } from '../internal/SelectTrigger';
5
+ import type { SingleSelectProps } from './types';
6
+
7
+ export const SingleSelect = <T extends string,>({
8
+ options,
9
+ value,
10
+ onChange,
11
+ size = 'medium',
12
+ isDisabled,
13
+ placeholder = 'Select…',
14
+ ariaLabel,
15
+ }: SingleSelectProps<T>) => {
16
+ const triggerRef = useRef<HTMLButtonElement>(null);
17
+ const [isOpen, setIsOpen] = useState(false);
18
+
19
+ const selectedIndex = options.findIndex((o) => o.value === value);
20
+ const selected = selectedIndex >= 0 ? options[selectedIndex] : undefined;
21
+
22
+ const close = () => setIsOpen(false);
23
+ const toggle = () => setIsOpen((v) => !v);
24
+ const commit = (next: T) => {
25
+ onChange(next);
26
+ close();
27
+ };
28
+
29
+ return (
30
+ <>
31
+ <SelectTrigger
32
+ ref={triggerRef}
33
+ size={size}
34
+ isOpen={isOpen}
35
+ isDisabled={isDisabled}
36
+ isPlaceholder={!selected}
37
+ onToggle={toggle}
38
+ ariaLabel={ariaLabel}
39
+ >
40
+ {selected?.label ?? placeholder}
41
+ </SelectTrigger>
42
+ <Popover
43
+ anchorRef={triggerRef}
44
+ isOpen={isOpen}
45
+ onClose={close}
46
+ ariaLabel={ariaLabel ?? 'Select an option'}
47
+ matchAnchorWidth
48
+ hasBodyPadding={false}
49
+ >
50
+ <OptionList<T>
51
+ options={options}
52
+ isSelected={(v) => v === value}
53
+ onCommit={commit}
54
+ initialFocusIndex={selectedIndex >= 0 ? selectedIndex : 0}
55
+ indicator="check"
56
+ />
57
+ </Popover>
58
+ </>
59
+ );
60
+ };
61
+
62
+ export type { SingleSelectProps };
@@ -0,0 +1,12 @@
1
+ import type { SelectOption } from '../internal/OptionList';
2
+ import type { SelectSize } from '../internal/SelectTrigger';
3
+
4
+ export type SingleSelectProps<T extends string = string> = {
5
+ options: SelectOption<T>[];
6
+ value: T | null;
7
+ onChange: (value: T) => void;
8
+ size?: SelectSize;
9
+ isDisabled?: boolean;
10
+ placeholder?: string;
11
+ ariaLabel?: string;
12
+ };
@@ -0,0 +1,4 @@
1
+ export { SingleSelect, type SingleSelectProps } from './SingleSelect';
2
+ export { MultiSelect, type MultiSelectProps } from './MultiSelect';
3
+ export type { SelectOption } from './internal/OptionList';
4
+ export type { SelectSize } from './internal/SelectTrigger';
@@ -0,0 +1,124 @@
1
+ import {
2
+ useEffect,
3
+ useId,
4
+ useRef,
5
+ useState,
6
+ type KeyboardEvent,
7
+ } from 'react';
8
+ import { cx } from '../../../../utils';
9
+ import styles from './styles.module.css';
10
+
11
+ export type SelectOption<T extends string> = {
12
+ value: T;
13
+ label: string;
14
+ };
15
+
16
+ export type OptionListIndicator = 'check' | 'checkbox' | 'none';
17
+
18
+ export type OptionListProps<T extends string> = {
19
+ options: SelectOption<T>[];
20
+ isSelected: (value: T) => boolean;
21
+ onCommit: (value: T) => void;
22
+ /** How each row signals its selected state.
23
+ * - `check`: a ✓ appears on the selected row (SingleSelect default).
24
+ * - `checkbox`: each row has a checkbox square that fills on selection (MultiSelect).
25
+ * - `none`: no leading indicator. */
26
+ indicator?: OptionListIndicator;
27
+ /** Initial focus index — typically the index of the (first) selected value. */
28
+ initialFocusIndex?: number;
29
+ ariaMultiSelectable?: boolean;
30
+ };
31
+
32
+ const renderIndicator = (variant: OptionListIndicator, isSelected: boolean) => {
33
+ if (variant === 'none') return null;
34
+ if (variant === 'check') {
35
+ return (
36
+ <span className={styles.indicator}>{isSelected ? '✓' : null}</span>
37
+ );
38
+ }
39
+ return (
40
+ <span className={styles.indicator}>
41
+ <span
42
+ className={cx(styles.checkbox, isSelected && styles.checkboxChecked)}
43
+ >
44
+ {isSelected ? '✓' : null}
45
+ </span>
46
+ </span>
47
+ );
48
+ };
49
+
50
+ export const OptionList = <T extends string,>({
51
+ options,
52
+ isSelected,
53
+ onCommit,
54
+ indicator = 'check',
55
+ initialFocusIndex = 0,
56
+ ariaMultiSelectable,
57
+ }: OptionListProps<T>) => {
58
+ const listRef = useRef<HTMLDivElement>(null);
59
+ const listId = useId();
60
+ const [focusIndex, setFocusIndex] = useState(() => {
61
+ if (options.length === 0) return 0;
62
+ return Math.min(Math.max(initialFocusIndex, 0), options.length - 1);
63
+ });
64
+
65
+ useEffect(() => {
66
+ listRef.current?.focus();
67
+ }, []);
68
+
69
+ const handleKey = (e: KeyboardEvent<HTMLDivElement>) => {
70
+ if (options.length === 0) return;
71
+ if (e.key === 'ArrowDown') {
72
+ e.preventDefault();
73
+ setFocusIndex((i) => (i + 1) % options.length);
74
+ } else if (e.key === 'ArrowUp') {
75
+ e.preventDefault();
76
+ setFocusIndex((i) => (i - 1 + options.length) % options.length);
77
+ } else if (e.key === 'Home') {
78
+ e.preventDefault();
79
+ setFocusIndex(0);
80
+ } else if (e.key === 'End') {
81
+ e.preventDefault();
82
+ setFocusIndex(options.length - 1);
83
+ } else if (e.key === 'Enter' || e.key === ' ') {
84
+ e.preventDefault();
85
+ const focused = options[focusIndex];
86
+ if (focused) onCommit(focused.value);
87
+ }
88
+ };
89
+
90
+ const activeOptionId =
91
+ options.length > 0 ? `${listId}-${focusIndex}` : undefined;
92
+
93
+ return (
94
+ <div
95
+ ref={listRef}
96
+ role="listbox"
97
+ tabIndex={0}
98
+ aria-multiselectable={ariaMultiSelectable}
99
+ aria-activedescendant={activeOptionId}
100
+ className={styles.list}
101
+ onKeyDown={handleKey}
102
+ >
103
+ {options.map((option, i) => {
104
+ const { value, label } = option;
105
+ const selected = isSelected(value);
106
+ const focused = i === focusIndex;
107
+ return (
108
+ <div
109
+ key={value}
110
+ id={`${listId}-${i}`}
111
+ role="option"
112
+ aria-selected={selected}
113
+ className={cx(styles.option, focused && styles.isFocused)}
114
+ onMouseEnter={() => setFocusIndex(i)}
115
+ onClick={() => onCommit(value)}
116
+ >
117
+ {renderIndicator(indicator, selected)}
118
+ <span className={styles.optionLabel}>{label}</span>
119
+ </div>
120
+ );
121
+ })}
122
+ </div>
123
+ );
124
+ };
@@ -0,0 +1,60 @@
1
+ import type { ReactNode, Ref } from 'react';
2
+ import { cx } from '../../../../utils';
3
+ import styles from './styles.module.css';
4
+
5
+ export type SelectSize = 'small' | 'medium';
6
+
7
+ export type SelectTriggerProps = {
8
+ children: ReactNode;
9
+ size?: SelectSize;
10
+ isOpen: boolean;
11
+ isDisabled?: boolean;
12
+ isPlaceholder?: boolean;
13
+ onToggle: () => void;
14
+ ariaLabel?: string;
15
+ ref?: Ref<HTMLButtonElement>;
16
+ };
17
+
18
+ const SIZE_MAP: Record<SelectSize, string> = {
19
+ small: styles.small,
20
+ medium: styles.medium,
21
+ };
22
+
23
+ export const SelectTrigger = (props: SelectTriggerProps) => {
24
+ const {
25
+ children,
26
+ size = 'medium',
27
+ isOpen,
28
+ isDisabled,
29
+ isPlaceholder,
30
+ onToggle,
31
+ ariaLabel,
32
+ ref,
33
+ } = props;
34
+
35
+ return (
36
+ <button
37
+ ref={ref}
38
+ type="button"
39
+ className={cx(
40
+ styles.trigger,
41
+ SIZE_MAP[size],
42
+ isOpen && styles.isOpen,
43
+ )}
44
+ aria-haspopup="listbox"
45
+ aria-expanded={isOpen}
46
+ aria-label={ariaLabel}
47
+ disabled={isDisabled}
48
+ onClick={onToggle}
49
+ >
50
+ <span
51
+ className={cx(styles.triggerLabel, isPlaceholder && styles.isPlaceholder)}
52
+ >
53
+ {children}
54
+ </span>
55
+ <span className={styles.chevron} aria-hidden="true">
56
+
57
+ </span>
58
+ </button>
59
+ );
60
+ };
@@ -0,0 +1,122 @@
1
+ .trigger {
2
+ display: flex;
3
+ align-items: center;
4
+ justify-content: space-between;
5
+ gap: var(--ui-space-2);
6
+ width: 100%;
7
+ font-family: var(--ui-font);
8
+ border: 1px solid var(--ui-border);
9
+ border-radius: var(--ui-radius);
10
+ background-color: var(--ui-background-0);
11
+ color: var(--ui-foreground);
12
+ line-height: var(--ui-line-height);
13
+ cursor: pointer;
14
+ text-align: left;
15
+ }
16
+
17
+ .trigger:focus-visible {
18
+ outline: 2px solid var(--ui-accent);
19
+ outline-offset: -1px;
20
+ border-color: var(--ui-accent);
21
+ }
22
+
23
+ .trigger:hover:not(:disabled) {
24
+ background-color: var(--ui-background-0-offset);
25
+ }
26
+
27
+ .trigger:disabled {
28
+ opacity: 0.5;
29
+ cursor: not-allowed;
30
+ }
31
+
32
+ .isOpen {
33
+ border-color: var(--ui-accent);
34
+ }
35
+
36
+ .small {
37
+ font-size: var(--ui-text-small);
38
+ padding: 4px 8px;
39
+ }
40
+
41
+ .medium {
42
+ font-size: var(--ui-text-medium);
43
+ padding: 6px 10px;
44
+ }
45
+
46
+ .triggerLabel {
47
+ flex: 1 1 auto;
48
+ overflow: hidden;
49
+ white-space: nowrap;
50
+ text-overflow: ellipsis;
51
+ }
52
+
53
+ .isPlaceholder {
54
+ color: var(--ui-muted);
55
+ }
56
+
57
+ .chevron {
58
+ color: var(--ui-muted);
59
+ font-size: var(--ui-text-xsmall);
60
+ flex: 0 0 auto;
61
+ }
62
+
63
+ .list {
64
+ display: flex;
65
+ flex-direction: column;
66
+ outline: none;
67
+ max-height: 280px;
68
+ overflow-y: auto;
69
+ padding: var(--ui-space-1) 0;
70
+ }
71
+
72
+ .option {
73
+ display: flex;
74
+ align-items: center;
75
+ gap: var(--ui-space-2);
76
+ padding: var(--ui-space-1) var(--ui-space-3);
77
+ font-size: var(--ui-text-small);
78
+ color: var(--ui-foreground);
79
+ cursor: pointer;
80
+ user-select: none;
81
+ white-space: nowrap;
82
+ }
83
+
84
+ .isFocused {
85
+ background-color: var(--ui-background-1);
86
+ }
87
+
88
+ .indicator {
89
+ display: inline-flex;
90
+ align-items: center;
91
+ justify-content: center;
92
+ width: 1rem;
93
+ flex: 0 0 auto;
94
+ color: var(--ui-muted);
95
+ }
96
+
97
+ .optionLabel {
98
+ flex: 1 1 auto;
99
+ overflow: hidden;
100
+ white-space: nowrap;
101
+ text-overflow: ellipsis;
102
+ }
103
+
104
+ .checkbox {
105
+ width: 14px;
106
+ height: 14px;
107
+ border: 1px solid var(--ui-border);
108
+ border-radius: 3px;
109
+ background-color: var(--ui-background-0);
110
+ display: inline-flex;
111
+ align-items: center;
112
+ justify-content: center;
113
+ flex: 0 0 auto;
114
+ }
115
+
116
+ .checkboxChecked {
117
+ background-color: var(--ui-accent);
118
+ border-color: var(--ui-accent);
119
+ color: var(--ui-background-0);
120
+ font-size: 11px;
121
+ line-height: 1;
122
+ }
@@ -0,0 +1,25 @@
1
+ import type { Meta, StoryObj } from '@storybook/react';
2
+ import { Textarea } from './index';
3
+
4
+ const meta: Meta<typeof Textarea> = {
5
+ title: 'Forms/Textarea',
6
+ component: Textarea,
7
+ args: { placeholder: 'Write something...', rows: 4 },
8
+ };
9
+
10
+ export default meta;
11
+ type Story = StoryObj<typeof Textarea>;
12
+
13
+ export const Default: Story = {};
14
+
15
+ export const Disabled: Story = {
16
+ args: { disabled: true, defaultValue: 'Read-only content.' },
17
+ };
18
+
19
+ export const AutoResize: Story = {
20
+ args: {
21
+ isAutoResize: true,
22
+ rows: 2,
23
+ placeholder: 'Type — the box grows to fit…',
24
+ },
25
+ };
@@ -0,0 +1,48 @@
1
+ import { cx } from '../../../utils';
2
+ import type { TextareaProps } from './types';
3
+ import styles from './styles.module.css';
4
+
5
+ export const Textarea = (props: TextareaProps) => {
6
+ const {
7
+ value,
8
+ defaultValue,
9
+ onChange,
10
+ rows = 2,
11
+ placeholder,
12
+ disabled,
13
+ required,
14
+ autoFocus,
15
+ id,
16
+ name,
17
+ isAutoResize,
18
+ } = props;
19
+
20
+ // field-sizing: content doesn't enforce a rows-based floor on its own,
21
+ // so compute min-height from `rows` and apply it inline when isAutoResize.
22
+ // Formula: rows × line-height × font-size + vertical padding + border.
23
+ // With .textarea's padding (8px top+bottom = 16px) + 1px border × 2 = 18px.
24
+ const style = isAutoResize
25
+ ? {
26
+ minHeight: `calc(var(--ui-text-medium) * var(--ui-line-height) * ${rows} + 18px)`,
27
+ }
28
+ : undefined;
29
+
30
+ return (
31
+ <textarea
32
+ className={cx(styles.textarea, isAutoResize && styles.autoResize)}
33
+ value={value}
34
+ defaultValue={defaultValue}
35
+ onChange={onChange}
36
+ rows={rows}
37
+ placeholder={placeholder}
38
+ disabled={disabled}
39
+ required={required}
40
+ autoFocus={autoFocus}
41
+ id={id}
42
+ name={name}
43
+ style={style}
44
+ />
45
+ );
46
+ };
47
+
48
+ export type { TextareaProps };
@@ -0,0 +1,34 @@
1
+ .textarea {
2
+ font-family: var(--ui-font);
3
+ font-size: var(--ui-text-medium);
4
+ border: 1px solid var(--ui-border);
5
+ border-radius: var(--ui-radius);
6
+ background-color: var(--ui-background-0);
7
+ color: var(--ui-foreground);
8
+ padding: 8px 10px;
9
+ width: 100%;
10
+ min-height: 80px;
11
+ resize: vertical;
12
+ line-height: var(--ui-line-height);
13
+ }
14
+
15
+ .textarea:focus {
16
+ outline: 2px solid var(--ui-accent);
17
+ outline-offset: -1px;
18
+ border-color: var(--ui-accent);
19
+ }
20
+
21
+ .textarea:disabled {
22
+ opacity: 0.5;
23
+ cursor: not-allowed;
24
+ }
25
+
26
+ .autoResize {
27
+ /* Modern CSS: textarea grows to fit content, shrinks when content shrinks.
28
+ The manual resize handle goes away since it'd fight the intrinsic sizing.
29
+ min-height is computed from `rows` and applied inline in the component
30
+ (field-sizing: content doesn't enforce a rows-based floor on its own).
31
+ Browser support: Chrome 123+, Safari 17.4+, Firefox 131+. */
32
+ field-sizing: content;
33
+ resize: none;
34
+ }
@@ -0,0 +1,24 @@
1
+ import type { ChangeEventHandler } from 'react';
2
+
3
+ export type TextareaProps = {
4
+ value?: string;
5
+ defaultValue?: string;
6
+ onChange?: ChangeEventHandler<HTMLTextAreaElement>;
7
+ rows?: number;
8
+ placeholder?: string;
9
+ disabled?: boolean;
10
+ required?: boolean;
11
+ autoFocus?: boolean;
12
+ id?: string;
13
+ name?: string;
14
+ /**
15
+ * Grow the textarea to fit its content. The manual resize handle is
16
+ * removed. Uses CSS `field-sizing: content` (Chrome 123+, Safari 17.4+)
17
+ * with no JS fallback — fine for modern browsers and this app's scope.
18
+ *
19
+ * `rows` is the minimum visible rows (the floor). An empty textarea
20
+ * with `rows={3}` renders at 3 rows tall; content longer than that
21
+ * grows the box.
22
+ */
23
+ isAutoResize?: boolean;
24
+ };