@ds-mo/ui 0.1.0

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 (213) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +81 -0
  3. package/dist/index.css +1 -0
  4. package/dist/index.d.ts +969 -0
  5. package/dist/index.js +2623 -0
  6. package/dist/index.js.map +1 -0
  7. package/dist/r/accordion.json +79 -0
  8. package/dist/r/badge.json +69 -0
  9. package/dist/r/banner.json +90 -0
  10. package/dist/r/breadcrumb.json +70 -0
  11. package/dist/r/button-group.json +65 -0
  12. package/dist/r/button.json +137 -0
  13. package/dist/r/card.json +82 -0
  14. package/dist/r/checkbox.json +91 -0
  15. package/dist/r/divider.json +57 -0
  16. package/dist/r/empty-state.json +69 -0
  17. package/dist/r/error-boundary.json +66 -0
  18. package/dist/r/fade.json +65 -0
  19. package/dist/r/field.json +71 -0
  20. package/dist/r/header.json +67 -0
  21. package/dist/r/input.json +77 -0
  22. package/dist/r/loader.json +54 -0
  23. package/dist/r/menu.json +130 -0
  24. package/dist/r/modal.json +93 -0
  25. package/dist/r/pagination.json +80 -0
  26. package/dist/r/radio.json +85 -0
  27. package/dist/r/registry.json +1600 -0
  28. package/dist/r/scrollbar.json +68 -0
  29. package/dist/r/select.json +93 -0
  30. package/dist/r/sidebar.json +105 -0
  31. package/dist/r/skeleton.json +72 -0
  32. package/dist/r/slider.json +87 -0
  33. package/dist/r/surface.json +97 -0
  34. package/dist/r/tab-group.json +72 -0
  35. package/dist/r/tab.json +71 -0
  36. package/dist/r/table.json +110 -0
  37. package/dist/r/tag.json +110 -0
  38. package/dist/r/text.json +94 -0
  39. package/dist/r/toast.json +77 -0
  40. package/dist/r/toggle-button-group.json +65 -0
  41. package/dist/r/toggle-button.json +94 -0
  42. package/dist/r/toggle.json +65 -0
  43. package/dist/r/tooltip.json +86 -0
  44. package/package.json +80 -0
  45. package/src/components/Accordion/Accordion.module.css +66 -0
  46. package/src/components/Accordion/Accordion.stories.tsx +95 -0
  47. package/src/components/Accordion/Accordion.tsx +122 -0
  48. package/src/components/Accordion/index.ts +2 -0
  49. package/src/components/Badge/Badge.module.css +6 -0
  50. package/src/components/Badge/Badge.stories.tsx +99 -0
  51. package/src/components/Badge/Badge.tsx +23 -0
  52. package/src/components/Badge/index.ts +2 -0
  53. package/src/components/Banner/Banner.module.css +67 -0
  54. package/src/components/Banner/Banner.stories.tsx +81 -0
  55. package/src/components/Banner/Banner.tsx +109 -0
  56. package/src/components/Banner/index.ts +2 -0
  57. package/src/components/Breadcrumb/Breadcrumb.module.css +46 -0
  58. package/src/components/Breadcrumb/Breadcrumb.stories.tsx +72 -0
  59. package/src/components/Breadcrumb/Breadcrumb.tsx +68 -0
  60. package/src/components/Breadcrumb/index.ts +2 -0
  61. package/src/components/Button/Button.module.css +343 -0
  62. package/src/components/Button/Button.stories.tsx +575 -0
  63. package/src/components/Button/Button.tsx +201 -0
  64. package/src/components/Button/index.ts +10 -0
  65. package/src/components/ButtonGroup/ButtonGroup.module.css +157 -0
  66. package/src/components/ButtonGroup/ButtonGroup.stories.tsx +384 -0
  67. package/src/components/ButtonGroup/ButtonGroup.tsx +122 -0
  68. package/src/components/ButtonGroup/index.ts +2 -0
  69. package/src/components/Card/Card.module.css +36 -0
  70. package/src/components/Card/Card.stories.tsx +78 -0
  71. package/src/components/Card/Card.tsx +53 -0
  72. package/src/components/Card/index.ts +2 -0
  73. package/src/components/Checkbox/Checkbox.module.css +47 -0
  74. package/src/components/Checkbox/Checkbox.stories.tsx +129 -0
  75. package/src/components/Checkbox/Checkbox.tsx +68 -0
  76. package/src/components/Checkbox/index.ts +2 -0
  77. package/src/components/Divider/Divider.module.css +19 -0
  78. package/src/components/Divider/Divider.stories.tsx +55 -0
  79. package/src/components/Divider/Divider.tsx +28 -0
  80. package/src/components/Divider/index.ts +2 -0
  81. package/src/components/EmptyState/EmptyState.module.css +8 -0
  82. package/src/components/EmptyState/EmptyState.stories.tsx +53 -0
  83. package/src/components/EmptyState/EmptyState.tsx +31 -0
  84. package/src/components/EmptyState/index.ts +2 -0
  85. package/src/components/ErrorBoundary/ErrorBoundary.module.css +18 -0
  86. package/src/components/ErrorBoundary/ErrorBoundary.stories.tsx +38 -0
  87. package/src/components/ErrorBoundary/ErrorBoundary.tsx +50 -0
  88. package/src/components/ErrorBoundary/index.ts +2 -0
  89. package/src/components/Fade/Fade.module.css +31 -0
  90. package/src/components/Fade/Fade.stories.tsx +35 -0
  91. package/src/components/Fade/Fade.tsx +33 -0
  92. package/src/components/Fade/index.ts +2 -0
  93. package/src/components/Field/Field.module.css +5 -0
  94. package/src/components/Field/Field.stories.tsx +111 -0
  95. package/src/components/Field/Field.tsx +22 -0
  96. package/src/components/Field/index.ts +2 -0
  97. package/src/components/Header/Header.module.css +33 -0
  98. package/src/components/Header/Header.stories.tsx +52 -0
  99. package/src/components/Header/Header.tsx +42 -0
  100. package/src/components/Header/index.ts +2 -0
  101. package/src/components/Input/Input.module.css +71 -0
  102. package/src/components/Input/Input.stories.tsx +135 -0
  103. package/src/components/Input/Input.tsx +76 -0
  104. package/src/components/Input/index.ts +2 -0
  105. package/src/components/LabelWrap/LabelWrap.module.css +34 -0
  106. package/src/components/LabelWrap/LabelWrap.tsx +32 -0
  107. package/src/components/LabelWrap/index.ts +1 -0
  108. package/src/components/Loader/Loader.module.css +9 -0
  109. package/src/components/Loader/Loader.stories.tsx +44 -0
  110. package/src/components/Loader/Loader.tsx +43 -0
  111. package/src/components/Loader/index.ts +2 -0
  112. package/src/components/Menu/DestructiveMenuItem.module.css +57 -0
  113. package/src/components/Menu/DestructiveMenuItem.tsx +64 -0
  114. package/src/components/Menu/Menu.module.css +49 -0
  115. package/src/components/Menu/Menu.stories.tsx +129 -0
  116. package/src/components/Menu/Menu.tsx +269 -0
  117. package/src/components/Menu/MenuItem.module.css +84 -0
  118. package/src/components/Menu/MenuItem.tsx +101 -0
  119. package/src/components/Menu/index.ts +6 -0
  120. package/src/components/Modal/Modal.module.css +67 -0
  121. package/src/components/Modal/Modal.stories.tsx +71 -0
  122. package/src/components/Modal/Modal.tsx +122 -0
  123. package/src/components/Modal/index.ts +2 -0
  124. package/src/components/Pagination/Pagination.module.css +64 -0
  125. package/src/components/Pagination/Pagination.stories.tsx +51 -0
  126. package/src/components/Pagination/Pagination.tsx +103 -0
  127. package/src/components/Pagination/index.ts +2 -0
  128. package/src/components/Radio/Radio.module.css +61 -0
  129. package/src/components/Radio/Radio.stories.tsx +101 -0
  130. package/src/components/Radio/Radio.tsx +108 -0
  131. package/src/components/Radio/index.ts +2 -0
  132. package/src/components/Scrollbar/Scrollbar.module.css +81 -0
  133. package/src/components/Scrollbar/Scrollbar.stories.tsx +46 -0
  134. package/src/components/Scrollbar/Scrollbar.tsx +187 -0
  135. package/src/components/Scrollbar/index.ts +2 -0
  136. package/src/components/Select/Select.module.css +31 -0
  137. package/src/components/Select/Select.stories.tsx +59 -0
  138. package/src/components/Select/Select.tsx +92 -0
  139. package/src/components/Select/index.ts +2 -0
  140. package/src/components/Sidebar/Sidebar.module.css +161 -0
  141. package/src/components/Sidebar/Sidebar.stories.tsx +125 -0
  142. package/src/components/Sidebar/Sidebar.tsx +129 -0
  143. package/src/components/Sidebar/SidebarItem.tsx +57 -0
  144. package/src/components/Sidebar/SidebarSection.tsx +42 -0
  145. package/src/components/Sidebar/index.ts +6 -0
  146. package/src/components/Skeleton/Skeleton.module.css +53 -0
  147. package/src/components/Skeleton/Skeleton.stories.tsx +89 -0
  148. package/src/components/Skeleton/Skeleton.tsx +66 -0
  149. package/src/components/Skeleton/index.ts +2 -0
  150. package/src/components/Slider/Slider.module.css +62 -0
  151. package/src/components/Slider/Slider.stories.tsx +120 -0
  152. package/src/components/Slider/Slider.tsx +74 -0
  153. package/src/components/Slider/index.ts +2 -0
  154. package/src/components/Surface/Surface.module.css +222 -0
  155. package/src/components/Surface/Surface.stories.tsx +213 -0
  156. package/src/components/Surface/Surface.tsx +219 -0
  157. package/src/components/Surface/index.ts +12 -0
  158. package/src/components/Tab/Tab.module.css +66 -0
  159. package/src/components/Tab/Tab.stories.tsx +103 -0
  160. package/src/components/Tab/Tab.tsx +41 -0
  161. package/src/components/Tab/index.ts +2 -0
  162. package/src/components/TabGroup/TabGroup.module.css +26 -0
  163. package/src/components/TabGroup/TabGroup.stories.tsx +60 -0
  164. package/src/components/TabGroup/TabGroup.tsx +83 -0
  165. package/src/components/TabGroup/index.ts +2 -0
  166. package/src/components/Table/Table.module.css +130 -0
  167. package/src/components/Table/Table.stories.tsx +111 -0
  168. package/src/components/Table/Table.tsx +226 -0
  169. package/src/components/Table/index.ts +11 -0
  170. package/src/components/Tag/Tag.module.css +270 -0
  171. package/src/components/Tag/Tag.stories.tsx +399 -0
  172. package/src/components/Tag/Tag.tsx +162 -0
  173. package/src/components/Tag/index.ts +2 -0
  174. package/src/components/Text/Text.module.css +166 -0
  175. package/src/components/Text/Text.stories.tsx +212 -0
  176. package/src/components/Text/Text.tsx +181 -0
  177. package/src/components/Text/index.ts +13 -0
  178. package/src/components/Toast/Toast.module.css +91 -0
  179. package/src/components/Toast/Toast.stories.tsx +66 -0
  180. package/src/components/Toast/Toast.tsx +182 -0
  181. package/src/components/Toast/index.ts +8 -0
  182. package/src/components/Toggle/Toggle.module.css +87 -0
  183. package/src/components/Toggle/Toggle.stories.tsx +85 -0
  184. package/src/components/Toggle/Toggle.tsx +52 -0
  185. package/src/components/Toggle/index.ts +2 -0
  186. package/src/components/ToggleButton/ToggleButton.module.css +172 -0
  187. package/src/components/ToggleButton/ToggleButton.stories.tsx +299 -0
  188. package/src/components/ToggleButton/ToggleButton.tsx +118 -0
  189. package/src/components/ToggleButton/index.ts +2 -0
  190. package/src/components/ToggleButtonGroup/ToggleButtonGroup.module.css +163 -0
  191. package/src/components/ToggleButtonGroup/ToggleButtonGroup.stories.tsx +341 -0
  192. package/src/components/ToggleButtonGroup/ToggleButtonGroup.tsx +111 -0
  193. package/src/components/ToggleButtonGroup/index.ts +2 -0
  194. package/src/components/Tooltip/Tooltip.module.css +45 -0
  195. package/src/components/Tooltip/Tooltip.stories.tsx +46 -0
  196. package/src/components/Tooltip/Tooltip.tsx +224 -0
  197. package/src/components/Tooltip/index.ts +2 -0
  198. package/src/css-modules.d.ts +4 -0
  199. package/src/docs/ColorUsage.mdx +195 -0
  200. package/src/docs/ElevationUsage.mdx +95 -0
  201. package/src/docs/Introduction.mdx +97 -0
  202. package/src/docs/OpticalSizing.mdx +178 -0
  203. package/src/docs/TypographyUsage.mdx +121 -0
  204. package/src/index.ts +154 -0
  205. package/src/stories/Colors.stories.tsx +528 -0
  206. package/src/stories/Dimensions.stories.tsx +200 -0
  207. package/src/stories/Effects.stories.tsx +231 -0
  208. package/src/stories/Icons.stories.tsx +159 -0
  209. package/src/stories/Typography.stories.tsx +157 -0
  210. package/src/types/icons.ts +15 -0
  211. package/src/types/index.ts +1 -0
  212. package/src/utils/cn.ts +6 -0
  213. package/src/utils/css-tokens.ts +22 -0
