@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,49 @@
1
+ import type { ReactNode } from 'react';
2
+
3
+ type Common = {
4
+ children: ReactNode;
5
+ };
6
+
7
+ type Plain = {
8
+ to?: never;
9
+ href?: never;
10
+ isExternal?: never;
11
+ onClick?: never;
12
+ };
13
+
14
+ type AsRouterLink = {
15
+ /** In-app navigation. Renders as a React Router `<Link to={to}>`. */
16
+ to: string;
17
+ href?: never;
18
+ isExternal?: never;
19
+ onClick?: never;
20
+ };
21
+
22
+ type AsAnchor = {
23
+ /** External / out-of-app navigation. Renders as a plain `<a href={href}>`. */
24
+ href: string;
25
+ /** With `href`, opens in a new tab via `target="_blank" rel="noopener noreferrer"`. */
26
+ isExternal?: boolean;
27
+ to?: never;
28
+ onClick?: never;
29
+ };
30
+
31
+ type AsButton = {
32
+ to?: never;
33
+ href?: never;
34
+ isExternal?: never;
35
+ /** Renders as a `<button>`. */
36
+ onClick: () => void;
37
+ };
38
+
39
+ /**
40
+ * Provide at most one of `to` (router link), `href` (plain `<a>`), or
41
+ * `onClick` (button). With none, renders a plain non-interactive `<div>`.
42
+ *
43
+ * Implemented as a discriminated union so TypeScript rejects combinations
44
+ * — but the resulting error can be confusing (it surfaces as a multi-variant
45
+ * mismatch rather than a single targeted message). If you see a strange
46
+ * type error on `LinedStack`, the first thing to check is whether you've
47
+ * passed more than one of `to` / `href` / `onClick`.
48
+ */
49
+ export type LinedStackProps = Common & (Plain | AsRouterLink | AsAnchor | AsButton);
@@ -0,0 +1,72 @@
1
+ import type { Meta, StoryObj } from '@storybook/react';
2
+ import { LongText } from './index';
3
+
4
+ const meta: Meta<typeof LongText> = {
5
+ title: 'Primitives/LongText',
6
+ component: LongText,
7
+ };
8
+
9
+ export default meta;
10
+ type Story = StoryObj<typeof LongText>;
11
+
12
+ const SHORT = 'A short string.';
13
+ const LONG =
14
+ 'This is a much longer string that will exceed the width of its container, ' +
15
+ 'forcing LongText to render with an ellipsis. Click (or focus + Enter) to ' +
16
+ 'open a popover with the full content. The popover wraps and is selectable.';
17
+
18
+ const Cell = ({ children }: { children: React.ReactNode }) => (
19
+ <div
20
+ style={{
21
+ width: 220,
22
+ border: '1px solid var(--ui-border-subtle)',
23
+ padding: 8,
24
+ borderRadius: 'var(--ui-radius)',
25
+ background: 'var(--ui-background-0)',
26
+ }}
27
+ >
28
+ {children}
29
+ </div>
30
+ );
31
+
32
+ export const InsideFixedWidth: Story = {
33
+ name: 'Inside a fixed-width container',
34
+ render: () => (
35
+ <div style={{ padding: 24, display: 'flex', flexDirection: 'column', gap: 12 }}>
36
+ <Cell>
37
+ <LongText>{SHORT}</LongText>
38
+ </Cell>
39
+ <Cell>
40
+ <LongText>{LONG}</LongText>
41
+ </Cell>
42
+ <Cell>
43
+ <LongText>{null}</LongText>
44
+ </Cell>
45
+ </div>
46
+ ),
47
+ };
48
+
49
+ export const ResizesLive: Story = {
50
+ name: 'Resize handle — interactivity tracks overflow state',
51
+ render: () => (
52
+ <div style={{ padding: 24 }}>
53
+ <p style={{ color: 'var(--ui-muted)', fontSize: 'var(--ui-text-small)' }}>
54
+ Drag the bottom-right corner to resize. The component becomes interactive only when the
55
+ text overflows.
56
+ </p>
57
+ <div
58
+ style={{
59
+ width: 320,
60
+ resize: 'horizontal',
61
+ overflow: 'auto',
62
+ border: '1px dashed var(--ui-border)',
63
+ padding: 8,
64
+ borderRadius: 'var(--ui-radius)',
65
+ background: 'var(--ui-background-0)',
66
+ }}
67
+ >
68
+ <LongText>{LONG}</LongText>
69
+ </div>
70
+ </div>
71
+ ),
72
+ };
@@ -0,0 +1,67 @@
1
+ import { useLayoutEffect, useRef, useState } from 'react';
2
+ import type { KeyboardEvent } from 'react';
3
+ import { Popover } from '../../Overlays/Popover';
4
+ import { EmptyValue } from '../EmptyValue';
5
+ import { cx } from '../../../utils';
6
+ import type { LongTextProps } from './types';
7
+ import styles from './styles.module.css';
8
+
9
+ export const LongText = (props: LongTextProps) => {
10
+ const { children } = props;
11
+
12
+ const triggerRef = useRef<HTMLSpanElement>(null);
13
+ const [isOpen, setIsOpen] = useState(false);
14
+ const [isOverflowing, setIsOverflowing] = useState(false);
15
+
16
+ useLayoutEffect(() => {
17
+ const el = triggerRef.current;
18
+ if (!el) return;
19
+ const check = () => setIsOverflowing(el.scrollWidth > el.clientWidth + 1);
20
+ check();
21
+ const ro = new ResizeObserver(check);
22
+ ro.observe(el);
23
+ return () => ro.disconnect();
24
+ }, [children]);
25
+
26
+ if (children == null || children === '') return <EmptyValue />;
27
+
28
+ const onKeyDown = (e: KeyboardEvent<HTMLSpanElement>) => {
29
+ if (e.key === 'Enter' || e.key === ' ') {
30
+ e.preventDefault();
31
+ setIsOpen(true);
32
+ }
33
+ };
34
+
35
+ const interactive = isOverflowing
36
+ ? {
37
+ role: 'button' as const,
38
+ tabIndex: 0,
39
+ onClick: () => setIsOpen(true),
40
+ onKeyDown,
41
+ }
42
+ : {};
43
+
44
+ return (
45
+ <>
46
+ <span
47
+ ref={triggerRef}
48
+ className={cx(styles.text, isOverflowing && styles.interactive)}
49
+ {...interactive}
50
+ >
51
+ {children}
52
+ </span>
53
+ {isOpen ? (
54
+ <Popover
55
+ anchorRef={triggerRef}
56
+ isOpen={isOpen}
57
+ onClose={() => setIsOpen(false)}
58
+ ariaLabel="Full text"
59
+ >
60
+ <div className={styles.fullText}>{children}</div>
61
+ </Popover>
62
+ ) : null}
63
+ </>
64
+ );
65
+ };
66
+
67
+ export type { LongTextProps };
@@ -0,0 +1,30 @@
1
+ .text {
2
+ display: block;
3
+ overflow: hidden;
4
+ text-overflow: ellipsis;
5
+ white-space: nowrap;
6
+ max-width: 100%;
7
+ border-radius: var(--ui-radius);
8
+ }
9
+
10
+ .interactive {
11
+ cursor: pointer;
12
+ }
13
+
14
+ .interactive:hover {
15
+ background-color: var(--ui-background-1);
16
+ }
17
+
18
+ .interactive:focus-visible {
19
+ outline: 2px solid var(--ui-accent);
20
+ outline-offset: 1px;
21
+ }
22
+
23
+ .fullText {
24
+ white-space: pre-wrap;
25
+ word-break: break-word;
26
+ max-width: 480px;
27
+ font-size: var(--ui-text-small);
28
+ line-height: var(--ui-line-height);
29
+ color: var(--ui-foreground);
30
+ }
@@ -0,0 +1,4 @@
1
+ export type LongTextProps = {
2
+ /** The full text. Renders truncated; opens a popover with the full content when truncated. */
3
+ children: string | null | undefined;
4
+ };
@@ -0,0 +1,51 @@
1
+ import type { Meta, StoryObj } from '@storybook/react';
2
+ import { Num } from './index';
3
+
4
+ const meta: Meta<typeof Num> = {
5
+ title: 'Primitives/Num',
6
+ component: Num,
7
+ };
8
+
9
+ export default meta;
10
+ type Story = StoryObj<typeof Num>;
11
+
12
+ const Row = ({ label, children }: { label: string; children: React.ReactNode }) => (
13
+ <div style={{ display: 'flex', gap: 16, padding: '4px 0' }}>
14
+ <span style={{ width: 220, color: 'var(--ui-muted)' }}>{label}</span>
15
+ <span>{children}</span>
16
+ </div>
17
+ );
18
+
19
+ export const Variants: Story = {
20
+ render: () => (
21
+ <div style={{ padding: 24, fontFamily: 'var(--ui-font)' }}>
22
+ <Row label="decimal (default)">
23
+ <Num value={1234567.89} />
24
+ </Row>
25
+ <Row label="integer">
26
+ <Num value={1234567.89} as="integer" />
27
+ </Row>
28
+ <Row label="currency (USD default)">
29
+ <Num value={1234.5} as="currency" />
30
+ </Row>
31
+ <Row label="currency EUR">
32
+ <Num value={1234.5} as="currency" currency="EUR" />
33
+ </Row>
34
+ <Row label="compact (1234)">
35
+ <Num value={1234} as="compact" />
36
+ </Row>
37
+ <Row label="compact (3_400_000)">
38
+ <Num value={3_400_000} as="compact" />
39
+ </Row>
40
+ <Row label="null value">
41
+ <Num value={null} />
42
+ </Row>
43
+ <Row label="undefined value">
44
+ <Num value={undefined} />
45
+ </Row>
46
+ <Row label="NaN">
47
+ <Num value={NaN} />
48
+ </Row>
49
+ </div>
50
+ ),
51
+ };
@@ -0,0 +1,37 @@
1
+ import { EmptyValue } from '../EmptyValue';
2
+ import type { NumProps, NumVariant } from './types';
3
+
4
+ const optionsFor = (
5
+ variant: NumVariant,
6
+ precision: number | undefined,
7
+ currency: string,
8
+ ): Intl.NumberFormatOptions => {
9
+ if (variant === 'integer') {
10
+ return { maximumFractionDigits: 0 };
11
+ }
12
+ if (variant === 'currency') {
13
+ return precision != null
14
+ ? {
15
+ style: 'currency',
16
+ currency,
17
+ minimumFractionDigits: precision,
18
+ maximumFractionDigits: precision,
19
+ }
20
+ : { style: 'currency', currency };
21
+ }
22
+ if (variant === 'compact') {
23
+ return { notation: 'compact', maximumFractionDigits: precision ?? 1 };
24
+ }
25
+ return precision != null
26
+ ? { minimumFractionDigits: precision, maximumFractionDigits: precision }
27
+ : {};
28
+ };
29
+
30
+ export const Num = (props: NumProps) => {
31
+ const { value, as = 'decimal', precision, currency = 'USD' } = props;
32
+
33
+ if (value == null || Number.isNaN(value)) return <EmptyValue />;
34
+ return <>{value.toLocaleString(undefined, optionsFor(as, precision, currency))}</>;
35
+ };
36
+
37
+ export type { NumProps, NumVariant };
@@ -0,0 +1,19 @@
1
+ export type NumVariant = 'decimal' | 'integer' | 'currency' | 'compact';
2
+
3
+ export type NumProps = {
4
+ value: number | null | undefined;
5
+ /**
6
+ * Format variant. Defaults to `decimal` (locale-aware separators).
7
+ * - `integer`: rounded to whole, with separators
8
+ * - `currency`: locale currency, defaults to USD
9
+ * - `compact`: 1.2K / 3.4M / 5.6B
10
+ *
11
+ * For percent, use the `Percent` component — its `fractionalValue` prop
12
+ * makes the 0.42-vs-42 input convention obvious at the call site.
13
+ */
14
+ as?: NumVariant;
15
+ /** Override fraction digits. Variant-specific defaults apply when omitted. */
16
+ precision?: number;
17
+ /** ISO currency code for `as='currency'`. Defaults to 'USD'. */
18
+ currency?: string;
19
+ };
@@ -0,0 +1,48 @@
1
+ import type { Meta, StoryObj } from '@storybook/react';
2
+ import { Percent } from './index';
3
+
4
+ const meta: Meta<typeof Percent> = {
5
+ title: 'Primitives/Percent',
6
+ component: Percent,
7
+ };
8
+
9
+ export default meta;
10
+ type Story = StoryObj<typeof Percent>;
11
+
12
+ const Row = ({ label, children }: { label: string; children: React.ReactNode }) => (
13
+ <div style={{ display: 'flex', gap: 16, padding: '4px 0' }}>
14
+ <span style={{ width: 220, color: 'var(--ui-muted)' }}>{label}</span>
15
+ <span>{children}</span>
16
+ </div>
17
+ );
18
+
19
+ export const Variants: Story = {
20
+ render: () => (
21
+ <div style={{ padding: 24, fontFamily: 'var(--ui-font)' }}>
22
+ <Row label="0.42">
23
+ <Percent fractionalValue={0.42} />
24
+ </Row>
25
+ <Row label="0.4256, precision=1">
26
+ <Percent fractionalValue={0.4256} precision={1} />
27
+ </Row>
28
+ <Row label="0.4256, precision=2">
29
+ <Percent fractionalValue={0.4256} precision={2} />
30
+ </Row>
31
+ <Row label="1 (full)">
32
+ <Percent fractionalValue={1} />
33
+ </Row>
34
+ <Row label="0 (none)">
35
+ <Percent fractionalValue={0} />
36
+ </Row>
37
+ <Row label="negative (-0.05)">
38
+ <Percent fractionalValue={-0.05} />
39
+ </Row>
40
+ <Row label="null">
41
+ <Percent fractionalValue={null} />
42
+ </Row>
43
+ <Row label="NaN">
44
+ <Percent fractionalValue={NaN} />
45
+ </Row>
46
+ </div>
47
+ ),
48
+ };
@@ -0,0 +1,15 @@
1
+ import { EmptyValue } from '../EmptyValue';
2
+ import type { PercentProps } from './types';
3
+
4
+ export const Percent = (props: PercentProps) => {
5
+ const { fractionalValue, precision } = props;
6
+
7
+ if (fractionalValue == null || Number.isNaN(fractionalValue)) return <EmptyValue />;
8
+ const options: Intl.NumberFormatOptions =
9
+ precision != null
10
+ ? { style: 'percent', maximumFractionDigits: precision, minimumFractionDigits: precision }
11
+ : { style: 'percent' };
12
+ return <>{fractionalValue.toLocaleString(undefined, options)}</>;
13
+ };
14
+
15
+ export type { PercentProps };
@@ -0,0 +1,10 @@
1
+ export type PercentProps = {
2
+ /**
3
+ * Fractional input — `0.42` renders as `42%`. Named `fractionalValue` (not
4
+ * just `value`) so the convention is obvious at the call site without
5
+ * needing to read the source.
6
+ */
7
+ fractionalValue: number | null | undefined;
8
+ /** Override fraction digits. When omitted, no fixed precision is set. */
9
+ precision?: number;
10
+ };
@@ -0,0 +1,57 @@
1
+ import type { Meta, StoryObj } from '@storybook/react';
2
+ import { RelativeTime } from './index';
3
+
4
+ const meta: Meta<typeof RelativeTime> = {
5
+ title: 'Primitives/RelativeTime',
6
+ component: RelativeTime,
7
+ };
8
+
9
+ export default meta;
10
+ type Story = StoryObj<typeof RelativeTime>;
11
+
12
+ const Row = ({ label, children }: { label: string; children: React.ReactNode }) => (
13
+ <div style={{ display: 'flex', gap: 16, padding: '4px 0' }}>
14
+ <span style={{ width: 220, color: 'var(--ui-muted)' }}>{label}</span>
15
+ <span>{children}</span>
16
+ </div>
17
+ );
18
+
19
+ const ago = (ms: number) => new Date(Date.now() - ms);
20
+ const ahead = (ms: number) => new Date(Date.now() + ms);
21
+
22
+ export const Variants: Story = {
23
+ render: () => (
24
+ <div style={{ padding: 24, fontFamily: 'var(--ui-font)' }}>
25
+ <Row label="seconds ago">
26
+ <RelativeTime value={ago(15_000)} />
27
+ </Row>
28
+ <Row label="minutes ago">
29
+ <RelativeTime value={ago(5 * 60_000)} />
30
+ </Row>
31
+ <Row label="hours ago">
32
+ <RelativeTime value={ago(3 * 3_600_000)} />
33
+ </Row>
34
+ <Row label="days ago">
35
+ <RelativeTime value={ago(4 * 86_400_000)} />
36
+ </Row>
37
+ <Row label="future (5 min)">
38
+ <RelativeTime value={ahead(5 * 60_000)} />
39
+ </Row>
40
+ <Row label="far past (45 days)">
41
+ <RelativeTime value={ago(45 * 86_400_000)} />
42
+ </Row>
43
+ <Row label="ISO string">
44
+ <RelativeTime value={ago(2 * 3_600_000).toISOString()} />
45
+ </Row>
46
+ <Row label="numeric epoch ms">
47
+ <RelativeTime value={Date.now() - 60_000} />
48
+ </Row>
49
+ <Row label="null">
50
+ <RelativeTime value={null} />
51
+ </Row>
52
+ <Row label="invalid string">
53
+ <RelativeTime value="not a date" />
54
+ </Row>
55
+ </div>
56
+ ),
57
+ };
@@ -0,0 +1,31 @@
1
+ import { EmptyValue } from '../EmptyValue';
2
+ import type { RelativeTimeProps } from './types';
3
+
4
+ const toDate = (v: Date | string | number): Date => (v instanceof Date ? v : new Date(v));
5
+
6
+ const formatRelative = (date: Date): string => {
7
+ const diffMs = Date.now() - date.getTime();
8
+ const past = diffMs >= 0;
9
+ const abs = Math.abs(diffMs);
10
+ const seconds = Math.round(abs / 1000);
11
+ const minutes = Math.round(seconds / 60);
12
+ const hours = Math.round(minutes / 60);
13
+ const days = Math.round(hours / 24);
14
+ const suffix = past ? 'ago' : 'from now';
15
+ if (seconds < 60) return `${seconds}s ${suffix}`;
16
+ if (minutes < 60) return `${minutes}m ${suffix}`;
17
+ if (hours < 24) return `${hours}h ${suffix}`;
18
+ if (days < 30) return `${days}d ${suffix}`;
19
+ return date.toLocaleDateString();
20
+ };
21
+
22
+ export const RelativeTime = (props: RelativeTimeProps) => {
23
+ const { value } = props;
24
+
25
+ if (value == null) return <EmptyValue />;
26
+ const d = toDate(value);
27
+ if (Number.isNaN(d.getTime())) return <EmptyValue />;
28
+ return <>{formatRelative(d)}</>;
29
+ };
30
+
31
+ export type { RelativeTimeProps };
@@ -0,0 +1,3 @@
1
+ export type RelativeTimeProps = {
2
+ value: Date | string | number | null | undefined;
3
+ };