@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
package/src/index.ts ADDED
@@ -0,0 +1,87 @@
1
+ export { Text, type TextProps } from './components/Content/Text';
2
+ export { Heading, type HeadingProps } from './components/Content/Heading';
3
+ export { Link, type LinkProps } from './components/Content/Link';
4
+ export { Stack, type StackProps } from './components/Layout/Stack';
5
+ export { Grid, type GridProps } from './components/Layout/Grid';
6
+ export { Button, type ButtonProps } from './components/Forms/Button';
7
+ export {
8
+ IconButton,
9
+ type IconButtonProps,
10
+ type IconButtonVariant,
11
+ type IconButtonSize,
12
+ } from './components/Forms/IconButton';
13
+ export {
14
+ Tooltip,
15
+ type TooltipProps,
16
+ type TooltipPlacement,
17
+ } from './components/Overlays/Tooltip';
18
+ export { Input, type InputProps } from './components/Forms/Input';
19
+ export { SearchInput, type SearchInputProps } from './components/Forms/SearchInput';
20
+ export { ColorInput, type ColorInputProps } from './components/Forms/ColorInput';
21
+ export { Textarea, type TextareaProps } from './components/Forms/Textarea';
22
+ export { SingleSelect, type SingleSelectProps } from './components/Forms/Select';
23
+ export { MultiSelect, type MultiSelectProps } from './components/Forms/Select';
24
+ export type { SelectOption, SelectSize } from './components/Forms/Select';
25
+ export { Field, type FieldProps } from './components/Forms/Field';
26
+ export { MediumModal, LargeModal, ConfirmModal } from './components/Modals';
27
+ export type { MediumModalProps, LargeModalProps, ConfirmModalProps } from './components/Modals';
28
+ export { Divider } from './components/Layout/Divider';
29
+ export { Debug } from './components/Layout/Debug';
30
+ export { Panels, type PanelsProps } from './components/Layout/Panels';
31
+ export { Bar, type BarProps } from './components/Layout/Bar';
32
+ export { Card, type CardProps } from './components/Content/Card';
33
+ export { Badge, type BadgeProps } from './components/Content/Badge';
34
+ export { Markdown, type MarkdownProps } from './components/Content/Markdown';
35
+ export { EditableMarkdown, type EditableMarkdownProps } from './components/Content/EditableMarkdown';
36
+ export { LoadingContainer, type LoadingContainerProps } from './components/Content/LoadingContainer';
37
+ export { Menu, type MenuProps } from './components/Content/Menu';
38
+ export type { MenuItem, MenuLeaf, MenuBranch } from './components/Content/Menu/types';
39
+ export { Popover, type PopoverProps, type PopoverPlacement } from './components/Overlays/Popover';
40
+ export { List, ListItem, type ListProps, type ListItemProps } from './components/Content/List';
41
+ export { TabBar, type TabBarProps, type TabBarItem } from './components/Navigation/TabBar';
42
+ export { VerticalNav, type VerticalNavProps, type VerticalNavItem } from './components/Navigation/VerticalNav';
43
+ export { Json, type JsonProps } from './components/Json/Json';
44
+ export {
45
+ QuickTable,
46
+ QuickTableRow,
47
+ QuickTableCell,
48
+ QuickTableHeaderRow,
49
+ QuickTableHeaderCell,
50
+ type QuickTableProps,
51
+ type QuickTableRowProps,
52
+ type QuickTableCellProps,
53
+ type QuickTableHeaderRowProps,
54
+ type QuickTableHeaderCellProps,
55
+ type QuickTableCellAlign,
56
+ } from './components/Tables/QuickTable';
57
+ export {
58
+ BigTable,
59
+ textColumn,
60
+ numberColumn,
61
+ currencyColumn,
62
+ dateColumn,
63
+ relativeDateColumn,
64
+ badgeColumn,
65
+ booleanColumn,
66
+ linkColumn,
67
+ idColumn,
68
+ type BigTableProps,
69
+ type ColumnDef,
70
+ type ColumnAlign,
71
+ } from './components/Tables/BigTable';
72
+ export { ChatMessage, ChatShell, ChatComposer, PillCombobox } from './components/Chat';
73
+ export type {
74
+ ChatMessageProps,
75
+ ChatMessageRole,
76
+ ChatShellProps,
77
+ ChatComposerProps,
78
+ PillComboboxOption,
79
+ PillComboboxProps,
80
+ } from './components/Chat';
81
+ export { EmptyValue } from './components/Primitives/EmptyValue';
82
+ export { Num, type NumProps, type NumVariant } from './components/Primitives/Num';
83
+ export { Percent, type PercentProps } from './components/Primitives/Percent';
84
+ export { RelativeTime, type RelativeTimeProps } from './components/Primitives/RelativeTime';
85
+ export { LinedStack, type LinedStackProps } from './components/Primitives/LinedStack';
86
+ export { LongText, type LongTextProps } from './components/Primitives/LongText';
87
+ export { type BackgroundToken } from './tokens';
@@ -0,0 +1,35 @@
1
+ # _StoryUtils — dev-only helpers for stories
2
+
3
+ **These are not product primitives. They exist only to make story files shorter and clearer.** Do not add them to the root `src/index.ts` barrel. Consuming apps should not import from here.
4
+
5
+ ## What lives here
6
+
7
+ - **`Lorem`** — deterministic text filler. `<Lorem paragraphs={3} />`, `<Lorem sentences={2} />`, `<Lorem words={20} />`. Same corpus every render (no hydration flicker, no jitter between renders). `as` takes either an intrinsic tag (`'p'`/`'span'`/`'div'`) or a component reference — `<Lorem paragraphs={20} as={Text} />` renders 20 `<Text>` paragraphs in one call, no `Repeat` needed. Defaults: `p` for paragraphs, `span` for sentences/words.
8
+ - **`Placeholder`** — sized rectangle with an optional label. `<Placeholder width={200} height={120} label="Sidebar" />`. Dashed border + subtle fill. For showing "something goes here" in layout stories when the content itself doesn't matter.
9
+ - **`Toggle`** — render-prop cheap boolean. `<Toggle>{({ isOn, on, off, toggle }) => …}</Toggle>`. Collapses the `useState(false)` + button + onClose preamble that every modal / menu / popover story repeats.
10
+ - **`Repeat`** — `<Repeat count={20}>{(i) => <Row />}</Repeat>`. Each child wrapped in a keyed `Fragment`. Sugar for `Array.from({length: n}, (_, i) => …)`.
11
+
12
+ ## Relaxed rules
13
+
14
+ Real primitives follow strict rules in `plan.md` (no `className`, no HTML pass-through, no `aria-*` at call sites, `is*`/`can*`/`has*` booleans, CSS modules only, tokens only). These story utils **intentionally relax the rules** where it helps:
15
+
16
+ - **Render-prop children are fine** (`Toggle`, `Repeat`). Real primitives favor explicit props; these favor terse story code.
17
+ - **`as` prop is fine** (`Lorem`). Real primitives pick their semantic tag; story utils can defer.
18
+ - **Inline styles for dynamic sizing are fine** (`Placeholder`). Same rule as library code, just used more liberally here.
19
+
20
+ What still applies: CSS modules, `--ui-*` tokens, no HTML attribute pass-through, no `className` escape hatch. The relaxed rules are about *shape of the API*, not styling discipline.
21
+
22
+ ## Don't graduate these into the public barrel
23
+
24
+ If a story util starts getting reached for by app code, that's a signal to build a real primitive, not to promote the story util. A real `Placeholder` for empty states would be a different primitive with proper typography, icon support, and action slots — not this dashed box. Keep the lanes separate.
25
+
26
+ ## Adding a new util
27
+
28
+ - New folder under `src/_StoryUtils/<Name>/` with `index.tsx`.
29
+ - Export from `src/_StoryUtils/index.ts`.
30
+ - No story file for the util itself unless it's actually tricky to use. These are trivial by design.
31
+ - Document the use case in this CLAUDE.md.
32
+
33
+ ## Where they're used
34
+
35
+ Stories under `src/**/*.stories.tsx` import from `'../_StoryUtils'` (relative) or the folder-local barrel. The ESLint boundary rule treats `_StoryUtils/` as a primitive; external imports go through `src/_StoryUtils/index.ts`. Stories and tests are the "dogfood" element type and are allowed to reach into their own primitive's index as usual.
@@ -0,0 +1,269 @@
1
+ import type { Meta, StoryObj } from '@storybook/react';
2
+ import { useRef, useState } from 'react';
3
+ import { Button } from '../components/Forms/Button';
4
+ import { Field } from '../components/Forms/Field';
5
+ import { Input } from '../components/Forms/Input';
6
+ import { MediumModal } from '../components/Modals/MediumModal';
7
+ import { Menu } from '../components/Content/Menu';
8
+ import { Popover } from '../components/Overlays/Popover';
9
+ import { SingleSelect } from '../components/Forms/Select/SingleSelect';
10
+ import { Stack } from '../components/Layout/Stack';
11
+ import { Text } from '../components/Content/Text';
12
+ import { Tooltip } from '../components/Overlays/Tooltip';
13
+ import { Toggle } from '.';
14
+
15
+ const meta: Meta = {
16
+ title: 'Composition/Layered overlays',
17
+ parameters: { layout: 'padded' },
18
+ };
19
+
20
+ export default meta;
21
+ type Story = StoryObj;
22
+
23
+ const PERIOD_OPTIONS = [
24
+ { value: 'weekly', label: 'Weekly' },
25
+ { value: 'monthly', label: 'Monthly' },
26
+ { value: 'quarterly', label: 'Quarterly' },
27
+ { value: 'annual', label: 'Annual' },
28
+ ];
29
+
30
+ const log = (label: string) => () => console.log('selected:', label);
31
+
32
+ const MENU_ITEMS = [
33
+ { label: 'Refresh', onSelect: log('Refresh') },
34
+ { label: 'Duplicate', onSelect: log('Duplicate') },
35
+ {
36
+ label: 'Export',
37
+ items: [
38
+ { label: 'CSV', onSelect: log('CSV') },
39
+ { label: 'JSON', onSelect: log('JSON') },
40
+ ],
41
+ },
42
+ { label: 'Delete', onSelect: log('Delete') },
43
+ ];
44
+
45
+ // Every layer kind opened from inside a modal. Confirms that Popover, Menu,
46
+ // and Tooltip all paint above the modal's backdrop+content because each is
47
+ // portaled to <body> later in mount order than the modal.
48
+ export const ModalContainsEverything: Story = {
49
+ render: () => {
50
+ const [period, setPeriod] = useState<string>('monthly');
51
+
52
+ return (
53
+ <Toggle defaultOn>
54
+ {({ isOn, on, off }) => (
55
+ <>
56
+ <Button onClick={on}>Open modal</Button>
57
+ <MediumModal
58
+ isOpen={isOn}
59
+ onClose={off}
60
+ title="Layer composition test"
61
+ footer={
62
+ <>
63
+ <Button onClick={off}>Cancel</Button>
64
+ <Button variant="primary" onClick={off}>Save</Button>
65
+ </>
66
+ }
67
+ >
68
+ <Stack gap={3}>
69
+ <Text size="small" isMuted>
70
+ Each row is a different layer kind opened from inside the
71
+ modal. They should all sit above the modal's backdrop.
72
+ </Text>
73
+
74
+ <Field label="Popover-based select">
75
+ <SingleSelect
76
+ options={PERIOD_OPTIONS}
77
+ value={period}
78
+ onChange={setPeriod}
79
+ />
80
+ </Field>
81
+
82
+ <Field label="Inline-edit popover">
83
+ <PopoverDemo />
84
+ </Field>
85
+
86
+ <Field label="Menu">
87
+ <Menu trigger="Actions ⋯" ariaLabel="Actions" items={MENU_ITEMS} />
88
+ </Field>
89
+
90
+ <Field label="Tooltip">
91
+ <Tooltip label="Tooltips also escape the modal stacking context">
92
+ <Button>Hover me</Button>
93
+ </Tooltip>
94
+ </Field>
95
+ </Stack>
96
+ </MediumModal>
97
+ </>
98
+ )}
99
+ </Toggle>
100
+ );
101
+ },
102
+ };
103
+
104
+ const PopoverDemo = () => {
105
+ const anchorRef = useRef<HTMLButtonElement>(null);
106
+ const [isOpen, setIsOpen] = useState(false);
107
+ const [value, setValue] = useState('150');
108
+ const [draft, setDraft] = useState(value);
109
+ return (
110
+ <>
111
+ <Button
112
+ ref={anchorRef}
113
+ size="small"
114
+ onClick={() => {
115
+ setDraft(value);
116
+ setIsOpen(true);
117
+ }}
118
+ >
119
+ Edit ${value}…
120
+ </Button>
121
+ <Popover
122
+ anchorRef={anchorRef}
123
+ isOpen={isOpen}
124
+ onClose={() => setIsOpen(false)}
125
+ title="Edit amount"
126
+ actions={
127
+ <>
128
+ <Button variant="ghost" size="small" onClick={() => setIsOpen(false)}>
129
+ Cancel
130
+ </Button>
131
+ <Button
132
+ variant="primary"
133
+ size="small"
134
+ onClick={() => {
135
+ setValue(draft);
136
+ setIsOpen(false);
137
+ }}
138
+ >
139
+ Save
140
+ </Button>
141
+ </>
142
+ }
143
+ >
144
+ <Field label="Amount">
145
+ <Input value={draft} onChange={(e) => setDraft(e.currentTarget.value)} autoFocus />
146
+ </Field>
147
+ </Popover>
148
+ </>
149
+ );
150
+ };
151
+
152
+ // Each layer is opened on top of the previous one. Mount order alone should
153
+ // guarantee correct stacking: modal < first popover < menu < tooltip.
154
+ export const StackedDeep: Story = {
155
+ render: () => {
156
+ const popoverAnchorRef = useRef<HTMLButtonElement>(null);
157
+ const [isPopoverOpen, setIsPopoverOpen] = useState(false);
158
+
159
+ return (
160
+ <Toggle defaultOn>
161
+ {({ isOn, on, off }) => (
162
+ <>
163
+ <Button onClick={on}>Open</Button>
164
+ <MediumModal
165
+ isOpen={isOn}
166
+ onClose={off}
167
+ title="Modal → Popover → Menu → Tooltip"
168
+ footer={<Button onClick={off}>Close</Button>}
169
+ >
170
+ <Stack gap={3}>
171
+ <Text>
172
+ Each layer is anchored inside the previous one. Open them in
173
+ order to confirm each new layer paints above its parent.
174
+ </Text>
175
+ <div>
176
+ <Button
177
+ ref={popoverAnchorRef}
178
+ onClick={() => setIsPopoverOpen(true)}
179
+ >
180
+ 1. Open popover
181
+ </Button>
182
+ </div>
183
+ <Popover
184
+ anchorRef={popoverAnchorRef}
185
+ isOpen={isPopoverOpen}
186
+ onClose={() => setIsPopoverOpen(false)}
187
+ title="A popover"
188
+ >
189
+ <Stack gap={2}>
190
+ <Text size="small">
191
+ 2. Open the menu (a layer above this popover):
192
+ </Text>
193
+ <Menu
194
+ trigger="Actions ⋯"
195
+ ariaLabel="Actions"
196
+ items={MENU_ITEMS}
197
+ />
198
+ <Text size="small">3. Hover for a tooltip:</Text>
199
+ <Tooltip label="Tooltip lives above everything">
200
+ <Button size="small">Hover me</Button>
201
+ </Tooltip>
202
+ </Stack>
203
+ </Popover>
204
+ </Stack>
205
+ </MediumModal>
206
+ </>
207
+ )}
208
+ </Toggle>
209
+ );
210
+ },
211
+ };
212
+
213
+ // Hostile container: overflow:hidden + transform on an ancestor. Inline-rendered
214
+ // floaters would clip or land in the wrong containing block. Portaled layers
215
+ // escape both.
216
+ export const InsideClippedTransformedContainer: Story = {
217
+ render: () => {
218
+ const [period, setPeriod] = useState<string>('monthly');
219
+ const popoverAnchorRef = useRef<HTMLButtonElement>(null);
220
+ const [isPopoverOpen, setIsPopoverOpen] = useState(false);
221
+
222
+ return (
223
+ <div
224
+ style={{
225
+ width: 360,
226
+ height: 240,
227
+ padding: 16,
228
+ overflow: 'hidden',
229
+ transform: 'translateZ(0)',
230
+ border: '1px dashed var(--ui-border)',
231
+ borderRadius: 8,
232
+ }}
233
+ >
234
+ <Stack gap={2}>
235
+ <Text size="small" isMuted>
236
+ This box has overflow:hidden and a transform. Layers should still
237
+ escape.
238
+ </Text>
239
+ <SingleSelect
240
+ options={PERIOD_OPTIONS}
241
+ value={period}
242
+ onChange={setPeriod}
243
+ />
244
+ <Menu trigger="Actions ⋯" ariaLabel="Actions" items={MENU_ITEMS} />
245
+ <Tooltip label="Escapes the clipped, transformed parent">
246
+ <Button size="small">Hover me</Button>
247
+ </Tooltip>
248
+ <Button
249
+ ref={popoverAnchorRef}
250
+ size="small"
251
+ onClick={() => setIsPopoverOpen(true)}
252
+ >
253
+ Open popover
254
+ </Button>
255
+ <Popover
256
+ anchorRef={popoverAnchorRef}
257
+ isOpen={isPopoverOpen}
258
+ onClose={() => setIsPopoverOpen(false)}
259
+ title="Escaped"
260
+ >
261
+ <Text size="small">
262
+ Rendered via portal-to-body, positioned in viewport coords.
263
+ </Text>
264
+ </Popover>
265
+ </Stack>
266
+ </div>
267
+ );
268
+ },
269
+ };
@@ -0,0 +1,54 @@
1
+ import type { ElementType, ReactNode } from 'react';
2
+
3
+ export type LoremProps = {
4
+ paragraphs?: number;
5
+ sentences?: number;
6
+ words?: number;
7
+ /**
8
+ * Element (or component) to render into. Accepts intrinsic tags like `'p'`
9
+ * / `'span'` / `'div'`, or a component reference (e.g. `Text`) so paragraph
10
+ * output inherits the library's typography without a wrapping `Repeat`.
11
+ * Defaults: `'p'` for paragraphs, `'span'` for sentences/words.
12
+ */
13
+ as?: ElementType;
14
+ };
15
+
16
+ const CORPUS = `Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. Curabitur pretium tincidunt lacus. Nulla gravida orci a odio. Nullam varius, turpis et commodo pharetra, est eros bibendum elit, nec luctus magna felis sollicitudin mauris. Integer in mauris eu nibh euismod gravida. Duis ac tellus et risus vulputate vehicula. Donec lobortis risus a elit. Etiam tempor. Ut ullamcorper, ligula eu tempor congue, eros est euismod turpis, id tincidunt sapien risus a quam. Maecenas fermentum consequat mi. Donec fermentum. Pellentesque malesuada nulla a mi. Duis sapien sem, aliquet nec, commodo eget, consequat quis, neque. Aliquam faucibus, elit ut dictum aliquet, felis nisl adipiscing sapien, sed malesuada diam lacus eget erat. Cras mollis scelerisque nunc. Nullam arcu.`;
17
+
18
+ const WORDS = CORPUS.replace(/[.,]/g, '').split(/\s+/);
19
+ const SENTENCES = CORPUS.match(/[^.]+\./g)?.map((s) => s.trim()) ?? [];
20
+
21
+ const makeParagraph = (index: number, sentencesPer: number): string => {
22
+ const start = (index * sentencesPer) % SENTENCES.length;
23
+ const out: string[] = [];
24
+ for (let i = 0; i < sentencesPer; i += 1) {
25
+ out.push(SENTENCES[(start + i) % SENTENCES.length]!);
26
+ }
27
+ return out.join(' ');
28
+ };
29
+
30
+ export const Lorem = (props: LoremProps): ReactNode => {
31
+ const { paragraphs, sentences, words, as } = props;
32
+
33
+ if (words != null) {
34
+ const Tag = as ?? 'span';
35
+ const picked = WORDS.slice(0, words).join(' ');
36
+ return <Tag>{picked.charAt(0).toUpperCase() + picked.slice(1) + '.'}</Tag>;
37
+ }
38
+
39
+ if (sentences != null) {
40
+ const Tag = as ?? 'span';
41
+ const picked = SENTENCES.slice(0, sentences).join(' ');
42
+ return <Tag>{picked}</Tag>;
43
+ }
44
+
45
+ const count = paragraphs ?? 1;
46
+ const Tag = as ?? 'p';
47
+ return (
48
+ <>
49
+ {Array.from({ length: count }, (_, i) => (
50
+ <Tag key={i}>{makeParagraph(i, 4)}</Tag>
51
+ ))}
52
+ </>
53
+ );
54
+ };
@@ -0,0 +1,27 @@
1
+ import type { CSSProperties, ReactNode } from 'react';
2
+ import styles from './styles.module.css';
3
+
4
+ export type PlaceholderProps = {
5
+ width?: string | number;
6
+ height?: string | number;
7
+ label?: ReactNode;
8
+ };
9
+
10
+ const toLength = (value: string | number | undefined): string | undefined => {
11
+ if (value == null) return undefined;
12
+ return typeof value === 'number' ? `${value}px` : value;
13
+ };
14
+
15
+ export const Placeholder = (props: PlaceholderProps) => {
16
+ const { width, height, label } = props;
17
+
18
+ const style: CSSProperties = {
19
+ width: toLength(width),
20
+ height: toLength(height),
21
+ };
22
+ return (
23
+ <div className={styles.placeholder} style={style}>
24
+ {label ? <span className={styles.label}>{label}</span> : null}
25
+ </div>
26
+ );
27
+ };
@@ -0,0 +1,20 @@
1
+ .placeholder {
2
+ display: flex;
3
+ align-items: center;
4
+ justify-content: center;
5
+ min-width: 48px;
6
+ min-height: 32px;
7
+ background-color: var(--ui-background-3);
8
+ border: 1px dashed var(--ui-border);
9
+ border-radius: var(--ui-radius);
10
+ color: var(--ui-muted);
11
+ font-family: var(--ui-font-mono);
12
+ font-size: var(--ui-text-xsmall);
13
+ text-align: center;
14
+ padding: var(--ui-space-1) var(--ui-space-2);
15
+ box-sizing: border-box;
16
+ }
17
+
18
+ .label {
19
+ opacity: 0.85;
20
+ }
@@ -0,0 +1,23 @@
1
+ import { Fragment, type ReactNode } from 'react';
2
+
3
+ export type RepeatProps = {
4
+ count: number;
5
+ children: (index: number) => ReactNode;
6
+ };
7
+
8
+ /**
9
+ * Sugar for `Array.from({ length: n }, (_, i) => …)` in stories.
10
+ * Each child is wrapped in a keyed `Fragment`, so callers don't need to
11
+ * thread keys through.
12
+ */
13
+ export const Repeat = (props: RepeatProps) => {
14
+ const { count, children } = props;
15
+
16
+ return (
17
+ <>
18
+ {Array.from({ length: count }, (_, i) => (
19
+ <Fragment key={i}>{children(i)}</Fragment>
20
+ ))}
21
+ </>
22
+ );
23
+ };
@@ -0,0 +1,29 @@
1
+ import { useCallback, useState, type ReactNode } from 'react';
2
+
3
+ export type ToggleChildArgs = {
4
+ isOn: boolean;
5
+ on: () => void;
6
+ off: () => void;
7
+ toggle: () => void;
8
+ setOn: (next: boolean) => void;
9
+ };
10
+
11
+ export type ToggleProps = {
12
+ defaultOn?: boolean;
13
+ children: (args: ToggleChildArgs) => ReactNode;
14
+ };
15
+
16
+ /**
17
+ * Render-prop boolean state for stories. Collapses the
18
+ * `useState(false)` + open button + onClose wiring that every modal,
19
+ * menu, or popover story would otherwise repeat.
20
+ */
21
+ export const Toggle = (props: ToggleProps) => {
22
+ const { defaultOn = false, children } = props;
23
+
24
+ const [isOn, setOn] = useState(defaultOn);
25
+ const on = useCallback(() => setOn(true), []);
26
+ const off = useCallback(() => setOn(false), []);
27
+ const toggle = useCallback(() => setOn((prev) => !prev), []);
28
+ return <>{children({ isOn, on, off, toggle, setOn })}</>;
29
+ };
@@ -0,0 +1,58 @@
1
+ import type { Meta, StoryObj } from '@storybook/react';
2
+ import { Lorem, Placeholder, Toggle, Repeat } from './index';
3
+ import { Button } from '../components/Forms/Button';
4
+ import { Stack } from '../components/Layout/Stack';
5
+ import { Text } from '../components/Content/Text';
6
+ import { MediumModal } from '../components/Modals';
7
+
8
+ const meta: Meta = { title: 'StoryUtils/Showcase' };
9
+ export default meta;
10
+ type Story = StoryObj;
11
+
12
+ export const LoremSamples: Story = {
13
+ render: () => (
14
+ <Stack gap={4}>
15
+ <Text size="small" isMuted>paragraphs=3 (default as="p")</Text>
16
+ <Lorem paragraphs={3} />
17
+ <Text size="small" isMuted>sentences=2</Text>
18
+ <Lorem sentences={2} />
19
+ <Text size="small" isMuted>words=12</Text>
20
+ <Lorem words={12} />
21
+ </Stack>
22
+ ),
23
+ };
24
+
25
+ export const PlaceholderSamples: Story = {
26
+ render: () => (
27
+ <Stack direction="row" gap={2}>
28
+ <Placeholder width={120} height={80} label="Sidebar" />
29
+ <Placeholder width={240} height={80} label="Main" />
30
+ <Placeholder width={80} height={80} label="Aside" />
31
+ </Stack>
32
+ ),
33
+ };
34
+
35
+ export const ToggleWithModal: Story = {
36
+ render: () => (
37
+ <Toggle>
38
+ {({ isOn, on, off }) => (
39
+ <>
40
+ <Button onClick={on}>Open modal</Button>
41
+ <MediumModal isOpen={isOn} onClose={off} title="From a Toggle">
42
+ <Lorem paragraphs={2} />
43
+ </MediumModal>
44
+ </>
45
+ )}
46
+ </Toggle>
47
+ ),
48
+ };
49
+
50
+ export const RepeatWithPlaceholder: Story = {
51
+ render: () => (
52
+ <Stack gap={1}>
53
+ <Repeat count={6}>
54
+ {(i) => <Placeholder height={32} label={`row ${i + 1}`} />}
55
+ </Repeat>
56
+ </Stack>
57
+ ),
58
+ };
@@ -0,0 +1,4 @@
1
+ export { Lorem, type LoremProps } from './Lorem';
2
+ export { Placeholder, type PlaceholderProps } from './Placeholder';
3
+ export { Toggle, type ToggleProps, type ToggleChildArgs } from './Toggle';
4
+ export { Repeat, type RepeatProps } from './Repeat';
package/src/tokens.ts ADDED
@@ -0,0 +1,31 @@
1
+ import type { CSSProperties } from 'react';
2
+
3
+ /**
4
+ * Background ramp level. Maps 1:1 to the `--ui-background-{0..3}` tokens
5
+ * defined in `tokens.css`. Each level is role-semantic, not brightness-ordered:
6
+ * 0 — primary interactive surface (content, inputs, modal)
7
+ * 1 — sidebar
8
+ * 2 — body / header / footer wall
9
+ * 3 — recessed or raised highlight (hover fills, badges)
10
+ * Each theme assigns a tone to each level. Brightness ordering between the
11
+ * four levels may differ between light and dark themes (e.g. in dark-warm,
12
+ * level 3 is lighter than level 0 because hover "lifts" on dark backgrounds).
13
+ */
14
+ export type BackgroundToken = '0' | '1' | '2' | '3';
15
+
16
+ /**
17
+ * Build an inline style that sets a named CSS custom property to the
18
+ * corresponding background token. Components consume the custom property
19
+ * in their stylesheet with a fallback to their default, e.g.
20
+ * background-color: var(--ui-bar-bg, transparent);
21
+ *
22
+ * `varName` is scoped per-component (e.g. `--ui-bar-bg`) so nested
23
+ * components don't inherit each other's overrides via the cascade.
24
+ */
25
+ export const backgroundStyle = (
26
+ varName: string,
27
+ token: BackgroundToken | undefined,
28
+ ): CSSProperties | undefined => {
29
+ if (token == null) return undefined;
30
+ return { [varName]: `var(--ui-background-${token})` } as CSSProperties;
31
+ };
@@ -0,0 +1,24 @@
1
+ import { describe, test, expect } from 'bun:test';
2
+ import { cx } from './utils';
3
+
4
+ describe('cx', () => {
5
+ test('joins truthy strings with spaces', () => {
6
+ expect(cx('a', 'b', 'c')).toBe('a b c');
7
+ });
8
+
9
+ test('filters out false, null, and undefined', () => {
10
+ expect(cx('a', false, 'b', null, undefined, 'c')).toBe('a b c');
11
+ });
12
+
13
+ test('returns empty string when all inputs are falsy', () => {
14
+ expect(cx(false, null, undefined)).toBe('');
15
+ });
16
+
17
+ test('conditional class pattern', () => {
18
+ const active = true;
19
+ const disabled = false;
20
+ expect(
21
+ cx('btn', active && 'btn-active', disabled && 'btn-disabled'),
22
+ ).toBe('btn btn-active');
23
+ });
24
+ });
package/src/utils.ts ADDED
@@ -0,0 +1,2 @@
1
+ export const cx = (...parts: (string | false | null | undefined)[]) =>
2
+ parts.filter(Boolean).join(' ');