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.
- package/LICENSE +21 -0
- package/README.md +113 -0
- package/dist/alert.d.ts +31 -0
- package/dist/alert.js +6 -0
- package/dist/alert.js.map +1 -0
- package/dist/avatar-group.d.ts +13 -0
- package/dist/avatar-group.js +3 -0
- package/dist/avatar-group.js.map +1 -0
- package/dist/avatar.d.ts +25 -0
- package/dist/avatar.js +3 -0
- package/dist/avatar.js.map +1 -0
- package/dist/badge.d.ts +23 -0
- package/dist/badge.js +3 -0
- package/dist/badge.js.map +1 -0
- package/dist/button.d.ts +20 -0
- package/dist/button.js +3 -0
- package/dist/button.js.map +1 -0
- package/dist/checkbox.d.ts +25 -0
- package/dist/checkbox.js +3 -0
- package/dist/checkbox.js.map +1 -0
- package/dist/chunk-2OWHZ4JT.js +36 -0
- package/dist/chunk-2OWHZ4JT.js.map +1 -0
- package/dist/chunk-34SIXSYL.js +64 -0
- package/dist/chunk-34SIXSYL.js.map +1 -0
- package/dist/chunk-37O2ZXD6.js +55 -0
- package/dist/chunk-37O2ZXD6.js.map +1 -0
- package/dist/chunk-4AZL76UJ.js +89 -0
- package/dist/chunk-4AZL76UJ.js.map +1 -0
- package/dist/chunk-4HSCN5TZ.js +86 -0
- package/dist/chunk-4HSCN5TZ.js.map +1 -0
- package/dist/chunk-5DDOOT33.js +258 -0
- package/dist/chunk-5DDOOT33.js.map +1 -0
- package/dist/chunk-5FVHWIMY.js +117 -0
- package/dist/chunk-5FVHWIMY.js.map +1 -0
- package/dist/chunk-5K6KRJGX.js +147 -0
- package/dist/chunk-5K6KRJGX.js.map +1 -0
- package/dist/chunk-5PQMQBQC.js +74 -0
- package/dist/chunk-5PQMQBQC.js.map +1 -0
- package/dist/chunk-7OCTVQ7C.js +95 -0
- package/dist/chunk-7OCTVQ7C.js.map +1 -0
- package/dist/chunk-7OPMOET7.js +39 -0
- package/dist/chunk-7OPMOET7.js.map +1 -0
- package/dist/chunk-BXXS7YRC.js +270 -0
- package/dist/chunk-BXXS7YRC.js.map +1 -0
- package/dist/chunk-CV2Q4YXX.js +272 -0
- package/dist/chunk-CV2Q4YXX.js.map +1 -0
- package/dist/chunk-EIMMDWIW.js +282 -0
- package/dist/chunk-EIMMDWIW.js.map +1 -0
- package/dist/chunk-EZ2CWTBE.js +230 -0
- package/dist/chunk-EZ2CWTBE.js.map +1 -0
- package/dist/chunk-FGHDG3Y4.js +89 -0
- package/dist/chunk-FGHDG3Y4.js.map +1 -0
- package/dist/chunk-FPP2XLKX.js +127 -0
- package/dist/chunk-FPP2XLKX.js.map +1 -0
- package/dist/chunk-G6OY35DI.js +295 -0
- package/dist/chunk-G6OY35DI.js.map +1 -0
- package/dist/chunk-H6KWJNOE.js +65 -0
- package/dist/chunk-H6KWJNOE.js.map +1 -0
- package/dist/chunk-HGILYGY3.js +45 -0
- package/dist/chunk-HGILYGY3.js.map +1 -0
- package/dist/chunk-I3BCB4Z5.js +88 -0
- package/dist/chunk-I3BCB4Z5.js.map +1 -0
- package/dist/chunk-KBWNUUWM.js +582 -0
- package/dist/chunk-KBWNUUWM.js.map +1 -0
- package/dist/chunk-KN7JFAZ6.js +113 -0
- package/dist/chunk-KN7JFAZ6.js.map +1 -0
- package/dist/chunk-MEF7PI6U.js +16 -0
- package/dist/chunk-MEF7PI6U.js.map +1 -0
- package/dist/chunk-NKGMQL6I.js +310 -0
- package/dist/chunk-NKGMQL6I.js.map +1 -0
- package/dist/chunk-NMFQRGLL.js +127 -0
- package/dist/chunk-NMFQRGLL.js.map +1 -0
- package/dist/chunk-OUBWD6CX.js +433 -0
- package/dist/chunk-OUBWD6CX.js.map +1 -0
- package/dist/chunk-PFNXVBLU.js +96 -0
- package/dist/chunk-PFNXVBLU.js.map +1 -0
- package/dist/chunk-PUPZ4HME.js +165 -0
- package/dist/chunk-PUPZ4HME.js.map +1 -0
- package/dist/chunk-QFS52OK5.js +690 -0
- package/dist/chunk-QFS52OK5.js.map +1 -0
- package/dist/chunk-QNC6O3PG.js +45 -0
- package/dist/chunk-QNC6O3PG.js.map +1 -0
- package/dist/chunk-QUHOXWBK.js +82 -0
- package/dist/chunk-QUHOXWBK.js.map +1 -0
- package/dist/chunk-UIQGSTBJ.js +106 -0
- package/dist/chunk-UIQGSTBJ.js.map +1 -0
- package/dist/chunk-UJQKVP6V.js +193 -0
- package/dist/chunk-UJQKVP6V.js.map +1 -0
- package/dist/chunk-VVPGEAC6.js +11 -0
- package/dist/chunk-VVPGEAC6.js.map +1 -0
- package/dist/chunk-XA3T5KWA.js +58 -0
- package/dist/chunk-XA3T5KWA.js.map +1 -0
- package/dist/chunk-YSHJHSJM.js +19 -0
- package/dist/chunk-YSHJHSJM.js.map +1 -0
- package/dist/chunk-YVHOAVSM.js +182 -0
- package/dist/chunk-YVHOAVSM.js.map +1 -0
- package/dist/collapse.d.ts +16 -0
- package/dist/collapse.js +3 -0
- package/dist/collapse.js.map +1 -0
- package/dist/count-badge.d.ts +11 -0
- package/dist/count-badge.js +4 -0
- package/dist/count-badge.js.map +1 -0
- package/dist/date-field.d.ts +39 -0
- package/dist/date-field.js +8 -0
- package/dist/date-field.js.map +1 -0
- package/dist/date-range-field.d.ts +30 -0
- package/dist/date-range-field.js +8 -0
- package/dist/date-range-field.js.map +1 -0
- package/dist/datetime-field.d.ts +28 -0
- package/dist/datetime-field.js +10 -0
- package/dist/datetime-field.js.map +1 -0
- package/dist/dialog.d.ts +26 -0
- package/dist/dialog.js +7 -0
- package/dist/dialog.js.map +1 -0
- package/dist/index.d.ts +35 -0
- package/dist/index.js +40 -0
- package/dist/index.js.map +1 -0
- package/dist/motion-tokens.d.ts +29 -0
- package/dist/motion-tokens.js +3 -0
- package/dist/motion-tokens.js.map +1 -0
- package/dist/multi-select.d.ts +25 -0
- package/dist/multi-select.js +7 -0
- package/dist/multi-select.js.map +1 -0
- package/dist/number-field.d.ts +24 -0
- package/dist/number-field.js +4 -0
- package/dist/number-field.js.map +1 -0
- package/dist/otp-field.d.ts +20 -0
- package/dist/otp-field.js +3 -0
- package/dist/otp-field.js.map +1 -0
- package/dist/overlay.d.ts +31 -0
- package/dist/overlay.js +4 -0
- package/dist/overlay.js.map +1 -0
- package/dist/pagination.d.ts +24 -0
- package/dist/pagination.js +5 -0
- package/dist/pagination.js.map +1 -0
- package/dist/radio-group.d.ts +46 -0
- package/dist/radio-group.js +6 -0
- package/dist/radio-group.js.map +1 -0
- package/dist/select-core-SAyS-8w0.d.ts +16 -0
- package/dist/select.d.ts +27 -0
- package/dist/select.js +7 -0
- package/dist/select.js.map +1 -0
- package/dist/status-badge.d.ts +17 -0
- package/dist/status-badge.js +5 -0
- package/dist/status-badge.js.map +1 -0
- package/dist/table.d.ts +65 -0
- package/dist/table.js +5 -0
- package/dist/table.js.map +1 -0
- package/dist/tabs.d.ts +44 -0
- package/dist/tabs.js +5 -0
- package/dist/tabs.js.map +1 -0
- package/dist/tag.d.ts +28 -0
- package/dist/tag.js +5 -0
- package/dist/tag.js.map +1 -0
- package/dist/text-field.d.ts +30 -0
- package/dist/text-field.js +6 -0
- package/dist/text-field.js.map +1 -0
- package/dist/textarea.d.ts +33 -0
- package/dist/textarea.js +5 -0
- package/dist/textarea.js.map +1 -0
- package/dist/time-field.d.ts +27 -0
- package/dist/time-field.js +6 -0
- package/dist/time-field.js.map +1 -0
- package/dist/toast-store.d.ts +75 -0
- package/dist/toast-store.js +3 -0
- package/dist/toast-store.js.map +1 -0
- package/dist/toast.d.ts +3 -0
- package/dist/toast.js +6 -0
- package/dist/toast.js.map +1 -0
- package/dist/toggle-tag.d.ts +24 -0
- package/dist/toggle-tag.js +4 -0
- package/dist/toggle-tag.js.map +1 -0
- package/dist/toggle.d.ts +21 -0
- package/dist/toggle.js +3 -0
- package/dist/toggle.js.map +1 -0
- package/dist/tooltip.d.ts +27 -0
- package/dist/tooltip.js +4 -0
- package/dist/tooltip.js.map +1 -0
- package/llms.txt +165 -0
- package/package.json +205 -0
- package/src/components/alert/Alert.tsx +118 -0
- package/src/components/alert/alert.css +136 -0
- package/src/components/avatar/Avatar.tsx +128 -0
- package/src/components/avatar/AvatarGroup.tsx +50 -0
- package/src/components/avatar/avatar.css +200 -0
- package/src/components/badge/Badge.tsx +66 -0
- package/src/components/badge/CountBadge.tsx +46 -0
- package/src/components/badge/StatusBadge.tsx +132 -0
- package/src/components/badge/badge.css +243 -0
- package/src/components/button/Button.tsx +68 -0
- package/src/components/button/button.css +222 -0
- package/src/components/checkbox/Checkbox.tsx +90 -0
- package/src/components/checkbox/checkbox.css +179 -0
- package/src/components/date-picker/DateField.tsx +362 -0
- package/src/components/date-picker/DateRangeField.tsx +533 -0
- package/src/components/date-picker/DateTimeField.tsx +177 -0
- package/src/components/date-picker/TimeField.tsx +100 -0
- package/src/components/date-picker/date-picker.css +591 -0
- package/src/components/date-picker/date-utils.ts +55 -0
- package/src/components/date-picker/field-shell.tsx +78 -0
- package/src/components/date-picker/glide-pill.tsx +81 -0
- package/src/components/date-picker/time-core.tsx +305 -0
- package/src/components/dialog/Dialog.tsx +181 -0
- package/src/components/dialog/dialog.css +170 -0
- package/src/components/glass/glass.css +100 -0
- package/src/components/icon/Icon.tsx +76 -0
- package/src/components/icon/IconSlot.tsx +11 -0
- package/src/components/icon/icon.css +33 -0
- package/src/components/input/NumberField.tsx +117 -0
- package/src/components/input/OtpField.tsx +118 -0
- package/src/components/input/TextField.tsx +123 -0
- package/src/components/input/input.css +335 -0
- package/src/components/motion/Collapse.tsx +33 -0
- package/src/components/motion/collapse.css +41 -0
- package/src/components/overlay/Overlay.tsx +239 -0
- package/src/components/overlay/overlay-core.tsx +565 -0
- package/src/components/overlay/overlay.css +119 -0
- package/src/components/overlay/sheet-drag.tsx +146 -0
- package/src/components/pagination/Pagination.tsx +140 -0
- package/src/components/pagination/pagination.css +48 -0
- package/src/components/radio-group/RadioGroup.tsx +182 -0
- package/src/components/radio-group/radio-group.css +277 -0
- package/src/components/select/MultiSelect.tsx +251 -0
- package/src/components/select/Select.tsx +235 -0
- package/src/components/select/select-core.tsx +417 -0
- package/src/components/select/select.css +386 -0
- package/src/components/table/Table.tsx +433 -0
- package/src/components/table/table.css +348 -0
- package/src/components/tabs/Tabs.tsx +371 -0
- package/src/components/tabs/tabs.css +228 -0
- package/src/components/tag/Tag.tsx +145 -0
- package/src/components/tag/ToggleTag.tsx +125 -0
- package/src/components/tag/tag.css +248 -0
- package/src/components/textarea/Textarea.tsx +197 -0
- package/src/components/textarea/textarea.css +219 -0
- package/src/components/toast/Toast.tsx +349 -0
- package/src/components/toast/toast-store.ts +266 -0
- package/src/components/toast/toast.css +233 -0
- package/src/components/toggle/Toggle.tsx +94 -0
- package/src/components/toggle/toggle.css +152 -0
- package/src/components/tooltip/Tooltip.tsx +365 -0
- package/src/components/tooltip/tooltip.css +86 -0
- package/src/index.ts +42 -0
- package/src/styles.css +39 -0
- package/src/tokens/avatar.css +20 -0
- package/src/tokens/color.css +56 -0
- package/src/tokens/elevation.css +20 -0
- package/src/tokens/fonts.css +3 -0
- package/src/tokens/glass.css +21 -0
- package/src/tokens/icons.css +7 -0
- package/src/tokens/layers.css +6 -0
- package/src/tokens/motion-tokens.ts +72 -0
- package/src/tokens/motion.css +49 -0
- package/src/tokens/radius.css +11 -0
- package/src/tokens/semantic.css +75 -0
- package/src/tokens/spacing.css +26 -0
- 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
|
+
}
|