@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,175 @@
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 { PopoverPlacement, PopoverProps } from './types';
11
+ import { cx } from '../../../utils';
12
+ import styles from './styles.module.css';
13
+
14
+ const GAP = 6;
15
+
16
+ type Position = {
17
+ top: number;
18
+ left: number;
19
+ /** Side of the panel that's closest to the anchor — used as transform-origin
20
+ * so the open animation grows *from* the trigger, not from panel center. */
21
+ originV: 'top' | 'bottom';
22
+ originH: 'left' | 'right';
23
+ };
24
+
25
+ const computePosition = (
26
+ anchor: DOMRect,
27
+ panel: DOMRect,
28
+ preferred: PopoverPlacement,
29
+ ): Position => {
30
+ const winW = window.innerWidth;
31
+ const winH = window.innerHeight;
32
+
33
+ const preferBottom =
34
+ preferred === 'auto'
35
+ ? winH - anchor.bottom >= panel.height + GAP ||
36
+ winH - anchor.bottom >= anchor.top
37
+ : preferred.startsWith('bottom');
38
+
39
+ const preferStart =
40
+ preferred === 'auto'
41
+ ? anchor.left + panel.width <= winW
42
+ : preferred.endsWith('-start');
43
+
44
+ const top = preferBottom
45
+ ? anchor.bottom + GAP
46
+ : anchor.top - panel.height - GAP;
47
+ const left = preferStart ? anchor.left : anchor.right - panel.width;
48
+
49
+ // 8px viewport margin keeps a near-edge anchor from pushing the panel off.
50
+ const clampedLeft = Math.max(8, Math.min(left, winW - panel.width - 8));
51
+ const clampedTop = Math.max(8, Math.min(top, winH - panel.height - 8));
52
+
53
+ return {
54
+ top: clampedTop,
55
+ left: clampedLeft,
56
+ originV: preferBottom ? 'top' : 'bottom',
57
+ originH: preferStart ? 'left' : 'right',
58
+ };
59
+ };
60
+
61
+ export const Popover = (props: PopoverProps) => {
62
+ const {
63
+ anchorRef,
64
+ isOpen,
65
+ onClose,
66
+ title,
67
+ actions,
68
+ ariaLabel,
69
+ placement = 'auto',
70
+ matchAnchorWidth,
71
+ hasBodyPadding = true,
72
+ children,
73
+ } = props;
74
+
75
+ const panelRef = useRef<HTMLDivElement>(null);
76
+ const restoreFocusRef = useRef<HTMLElement | null>(null);
77
+ const [position, setPosition] = useState<Position | null>(null);
78
+ const titleId = useId();
79
+
80
+ useLayoutEffect(() => {
81
+ if (!isOpen) {
82
+ setPosition(null);
83
+ return;
84
+ }
85
+ const anchor = anchorRef.current;
86
+ const panel = panelRef.current;
87
+ if (!anchor || !panel) return;
88
+
89
+ const anchorRect = anchor.getBoundingClientRect();
90
+
91
+ // Apply the anchor width *before* measuring the panel rect so the panel's
92
+ // height reflects the final width (content may wrap differently).
93
+ if (matchAnchorWidth) {
94
+ panel.style.width = `${anchorRect.width}px`;
95
+ panel.style.minWidth = `${anchorRect.width}px`;
96
+ }
97
+
98
+ setPosition(
99
+ computePosition(anchorRect, panel.getBoundingClientRect(), placement),
100
+ );
101
+ }, [isOpen, anchorRef, placement, matchAnchorWidth]);
102
+
103
+ useEffect(() => {
104
+ if (!isOpen) return;
105
+
106
+ const handleMouseDown = (e: MouseEvent) => {
107
+ const target = e.target as Node;
108
+ if (panelRef.current?.contains(target)) return;
109
+ if (anchorRef.current?.contains(target)) return;
110
+ onClose();
111
+ };
112
+
113
+ const handleKey = (e: KeyboardEvent) => {
114
+ if (e.key === 'Escape') onClose();
115
+ };
116
+
117
+ window.addEventListener('mousedown', handleMouseDown);
118
+ window.addEventListener('keydown', handleKey);
119
+ return () => {
120
+ window.removeEventListener('mousedown', handleMouseDown);
121
+ window.removeEventListener('keydown', handleKey);
122
+ };
123
+ }, [isOpen, anchorRef, onClose]);
124
+
125
+ // Capture the anchor at open time so a rerender that swaps the element
126
+ // still restores focus to whatever the user originally engaged.
127
+ useEffect(() => {
128
+ if (isOpen) {
129
+ restoreFocusRef.current = anchorRef.current;
130
+ return;
131
+ }
132
+ if (restoreFocusRef.current) {
133
+ restoreFocusRef.current.focus();
134
+ restoreFocusRef.current = null;
135
+ }
136
+ }, [isOpen, anchorRef]);
137
+
138
+ if (!isOpen) return null;
139
+
140
+ const style: CSSProperties = position
141
+ ? {
142
+ top: position.top,
143
+ left: position.left,
144
+ transformOrigin: `${position.originV} ${position.originH}`,
145
+ }
146
+ : { top: 0, left: 0, visibility: 'hidden' };
147
+
148
+ // Portal to <body> so we sit at the same DOM depth as every other layer
149
+ // (modal, future tooltip/menu when they get layered). Stacking falls out
150
+ // of mount order — the most-recently-opened layer is the last child of
151
+ // <body> and paints on top, no z-index tier required.
152
+ return createPortal(
153
+ <div
154
+ ref={panelRef}
155
+ className={styles.panel}
156
+ role="dialog"
157
+ aria-labelledby={title ? titleId : undefined}
158
+ aria-label={!title ? ariaLabel : undefined}
159
+ style={style}
160
+ >
161
+ {title && (
162
+ <div id={titleId} className={styles.title}>
163
+ {title}
164
+ </div>
165
+ )}
166
+ <div className={cx(styles.body, !hasBodyPadding && styles.bodyFlush)}>
167
+ {children}
168
+ </div>
169
+ {actions && <div className={styles.actions}>{actions}</div>}
170
+ </div>,
171
+ document.body,
172
+ );
173
+ };
174
+
175
+ export type { PopoverProps, PopoverPlacement };
@@ -0,0 +1,59 @@
1
+ .panel {
2
+ position: fixed;
3
+ /* Same z-index as ModalShell's .layer — all portal layers share one tier
4
+ * and rely on DOM-append order for stacking. The most-recently-opened
5
+ * layer is the last child of <body> and paints on top. */
6
+ z-index: 100;
7
+ min-width: 18rem;
8
+ max-width: min(480px, calc(100vw - 32px));
9
+ background-color: var(--ui-background-0);
10
+ border: 1px solid var(--ui-border);
11
+ border-radius: var(--ui-radius);
12
+ box-shadow: 0 4px 16px var(--ui-shadow-soft);
13
+ display: flex;
14
+ flex-direction: column;
15
+ /* Clip the bg-1 title/actions strips to the panel's rounded corners. */
16
+ overflow: hidden;
17
+ /* transform-origin is set inline from the computed placement so the panel
18
+ grows from the side closest to the anchor. */
19
+ animation: popoverIn 110ms ease-out;
20
+ }
21
+
22
+ @keyframes popoverIn {
23
+ from {
24
+ opacity: 0;
25
+ transform: scale(0.96);
26
+ }
27
+ to {
28
+ opacity: 1;
29
+ transform: scale(1);
30
+ }
31
+ }
32
+
33
+ .title {
34
+ padding: var(--ui-space-1) var(--ui-space-3);
35
+ border-bottom: 1px solid var(--ui-border-subtle);
36
+ background-color: var(--ui-background-1);
37
+ font-size: var(--ui-text-xsmall);
38
+ font-weight: 600;
39
+ color: var(--ui-foreground);
40
+ user-select: none;
41
+ white-space: nowrap;
42
+ }
43
+
44
+ .body {
45
+ padding: var(--ui-space-3);
46
+ }
47
+
48
+ .bodyFlush {
49
+ padding: 0;
50
+ }
51
+
52
+ .actions {
53
+ display: flex;
54
+ justify-content: flex-end;
55
+ gap: var(--ui-space-2);
56
+ padding: var(--ui-space-1) var(--ui-space-3);
57
+ border-top: 1px solid var(--ui-border-subtle);
58
+ background-color: var(--ui-background-1);
59
+ }
@@ -0,0 +1,34 @@
1
+ import type { ReactNode, RefObject } from 'react';
2
+
3
+ export type PopoverPlacement =
4
+ | 'auto'
5
+ | 'bottom-start'
6
+ | 'bottom-end'
7
+ | 'top-start'
8
+ | 'top-end';
9
+
10
+ export type PopoverProps = {
11
+ /** Ref of the element the popover anchors to. Popover reads its rect on open. */
12
+ anchorRef: RefObject<HTMLElement | null>;
13
+ /** Visibility — controlled by the caller. */
14
+ isOpen: boolean;
15
+ /** Fires on outside click, Escape, or when the anchor unmounts. */
16
+ onClose: () => void;
17
+ /** Optional title bar — small muted label above the body. Also becomes the
18
+ * accessible name when `ariaLabel` isn't provided. */
19
+ title?: string;
20
+ /** Optional actions bar — right-aligned flex row below the body. Typically
21
+ * a Cancel + Save pair for inline-form popovers. */
22
+ actions?: ReactNode;
23
+ /** Accessible name. Optional when `title` is provided (title labels the dialog). */
24
+ ariaLabel?: string;
25
+ /** Preferred placement relative to the anchor. `auto` picks the side with more room. */
26
+ placement?: PopoverPlacement;
27
+ /** If true, the panel's width is pinned to the anchor's width. Disables the
28
+ * default min-width. Used by select-style triggers so the menu aligns flush. */
29
+ matchAnchorWidth?: boolean;
30
+ /** Defaults to true. Set false for edge-to-edge body content like option lists. */
31
+ hasBodyPadding?: boolean;
32
+ /** Popover body contents — typically a small form or inline editor. */
33
+ children: ReactNode;
34
+ };
@@ -0,0 +1,41 @@
1
+ import type { Meta, StoryObj } from '@storybook/react';
2
+ import { Tooltip } from './index';
3
+ import type { TooltipPlacement } from './types';
4
+ import { Button } from '../../Forms/Button';
5
+
6
+ const meta: Meta<typeof Tooltip> = {
7
+ title: 'Overlays/Tooltip',
8
+ component: Tooltip,
9
+ decorators: [
10
+ (Story) => (
11
+ <div style={{ padding: 80 }}>
12
+ <Story />
13
+ </div>
14
+ ),
15
+ ],
16
+ };
17
+
18
+ export default meta;
19
+ type Story = StoryObj<typeof Tooltip>;
20
+
21
+ export const Default: Story = {
22
+ render: () => (
23
+ <Tooltip label="Hello, tooltip">
24
+ <Button>Hover or focus me</Button>
25
+ </Tooltip>
26
+ ),
27
+ };
28
+
29
+ const PLACEMENTS: TooltipPlacement[] = ['top', 'bottom', 'left', 'right'];
30
+
31
+ export const Placements: Story = {
32
+ render: () => (
33
+ <div style={{ display: 'flex', gap: 32, flexWrap: 'wrap' }}>
34
+ {PLACEMENTS.map((placement) => (
35
+ <Tooltip key={placement} label={`Placement: ${placement}`} placement={placement}>
36
+ <Button>{placement}</Button>
37
+ </Tooltip>
38
+ ))}
39
+ </div>
40
+ ),
41
+ };
@@ -0,0 +1,115 @@
1
+ import {
2
+ useEffect,
3
+ useLayoutEffect,
4
+ useRef,
5
+ useState,
6
+ type CSSProperties,
7
+ } from 'react';
8
+ import { createPortal } from 'react-dom';
9
+ import type { TooltipPlacement, TooltipProps } from './types';
10
+ import styles from './styles.module.css';
11
+
12
+ const ENTER_DELAY_MS = 400;
13
+ const GAP = 4;
14
+
15
+ type Position = { top: number; left: number };
16
+
17
+ const computePosition = (
18
+ host: DOMRect,
19
+ tip: DOMRect,
20
+ placement: TooltipPlacement,
21
+ ): Position => {
22
+ switch (placement) {
23
+ case 'top':
24
+ return {
25
+ top: host.top - tip.height - GAP,
26
+ left: host.left + (host.width - tip.width) / 2,
27
+ };
28
+ case 'bottom':
29
+ return {
30
+ top: host.bottom + GAP,
31
+ left: host.left + (host.width - tip.width) / 2,
32
+ };
33
+ case 'left':
34
+ return {
35
+ top: host.top + (host.height - tip.height) / 2,
36
+ left: host.left - tip.width - GAP,
37
+ };
38
+ case 'right':
39
+ return {
40
+ top: host.top + (host.height - tip.height) / 2,
41
+ left: host.right + GAP,
42
+ };
43
+ }
44
+ };
45
+
46
+ export const Tooltip = (props: TooltipProps) => {
47
+ const { label, placement = 'top', children } = props;
48
+
49
+ const hostRef = useRef<HTMLSpanElement>(null);
50
+ const tipRef = useRef<HTMLSpanElement>(null);
51
+ const enterTimer = useRef<number | null>(null);
52
+
53
+ const [isVisible, setIsVisible] = useState(false);
54
+ const [position, setPosition] = useState<Position | null>(null);
55
+
56
+ const open = () => {
57
+ if (enterTimer.current != null) return;
58
+ enterTimer.current = window.setTimeout(() => {
59
+ enterTimer.current = null;
60
+ setIsVisible(true);
61
+ }, ENTER_DELAY_MS);
62
+ };
63
+
64
+ const close = () => {
65
+ if (enterTimer.current != null) {
66
+ clearTimeout(enterTimer.current);
67
+ enterTimer.current = null;
68
+ }
69
+ setIsVisible(false);
70
+ setPosition(null);
71
+ };
72
+
73
+ useEffect(
74
+ () => () => {
75
+ if (enterTimer.current != null) clearTimeout(enterTimer.current);
76
+ },
77
+ [],
78
+ );
79
+
80
+ useLayoutEffect(() => {
81
+ if (!isVisible) return;
82
+ const host = hostRef.current;
83
+ const tip = tipRef.current;
84
+ if (!host || !tip) return;
85
+ setPosition(
86
+ computePosition(host.getBoundingClientRect(), tip.getBoundingClientRect(), placement),
87
+ );
88
+ }, [isVisible, placement, label]);
89
+
90
+ const tipStyle: CSSProperties = position
91
+ ? { top: position.top, left: position.left }
92
+ : { top: 0, left: 0, visibility: 'hidden' };
93
+
94
+ return (
95
+ <span
96
+ ref={hostRef}
97
+ className={styles.host}
98
+ onMouseEnter={open}
99
+ onMouseLeave={close}
100
+ onFocus={open}
101
+ onBlur={close}
102
+ >
103
+ {children}
104
+ {isVisible &&
105
+ createPortal(
106
+ <span ref={tipRef} role="tooltip" className={styles.tip} style={tipStyle}>
107
+ {label}
108
+ </span>,
109
+ document.body,
110
+ )}
111
+ </span>
112
+ );
113
+ };
114
+
115
+ export type { TooltipProps, TooltipPlacement };
@@ -0,0 +1,25 @@
1
+ .host {
2
+ position: relative;
3
+ display: inline-flex;
4
+ /* Don't stretch to fill a flex/grid parent's cross axis — the JS that
5
+ * positions the tip measures this span and would center on the stretched
6
+ * width. Block parents ignore align-self, so this is harmless there. */
7
+ align-self: start;
8
+ width: fit-content;
9
+ }
10
+
11
+ .tip {
12
+ position: fixed;
13
+ /* Same z-index as every other portal layer (Modal, Popover, Menu). Stacking
14
+ * order between layers comes from DOM-append order, not z-index. */
15
+ z-index: 100;
16
+ pointer-events: none;
17
+ padding: var(--ui-space-1) var(--ui-space-2);
18
+ background-color: var(--ui-foreground);
19
+ color: var(--ui-background-0);
20
+ font-family: var(--ui-font);
21
+ font-size: var(--ui-text-small);
22
+ line-height: 1.2;
23
+ border-radius: var(--ui-radius);
24
+ white-space: nowrap;
25
+ }
@@ -0,0 +1,15 @@
1
+ import type { ReactNode } from 'react';
2
+
3
+ export type TooltipPlacement = 'top' | 'bottom' | 'left' | 'right';
4
+
5
+ export type TooltipProps = {
6
+ /** Tooltip text shown on hover/focus of the trigger. */
7
+ label: string;
8
+ /** Side of the trigger to render on. Defaults to `'top'`. */
9
+ placement?: TooltipPlacement;
10
+ /** The trigger element. Should be a single focusable element so that
11
+ * `:focus-within` reveal works. The tooltip text is purely visual —
12
+ * triggers should still carry their own accessible name (e.g. an
13
+ * `aria-label` on an IconButton). */
14
+ children: ReactNode;
15
+ };
@@ -0,0 +1,18 @@
1
+ import type { Meta, StoryObj } from '@storybook/react';
2
+ import { EmptyValue } from './index';
3
+
4
+ const meta: Meta<typeof EmptyValue> = {
5
+ title: 'Primitives/EmptyValue',
6
+ component: EmptyValue,
7
+ };
8
+
9
+ export default meta;
10
+ type Story = StoryObj<typeof EmptyValue>;
11
+
12
+ export const Default: Story = {
13
+ render: () => (
14
+ <div style={{ padding: 24 }}>
15
+ Last login: <EmptyValue />
16
+ </div>
17
+ ),
18
+ };
@@ -0,0 +1,3 @@
1
+ import styles from './styles.module.css';
2
+
3
+ export const EmptyValue = () => <span className={styles.dash}>—</span>;
@@ -0,0 +1,3 @@
1
+ .dash {
2
+ color: var(--ui-muted);
3
+ }
@@ -0,0 +1,101 @@
1
+ import type { Meta, StoryObj } from '@storybook/react';
2
+ import { LinedStack } from './index';
3
+ import { Text } from '../../Content/Text';
4
+
5
+ const meta: Meta<typeof LinedStack> = {
6
+ title: 'Primitives/LinedStack',
7
+ component: LinedStack,
8
+ };
9
+
10
+ export default meta;
11
+ type Story = StoryObj<typeof LinedStack>;
12
+
13
+ const wrap = { padding: 24, display: 'flex', gap: 32 };
14
+
15
+ export const Plain: Story = {
16
+ name: 'Plain — non-interactive',
17
+ render: () => (
18
+ <div style={wrap}>
19
+ <LinedStack>
20
+ <strong>Alice Park</strong>
21
+ <Text isMuted size="xsmall">
22
+ alice.park@example.com
23
+ </Text>
24
+ </LinedStack>
25
+ </div>
26
+ ),
27
+ };
28
+
29
+ export const AsRouterLink: Story = {
30
+ name: 'As router link — `to` prop',
31
+ render: () => (
32
+ <div style={wrap}>
33
+ <LinedStack to="/users/usr_1234">
34
+ <strong>Alice Park</strong>
35
+ <Text isMuted size="xsmall">
36
+ alice.park@example.com
37
+ </Text>
38
+ </LinedStack>
39
+ </div>
40
+ ),
41
+ };
42
+
43
+ export const AsAnchor: Story = {
44
+ name: 'As anchor — `href` prop (typically external)',
45
+ render: () => (
46
+ <div style={wrap}>
47
+ <LinedStack href="https://example.com/u/alice" isExternal>
48
+ <strong>Alice Park</strong>
49
+ <Text isMuted size="xsmall">
50
+ example.com/u/alice
51
+ </Text>
52
+ </LinedStack>
53
+ </div>
54
+ ),
55
+ };
56
+
57
+ export const AsButton: Story = {
58
+ name: 'As button — `onClick` prop',
59
+ render: () => (
60
+ <div style={wrap}>
61
+ <LinedStack onClick={() => alert('clicked')}>
62
+ <strong>Alice Park</strong>
63
+ <Text isMuted size="xsmall">
64
+ alice.park@example.com
65
+ </Text>
66
+ </LinedStack>
67
+ </div>
68
+ ),
69
+ };
70
+
71
+ export const Mixed: Story = {
72
+ name: 'Side-by-side: plain, router link, anchor, button',
73
+ render: () => (
74
+ <div style={wrap}>
75
+ <LinedStack>
76
+ <strong>Plain</strong>
77
+ <Text isMuted size="xsmall">
78
+ no interactivity
79
+ </Text>
80
+ </LinedStack>
81
+ <LinedStack to="/users/usr_1234">
82
+ <strong>Router Link</strong>
83
+ <Text isMuted size="xsmall">
84
+ renders &lt;Link to&gt;
85
+ </Text>
86
+ </LinedStack>
87
+ <LinedStack href="https://example.com" isExternal>
88
+ <strong>Anchor</strong>
89
+ <Text isMuted size="xsmall">
90
+ renders &lt;a href&gt;
91
+ </Text>
92
+ </LinedStack>
93
+ <LinedStack onClick={() => alert('clicked')}>
94
+ <strong>Button</strong>
95
+ <Text isMuted size="xsmall">
96
+ renders &lt;button&gt;
97
+ </Text>
98
+ </LinedStack>
99
+ </div>
100
+ ),
101
+ };
@@ -0,0 +1,41 @@
1
+ import { Link as RouterLink } from 'react-router';
2
+ import { cx } from '../../../utils';
3
+ import type { LinedStackProps } from './types';
4
+ import styles from './styles.module.css';
5
+
6
+ export const LinedStack = (props: LinedStackProps) => {
7
+ const { children, to, href, isExternal, onClick } = props;
8
+
9
+ if (to != null) {
10
+ return (
11
+ <RouterLink to={to} className={cx(styles.stack, styles.interactive)}>
12
+ {children}
13
+ </RouterLink>
14
+ );
15
+ }
16
+
17
+ if (href != null) {
18
+ return (
19
+ <a
20
+ href={href}
21
+ target={isExternal ? '_blank' : undefined}
22
+ rel={isExternal ? 'noopener noreferrer' : undefined}
23
+ className={cx(styles.stack, styles.interactive)}
24
+ >
25
+ {children}
26
+ </a>
27
+ );
28
+ }
29
+
30
+ if (onClick != null) {
31
+ return (
32
+ <button type="button" onClick={onClick} className={cx(styles.stack, styles.interactive)}>
33
+ {children}
34
+ </button>
35
+ );
36
+ }
37
+
38
+ return <div className={styles.stack}>{children}</div>;
39
+ };
40
+
41
+ export type { LinedStackProps };
@@ -0,0 +1,27 @@
1
+ .stack {
2
+ display: flex;
3
+ flex-direction: column;
4
+ align-items: flex-start;
5
+ }
6
+
7
+ .interactive {
8
+ cursor: pointer;
9
+ text-decoration: none;
10
+ color: inherit;
11
+ background: none;
12
+ border: none;
13
+ padding: 0;
14
+ font: inherit;
15
+ text-align: left;
16
+ border-radius: var(--ui-radius);
17
+ transition: background-color 0.12s;
18
+ }
19
+
20
+ .interactive:hover {
21
+ background-color: var(--ui-background-1);
22
+ }
23
+
24
+ .interactive:focus-visible {
25
+ outline: 2px solid var(--ui-accent);
26
+ outline-offset: 2px;
27
+ }