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,146 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
/* sheet-drag - drag-to-dismiss for Overlay's sheet mode (dismiss - scrim - rubber-band - scroll handoff). */
|
|
4
|
+
import * as React from 'react';
|
|
5
|
+
import { useMotionValue, useTransform, useDragControls, animate, type PanInfo } from 'motion/react';
|
|
6
|
+
import { UIMotion } from '../../tokens/motion-tokens';
|
|
7
|
+
|
|
8
|
+
const sdSM = UIMotion;
|
|
9
|
+
const { useEffect: sdUseEffect, useRef: sdUseRef } = React;
|
|
10
|
+
|
|
11
|
+
const DISMISS_RATIO = 0.4;
|
|
12
|
+
const DISMISS_VELOCITY = 500;
|
|
13
|
+
const INTENT_PX = 4;
|
|
14
|
+
const STRETCH_MAX = 0.06;
|
|
15
|
+
|
|
16
|
+
/* nearest scrollable ancestor of `node`, stopping at the slot */
|
|
17
|
+
function findScrollable(node: EventTarget | null, stop: HTMLElement | null): HTMLElement | null {
|
|
18
|
+
let el = node instanceof Element ? node : null;
|
|
19
|
+
while (el && el !== stop) {
|
|
20
|
+
const s = getComputedStyle(el);
|
|
21
|
+
if (
|
|
22
|
+
/(auto|scroll)/.test(s.overflowY + s.overflowX) &&
|
|
23
|
+
(el.scrollHeight > el.clientHeight || el.scrollWidth > el.clientWidth)
|
|
24
|
+
)
|
|
25
|
+
return el as HTMLElement;
|
|
26
|
+
el = el.parentElement;
|
|
27
|
+
}
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function useSheetDrag({
|
|
32
|
+
side,
|
|
33
|
+
slotRef,
|
|
34
|
+
enabled,
|
|
35
|
+
requestClose,
|
|
36
|
+
}: {
|
|
37
|
+
side: 'right' | 'bottom';
|
|
38
|
+
slotRef: React.RefObject<HTMLElement>;
|
|
39
|
+
enabled: boolean;
|
|
40
|
+
requestClose: () => void;
|
|
41
|
+
}) {
|
|
42
|
+
const axis = side === 'bottom' ? 'y' : 'x';
|
|
43
|
+
/* matches the `closed` variant so the scrim starts transparent */
|
|
44
|
+
const travel = useMotionValue<string | number>('100%');
|
|
45
|
+
const stretch = useMotionValue(1);
|
|
46
|
+
const controls = useDragControls();
|
|
47
|
+
const savedUserSelect = sdUseRef<string | null>(null);
|
|
48
|
+
|
|
49
|
+
/* suspend selection only while dragging; restore also covers unmount mid-drag */
|
|
50
|
+
function suspendSelection() {
|
|
51
|
+
const sel = window.getSelection();
|
|
52
|
+
if (sel) sel.removeAllRanges();
|
|
53
|
+
savedUserSelect.current = document.body.style.userSelect;
|
|
54
|
+
document.body.style.userSelect = 'none';
|
|
55
|
+
}
|
|
56
|
+
function restoreSelection() {
|
|
57
|
+
if (savedUserSelect.current === null) return;
|
|
58
|
+
document.body.style.userSelect = savedUserSelect.current;
|
|
59
|
+
savedUserSelect.current = null;
|
|
60
|
+
}
|
|
61
|
+
sdUseEffect(() => restoreSelection, []); // eslint-disable-line react-hooks/exhaustive-deps
|
|
62
|
+
|
|
63
|
+
const progress = useTransform(travel, (v) => {
|
|
64
|
+
if (typeof v === 'string') return Math.min(Math.max(parseFloat(v) / 100 || 0, 0), 1);
|
|
65
|
+
const el = slotRef.current;
|
|
66
|
+
const size = el ? (axis === 'y' ? el.offsetHeight : el.offsetWidth) : Infinity;
|
|
67
|
+
return Math.min(Math.max(v / size, 0), 1);
|
|
68
|
+
});
|
|
69
|
+
const scrimOpacity = useTransform(progress, (p) => 1 - p);
|
|
70
|
+
|
|
71
|
+
/* pointer intent: watch the first few px, then hand off to drag or to scroll/selection */
|
|
72
|
+
function onPointerDown(e: React.PointerEvent) {
|
|
73
|
+
if (e.button !== 0) return;
|
|
74
|
+
const startX = e.clientX,
|
|
75
|
+
startY = e.clientY;
|
|
76
|
+
const scrollable = findScrollable(e.target, slotRef.current);
|
|
77
|
+
const onMove = (ev: PointerEvent) => {
|
|
78
|
+
const dx = ev.clientX - startX,
|
|
79
|
+
dy = ev.clientY - startY;
|
|
80
|
+
if (Math.max(Math.abs(dx), Math.abs(dy)) < INTENT_PX) return;
|
|
81
|
+
cleanup();
|
|
82
|
+
const along = axis === 'y' ? dy : dx;
|
|
83
|
+
const cross = axis === 'y' ? dx : dy;
|
|
84
|
+
if (Math.abs(along) <= Math.abs(cross)) return;
|
|
85
|
+
if (scrollable) {
|
|
86
|
+
if (along < 0) return;
|
|
87
|
+
if (axis === 'y' ? scrollable.scrollTop > 0 : scrollable.scrollLeft > 0) return;
|
|
88
|
+
}
|
|
89
|
+
suspendSelection();
|
|
90
|
+
/* framer may not fire onDragEnd (release exactly at threshold), so restore here too - idempotent with the onDragEnd restore */
|
|
91
|
+
window.addEventListener('pointerup', restoreSelection, { once: true });
|
|
92
|
+
window.addEventListener('pointercancel', restoreSelection, { once: true });
|
|
93
|
+
controls.start(ev);
|
|
94
|
+
};
|
|
95
|
+
const cleanup = () => {
|
|
96
|
+
window.removeEventListener('pointermove', onMove);
|
|
97
|
+
window.removeEventListener('pointerup', cleanup);
|
|
98
|
+
window.removeEventListener('pointercancel', cleanup);
|
|
99
|
+
};
|
|
100
|
+
window.addEventListener('pointermove', onMove);
|
|
101
|
+
window.addEventListener('pointerup', cleanup);
|
|
102
|
+
window.addEventListener('pointercancel', cleanup);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/* overdrag away from the edge - damped, capped stretch (info.offset is raw; travel is clamped) */
|
|
106
|
+
function onDrag(_ev: PointerEvent | MouseEvent | TouchEvent, info: PanInfo) {
|
|
107
|
+
const el = slotRef.current;
|
|
108
|
+
if (!el) return;
|
|
109
|
+
const size = axis === 'y' ? el.offsetHeight : el.offsetWidth;
|
|
110
|
+
const o = info.offset[axis];
|
|
111
|
+
stretch.set(o < 0 ? 1 + Math.min(-o / size, 1) * STRETCH_MAX : 1);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function onDragEnd(_ev: PointerEvent | MouseEvent | TouchEvent, info: PanInfo) {
|
|
115
|
+
restoreSelection();
|
|
116
|
+
animate(stretch, 1, sdSM.t.settle);
|
|
117
|
+
const el = slotRef.current;
|
|
118
|
+
if (!el) return;
|
|
119
|
+
const size = axis === 'y' ? el.offsetHeight : el.offsetWidth;
|
|
120
|
+
if (info.offset[axis] > size * DISMISS_RATIO || info.velocity[axis] > DISMISS_VELOCITY)
|
|
121
|
+
requestClose();
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const stretchStyle =
|
|
125
|
+
axis === 'y' ? { scaleY: stretch, originY: 1 } : { scaleX: stretch, originX: 1 };
|
|
126
|
+
|
|
127
|
+
const slotProps = enabled
|
|
128
|
+
? {
|
|
129
|
+
drag: axis,
|
|
130
|
+
dragControls: controls,
|
|
131
|
+
dragListener: false,
|
|
132
|
+
dragMomentum: false,
|
|
133
|
+
dragConstraints: { top: 0, bottom: 0, left: 0, right: 0 },
|
|
134
|
+
/* away from the edge: hard clamp (stretch covers it); toward: free */
|
|
135
|
+
dragElastic: axis === 'y' ? { top: 0, bottom: 1 } : { left: 0, right: 1 },
|
|
136
|
+
onPointerDown,
|
|
137
|
+
onDrag,
|
|
138
|
+
onDragEnd,
|
|
139
|
+
style: { [axis]: travel, ...stretchStyle },
|
|
140
|
+
}
|
|
141
|
+
: { style: { [axis]: travel } }; // scrim coupling still applies
|
|
142
|
+
|
|
143
|
+
return { slotProps, scrimOpacity };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export { useSheetDrag };
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
/* Pagination - pure cursor strip: mono range readout + prev/next, no page numbers. */
|
|
4
|
+
|
|
5
|
+
import * as React from 'react';
|
|
6
|
+
import { animate } from 'motion/react';
|
|
7
|
+
import { UIMotion } from '../../tokens/motion-tokens';
|
|
8
|
+
import { Icon } from '../icon/Icon';
|
|
9
|
+
|
|
10
|
+
export interface PaginationProps {
|
|
11
|
+
/** Accessible name for the nav landmark - name the list ("Posts"), not "pagination". @default 'Pagination' */
|
|
12
|
+
label?: string;
|
|
13
|
+
/** Items currently shown, 1-based inclusive: `[from, to]` (e.g. `[26, 50]`). */
|
|
14
|
+
range: [number, number];
|
|
15
|
+
/** Total item count - render `of N` only when the API reports one; omit for endless lists. */
|
|
16
|
+
total?: number | null;
|
|
17
|
+
/** A previous cursor exists. @default false */
|
|
18
|
+
hasPrev?: boolean;
|
|
19
|
+
/** A next cursor exists. @default false */
|
|
20
|
+
hasNext?: boolean;
|
|
21
|
+
/** Fired when the previous arrow is pressed. */
|
|
22
|
+
onPrev?: () => void;
|
|
23
|
+
/** Fired when the next arrow is pressed. */
|
|
24
|
+
onNext?: () => void;
|
|
25
|
+
/** A page fetch is in flight: both arrows go inert, the clicked arrow carries the spinner. @default false */
|
|
26
|
+
loading?: boolean;
|
|
27
|
+
className?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const { useEffect, useRef } = React;
|
|
31
|
+
|
|
32
|
+
/* --space-2 in real px - tokens resolve to "0.5rem"; bare parseFloat gives 0.5, so convert rem-px */
|
|
33
|
+
let pgnTravelPx: number | null = null;
|
|
34
|
+
function pgnTravel() {
|
|
35
|
+
if (pgnTravelPx == null) {
|
|
36
|
+
const cs = getComputedStyle(document.documentElement);
|
|
37
|
+
const v = cs.getPropertyValue('--space-2').trim();
|
|
38
|
+
const n = parseFloat(v);
|
|
39
|
+
pgnTravelPx = v.endsWith('rem') ? n * parseFloat(cs.fontSize) : n;
|
|
40
|
+
}
|
|
41
|
+
return pgnTravelPx;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/* thin-space thousands grouping ("48 210") - section E mono-numeral convention */
|
|
45
|
+
function pgnFormat(n: number) {
|
|
46
|
+
return n.toLocaleString('en-US').replace(/,/g, '\u2009');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function Pagination({
|
|
50
|
+
label = 'Pagination',
|
|
51
|
+
range,
|
|
52
|
+
total = null,
|
|
53
|
+
hasPrev = false,
|
|
54
|
+
hasNext = false,
|
|
55
|
+
onPrev,
|
|
56
|
+
onNext,
|
|
57
|
+
loading = false,
|
|
58
|
+
className = '',
|
|
59
|
+
}: PaginationProps) {
|
|
60
|
+
const rangeRef = useRef<HTMLSpanElement>(null);
|
|
61
|
+
const prevBtnRef = useRef<HTMLButtonElement>(null);
|
|
62
|
+
const nextBtnRef = useRef<HTMLButtonElement>(null);
|
|
63
|
+
const shownRef = useRef<[number, number]>(range); // last range rendered (skip first mount)
|
|
64
|
+
const lastDirRef = useRef(0); // -1 prev - +1 next - last arrow fired
|
|
65
|
+
|
|
66
|
+
const from = range[0];
|
|
67
|
+
const to = range[1];
|
|
68
|
+
|
|
69
|
+
/* direction-aware entrance: new numbers arrive from the side you traveled toward (one persistent node, imperative keyframes) */
|
|
70
|
+
useEffect(() => {
|
|
71
|
+
const pf = shownRef.current[0],
|
|
72
|
+
pt = shownRef.current[1];
|
|
73
|
+
if (pf === from && pt === to) return;
|
|
74
|
+
const dir = from > pf ? 1 : -1;
|
|
75
|
+
shownRef.current = range;
|
|
76
|
+
animate(rangeRef.current, { x: [dir * pgnTravel(), 0], opacity: [0, 1] }, UIMotion.t.enter);
|
|
77
|
+
}, [from, to]);
|
|
78
|
+
|
|
79
|
+
/* edge disables the focused arrow and focus falls to <body> - hand it to the surviving direction; activeElement guard avoids stealing focus */
|
|
80
|
+
useEffect(() => {
|
|
81
|
+
if (loading || document.activeElement !== document.body) return;
|
|
82
|
+
const d = lastDirRef.current;
|
|
83
|
+
if (d === 1 && !hasNext && hasPrev && prevBtnRef.current) prevBtnRef.current.focus();
|
|
84
|
+
if (d === -1 && !hasPrev && hasNext && nextBtnRef.current) nextBtnRef.current.focus();
|
|
85
|
+
}, [loading, hasPrev, hasNext]);
|
|
86
|
+
|
|
87
|
+
const prevBusy = loading && lastDirRef.current === -1;
|
|
88
|
+
const nextBusy = loading && lastDirRef.current === 1;
|
|
89
|
+
|
|
90
|
+
return (
|
|
91
|
+
<nav
|
|
92
|
+
className={('pgn ' + className).trim()}
|
|
93
|
+
aria-label={label}
|
|
94
|
+
aria-busy={loading || undefined}
|
|
95
|
+
>
|
|
96
|
+
<span className="pgn__readout" aria-live="polite">
|
|
97
|
+
<span className="pgn__range" ref={rangeRef}>
|
|
98
|
+
<b>
|
|
99
|
+
{pgnFormat(from)}-{pgnFormat(to)}
|
|
100
|
+
</b>
|
|
101
|
+
{total != null ? <span className="pgn__total"> of {pgnFormat(total)}</span> : null}
|
|
102
|
+
</span>
|
|
103
|
+
</span>
|
|
104
|
+
<div className="pgn__nav">
|
|
105
|
+
<button
|
|
106
|
+
type="button"
|
|
107
|
+
ref={prevBtnRef}
|
|
108
|
+
className={
|
|
109
|
+
'btn btn--ghost btn--sm pgn__btn pgn__btn--prev' + (prevBusy ? ' is-loading' : '')
|
|
110
|
+
}
|
|
111
|
+
disabled={!hasPrev || loading}
|
|
112
|
+
aria-label="Previous page"
|
|
113
|
+
onClick={() => {
|
|
114
|
+
lastDirRef.current = -1;
|
|
115
|
+
if (onPrev) onPrev();
|
|
116
|
+
}}
|
|
117
|
+
>
|
|
118
|
+
<Icon name="caret-left" size="sm" />
|
|
119
|
+
{prevBusy ? <span className="btn__spinner" aria-hidden="true"></span> : null}
|
|
120
|
+
</button>
|
|
121
|
+
<button
|
|
122
|
+
type="button"
|
|
123
|
+
ref={nextBtnRef}
|
|
124
|
+
className={
|
|
125
|
+
'btn btn--ghost btn--sm pgn__btn pgn__btn--next' + (nextBusy ? ' is-loading' : '')
|
|
126
|
+
}
|
|
127
|
+
disabled={!hasNext || loading}
|
|
128
|
+
aria-label="Next page"
|
|
129
|
+
onClick={() => {
|
|
130
|
+
lastDirRef.current = 1;
|
|
131
|
+
if (onNext) onNext();
|
|
132
|
+
}}
|
|
133
|
+
>
|
|
134
|
+
<Icon name="caret-right" size="sm" />
|
|
135
|
+
{nextBusy ? <span className="btn__spinner" aria-hidden="true"></span> : null}
|
|
136
|
+
</button>
|
|
137
|
+
</div>
|
|
138
|
+
</nav>
|
|
139
|
+
);
|
|
140
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/* Pagination - cursor strip: layout, mono readout, square arrows + directional caret nudge (on Button vocabulary). */
|
|
2
|
+
|
|
3
|
+
.pgn {
|
|
4
|
+
display: inline-flex;
|
|
5
|
+
align-items: center;
|
|
6
|
+
gap: var(--space-3);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/* mono tabular readout; .pgn__range is the node Motion slides - keep it transformable (inline-block) */
|
|
10
|
+
.pgn__readout {
|
|
11
|
+
font: var(--type-mono);
|
|
12
|
+
font-variant-numeric: tabular-nums;
|
|
13
|
+
color: var(--text-subtle);
|
|
14
|
+
white-space: nowrap;
|
|
15
|
+
}
|
|
16
|
+
.pgn__range {
|
|
17
|
+
display: inline-block;
|
|
18
|
+
}
|
|
19
|
+
.pgn__range b {
|
|
20
|
+
font-weight: var(--weight-medium);
|
|
21
|
+
color: var(--text-body);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/* arrows kept adjacent (not flanking the readout) so repeated paging needs no mouse travel and a growing readout never shifts them */
|
|
25
|
+
.pgn__nav {
|
|
26
|
+
display: flex;
|
|
27
|
+
gap: var(--space-1);
|
|
28
|
+
}
|
|
29
|
+
.pgn__btn {
|
|
30
|
+
width: var(--control-height-sm);
|
|
31
|
+
padding: 0;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/* Caret nudges toward its direction on hover - a 1px optical cue. */
|
|
35
|
+
.pgn__btn .icon {
|
|
36
|
+
transition: transform var(--duration-fast) var(--ease-standard);
|
|
37
|
+
}
|
|
38
|
+
.pgn__btn--prev:hover:not(:disabled) .icon {
|
|
39
|
+
transform: translateX(calc(var(--space-px) * -1));
|
|
40
|
+
}
|
|
41
|
+
.pgn__btn--next:hover:not(:disabled) .icon {
|
|
42
|
+
transform: translateX(var(--space-px));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/* loading: clicked arrow shows Button's spinner - hide its caret like button.css hides .btn__icon */
|
|
46
|
+
.pgn__btn.is-loading .icon {
|
|
47
|
+
opacity: 0;
|
|
48
|
+
}
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
// RadioGroup - single-select rows or cards; one marker glides between dots via Motion layoutId.
|
|
4
|
+
|
|
5
|
+
import * as React from 'react';
|
|
6
|
+
import { motion, LayoutGroup } from 'motion/react';
|
|
7
|
+
import { Icon } from '../icon/Icon';
|
|
8
|
+
import { IconSlot } from '../icon/IconSlot';
|
|
9
|
+
import { UIMotion } from '../../tokens/motion-tokens';
|
|
10
|
+
|
|
11
|
+
const SM = UIMotion;
|
|
12
|
+
|
|
13
|
+
export interface RadioOption {
|
|
14
|
+
/** The stored value - what `onChange` returns and `value` matches. */
|
|
15
|
+
value: string;
|
|
16
|
+
/** Visible label (sentence case). */
|
|
17
|
+
label: React.ReactNode;
|
|
18
|
+
/** Optional secondary line (muted) explaining the choice. */
|
|
19
|
+
description?: React.ReactNode;
|
|
20
|
+
/** Leading icon - your own node. CARDS only; ignored for rows. */
|
|
21
|
+
icon?: React.ReactNode;
|
|
22
|
+
/** Disable just this option. */
|
|
23
|
+
disabled?: boolean;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface RadioGroupProps extends Omit<
|
|
27
|
+
React.FieldsetHTMLAttributes<HTMLFieldSetElement>,
|
|
28
|
+
'onChange'
|
|
29
|
+
> {
|
|
30
|
+
/** Shared radio name - ties the options into one keyboard group. Required. */
|
|
31
|
+
name: string;
|
|
32
|
+
/** The selected value (controlled). */
|
|
33
|
+
value?: string;
|
|
34
|
+
/** Called with the chosen option's `value`. */
|
|
35
|
+
onChange?: (value: string) => void;
|
|
36
|
+
/** Group label / legend (sentence case). */
|
|
37
|
+
label?: React.ReactNode;
|
|
38
|
+
/** Persistent context shown under the legend, before the options. */
|
|
39
|
+
helper?: React.ReactNode;
|
|
40
|
+
/** Group-level error (e.g. required). Sets the error state + reveals the message. */
|
|
41
|
+
error?: React.ReactNode;
|
|
42
|
+
/** Show a danger `*` after the label. */
|
|
43
|
+
required?: boolean;
|
|
44
|
+
/** Show a muted "(optional)" after the label. */
|
|
45
|
+
optional?: boolean;
|
|
46
|
+
/** Skin: quiet rows (default) or selectable cards. */
|
|
47
|
+
variant?: 'rows' | 'cards';
|
|
48
|
+
/** Lay options out in a line instead of a stack. */
|
|
49
|
+
orientation?: 'vertical' | 'horizontal';
|
|
50
|
+
/** Control size: sm (dot 16) - md (default, dot 18). */
|
|
51
|
+
size?: 'sm' | 'md';
|
|
52
|
+
/** Disable the whole group. */
|
|
53
|
+
disabled?: boolean;
|
|
54
|
+
/** The options to choose between. */
|
|
55
|
+
options: RadioOption[];
|
|
56
|
+
className?: string;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function RadioGroup({
|
|
60
|
+
name,
|
|
61
|
+
value,
|
|
62
|
+
onChange,
|
|
63
|
+
label,
|
|
64
|
+
helper,
|
|
65
|
+
error,
|
|
66
|
+
required,
|
|
67
|
+
optional,
|
|
68
|
+
variant = 'rows',
|
|
69
|
+
orientation = 'vertical',
|
|
70
|
+
size = 'md',
|
|
71
|
+
disabled,
|
|
72
|
+
options = [],
|
|
73
|
+
className = '',
|
|
74
|
+
...rest
|
|
75
|
+
}: RadioGroupProps) {
|
|
76
|
+
const groupId = React.useId();
|
|
77
|
+
|
|
78
|
+
const cls = [
|
|
79
|
+
'rg',
|
|
80
|
+
variant === 'cards' ? 'rg--cards' : '',
|
|
81
|
+
orientation === 'horizontal' ? 'rg--horizontal' : '',
|
|
82
|
+
size === 'sm' ? 'rg--sm' : '',
|
|
83
|
+
error ? 'is-error' : '',
|
|
84
|
+
className,
|
|
85
|
+
]
|
|
86
|
+
.filter(Boolean)
|
|
87
|
+
.join(' ');
|
|
88
|
+
|
|
89
|
+
return (
|
|
90
|
+
<fieldset className={cls} aria-invalid={error ? true : undefined} {...rest}>
|
|
91
|
+
{label && (
|
|
92
|
+
<div className="rg__head">
|
|
93
|
+
<legend className="rg__label">
|
|
94
|
+
{label}
|
|
95
|
+
{required && (
|
|
96
|
+
<span className="rg__req" aria-hidden="true">
|
|
97
|
+
*
|
|
98
|
+
</span>
|
|
99
|
+
)}
|
|
100
|
+
{optional && <span className="rg__optional">(optional)</span>}
|
|
101
|
+
</legend>
|
|
102
|
+
{helper && <p className="rg__helper">{helper}</p>}
|
|
103
|
+
</div>
|
|
104
|
+
)}
|
|
105
|
+
|
|
106
|
+
<LayoutGroup id={groupId}>
|
|
107
|
+
<div className="rg__options">
|
|
108
|
+
{options.map((opt) => {
|
|
109
|
+
const selected = opt.value === value;
|
|
110
|
+
const isDisabled = disabled || opt.disabled;
|
|
111
|
+
return (
|
|
112
|
+
<label
|
|
113
|
+
key={opt.value}
|
|
114
|
+
className={[
|
|
115
|
+
'rg-opt',
|
|
116
|
+
selected ? 'is-selected' : '',
|
|
117
|
+
isDisabled ? 'is-disabled' : '',
|
|
118
|
+
]
|
|
119
|
+
.filter(Boolean)
|
|
120
|
+
.join(' ')}
|
|
121
|
+
>
|
|
122
|
+
<input
|
|
123
|
+
className="rg-opt__input"
|
|
124
|
+
type="radio"
|
|
125
|
+
name={name}
|
|
126
|
+
value={opt.value}
|
|
127
|
+
checked={selected}
|
|
128
|
+
disabled={isDisabled}
|
|
129
|
+
onChange={() => onChange && onChange(opt.value)}
|
|
130
|
+
/>
|
|
131
|
+
{variant === 'cards' && selected && (
|
|
132
|
+
<motion.span
|
|
133
|
+
className="rg__card-fill"
|
|
134
|
+
layoutId="card-fill"
|
|
135
|
+
transition={SM.t.layout}
|
|
136
|
+
aria-hidden="true"
|
|
137
|
+
></motion.span>
|
|
138
|
+
)}
|
|
139
|
+
{variant === 'cards' && opt.icon && (
|
|
140
|
+
<span className="rg-opt__icon">
|
|
141
|
+
<IconSlot>{opt.icon}</IconSlot>
|
|
142
|
+
</span>
|
|
143
|
+
)}
|
|
144
|
+
<span className="rg-opt__control">
|
|
145
|
+
<span className="rg-opt__dot">
|
|
146
|
+
{selected && (
|
|
147
|
+
<motion.span
|
|
148
|
+
className="rg__marker"
|
|
149
|
+
layoutId="marker"
|
|
150
|
+
transition={SM.t.layout}
|
|
151
|
+
aria-hidden="true"
|
|
152
|
+
></motion.span>
|
|
153
|
+
)}
|
|
154
|
+
</span>
|
|
155
|
+
</span>
|
|
156
|
+
<span className="rg-opt__body">
|
|
157
|
+
<span className="rg-opt__label">{opt.label}</span>
|
|
158
|
+
{opt.description && <span className="rg-opt__desc">{opt.description}</span>}
|
|
159
|
+
</span>
|
|
160
|
+
</label>
|
|
161
|
+
);
|
|
162
|
+
})}
|
|
163
|
+
</div>
|
|
164
|
+
</LayoutGroup>
|
|
165
|
+
|
|
166
|
+
<div
|
|
167
|
+
className={'collapse collapse--fade rg__msg-wrap'}
|
|
168
|
+
data-open={error ? 'true' : 'false'}
|
|
169
|
+
data-axis="height"
|
|
170
|
+
>
|
|
171
|
+
<div className="collapse__inner">
|
|
172
|
+
<div className="rg__msg">
|
|
173
|
+
<Icon name="warning-circle" size="sm" weight="fill" />
|
|
174
|
+
{error}
|
|
175
|
+
</div>
|
|
176
|
+
</div>
|
|
177
|
+
</div>
|
|
178
|
+
</fieldset>
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export { RadioGroup };
|