premium-ds 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 (257) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +113 -0
  3. package/dist/alert.d.ts +31 -0
  4. package/dist/alert.js +6 -0
  5. package/dist/alert.js.map +1 -0
  6. package/dist/avatar-group.d.ts +13 -0
  7. package/dist/avatar-group.js +3 -0
  8. package/dist/avatar-group.js.map +1 -0
  9. package/dist/avatar.d.ts +25 -0
  10. package/dist/avatar.js +3 -0
  11. package/dist/avatar.js.map +1 -0
  12. package/dist/badge.d.ts +23 -0
  13. package/dist/badge.js +3 -0
  14. package/dist/badge.js.map +1 -0
  15. package/dist/button.d.ts +20 -0
  16. package/dist/button.js +3 -0
  17. package/dist/button.js.map +1 -0
  18. package/dist/checkbox.d.ts +25 -0
  19. package/dist/checkbox.js +3 -0
  20. package/dist/checkbox.js.map +1 -0
  21. package/dist/chunk-2OWHZ4JT.js +36 -0
  22. package/dist/chunk-2OWHZ4JT.js.map +1 -0
  23. package/dist/chunk-34SIXSYL.js +64 -0
  24. package/dist/chunk-34SIXSYL.js.map +1 -0
  25. package/dist/chunk-37O2ZXD6.js +55 -0
  26. package/dist/chunk-37O2ZXD6.js.map +1 -0
  27. package/dist/chunk-4AZL76UJ.js +89 -0
  28. package/dist/chunk-4AZL76UJ.js.map +1 -0
  29. package/dist/chunk-4HSCN5TZ.js +86 -0
  30. package/dist/chunk-4HSCN5TZ.js.map +1 -0
  31. package/dist/chunk-5DDOOT33.js +258 -0
  32. package/dist/chunk-5DDOOT33.js.map +1 -0
  33. package/dist/chunk-5FVHWIMY.js +117 -0
  34. package/dist/chunk-5FVHWIMY.js.map +1 -0
  35. package/dist/chunk-5K6KRJGX.js +147 -0
  36. package/dist/chunk-5K6KRJGX.js.map +1 -0
  37. package/dist/chunk-5PQMQBQC.js +74 -0
  38. package/dist/chunk-5PQMQBQC.js.map +1 -0
  39. package/dist/chunk-7OCTVQ7C.js +95 -0
  40. package/dist/chunk-7OCTVQ7C.js.map +1 -0
  41. package/dist/chunk-7OPMOET7.js +39 -0
  42. package/dist/chunk-7OPMOET7.js.map +1 -0
  43. package/dist/chunk-BXXS7YRC.js +270 -0
  44. package/dist/chunk-BXXS7YRC.js.map +1 -0
  45. package/dist/chunk-CV2Q4YXX.js +272 -0
  46. package/dist/chunk-CV2Q4YXX.js.map +1 -0
  47. package/dist/chunk-EIMMDWIW.js +282 -0
  48. package/dist/chunk-EIMMDWIW.js.map +1 -0
  49. package/dist/chunk-EZ2CWTBE.js +230 -0
  50. package/dist/chunk-EZ2CWTBE.js.map +1 -0
  51. package/dist/chunk-FGHDG3Y4.js +89 -0
  52. package/dist/chunk-FGHDG3Y4.js.map +1 -0
  53. package/dist/chunk-FPP2XLKX.js +127 -0
  54. package/dist/chunk-FPP2XLKX.js.map +1 -0
  55. package/dist/chunk-G6OY35DI.js +295 -0
  56. package/dist/chunk-G6OY35DI.js.map +1 -0
  57. package/dist/chunk-H6KWJNOE.js +65 -0
  58. package/dist/chunk-H6KWJNOE.js.map +1 -0
  59. package/dist/chunk-HGILYGY3.js +45 -0
  60. package/dist/chunk-HGILYGY3.js.map +1 -0
  61. package/dist/chunk-I3BCB4Z5.js +88 -0
  62. package/dist/chunk-I3BCB4Z5.js.map +1 -0
  63. package/dist/chunk-KBWNUUWM.js +582 -0
  64. package/dist/chunk-KBWNUUWM.js.map +1 -0
  65. package/dist/chunk-KN7JFAZ6.js +113 -0
  66. package/dist/chunk-KN7JFAZ6.js.map +1 -0
  67. package/dist/chunk-MEF7PI6U.js +16 -0
  68. package/dist/chunk-MEF7PI6U.js.map +1 -0
  69. package/dist/chunk-NKGMQL6I.js +310 -0
  70. package/dist/chunk-NKGMQL6I.js.map +1 -0
  71. package/dist/chunk-NMFQRGLL.js +127 -0
  72. package/dist/chunk-NMFQRGLL.js.map +1 -0
  73. package/dist/chunk-OUBWD6CX.js +433 -0
  74. package/dist/chunk-OUBWD6CX.js.map +1 -0
  75. package/dist/chunk-PFNXVBLU.js +96 -0
  76. package/dist/chunk-PFNXVBLU.js.map +1 -0
  77. package/dist/chunk-PUPZ4HME.js +165 -0
  78. package/dist/chunk-PUPZ4HME.js.map +1 -0
  79. package/dist/chunk-QFS52OK5.js +690 -0
  80. package/dist/chunk-QFS52OK5.js.map +1 -0
  81. package/dist/chunk-QNC6O3PG.js +45 -0
  82. package/dist/chunk-QNC6O3PG.js.map +1 -0
  83. package/dist/chunk-QUHOXWBK.js +82 -0
  84. package/dist/chunk-QUHOXWBK.js.map +1 -0
  85. package/dist/chunk-UIQGSTBJ.js +106 -0
  86. package/dist/chunk-UIQGSTBJ.js.map +1 -0
  87. package/dist/chunk-UJQKVP6V.js +193 -0
  88. package/dist/chunk-UJQKVP6V.js.map +1 -0
  89. package/dist/chunk-VVPGEAC6.js +11 -0
  90. package/dist/chunk-VVPGEAC6.js.map +1 -0
  91. package/dist/chunk-XA3T5KWA.js +58 -0
  92. package/dist/chunk-XA3T5KWA.js.map +1 -0
  93. package/dist/chunk-YSHJHSJM.js +19 -0
  94. package/dist/chunk-YSHJHSJM.js.map +1 -0
  95. package/dist/chunk-YVHOAVSM.js +182 -0
  96. package/dist/chunk-YVHOAVSM.js.map +1 -0
  97. package/dist/collapse.d.ts +16 -0
  98. package/dist/collapse.js +3 -0
  99. package/dist/collapse.js.map +1 -0
  100. package/dist/count-badge.d.ts +11 -0
  101. package/dist/count-badge.js +4 -0
  102. package/dist/count-badge.js.map +1 -0
  103. package/dist/date-field.d.ts +39 -0
  104. package/dist/date-field.js +8 -0
  105. package/dist/date-field.js.map +1 -0
  106. package/dist/date-range-field.d.ts +30 -0
  107. package/dist/date-range-field.js +8 -0
  108. package/dist/date-range-field.js.map +1 -0
  109. package/dist/datetime-field.d.ts +28 -0
  110. package/dist/datetime-field.js +10 -0
  111. package/dist/datetime-field.js.map +1 -0
  112. package/dist/dialog.d.ts +26 -0
  113. package/dist/dialog.js +7 -0
  114. package/dist/dialog.js.map +1 -0
  115. package/dist/index.d.ts +35 -0
  116. package/dist/index.js +40 -0
  117. package/dist/index.js.map +1 -0
  118. package/dist/motion-tokens.d.ts +29 -0
  119. package/dist/motion-tokens.js +3 -0
  120. package/dist/motion-tokens.js.map +1 -0
  121. package/dist/multi-select.d.ts +25 -0
  122. package/dist/multi-select.js +7 -0
  123. package/dist/multi-select.js.map +1 -0
  124. package/dist/number-field.d.ts +24 -0
  125. package/dist/number-field.js +4 -0
  126. package/dist/number-field.js.map +1 -0
  127. package/dist/otp-field.d.ts +20 -0
  128. package/dist/otp-field.js +3 -0
  129. package/dist/otp-field.js.map +1 -0
  130. package/dist/overlay.d.ts +31 -0
  131. package/dist/overlay.js +4 -0
  132. package/dist/overlay.js.map +1 -0
  133. package/dist/pagination.d.ts +24 -0
  134. package/dist/pagination.js +5 -0
  135. package/dist/pagination.js.map +1 -0
  136. package/dist/radio-group.d.ts +46 -0
  137. package/dist/radio-group.js +6 -0
  138. package/dist/radio-group.js.map +1 -0
  139. package/dist/select-core-SAyS-8w0.d.ts +16 -0
  140. package/dist/select.d.ts +27 -0
  141. package/dist/select.js +7 -0
  142. package/dist/select.js.map +1 -0
  143. package/dist/status-badge.d.ts +17 -0
  144. package/dist/status-badge.js +5 -0
  145. package/dist/status-badge.js.map +1 -0
  146. package/dist/table.d.ts +65 -0
  147. package/dist/table.js +5 -0
  148. package/dist/table.js.map +1 -0
  149. package/dist/tabs.d.ts +44 -0
  150. package/dist/tabs.js +5 -0
  151. package/dist/tabs.js.map +1 -0
  152. package/dist/tag.d.ts +28 -0
  153. package/dist/tag.js +5 -0
  154. package/dist/tag.js.map +1 -0
  155. package/dist/text-field.d.ts +30 -0
  156. package/dist/text-field.js +6 -0
  157. package/dist/text-field.js.map +1 -0
  158. package/dist/textarea.d.ts +33 -0
  159. package/dist/textarea.js +5 -0
  160. package/dist/textarea.js.map +1 -0
  161. package/dist/time-field.d.ts +27 -0
  162. package/dist/time-field.js +6 -0
  163. package/dist/time-field.js.map +1 -0
  164. package/dist/toast-store.d.ts +75 -0
  165. package/dist/toast-store.js +3 -0
  166. package/dist/toast-store.js.map +1 -0
  167. package/dist/toast.d.ts +3 -0
  168. package/dist/toast.js +6 -0
  169. package/dist/toast.js.map +1 -0
  170. package/dist/toggle-tag.d.ts +24 -0
  171. package/dist/toggle-tag.js +4 -0
  172. package/dist/toggle-tag.js.map +1 -0
  173. package/dist/toggle.d.ts +21 -0
  174. package/dist/toggle.js +3 -0
  175. package/dist/toggle.js.map +1 -0
  176. package/dist/tooltip.d.ts +27 -0
  177. package/dist/tooltip.js +4 -0
  178. package/dist/tooltip.js.map +1 -0
  179. package/llms.txt +165 -0
  180. package/package.json +205 -0
  181. package/src/components/alert/Alert.tsx +118 -0
  182. package/src/components/alert/alert.css +136 -0
  183. package/src/components/avatar/Avatar.tsx +128 -0
  184. package/src/components/avatar/AvatarGroup.tsx +50 -0
  185. package/src/components/avatar/avatar.css +200 -0
  186. package/src/components/badge/Badge.tsx +66 -0
  187. package/src/components/badge/CountBadge.tsx +46 -0
  188. package/src/components/badge/StatusBadge.tsx +132 -0
  189. package/src/components/badge/badge.css +243 -0
  190. package/src/components/button/Button.tsx +68 -0
  191. package/src/components/button/button.css +222 -0
  192. package/src/components/checkbox/Checkbox.tsx +90 -0
  193. package/src/components/checkbox/checkbox.css +179 -0
  194. package/src/components/date-picker/DateField.tsx +362 -0
  195. package/src/components/date-picker/DateRangeField.tsx +533 -0
  196. package/src/components/date-picker/DateTimeField.tsx +177 -0
  197. package/src/components/date-picker/TimeField.tsx +100 -0
  198. package/src/components/date-picker/date-picker.css +591 -0
  199. package/src/components/date-picker/date-utils.ts +55 -0
  200. package/src/components/date-picker/field-shell.tsx +78 -0
  201. package/src/components/date-picker/glide-pill.tsx +81 -0
  202. package/src/components/date-picker/time-core.tsx +305 -0
  203. package/src/components/dialog/Dialog.tsx +181 -0
  204. package/src/components/dialog/dialog.css +170 -0
  205. package/src/components/glass/glass.css +100 -0
  206. package/src/components/icon/Icon.tsx +76 -0
  207. package/src/components/icon/IconSlot.tsx +11 -0
  208. package/src/components/icon/icon.css +33 -0
  209. package/src/components/input/NumberField.tsx +117 -0
  210. package/src/components/input/OtpField.tsx +118 -0
  211. package/src/components/input/TextField.tsx +123 -0
  212. package/src/components/input/input.css +335 -0
  213. package/src/components/motion/Collapse.tsx +33 -0
  214. package/src/components/motion/collapse.css +41 -0
  215. package/src/components/overlay/Overlay.tsx +239 -0
  216. package/src/components/overlay/overlay-core.tsx +565 -0
  217. package/src/components/overlay/overlay.css +119 -0
  218. package/src/components/overlay/sheet-drag.tsx +146 -0
  219. package/src/components/pagination/Pagination.tsx +140 -0
  220. package/src/components/pagination/pagination.css +48 -0
  221. package/src/components/radio-group/RadioGroup.tsx +182 -0
  222. package/src/components/radio-group/radio-group.css +277 -0
  223. package/src/components/select/MultiSelect.tsx +251 -0
  224. package/src/components/select/Select.tsx +235 -0
  225. package/src/components/select/select-core.tsx +417 -0
  226. package/src/components/select/select.css +386 -0
  227. package/src/components/table/Table.tsx +433 -0
  228. package/src/components/table/table.css +348 -0
  229. package/src/components/tabs/Tabs.tsx +371 -0
  230. package/src/components/tabs/tabs.css +228 -0
  231. package/src/components/tag/Tag.tsx +145 -0
  232. package/src/components/tag/ToggleTag.tsx +125 -0
  233. package/src/components/tag/tag.css +248 -0
  234. package/src/components/textarea/Textarea.tsx +197 -0
  235. package/src/components/textarea/textarea.css +219 -0
  236. package/src/components/toast/Toast.tsx +349 -0
  237. package/src/components/toast/toast-store.ts +266 -0
  238. package/src/components/toast/toast.css +233 -0
  239. package/src/components/toggle/Toggle.tsx +94 -0
  240. package/src/components/toggle/toggle.css +152 -0
  241. package/src/components/tooltip/Tooltip.tsx +365 -0
  242. package/src/components/tooltip/tooltip.css +86 -0
  243. package/src/index.ts +42 -0
  244. package/src/styles.css +39 -0
  245. package/src/tokens/avatar.css +20 -0
  246. package/src/tokens/color.css +56 -0
  247. package/src/tokens/elevation.css +20 -0
  248. package/src/tokens/fonts.css +3 -0
  249. package/src/tokens/glass.css +21 -0
  250. package/src/tokens/icons.css +7 -0
  251. package/src/tokens/layers.css +6 -0
  252. package/src/tokens/motion-tokens.ts +72 -0
  253. package/src/tokens/motion.css +49 -0
  254. package/src/tokens/radius.css +11 -0
  255. package/src/tokens/semantic.css +75 -0
  256. package/src/tokens/spacing.css +26 -0
  257. package/src/tokens/typography.css +54 -0
