@vitavision/ui 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE-APACHE +202 -0
- package/LICENSE-MIT +21 -0
- package/README.md +152 -0
- package/dist/index.d.ts +883 -0
- package/dist/index.js +1348 -0
- package/dist/index.js.map +1 -0
- package/package.json +65 -0
- package/src/components/Tabs.tsx +137 -0
- package/src/components/ThemeToggle.tsx +95 -0
- package/src/components/ui/Badge.tsx +125 -0
- package/src/components/ui/Button.tsx +188 -0
- package/src/components/ui/Density.tsx +66 -0
- package/src/components/ui/Dialog.tsx +187 -0
- package/src/components/ui/Disclosure.tsx +62 -0
- package/src/components/ui/Feedback.tsx +190 -0
- package/src/components/ui/Field.tsx +149 -0
- package/src/components/ui/Input.tsx +100 -0
- package/src/components/ui/Panel.tsx +248 -0
- package/src/components/ui/SegmentedControl.tsx +112 -0
- package/src/components/ui/Select.tsx +168 -0
- package/src/components/ui/Slider.tsx +96 -0
- package/src/components/ui/Table.tsx +162 -0
- package/src/components/ui/Toggle.tsx +163 -0
- package/src/components/ui/ToggleChip.tsx +81 -0
- package/src/components/ui/Tooltip.tsx +99 -0
- package/src/components/ui/cn.ts +41 -0
- package/src/components/ui/index.ts +47 -0
- package/src/index.ts +25 -0
- package/src/styles.css +170 -0
- package/src/theme.ts +113 -0
- package/src/tone.ts +42 -0
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Status colour, in one place.
|
|
3
|
+
*
|
|
4
|
+
* The tone vocabulary is deliberately small and deliberately *about verdicts*. The chrome
|
|
5
|
+
* carries no saturation, so a colour on screen means something: green is a normal sample
|
|
6
|
+
* (or an in-tolerance measurement), red is a defect (or an out-of-tolerance one), amber is
|
|
7
|
+
* a caveat. `info` is the one tone that borrows the interaction accent, and it is reserved
|
|
8
|
+
* for statements of fact about a method -- "produces anomaly maps", "caliper axis fixed" --
|
|
9
|
+
* rather than for anything a reader must act on.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { ReactNode } from "react";
|
|
13
|
+
|
|
14
|
+
import { cn } from "./cn";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The badge and status-dot vocabulary: the verdicts (`normal`, `defect`, `warning`),
|
|
18
|
+
* `unlabeled` for a sample with no verdict yet, `info` for a statement of fact about a
|
|
19
|
+
* method, and `neutral` for everything else.
|
|
20
|
+
*/
|
|
21
|
+
export type Tone = "neutral" | "normal" | "defect" | "unlabeled" | "warning" | "info";
|
|
22
|
+
|
|
23
|
+
const TONE_CLASSES: Record<Tone, string> = {
|
|
24
|
+
neutral: "bg-raised text-fg-muted ring-line",
|
|
25
|
+
normal: "bg-normal/12 text-normal ring-normal/25",
|
|
26
|
+
defect: "bg-defect/12 text-defect ring-defect/25",
|
|
27
|
+
unlabeled: "bg-raised text-fg-subtle ring-line",
|
|
28
|
+
warning: "bg-warn/12 text-warn ring-warn/25",
|
|
29
|
+
info: "bg-signal/12 text-signal ring-signal/25",
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const DOT_CLASSES: Record<Tone, string> = {
|
|
33
|
+
neutral: "bg-fg-subtle",
|
|
34
|
+
normal: "bg-normal",
|
|
35
|
+
defect: "bg-defect",
|
|
36
|
+
unlabeled: "bg-line-strong",
|
|
37
|
+
warning: "bg-warn",
|
|
38
|
+
info: "bg-signal",
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* A short label in a tone: a verdict, a status, a count.
|
|
43
|
+
*
|
|
44
|
+
* The tone is exposed as `data-tone`, so a stylesheet or a test can read it without parsing
|
|
45
|
+
* class names.
|
|
46
|
+
*/
|
|
47
|
+
export function Badge({
|
|
48
|
+
tone = "neutral",
|
|
49
|
+
className,
|
|
50
|
+
children,
|
|
51
|
+
}: {
|
|
52
|
+
/** Which colour, by meaning. Defaults to `neutral`. */
|
|
53
|
+
tone?: Tone | undefined;
|
|
54
|
+
/** Merged with the badge's own classes through `cn`. */
|
|
55
|
+
className?: string | undefined;
|
|
56
|
+
/** The label. */
|
|
57
|
+
children: ReactNode;
|
|
58
|
+
}) {
|
|
59
|
+
return (
|
|
60
|
+
<span
|
|
61
|
+
data-tone={tone}
|
|
62
|
+
className={cn(
|
|
63
|
+
"inline-flex items-center gap-1 rounded-control px-1.5 py-0.5 text-xs font-medium ring-1 ring-inset",
|
|
64
|
+
TONE_CLASSES[tone],
|
|
65
|
+
className,
|
|
66
|
+
)}
|
|
67
|
+
>
|
|
68
|
+
{children}
|
|
69
|
+
</span>
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* A status as a dot and a word.
|
|
75
|
+
*
|
|
76
|
+
* The mapping from a domain status to a tone belongs to each caller -- only it knows
|
|
77
|
+
* whether "running" is good news -- but the *rendering* of the answer does not.
|
|
78
|
+
*/
|
|
79
|
+
export function StatusDot({
|
|
80
|
+
tone,
|
|
81
|
+
className,
|
|
82
|
+
children,
|
|
83
|
+
}: {
|
|
84
|
+
/** The status, as a tone; also exposed as `data-tone`. */
|
|
85
|
+
tone: Tone;
|
|
86
|
+
/** Merged with the row's own classes through `cn`. */
|
|
87
|
+
className?: string | undefined;
|
|
88
|
+
/** The word beside the dot. The dot alone is decoration: say the status in words. */
|
|
89
|
+
children?: ReactNode;
|
|
90
|
+
}) {
|
|
91
|
+
return (
|
|
92
|
+
<span
|
|
93
|
+
data-tone={tone}
|
|
94
|
+
className={cn("inline-flex items-center gap-1.5 text-xs text-fg-muted", className)}
|
|
95
|
+
>
|
|
96
|
+
<span className={cn("size-1.5 shrink-0 rounded-full", DOT_CLASSES[tone])} aria-hidden />
|
|
97
|
+
{children}
|
|
98
|
+
</span>
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Counts as a compact `12 normal · 9 defect` run of badges, skipping the zeroes. */
|
|
103
|
+
export function CountRun({
|
|
104
|
+
counts,
|
|
105
|
+
className,
|
|
106
|
+
}: {
|
|
107
|
+
/** `[name, count, tone]` triples, in display order. Zero counts are dropped. */
|
|
108
|
+
counts: [string, number, Tone][];
|
|
109
|
+
/** Merged with the run's own classes through `cn`. */
|
|
110
|
+
className?: string | undefined;
|
|
111
|
+
}) {
|
|
112
|
+
const shown = counts.filter(([, value]) => value > 0);
|
|
113
|
+
if (shown.length === 0) {
|
|
114
|
+
return <span className={cn("text-xs text-fg-subtle", className)}>empty</span>;
|
|
115
|
+
}
|
|
116
|
+
return (
|
|
117
|
+
<span className={cn("flex flex-wrap items-center gap-1.5", className)}>
|
|
118
|
+
{shown.map(([name, value, tone]) => (
|
|
119
|
+
<Badge key={name} tone={tone}>
|
|
120
|
+
<span className="font-mono">{value}</span> {name}
|
|
121
|
+
</Badge>
|
|
122
|
+
))}
|
|
123
|
+
</span>
|
|
124
|
+
);
|
|
125
|
+
}
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import { Slot, Slottable } from "@radix-ui/react-slot";
|
|
2
|
+
import { Loader2 } from "lucide-react";
|
|
3
|
+
import type { ComponentProps, ReactNode } from "react";
|
|
4
|
+
|
|
5
|
+
import { byDensity, useDensity } from "./Density";
|
|
6
|
+
import { cn, focusRing } from "./cn";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* A button's look, by what the action is: `primary` for the one action a screen is for,
|
|
10
|
+
* `secondary` for the rest, `ghost` for toolbar actions, `danger` for an action that
|
|
11
|
+
* destroys something.
|
|
12
|
+
*/
|
|
13
|
+
export type ButtonVariant = "primary" | "secondary" | "ghost" | "danger";
|
|
14
|
+
|
|
15
|
+
/** A button's height and padding. `md` is the comfortable density's size, `sm` the compact one's. */
|
|
16
|
+
export type ButtonSize = "sm" | "md";
|
|
17
|
+
|
|
18
|
+
const VARIANT_CLASSES: Record<ButtonVariant, string> = {
|
|
19
|
+
primary: "bg-signal text-signal-fg hover:bg-signal-strong",
|
|
20
|
+
secondary: "bg-surface text-fg ring-1 ring-inset ring-line-strong hover:bg-raised",
|
|
21
|
+
ghost: "text-fg-muted hover:bg-raised hover:text-fg",
|
|
22
|
+
// Soft rather than a solid red block. The verdict red is also the colour of a defect
|
|
23
|
+
// badge, and a filled button of it reads as an alarm on a screen that is mostly grey.
|
|
24
|
+
// Genuinely destructive actions are behind a confirmation dialog regardless. The hover
|
|
25
|
+
// tint stops at /15 so the red label keeps 4.5:1 on it.
|
|
26
|
+
danger: "bg-defect/10 text-defect ring-1 ring-inset ring-defect/30 hover:bg-defect/15",
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const SIZE_CLASSES: Record<ButtonSize, string> = {
|
|
30
|
+
sm: "h-7 gap-1.5 px-2.5 text-xs",
|
|
31
|
+
md: "h-8 gap-2 px-3 text-sm",
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The classes that make a control look like a button, for the one element that has to be
|
|
36
|
+
* something else. Prefer `Button` and `ButtonLink`; this exists so a third shape — a
|
|
37
|
+
* `<label>` wrapping a file input, say — does not have to copy the variant table.
|
|
38
|
+
*
|
|
39
|
+
* @param options - The variant (default `secondary`), the size (default `md`) and the
|
|
40
|
+
* caller's own classes, merged last.
|
|
41
|
+
* @returns The class string.
|
|
42
|
+
*/
|
|
43
|
+
export function buttonClasses({
|
|
44
|
+
variant = "secondary",
|
|
45
|
+
size = "md",
|
|
46
|
+
className,
|
|
47
|
+
}: {
|
|
48
|
+
variant?: ButtonVariant | undefined;
|
|
49
|
+
size?: ButtonSize | undefined;
|
|
50
|
+
className?: string | undefined;
|
|
51
|
+
} = {}): string {
|
|
52
|
+
return cn(
|
|
53
|
+
"inline-flex shrink-0 items-center justify-center rounded-control font-medium transition-colors",
|
|
54
|
+
"disabled:cursor-not-allowed disabled:opacity-45",
|
|
55
|
+
SIZE_CLASSES[size],
|
|
56
|
+
VARIANT_CLASSES[variant],
|
|
57
|
+
focusRing,
|
|
58
|
+
className,
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function ButtonIcon({ icon }: { icon: ReactNode }) {
|
|
63
|
+
return (
|
|
64
|
+
<span className="shrink-0 [&>svg]:size-3.5" aria-hidden>
|
|
65
|
+
{icon}
|
|
66
|
+
</span>
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* An action. `asChild` renders the button's look onto the single child element instead of a
|
|
72
|
+
* `<button>` — how a router's link becomes a button-shaped navigation without this package
|
|
73
|
+
* knowing about routers: `<Button asChild><Link to="/runs">Runs</Link></Button>`.
|
|
74
|
+
* For a plain URL, `ButtonLink` is the shorter spelling.
|
|
75
|
+
*
|
|
76
|
+
* Takes every `<button>` prop, `ref` included. State is exposed as `data-variant` and, while
|
|
77
|
+
* `loading`, `data-loading` (plus `aria-busy`).
|
|
78
|
+
*/
|
|
79
|
+
export function Button({
|
|
80
|
+
variant = "secondary",
|
|
81
|
+
size,
|
|
82
|
+
type = "button",
|
|
83
|
+
loading = false,
|
|
84
|
+
icon,
|
|
85
|
+
asChild = false,
|
|
86
|
+
className,
|
|
87
|
+
children,
|
|
88
|
+
disabled,
|
|
89
|
+
...rest
|
|
90
|
+
}: ComponentProps<"button"> & {
|
|
91
|
+
/** What kind of action this is. Defaults to `secondary`. */
|
|
92
|
+
variant?: ButtonVariant | undefined;
|
|
93
|
+
/** Defaults to the density in force — `md` comfortable, `sm` compact. */
|
|
94
|
+
size?: ButtonSize | undefined;
|
|
95
|
+
/** Swaps the icon for a spinner and blocks the click, without the label changing. */
|
|
96
|
+
loading?: boolean | undefined;
|
|
97
|
+
/** A leading icon, decorative (`aria-hidden`): the label carries the meaning. */
|
|
98
|
+
icon?: ReactNode;
|
|
99
|
+
/**
|
|
100
|
+
* Render onto the one child element (Radix `Slot`) rather than a `<button>`. The child
|
|
101
|
+
* keeps its own semantics — a link stays a link — so `type`, `disabled` and `loading` do
|
|
102
|
+
* not apply.
|
|
103
|
+
*/
|
|
104
|
+
asChild?: boolean | undefined;
|
|
105
|
+
}) {
|
|
106
|
+
const density = useDensity();
|
|
107
|
+
const resolved = size ?? byDensity(density, "md", "sm");
|
|
108
|
+
|
|
109
|
+
if (asChild) {
|
|
110
|
+
return (
|
|
111
|
+
<Slot
|
|
112
|
+
{...rest}
|
|
113
|
+
data-variant={variant}
|
|
114
|
+
className={buttonClasses({ variant, size: resolved, className })}
|
|
115
|
+
>
|
|
116
|
+
{icon && <ButtonIcon icon={icon} />}
|
|
117
|
+
<Slottable>{children}</Slottable>
|
|
118
|
+
</Slot>
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return (
|
|
123
|
+
<button
|
|
124
|
+
type={type}
|
|
125
|
+
disabled={disabled || loading}
|
|
126
|
+
aria-busy={loading || undefined}
|
|
127
|
+
data-variant={variant}
|
|
128
|
+
data-loading={loading ? "" : undefined}
|
|
129
|
+
{...rest}
|
|
130
|
+
className={buttonClasses({ variant, size: resolved, className })}
|
|
131
|
+
>
|
|
132
|
+
{loading ? (
|
|
133
|
+
<Loader2 className="size-3.5 shrink-0 animate-spin" aria-hidden />
|
|
134
|
+
) : (
|
|
135
|
+
icon && <ButtonIcon icon={icon} />
|
|
136
|
+
)}
|
|
137
|
+
{children}
|
|
138
|
+
</button>
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* A navigation that looks like a button: one `<a>`, styled by the same variants and density.
|
|
144
|
+
*
|
|
145
|
+
* The alternative every consumer reached for was `<Link><Button/></Link>` — a button inside
|
|
146
|
+
* an anchor, which is invalid HTML, gives a keyboard two tab stops for one action, and makes
|
|
147
|
+
* a screen reader announce a button that navigates. There is no `disabled` or `loading`:
|
|
148
|
+
* a link that cannot be followed should not be rendered as one, and a navigation has no
|
|
149
|
+
* pending state of its own.
|
|
150
|
+
*
|
|
151
|
+
* It renders a plain `<a href>`, and takes every `<a>` prop, `ref` included. For a router's
|
|
152
|
+
* link, pass it as the only child with `asChild` —
|
|
153
|
+
* `<ButtonLink asChild><Link to="/runs">Runs</Link></ButtonLink>` — so client-side
|
|
154
|
+
* navigation keeps working and this package still never imports a router.
|
|
155
|
+
*/
|
|
156
|
+
export function ButtonLink({
|
|
157
|
+
variant = "secondary",
|
|
158
|
+
size,
|
|
159
|
+
icon,
|
|
160
|
+
asChild = false,
|
|
161
|
+
className,
|
|
162
|
+
children,
|
|
163
|
+
...rest
|
|
164
|
+
}: ComponentProps<"a"> & {
|
|
165
|
+
/** What kind of action this is. Defaults to `secondary`. */
|
|
166
|
+
variant?: ButtonVariant | undefined;
|
|
167
|
+
/** Defaults to the density in force — `md` comfortable, `sm` compact. */
|
|
168
|
+
size?: ButtonSize | undefined;
|
|
169
|
+
/** A leading icon, decorative (`aria-hidden`): the label carries the meaning. */
|
|
170
|
+
icon?: ReactNode;
|
|
171
|
+
/** Render onto the one child element — a router's `<Link>` — instead of an `<a>`. */
|
|
172
|
+
asChild?: boolean | undefined;
|
|
173
|
+
}) {
|
|
174
|
+
const density = useDensity();
|
|
175
|
+
const resolved = size ?? byDensity(density, "md", "sm");
|
|
176
|
+
const Comp = asChild ? Slot : "a";
|
|
177
|
+
|
|
178
|
+
return (
|
|
179
|
+
<Comp
|
|
180
|
+
{...rest}
|
|
181
|
+
data-variant={variant}
|
|
182
|
+
className={buttonClasses({ variant, size: resolved, className })}
|
|
183
|
+
>
|
|
184
|
+
{icon && <ButtonIcon icon={icon} />}
|
|
185
|
+
<Slottable>{children}</Slottable>
|
|
186
|
+
</Comp>
|
|
187
|
+
);
|
|
188
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* How much room the chrome takes, as one decision per region rather than per component.
|
|
3
|
+
*
|
|
4
|
+
* The default spacing is written for a page: a form you read through once, where generous
|
|
5
|
+
* padding is what makes it legible. An inspector column beside a canvas is the opposite
|
|
6
|
+
* problem — it is a permanent surface that accumulates tools, and there the same padding is
|
|
7
|
+
* simply less of the instrument on screen. Nesting compounds it: a panel body's `p-4`
|
|
8
|
+
* inside a section's `gap-3` inside a column's own padding is three margins deep before any
|
|
9
|
+
* control appears.
|
|
10
|
+
*
|
|
11
|
+
* `compact` is not "the same design, smaller". It drops the leading and the padding a
|
|
12
|
+
* reading surface needs and keeps the type sizes and hit targets a control needs, so a
|
|
13
|
+
* dense column stays clickable. Below this there is no honest saving left — the next step
|
|
14
|
+
* down is a control too small to hit.
|
|
15
|
+
*
|
|
16
|
+
* The default is `comfortable`, so a consumer that never mentions density gets exactly what
|
|
17
|
+
* it had.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { createContext, use } from "react";
|
|
21
|
+
import type { ReactNode } from "react";
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* How much room the chrome takes: `comfortable` (the default, for a page you read through)
|
|
25
|
+
* or `compact` (for a permanent tool surface such as an inspector column).
|
|
26
|
+
*/
|
|
27
|
+
export type Density = "comfortable" | "compact";
|
|
28
|
+
|
|
29
|
+
const DensityContext = createContext<Density>("comfortable");
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The density in force here. Every primitive that has two spacings reads this.
|
|
33
|
+
*
|
|
34
|
+
* @returns The value of the nearest `DensityProvider`, or `comfortable` outside one.
|
|
35
|
+
*/
|
|
36
|
+
export function useDensity(): Density {
|
|
37
|
+
return use(DensityContext);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Pick one of two values by the density in force — the primitives' own idiom.
|
|
42
|
+
*
|
|
43
|
+
* @param density - The density in force, from `useDensity()`.
|
|
44
|
+
* @param comfortable - The value for `comfortable`.
|
|
45
|
+
* @param compact - The value for `compact`.
|
|
46
|
+
* @returns `compact` when the density is compact, else `comfortable`.
|
|
47
|
+
*/
|
|
48
|
+
export function byDensity<T>(density: Density, comfortable: T, compact: T): T {
|
|
49
|
+
return density === "compact" ? compact : comfortable;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Sets the density for a region: every primitive below it reads the value through
|
|
54
|
+
* `useDensity`. Nest providers to switch a sub-region back.
|
|
55
|
+
*/
|
|
56
|
+
export function DensityProvider({
|
|
57
|
+
value,
|
|
58
|
+
children,
|
|
59
|
+
}: {
|
|
60
|
+
/** The density for everything below. */
|
|
61
|
+
value: Density;
|
|
62
|
+
/** The region. */
|
|
63
|
+
children: ReactNode;
|
|
64
|
+
}) {
|
|
65
|
+
return <DensityContext value={value}>{children}</DensityContext>;
|
|
66
|
+
}
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Confirmation for the things that cannot be undone, and a shell for the things that need
|
|
3
|
+
* a form.
|
|
4
|
+
*
|
|
5
|
+
* `Dialog` is a modal with no verdict of its own: a title, a body the caller fills, and a
|
|
6
|
+
* footer it chooses. `ConfirmDialog` is one arrangement of it -- the destructive-action
|
|
7
|
+
* confirmation every app in this family needs at least once (deleting a dataset, discarding
|
|
8
|
+
* an in-progress measurement session) -- rather than a second copy of the overlay, the
|
|
9
|
+
* centring and the width cap.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import * as RadixDialog from "@radix-ui/react-dialog";
|
|
13
|
+
import { useCallback, useState, type ReactNode } from "react";
|
|
14
|
+
|
|
15
|
+
import { Button } from "./Button";
|
|
16
|
+
import { cn, focusRingInset } from "./cn";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Whether the body overflows its height cap, kept current as the dialog or its content
|
|
20
|
+
* resizes. A scrolling body has to be reachable by keyboard (so it can be scrolled without
|
|
21
|
+
* a pointer); a body that fits must not add a tab stop.
|
|
22
|
+
*/
|
|
23
|
+
function useOverflowing(): [
|
|
24
|
+
(node: HTMLDivElement | null) => (() => void) | undefined,
|
|
25
|
+
boolean,
|
|
26
|
+
] {
|
|
27
|
+
const [overflowing, setOverflowing] = useState(false);
|
|
28
|
+
const ref = useCallback((node: HTMLDivElement | null) => {
|
|
29
|
+
if (node === null) return undefined;
|
|
30
|
+
const measure = () => setOverflowing(node.scrollHeight > node.clientHeight + 1);
|
|
31
|
+
measure();
|
|
32
|
+
if (typeof ResizeObserver === "undefined") return undefined;
|
|
33
|
+
const observer = new ResizeObserver(measure);
|
|
34
|
+
observer.observe(node);
|
|
35
|
+
// The body is capped, so once it hits the cap only the content keeps changing size.
|
|
36
|
+
if (node.firstElementChild) observer.observe(node.firstElementChild);
|
|
37
|
+
return () => observer.disconnect();
|
|
38
|
+
}, []);
|
|
39
|
+
return [ref, overflowing];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* A modal: a title, a body the caller fills, and a footer it chooses.
|
|
44
|
+
*
|
|
45
|
+
* Controlled — the caller owns `open`. The body scrolls under a height cap so the footer
|
|
46
|
+
* stays on screen; while it overflows it is a keyboard tab stop (with the focus ring) and
|
|
47
|
+
* carries `data-overflowing`, so it can be scrolled without a pointer.
|
|
48
|
+
*/
|
|
49
|
+
export function Dialog({
|
|
50
|
+
open,
|
|
51
|
+
onOpenChange,
|
|
52
|
+
title,
|
|
53
|
+
description,
|
|
54
|
+
footer,
|
|
55
|
+
className,
|
|
56
|
+
children,
|
|
57
|
+
}: {
|
|
58
|
+
/** Whether the dialog is shown. */
|
|
59
|
+
open: boolean;
|
|
60
|
+
/** Called with `false` on Escape, an overlay click or a `DialogClose`. */
|
|
61
|
+
onOpenChange: (open: boolean) => void;
|
|
62
|
+
/** Names the dialog (`aria-labelledby`). */
|
|
63
|
+
title: string;
|
|
64
|
+
/** Optional: what the reader needs before they can answer, not decoration. */
|
|
65
|
+
description?: ReactNode;
|
|
66
|
+
/** The action row, right-aligned below the body. */
|
|
67
|
+
footer?: ReactNode;
|
|
68
|
+
/** Merged with the dialog panel's own classes through `cn`. */
|
|
69
|
+
className?: string | undefined;
|
|
70
|
+
/** The body. */
|
|
71
|
+
children?: ReactNode;
|
|
72
|
+
}) {
|
|
73
|
+
const [bodyRef, overflowing] = useOverflowing();
|
|
74
|
+
|
|
75
|
+
return (
|
|
76
|
+
<RadixDialog.Root open={open} onOpenChange={onOpenChange}>
|
|
77
|
+
<RadixDialog.Portal>
|
|
78
|
+
<RadixDialog.Overlay className="fixed inset-0 z-50 bg-black/50 backdrop-blur-[1px]" />
|
|
79
|
+
<RadixDialog.Content
|
|
80
|
+
className={cn(
|
|
81
|
+
"fixed top-1/2 left-1/2 z-50 w-[min(28rem,calc(100vw-2rem))] -translate-x-1/2 -translate-y-1/2",
|
|
82
|
+
// A ceiling and a column, so a dialog that lists things keeps its footer on
|
|
83
|
+
// screen and scrolls its body instead of growing past the viewport. Every
|
|
84
|
+
// short dialog is far below the cap and is unaffected. This is a scroller
|
|
85
|
+
// inside an overlay, not a second page scroller.
|
|
86
|
+
"flex max-h-[calc(100vh-4rem)] flex-col",
|
|
87
|
+
"rounded-panel border border-line bg-overlay p-5 shadow-xl shadow-black/40",
|
|
88
|
+
"focus:outline-none",
|
|
89
|
+
className,
|
|
90
|
+
)}
|
|
91
|
+
>
|
|
92
|
+
<RadixDialog.Title className="text-sm font-semibold tracking-tight text-fg">
|
|
93
|
+
{title}
|
|
94
|
+
</RadixDialog.Title>
|
|
95
|
+
{/* Radix warns when a dialog has no description; render an empty one rather than
|
|
96
|
+
inventing prose for a form whose fields already carry their own labels. */}
|
|
97
|
+
<RadixDialog.Description
|
|
98
|
+
className={cn(
|
|
99
|
+
"mt-2 text-sm leading-relaxed text-fg-muted",
|
|
100
|
+
description === undefined && "sr-only",
|
|
101
|
+
)}
|
|
102
|
+
>
|
|
103
|
+
{description ?? title}
|
|
104
|
+
</RadixDialog.Description>
|
|
105
|
+
<div
|
|
106
|
+
ref={bodyRef}
|
|
107
|
+
tabIndex={overflowing ? 0 : undefined}
|
|
108
|
+
data-overflowing={overflowing ? "" : undefined}
|
|
109
|
+
className={cn("min-h-0 overflow-y-auto", focusRingInset)}
|
|
110
|
+
>
|
|
111
|
+
<div>{children}</div>
|
|
112
|
+
</div>
|
|
113
|
+
{footer && <div className="mt-5 flex shrink-0 justify-end gap-2">{footer}</div>}
|
|
114
|
+
</RadixDialog.Content>
|
|
115
|
+
</RadixDialog.Portal>
|
|
116
|
+
</RadixDialog.Root>
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* The destructive-action confirmation: a `Dialog` whose footer is Cancel and one confirm
|
|
122
|
+
* button. Say in `description` what will actually happen.
|
|
123
|
+
*/
|
|
124
|
+
export function ConfirmDialog({
|
|
125
|
+
open,
|
|
126
|
+
onOpenChange,
|
|
127
|
+
title,
|
|
128
|
+
description,
|
|
129
|
+
confirmLabel,
|
|
130
|
+
onConfirm,
|
|
131
|
+
loading = false,
|
|
132
|
+
disabled = false,
|
|
133
|
+
destructive = false,
|
|
134
|
+
className,
|
|
135
|
+
}: {
|
|
136
|
+
/** Whether the dialog is shown. */
|
|
137
|
+
open: boolean;
|
|
138
|
+
/** Called with `false` on Cancel, Escape or an overlay click. */
|
|
139
|
+
onOpenChange: (open: boolean) => void;
|
|
140
|
+
/** The question, e.g. "Delete dataset?". */
|
|
141
|
+
title: string;
|
|
142
|
+
/** Say what will actually happen, naming the thing by the name the reader gave it. */
|
|
143
|
+
description: ReactNode;
|
|
144
|
+
/** The confirm button's label: the verb, e.g. "Delete". */
|
|
145
|
+
confirmLabel: string;
|
|
146
|
+
/** Called on confirm. The dialog stays open; close it (or set `loading`) yourself. */
|
|
147
|
+
onConfirm: () => void;
|
|
148
|
+
/** Shows the confirm button's spinner and blocks it while the action runs. */
|
|
149
|
+
loading?: boolean;
|
|
150
|
+
/** Blocks the confirm button. */
|
|
151
|
+
disabled?: boolean;
|
|
152
|
+
/** Renders the confirm button as `danger` rather than `primary`. */
|
|
153
|
+
destructive?: boolean;
|
|
154
|
+
/** Merged with the dialog panel's own classes through `cn`. */
|
|
155
|
+
className?: string | undefined;
|
|
156
|
+
}) {
|
|
157
|
+
return (
|
|
158
|
+
<Dialog
|
|
159
|
+
open={open}
|
|
160
|
+
onOpenChange={onOpenChange}
|
|
161
|
+
title={title}
|
|
162
|
+
description={description}
|
|
163
|
+
className={className}
|
|
164
|
+
footer={
|
|
165
|
+
<>
|
|
166
|
+
<RadixDialog.Close asChild>
|
|
167
|
+
<Button variant="ghost">Cancel</Button>
|
|
168
|
+
</RadixDialog.Close>
|
|
169
|
+
<Button
|
|
170
|
+
variant={destructive ? "danger" : "primary"}
|
|
171
|
+
loading={loading}
|
|
172
|
+
disabled={disabled}
|
|
173
|
+
onClick={onConfirm}
|
|
174
|
+
>
|
|
175
|
+
{confirmLabel}
|
|
176
|
+
</Button>
|
|
177
|
+
</>
|
|
178
|
+
}
|
|
179
|
+
/>
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* The dialog's own dismiss, for a footer the caller builds: wrap a button in it with
|
|
185
|
+
* `asChild` — `<DialogClose asChild><Button>Done</Button></DialogClose>`.
|
|
186
|
+
*/
|
|
187
|
+
export const DialogClose = RadixDialog.Close;
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Content folded away, with a caret we draw ourselves.
|
|
3
|
+
*
|
|
4
|
+
* The base layer (`styles.css`) removes the native `<details>` marker; this supplies one
|
|
5
|
+
* that rotates with the element's own open state, so no JavaScript is involved in the
|
|
6
|
+
* animation.
|
|
7
|
+
*
|
|
8
|
+
* `<details>` rather than a headless package on purpose: the element already has the
|
|
9
|
+
* semantics, the keyboard behaviour and the find-in-page integration.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { ChevronRight } from "lucide-react";
|
|
13
|
+
import type { ReactNode } from "react";
|
|
14
|
+
|
|
15
|
+
import { cn } from "./cn";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Content folded away behind a summary line, on a native `<details>`, with a caret that
|
|
19
|
+
* rotates with the element's own `open` state (no JavaScript involved).
|
|
20
|
+
*
|
|
21
|
+
* Uncontrolled: `defaultOpen` sets the initial state and the element owns it after that.
|
|
22
|
+
*/
|
|
23
|
+
export function Disclosure({
|
|
24
|
+
summary,
|
|
25
|
+
count,
|
|
26
|
+
defaultOpen = false,
|
|
27
|
+
className,
|
|
28
|
+
children,
|
|
29
|
+
}: {
|
|
30
|
+
/** The always-visible line that toggles the content. */
|
|
31
|
+
summary: ReactNode;
|
|
32
|
+
/** How many things are inside, where the number is the reason to open it. */
|
|
33
|
+
count?: number;
|
|
34
|
+
/** Whether it starts open. */
|
|
35
|
+
defaultOpen?: boolean;
|
|
36
|
+
/** Merged with the `<details>` element's own classes through `cn`. */
|
|
37
|
+
className?: string | undefined;
|
|
38
|
+
/** The folded content. */
|
|
39
|
+
children: ReactNode;
|
|
40
|
+
}) {
|
|
41
|
+
return (
|
|
42
|
+
<details open={defaultOpen} className={cn("group", className)}>
|
|
43
|
+
<summary
|
|
44
|
+
className={cn(
|
|
45
|
+
"flex cursor-pointer items-center gap-1.5 rounded-control py-1 text-xs font-medium text-fg-muted",
|
|
46
|
+
"transition-colors hover:text-fg",
|
|
47
|
+
"focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-signal",
|
|
48
|
+
)}
|
|
49
|
+
>
|
|
50
|
+
<ChevronRight
|
|
51
|
+
className="size-3.5 shrink-0 transition-transform group-open:rotate-90"
|
|
52
|
+
aria-hidden
|
|
53
|
+
/>
|
|
54
|
+
{summary}
|
|
55
|
+
{count !== undefined && (
|
|
56
|
+
<span className="font-mono text-fg-subtle tabular-nums">{count}</span>
|
|
57
|
+
)}
|
|
58
|
+
</summary>
|
|
59
|
+
<div className="pt-3">{children}</div>
|
|
60
|
+
</details>
|
|
61
|
+
);
|
|
62
|
+
}
|