aplance-ui 1.3.0 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) 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-context.ts +33 -29
  6. package/registry/components/primitives/popover/popover.module.css +4 -2
  7. package/registry/components/primitives/popover/popover.tsx +150 -37
  8. package/registry/components/ui/accordion/accordion.module.css +52 -0
  9. package/registry/components/ui/accordion/accordion.tsx +97 -25
  10. package/registry/components/ui/avatar/avatar.tsx +2 -2
  11. package/registry/components/ui/breadcrumbs/breadcrumbs.module.css +9 -9
  12. package/registry/components/ui/button/button.module.css +43 -5
  13. package/registry/components/ui/button/button.tsx +2 -1
  14. package/registry/components/ui/copy-button/copy-button.module.css +2 -0
  15. package/registry/components/ui/copy-button/copy-button.tsx +5 -0
  16. package/registry/components/ui/data-table/data-table.module.css +786 -710
  17. package/registry/components/ui/data-table/data-table.tsx +1238 -1211
  18. package/registry/components/ui/data-table/filter-modal.tsx +86 -0
  19. package/registry/components/ui/data-table/filters.tsx +21 -0
  20. package/registry/components/ui/data-table/pagination-bar.tsx +31 -17
  21. package/registry/components/ui/data-table/types.ts +19 -1
  22. package/registry/components/ui/data-table/useDataTable.tsx +2 -2
  23. package/registry/components/ui/data-table/utils.ts +30 -4
  24. package/registry/components/ui/input-fields/common/input-field-common.module.css +8 -2
  25. package/registry/components/ui/input-fields/common/picker-popover-common.module.css +4 -0
  26. package/registry/components/ui/input-fields/custom-select/custom-select.module.css +118 -76
  27. package/registry/components/ui/input-fields/custom-select/custom-select.tsx +552 -484
  28. package/registry/components/ui/input-fields/date-picker/date-picker.tsx +936 -933
  29. package/registry/components/ui/input-fields/date-picker/types.ts +2 -0
  30. package/registry/components/ui/input-fields/emoji-picker/emoji-picker.tsx +8 -0
  31. package/registry/components/ui/input-fields/input/input.tsx +348 -348
  32. package/registry/components/ui/input-fields/media-input/media-input.tsx +3 -1
  33. package/registry/components/ui/input-fields/media-input/types.ts +8 -2
  34. package/registry/components/ui/input-fields/media-input/useMediaUpload.tsx +5 -5
  35. package/registry/components/ui/input-fields/media-input/utils.ts +18 -5
  36. package/registry/components/ui/scroll-rail/scroll-rail.module.css +104 -0
  37. package/registry/components/ui/scroll-rail/scroll-rail.tsx +159 -0
  38. package/registry/components/ui/tabs/tabs.module.css +8 -12
  39. package/registry/components/ui/tabs/tabs.tsx +94 -50
  40. package/registry/components/ui/tooltip/tooltip.module.css +8 -3
  41. package/registry/components/ui/tooltip/tooltip.tsx +16 -3
  42. package/registry/hooks/useMediaQuery.ts +38 -0
  43. package/registry/lib/string.ts +3 -3
  44. package/registry/registry.json +54 -4