@@ -0,0 +1,371 @@
1
+ 'use client';
2
+
3
+ // Tabs.tsx - Tabs: line tabs for view switching.
4
+ // Sits on a hairline baseline; a single accent ink marks the active view.
5
+ // `TabPanel` is the matching content wrapper (aria wiring + directional
6
+ // entrance). All styling lives in tabs.css; this file composes class names
7
+ // and drives the ink.
8
+ //
9
+ // TWO MOTIONS, BOTH IMPLICIT - consumers configure neither.
10
+ //
11
+ // 1 - "the ink reaches, then releases" (selection).
12
+ // One persistent ink node. On selection, the edge facing the destination
13
+ // travels FIRST (the ink stretches across the gap, ease-standard), then the
14
+ // trailing edge releases and settles (ease-entrance). Implemented as Motion
15
+ // keyframes on x (transform) + width (measured px - measured px - the
16
+ // surfaced section C morph exception, Tooltip precedent: the destination width is
17
+ // unknowable to transforms, and scaleX would distort the radius). Interrupts
18
+ // are honest: a new travel starts from the ink's LIVE rect, not the last
19
+ // target. Reduced motion: tokens collapse durations, and placement falls
20
+ // back to an instant set.
21
+ //
22
+ // 2 - the gliding hover. ONE shared pill instead of per-tab hover
23
+ // backgrounds: it fades in under the first tab the pointer touches, GLIDES -
24
+ // morphing position and width - as the pointer moves along the row, and
25
+ // fades out when it leaves. Declarative: a `layoutId` node conditionally
26
+ // rendered inside the hovered tab - Motion's FLIP owns the travel. Tab-to-tab
27
+ // is an ATOMIC remount: old pill out + new pill in within one commit, so
28
+ // exactly one node carries the layoutId per frame. (AnimatePresence is
29
+ // deliberately NOT used here: it holds the exiting pill for a tick, two
30
+ // same-layoutId nodes overlap, and the handoff can hide both for a frame -
31
+ // an intermittent white flicker.) True leave keeps the pill MOUNTED in the
32
+ // last tab and fades it via `animate`; the engine's completion callback
33
+ // unmounts it - no hand-rolled exit timer. Hover state is per-tab
34
+ // pointerenter (disabled buttons don't fire it, so the pill holds - same for
35
+ // the gaps); only pointerleave of the LIST starts the fade-out.
36
+ //
37
+ // Keyboard (WAI-ARIA tabs, automatic activation): roving tabindex; left/right arrows cycle
38
+ // (wrapping, skipping disabled), Home/End jump; selection follows focus.
39
+ // Overflow is honest: the row scrolls, clipped edges fade (data-fade set from
40
+ // scroll geometry), and the selected tab is kept in view.
41
+
42
+ import * as React from 'react';
43
+ import { motion as tabsMotion, animate as tabsAnimate } from 'motion/react';
44
+ import { UIMotion } from '../../tokens/motion-tokens';
45
+ import { IconSlot } from '../icon/IconSlot';
46
+
47
+ const TabsSM = UIMotion;
48
+
49
+ /** One tab in the row. */
50
+ export interface TabItem {
51
+ /** Stable identity - also used in the tab/panel id pair. */
52
+ value: string;
53
+ label: React.ReactNode;
54
+ /** Leading icon - your own node. */
55
+ icon?: React.ReactNode;
56
+ /** Rendered mono + tabular. Pass a number or a preformatted string. */
57
+ count?: number | string;
58
+ disabled?: boolean;
59
+ }
60
+
61
+ export interface TabsProps extends Omit<React.HTMLAttributes<HTMLDivElement>, 'onChange'> {
62
+ items: TabItem[];
63
+ /** Controlled - the value of the active tab (null/undefined hides the ink). */
64
+ value: string | null | undefined;
65
+ /**
66
+ * Fires on click and on arrow-key travel (automatic activation).
67
+ * `dir` is the direction of travel (+1 right / -1 left) - hand it to
68
+ * TabPanel so content enters from the side the user moved toward.
69
+ */
70
+ onChange?: (value: string, dir: 1 | -1) => void;
71
+ /**
72
+ * Shared id prefix wiring tab and panel aria. Give Tabs and its TabPanel the
73
+ * same `name`; omit it when the tabs have no managed panel.
74
+ */
75
+ name?: string;
76
+ /** aria-label for the tablist (e.g. "Section views"). */
77
+ label?: string;
78
+ }
79
+
80
+ /* Scroll-geometry constants (px) - measurement math, not styling.
81
+ EDGE_PAD ~ --space-6: breathing room kept between the selected tab and a
82
+ faded edge. ENTER_X: TabPanel's entrance travel (bespoke motion geometry,
83
+ like Tooltip's offsets - there is no spatial motion token). */
84
+ const TABS_EDGE_PAD = 24;
85
+ const TABS_ENTER_X = 10;
86
+
87
+ export function Tabs({
88
+ items = [],
89
+ value,
90
+ onChange,
91
+ name,
92
+ label,
93
+ className = '',
94
+ ...rest
95
+ }: TabsProps) {
96
+ const autoId = React.useId();
97
+ const base = name || autoId;
98
+
99
+ const listRef = React.useRef<HTMLDivElement>(null);
100
+ const inkRef = React.useRef<HTMLSpanElement>(null);
101
+ const tabRefs = React.useRef<Record<string, HTMLButtonElement | null>>({});
102
+ const animRef = React.useRef<ReturnType<typeof tabsAnimate> | null>(null);
103
+ const placedRef = React.useRef(false);
104
+ const valueRef = React.useRef(value);
105
+ valueRef.current = value;
106
+
107
+ /* The gliding hover - the only state here. `hovered` is STICKY (the last
108
+ tab the pointer touched; it outlives the pointer so the leave-fade plays
109
+ in place), `inList` is whether the pointer is currently in the row.
110
+ wasInRef (one render behind) picks the entrance: fresh entry = fade in
111
+ where it lands (layout snap, no glide from a stale spot); tab-to-tab =
112
+ opaque glide. */
113
+ const [hovered, setHovered] = React.useState<string | null>(null);
114
+ const [inList, setInList] = React.useState(false);
115
+ const wasInRef = React.useRef(false);
116
+ React.useEffect(() => {
117
+ wasInRef.current = inList;
118
+ });
119
+
120
+ /* Place the ink under the active tab - instantly (mount, resize, reduced
121
+ motion) or as the reach-then-release travel. Coordinates are offsetLeft
122
+ space (layout, scroll-independent) since the ink lives in the scroller. */
123
+ const place = (animated: boolean) => {
124
+ const list = listRef.current,
125
+ ink = inkRef.current;
126
+ const el = valueRef.current != null ? tabRefs.current[valueRef.current] : null;
127
+ if (!list || !ink) return;
128
+ if (animRef.current) animRef.current.stop();
129
+ if (!el) {
130
+ ink.style.opacity = '0';
131
+ placedRef.current = false;
132
+ return;
133
+ }
134
+ const next = { x: el.offsetLeft, w: el.offsetWidth };
135
+ ink.style.opacity = '1';
136
+
137
+ if (!animated || !placedRef.current || TabsSM.reduced) {
138
+ ink.style.transform = 'translateX(' + next.x + 'px)';
139
+ ink.style.width = next.w + 'px';
140
+ } else {
141
+ /* start from the LIVE rect so an interrupted travel never jumps back */
142
+ const lr = list.getBoundingClientRect();
143
+ const ir = ink.getBoundingClientRect();
144
+ const cur = { x: ir.left - lr.left + list.scrollLeft, w: ir.width };
145
+ if (Math.abs(cur.x - next.x) > 0.5 || Math.abs(cur.w - next.w) > 0.5) {
146
+ const dir = next.x + next.w / 2 >= cur.x + cur.w / 2 ? 1 : -1;
147
+ const span = dir === 1 ? next.x + next.w - cur.x : cur.x + cur.w - next.x;
148
+ const kf =
149
+ dir === 1
150
+ ? {
151
+ x: [cur.x, cur.x, next.x],
152
+ width: [cur.w, span, next.w],
153
+ } /* right edge reaches, left releases */
154
+ : {
155
+ x: [cur.x, next.x, next.x],
156
+ width: [cur.w, span, next.w],
157
+ }; /* left edge reaches, right releases */
158
+ animRef.current = tabsAnimate(ink, kf, {
159
+ duration: TabsSM.dur.slow,
160
+ times: [0, 0.55, 1],
161
+ ease: [TabsSM.ease.standard, TabsSM.ease.entrance],
162
+ });
163
+ }
164
+ }
165
+ placedRef.current = true;
166
+ };
167
+
168
+ /* Edge fades: pure scroll geometry - data-fade="start end" on the list. */
169
+ const updateEdges = () => {
170
+ const l = listRef.current;
171
+ if (!l) return;
172
+ const max = l.scrollWidth - l.clientWidth;
173
+ l.dataset.fade = (
174
+ (l.scrollLeft > 1 ? 'start ' : '') + (l.scrollLeft < max - 1 ? 'end' : '')
175
+ ).trim();
176
+ };
177
+
178
+ /* Selection drives the travel + keeps the active tab clear of a faded edge. */
179
+ React.useLayoutEffect(() => {
180
+ place(true);
181
+ const l = listRef.current,
182
+ el = value != null ? tabRefs.current[value] : null;
183
+ if (l && el && l.scrollWidth > l.clientWidth) {
184
+ const behavior: ScrollBehavior = TabsSM.reduced ? 'auto' : 'smooth';
185
+ if (el.offsetLeft < l.scrollLeft + TABS_EDGE_PAD) {
186
+ l.scrollTo({ left: el.offsetLeft - TABS_EDGE_PAD, behavior });
187
+ } else if (el.offsetLeft + el.offsetWidth > l.scrollLeft + l.clientWidth - TABS_EDGE_PAD) {
188
+ l.scrollTo({
189
+ left: el.offsetLeft + el.offsetWidth - l.clientWidth + TABS_EDGE_PAD,
190
+ behavior,
191
+ });
192
+ }
193
+ }
194
+ }, [value]);
195
+
196
+ /* Any size change (fonts arriving, container resize, label edits) re-seats
197
+ the ink instantly and re-derives the edge fades. */
198
+ React.useLayoutEffect(() => {
199
+ const ro = new ResizeObserver(() => {
200
+ place(false);
201
+ updateEdges();
202
+ });
203
+ if (listRef.current) ro.observe(listRef.current);
204
+ Object.values(tabRefs.current).forEach((el) => el && ro.observe(el));
205
+ updateEdges();
206
+ return () => ro.disconnect();
207
+ }, [items.length]);
208
+
209
+ const select = (v: string) => {
210
+ if (v === value || !onChange) return;
211
+ const idx = (x: string | null | undefined) => items.findIndex((i) => i.value === x);
212
+ onChange(v, idx(v) >= idx(value) ? 1 : -1);
213
+ };
214
+
215
+ const onKeyDown = (e: React.KeyboardEvent) => {
216
+ const enabled = items.filter((i) => !i.disabled);
217
+ if (!enabled.length) return;
218
+ let next: TabItem | null = null;
219
+ if (e.key === 'ArrowRight' || e.key === 'ArrowLeft') {
220
+ const step = e.key === 'ArrowRight' ? 1 : -1;
221
+ const i = enabled.findIndex((it) => it.value === value);
222
+ next = enabled[(i + step + enabled.length) % enabled.length];
223
+ } else if (e.key === 'Home') {
224
+ next = enabled[0];
225
+ } else if (e.key === 'End') {
226
+ next = enabled[enabled.length - 1];
227
+ }
228
+ if (!next) return;
229
+ e.preventDefault();
230
+ select(next.value);
231
+ const el = tabRefs.current[next.value];
232
+ if (el) el.focus();
233
+ };
234
+
235
+ /* Roving tabindex - the selected tab is the stop; if nothing is selected
236
+ yet, the first enabled tab takes it so the list stays reachable. */
237
+ const focusValue = items.some((i) => i.value === value && !i.disabled)
238
+ ? value
239
+ : (items.find((i) => !i.disabled) || ({} as Partial<TabItem>)).value;
240
+
241
+ return (
242
+ <div className={('tabs ' + className).trim()} {...rest}>
243
+ {/* layoutScroll: the pill FLIPs correctly even while this row is being
244
+ scrolled. NOTE: framer-motion@12.40.0's UMD dev build prints a
245
+ spurious React key warning when any motion component mounts -
246
+ pre-existing and system-wide (stable Toggle prints the same one at
247
+ load), harmless, dev-only. Not caused by, or fixable in, this code. */}
248
+ <tabsMotion.div
249
+ className="tabs__list"
250
+ role="tablist"
251
+ aria-label={label}
252
+ ref={listRef}
253
+ layoutScroll
254
+ onScroll={updateEdges}
255
+ onKeyDown={onKeyDown}
256
+ onPointerLeave={() => setInList(false)}
257
+ >
258
+ {items.map((it) => {
259
+ const selected = it.value === value;
260
+ return (
261
+ <button
262
+ key={it.value}
263
+ type="button"
264
+ role="tab"
265
+ id={base + '-tab-' + it.value}
266
+ aria-selected={selected}
267
+ aria-controls={name ? name + '-panel-' + it.value : undefined}
268
+ tabIndex={it.value === focusValue ? 0 : -1}
269
+ disabled={it.disabled}
270
+ className={'tab' + (selected ? ' is-selected' : '')}
271
+ ref={(el) => {
272
+ tabRefs.current[it.value] = el;
273
+ }}
274
+ onClick={() => select(it.value)}
275
+ onPointerEnter={
276
+ it.disabled
277
+ ? undefined
278
+ : () => {
279
+ setHovered(it.value);
280
+ setInList(true);
281
+ }
282
+ }
283
+ >
284
+ <span className="tab__pad">
285
+ {hovered === it.value && (
286
+ <tabsMotion.span
287
+ key="pill"
288
+ className="tab__hover"
289
+ layoutId={base + '-hover'}
290
+ aria-hidden="true"
291
+ initial={wasInRef.current ? false : { opacity: 0 }}
292
+ animate={{ opacity: inList ? 1 : 0 }}
293
+ transition={{
294
+ /* glide: slow travel, soft-start (ease.standard) - for
295
+ continuous hover sweeps the early gentle pickup reads
296
+ better than the fuller in-out (--ease-glide). Fresh
297
+ entry snaps in place instead of gliding from a stale
298
+ position. */
299
+ layout: wasInRef.current
300
+ ? { duration: TabsSM.dur.slow, ease: TabsSM.ease.standard }
301
+ : { duration: 0 },
302
+ opacity: inList
303
+ ? { duration: TabsSM.dur.fast, ease: TabsSM.ease.standard }
304
+ : { duration: TabsSM.dur.fast, ease: TabsSM.ease.exit },
305
+ }}
306
+ onAnimationComplete={() => {
307
+ if (!inList) setHovered(null);
308
+ }}
309
+ ></tabsMotion.span>
310
+ )}
311
+ {it.icon && <IconSlot size="sm">{it.icon}</IconSlot>}
312
+ <span className="tab__label">{it.label}</span>
313
+ {it.count != null && <span className="tab__count">{it.count}</span>}
314
+ </span>
315
+ </button>
316
+ );
317
+ })}
318
+ <span className="tabs__ink" key="ink" ref={inkRef} aria-hidden="true"></span>
319
+ </tabsMotion.div>
320
+ </div>
321
+ );
322
+ }
323
+
324
+ export interface TabPanelProps extends Omit<React.HTMLAttributes<HTMLDivElement>, 'dir'> {
325
+ /** The active tab's value - changing it cuts to the new content. */
326
+ tab: string;
327
+ /** Same `name` as the paired Tabs - wires role/id/aria-labelledby. */
328
+ name?: string;
329
+ /** Direction of travel from Tabs' onChange; 0 = plain fade. */
330
+ dir?: -1 | 0 | 1;
331
+ children?: React.ReactNode;
332
+ }
333
+
334
+ // TabPanel - the content side of a Tabs pair. The ROOT is static chrome:
335
+ // whatever border/padding/background the consumer styles it with never
336
+ // moves. Switching `tab` cuts to the new content, then ONLY the inner
337
+ // content node enters from the direction of travel (pass Tabs' onChange dir
338
+ // through) via an imperative Motion tween on one persistent node - same
339
+ // resolved pattern as Tooltip's content: cut + entrance, no exit
340
+ // choreography, and no remount, so first paint is naturally static.
341
+ export function TabPanel({ tab, name, dir = 0, className = '', children, ...rest }: TabPanelProps) {
342
+ const innerRef = React.useRef<HTMLDivElement>(null);
343
+ const firstRef = React.useRef(true);
344
+ React.useLayoutEffect(() => {
345
+ if (firstRef.current) {
346
+ firstRef.current = false;
347
+ return;
348
+ }
349
+ if (TabsSM.reduced) return;
350
+ const anim = tabsAnimate(
351
+ innerRef.current,
352
+ { opacity: [0, 1], x: [dir * TABS_ENTER_X, 0] },
353
+ TabsSM.t.enter,
354
+ );
355
+ return () => anim.stop();
356
+ }, [tab]);
357
+ return (
358
+ <div
359
+ role="tabpanel"
360
+ tabIndex={0}
361
+ id={name ? name + '-panel-' + tab : undefined}
362
+ aria-labelledby={name ? name + '-tab-' + tab : undefined}
363
+ className={('tab-panel ' + className).trim()}
364
+ {...rest}
365
+ >
366
+ <div className="tab-panel__inner" ref={innerRef}>
367
+ {children}
368
+ </div>
369
+ </div>
370
+ );
371
+ }
@@ -0,0 +1,228 @@
1
+ /* tabs.css - Tabs.
2
+ -------------------------------------------------------------------------
3
+ Line tabs for VIEW SWITCHING (overview / activity / settings) - peers
4
+ of the content below them, sitting on a hairline baseline. NOT a form
5
+ control: pick Segmented control for 2-3 short filter options.
6
+
7
+ Anatomy: .tabs > .tabs__list[role=tablist] > .tab* + .tabs__ink
8
+ .tab > .tab__pad ( .tab__hover? + Icon? + .tab__label + .tab__count? )
9
+ States: :hover - :active - :focus-visible - :disabled - .is-selected
10
+
11
+ THE SIGNATURE MOTION - "the ink reaches, then releases."
12
+ The active underline is ONE persistent node (.tabs__ink). On selection the
13
+ edge facing the destination travels first - the ink stretches across the
14
+ gap - then the trailing edge releases and settles. Direction-aware, always
15
+ decelerating. Motion (animate(), Tabs.jsx) owns the travel; there is NO css
16
+ transition on the ink. Width is animated measured-px - measured-px - the
17
+ surfaced section C morph exception (Tooltip precedent), since the destination tab
18
+ width is unknowable to transforms and scaleX would distort the radius.
19
+
20
+ THE SECOND MOTION - the gliding hover. Hover feedback is ONE shared pill
21
+ (.tab__hover), not a per-tab background: it fades in under the first tab
22
+ you touch, then GLIDES - morphing position and width - as the pointer
23
+ moves along the row, and fades out when it leaves. Implicit: consumers get
24
+ it for free, there is no API. It is a `layoutId` node rendered inside the
25
+ hovered tab - Motion's FLIP owns the travel; tab-to-tab is an atomic
26
+ remount (one node per frame - no AnimatePresence, which caused a flicker),
27
+ and the leave-fade plays on the still-mounted pill before it unmounts -
28
+ so this file only gives it paint. No CSS transition on it.
29
+ Label color, icon weight and press dip remain trivial-tier CSS. */
30
+
31
+ /* -- Shell - owns the full-width baseline ----------------------------------
32
+ The hairline is a ::after on the shell (not a border on the scroller) so it
33
+ spans edge-to-edge while the scroller above it clips, masks and scrolls
34
+ freely. The scroller gets z-index:1 so the ink paints OVER the hairline. */
35
+ .tabs {
36
+ position: relative;
37
+ }
38
+ .tabs::after {
39
+ content: '';
40
+ position: absolute;
41
+ inset-inline: 0;
42
+ bottom: 0;
43
+ height: var(--border-hairline);
44
+ background: var(--border-default);
45
+ }
46
+
47
+ /* -- Scroller - the tablist ------------------------------------------------
48
+ Overflow is honest: the row scrolls (scrollbar hidden) and the clipped edge
49
+ fades out via a mask. The fade lengths are two custom props flipped by
50
+ data-fade (set from scroll geometry in Tabs.jsx) - at 0px the mask is fully
51
+ opaque, i.e. a no-op. Negative margin + matching padding align the first
52
+ label flush with the container edge while the hover pill still has room. */
53
+ .tabs__list {
54
+ position: relative;
55
+ z-index: 1;
56
+ display: flex;
57
+ gap: var(--space-1);
58
+ overflow-x: auto;
59
+ scrollbar-width: none;
60
+ margin-inline: calc(var(--space-2) * -1);
61
+ padding-inline: var(--space-2);
62
+
63
+ --tabs-fade-start: 0px;
64
+ --tabs-fade-end: 0px;
65
+ mask-image: linear-gradient(
66
+ to right,
67
+ transparent,
68
+ black var(--tabs-fade-start),
69
+ black calc(100% - var(--tabs-fade-end)),
70
+ transparent
71
+ );
72
+ }
73
+ .tabs__list::-webkit-scrollbar {
74
+ display: none;
75
+ }
76
+ .tabs__list[data-fade~='start'] {
77
+ --tabs-fade-start: var(--space-8);
78
+ }
79
+ .tabs__list[data-fade~='end'] {
80
+ --tabs-fade-end: var(--space-8);
81
+ }
82
+
83
+ /* -- Tab - the hit target --------------------------------------------------
84
+ The button is pill + a bottom gap down to the baseline (total height 36px).
85
+ All visual feedback lives on the inner pill so hover/press/focus read as a
86
+ soft inset shape, while the ink below stays anchored to the baseline. */
87
+ .tab {
88
+ appearance: none;
89
+ border: 0;
90
+ background: none;
91
+ margin: 0;
92
+ padding: 0 0 var(--space-2);
93
+ flex: none;
94
+ display: block;
95
+ font-family: var(--font-sans);
96
+ cursor: pointer;
97
+ -webkit-tap-highlight-color: transparent;
98
+ }
99
+ .tab:focus-visible {
100
+ outline: none;
101
+ }
102
+
103
+ .tab__pad {
104
+ position: relative; /* containing block for the pill - NO isolation: the
105
+ pill's z-index:-1 must resolve in the LIST's
106
+ stacking context (z-index:1), so the in-flight
107
+ pill paints beneath EVERY tab's content. Isolating
108
+ here traps it per-tab - a later (right) sibling's
109
+ pill then paints OVER an earlier tab's label while
110
+ traveling left-right. */
111
+ display: flex;
112
+ align-items: center;
113
+ gap: var(--space-2);
114
+ height: var(--control-height-sm); /* 28 */
115
+ padding-inline: var(--space-2);
116
+ border-radius: var(--radius-md);
117
+ font-size: var(--size-body);
118
+ font-weight: var(
119
+ --weight-medium
120
+ ); /* constant weight - selection
121
+ never reflows the row */
122
+ line-height: 1;
123
+ white-space: nowrap;
124
+ color: var(--text-muted);
125
+ transition:
126
+ var(--transition-control),
127
+ transform var(--duration-fast) var(--ease-standard);
128
+ }
129
+ .tab__pad .icon {
130
+ display: inline-flex;
131
+ }
132
+
133
+ /* States - hover strengthens the label (the shared glide pill supplies the
134
+ background), press dips 1px on a darker fill (tactile, like Button), focus
135
+ is the composed accent ring, selection is text-strong + the ink. */
136
+ .tab:hover:not(:disabled) .tab__pad {
137
+ color: var(--text-body);
138
+ }
139
+ .tab:active:not(:disabled) .tab__pad {
140
+ background: var(--bg-muted);
141
+ transform: translateY(var(--space-px));
142
+ }
143
+ .tab.is-selected .tab__pad,
144
+ .tab.is-selected:hover:not(:disabled) .tab__pad {
145
+ color: var(--text-strong);
146
+ }
147
+ .tab:focus-visible .tab__pad {
148
+ box-shadow: var(--ring-accent);
149
+ }
150
+ .tab:disabled {
151
+ cursor: not-allowed;
152
+ }
153
+ .tab:disabled .tab__pad {
154
+ color: var(--text-disabled);
155
+ }
156
+
157
+ /* -- Count - always mono, always tabular -----------------------------------*/
158
+ .tab__count {
159
+ font-family: var(--font-mono);
160
+ font-size: var(--size-micro);
161
+ line-height: 1;
162
+ font-variant-numeric: tabular-nums;
163
+ color: var(--text-subtle);
164
+ background: var(--bg-muted);
165
+ padding: var(--space-1);
166
+ border-radius: var(--radius-sm);
167
+ transition: var(--transition-colors);
168
+ }
169
+ .tab.is-selected .tab__count {
170
+ color: var(--text-accent);
171
+ background: var(--accent-wash);
172
+ }
173
+ .tab:disabled .tab__count {
174
+ color: var(--text-disabled);
175
+ background: var(--bg-subtle);
176
+ }
177
+
178
+ /* -- The gliding hover ------------------------------------------------------
179
+ One shared pill, rendered inside whichever tab is hovered (layoutId - FLIP
180
+ moves it between tabs). Paint only: geometry is inset 0 of the pad; the
181
+ negative z-index resolves in the LIST's stacking context (see .tab__pad),
182
+ keeping the pill under every tab's label in BOTH travel directions. Motion
183
+ writes transform/opacity at runtime; nothing transitions here. */
184
+ .tab__hover {
185
+ position: absolute;
186
+ inset: 0;
187
+ z-index: -1;
188
+ border-radius: var(--radius-md);
189
+ background: var(--bg-muted); /* not -subtle: ~ invisible on --bg-app */
190
+ will-change: transform, opacity;
191
+ }
192
+
193
+ /* -- The ink ---------------------------------------------------------------
194
+ Positioned in the scroller's layout coordinates (offsetLeft space), so it
195
+ scrolls WITH its tab. Motion writes transform + width at runtime; resting
196
+ geometry is set the same way, so there is nothing to transition here.
197
+ EXCEPTION (intentional, mirrors RadioGroup's dot dims): 2px ink height is a
198
+ bespoke control dim - elevation.css reserves 2px for selected emphasis, but
199
+ ships no width token for it (--border-emphasis is 1.5px, which renders as
200
+ sub-pixel fuzz on 1x screens). Do not "fix" it to a token. */
201
+ .tabs__ink {
202
+ --tabs-ink-h: 2px;
203
+ position: absolute;
204
+ left: 0;
205
+ bottom: 0;
206
+ height: var(--tabs-ink-h);
207
+ border-radius: var(--radius-full);
208
+ background: var(--accent);
209
+ pointer-events: none;
210
+ will-change: transform, width;
211
+ }
212
+
213
+ /* -- Panel -----------------------------------------------------------------
214
+ The panel ROOT is static chrome - borders, padding, background put on it
215
+ by the consumer never move. Only .tab-panel__inner (the content) enters
216
+ from the direction of travel (Motion, TabPanel). The root is focusable per
217
+ the APG (tabindex=0) so keyboard users land on content right after the
218
+ tablist; the ring shows only for keyboard focus. */
219
+ .tab-panel {
220
+ outline: none;
221
+ }
222
+ .tab-panel:focus-visible {
223
+ box-shadow: var(--ring-accent);
224
+ border-radius: var(--radius-md);
225
+ }
226
+ .tab-panel__inner {
227
+ will-change: transform, opacity;
228
+ }