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.
Files changed (38) hide show
  1. package/package.json +1 -1
  2. package/registry/components/primitives/icon/icon.tsx +4 -6
  3. package/registry/components/primitives/modal/modal.module.css +5 -0
  4. package/registry/components/primitives/modal/modal.tsx +32 -34
  5. package/registry/components/primitives/popover/popover.module.css +4 -2
  6. package/registry/components/primitives/popover/popover.tsx +16 -17
  7. package/registry/components/primitives/scroll-rail/scroll-rail.module.css +148 -0
  8. package/registry/components/primitives/scroll-rail/scroll-rail.tsx +214 -0
  9. package/registry/components/ui/accordion/accordion.module.css +52 -0
  10. package/registry/components/ui/accordion/accordion.tsx +97 -25
  11. package/registry/components/ui/avatar/avatar.tsx +2 -2
  12. package/registry/components/ui/breadcrumbs/breadcrumbs.module.css +9 -9
  13. package/registry/components/ui/button/button.module.css +43 -5
  14. package/registry/components/ui/button/button.tsx +2 -1
  15. package/registry/components/ui/copy-button/copy-button.module.css +2 -0
  16. package/registry/components/ui/data-table/data-table.module.css +68 -25
  17. package/registry/components/ui/data-table/data-table.tsx +1238 -1229
  18. package/registry/components/ui/data-table/filters.tsx +21 -0
  19. package/registry/components/ui/data-table/pagination-bar.tsx +31 -17
  20. package/registry/components/ui/data-table/types.ts +11 -1
  21. package/registry/components/ui/data-table/useDataTable.tsx +1 -2
  22. package/registry/components/ui/data-table/utils.ts +4 -3
  23. package/registry/components/ui/input-fields/common/input-field-common.module.css +8 -2
  24. package/registry/components/ui/input-fields/common/picker-popover-common.module.css +4 -0
  25. package/registry/components/ui/input-fields/custom-select/custom-select.module.css +115 -84
  26. package/registry/components/ui/input-fields/custom-select/custom-select.tsx +63 -11
  27. package/registry/components/ui/input-fields/date-picker/date-picker.tsx +51 -55
  28. package/registry/components/ui/input-fields/input/input.tsx +348 -348
  29. package/registry/components/ui/input-fields/media-input/media-input.tsx +3 -1
  30. package/registry/components/ui/input-fields/media-input/types.ts +8 -2
  31. package/registry/components/ui/input-fields/media-input/useMediaUpload.tsx +5 -5
  32. package/registry/components/ui/input-fields/media-input/utils.ts +18 -5
  33. package/registry/components/ui/tabs/tabs.module.css +8 -12
  34. package/registry/components/ui/tabs/tabs.tsx +75 -44
  35. package/registry/components/ui/tooltip/tooltip.module.css +8 -3
  36. package/registry/components/ui/tooltip/tooltip.tsx +16 -3
  37. package/registry/lib/string.ts +3 -3
  38. package/registry/registry.json +32 -3
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "aplance-ui",
3
- "version": "1.4.0",
3
+ "version": "1.5.1",
4
4
  "description": "The aplance-ui CLI - scaffold the design system and copy components into your project as source.",
5
5
  "keywords": [
6
6
  "react",
@@ -31,12 +31,10 @@ export const Icon: FC<IconProps> = ({
31
31
  className={clsx(
32
32
  s.wrapper,
33
33
  s[size],
34
- {
35
- [s.disabled]: disabled,
36
- [s.clickable]: !!onClick || clickable,
37
- [s.rotate]: rotate,
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
- nestedTriggers.push(child);
218
- } else {
219
- dialogChildren.push(child);
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: setShouldRender(true) → node mounts → useLayoutEffect below fires → isVisible=true → open transition
250
- // Close: setIsVisible(false) immediately → close transition plays → setTimeout unmounts after duration
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
- setShouldRender(true);
254
- } else {
255
- setIsVisible(false);
256
- const t = setTimeout(() => {
257
- setShouldRender(false);
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 after the node is painted for the first time.
265
- // Force-reads offsetHeight to flush the browser's style recalc so it
266
- // registers the closed CSS state before we flip to open - same trick
267
- // Popover uses with isMeasured + ResizeObserver.
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(isOpen);
270
+ setIsVisible(true);
272
271
  }
273
- // eslint-disable-next-line react-hooks/exhaustive-deps
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 (focusable.length === 0) {
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
- {childrenSnapshot.current}
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="ghost"
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
- min-width: fit-content;
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 && menuRef.current) {
251
- calculatePosition();
252
-
253
- if (typeof ResizeObserver === "undefined") {
254
- return () => setIsMeasured(false);
255
- }
250
+ if (!shouldRender || !menuRef.current) return;
251
+ calculatePosition();
256
252
 
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
- };
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;