@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,54 @@
1
+ .container {
2
+ position: relative;
3
+ }
4
+
5
+ /* Wraps the consumer's children so we can apply `inert` without leaking it
6
+ to the overlay siblings. No visual styles — children flow naturally. */
7
+ .content {
8
+ /* intentionally empty */
9
+ }
10
+
11
+ .overlay {
12
+ position: absolute;
13
+ inset: 0;
14
+ display: flex;
15
+ align-items: center;
16
+ justify-content: center;
17
+ /* Theme-aware translucent frost — lightens (or darkens, depending on theme)
18
+ the content beneath by mixing the primary surface with transparency. */
19
+ background-color: color-mix(in srgb, var(--ui-background-0) 70%, transparent);
20
+ z-index: 1;
21
+ }
22
+
23
+ .spinner {
24
+ width: 32px;
25
+ height: 32px;
26
+ border: 3px solid var(--ui-border);
27
+ border-top-color: var(--ui-accent);
28
+ border-radius: 50%;
29
+ animation: spin 0.8s linear infinite;
30
+ }
31
+
32
+ @keyframes spin {
33
+ to { transform: rotate(360deg); }
34
+ }
35
+
36
+ /* Respect the OS-level reduced-motion preference — slow the spinner rather
37
+ than remove it entirely so the loading signal is still visible. */
38
+ @media (prefers-reduced-motion: reduce) {
39
+ .spinner {
40
+ animation-duration: 4s;
41
+ }
42
+ }
43
+
44
+ .srOnly {
45
+ position: absolute;
46
+ width: 1px;
47
+ height: 1px;
48
+ padding: 0;
49
+ margin: -1px;
50
+ overflow: hidden;
51
+ clip: rect(0, 0, 0, 0);
52
+ white-space: nowrap;
53
+ border: 0;
54
+ }
@@ -0,0 +1,8 @@
1
+ import type { ReactNode } from 'react';
2
+
3
+ export type LoadingContainerProps = {
4
+ children: ReactNode;
5
+ isLoading: boolean;
6
+ /** Accessible label announced when the overlay appears. Default: "Loading". */
7
+ label?: string;
8
+ };
@@ -0,0 +1,39 @@
1
+ import type { Meta, StoryObj } from '@storybook/react';
2
+ import { Markdown } from './index';
3
+
4
+ const sample = `# Heading 1
5
+
6
+ A paragraph with **bold** text, *italic*, and \`inline code\`. Here's a [link](https://example.com).
7
+
8
+ ## Heading 2
9
+
10
+ - Bullet one
11
+ - Bullet two with some \`code\`
12
+ - Bullet three
13
+
14
+ \`\`\`ts
15
+ const greeting = 'hello';
16
+ console.log(greeting);
17
+ \`\`\`
18
+
19
+ > A blockquote with some thoughtful text.
20
+ `;
21
+
22
+ const meta: Meta<typeof Markdown> = {
23
+ title: 'Content/Markdown',
24
+ component: Markdown,
25
+ };
26
+
27
+ export default meta;
28
+ type Story = StoryObj<typeof Markdown>;
29
+
30
+ export const Default: Story = {
31
+ args: { children: sample },
32
+ };
33
+
34
+ export const Inline: Story = {
35
+ args: {
36
+ isInline: true,
37
+ children: 'Inline markdown with `code` and a [link](https://example.com).',
38
+ },
39
+ };
@@ -0,0 +1,28 @@
1
+ import ReactMarkdown from 'react-markdown';
2
+ import type { MarkdownProps } from './types';
3
+ import styles from './styles.module.css';
4
+
5
+ const InlineParagraph = (props: { children?: React.ReactNode }) => {
6
+ const { children } = props;
7
+
8
+ return <>{children}</>;
9
+ };
10
+
11
+ const INLINE_COMPONENTS = {
12
+ p: InlineParagraph,
13
+ };
14
+
15
+ export const Markdown = (props: MarkdownProps) => {
16
+ const { children, isInline } = props;
17
+
18
+ if (isInline) {
19
+ return <ReactMarkdown components={INLINE_COMPONENTS}>{children}</ReactMarkdown>;
20
+ }
21
+ return (
22
+ <div className={styles.markdown}>
23
+ <ReactMarkdown>{children}</ReactMarkdown>
24
+ </div>
25
+ );
26
+ };
27
+
28
+ export type { MarkdownProps };
@@ -0,0 +1,79 @@
1
+ .markdown {
2
+ font-size: var(--ui-text-medium);
3
+ line-height: var(--ui-line-height);
4
+ color: var(--ui-foreground);
5
+ }
6
+
7
+ .markdown :global(h1),
8
+ .markdown :global(h2),
9
+ .markdown :global(h3),
10
+ .markdown :global(h4) {
11
+ font-weight: 600;
12
+ line-height: var(--ui-line-height-tight);
13
+ margin-top: var(--ui-space-4);
14
+ margin-bottom: var(--ui-space-2);
15
+ }
16
+
17
+ .markdown :global(h1) { font-size: var(--ui-heading-1); }
18
+ .markdown :global(h2) { font-size: var(--ui-heading-2); }
19
+ .markdown :global(h3),
20
+ .markdown :global(h4) { font-size: var(--ui-heading-3); }
21
+
22
+ .markdown :global(p) { margin-bottom: var(--ui-space-3); }
23
+ .markdown :global(p):last-child { margin-bottom: 0; }
24
+
25
+ .markdown :global(ul),
26
+ .markdown :global(ol) {
27
+ margin-bottom: var(--ui-space-3);
28
+ padding-left: 1.25rem;
29
+ }
30
+
31
+ .markdown :global(li) { margin-bottom: var(--ui-space-1); }
32
+
33
+ .markdown :global(code) {
34
+ font-family: var(--ui-font-mono);
35
+ font-size: 0.9em;
36
+ background-color: var(--ui-background-2);
37
+ padding: 1px 4px;
38
+ border-radius: var(--ui-radius);
39
+ }
40
+
41
+ .markdown :global(pre) {
42
+ background-color: var(--ui-background-2);
43
+ padding: var(--ui-space-3);
44
+ border-radius: var(--ui-radius);
45
+ overflow-x: auto;
46
+ margin-bottom: var(--ui-space-3);
47
+ }
48
+
49
+ .markdown :global(pre code) {
50
+ background-color: transparent;
51
+ padding: 0;
52
+ font-size: var(--ui-text-small);
53
+ }
54
+
55
+ .markdown :global(a) {
56
+ color: var(--ui-accent);
57
+ text-decoration: underline;
58
+ text-underline-offset: 2px;
59
+ }
60
+
61
+ .markdown :global(a):hover {
62
+ color: var(--ui-accent-hover);
63
+ }
64
+
65
+ .markdown :global(img) {
66
+ display: block;
67
+ max-width: 100%;
68
+ height: auto;
69
+ margin: var(--ui-space-2) 0;
70
+ border-radius: var(--ui-radius);
71
+ border: 1px solid var(--ui-border-subtle);
72
+ }
73
+
74
+ .markdown :global(blockquote) {
75
+ border-left: 3px solid var(--ui-border);
76
+ padding-left: var(--ui-space-3);
77
+ color: var(--ui-muted);
78
+ margin-bottom: var(--ui-space-3);
79
+ }
@@ -0,0 +1,8 @@
1
+ export type MarkdownProps = {
2
+ children: string;
3
+ /**
4
+ * Render as inline markdown — strips the default paragraph wrapper so
5
+ * the output can sit inside a heading, link, or other inline context.
6
+ */
7
+ isInline?: boolean;
8
+ };
@@ -0,0 +1,186 @@
1
+ import type { Meta, StoryObj } from '@storybook/react';
2
+ import { Menu } from './index';
3
+ import type { MenuItem } from './types';
4
+
5
+ const log = (label: string) => () => console.log('selected:', label);
6
+
7
+ const flatItems: MenuItem[] = [
8
+ { label: 'Refresh', onSelect: log('Refresh') },
9
+ { label: 'Full sync', onSelect: log('Full sync') },
10
+ { label: 'Settings', onSelect: log('Settings') },
11
+ ];
12
+
13
+ const nestedItems: MenuItem[] = [
14
+ { label: 'Settings', onSelect: log('Settings') },
15
+ {
16
+ label: 'Linear',
17
+ items: [
18
+ { label: 'Refresh', onSelect: log('Linear: Refresh') },
19
+ { label: 'Full sync', onSelect: log('Linear: Full sync') },
20
+ {
21
+ label: 'Viewer',
22
+ items: [
23
+ { label: 'Show me', onSelect: log('Viewer: Show me') },
24
+ { label: 'Clear', onSelect: log('Viewer: Clear') },
25
+ ],
26
+ },
27
+ ],
28
+ },
29
+ {
30
+ label: 'Database',
31
+ items: [
32
+ { label: 'Export work items', onSelect: log('DB: Export') },
33
+ { label: 'Reset (archives first)', onSelect: log('DB: Reset') },
34
+ ],
35
+ },
36
+ { label: 'About', onSelect: log('About') },
37
+ ];
38
+
39
+ const meta: Meta<typeof Menu> = {
40
+ title: 'Content/Menu',
41
+ component: Menu,
42
+ args: {
43
+ trigger: '⋯',
44
+ ariaLabel: 'More',
45
+ },
46
+ };
47
+
48
+ export default meta;
49
+ type Story = StoryObj<typeof Menu>;
50
+
51
+ export const Flat: Story = {
52
+ args: { items: flatItems },
53
+ decorators: [
54
+ (Story) => (
55
+ <div style={{ padding: 48, display: 'flex', justifyContent: 'center' }}>
56
+ <Story />
57
+ </div>
58
+ ),
59
+ ],
60
+ };
61
+
62
+ export const WithTitle: Story = {
63
+ args: { items: flatItems, title: 'Actions' },
64
+ decorators: [
65
+ (Story) => (
66
+ <div style={{ padding: 48, display: 'flex', justifyContent: 'center' }}>
67
+ <Story />
68
+ </div>
69
+ ),
70
+ ],
71
+ };
72
+
73
+ const activeItems: MenuItem[] = [
74
+ { label: 'Light', onSelect: log('Light') },
75
+ { label: 'Dark', isActive: true, onSelect: log('Dark') },
76
+ { label: 'System', onSelect: log('System') },
77
+ ];
78
+
79
+ export const WithActiveItem: Story = {
80
+ args: { items: activeItems, title: 'Dark Mode' },
81
+ decorators: [
82
+ (Story) => (
83
+ <div style={{ padding: 48, display: 'flex', justifyContent: 'center' }}>
84
+ <Story />
85
+ </div>
86
+ ),
87
+ ],
88
+ };
89
+
90
+ export const Nested: Story = {
91
+ args: { items: nestedItems },
92
+ decorators: [
93
+ (Story) => (
94
+ <div style={{ padding: 48, display: 'flex', justifyContent: 'center' }}>
95
+ <Story />
96
+ </div>
97
+ ),
98
+ ],
99
+ };
100
+
101
+ // Each trigger is pinned to one corner of a viewport-filling box so the
102
+ // auto-placement picks a different quadrant to open into. Inner submenus
103
+ // should also flip direction automatically based on the parent item's
104
+ // position on screen.
105
+ export const Corners: Story = {
106
+ parameters: { layout: 'fullscreen' },
107
+ args: { items: nestedItems },
108
+ render: (args) => (
109
+ <div
110
+ style={{
111
+ position: 'relative',
112
+ height: '100vh',
113
+ width: '100%',
114
+ }}
115
+ >
116
+ <div style={{ position: 'absolute', top: 16, left: 16 }}>
117
+ <Menu {...args} trigger="⋯ TL" ariaLabel="Top left" />
118
+ </div>
119
+ <div style={{ position: 'absolute', top: 16, right: 16 }}>
120
+ <Menu {...args} trigger="⋯ TR" ariaLabel="Top right" />
121
+ </div>
122
+ <div style={{ position: 'absolute', bottom: 16, left: 16 }}>
123
+ <Menu {...args} trigger="⋯ BL" ariaLabel="Bottom left" />
124
+ </div>
125
+ <div style={{ position: 'absolute', bottom: 16, right: 16 }}>
126
+ <Menu {...args} trigger="⋯ BR" ariaLabel="Bottom right" />
127
+ </div>
128
+ </div>
129
+ ),
130
+ };
131
+
132
+ const inCorner = (
133
+ corner: 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right',
134
+ ): React.CSSProperties => ({
135
+ position: 'absolute',
136
+ ...(corner.startsWith('top') ? { top: 16 } : { bottom: 16 }),
137
+ ...(corner.endsWith('left') ? { left: 16 } : { right: 16 }),
138
+ });
139
+
140
+ export const TopLeft: Story = {
141
+ parameters: { layout: 'fullscreen' },
142
+ args: { items: nestedItems, trigger: '⋯ TL' },
143
+ render: (args) => (
144
+ <div style={{ position: 'relative', height: '100vh' }}>
145
+ <div style={inCorner('top-left')}>
146
+ <Menu {...args} />
147
+ </div>
148
+ </div>
149
+ ),
150
+ };
151
+
152
+ export const TopRight: Story = {
153
+ parameters: { layout: 'fullscreen' },
154
+ args: { items: nestedItems, trigger: '⋯ TR' },
155
+ render: (args) => (
156
+ <div style={{ position: 'relative', height: '100vh' }}>
157
+ <div style={inCorner('top-right')}>
158
+ <Menu {...args} />
159
+ </div>
160
+ </div>
161
+ ),
162
+ };
163
+
164
+ export const BottomLeft: Story = {
165
+ parameters: { layout: 'fullscreen' },
166
+ args: { items: nestedItems, trigger: '⋯ BL' },
167
+ render: (args) => (
168
+ <div style={{ position: 'relative', height: '100vh' }}>
169
+ <div style={inCorner('bottom-left')}>
170
+ <Menu {...args} />
171
+ </div>
172
+ </div>
173
+ ),
174
+ };
175
+
176
+ export const BottomRight: Story = {
177
+ parameters: { layout: 'fullscreen' },
178
+ args: { items: nestedItems, trigger: '⋯ BR' },
179
+ render: (args) => (
180
+ <div style={{ position: 'relative', height: '100vh' }}>
181
+ <div style={inCorner('bottom-right')}>
182
+ <Menu {...args} />
183
+ </div>
184
+ </div>
185
+ ),
186
+ };
@@ -0,0 +1,259 @@
1
+ import {
2
+ useEffect,
3
+ useId,
4
+ useLayoutEffect,
5
+ useRef,
6
+ useState,
7
+ type CSSProperties,
8
+ } from 'react';
9
+ import { createPortal } from 'react-dom';
10
+ import type { MenuItem, MenuProps } from './types';
11
+ import { cx } from '../../../utils';
12
+ import styles from './styles.module.css';
13
+
14
+ const isBranch = (
15
+ item: MenuItem,
16
+ ): item is { label: string; items: MenuItem[] } => 'items' in item;
17
+
18
+ type RootPosition = {
19
+ top: number;
20
+ left: number;
21
+ };
22
+
23
+ type SubmenuPlacement = {
24
+ h: 'subOpenRight' | 'subOpenLeft';
25
+ v: 'subAlignTop' | 'subAlignBottom';
26
+ };
27
+
28
+ const GAP = 4;
29
+
30
+ // Rough sizing estimates used to decide direction without pre-measuring the
31
+ // actual panel. If the real panel ends up taller/wider than these, it can
32
+ // still overflow — acceptable for v0.1.
33
+ const SUBMENU_EST_WIDTH = 180;
34
+ const ITEM_EST_HEIGHT = 34;
35
+ const PANEL_EST_PADDING = 8;
36
+
37
+ const computeRootPosition = (
38
+ trigger: DOMRect,
39
+ panel: DOMRect,
40
+ ): RootPosition => {
41
+ const winW = window.innerWidth;
42
+ const winH = window.innerHeight;
43
+ const openRight = winW - trigger.right >= trigger.left;
44
+ const openDown = winH - trigger.bottom >= trigger.top;
45
+ return {
46
+ top: openDown ? trigger.bottom + GAP : trigger.top - panel.height - GAP,
47
+ left: openRight ? trigger.left : trigger.right - panel.width,
48
+ };
49
+ };
50
+
51
+ const computeSubmenuPlacement = (
52
+ itemRect: DOMRect,
53
+ childItemCount: number,
54
+ ): SubmenuPlacement => {
55
+ const winW = window.innerWidth;
56
+ const winH = window.innerHeight;
57
+ const submenuH = childItemCount * ITEM_EST_HEIGHT + PANEL_EST_PADDING * 2;
58
+ return {
59
+ h:
60
+ itemRect.right + SUBMENU_EST_WIDTH <= winW ? 'subOpenRight' : 'subOpenLeft',
61
+ v: itemRect.top + submenuH <= winH ? 'subAlignTop' : 'subAlignBottom',
62
+ };
63
+ };
64
+
65
+ type RootPanelProps = {
66
+ isRoot: true;
67
+ panelRef: React.RefObject<HTMLDivElement | null>;
68
+ position: RootPosition | null;
69
+ items: MenuItem[];
70
+ title?: string;
71
+ onCloseAll: () => void;
72
+ };
73
+
74
+ type SubPanelProps = {
75
+ isRoot: false;
76
+ placement: SubmenuPlacement;
77
+ items: MenuItem[];
78
+ onCloseAll: () => void;
79
+ };
80
+
81
+ type PanelProps = RootPanelProps | SubPanelProps;
82
+
83
+ const Panel = (props: PanelProps) => {
84
+ const { items, onCloseAll } = props;
85
+ const titleId = useId();
86
+ const [submenu, setSubmenu] = useState<{
87
+ index: number;
88
+ placement: SubmenuPlacement;
89
+ } | null>(null);
90
+
91
+ const handleBranchEnter = (
92
+ e: React.MouseEvent<HTMLDivElement>,
93
+ i: number,
94
+ branchItems: MenuItem[],
95
+ ) => {
96
+ const rect = e.currentTarget.getBoundingClientRect();
97
+ setSubmenu({
98
+ index: i,
99
+ placement: computeSubmenuPlacement(rect, branchItems.length),
100
+ });
101
+ };
102
+
103
+ const rootStyle: CSSProperties | undefined = props.isRoot
104
+ ? props.position
105
+ ? { top: props.position.top, left: props.position.left }
106
+ : { top: 0, left: 0, visibility: 'hidden' }
107
+ : undefined;
108
+
109
+ const className = props.isRoot
110
+ ? cx(styles.panel, styles.panelRoot)
111
+ : cx(
112
+ styles.panel,
113
+ styles.panelSubmenu,
114
+ styles[props.placement.h],
115
+ styles[props.placement.v],
116
+ );
117
+
118
+ return (
119
+ <div
120
+ ref={props.isRoot ? props.panelRef : undefined}
121
+ className={className}
122
+ style={rootStyle}
123
+ role="menu"
124
+ aria-labelledby={props.isRoot && props.title ? titleId : undefined}
125
+ >
126
+ {props.isRoot && props.title && (
127
+ <div id={titleId} className={styles.title}>
128
+ {props.title}
129
+ </div>
130
+ )}
131
+ {items.map((item, i) => {
132
+ const branch = isBranch(item);
133
+ return (
134
+ <div key={i} className={styles.itemWrapper}>
135
+ {branch ? (
136
+ <>
137
+ <div
138
+ className={cx(styles.item, styles.branchItem)}
139
+ role="menuitem"
140
+ aria-haspopup="menu"
141
+ aria-expanded={submenu?.index === i}
142
+ onMouseEnter={(e) => handleBranchEnter(e, i, item.items)}
143
+ >
144
+ <span>{item.label}</span>
145
+ <span className={styles.caret}>▸</span>
146
+ </div>
147
+ {submenu?.index === i && (
148
+ <Panel
149
+ isRoot={false}
150
+ placement={submenu.placement}
151
+ items={item.items}
152
+ onCloseAll={onCloseAll}
153
+ />
154
+ )}
155
+ </>
156
+ ) : (
157
+ <button
158
+ type="button"
159
+ className={cx(
160
+ styles.item,
161
+ item.isDisabled && styles.itemDisabled,
162
+ )}
163
+ role="menuitem"
164
+ aria-checked={item.isActive ? true : undefined}
165
+ aria-disabled={item.isDisabled ? true : undefined}
166
+ disabled={item.isDisabled}
167
+ onMouseEnter={() => setSubmenu(null)}
168
+ onClick={() => {
169
+ if (item.isDisabled) return;
170
+ item.onSelect();
171
+ onCloseAll();
172
+ }}
173
+ >
174
+ <span>{item.label}</span>
175
+ {item.isActive && <span className={styles.check}>✓</span>}
176
+ </button>
177
+ )}
178
+ </div>
179
+ );
180
+ })}
181
+ </div>
182
+ );
183
+ };
184
+
185
+ export const Menu = (props: MenuProps) => {
186
+ const { trigger, ariaLabel, items, title } = props;
187
+
188
+ const [open, setOpen] = useState(false);
189
+ const [position, setPosition] = useState<RootPosition | null>(null);
190
+ const triggerRef = useRef<HTMLButtonElement>(null);
191
+ const panelRef = useRef<HTMLDivElement>(null);
192
+
193
+ const handleToggle = () => setOpen((v) => !v);
194
+
195
+ useLayoutEffect(() => {
196
+ if (!open) {
197
+ setPosition(null);
198
+ return;
199
+ }
200
+ const trig = triggerRef.current;
201
+ const panel = panelRef.current;
202
+ if (!trig || !panel) return;
203
+ setPosition(
204
+ computeRootPosition(trig.getBoundingClientRect(), panel.getBoundingClientRect()),
205
+ );
206
+ }, [open]);
207
+
208
+ useEffect(() => {
209
+ if (!open) return;
210
+
211
+ const handleMouseDown = (e: MouseEvent) => {
212
+ const target = e.target as Node;
213
+ if (triggerRef.current?.contains(target)) return;
214
+ if (panelRef.current?.contains(target)) return;
215
+ setOpen(false);
216
+ };
217
+
218
+ const handleKey = (e: KeyboardEvent) => {
219
+ if (e.key === 'Escape') setOpen(false);
220
+ };
221
+
222
+ window.addEventListener('mousedown', handleMouseDown);
223
+ window.addEventListener('keydown', handleKey);
224
+ return () => {
225
+ window.removeEventListener('mousedown', handleMouseDown);
226
+ window.removeEventListener('keydown', handleKey);
227
+ };
228
+ }, [open]);
229
+
230
+ return (
231
+ <>
232
+ <button
233
+ ref={triggerRef}
234
+ type="button"
235
+ className={styles.trigger}
236
+ aria-label={ariaLabel}
237
+ aria-haspopup="menu"
238
+ aria-expanded={open}
239
+ onClick={handleToggle}
240
+ >
241
+ {trigger}
242
+ </button>
243
+ {open &&
244
+ createPortal(
245
+ <Panel
246
+ isRoot
247
+ panelRef={panelRef}
248
+ position={position}
249
+ items={items}
250
+ title={title}
251
+ onCloseAll={() => setOpen(false)}
252
+ />,
253
+ document.body,
254
+ )}
255
+ </>
256
+ );
257
+ };
258
+
259
+ export type { MenuProps };