aplance-ui 1.4.0 → 1.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/registry/components/primitives/icon/icon.tsx +4 -6
- package/registry/components/primitives/modal/modal.module.css +5 -0
- package/registry/components/primitives/modal/modal.tsx +32 -34
- package/registry/components/primitives/popover/popover.module.css +4 -2
- package/registry/components/primitives/popover/popover.tsx +16 -17
- package/registry/components/primitives/scroll-rail/scroll-rail.module.css +148 -0
- package/registry/components/primitives/scroll-rail/scroll-rail.tsx +214 -0
- package/registry/components/ui/accordion/accordion.module.css +52 -0
- package/registry/components/ui/accordion/accordion.tsx +97 -25
- package/registry/components/ui/avatar/avatar.tsx +2 -2
- package/registry/components/ui/breadcrumbs/breadcrumbs.module.css +9 -9
- package/registry/components/ui/button/button.module.css +43 -5
- package/registry/components/ui/button/button.tsx +2 -1
- package/registry/components/ui/copy-button/copy-button.module.css +2 -0
- package/registry/components/ui/data-table/data-table.module.css +68 -25
- package/registry/components/ui/data-table/data-table.tsx +1238 -1229
- package/registry/components/ui/data-table/filters.tsx +21 -0
- package/registry/components/ui/data-table/pagination-bar.tsx +31 -17
- package/registry/components/ui/data-table/types.ts +11 -1
- package/registry/components/ui/data-table/useDataTable.tsx +1 -2
- package/registry/components/ui/data-table/utils.ts +4 -3
- package/registry/components/ui/input-fields/common/input-field-common.module.css +8 -2
- package/registry/components/ui/input-fields/common/picker-popover-common.module.css +4 -0
- package/registry/components/ui/input-fields/custom-select/custom-select.module.css +115 -84
- package/registry/components/ui/input-fields/custom-select/custom-select.tsx +63 -11
- package/registry/components/ui/input-fields/date-picker/date-picker.tsx +51 -55
- package/registry/components/ui/input-fields/input/input.tsx +348 -348
- package/registry/components/ui/input-fields/media-input/media-input.tsx +3 -1
- package/registry/components/ui/input-fields/media-input/types.ts +8 -2
- package/registry/components/ui/input-fields/media-input/useMediaUpload.tsx +5 -5
- package/registry/components/ui/input-fields/media-input/utils.ts +18 -5
- package/registry/components/ui/tabs/tabs.module.css +8 -12
- package/registry/components/ui/tabs/tabs.tsx +75 -44
- package/registry/components/ui/tooltip/tooltip.module.css +8 -3
- package/registry/components/ui/tooltip/tooltip.tsx +16 -3
- package/registry/lib/string.ts +3 -3
- package/registry/registry.json +32 -3
package/package.json
CHANGED
|
@@ -31,12 +31,10 @@ export const Icon: FC<IconProps> = ({
|
|
|
31
31
|
className={clsx(
|
|
32
32
|
s.wrapper,
|
|
33
33
|
s[size],
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
[s.rotate_sub]: rotateSub,
|
|
39
|
-
},
|
|
34
|
+
disabled && s.disabled,
|
|
35
|
+
(!!onClick || clickable) && s.clickable,
|
|
36
|
+
rotate && s.rotate,
|
|
37
|
+
rotateSub && s.rotate_sub,
|
|
40
38
|
className,
|
|
41
39
|
)}
|
|
42
40
|
style={style}
|
|
@@ -21,6 +21,11 @@
|
|
|
21
21
|
opacity: 0;
|
|
22
22
|
transition: opacity var(--modal-animation-duration) var(--ease-in-out);
|
|
23
23
|
|
|
24
|
+
/* Closing, it fades out without taking clicks: the page under it answers. */
|
|
25
|
+
&[data-state='closed'] {
|
|
26
|
+
pointer-events: none;
|
|
27
|
+
}
|
|
28
|
+
|
|
24
29
|
&[data-state='open'] {
|
|
25
30
|
opacity: 1;
|
|
26
31
|
}
|
|
@@ -207,23 +207,23 @@ export const Modal: FC<ModalProps> = ({
|
|
|
207
207
|
const contentRef = useRef<HTMLDivElement>(null);
|
|
208
208
|
const modalId = useId();
|
|
209
209
|
|
|
210
|
+
// Freeze dialog content during close animation so conditional content
|
|
211
|
+
// (e.g. {editAgent && <form>}) doesn't collapse before the animation ends:
|
|
212
|
+
// open, the dialog follows its children; closed, it keeps the last ones.
|
|
213
|
+
const [openChildren, setOpenChildren] = useState(children);
|
|
214
|
+
if (isOpen && openChildren !== children) setOpenChildren(children);
|
|
215
|
+
|
|
210
216
|
// Split children: <ModalTrigger> siblings render outside the dialog so they
|
|
211
217
|
// stay visible while closed; everything else is dialog content. Order is
|
|
212
218
|
// preserved within each bucket, which is all consumers care about.
|
|
213
219
|
const nestedTriggers: ReactNode[] = [];
|
|
214
|
-
const dialogChildren: ReactNode[] = [];
|
|
215
220
|
Children.forEach(children, (child) => {
|
|
216
|
-
if (isValidElement(child) && child.type === ModalTrigger)
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
+
if (isValidElement(child) && child.type === ModalTrigger) nestedTriggers.push(child);
|
|
222
|
+
});
|
|
223
|
+
const dialogChildren: ReactNode[] = [];
|
|
224
|
+
Children.forEach(isOpen ? children : openChildren, (child) => {
|
|
225
|
+
if (!(isValidElement(child) && child.type === ModalTrigger)) dialogChildren.push(child);
|
|
221
226
|
});
|
|
222
|
-
|
|
223
|
-
// Freeze dialog content during close animation so conditional content
|
|
224
|
-
// (e.g. {editAgent && <form>}) doesn't collapse before the animation ends.
|
|
225
|
-
const childrenSnapshot = useRef<ReactNode>(dialogChildren);
|
|
226
|
-
if (isOpen) childrenSnapshot.current = dialogChildren;
|
|
227
227
|
|
|
228
228
|
// Stash callbacks in refs so identity changes on the parent don't retrigger
|
|
229
229
|
// the open/close effect (which would leak timers and re-fire onOpen).
|
|
@@ -246,32 +246,30 @@ export const Modal: FC<ModalProps> = ({
|
|
|
246
246
|
const close = useCallback(() => setIsOpen(false), [setIsOpen]);
|
|
247
247
|
const toggle = useCallback(() => setIsOpen(!isOpen), [isOpen, setIsOpen]);
|
|
248
248
|
|
|
249
|
-
// Open:
|
|
250
|
-
// Close:
|
|
249
|
+
// Open: the node mounts in this render → useLayoutEffect below fires → isVisible=true → open transition
|
|
250
|
+
// Close: isVisible=false in this render → close transition plays → setTimeout unmounts after duration
|
|
251
|
+
if (isOpen && !shouldRender) setShouldRender(true);
|
|
252
|
+
if (!isOpen && isVisible) setIsVisible(false);
|
|
251
253
|
useEffect(() => {
|
|
252
|
-
if (isOpen)
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
onCloseRef.current?.();
|
|
259
|
-
}, animationDuration);
|
|
260
|
-
return () => clearTimeout(t);
|
|
261
|
-
}
|
|
254
|
+
if (isOpen) return undefined;
|
|
255
|
+
const t = setTimeout(() => {
|
|
256
|
+
setShouldRender(false);
|
|
257
|
+
onCloseRef.current?.();
|
|
258
|
+
}, animationDuration);
|
|
259
|
+
return () => clearTimeout(t);
|
|
262
260
|
}, [isOpen, animationDuration]);
|
|
263
261
|
|
|
264
|
-
// Fires synchronously
|
|
265
|
-
//
|
|
266
|
-
//
|
|
267
|
-
//
|
|
262
|
+
// Fires synchronously once the node is painted, and again when it is opened
|
|
263
|
+
// while still mounted (reopened during its close transition, which would
|
|
264
|
+
// otherwise stay closed). Force-reads offsetHeight to flush the browser's
|
|
265
|
+
// style recalc so it registers the closed CSS state before we flip to open -
|
|
266
|
+
// same trick Popover uses with isMeasured + ResizeObserver.
|
|
268
267
|
useLayoutEffect(() => {
|
|
269
|
-
if (shouldRender && mounted && contentRef.current) {
|
|
268
|
+
if (shouldRender && mounted && isOpen && contentRef.current) {
|
|
270
269
|
void contentRef.current.offsetHeight; // flush reflow
|
|
271
|
-
setIsVisible(
|
|
270
|
+
setIsVisible(true);
|
|
272
271
|
}
|
|
273
|
-
|
|
274
|
-
}, [shouldRender, mounted]); // only run when node mounts or mounted state changes
|
|
272
|
+
}, [shouldRender, mounted, isOpen]);
|
|
275
273
|
|
|
276
274
|
useEffect(() => {
|
|
277
275
|
if (isOpen) onOpenRef.current?.();
|
|
@@ -313,7 +311,7 @@ export const Modal: FC<ModalProps> = ({
|
|
|
313
311
|
|
|
314
312
|
const trap = (e: globalThis.KeyboardEvent) => {
|
|
315
313
|
if (e.key !== "Tab") return;
|
|
316
|
-
if (
|
|
314
|
+
if (!first || !last) {
|
|
317
315
|
e.preventDefault();
|
|
318
316
|
return;
|
|
319
317
|
}
|
|
@@ -388,7 +386,7 @@ export const Modal: FC<ModalProps> = ({
|
|
|
388
386
|
style={style}
|
|
389
387
|
>
|
|
390
388
|
{/* children render ONLY here - never outside the dialog */}
|
|
391
|
-
{
|
|
389
|
+
{dialogChildren}
|
|
392
390
|
</div>
|
|
393
391
|
</div>
|
|
394
392
|
) : null;
|
|
@@ -547,7 +545,7 @@ export const ModalHeader: FC<ComponentProps<"div">> = ({
|
|
|
547
545
|
{showCloseButton && (
|
|
548
546
|
<Button
|
|
549
547
|
as="button"
|
|
550
|
-
variant="
|
|
548
|
+
variant="bordered"
|
|
551
549
|
layout="box"
|
|
552
550
|
size="sm"
|
|
553
551
|
className={s.close_button}
|
|
@@ -7,8 +7,10 @@
|
|
|
7
7
|
justify-content: center;
|
|
8
8
|
|
|
9
9
|
.trigger {
|
|
10
|
-
width: 100%;
|
|
11
|
-
|
|
10
|
+
width: 100%;
|
|
11
|
+
/* At least as wide as its content, unless the host sets the variable (a
|
|
12
|
+
form field sets 0, so a long value truncates). */
|
|
13
|
+
min-width: var(--popover-trigger-min-width, fit-content);
|
|
12
14
|
display: flex;
|
|
13
15
|
align-items: center;
|
|
14
16
|
|
|
@@ -232,11 +232,11 @@ export const Popover: FC<PopoverProps> = ({
|
|
|
232
232
|
// in PopoverMenu, so we DON'T reset isMeasured here on close - that would lose
|
|
233
233
|
// the position cache and break re-opens that interrupt the exit animation.
|
|
234
234
|
// isMeasured is reset only when the menu actually unmounts (cleanup below).
|
|
235
|
+
// Open mounts the menu in this render; close unmounts it once the exit
|
|
236
|
+
// animation has played.
|
|
237
|
+
if (isOpen && !shouldRender) setShouldRender(true);
|
|
235
238
|
useEffect(() => {
|
|
236
|
-
if (isOpen)
|
|
237
|
-
setShouldRender(true);
|
|
238
|
-
return;
|
|
239
|
-
}
|
|
239
|
+
if (isOpen) return;
|
|
240
240
|
const timer = setTimeout(() => {
|
|
241
241
|
setShouldRender(false);
|
|
242
242
|
if (onClose) onClose();
|
|
@@ -247,21 +247,20 @@ export const Popover: FC<PopoverProps> = ({
|
|
|
247
247
|
// Measure after menu is rendered. Cleanup resets isMeasured so the next mount
|
|
248
248
|
// re-runs the entry animation from a known starting state.
|
|
249
249
|
useIsomorphicLayoutEffect(() => {
|
|
250
|
-
if (shouldRender
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
if (typeof ResizeObserver === "undefined") {
|
|
254
|
-
return () => setIsMeasured(false);
|
|
255
|
-
}
|
|
250
|
+
if (!shouldRender || !menuRef.current) return;
|
|
251
|
+
calculatePosition();
|
|
256
252
|
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
if (popoverRef.current) observer.observe(popoverRef.current);
|
|
260
|
-
return () => {
|
|
261
|
-
observer.disconnect();
|
|
262
|
-
setIsMeasured(false);
|
|
263
|
-
};
|
|
253
|
+
if (typeof ResizeObserver === "undefined") {
|
|
254
|
+
return () => setIsMeasured(false);
|
|
264
255
|
}
|
|
256
|
+
|
|
257
|
+
const observer = new ResizeObserver(() => calculatePosition());
|
|
258
|
+
observer.observe(menuRef.current);
|
|
259
|
+
if (popoverRef.current) observer.observe(popoverRef.current);
|
|
260
|
+
return () => {
|
|
261
|
+
observer.disconnect();
|
|
262
|
+
setIsMeasured(false);
|
|
263
|
+
};
|
|
265
264
|
}, [shouldRender, mounted, calculatePosition]);
|
|
266
265
|
|
|
267
266
|
// Clear any pending hover-close timer when the Popover unmounts so the
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/* ------------------------------------------------------------
|
|
2
|
+
Scroll rail: a row that scrolls sideways
|
|
3
|
+
Each end fades only while there is more past it, and a chevron sits in
|
|
4
|
+
that end. The fades are a mask, as InfiniteScroll's are, so the row
|
|
5
|
+
dissolves into whatever is behind it. Their widths are registered
|
|
6
|
+
properties, so an end eases in and out instead of snapping.
|
|
7
|
+
------------------------------------------------------------ */
|
|
8
|
+
|
|
9
|
+
@property --scroll-rail-start {
|
|
10
|
+
syntax: "<length>";
|
|
11
|
+
inherits: false;
|
|
12
|
+
initial-value: 0px;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
@property --scroll-rail-end {
|
|
16
|
+
syntax: "<length>";
|
|
17
|
+
inherits: false;
|
|
18
|
+
initial-value: 0px;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
.rail {
|
|
22
|
+
/* How wide each end's fade is, beyond the room its chevron takes: a little
|
|
23
|
+
over a control's corner, so a control passes under the edge rather than
|
|
24
|
+
being cut at it, and narrow enough that a short rail still shows its row. */
|
|
25
|
+
--scroll-rail-fade: var(--spacing-8);
|
|
26
|
+
--scroll-rail-control: calc(var(--spacing-10) + var(--spacing-2));
|
|
27
|
+
|
|
28
|
+
position: relative;
|
|
29
|
+
display: flex;
|
|
30
|
+
min-width: 0;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
.rail_without_controls {
|
|
34
|
+
--scroll-rail-control: 0px;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/* The scroller. It shows no scrollbar, so the row never changes height, and
|
|
38
|
+
pads like a toolbar so its controls' focus rings show, since a scroller
|
|
39
|
+
clips both axes. */
|
|
40
|
+
.track {
|
|
41
|
+
--scroll-rail-start: 0px;
|
|
42
|
+
--scroll-rail-end: 0px;
|
|
43
|
+
|
|
44
|
+
flex: 1;
|
|
45
|
+
min-width: 0;
|
|
46
|
+
overflow-x: auto;
|
|
47
|
+
overscroll-behavior-x: contain;
|
|
48
|
+
scrollbar-width: none;
|
|
49
|
+
padding-block: var(--spacing-1);
|
|
50
|
+
-webkit-mask-image: linear-gradient(
|
|
51
|
+
to right,
|
|
52
|
+
transparent calc(var(--scroll-rail-start) - var(--scroll-rail-fade)),
|
|
53
|
+
#000 var(--scroll-rail-start),
|
|
54
|
+
#000 calc(100% - var(--scroll-rail-end)),
|
|
55
|
+
transparent calc(100% - var(--scroll-rail-end) + var(--scroll-rail-fade))
|
|
56
|
+
);
|
|
57
|
+
mask-image: linear-gradient(
|
|
58
|
+
to right,
|
|
59
|
+
transparent calc(var(--scroll-rail-start) - var(--scroll-rail-fade)),
|
|
60
|
+
#000 var(--scroll-rail-start),
|
|
61
|
+
#000 calc(100% - var(--scroll-rail-end)),
|
|
62
|
+
transparent calc(100% - var(--scroll-rail-end) + var(--scroll-rail-fade))
|
|
63
|
+
);
|
|
64
|
+
transition:
|
|
65
|
+
--scroll-rail-start var(--transition-fast),
|
|
66
|
+
--scroll-rail-end var(--transition-fast);
|
|
67
|
+
|
|
68
|
+
&::-webkit-scrollbar {
|
|
69
|
+
display: none;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/* An end with more past it: clear under its chevron, then the fade. */
|
|
74
|
+
.rail_start .track {
|
|
75
|
+
--scroll-rail-start: calc(
|
|
76
|
+
var(--scroll-rail-control) + var(--scroll-rail-fade)
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
.rail_end .track {
|
|
81
|
+
--scroll-rail-end: calc(var(--scroll-rail-control) + var(--scroll-rail-fade));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/* The items, at their own widths: the row scrolls rather than squeezing them. */
|
|
85
|
+
.content {
|
|
86
|
+
display: flex;
|
|
87
|
+
align-items: center;
|
|
88
|
+
width: max-content;
|
|
89
|
+
|
|
90
|
+
> * {
|
|
91
|
+
flex-shrink: 0;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/* A chevron, over the clear edge of its fade, on the row's middle. The
|
|
96
|
+
primitive owns only neutral button chrome; native props let a caller replace
|
|
97
|
+
the icon, label, classes, and inline styles without depending on Button. */
|
|
98
|
+
.control {
|
|
99
|
+
position: absolute;
|
|
100
|
+
top: 50%;
|
|
101
|
+
translate: 0 -50%;
|
|
102
|
+
z-index: 1;
|
|
103
|
+
display: inline-flex;
|
|
104
|
+
align-items: center;
|
|
105
|
+
justify-content: center;
|
|
106
|
+
width: var(--spacing-10);
|
|
107
|
+
height: var(--spacing-10);
|
|
108
|
+
padding: 0;
|
|
109
|
+
appearance: none;
|
|
110
|
+
font: inherit;
|
|
111
|
+
color: var(--color-foreground);
|
|
112
|
+
background-color: var(--color-background);
|
|
113
|
+
border: 1px solid var(--color-border);
|
|
114
|
+
border-radius: var(--radius-md);
|
|
115
|
+
box-shadow: var(--shadow-sm);
|
|
116
|
+
cursor: pointer;
|
|
117
|
+
transition:
|
|
118
|
+
color var(--transition-base),
|
|
119
|
+
background-color var(--transition-base),
|
|
120
|
+
border-color var(--transition-base),
|
|
121
|
+
opacity var(--transition-base);
|
|
122
|
+
|
|
123
|
+
&:hover:not(:disabled) {
|
|
124
|
+
background-color: var(--color-muted);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
&:focus-visible {
|
|
128
|
+
outline: 2px solid var(--color-primary);
|
|
129
|
+
outline-offset: 2px;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
&:disabled {
|
|
133
|
+
cursor: not-allowed;
|
|
134
|
+
opacity: 0.5;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
> svg {
|
|
138
|
+
pointer-events: none;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
.control_start {
|
|
143
|
+
left: 0;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
.control_end {
|
|
147
|
+
right: 0;
|
|
148
|
+
}
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import {
|
|
2
|
+
useCallback,
|
|
3
|
+
useEffect,
|
|
4
|
+
useRef,
|
|
5
|
+
useState,
|
|
6
|
+
type ComponentPropsWithoutRef,
|
|
7
|
+
type ReactNode,
|
|
8
|
+
type Ref,
|
|
9
|
+
} from "react";
|
|
10
|
+
import clsx from "clsx";
|
|
11
|
+
import { IconChevronLeft, IconChevronRight } from "@tabler/icons-react";
|
|
12
|
+
import { useResizeObserver } from "../../../hooks/useResizeObserver";
|
|
13
|
+
import s from "./scroll-rail.module.css";
|
|
14
|
+
|
|
15
|
+
// A row that scrolls sideways, as a row of chips or tabs does on a phone:
|
|
16
|
+
//
|
|
17
|
+
// - It shows no scrollbar, as a tab list shows none, so it never changes the
|
|
18
|
+
// height of what holds it.
|
|
19
|
+
// - Each end fades only while there is more past it: a fade at an end the row
|
|
20
|
+
// has reached would claim content that is not there.
|
|
21
|
+
// - A chevron sits in each faded end and scrolls the row on by most of its
|
|
22
|
+
// width, so the item that was at the edge stays in view as an anchor. It is
|
|
23
|
+
// mounted only while there is more that way: a button left over the last
|
|
24
|
+
// item would take its clicks. Keyboard focus also scrolls a control into
|
|
25
|
+
// view, while the labelled chevrons offer an explicit alternative.
|
|
26
|
+
// - A mouse wheel turned over it moves it across, while it has further to go
|
|
27
|
+
// that way; past its ends the page scrolls as usual. A swipe moves it as
|
|
28
|
+
// any scroller.
|
|
29
|
+
|
|
30
|
+
/** How far a chevron scrolls, in widths of the row. */
|
|
31
|
+
const PAGE = 0.8;
|
|
32
|
+
|
|
33
|
+
export type ScrollRailControlProps = Omit<
|
|
34
|
+
ComponentPropsWithoutRef<"button">,
|
|
35
|
+
"type"
|
|
36
|
+
>;
|
|
37
|
+
|
|
38
|
+
export type ScrollRailControls = {
|
|
39
|
+
/** Class shared by both arrow buttons. */
|
|
40
|
+
className?: string;
|
|
41
|
+
/** Native button props for the previous arrow. `children` replaces its icon. */
|
|
42
|
+
previous?: ScrollRailControlProps;
|
|
43
|
+
/** Native button props for the next arrow. `children` replaces its icon. */
|
|
44
|
+
next?: ScrollRailControlProps;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
export type ScrollRailProps = Omit<
|
|
48
|
+
ComponentPropsWithoutRef<"div">,
|
|
49
|
+
"children"
|
|
50
|
+
> & {
|
|
51
|
+
children: ReactNode;
|
|
52
|
+
/** The rail's own box, as a flex item where it sits. */
|
|
53
|
+
className?: string;
|
|
54
|
+
/** Class merged onto the horizontal scroller. */
|
|
55
|
+
trackClassName?: string;
|
|
56
|
+
/** The row's layout around its items, such as their gap. */
|
|
57
|
+
contentClassName?: string;
|
|
58
|
+
/**
|
|
59
|
+
* Customize the overflow arrows with native button props, or hide them with
|
|
60
|
+
* `false`. A custom onClick runs first and can prevent the built-in scroll.
|
|
61
|
+
*/
|
|
62
|
+
controls?: false | ScrollRailControls;
|
|
63
|
+
/** The scroller, for a caller that scrolls it itself, as Tabs keeps its
|
|
64
|
+
* active tab in view. */
|
|
65
|
+
ref?: Ref<HTMLDivElement>;
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
type Ends = { start: boolean; end: boolean };
|
|
69
|
+
|
|
70
|
+
// A wheel's step in pixels, by the unit it reports.
|
|
71
|
+
const wheelUnit = (event: WheelEvent, track: HTMLElement) =>
|
|
72
|
+
event.deltaMode === WheelEvent.DOM_DELTA_LINE
|
|
73
|
+
? 16
|
|
74
|
+
: event.deltaMode === WheelEvent.DOM_DELTA_PAGE
|
|
75
|
+
? track.clientWidth
|
|
76
|
+
: 1;
|
|
77
|
+
|
|
78
|
+
export const ScrollRail = ({
|
|
79
|
+
children,
|
|
80
|
+
className,
|
|
81
|
+
trackClassName,
|
|
82
|
+
contentClassName,
|
|
83
|
+
controls,
|
|
84
|
+
ref,
|
|
85
|
+
...rootProps
|
|
86
|
+
}: ScrollRailProps) => {
|
|
87
|
+
const trackRef = useRef<HTMLDivElement | null>(null);
|
|
88
|
+
const setTrack = useCallback(
|
|
89
|
+
(node: HTMLDivElement | null) => {
|
|
90
|
+
trackRef.current = node;
|
|
91
|
+
if (typeof ref === "function") ref(node);
|
|
92
|
+
else if (ref) ref.current = node;
|
|
93
|
+
},
|
|
94
|
+
[ref],
|
|
95
|
+
);
|
|
96
|
+
const contentRef = useRef<HTMLDivElement | null>(null);
|
|
97
|
+
const [ends, setEnds] = useState<Ends>({ start: false, end: false });
|
|
98
|
+
|
|
99
|
+
// Whether there is more past each end; a scroll renders only when one flips.
|
|
100
|
+
const measure = useCallback(() => {
|
|
101
|
+
const track = trackRef.current;
|
|
102
|
+
if (!track) return;
|
|
103
|
+
const left = track.scrollWidth - track.clientWidth - track.scrollLeft;
|
|
104
|
+
const start = track.scrollLeft > 1;
|
|
105
|
+
const end = left > 1;
|
|
106
|
+
setEnds((was) =>
|
|
107
|
+
was.start === start && was.end === end ? was : { start, end },
|
|
108
|
+
);
|
|
109
|
+
}, []);
|
|
110
|
+
|
|
111
|
+
// The row's width and its items' change apart: a resize of either.
|
|
112
|
+
useResizeObserver(trackRef, measure);
|
|
113
|
+
useResizeObserver(contentRef, measure);
|
|
114
|
+
|
|
115
|
+
// Measure once after mount as well. ResizeObserver reports later size
|
|
116
|
+
// changes, but its initial notification is not synchronous in every host.
|
|
117
|
+
useEffect(() => {
|
|
118
|
+
measure();
|
|
119
|
+
}, [measure]);
|
|
120
|
+
|
|
121
|
+
useEffect(() => {
|
|
122
|
+
const track = trackRef.current;
|
|
123
|
+
if (!track) return;
|
|
124
|
+
const onWheel = (event: WheelEvent) => {
|
|
125
|
+
// A sideways swipe moves it natively.
|
|
126
|
+
if (Math.abs(event.deltaX) >= Math.abs(event.deltaY)) return;
|
|
127
|
+
const max = track.scrollWidth - track.clientWidth;
|
|
128
|
+
const left = Math.min(
|
|
129
|
+
max,
|
|
130
|
+
Math.max(0, track.scrollLeft + event.deltaY * wheelUnit(event, track)),
|
|
131
|
+
);
|
|
132
|
+
if (left === track.scrollLeft) return;
|
|
133
|
+
event.preventDefault();
|
|
134
|
+
track.scrollLeft = left;
|
|
135
|
+
};
|
|
136
|
+
track.addEventListener("wheel", onWheel, { passive: false });
|
|
137
|
+
return () => track.removeEventListener("wheel", onWheel);
|
|
138
|
+
}, []);
|
|
139
|
+
|
|
140
|
+
const page = (direction: -1 | 1) => {
|
|
141
|
+
const track = trackRef.current;
|
|
142
|
+
if (!track) return;
|
|
143
|
+
const still = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
144
|
+
track.scrollBy({
|
|
145
|
+
left: direction * track.clientWidth * PAGE,
|
|
146
|
+
behavior: still ? "auto" : "smooth",
|
|
147
|
+
});
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
const renderControl = (direction: -1 | 1) => {
|
|
151
|
+
const visible = direction === -1 ? ends.start : ends.end;
|
|
152
|
+
if (!visible || controls === false) return null;
|
|
153
|
+
|
|
154
|
+
const control = direction === -1 ? controls?.previous : controls?.next;
|
|
155
|
+
const {
|
|
156
|
+
children: controlIcon,
|
|
157
|
+
className: controlClassName,
|
|
158
|
+
onClick,
|
|
159
|
+
"aria-label": ariaLabel,
|
|
160
|
+
...controlProps
|
|
161
|
+
} = control ?? {};
|
|
162
|
+
const label = direction === -1 ? "Scroll left" : "Scroll right";
|
|
163
|
+
|
|
164
|
+
return (
|
|
165
|
+
<button
|
|
166
|
+
{...controlProps}
|
|
167
|
+
type="button"
|
|
168
|
+
className={clsx(
|
|
169
|
+
s.control,
|
|
170
|
+
direction === -1 ? s.control_start : s.control_end,
|
|
171
|
+
controls?.className,
|
|
172
|
+
controlClassName,
|
|
173
|
+
)}
|
|
174
|
+
aria-label={ariaLabel ?? label}
|
|
175
|
+
onClick={(event) => {
|
|
176
|
+
onClick?.(event);
|
|
177
|
+
if (!event.defaultPrevented) page(direction);
|
|
178
|
+
}}
|
|
179
|
+
>
|
|
180
|
+
{controlIcon ??
|
|
181
|
+
(direction === -1 ? (
|
|
182
|
+
<IconChevronLeft size={14} stroke={2} />
|
|
183
|
+
) : (
|
|
184
|
+
<IconChevronRight size={14} stroke={2} />
|
|
185
|
+
))}
|
|
186
|
+
</button>
|
|
187
|
+
);
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
return (
|
|
191
|
+
<div
|
|
192
|
+
{...rootProps}
|
|
193
|
+
className={clsx(
|
|
194
|
+
s.rail,
|
|
195
|
+
ends.start && s.rail_start,
|
|
196
|
+
ends.end && s.rail_end,
|
|
197
|
+
controls === false && s.rail_without_controls,
|
|
198
|
+
className,
|
|
199
|
+
)}
|
|
200
|
+
>
|
|
201
|
+
<div
|
|
202
|
+
ref={setTrack}
|
|
203
|
+
className={clsx(s.track, trackClassName)}
|
|
204
|
+
onScroll={measure}
|
|
205
|
+
>
|
|
206
|
+
<div ref={contentRef} className={clsx(s.content, contentClassName)}>
|
|
207
|
+
{children}
|
|
208
|
+
</div>
|
|
209
|
+
</div>
|
|
210
|
+
{renderControl(-1)}
|
|
211
|
+
{renderControl(1)}
|
|
212
|
+
</div>
|
|
213
|
+
);
|
|
214
|
+
};
|
|
@@ -110,6 +110,58 @@
|
|
|
110
110
|
padding: var(--spacing-5) var(--spacing-6);
|
|
111
111
|
}
|
|
112
112
|
|
|
113
|
+
/* A heading with controls beside its button (an item's actions, a toggle
|
|
114
|
+
button): the row takes the heading's padding and its shades, and the
|
|
115
|
+
button inside it takes what is left. */
|
|
116
|
+
.header {
|
|
117
|
+
display: flex;
|
|
118
|
+
align-items: center;
|
|
119
|
+
gap: var(--spacing-3);
|
|
120
|
+
width: 100%;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
.header_trigger {
|
|
124
|
+
flex: 1;
|
|
125
|
+
min-width: 0;
|
|
126
|
+
padding: 0;
|
|
127
|
+
background: transparent;
|
|
128
|
+
border-radius: var(--radius-sm);
|
|
129
|
+
|
|
130
|
+
&:focus-visible {
|
|
131
|
+
box-shadow: var(--shadow-ring);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
.header_bordered,
|
|
136
|
+
.header_minimal {
|
|
137
|
+
&:has(> .header_trigger:hover:not(:disabled)) {
|
|
138
|
+
background: var(--color-muted);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
.header_minimal {
|
|
143
|
+
border-radius: inherit;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
.actions {
|
|
147
|
+
flex-shrink: 0;
|
|
148
|
+
display: flex;
|
|
149
|
+
align-items: center;
|
|
150
|
+
gap: var(--spacing-2);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
.toggle {
|
|
154
|
+
flex-shrink: 0;
|
|
155
|
+
|
|
156
|
+
& svg {
|
|
157
|
+
transition: transform var(--transition-base);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
&.toggle_open svg {
|
|
161
|
+
transform: rotate(var(--accordion-icon-rotation, 180deg));
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
113
165
|
.title {
|
|
114
166
|
flex: 1;
|
|
115
167
|
min-width: 0;
|