@@ -0,0 +1,213 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite';
2
+ import React from 'react';
3
+ import { Surface } from './Surface';
4
+ import type { SurfaceIntent, SurfaceContrast, SurfaceElevation } from './Surface';
5
+
6
+ const INTENTS: SurfaceIntent[] = ['brand', 'positive', 'negative', 'warning', 'caution', 'ai', 'neutral'];
7
+ const CONTRASTS: SurfaceContrast[] = ['faint', 'medium', 'bold', 'strong'];
8
+ const ELEVATIONS: SurfaceElevation[] = ['none', 'depressed', 'depressed-md', 'flat', 'elevated', 'floating'];
9
+
10
+ const meta: Meta<typeof Surface> = {
11
+ title: 'Primitives (Reviewed)/Surface',
12
+ component: Surface,
13
+ args: {
14
+ background: 'primary',
15
+ elevation: 'elevated',
16
+ radius: 'lg',
17
+ children: 'Surface content',
18
+ },
19
+ argTypes: {
20
+ background: { control: 'select', options: ['primary', 'secondary', 'transparent', 'translucent'] },
21
+ intent: { control: 'select', options: INTENTS },
22
+ contrast: { control: 'select', options: CONTRASTS },
23
+ elevation: { control: 'select', options: [...ELEVATIONS, 'overlayTop', 'overlayRight', 'overlayBottom', 'overlayLeft'] },
24
+ radius: { control: 'select', options: ['none', 'xs', 'sm', 'md', 'lg', 'xl', '2xl', 'full'] },
25
+ interactive:{ control: 'boolean' },
26
+ selected: { control: 'boolean' },
27
+ inactive: { control: 'boolean' },
28
+ },
29
+ };
30
+
31
+ export default meta;
32
+ type Story = StoryObj<typeof Surface>;
33
+
34
+ // ─── Helpers ──────────────────────────────────────────────────────────────────
35
+
36
+ const monoLabel = (text: string, minWidth = 80) => (
37
+ <span style={{ fontSize: 10, fontFamily: 'monospace', color: 'var(--color-foreground-tertiary)', minWidth, flexShrink: 0 }}>
38
+ {text}
39
+ </span>
40
+ );
41
+
42
+ const divider = <hr style={{ border: 'none', borderTop: '1px solid var(--color-border-tertiary)', margin: '16px 0' }} />;
43
+
44
+ // ─── Stories ──────────────────────────────────────────────────────────────────
45
+
46
+ export const Playground: Story = {
47
+ args: {
48
+ style: { padding: 24, minWidth: 240, minHeight: 100 },
49
+ },
50
+ };
51
+
52
+ export const Elevations: Story = {
53
+ parameters: { layout: 'padded' },
54
+ render: () => (
55
+ /* Secondary background provides the contrast needed to see depressed vs elevated */
56
+ <div style={{ background: 'var(--color-background-secondary)', padding: 40, borderRadius: 16, display: 'flex', gap: 20, flexWrap: 'wrap', alignItems: 'flex-start' }}>
57
+ {ELEVATIONS.map(elevation => (
58
+ <Surface
59
+ key={elevation}
60
+ background="primary"
61
+ elevation={elevation}
62
+ radius="lg"
63
+ style={{ padding: '20px 24px', minWidth: 130, textAlign: 'center', fontSize: 11, fontFamily: 'monospace' }}
64
+ >
65
+ {elevation}
66
+ </Surface>
67
+ ))}
68
+ </div>
69
+ ),
70
+ };
71
+
72
+ export const PanelOverlays: Story = {
73
+ parameters: { layout: 'padded' },
74
+ render: () => (
75
+ <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 24 }}>
76
+ {(['overlayTop', 'overlayBottom', 'overlayLeft', 'overlayRight'] as const).map(elevation => {
77
+ const isTop = elevation === 'overlayTop';
78
+ const isBottom = elevation === 'overlayBottom';
79
+ const isLeft = elevation === 'overlayLeft';
80
+ return (
81
+ <div key={elevation} style={{ position: 'relative', height: 120, overflow: 'hidden', borderRadius: 10, background: 'var(--color-background-secondary)' }}>
82
+ <Surface
83
+ background="primary"
84
+ elevation={elevation}
85
+ style={{
86
+ position: 'absolute',
87
+ textAlign: 'center',
88
+ fontSize: 11,
89
+ fontFamily: 'monospace',
90
+ ...(isTop ? { top: 0, left: 0, right: 0, padding: 16 } : {}),
91
+ ...(isBottom ? { bottom: 0, left: 0, right: 0, padding: 16 } : {}),
92
+ ...(isLeft ? { top: 0, bottom: 0, left: 0, width: 130, display: 'flex', alignItems: 'center', justifyContent: 'center' } : {}),
93
+ ...(!isTop && !isBottom && !isLeft ? { top: 0, bottom: 0, right: 0, width: 130, display: 'flex', alignItems: 'center', justifyContent: 'center' } : {}),
94
+ }}
95
+ >
96
+ {elevation}
97
+ </Surface>
98
+ </div>
99
+ );
100
+ })}
101
+ </div>
102
+ ),
103
+ };
104
+
105
+ export const IntentMatrix: Story = {
106
+ name: 'Intent × Contrast',
107
+ parameters: { layout: 'padded' },
108
+ render: () => (
109
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
110
+ {/* Header */}
111
+ <div style={{ display: 'flex', gap: 8, marginBottom: 4, marginLeft: 88 }}>
112
+ {CONTRASTS.map(c => (
113
+ <span key={c} style={{ minWidth: 140, textAlign: 'center', fontSize: 10, fontFamily: 'monospace', color: 'var(--color-foreground-tertiary)' }}>{c}</span>
114
+ ))}
115
+ </div>
116
+ {INTENTS.map(intent => (
117
+ <div key={intent} style={{ display: 'flex', gap: 8, alignItems: 'stretch' }}>
118
+ {monoLabel(intent, 88)}
119
+ {CONTRASTS.map(contrast => (
120
+ <Surface
121
+ key={contrast}
122
+ intent={intent}
123
+ contrast={contrast}
124
+ radius="md"
125
+ style={{ minWidth: 140, padding: '10px 16px', fontSize: 11, fontFamily: 'monospace', textAlign: 'center' }}
126
+ >
127
+ {intent} / {contrast}
128
+ </Surface>
129
+ ))}
130
+ </div>
131
+ ))}
132
+ </div>
133
+ ),
134
+ };
135
+
136
+ export const Backgrounds: Story = {
137
+ parameters: { layout: 'padded' },
138
+ render: () => (
139
+ <div style={{ display: 'flex', gap: 16 }}>
140
+ {(['primary', 'secondary', 'translucent', 'transparent'] as const).map(bg => (
141
+ <div key={bg} style={{ display: 'flex', flexDirection: 'column', gap: 6, alignItems: 'center' }}>
142
+ <Surface
143
+ background={bg}
144
+ elevation="flat"
145
+ radius="lg"
146
+ style={{ width: 140, height: 80, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 11, fontFamily: 'monospace' }}
147
+ >
148
+ {bg}
149
+ </Surface>
150
+ </div>
151
+ ))}
152
+ </div>
153
+ ),
154
+ };
155
+
156
+ export const Interactive: Story = {
157
+ parameters: { layout: 'padded' },
158
+ render: () => (
159
+ <div style={{ display: 'flex', gap: 16, flexWrap: 'wrap' }}>
160
+ {([
161
+ { label: 'default', props: {} },
162
+ { label: 'interactive', props: { interactive: true } },
163
+ { label: 'selected', props: { interactive: true, selected: true } },
164
+ { label: 'inactive', props: { inactive: true } },
165
+ { label: 'as="button"', props: { interactive: true, as: 'button' as const } },
166
+ ]).map(({ label, props }) => (
167
+ <div key={label} style={{ display: 'flex', flexDirection: 'column', gap: 6, alignItems: 'center' }}>
168
+ <Surface
169
+ background="primary"
170
+ elevation="elevated"
171
+ radius="lg"
172
+ style={{ width: 160, height: 80, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 11, fontFamily: 'monospace', cursor: props.interactive ? 'pointer' : 'default' }}
173
+ {...props}
174
+ >
175
+ {label}
176
+ </Surface>
177
+ </div>
178
+ ))}
179
+ </div>
180
+ ),
181
+ };
182
+
183
+ export const EdgeDividers: Story = {
184
+ parameters: { layout: 'padded' },
185
+ render: () => (
186
+ <div style={{ display: 'flex', gap: 40, alignItems: 'flex-start' }}>
187
+ {/* Single edge variants */}
188
+ <div style={{ display: 'flex', gap: 16 }}>
189
+ {(['top', 'right', 'bottom', 'left'] as const).map(edge => (
190
+ <div key={edge} style={{ display: 'flex', flexDirection: 'column', gap: 6, alignItems: 'center' }}>
191
+ {monoLabel(edge)}
192
+ <Surface
193
+ edge={edge}
194
+ style={{ width: 80, height: 60, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 11, fontFamily: 'monospace' }}
195
+ >
196
+ {edge}
197
+ </Surface>
198
+ </div>
199
+ ))}
200
+ </div>
201
+ {divider}
202
+ {/* Stacked sections pattern */}
203
+ <div>
204
+ {monoLabel('stacked sections')}
205
+ <div style={{ width: 200, marginTop: 8 }}>
206
+ <Surface edge="bottom" style={{ padding: '12px 16px', fontSize: 12 }}>Section 1</Surface>
207
+ <Surface edge="bottom" style={{ padding: '12px 16px', fontSize: 12 }}>Section 2</Surface>
208
+ <Surface style={{ padding: '12px 16px', fontSize: 12 }}>Section 3</Surface>
209
+ </div>
210
+ </div>
211
+ </div>
212
+ ),
213
+ };
@@ -0,0 +1,219 @@
1
+ import React, { forwardRef } from 'react';
2
+ import styles from './Surface.module.css';
3
+
4
+ /**
5
+ * Semantic intent for colored surfaces.
6
+ * Each intent has four contrast levels (faint, medium, bold, strong).
7
+ */
8
+ export type SurfaceIntent =
9
+ | 'brand'
10
+ | 'positive'
11
+ | 'negative'
12
+ | 'warning'
13
+ | 'caution'
14
+ | 'ai'
15
+ | 'neutral'
16
+ | 'walkthrough'
17
+ | 'guide';
18
+
19
+ /** Background type for non-intent surfaces. */
20
+ export type SurfaceBackground = 'primary' | 'secondary' | 'transparent' | 'translucent';
21
+
22
+ /** Contrast level for semantic intents. */
23
+ export type SurfaceContrast = 'faint' | 'medium' | 'bold' | 'strong';
24
+
25
+ /** Elevation for depth/shadow effects. Progressive: none → flat → elevated → floating. */
26
+ export type SurfaceElevation =
27
+ | 'none'
28
+ | 'depressed'
29
+ | 'depressed-md'
30
+ | 'flat'
31
+ | 'elevated'
32
+ | 'floating'
33
+ | 'overlayTop'
34
+ | 'overlayRight'
35
+ | 'overlayBottom'
36
+ | 'overlayLeft';
37
+
38
+ /** Edge divider border direction. */
39
+ export type SurfaceEdge = 'top' | 'right' | 'bottom' | 'left';
40
+
41
+ /** Corner radius presets. */
42
+ export type SurfaceRadiusPreset = 'none' | 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl' | 'full';
43
+
44
+ /** Corner radius — preset, CSS variable, or custom string (e.g. '12px 12px 0 0'). */
45
+ export type SurfaceRadius = SurfaceRadiusPreset | (string & {});
46
+
47
+ /** HTML elements Surface can render as. */
48
+ export type SurfaceElement =
49
+ | 'div'
50
+ | 'section'
51
+ | 'aside'
52
+ | 'article'
53
+ | 'header'
54
+ | 'footer'
55
+ | 'main'
56
+ | 'nav'
57
+ | 'span'
58
+ | 'button'
59
+ | 'td'
60
+ | 'th';
61
+
62
+ /** Props shared by both surface modes. */
63
+ interface SurfaceBaseProps extends React.HTMLAttributes<HTMLElement> {
64
+ children?: React.ReactNode;
65
+ /** Elevation for depth/shadow effects. Defaults to 'none'. */
66
+ elevation?: SurfaceElevation;
67
+ /** Edge divider border(s). Only applies when elevation is 'none'. */
68
+ edge?: SurfaceEdge | SurfaceEdge[];
69
+ /** Corner radius — preset or CSS variable. */
70
+ radius?: SurfaceRadius;
71
+ /** Enable hover/pressed interaction overlay. */
72
+ interactive?: boolean;
73
+ /** Apply selected background state. */
74
+ selected?: boolean;
75
+ /** Dim and disable pointer events. */
76
+ inactive?: boolean;
77
+ /** HTML element to render. Defaults to 'div'. */
78
+ as?: SurfaceElement;
79
+ /** Button type — only used when as='button'. */
80
+ type?: 'button' | 'submit' | 'reset';
81
+ }
82
+
83
+ /** Non-intent surface — uses a basic background color. */
84
+ interface SurfaceDefaultProps extends SurfaceBaseProps {
85
+ /** Background color. Defaults to 'transparent'. */
86
+ background?: SurfaceBackground;
87
+ intent?: never;
88
+ contrast?: never;
89
+ }
90
+
91
+ /** Intent surface — uses a semantic color with contrast level. */
92
+ interface SurfaceIntentProps extends SurfaceBaseProps {
93
+ /** Semantic intent color. */
94
+ intent: SurfaceIntent;
95
+ /** Contrast level for the intent background. Defaults to 'faint'. */
96
+ contrast?: SurfaceContrast;
97
+ background?: never;
98
+ }
99
+
100
+ export type SurfaceProps = SurfaceDefaultProps | SurfaceIntentProps;
101
+
102
+ const backgroundClassMap: Record<SurfaceBackground, string> = {
103
+ primary: styles.bgPrimary,
104
+ secondary: styles.bgSecondary,
105
+ transparent: styles.bgTransparent,
106
+ translucent: styles.bgTranslucent,
107
+ };
108
+
109
+ const elevationClassMap: Record<SurfaceElevation, string> = {
110
+ none: styles.elevationNone,
111
+ depressed: styles.elevationDepressed,
112
+ 'depressed-md': styles.elevationDepressedMd,
113
+ flat: styles.elevationFlat,
114
+ elevated: styles.elevationElevated,
115
+ floating: styles.elevationFloating,
116
+ overlayTop: styles.elevationOverlayTop,
117
+ overlayRight: styles.elevationOverlayRight,
118
+ overlayBottom: styles.elevationOverlayBottom,
119
+ overlayLeft: styles.elevationOverlayLeft,
120
+ };
121
+
122
+ const edgeClassMap: Record<SurfaceEdge, string> = {
123
+ top: styles.edgeTop,
124
+ right: styles.edgeRight,
125
+ bottom: styles.edgeBottom,
126
+ left: styles.edgeLeft,
127
+ };
128
+
129
+ const radiusClassMap: Record<SurfaceRadiusPreset, string> = {
130
+ none: styles.radiusNone,
131
+ xs: styles.radiusXs,
132
+ sm: styles.radiusSm,
133
+ md: styles.radiusMd,
134
+ lg: styles.radiusLg,
135
+ xl: styles.radiusXl,
136
+ '2xl': styles.radius2xl,
137
+ full: styles.radiusFull,
138
+ };
139
+
140
+ const getIntentContrastClass = (intent: SurfaceIntent, contrast: SurfaceContrast): string => {
141
+ const intentKey = intent.charAt(0).toUpperCase() + intent.slice(1);
142
+ const contrastKey = contrast.charAt(0).toUpperCase() + contrast.slice(1);
143
+ return styles[`intent${intentKey}${contrastKey}`] || '';
144
+ };
145
+
146
+ const isPresetRadius = (radius: SurfaceRadius): radius is SurfaceRadiusPreset =>
147
+ radius in radiusClassMap;
148
+
149
+ export const Surface = forwardRef<HTMLElement, SurfaceProps>(
150
+ (
151
+ {
152
+ children,
153
+ background = 'transparent',
154
+ intent,
155
+ contrast = 'faint',
156
+ elevation = 'none',
157
+ edge,
158
+ radius,
159
+ interactive = false,
160
+ selected = false,
161
+ inactive = false,
162
+ as: Component = 'div',
163
+ className = '',
164
+ style,
165
+ type,
166
+ ...rest
167
+ },
168
+ ref
169
+ ) => {
170
+ const classes = [
171
+ styles.surface,
172
+ // Background or intent+contrast
173
+ intent
174
+ ? getIntentContrastClass(intent, contrast)
175
+ : backgroundClassMap[background],
176
+ // Elevation
177
+ elevationClassMap[elevation],
178
+ // Edge(s) — only when elevation is 'none'
179
+ ...(edge && elevation === 'none'
180
+ ? Array.isArray(edge)
181
+ ? edge.map(e => edgeClassMap[e])
182
+ : [edgeClassMap[edge]]
183
+ : []),
184
+ // Radius (preset only — custom handled via inline style)
185
+ radius && isPresetRadius(radius) ? radiusClassMap[radius] : '',
186
+ // States
187
+ interactive ? styles.interactive : '',
188
+ selected ? styles.selected : '',
189
+ inactive ? styles.inactive : '',
190
+ className,
191
+ ]
192
+ .filter(Boolean)
193
+ .join(' ');
194
+
195
+ const computedStyle: React.CSSProperties = {
196
+ ...style,
197
+ ...(radius && !isPresetRadius(radius) ? { borderRadius: radius } : {}),
198
+ };
199
+
200
+ const hasStyle =
201
+ (radius && !isPresetRadius(radius)) || (style && Object.keys(style).length > 0);
202
+
203
+ const elementProps = {
204
+ ref,
205
+ className: classes,
206
+ style: hasStyle ? computedStyle : undefined,
207
+ disabled: Component === 'button' ? inactive : undefined,
208
+ type: Component === 'button' ? type || 'button' : undefined,
209
+ ...rest,
210
+ };
211
+
212
+ return (
213
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
214
+ <Component {...(elementProps as any)}>{children}</Component>
215
+ );
216
+ }
217
+ );
218
+
219
+ Surface.displayName = 'Surface';
@@ -0,0 +1,12 @@
1
+ export { Surface } from './Surface';
2
+ export type {
3
+ SurfaceProps,
4
+ SurfaceIntent,
5
+ SurfaceBackground,
6
+ SurfaceContrast,
7
+ SurfaceElevation,
8
+ SurfaceEdge,
9
+ SurfaceRadius,
10
+ SurfaceRadiusPreset,
11
+ SurfaceElement,
12
+ } from './Surface';
@@ -0,0 +1,66 @@
1
+ /* ==========================================================================
2
+ Tab — hover/pressed via direct background-color, no pseudo-element overlay.
3
+ ========================================================================== */
4
+
5
+ .tab {
6
+ display: flex;
7
+ align-items: center;
8
+ justify-content: center;
9
+ padding: var(--dimension-space-075);
10
+ background-color: transparent;
11
+ border: none;
12
+ border-radius: var(--dimension-radius-075);
13
+ cursor: pointer;
14
+ -webkit-user-select: none;
15
+ user-select: none;
16
+ color: var(--color-foreground-secondary);
17
+ position: relative;
18
+ will-change: color;
19
+ transition: color var(--effect-motion-short-2),
20
+ background-color var(--effect-motion-short-2);
21
+ flex-shrink: 0;
22
+ height: var(--dimension-size-400);
23
+ box-sizing: border-box;
24
+
25
+ /* Interaction — ghost defaults */
26
+ --_hover-bg: var(--color-interaction-hover);
27
+ --_pressed-bg: var(--color-interaction-pressed);
28
+ }
29
+
30
+ .tab:hover { background-color: var(--_hover-bg); }
31
+ .tab:active { background-color: var(--_pressed-bg); }
32
+
33
+ .tabSelected { color: var(--color-foreground-primary); }
34
+
35
+ /* ==========================================================================
36
+ Background context — parent surface awareness
37
+ ========================================================================== */
38
+
39
+ .onMedium {
40
+ --_hover-bg: var(--color-interaction-on-medium-background-hover);
41
+ --_pressed-bg: var(--color-interaction-on-medium-background-pressed);
42
+ }
43
+
44
+ .onBold {
45
+ --_hover-bg: var(--color-interaction-on-bold-background-hover);
46
+ --_pressed-bg: var(--color-interaction-on-bold-background-pressed);
47
+ color: var(--color-foreground-on-bold-background-secondary);
48
+ }
49
+
50
+ .onBold.tabSelected { color: var(--color-foreground-on-bold-background-primary); }
51
+
52
+ .onStrong {
53
+ --_hover-bg: var(--color-interaction-on-strong-background-hover);
54
+ --_pressed-bg: var(--color-interaction-on-strong-background-pressed);
55
+ color: var(--color-foreground-on-bold-background-secondary);
56
+ }
57
+
58
+ .onStrong.tabSelected { color: var(--color-foreground-on-bold-background-primary); }
59
+
60
+ .onAlwaysDark {
61
+ --_hover-bg: var(--color-always-dark-interaction-hover);
62
+ --_pressed-bg: var(--color-always-dark-interaction-pressed);
63
+ color: var(--color-always-dark-foreground-secondary);
64
+ }
65
+
66
+ .onAlwaysDark.tabSelected { color: var(--color-always-dark-foreground-primary); }
@@ -0,0 +1,103 @@
1
+ import { useState } from 'react';
2
+ import type { Meta, StoryObj } from '@storybook/react-vite';
3
+ import { Tab } from './Tab';
4
+ import type { TabBackground } from './Tab';
5
+ import { Surface } from '@/components/Surface';
6
+
7
+ const meta: Meta<typeof Tab> = {
8
+ title: 'Primitives/Tab',
9
+ component: Tab,
10
+ };
11
+
12
+ export default meta;
13
+ type Story = StoryObj<typeof Tab>;
14
+
15
+ export const Default: Story = {
16
+ render: () => {
17
+ const [selected, setSelected] = useState('overview');
18
+ const tabs = ['Overview', 'Activity', 'Settings', 'Members'];
19
+ return (
20
+ <div style={{ display: 'flex', gap: 0, padding: 24 }}>
21
+ {tabs.map(tab => (
22
+ <Tab
23
+ key={tab}
24
+ label={tab}
25
+ isSelected={selected === tab.toLowerCase()}
26
+ onClick={() => setSelected(tab.toLowerCase())}
27
+ />
28
+ ))}
29
+ </div>
30
+ );
31
+ },
32
+ };
33
+
34
+ export const SingleTab: Story = {
35
+ args: {
36
+ label: 'Selected Tab',
37
+ isSelected: true,
38
+ },
39
+ };
40
+
41
+ export const UnselectedTab: Story = {
42
+ args: {
43
+ label: 'Unselected Tab',
44
+ isSelected: false,
45
+ },
46
+ };
47
+
48
+ // ─── Background Context ──────────────────────────────────────────────────────
49
+
50
+ const BG_CONTEXTS: { bg: TabBackground; contrast: 'faint' | 'medium' | 'bold' | 'strong' }[] = [
51
+ { bg: 'faint', contrast: 'faint' },
52
+ { bg: 'medium', contrast: 'medium' },
53
+ { bg: 'bold', contrast: 'bold' },
54
+ { bg: 'strong', contrast: 'strong' },
55
+ ];
56
+
57
+ export const BackgroundContext: Story = {
58
+ parameters: { layout: 'padded' },
59
+ render: () => {
60
+ const [selected, setSelected] = useState<Record<string, string>>({
61
+ faint: 'overview', medium: 'overview', bold: 'overview', strong: 'overview', dark: 'overview',
62
+ });
63
+ const tabs = ['Overview', 'Activity', 'Settings'];
64
+ return (
65
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 24 }}>
66
+ {BG_CONTEXTS.map(({ bg, contrast }) => (
67
+ <Surface key={bg} intent="brand" contrast={contrast} radius="md" style={{ padding: 16 }}>
68
+ <span style={{ fontSize: 10, fontFamily: 'monospace', opacity: 0.7, display: 'block', marginBottom: 8 }}>
69
+ background="{bg}"
70
+ </span>
71
+ <div style={{ display: 'flex', gap: 0 }}>
72
+ {tabs.map(tab => (
73
+ <Tab
74
+ key={tab}
75
+ label={tab}
76
+ background={bg}
77
+ isSelected={selected[bg] === tab.toLowerCase()}
78
+ onClick={() => setSelected(s => ({ ...s, [bg]: tab.toLowerCase() }))}
79
+ />
80
+ ))}
81
+ </div>
82
+ </Surface>
83
+ ))}
84
+ <div style={{ background: '#1a1a2e', borderRadius: 8, padding: 16 }}>
85
+ <span style={{ fontSize: 10, fontFamily: 'monospace', color: 'rgba(255,255,255,0.5)', display: 'block', marginBottom: 8 }}>
86
+ background="always-dark"
87
+ </span>
88
+ <div style={{ display: 'flex', gap: 0 }}>
89
+ {tabs.map(tab => (
90
+ <Tab
91
+ key={tab}
92
+ label={tab}
93
+ background="always-dark"
94
+ isSelected={selected.dark === tab.toLowerCase()}
95
+ onClick={() => setSelected(s => ({ ...s, dark: tab.toLowerCase() }))}
96
+ />
97
+ ))}
98
+ </div>
99
+ </div>
100
+ </div>
101
+ );
102
+ },
103
+ };
@@ -0,0 +1,41 @@
1
+ import React, { forwardRef } from 'react';
2
+ import { cn } from '@/utils/cn';
3
+ import { Text } from '@/components/Text';
4
+ import styles from './Tab.module.css';
5
+
6
+ export type TabBackground = 'faint' | 'medium' | 'bold' | 'strong' | 'always-dark';
7
+
8
+ export interface TabProps {
9
+ id?: string;
10
+ label: string;
11
+ isSelected?: boolean;
12
+ /** Parent surface context. Adjusts hover tokens and fg for tabs on colored backgrounds. */
13
+ background?: TabBackground;
14
+ onClick?: () => void;
15
+ className?: string;
16
+ }
17
+
18
+ export const Tab = forwardRef<HTMLButtonElement, TabProps>(
19
+ ({ id, label, isSelected = false, background, onClick, className }, ref) => {
20
+ const bgClass = background && background !== 'faint'
21
+ ? styles[background === 'always-dark' ? 'onAlwaysDark' : `on${background.charAt(0).toUpperCase() + background.slice(1)}`]
22
+ : undefined;
23
+ return (
24
+ <button
25
+ ref={ref}
26
+ id={id}
27
+ className={cn(styles.tab, isSelected && styles.tabSelected, bgClass, className)}
28
+ onClick={onClick}
29
+ type="button"
30
+ aria-selected={isSelected}
31
+ role="tab"
32
+ >
33
+ <Text variant={isSelected ? 'text-body-medium-emphasis' : 'text-body-medium'} as="span">
34
+ {label}
35
+ </Text>
36
+ </button>
37
+ );
38
+ }
39
+ );
40
+
41
+ Tab.displayName = 'Tab';
@@ -0,0 +1,2 @@
1
+ export { Tab } from './Tab';
2
+ export type { TabProps, TabBackground } from './Tab';
@@ -0,0 +1,26 @@
1
+ .tabGroup {
2
+ display: flex;
3
+ flex-direction: column;
4
+ position: relative;
5
+ }
6
+
7
+ .tabsRow {
8
+ display: flex;
9
+ gap: var(--dimension-space-025);
10
+ }
11
+
12
+ .track {
13
+ height: 2px;
14
+ background-color: var(--color-border-secondary);
15
+ position: relative;
16
+ }
17
+
18
+ .indicator {
19
+ position: absolute;
20
+ bottom: 0;
21
+ height: 2px;
22
+ background-color: var(--color-foreground-primary);
23
+ border-radius: 1px;
24
+ transition: transform var(--effect-motion-short-3), width var(--effect-motion-short-3);
25
+ will-change: transform, width;
26
+ }