@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,235 @@
1
+ import {
2
+ useEffect,
3
+ useId,
4
+ useMemo,
5
+ useRef,
6
+ useState,
7
+ type KeyboardEvent,
8
+ type MouseEvent,
9
+ type ReactNode,
10
+ } from 'react';
11
+ import { Popover } from '../../Overlays/Popover';
12
+ import { cx } from '../../../utils';
13
+ import type { PillComboboxOption } from '../PillCombobox/types';
14
+ import styles from './styles.module.css';
15
+
16
+ // Internal core for the chat combobox family. Owns all behavior (keyboard,
17
+ // popover, query/filter state, pill rendering). Variants — `PillCombobox`
18
+ // (bordered form element) and `ChatRecipientsHeader` (flush header) — wrap
19
+ // this and supply their own chrome via `shellClassName` (outer) +
20
+ // `rowClassName` (inner).
21
+ //
22
+ // The two-element shape is what lets a flush variant render a visible
23
+ // focus state: outer takes any small padding (so the inner has room to
24
+ // inset), and inner takes the focus-within visual (background tint, ring,
25
+ // etc.) without bleeding to the modal's edges.
26
+ export type PillComboboxCoreProps<T extends string> = {
27
+ options: PillComboboxOption<T>[];
28
+ values: T[];
29
+ onChange: (next: T[]) => void;
30
+ ariaLabel: string;
31
+ placeholder?: string;
32
+ isDisabled?: boolean;
33
+ emptyHint?: string;
34
+ /** Outer wrapper className. Anchors the popover; owns edge chrome
35
+ * (border/radius for bordered, bottom-rule for flush). */
36
+ shellClassName: string;
37
+ /** Inner row className. Owns layout (flex, gap, padding) and the
38
+ * focus-within visual feedback. */
39
+ rowClassName: string;
40
+ /** Optional content rendered as a sibling of the inner row, before
41
+ * it — e.g. an inline "To" label in the header variant. Sits in the
42
+ * outer shell so it isn't covered by the row's focus visual. */
43
+ leadingSlot?: ReactNode;
44
+ };
45
+
46
+ export const PillComboboxCore = <T extends string,>({
47
+ options,
48
+ values,
49
+ onChange,
50
+ ariaLabel,
51
+ placeholder,
52
+ isDisabled,
53
+ emptyHint = 'No matches.',
54
+ shellClassName,
55
+ rowClassName,
56
+ leadingSlot,
57
+ }: PillComboboxCoreProps<T>) => {
58
+ const shellRef = useRef<HTMLDivElement>(null);
59
+ const inputRef = useRef<HTMLInputElement>(null);
60
+ const listId = useId();
61
+
62
+ const [query, setQuery] = useState('');
63
+ const [isOpen, setIsOpen] = useState(false);
64
+ const [focusIndex, setFocusIndex] = useState(0);
65
+
66
+ const labelByValue = useMemo(() => {
67
+ const m = new Map<T, string>();
68
+ for (const o of options) m.set(o.value, o.label);
69
+ return m;
70
+ }, [options]);
71
+
72
+ const filtered = useMemo(() => {
73
+ const selectedSet = new Set(values);
74
+ const q = query.trim().toLowerCase();
75
+ return options.filter(
76
+ (o) =>
77
+ !selectedSet.has(o.value) &&
78
+ (q === '' || o.label.toLowerCase().includes(q)),
79
+ );
80
+ }, [options, values, query]);
81
+
82
+ // Keep focusIndex in range when filtered shrinks (e.g. after a select).
83
+ useEffect(() => {
84
+ setFocusIndex((i) => (filtered.length === 0 ? 0 : Math.min(i, filtered.length - 1)));
85
+ }, [filtered.length]);
86
+
87
+ const open = () => {
88
+ if (!isDisabled) setIsOpen(true);
89
+ };
90
+ const close = () => setIsOpen(false);
91
+
92
+ const select = (option: PillComboboxOption<T>) => {
93
+ onChange([...values, option.value]);
94
+ setQuery('');
95
+ setFocusIndex(0);
96
+ inputRef.current?.focus();
97
+ // popover stays open for rapid-fire add
98
+ };
99
+
100
+ const removeAt = (index: number) => {
101
+ if (isDisabled) return;
102
+ const next = values.slice();
103
+ next.splice(index, 1);
104
+ onChange(next);
105
+ inputRef.current?.focus();
106
+ };
107
+
108
+ const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
109
+ if (isDisabled) return;
110
+ if (e.key === 'ArrowDown') {
111
+ e.preventDefault();
112
+ open();
113
+ setFocusIndex((i) =>
114
+ filtered.length === 0 ? 0 : (i + 1) % filtered.length,
115
+ );
116
+ } else if (e.key === 'ArrowUp') {
117
+ e.preventDefault();
118
+ open();
119
+ setFocusIndex((i) =>
120
+ filtered.length === 0 ? 0 : (i - 1 + filtered.length) % filtered.length,
121
+ );
122
+ } else if (e.key === 'Enter') {
123
+ if (isOpen && filtered.length > 0) {
124
+ e.preventDefault();
125
+ const focused = filtered[focusIndex];
126
+ if (focused) select(focused);
127
+ }
128
+ } else if (e.key === 'Escape') {
129
+ if (isOpen) {
130
+ e.preventDefault();
131
+ close();
132
+ }
133
+ } else if (e.key === 'Backspace') {
134
+ if (query === '' && values.length > 0) {
135
+ e.preventDefault();
136
+ removeAt(values.length - 1);
137
+ }
138
+ }
139
+ };
140
+
141
+ const handleRowMouseDown = (e: MouseEvent<HTMLDivElement>) => {
142
+ // Clicking empty row space focuses the input. Clicks on pills, the
143
+ // remove button, the leadingSlot, or the input itself bubble through
144
+ // and handle themselves.
145
+ if (e.target === e.currentTarget) {
146
+ e.preventDefault();
147
+ inputRef.current?.focus();
148
+ }
149
+ };
150
+
151
+ const showInputPlaceholder = values.length === 0 && query === '';
152
+
153
+ return (
154
+ <>
155
+ <div ref={shellRef} className={shellClassName}>
156
+ {leadingSlot}
157
+ <div className={rowClassName} onMouseDown={handleRowMouseDown}>
158
+ {values.map((value, i) => {
159
+ const label = labelByValue.get(value) ?? value;
160
+ return (
161
+ <span key={value} className={styles.pill}>
162
+ <span>{label}</span>
163
+ <button
164
+ type="button"
165
+ className={styles.pillRemove}
166
+ aria-label={`Remove ${label}`}
167
+ onClick={() => removeAt(i)}
168
+ disabled={isDisabled}
169
+ >
170
+ ×
171
+ </button>
172
+ </span>
173
+ );
174
+ })}
175
+ <input
176
+ ref={inputRef}
177
+ type="text"
178
+ role="combobox"
179
+ aria-expanded={isOpen}
180
+ aria-controls={listId}
181
+ aria-autocomplete="list"
182
+ aria-label={ariaLabel}
183
+ className={styles.input}
184
+ value={query}
185
+ onChange={(e) => {
186
+ setQuery(e.target.value);
187
+ open();
188
+ }}
189
+ onFocus={open}
190
+ onKeyDown={handleKeyDown}
191
+ placeholder={showInputPlaceholder ? placeholder : undefined}
192
+ disabled={isDisabled}
193
+ />
194
+ </div>
195
+ </div>
196
+ <Popover
197
+ anchorRef={shellRef}
198
+ isOpen={isOpen && !isDisabled}
199
+ onClose={close}
200
+ ariaLabel={ariaLabel}
201
+ matchAnchorWidth
202
+ hasBodyPadding={false}
203
+ >
204
+ <div id={listId} role="listbox" className={styles.list}>
205
+ {filtered.length === 0 ? (
206
+ <div className={styles.empty}>{emptyHint}</div>
207
+ ) : (
208
+ filtered.map((option, i) => {
209
+ const focused = i === focusIndex;
210
+ return (
211
+ <div
212
+ key={option.value}
213
+ role="option"
214
+ aria-selected={focused}
215
+ className={cx(
216
+ styles.option,
217
+ focused && styles.optionFocused,
218
+ )}
219
+ onMouseEnter={() => setFocusIndex(i)}
220
+ onMouseDown={(e) => {
221
+ // Prevent input blur; commit on click.
222
+ e.preventDefault();
223
+ }}
224
+ onClick={() => select(option)}
225
+ >
226
+ <span>{option.label}</span>
227
+ </div>
228
+ );
229
+ })
230
+ )}
231
+ </div>
232
+ </Popover>
233
+ </>
234
+ );
235
+ };
@@ -0,0 +1,79 @@
1
+ .pill {
2
+ display: inline-flex;
3
+ align-items: center;
4
+ gap: var(--ui-space-1);
5
+ padding: 2px var(--ui-space-1) 2px var(--ui-space-2);
6
+ background-color: var(--ui-background-2);
7
+ border: 1px solid var(--ui-border-subtle);
8
+ border-radius: var(--ui-radius);
9
+ font-size: var(--ui-text-small);
10
+ line-height: var(--ui-line-height-tight);
11
+ color: var(--ui-foreground);
12
+ }
13
+
14
+ .pillRemove {
15
+ font: inherit;
16
+ font-size: var(--ui-text-small);
17
+ line-height: 1;
18
+ padding: 0 var(--ui-space-1);
19
+ background: transparent;
20
+ border: 0;
21
+ color: var(--ui-muted);
22
+ cursor: pointer;
23
+ border-radius: var(--ui-radius);
24
+ }
25
+
26
+ .pillRemove:hover:not(:disabled) {
27
+ color: var(--ui-foreground);
28
+ background-color: var(--ui-background-1);
29
+ }
30
+
31
+ .pillRemove:focus-visible {
32
+ outline: 2px solid var(--ui-accent);
33
+ outline-offset: 1px;
34
+ }
35
+
36
+ .input {
37
+ font-family: var(--ui-font);
38
+ font-size: var(--ui-text-medium);
39
+ line-height: var(--ui-line-height);
40
+ color: var(--ui-foreground);
41
+ background: transparent;
42
+ border: 0;
43
+ outline: none;
44
+ flex: 1 1 8rem;
45
+ min-width: 6rem;
46
+ padding: var(--ui-space-1) 0;
47
+ }
48
+
49
+ .input:disabled {
50
+ cursor: not-allowed;
51
+ }
52
+
53
+ .list {
54
+ display: flex;
55
+ flex-direction: column;
56
+ max-height: 240px;
57
+ overflow-y: auto;
58
+ }
59
+
60
+ .option {
61
+ display: flex;
62
+ flex-direction: column;
63
+ gap: 2px;
64
+ padding: var(--ui-space-2) var(--ui-space-3);
65
+ cursor: pointer;
66
+ color: var(--ui-foreground);
67
+ font-size: var(--ui-text-medium);
68
+ line-height: var(--ui-line-height-tight);
69
+ }
70
+
71
+ .optionFocused {
72
+ background-color: var(--ui-background-1);
73
+ }
74
+
75
+ .empty {
76
+ padding: var(--ui-space-2) var(--ui-space-3);
77
+ color: var(--ui-muted);
78
+ font-size: var(--ui-text-small);
79
+ }
@@ -0,0 +1,12 @@
1
+ export { ChatMessage, type ChatMessageProps, type ChatMessageRole } from './ChatMessage';
2
+ export { ChatShell, type ChatShellProps } from './ChatShell';
3
+ export { ChatComposer, type ChatComposerProps } from './ChatComposer';
4
+ export {
5
+ PillCombobox,
6
+ type PillComboboxOption,
7
+ type PillComboboxProps,
8
+ } from './PillCombobox';
9
+ export {
10
+ ChatRecipientsHeader,
11
+ type ChatRecipientsHeaderProps,
12
+ } from './ChatRecipientsHeader';
@@ -0,0 +1,31 @@
1
+ import type { Meta, StoryObj } from '@storybook/react';
2
+ import { Badge } from './index';
3
+ import { Stack } from '../../Layout/Stack';
4
+
5
+ const meta: Meta<typeof Badge> = {
6
+ title: 'Content/Badge',
7
+ component: Badge,
8
+ args: { children: 'draft' },
9
+ };
10
+
11
+ export default meta;
12
+ type Story = StoryObj<typeof Badge>;
13
+
14
+ export const Default: Story = {};
15
+
16
+ export const Mono: Story = { args: { isMono: true, children: 'SPM-2816' } };
17
+
18
+ export const Clickable: Story = {
19
+ args: { isMono: true, children: 'ENG-4412', onClick: () => alert('clicked') },
20
+ };
21
+
22
+ export const Cluster: Story = {
23
+ render: () => (
24
+ <Stack direction="row" gap={1} isWrap>
25
+ <Badge>open</Badge>
26
+ <Badge>in progress</Badge>
27
+ <Badge isMono>SPM-2816</Badge>
28
+ <Badge isMono>ENG-4412</Badge>
29
+ </Stack>
30
+ ),
31
+ };
@@ -0,0 +1,22 @@
1
+ import type { BadgeProps } from './types';
2
+ import { cx } from '../../../utils';
3
+ import styles from './styles.module.css';
4
+
5
+ export const Badge = (props: BadgeProps) => {
6
+ const { children, isMono, onClick } = props;
7
+
8
+ if (onClick) {
9
+ return (
10
+ <button
11
+ type="button"
12
+ onClick={onClick}
13
+ className={cx(styles.badge, styles.clickable, isMono && styles.mono)}
14
+ >
15
+ {children}
16
+ </button>
17
+ );
18
+ }
19
+ return <span className={cx(styles.badge, isMono && styles.mono)}>{children}</span>;
20
+ };
21
+
22
+ export type { BadgeProps };
@@ -0,0 +1,25 @@
1
+ .badge {
2
+ display: inline-flex;
3
+ align-items: center;
4
+ font-size: var(--ui-text-xsmall);
5
+ color: var(--ui-foreground);
6
+ background-color: var(--ui-background-3);
7
+ padding: 2px 6px;
8
+ border-radius: var(--ui-radius);
9
+ line-height: 1.2;
10
+ border: 0;
11
+ font-family: inherit;
12
+ }
13
+
14
+ .mono {
15
+ font-family: var(--ui-font-mono);
16
+ }
17
+
18
+ .clickable {
19
+ cursor: pointer;
20
+ text-decoration: none;
21
+ }
22
+
23
+ .clickable:hover {
24
+ background-color: var(--ui-border-subtle);
25
+ }
@@ -0,0 +1,7 @@
1
+ import type { ReactNode } from 'react';
2
+
3
+ export type BadgeProps = {
4
+ children: ReactNode;
5
+ isMono?: boolean;
6
+ onClick?: () => void;
7
+ };
@@ -0,0 +1,24 @@
1
+ import type { Meta, StoryObj } from '@storybook/react';
2
+ import { Card } from './index';
3
+ import { Heading } from '../Heading';
4
+ import { Stack } from '../../Layout/Stack';
5
+ import { Text } from '../Text';
6
+
7
+ const meta: Meta<typeof Card> = {
8
+ title: 'Content/Card',
9
+ component: Card,
10
+ };
11
+
12
+ export default meta;
13
+ type Story = StoryObj<typeof Card>;
14
+
15
+ export const Default: Story = {
16
+ render: () => (
17
+ <Card>
18
+ <Stack gap={2}>
19
+ <Heading level={3}>Card title</Heading>
20
+ <Text>Cards are simple bordered containers for grouped content.</Text>
21
+ </Stack>
22
+ </Card>
23
+ ),
24
+ };
@@ -0,0 +1,21 @@
1
+ import type { CardPadding, CardProps } from './types';
2
+ import { cx } from '../../../utils';
3
+ import styles from './styles.module.css';
4
+
5
+ const PADDING_MAP: Record<CardPadding, string> = {
6
+ 0: styles.padding0,
7
+ 1: styles.padding1,
8
+ 2: styles.padding2,
9
+ 3: styles.padding3,
10
+ 4: styles.padding4,
11
+ 5: styles.padding5,
12
+ 6: styles.padding6,
13
+ };
14
+
15
+ export const Card = (props: CardProps) => {
16
+ const { children, padding = 3 } = props;
17
+
18
+ return <div className={cx(styles.card, PADDING_MAP[padding])}>{children}</div>;
19
+ };
20
+
21
+ export type { CardProps };
@@ -0,0 +1,13 @@
1
+ .card {
2
+ background-color: var(--ui-background-0);
3
+ border: 1px solid var(--ui-border);
4
+ border-radius: var(--ui-radius);
5
+ }
6
+
7
+ .padding0 { padding: 0; }
8
+ .padding1 { padding: var(--ui-space-1); }
9
+ .padding2 { padding: var(--ui-space-2); }
10
+ .padding3 { padding: var(--ui-space-3); }
11
+ .padding4 { padding: var(--ui-space-4); }
12
+ .padding5 { padding: var(--ui-space-5); }
13
+ .padding6 { padding: var(--ui-space-6); }
@@ -0,0 +1,8 @@
1
+ import type { ReactNode } from 'react';
2
+
3
+ export type CardPadding = 0 | 1 | 2 | 3 | 4 | 5 | 6;
4
+
5
+ export type CardProps = {
6
+ children: ReactNode;
7
+ padding?: CardPadding;
8
+ };
@@ -0,0 +1,58 @@
1
+ import type { Meta, StoryObj } from '@storybook/react';
2
+ import { useState } from 'react';
3
+ import { EditableMarkdown } from './index';
4
+
5
+ const sample = `# Project notes
6
+
7
+ A paragraph with **bold** text, *italic*, and \`inline code\`.
8
+
9
+ - First point
10
+ - Second point with a [link](https://example.com)
11
+ - Third point
12
+
13
+ \`\`\`ts
14
+ const greeting = 'hello';
15
+ \`\`\`
16
+
17
+ > A passing thought, set aside.
18
+ `;
19
+
20
+ const Harness = ({ initial }: { initial: string }) => {
21
+ const [value, setValue] = useState(initial);
22
+ return (
23
+ <div style={{ width: 640 }}>
24
+ <EditableMarkdown
25
+ value={value}
26
+ label="description"
27
+ onSave={async (next) => {
28
+ await new Promise((r) => setTimeout(r, 400));
29
+ setValue(next);
30
+ }}
31
+ />
32
+ </div>
33
+ );
34
+ };
35
+
36
+ const meta: Meta<typeof EditableMarkdown> = {
37
+ title: 'Content/EditableMarkdown',
38
+ component: EditableMarkdown,
39
+ };
40
+
41
+ export default meta;
42
+ type Story = StoryObj<typeof EditableMarkdown>;
43
+
44
+ export const WithContent: Story = {
45
+ render: () => <Harness initial={sample} />,
46
+ };
47
+
48
+ export const Empty: Story = {
49
+ render: () => <Harness initial="" />,
50
+ };
51
+
52
+ export const ReadOnly: Story = {
53
+ render: () => (
54
+ <div style={{ width: 640 }}>
55
+ <EditableMarkdown value={sample} canEdit={false} onSave={() => {}} />
56
+ </div>
57
+ ),
58
+ };
@@ -0,0 +1,140 @@
1
+ import { useEffect, useRef, useState } from 'react';
2
+ import ReactMarkdown from 'react-markdown';
3
+ import { cx } from '../../../utils';
4
+ import type { EditableMarkdownProps } from './types';
5
+ import styles from './styles.module.css';
6
+
7
+ type Tab = 'write' | 'preview';
8
+
9
+ export const EditableMarkdown = (props: EditableMarkdownProps) => {
10
+ const {
11
+ value,
12
+ onSave,
13
+ onCancel,
14
+ canEdit = true,
15
+ placeholder = 'No content',
16
+ label,
17
+ } = props;
18
+
19
+ const [isEditing, setIsEditing] = useState(false);
20
+ const [activeTab, setActiveTab] = useState<Tab>('write');
21
+ const [draft, setDraft] = useState('');
22
+ const [isSaving, setIsSaving] = useState(false);
23
+ const textareaRef = useRef<HTMLTextAreaElement>(null);
24
+
25
+ // Refocus the textarea when entering edit mode or switching back to Write.
26
+ // Keeps the native selection/cursor intact across tab toggles because the
27
+ // textarea stays mounted (display:none) when Preview is active.
28
+ useEffect(() => {
29
+ if (isEditing && activeTab === 'write') {
30
+ textareaRef.current?.focus();
31
+ }
32
+ }, [isEditing, activeTab]);
33
+
34
+ const enterEdit = () => {
35
+ setDraft(value);
36
+ setActiveTab('write');
37
+ setIsEditing(true);
38
+ };
39
+
40
+ const handleCancel = () => {
41
+ setIsEditing(false);
42
+ onCancel?.();
43
+ };
44
+
45
+ const handleSave = async () => {
46
+ try {
47
+ setIsSaving(true);
48
+ await onSave(draft);
49
+ setIsEditing(false);
50
+ } finally {
51
+ setIsSaving(false);
52
+ }
53
+ };
54
+
55
+ if (!isEditing) {
56
+ return (
57
+ <div className={styles.container}>
58
+ {value ? (
59
+ <div className={styles.markdown}>
60
+ <ReactMarkdown>{value}</ReactMarkdown>
61
+ </div>
62
+ ) : (
63
+ <div className={styles.empty}>{placeholder}</div>
64
+ )}
65
+ {canEdit && (
66
+ <button
67
+ type="button"
68
+ className={styles.editButton}
69
+ onClick={enterEdit}
70
+ aria-label={label ? `Edit ${label}` : 'Edit'}
71
+ >
72
+ Edit
73
+ </button>
74
+ )}
75
+ </div>
76
+ );
77
+ }
78
+
79
+ return (
80
+ <div className={styles.editor}>
81
+ <div className={styles.tabs} role="tablist">
82
+ <button
83
+ type="button"
84
+ role="tab"
85
+ aria-selected={activeTab === 'write'}
86
+ className={cx(styles.tab, activeTab === 'write' && styles.tabActive)}
87
+ onClick={() => setActiveTab('write')}
88
+ >
89
+ Write
90
+ </button>
91
+ <button
92
+ type="button"
93
+ role="tab"
94
+ aria-selected={activeTab === 'preview'}
95
+ className={cx(styles.tab, activeTab === 'preview' && styles.tabActive)}
96
+ onClick={() => setActiveTab('preview')}
97
+ >
98
+ Preview
99
+ </button>
100
+ </div>
101
+ <div className={styles.body}>
102
+ <textarea
103
+ ref={textareaRef}
104
+ className={cx(styles.textarea, activeTab !== 'write' && styles.hidden)}
105
+ value={draft}
106
+ onChange={(e) => setDraft(e.target.value)}
107
+ />
108
+ <div className={cx(styles.preview, activeTab !== 'preview' && styles.hidden)}>
109
+ {draft ? (
110
+ <div className={styles.markdown}>
111
+ <ReactMarkdown>{draft}</ReactMarkdown>
112
+ </div>
113
+ ) : (
114
+ <div className={styles.empty}>Nothing to preview</div>
115
+ )}
116
+ </div>
117
+ </div>
118
+ <div className={styles.actions}>
119
+ <button
120
+ type="button"
121
+ className={cx(styles.actionButton, styles.cancelButton)}
122
+ onClick={handleCancel}
123
+ disabled={isSaving}
124
+ >
125
+ Cancel
126
+ </button>
127
+ <button
128
+ type="button"
129
+ className={cx(styles.actionButton, styles.saveButton)}
130
+ onClick={handleSave}
131
+ disabled={isSaving}
132
+ >
133
+ {isSaving ? 'Saving…' : 'Save'}
134
+ </button>
135
+ </div>
136
+ </div>
137
+ );
138
+ };
139
+
140
+ export type { EditableMarkdownProps };