@@ -111,7 +111,9 @@ export const MediaInput = (props: MediaInputProps) => {
111
111
  (multiple ? "Drop files here or browse" : "Drop a file here or browse");
112
112
  const hintParts = [
113
113
  accept ? `Accepts ${accept}` : null,
114
- maxSize ? `Up to ${formatBytes(maxSize)} per file` : null,
114
+ typeof maxSize === "number" && maxSize > 0
115
+ ? `Up to ${formatBytes(maxSize)} per file`
116
+ : null,
115
117
  multiple && maxFiles ? `Max ${maxFiles} files` : null,
116
118
  ].filter(Boolean);
117
119
  const resolvedHint = dropzoneHint ?? hintParts.join(" - ");
@@ -58,6 +58,9 @@ export type MediaPreviewLayout = (typeof mediaPreviewLayouts)[number];
58
58
  export const mediaPreviewSizes = ["sm", "md", "lg"] as const;
59
59
  export type MediaPreviewSize = (typeof mediaPreviewSizes)[number];
60
60
 
61
+ /** A size cap for every file, or each file's own; undefined takes any size. */
62
+ export type MaxSize = number | ((file: File) => number | undefined);
63
+
61
64
  export type MediaInputProps = Omit<
62
65
  BaseInputFieldProps,
63
66
  "iconStart" | "iconEnd"
@@ -68,8 +71,11 @@ export type MediaInputProps = Omit<
68
71
  multiple?: boolean;
69
72
  /** Hard cap on accepted files (only enforced when `multiple`). */
70
73
  maxFiles?: number;
71
- /** Per-file size cap in bytes. */
72
- maxSize?: number;
74
+ /**
75
+ * Per-file size cap in bytes, or a function giving a file's own cap, for a
76
+ * field that takes kinds of file with caps of their own.
77
+ */
78
+ maxSize?: MaxSize;
73
79
 
74
80
  /**
75
81
  * Variant for the inner Browse button. Independent from `buttonStyle`
@@ -10,7 +10,7 @@ import {
10
10
  useRef,
11
11
  useState,
12
12
  } from "react";
13
- import type { MediaFile, MediaRejection } from "./types";
13
+ import type { MaxSize, MediaFile, MediaRejection } from "./types";
14
14
  import {
15
15
  revokePreviewUrl,
16
16
  validateIncomingFiles,
@@ -30,7 +30,7 @@ export type UseMediaUploadOptions = {
30
30
  accept?: string;
31
31
  multiple?: boolean;
32
32
  maxFiles?: number;
33
- maxSize?: number;
33
+ maxSize?: MaxSize;
34
34
 
35
35
  disabled?: boolean;
36
36
  readOnly?: boolean;
@@ -96,10 +96,10 @@ export function useMediaUpload({
96
96
  filesRef.current = isControlled ? value! : internal;
97
97
  }, [isControlled, value, internal]);
98
98
 
99
+ // Disabled or read-only ends a drag in progress, in the render that sees it.
100
+ if ((disabled || readOnly) && isDragging) setIsDragging(false);
99
101
  useEffect(() => {
100
- if (!disabled && !readOnly) return;
101
- dragCounter.current = 0;
102
- setIsDragging(false);
102
+ if (disabled || readOnly) dragCounter.current = 0;
103
103
  }, [disabled, readOnly]);
104
104
 
105
105
  // Revoke any preview URLs the hook created on unmount so blob memory is
@@ -1,4 +1,10 @@
1
- import type { MediaFile, MediaKind, MediaRejection, MediaRejectionCode } from "./types";
1
+ import type {
2
+ MaxSize,
3
+ MediaFile,
4
+ MediaKind,
5
+ MediaRejection,
6
+ MediaRejectionCode,
7
+ } from "./types";
2
8
 
3
9
  const SIZE_UNITS = ["B", "KB", "MB", "GB", "TB"] as const;
4
10
 
@@ -114,7 +120,7 @@ export const rejectionMessage = (
114
120
 
115
121
  export type ValidateOptions = {
116
122
  accept?: string;
117
- maxSize?: number;
123
+ maxSize?: MaxSize;
118
124
  maxFiles?: number;
119
125
  multiple: boolean;
120
126
  existing: MediaFile[];
@@ -142,10 +148,16 @@ export const validateIncomingFiles = (
142
148
  typeof options.maxFiles === "number" && Number.isFinite(options.maxFiles)
143
149
  ? Math.max(0, Math.floor(options.maxFiles))
144
150
  : undefined;
145
- const maxSize =
146
- typeof options.maxSize === "number" && Number.isFinite(options.maxSize)
147
- ? Math.max(0, options.maxSize)
151
+ // A file's own cap: the one for every file, or the one the function gives it.
152
+ const capOf = (file: File) => {
153
+ const cap =
154
+ typeof options.maxSize === "function"
155
+ ? options.maxSize(file)
156
+ : options.maxSize;
157
+ return typeof cap === "number" && Number.isFinite(cap)
158
+ ? Math.max(0, cap)
148
159
  : undefined;
160
+ };
149
161
  const remainingCapacity = options.multiple
150
162
  ? maxFiles !== undefined
151
163
  ? Math.max(0, maxFiles - options.existing.length)
@@ -161,6 +173,7 @@ export const validateIncomingFiles = (
161
173
  });
162
174
  continue;
163
175
  }
176
+ const maxSize = capOf(file);
164
177
  if (maxSize !== undefined && file.size > maxSize) {
165
178
  rejected.push({
166
179
  file,
@@ -0,0 +1,104 @@
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
+ /* The scroller. It shows no scrollbar, so the row never changes height, and
34
+ pads like a toolbar so its controls' focus rings show, since a scroller
35
+ clips both axes. */
36
+ .track {
37
+ --scroll-rail-start: 0px;
38
+ --scroll-rail-end: 0px;
39
+
40
+ flex: 1;
41
+ min-width: 0;
42
+ overflow-x: auto;
43
+ overscroll-behavior-x: contain;
44
+ scrollbar-width: none;
45
+ padding-block: var(--spacing-1);
46
+ -webkit-mask-image: linear-gradient(
47
+ to right,
48
+ transparent calc(var(--scroll-rail-start) - var(--scroll-rail-fade)),
49
+ #000 var(--scroll-rail-start),
50
+ #000 calc(100% - var(--scroll-rail-end)),
51
+ transparent calc(100% - var(--scroll-rail-end) + var(--scroll-rail-fade))
52
+ );
53
+ mask-image: linear-gradient(
54
+ to right,
55
+ transparent calc(var(--scroll-rail-start) - var(--scroll-rail-fade)),
56
+ #000 var(--scroll-rail-start),
57
+ #000 calc(100% - var(--scroll-rail-end)),
58
+ transparent calc(100% - var(--scroll-rail-end) + var(--scroll-rail-fade))
59
+ );
60
+ transition:
61
+ --scroll-rail-start var(--transition-fast),
62
+ --scroll-rail-end var(--transition-fast);
63
+
64
+ &::-webkit-scrollbar {
65
+ display: none;
66
+ }
67
+ }
68
+
69
+ /* An end with more past it: clear under its chevron, then the fade. */
70
+ .rail_start .track {
71
+ --scroll-rail-start: calc(
72
+ var(--scroll-rail-control) + var(--scroll-rail-fade)
73
+ );
74
+ }
75
+
76
+ .rail_end .track {
77
+ --scroll-rail-end: calc(var(--scroll-rail-control) + var(--scroll-rail-fade));
78
+ }
79
+
80
+ /* The items, at their own widths: the row scrolls rather than squeezing them. */
81
+ .content {
82
+ display: flex;
83
+ align-items: center;
84
+ width: max-content;
85
+
86
+ > * {
87
+ flex-shrink: 0;
88
+ }
89
+ }
90
+
91
+ /* A chevron, over the clear edge of its fade, on the row's middle. */
92
+ .more {
93
+ position: absolute;
94
+ top: 50%;
95
+ translate: 0 -50%;
96
+ }
97
+
98
+ .more_start {
99
+ left: 0;
100
+ }
101
+
102
+ .more_end {
103
+ right: 0;
104
+ }
@@ -0,0 +1,159 @@
1
+ import {
2
+ useCallback,
3
+ useEffect,
4
+ useRef,
5
+ useState,
6
+ type ReactNode,
7
+ type Ref,
8
+ } from "react";
9
+ import clsx from "clsx";
10
+ import { IconChevronLeft, IconChevronRight } from "@tabler/icons-react";
11
+ import { useResizeObserver } from "../../../hooks/useResizeObserver";
12
+ import { Button } from "../button/button";
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 ScrollRailProps = {
34
+ children: ReactNode;
35
+ /** The rail's own box, as a flex item where it sits. */
36
+ className?: string;
37
+ /** The row's layout around its items, such as their gap. */
38
+ contentClassName?: string;
39
+ /** The scroller, for a caller that scrolls it itself, as Tabs keeps its
40
+ * active tab in view. */
41
+ ref?: Ref<HTMLDivElement>;
42
+ };
43
+
44
+ type Ends = { start: boolean; end: boolean };
45
+
46
+ // A wheel's step in pixels, by the unit it reports.
47
+ const wheelUnit = (event: WheelEvent, track: HTMLElement) =>
48
+ event.deltaMode === WheelEvent.DOM_DELTA_LINE
49
+ ? 16
50
+ : event.deltaMode === WheelEvent.DOM_DELTA_PAGE
51
+ ? track.clientWidth
52
+ : 1;
53
+
54
+ export const ScrollRail = ({
55
+ children,
56
+ className,
57
+ contentClassName,
58
+ ref,
59
+ }: ScrollRailProps) => {
60
+ const trackRef = useRef<HTMLDivElement | null>(null);
61
+ const setTrack = useCallback(
62
+ (node: HTMLDivElement | null) => {
63
+ trackRef.current = node;
64
+ if (typeof ref === "function") ref(node);
65
+ else if (ref) ref.current = node;
66
+ },
67
+ [ref],
68
+ );
69
+ const contentRef = useRef<HTMLDivElement | null>(null);
70
+ const [ends, setEnds] = useState<Ends>({ start: false, end: false });
71
+
72
+ // Whether there is more past each end; a scroll renders only when one flips.
73
+ const measure = useCallback(() => {
74
+ const track = trackRef.current;
75
+ if (!track) return;
76
+ const left = track.scrollWidth - track.clientWidth - track.scrollLeft;
77
+ const start = track.scrollLeft > 1;
78
+ const end = left > 1;
79
+ setEnds((was) =>
80
+ was.start === start && was.end === end ? was : { start, end },
81
+ );
82
+ }, []);
83
+
84
+ // The row's width and its items' change apart: a resize of either.
85
+ useResizeObserver(trackRef, measure);
86
+ useResizeObserver(contentRef, measure);
87
+
88
+ useEffect(() => {
89
+ const track = trackRef.current;
90
+ if (!track) return;
91
+ const onWheel = (event: WheelEvent) => {
92
+ // A sideways swipe moves it natively.
93
+ if (Math.abs(event.deltaX) >= Math.abs(event.deltaY)) return;
94
+ const max = track.scrollWidth - track.clientWidth;
95
+ const left = Math.min(
96
+ max,
97
+ Math.max(0, track.scrollLeft + event.deltaY * wheelUnit(event, track)),
98
+ );
99
+ if (left === track.scrollLeft) return;
100
+ event.preventDefault();
101
+ track.scrollLeft = left;
102
+ };
103
+ track.addEventListener("wheel", onWheel, { passive: false });
104
+ return () => track.removeEventListener("wheel", onWheel);
105
+ }, []);
106
+
107
+ const page = (direction: -1 | 1) => {
108
+ const track = trackRef.current;
109
+ if (!track) return;
110
+ const still = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
111
+ track.scrollBy({
112
+ left: direction * track.clientWidth * PAGE,
113
+ behavior: still ? "auto" : "smooth",
114
+ });
115
+ };
116
+
117
+ return (
118
+ <div
119
+ className={clsx(
120
+ s.rail,
121
+ ends.start && s.rail_start,
122
+ ends.end && s.rail_end,
123
+ className,
124
+ )}
125
+ >
126
+ <div ref={setTrack} className={s.track} onScroll={measure}>
127
+ <div ref={contentRef} className={clsx(s.content, contentClassName)}>
128
+ {children}
129
+ </div>
130
+ </div>
131
+ {ends.start ? (
132
+ <Button
133
+ type="button"
134
+ variant="bordered"
135
+ size="sm"
136
+ layout="box"
137
+ className={clsx(s.more, s.more_start)}
138
+ aria-label="Scroll left"
139
+ onClick={() => page(-1)}
140
+ >
141
+ <IconChevronLeft size={14} stroke={2} />
142
+ </Button>
143
+ ) : null}
144
+ {ends.end ? (
145
+ <Button
146
+ type="button"
147
+ variant="bordered"
148
+ size="sm"
149
+ layout="box"
150
+ className={clsx(s.more, s.more_end)}
151
+ aria-label="Scroll right"
152
+ onClick={() => page(1)}
153
+ >
154
+ <IconChevronRight size={14} stroke={2} />
155
+ </Button>
156
+ ) : null}
157
+ </div>
158
+ );
159
+ };
@@ -10,23 +10,19 @@
10
10
 
11
11
  /* ─────────────────────────────────────────────
12
12
  Tab list
13
+ It sits in a ScrollRail, which scrolls it when the tabs don't fit (many
14
+ status tabs on a phone) with fades and chevrons at the ends that have
15
+ more. The rail's row is at least its width, so the list still spans it.
13
16
  ───────────────────────────────────────────── */
17
+ .rail_content {
18
+ min-width: 100%;
19
+ }
20
+
14
21
  .list {
15
22
  position: relative;
16
23
  display: inline-flex;
24
+ flex: 1 0 auto;
17
25
  align-items: center;
18
- /* Scroll horizontally when the tabs don't fit (e.g. many status tabs on a
19
- phone) instead of clipping/overflowing the page. The active indicator is
20
- absolutely positioned inside, so it scrolls with the tabs and stays aligned.
21
- Scrollbar hidden for a clean strip - the partially-shown edge tab signals
22
- scrollability. */
23
- max-width: 100%;
24
- overflow-x: auto;
25
- scrollbar-width: none;
26
-
27
- &::-webkit-scrollbar {
28
- display: none;
29
- }
30
26
 
31
27
  /* style1 - pill container */
32
28
  &.list_style1 {
@@ -4,19 +4,26 @@ import {
4
4
  useEffect,
5
5
  useId,
6
6
  useCallback,
7
+ useLayoutEffect,
7
8
  type ReactNode,
8
9
  type ComponentProps,
9
10
  } from "react";
10
11
  import clsx from "clsx";
11
12
  import { useResizeObserver } from "../../../hooks/useResizeObserver";
13
+ import { ScrollRail } from "../scroll-rail/scroll-rail";
12
14
  import s from "./tabs.module.css";
13
15
 
14
16
  // Variants & Types
15
17
  export const variants = ["style1", "style2"] as const;
16
18
  export type Variant = (typeof variants)[number];
17
19
 
20
+ const useIsomorphicLayoutEffect =
21
+ typeof window === "undefined" ? useEffect : useLayoutEffect;
22
+
18
23
  export type TabItem = {
19
24
  id: string;
25
+ // Consumers can compose counts, badges, icons, etc. at the call site.
26
+ // Dynamic label changes re-measure the indicator without the slide animation.
20
27
  label: ReactNode;
21
28
  content?: ReactNode;
22
29
  disabled?: boolean;
@@ -49,32 +56,62 @@ export const Tabs = ({
49
56
 
50
57
  const activeId = isControlled ? controlledActive : internalActive;
51
58
 
52
- // Sliding indicator state
59
+ // `animate` separates deliberate tab switches from layout-driven re-fits.
53
60
  const listRef = useRef<HTMLDivElement>(null);
54
- const [indicator, setIndicator] = useState({ left: 0, width: 0 });
61
+ // The rail's scroller, when the tabs are wider than their row.
62
+ const railRef = useRef<HTMLDivElement>(null);
63
+ const [indicator, setIndicator] = useState({ left: 0, width: 0, animate: true });
55
64
 
56
- const updateIndicator = useCallback((id: string) => {
65
+ const updateIndicator = useCallback((id: string, animate: boolean) => {
57
66
  const list = listRef.current;
58
67
  if (!list) return;
59
68
  const btn = list.querySelector<HTMLButtonElement>(`[data-tab-id="${id}"]`);
60
69
  if (!btn) return;
61
70
  const listRect = list.getBoundingClientRect();
62
71
  const btnRect = btn.getBoundingClientRect();
63
- setIndicator({
64
- // `list` is a horizontal scroller and the indicator is absolutely
65
- // positioned inside it, so its `left` is measured in content coordinates.
66
- // Add scrollLeft, otherwise the indicator drifts away from the active tab
67
- // once the strip is scrolled.
68
- left: btnRect.left - listRect.left + list.scrollLeft,
69
- width: btnRect.width,
72
+ // Measured against the list, which scrolls with its tabs inside the rail,
73
+ // so the indicator stays on its tab wherever the strip is scrolled.
74
+ const left = btnRect.left - listRect.left;
75
+ const width = btnRect.width;
76
+ setIndicator((prev) =>
77
+ prev.left === left && prev.width === width ? prev : { left, width, animate },
78
+ );
79
+ }, []);
80
+
81
+ // The active tab in view: a strip wider than its row opens on its tab, and
82
+ // scrolls to each one chosen that sits past an edge. A tab already in view
83
+ // stays where it is.
84
+ const revealActive = useCallback((id: string, animate: boolean) => {
85
+ const rail = railRef.current;
86
+ const btn = listRef.current?.querySelector<HTMLButtonElement>(
87
+ `[data-tab-id="${id}"]`,
88
+ );
89
+ if (!rail || !btn) return;
90
+ const railRect = rail.getBoundingClientRect();
91
+ const btnRect = btn.getBoundingClientRect();
92
+ if (btnRect.left >= railRect.left && btnRect.right <= railRect.right) return;
93
+ const still = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
94
+ rail.scrollTo({
95
+ left:
96
+ rail.scrollLeft +
97
+ btnRect.left -
98
+ railRect.left -
99
+ (railRect.width - btnRect.width) / 2,
100
+ behavior: animate && !still ? "smooth" : "auto",
70
101
  });
71
102
  }, []);
72
103
 
73
- useEffect(() => {
74
- updateIndicator(activeId);
75
- }, [activeId, updateIndicator]);
104
+ const prevActiveRef = useRef<string | null>(null);
105
+ useIsomorphicLayoutEffect(() => {
106
+ const isSwitch = prevActiveRef.current !== activeId;
107
+ const isFirst = prevActiveRef.current === null;
108
+ prevActiveRef.current = activeId;
109
+ updateIndicator(activeId, isSwitch);
110
+ revealActive(activeId, !isFirst);
111
+ }, [tabs, activeId, updateIndicator, revealActive]);
76
112
 
77
- useResizeObserver(listRef, () => updateIndicator(activeId));
113
+ // Container and font reflows re-fit the indicator without animating.
114
+ useResizeObserver(listRef, () => updateIndicator(activeId, false));
78
115
  const handleSelect = (id: string) => {
79
116
  if (!isControlled) setInternalActive(id);
80
117
  onTabChange?.(id);
@@ -83,43 +120,50 @@ export const Tabs = ({
83
120
 
84
121
  return (
85
122
  <div className={clsx(s.root, className)} {...rest}>
86
- {/* Tab List */}
87
- <div
88
- ref={listRef}
89
- role="tablist"
90
- className={clsx(s.list, s[`list_${variant}`])}
91
- aria-orientation="horizontal"
92
- >
93
- {/* Animated indicator */}
94
- <span
95
- className={clsx(s.indicator, s[`indicator_${variant}`])}
96
- style={{ left: indicator.left, width: indicator.width }}
97
- aria-hidden="true"
98
- />
123
+ {/* Tab List - in a rail that scrolls it, with fades and chevrons,
124
+ when the tabs are wider than their row */}
125
+ <ScrollRail ref={railRef} contentClassName={s.rail_content}>
126
+ <div
127
+ ref={listRef}
128
+ role="tablist"
129
+ className={clsx(s.list, s[`list_${variant}`])}
130
+ aria-orientation="horizontal"
131
+ >
132
+ {/* Animated indicator */}
133
+ <span
134
+ className={clsx(s.indicator, s[`indicator_${variant}`])}
135
+ style={{
136
+ left: indicator.left,
137
+ width: indicator.width,
138
+ transition: indicator.animate ? undefined : "none",
139
+ }}
140
+ aria-hidden="true"
141
+ />
99
142
 
100
- {tabs.map((tab) => {
101
- const isActive = tab.id === activeId;
102
- return (
103
- <button
104
- key={tab.id}
105
- role="tab"
106
- id={`${uid}-tab-${tab.id}`}
107
- aria-controls={`${uid}-panel-${tab.id}`}
108
- aria-selected={isActive}
109
- disabled={tab.disabled}
110
- data-tab-id={tab.id}
111
- className={clsx(
112
- s.tab,
113
- s[`tab_${variant}`],
114
- isActive && s.tab_active,
115
- )}
116
- onClick={() => !tab.disabled && handleSelect(tab.id)}
117
- >
118
- {tab.label}
119
- </button>
120
- );
121
- })}
122
- </div>
143
+ {tabs.map((tab) => {
144
+ const isActive = tab.id === activeId;
145
+ return (
146
+ <button
147
+ key={tab.id}
148
+ role="tab"
149
+ id={`${uid}-tab-${tab.id}`}
150
+ aria-controls={`${uid}-panel-${tab.id}`}
151
+ aria-selected={isActive}
152
+ disabled={tab.disabled}
153
+ data-tab-id={tab.id}
154
+ className={clsx(
155
+ s.tab,
156
+ s[`tab_${variant}`],
157
+ isActive && s.tab_active,
158
+ )}
159
+ onClick={() => !tab.disabled && handleSelect(tab.id)}
160
+ >
161
+ {tab.label}
162
+ </button>
163
+ );
164
+ })}
165
+ </div>
166
+ </ScrollRail>
123
167
 
124
168
  {/* Tab Panel */}
125
169
  {activeContent !== undefined && (
@@ -2,10 +2,15 @@
2
2
  not including the popover selectors because it's not at all required
3
3
  */
4
4
 
5
+ /* The cap, applied together with the space the popover found (tooltip.tsx).
6
+ Zero specificity, so a class passed through `popoverMenu.className` can set
7
+ its own. */
8
+ :where(.container) {
9
+ --tooltip-max-width: 250px;
10
+ }
11
+
5
12
  .container {
6
- width: 100%;
7
- min-width: fit-content;
8
- max-width: 250px;
13
+ width: max-content;
9
14
  background-color: var(--color-secondary);
10
15
  color: var(--color-secondary-foreground);
11
16
  border-radius: var(--radius-sm);
@@ -1,4 +1,4 @@
1
- import { type ReactNode, type FC } from "react";
1
+ import { type CSSProperties, type ReactNode, type FC } from "react";
2
2
  import {
3
3
  Popover,
4
4
  PopoverTrigger,
@@ -26,6 +26,16 @@ export type TooltipProps = {
26
26
  popoverMenu?: ComponentProps<typeof PopoverMenu>;
27
27
  };
28
28
 
29
+ // The menu's auto positioning sizes it as a dropdown: `overflow: auto` (which
30
+ // turns the arrow, outside the box, into a scrollbar) and a `max-width` of the
31
+ // space it found (which would replace the tooltip's own cap). A tooltip never
32
+ // scrolls and hugs its text up to the smaller of its cap and that space.
33
+ const menuStyle: CSSProperties = {
34
+ overflow: "visible",
35
+ maxWidth:
36
+ "min(var(--tooltip-max-width), var(--popover-available-width, var(--tooltip-max-width)))",
37
+ };
38
+
29
39
  export const Tooltip: FC<TooltipProps> = ({
30
40
  content,
31
41
  children,
@@ -53,10 +63,13 @@ export const Tooltip: FC<TooltipProps> = ({
53
63
  <PopoverTrigger asChild {...popoverTrigger}>
54
64
  {children}
55
65
  </PopoverTrigger>
56
- {/* Using menu as a direct content wrapper */}
66
+ {/* Using menu as a direct content wrapper. Sized by its text, not by
67
+ what it points at. */}
57
68
  <PopoverMenu
58
- className={clsx(s.container, popoverMenu?.className)}
69
+ matchTriggerWidth={false}
59
70
  {...popoverMenu}
71
+ className={clsx(s.container, popoverMenu?.className)}
72
+ style={{ ...menuStyle, ...popoverMenu?.style }}
60
73
  >
61
74
  {showArrow && (
62
75
  <div