aplance-ui 1.4.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.
- 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/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/scroll-rail/scroll-rail.module.css +104 -0
- package/registry/components/ui/scroll-rail/scroll-rail.tsx +159 -0
- 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 +33 -3
|
@@ -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
|
|
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
|
-
/**
|
|
72
|
-
|
|
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?:
|
|
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 (
|
|
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 {
|
|
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?:
|
|
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
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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 {
|
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
} from "react";
|
|
11
11
|
import clsx from "clsx";
|
|
12
12
|
import { useResizeObserver } from "../../../hooks/useResizeObserver";
|
|
13
|
+
import { ScrollRail } from "../scroll-rail/scroll-rail";
|
|
13
14
|
import s from "./tabs.module.css";
|
|
14
15
|
|
|
15
16
|
// Variants & Types
|
|
@@ -57,6 +58,8 @@ export const Tabs = ({
|
|
|
57
58
|
|
|
58
59
|
// `animate` separates deliberate tab switches from layout-driven re-fits.
|
|
59
60
|
const listRef = useRef<HTMLDivElement>(null);
|
|
61
|
+
// The rail's scroller, when the tabs are wider than their row.
|
|
62
|
+
const railRef = useRef<HTMLDivElement>(null);
|
|
60
63
|
const [indicator, setIndicator] = useState({ left: 0, width: 0, animate: true });
|
|
61
64
|
|
|
62
65
|
const updateIndicator = useCallback((id: string, animate: boolean) => {
|
|
@@ -66,21 +69,46 @@ export const Tabs = ({
|
|
|
66
69
|
if (!btn) return;
|
|
67
70
|
const listRect = list.getBoundingClientRect();
|
|
68
71
|
const btnRect = btn.getBoundingClientRect();
|
|
69
|
-
//
|
|
70
|
-
// stays
|
|
71
|
-
const left = btnRect.left - listRect.left
|
|
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;
|
|
72
75
|
const width = btnRect.width;
|
|
73
76
|
setIndicator((prev) =>
|
|
74
77
|
prev.left === left && prev.width === width ? prev : { left, width, animate },
|
|
75
78
|
);
|
|
76
79
|
}, []);
|
|
77
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",
|
|
101
|
+
});
|
|
102
|
+
}, []);
|
|
103
|
+
|
|
78
104
|
const prevActiveRef = useRef<string | null>(null);
|
|
79
105
|
useIsomorphicLayoutEffect(() => {
|
|
80
106
|
const isSwitch = prevActiveRef.current !== activeId;
|
|
107
|
+
const isFirst = prevActiveRef.current === null;
|
|
81
108
|
prevActiveRef.current = activeId;
|
|
82
109
|
updateIndicator(activeId, isSwitch);
|
|
83
|
-
|
|
110
|
+
revealActive(activeId, !isFirst);
|
|
111
|
+
}, [tabs, activeId, updateIndicator, revealActive]);
|
|
84
112
|
|
|
85
113
|
// Container and font reflows re-fit the indicator without animating.
|
|
86
114
|
useResizeObserver(listRef, () => updateIndicator(activeId, false));
|
|
@@ -92,47 +120,50 @@ export const Tabs = ({
|
|
|
92
120
|
|
|
93
121
|
return (
|
|
94
122
|
<div className={clsx(s.root, className)} {...rest}>
|
|
95
|
-
{/* Tab List
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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
|
+
/>
|
|
112
142
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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>
|
|
136
167
|
|
|
137
168
|
{/* Tab Panel */}
|
|
138
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:
|
|
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
|
-
|
|
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
|
package/registry/lib/string.ts
CHANGED
|
@@ -17,7 +17,7 @@ export function hashName(name: string): number {
|
|
|
17
17
|
* Extract 1-2 letter initials from a name (e.g. "John Doe" -> "JD")
|
|
18
18
|
*/
|
|
19
19
|
export function getInitials(name: string): string {
|
|
20
|
-
const
|
|
21
|
-
if (
|
|
22
|
-
return
|
|
20
|
+
const [first = "", second = ""] = name.trim().split(/\s+/);
|
|
21
|
+
if (second) return first.charAt(0) + second.charAt(0);
|
|
22
|
+
return first.substring(0, 2) || "?";
|
|
23
23
|
}
|