@tangle-network/ui 11.38.2 → 11.40.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/CHANGELOG.md +27 -0
- package/dist/index.d.ts +4 -2
- package/dist/index.js +3 -1
- package/dist/presentation.d.ts +1 -1
- package/dist/presentation.js +1 -0
- package/dist/primitives/avatar.d.ts +9 -1
- package/dist/primitives/avatar.js +6 -2
- package/dist/primitives/button.d.ts +2 -1
- package/dist/primitives/field.d.ts +27 -0
- package/dist/primitives/field.js +54 -0
- package/dist/primitives/info-tip.js +50 -0
- package/dist/primitives/page-shell.d.ts +19 -3
- package/dist/primitives/page-shell.js +80 -22
- package/dist/primitives/settings.d.ts +125 -0
- package/dist/primitives/settings.js +316 -0
- package/dist/primitives/switch.js +1 -1
- package/dist/primitives/tooltip.d.ts +4 -1
- package/dist/primitives/tooltip.js +16 -12
- package/dist/primitives.d.ts +4 -2
- package/dist/primitives.js +3 -1
- package/package.json +2 -2
- package/src/presentation.ts +2 -0
- package/src/primitives/avatar.tsx +10 -5
- package/src/primitives/button.tsx +2 -1
- package/src/primitives/field.tsx +63 -0
- package/src/primitives/index.ts +16 -0
- package/src/primitives/info-tip.tsx +40 -0
- package/src/primitives/page-shell.tsx +70 -8
- package/src/primitives/settings.stories.tsx +267 -0
- package/src/primitives/settings.tsx +404 -0
- package/src/primitives/switch.tsx +3 -0
- package/src/primitives/tooltip.tsx +14 -6
- package/src/styles/presentation.css +43 -2
|
@@ -0,0 +1,404 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { CircleAlert, Ellipsis, Lock } from "lucide-react";
|
|
4
|
+
import * as React from "react";
|
|
5
|
+
import { cn } from "../lib/utils";
|
|
6
|
+
import { Button } from "./button";
|
|
7
|
+
import { Card, CardFooter, CardTitle } from "./card";
|
|
8
|
+
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "./dropdown-menu";
|
|
9
|
+
import { fieldMessageId } from "./field";
|
|
10
|
+
import { InfoTip } from "./info-tip";
|
|
11
|
+
import { Label } from "./label";
|
|
12
|
+
|
|
13
|
+
/*
|
|
14
|
+
* Settings rows and lists answer to their section card's width, not the
|
|
15
|
+
* viewport's: the card is the `settings-section` query container, so a card in
|
|
16
|
+
* a phone and one in a narrow split pane stack the same way. Below 34rem a
|
|
17
|
+
* label and a 13rem control no longer fit side by side.
|
|
18
|
+
*
|
|
19
|
+
* Type is one size: 14px for labels, values, descriptions, statuses and
|
|
20
|
+
* errors; section titles are 16px. Controls keep their own `md` size.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
const SCROLL_OFFSET = "scroll-mt-[calc(var(--shell-header-height)_+_1rem)]";
|
|
24
|
+
const DIVIDED = "not-first:border-t not-first:border-[color:var(--border-subtle)]";
|
|
25
|
+
const ERROR_TEXT = "text-[color:var(--surface-danger-text)]";
|
|
26
|
+
|
|
27
|
+
function ErrorLine({ id, className, children }: { id?: string; className?: string; children: React.ReactNode }) {
|
|
28
|
+
return (
|
|
29
|
+
<div id={id} role="alert" className={cn("flex items-start gap-1.5 text-sm leading-5 [overflow-wrap:anywhere]", ERROR_TEXT, className)}>
|
|
30
|
+
<CircleAlert aria-hidden="true" className="mt-0.5 size-4 shrink-0" />
|
|
31
|
+
<div className="min-w-0">{children}</div>
|
|
32
|
+
</div>
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function LockLine({ id, className, children }: { id?: string; className?: string; children: React.ReactNode }) {
|
|
37
|
+
return (
|
|
38
|
+
<div id={id} className={cn("flex items-start gap-1.5 text-sm leading-5 text-muted-foreground [overflow-wrap:anywhere]", className)}>
|
|
39
|
+
<Lock aria-hidden="true" className="mt-0.5 size-4 shrink-0" />
|
|
40
|
+
<div className="min-w-0">{children}</div>
|
|
41
|
+
</div>
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export type SettingsRowLayout = "inline" | "compact" | "stacked";
|
|
46
|
+
|
|
47
|
+
export interface SettingsRowProps {
|
|
48
|
+
/** The row's id, for a deep link such as `#timezone`. */
|
|
49
|
+
id?: string;
|
|
50
|
+
label: string;
|
|
51
|
+
/** One line that states a consequence the person needs to act. Most rows need none. */
|
|
52
|
+
description?: React.ReactNode;
|
|
53
|
+
/** Help that explains the setting, shown from an info button beside the label. */
|
|
54
|
+
info?: React.ReactNode;
|
|
55
|
+
/**
|
|
56
|
+
* The control's id. The label targets it, and the row's description, error
|
|
57
|
+
* or lock reason renders at {@link fieldMessageId}. When the row's only child
|
|
58
|
+
* carries this id, the row adds that message to its `aria-describedby` and
|
|
59
|
+
* marks it invalid on error; otherwise point the control there yourself.
|
|
60
|
+
*/
|
|
61
|
+
htmlFor?: string;
|
|
62
|
+
/** A save state after the label, such as "Saving…" or "Saved". It is announced politely. */
|
|
63
|
+
status?: React.ReactNode;
|
|
64
|
+
/** Replaces the description in danger text and is announced. */
|
|
65
|
+
error?: React.ReactNode;
|
|
66
|
+
/** Shows a Retry action after the error. */
|
|
67
|
+
onRetry?: () => void;
|
|
68
|
+
/**
|
|
69
|
+
* `inline` (default): text left, control right, stacked below 34rem.
|
|
70
|
+
* `compact`: the control keeps its own width and stays on the line (switches, single buttons).
|
|
71
|
+
* `stacked`: the control spans the row under the text (textareas, lists).
|
|
72
|
+
*/
|
|
73
|
+
layout?: SettingsRowLayout;
|
|
74
|
+
/** Why the control is unavailable, naming who can change it. Replaces the description after a lock glyph. */
|
|
75
|
+
disabledReason?: React.ReactNode;
|
|
76
|
+
children?: React.ReactNode;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const ROW_COLUMNS: Record<SettingsRowLayout, string> = {
|
|
80
|
+
inline: "grid-cols-[minmax(0,1fr)_minmax(13rem,20rem)] @max-[34rem]/settings-section:grid-cols-1",
|
|
81
|
+
compact: "grid-cols-[minmax(0,1fr)_auto]",
|
|
82
|
+
stacked: "grid-cols-1",
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
const ROW_CONTROL: Record<SettingsRowLayout, string> = {
|
|
86
|
+
inline: "justify-end @max-[34rem]/settings-section:flex-col @max-[34rem]/settings-section:items-stretch",
|
|
87
|
+
compact: "justify-end",
|
|
88
|
+
stacked: "flex-col items-stretch",
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
type DescribableProps = { id?: string; "aria-describedby"?: string; "aria-invalid"?: unknown };
|
|
92
|
+
|
|
93
|
+
/** Point the row's own control at the row's message without changing anything else about it. */
|
|
94
|
+
function describeControl(children: React.ReactNode, htmlFor: string | undefined, messageId: string | undefined, invalid: boolean) {
|
|
95
|
+
if (!htmlFor || !messageId || !React.isValidElement<DescribableProps>(children) || children.props.id !== htmlFor) return children;
|
|
96
|
+
const ids = new Set([...(children.props["aria-describedby"] ?? "").split(/\s+/), messageId].filter(Boolean));
|
|
97
|
+
return React.cloneElement(children, {
|
|
98
|
+
"aria-describedby": [...ids].join(" "),
|
|
99
|
+
...(invalid && children.props["aria-invalid"] === undefined ? { "aria-invalid": true } : {}),
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* One setting: its label, an optional consequence line and its control. The
|
|
105
|
+
* label carries the save status after it; an error or a lock reason replaces
|
|
106
|
+
* the description. Rows inside a {@link SettingsSection} are divided by a
|
|
107
|
+
* hairline and stack when the card is narrower than 34rem.
|
|
108
|
+
*/
|
|
109
|
+
export function SettingsRow({
|
|
110
|
+
id, label, description, info, htmlFor, status, error, onRetry, layout = "inline", disabledReason, children,
|
|
111
|
+
}: SettingsRowProps) {
|
|
112
|
+
const messageId = htmlFor ? fieldMessageId(htmlFor) : undefined;
|
|
113
|
+
const hasMessage = error != null || disabledReason != null || description != null;
|
|
114
|
+
const control = describeControl(children, htmlFor, hasMessage ? messageId : undefined, error != null);
|
|
115
|
+
return (
|
|
116
|
+
<div
|
|
117
|
+
id={id}
|
|
118
|
+
data-layout={layout}
|
|
119
|
+
className={cn(
|
|
120
|
+
"grid min-w-0 items-center gap-x-8 gap-y-3 px-5 py-4 @max-[34rem]/settings-section:px-4",
|
|
121
|
+
SCROLL_OFFSET,
|
|
122
|
+
DIVIDED,
|
|
123
|
+
ROW_COLUMNS[layout],
|
|
124
|
+
)}
|
|
125
|
+
>
|
|
126
|
+
<div className="min-w-0">
|
|
127
|
+
<div className="flex min-h-5 flex-wrap items-center gap-x-2 gap-y-1">
|
|
128
|
+
{htmlFor
|
|
129
|
+
? <Label htmlFor={htmlFor} className="leading-5 text-foreground">{label}</Label>
|
|
130
|
+
: <span className="font-medium text-sm leading-5 text-foreground">{label}</span>}
|
|
131
|
+
{info != null && <InfoTip label={label}>{info}</InfoTip>}
|
|
132
|
+
<span aria-live="polite" className="inline-flex items-center gap-1 text-sm leading-5 text-muted-foreground">{status}</span>
|
|
133
|
+
</div>
|
|
134
|
+
{error != null
|
|
135
|
+
? <ErrorLine id={messageId} className="mt-0.5">
|
|
136
|
+
{error}
|
|
137
|
+
{onRetry && <>{" "}<Button type="button" variant="bare" onClick={onRetry} className="rounded-sm font-medium text-[var(--accent-text)] underline-offset-4 hover:underline">Retry</Button></>}
|
|
138
|
+
</ErrorLine>
|
|
139
|
+
: disabledReason != null
|
|
140
|
+
? <LockLine id={messageId} className="mt-0.5">{disabledReason}</LockLine>
|
|
141
|
+
: description != null && (
|
|
142
|
+
<div id={messageId} className="mt-0.5 text-sm leading-5 text-muted-foreground [overflow-wrap:anywhere]">{description}</div>
|
|
143
|
+
)}
|
|
144
|
+
</div>
|
|
145
|
+
{control != null && control !== false && (
|
|
146
|
+
<div className={cn("flex min-w-0 items-center gap-2", ROW_CONTROL[layout])}>{control}</div>
|
|
147
|
+
)}
|
|
148
|
+
</div>
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export interface SettingsSectionProps {
|
|
153
|
+
/** The section's anchor: `#id` scrolls to it. The title's id is `${id}-title`. */
|
|
154
|
+
id: string;
|
|
155
|
+
title: string;
|
|
156
|
+
/** One short line, at most about twelve words. Leave it out when the title says enough. */
|
|
157
|
+
description?: React.ReactNode;
|
|
158
|
+
/** Help that explains the section, shown from an info button beside the title. */
|
|
159
|
+
info?: React.ReactNode;
|
|
160
|
+
/** Section actions, top right, such as "Edit brief" or "Add source". */
|
|
161
|
+
actions?: React.ReactNode;
|
|
162
|
+
/** `danger` draws the card's edge and title in the danger tone. */
|
|
163
|
+
tone?: "default" | "danger";
|
|
164
|
+
/** The card's footer on the muted plane, such as the one save bar a section may have. */
|
|
165
|
+
footer?: React.ReactNode;
|
|
166
|
+
/** Rows, lists and any other blocks of the section. */
|
|
167
|
+
children?: React.ReactNode;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* One settings card: a 16px title with optional description, info tip and
|
|
172
|
+
* actions, then its rows and lists, then an optional footer. The card is the
|
|
173
|
+
* width container its rows and lists respond to.
|
|
174
|
+
*/
|
|
175
|
+
export function SettingsSection({
|
|
176
|
+
id, title, description, info, actions, tone = "default", footer, children,
|
|
177
|
+
}: SettingsSectionProps) {
|
|
178
|
+
const titleId = `${id}-title`;
|
|
179
|
+
const danger = tone === "danger";
|
|
180
|
+
return (
|
|
181
|
+
<Card
|
|
182
|
+
id={id}
|
|
183
|
+
role="region"
|
|
184
|
+
aria-labelledby={titleId}
|
|
185
|
+
data-tone={tone}
|
|
186
|
+
className={cn("@container/settings-section min-w-0", SCROLL_OFFSET, danger && "border-[color:var(--surface-danger-border)]")}
|
|
187
|
+
>
|
|
188
|
+
<div className="flex items-start justify-between gap-4 px-5 py-4 @max-[34rem]/settings-section:flex-wrap @max-[34rem]/settings-section:px-4">
|
|
189
|
+
<div className="min-w-0">
|
|
190
|
+
<div className="flex items-center gap-1.5">
|
|
191
|
+
<CardTitle as="h2" id={titleId} className={cn("leading-6 [overflow-wrap:anywhere]", danger && ERROR_TEXT)}>{title}</CardTitle>
|
|
192
|
+
{info != null && <InfoTip label={title}>{info}</InfoTip>}
|
|
193
|
+
</div>
|
|
194
|
+
{description != null && (
|
|
195
|
+
<div className="mt-0.5 text-sm leading-5 text-muted-foreground [overflow-wrap:anywhere]">{description}</div>
|
|
196
|
+
)}
|
|
197
|
+
</div>
|
|
198
|
+
{actions != null && <div className="flex shrink-0 flex-wrap items-center gap-2">{actions}</div>}
|
|
199
|
+
</div>
|
|
200
|
+
{children}
|
|
201
|
+
{footer != null && (
|
|
202
|
+
<CardFooter className="justify-between gap-3 rounded-b-[inherit] border-t border-[color:var(--border-subtle)] bg-muted px-5 py-3 text-sm leading-5 [&:not(:first-child)]:pt-3 @max-[34rem]/settings-section:px-4">
|
|
203
|
+
{footer}
|
|
204
|
+
</CardFooter>
|
|
205
|
+
)}
|
|
206
|
+
</Card>
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** Track widths for a {@link SettingsList}. Every slot its items use needs a column, except status and menu, which have fallbacks. */
|
|
211
|
+
export interface SettingsListColumns {
|
|
212
|
+
/** A logo or avatar column, such as `"36px"`. */
|
|
213
|
+
leading?: string;
|
|
214
|
+
/** The title column; `minmax(0,1fr)` by default. */
|
|
215
|
+
main?: string;
|
|
216
|
+
/** A fixed state column, such as `"8rem"`. Below 34rem the state moves under the title, full width. */
|
|
217
|
+
status?: string;
|
|
218
|
+
/** A fixed column for the at-rest action, such as `"9rem"`. Below 34rem it shrinks to its content. */
|
|
219
|
+
action?: string;
|
|
220
|
+
/** A column for the ⋯ menu, such as `"36px"`. Without it the menu ends the action cell. */
|
|
221
|
+
menu?: string;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const LIST_SLOTS = ["leading", "main", "status", "action", "menu"] as const;
|
|
225
|
+
|
|
226
|
+
const LENGTH = /^[\d.]+(?:px|rem|em|ch)$/;
|
|
227
|
+
|
|
228
|
+
function listTracks(columns: SettingsListColumns, narrow: boolean) {
|
|
229
|
+
const tracks = LIST_SLOTS.flatMap((slot): Array<[string, string]> => {
|
|
230
|
+
if (slot === "main") return [[slot, columns.main ?? "minmax(0,1fr)"]];
|
|
231
|
+
const width = columns[slot];
|
|
232
|
+
if (!width || (narrow && slot === "status")) return [];
|
|
233
|
+
return [[slot, narrow && slot === "action" ? "auto" : width]];
|
|
234
|
+
});
|
|
235
|
+
// A subgrid item's inline padding (px-5, px-4 when narrow) lands on its edge
|
|
236
|
+
// cells as extra margin, so a fixed edge track grows by it to keep its width.
|
|
237
|
+
const pad = narrow ? "1rem" : "1.25rem";
|
|
238
|
+
return tracks.map(([slot, width], index) => {
|
|
239
|
+
const edge = index === 0 || index === tracks.length - 1;
|
|
240
|
+
return `[${slot}] ${edge && LENGTH.test(width) ? `calc(${width} + ${pad})` : width}`;
|
|
241
|
+
}).join(" ");
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const SettingsListContext = React.createContext<SettingsListColumns>({});
|
|
245
|
+
|
|
246
|
+
export interface SettingsListProps {
|
|
247
|
+
columns: SettingsListColumns;
|
|
248
|
+
"aria-label"?: string;
|
|
249
|
+
children?: React.ReactNode;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Comparable rows whose status and action columns line up across items (CSS
|
|
254
|
+
* subgrid). Below 34rem each item becomes two lines: the title and meta with
|
|
255
|
+
* its action on the right, then the state under them at full width.
|
|
256
|
+
*/
|
|
257
|
+
export function SettingsList({ columns, children, "aria-label": ariaLabel }: SettingsListProps) {
|
|
258
|
+
const style = {
|
|
259
|
+
"--settings-list-columns": listTracks(columns, false),
|
|
260
|
+
"--settings-list-columns-narrow": listTracks(columns, true),
|
|
261
|
+
} as React.CSSProperties;
|
|
262
|
+
return (
|
|
263
|
+
<SettingsListContext.Provider value={columns}>
|
|
264
|
+
<ul
|
|
265
|
+
role="list"
|
|
266
|
+
aria-label={ariaLabel}
|
|
267
|
+
style={style}
|
|
268
|
+
className={cn(
|
|
269
|
+
"grid min-w-0 gap-x-4 [grid-template-columns:var(--settings-list-columns)] @max-[34rem]/settings-section:[grid-template-columns:var(--settings-list-columns-narrow)]",
|
|
270
|
+
DIVIDED,
|
|
271
|
+
)}
|
|
272
|
+
>
|
|
273
|
+
{children}
|
|
274
|
+
</ul>
|
|
275
|
+
</SettingsListContext.Provider>
|
|
276
|
+
);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
export interface SettingsListMenuItem {
|
|
280
|
+
label: string;
|
|
281
|
+
onSelect: () => void;
|
|
282
|
+
/** Destructive actions (delete, remove, revoke) belong here, in the danger tone. */
|
|
283
|
+
tone?: "danger";
|
|
284
|
+
/** Shown under the label; the item stays focusable so the reason is read, and selecting it does nothing. */
|
|
285
|
+
disabledReason?: React.ReactNode;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
export interface SettingsListAction {
|
|
289
|
+
label: string;
|
|
290
|
+
onSelect: () => void;
|
|
291
|
+
/** Why the action is unavailable, naming who can take it. The button stays focusable, does nothing, and the reason shows under the meta. */
|
|
292
|
+
disabledReason?: React.ReactNode;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
export interface SettingsListItemProps {
|
|
296
|
+
/** A logo or avatar, in the list's `leading` column. */
|
|
297
|
+
leading?: React.ReactNode;
|
|
298
|
+
/** The item's name. Make it the link to the item's detail. */
|
|
299
|
+
title: React.ReactNode;
|
|
300
|
+
/** One line under the title, such as an address or a schedule. */
|
|
301
|
+
meta?: React.ReactNode;
|
|
302
|
+
/** The item's state as plain text, or a control that sets it (a role select). */
|
|
303
|
+
status?: React.ReactNode;
|
|
304
|
+
/** Shown under the meta in danger text and announced. */
|
|
305
|
+
error?: React.ReactNode;
|
|
306
|
+
/** The one verb the item needs at rest. It is drawn as an outline button, so it never reads as the state beside it. */
|
|
307
|
+
action?: SettingsListAction;
|
|
308
|
+
/** Everything else, including destructive actions, behind a ⋯ button. */
|
|
309
|
+
menu?: SettingsListMenuItem[];
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function ItemMenu({ items, titleId }: { items: SettingsListMenuItem[]; titleId: string }) {
|
|
313
|
+
const triggerId = React.useId();
|
|
314
|
+
return (
|
|
315
|
+
<DropdownMenu>
|
|
316
|
+
<DropdownMenuTrigger asChild>
|
|
317
|
+
<Button type="button" variant="ghost" size="icon" id={triggerId} aria-label="More actions" aria-labelledby={`${triggerId} ${titleId}`}>
|
|
318
|
+
<Ellipsis aria-hidden="true" />
|
|
319
|
+
</Button>
|
|
320
|
+
</DropdownMenuTrigger>
|
|
321
|
+
<DropdownMenuContent align="end" className="min-w-44 max-w-72">
|
|
322
|
+
{items.map((item) => {
|
|
323
|
+
const unavailable = item.disabledReason != null;
|
|
324
|
+
return (
|
|
325
|
+
<DropdownMenuItem
|
|
326
|
+
key={item.label}
|
|
327
|
+
aria-disabled={unavailable || undefined}
|
|
328
|
+
onSelect={(event) => { if (unavailable) event.preventDefault(); else item.onSelect(); }}
|
|
329
|
+
className={cn(
|
|
330
|
+
"flex-col items-start gap-0.5 leading-5",
|
|
331
|
+
item.tone === "danger" && !unavailable && cn(ERROR_TEXT, "focus:text-[color:var(--surface-danger-text)]"),
|
|
332
|
+
unavailable && "cursor-not-allowed text-muted-foreground focus:text-muted-foreground",
|
|
333
|
+
)}
|
|
334
|
+
>
|
|
335
|
+
<span>{item.label}</span>
|
|
336
|
+
{unavailable && <LockLine>{item.disabledReason}</LockLine>}
|
|
337
|
+
</DropdownMenuItem>
|
|
338
|
+
);
|
|
339
|
+
})}
|
|
340
|
+
</DropdownMenuContent>
|
|
341
|
+
</DropdownMenu>
|
|
342
|
+
);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// Below 34rem the state takes a second line from the title to the card edge, so a role select gets the full width.
|
|
346
|
+
const NARROW_STATUS = "@max-[34rem]/settings-section:col-start-[main] @max-[34rem]/settings-section:col-end-[-1] @max-[34rem]/settings-section:row-start-2 @max-[34rem]/settings-section:mt-2";
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* One item in a {@link SettingsList}; each slot goes to the list's column of
|
|
350
|
+
* that name. Without a status column the state follows the meta, and without
|
|
351
|
+
* a menu column the ⋯ menu ends the action cell.
|
|
352
|
+
*/
|
|
353
|
+
export function SettingsListItem({ leading, title, meta, status, error, action, menu }: SettingsListItemProps) {
|
|
354
|
+
const columns = React.useContext(SettingsListContext);
|
|
355
|
+
const titleId = React.useId();
|
|
356
|
+
const reasonId = `${titleId}-reason`;
|
|
357
|
+
const locked = action?.disabledReason != null;
|
|
358
|
+
const menuNode = menu && menu.length > 0 ? <ItemMenu items={menu} titleId={titleId} /> : null;
|
|
359
|
+
const actionButton = action && (
|
|
360
|
+
<Button
|
|
361
|
+
type="button"
|
|
362
|
+
variant="outline"
|
|
363
|
+
aria-disabled={locked || undefined}
|
|
364
|
+
aria-describedby={locked ? reasonId : undefined}
|
|
365
|
+
onClick={locked ? undefined : action.onSelect}
|
|
366
|
+
>
|
|
367
|
+
{action.label}
|
|
368
|
+
</Button>
|
|
369
|
+
);
|
|
370
|
+
const actionCell = (actionButton || (menuNode && !columns.menu)) && (
|
|
371
|
+
<>{actionButton}{!columns.menu && menuNode}</>
|
|
372
|
+
);
|
|
373
|
+
return (
|
|
374
|
+
<li
|
|
375
|
+
className={cn(
|
|
376
|
+
"col-span-full grid grid-cols-subgrid items-center px-5 py-3 @max-[34rem]/settings-section:px-4",
|
|
377
|
+
DIVIDED,
|
|
378
|
+
)}
|
|
379
|
+
>
|
|
380
|
+
{leading != null && <div className="col-start-[leading] row-start-1 flex">{leading}</div>}
|
|
381
|
+
<div className="col-start-[main] row-start-1 min-w-0">
|
|
382
|
+
<div
|
|
383
|
+
id={titleId}
|
|
384
|
+
className="font-medium text-sm leading-5 text-foreground [overflow-wrap:anywhere] [&_a]:rounded-sm [&_a]:text-foreground [&_a]:underline-offset-4 [&_a:hover]:underline [&_a:focus-visible]:outline-2 [&_a:focus-visible]:outline-offset-2 [&_a:focus-visible]:outline-[var(--focus-border)]"
|
|
385
|
+
>
|
|
386
|
+
{title}
|
|
387
|
+
</div>
|
|
388
|
+
{meta != null && <div className="text-sm leading-5 text-muted-foreground [overflow-wrap:anywhere]">{meta}</div>}
|
|
389
|
+
{status != null && !columns.status && <div className="mt-1 text-sm leading-5 text-muted-foreground">{status}</div>}
|
|
390
|
+
{locked && <LockLine id={reasonId} className="mt-1">{action?.disabledReason}</LockLine>}
|
|
391
|
+
</div>
|
|
392
|
+
{error != null && (
|
|
393
|
+
<ErrorLine className="col-start-[main] row-start-2 mt-1 @max-[34rem]/settings-section:col-end-[-1] @max-[34rem]/settings-section:row-start-3">{error}</ErrorLine>
|
|
394
|
+
)}
|
|
395
|
+
{status != null && columns.status && (
|
|
396
|
+
<div className={cn("col-start-[status] row-start-1 min-w-0 text-sm leading-5 text-muted-foreground", NARROW_STATUS)}>{status}</div>
|
|
397
|
+
)}
|
|
398
|
+
{actionCell && (
|
|
399
|
+
<div className="col-start-[action] row-start-1 flex min-w-0 items-center justify-end gap-1">{actionCell}</div>
|
|
400
|
+
)}
|
|
401
|
+
{menuNode && columns.menu && <div className="col-start-[menu] row-start-1 flex justify-end">{menuNode}</div>}
|
|
402
|
+
</li>
|
|
403
|
+
);
|
|
404
|
+
}
|
|
@@ -22,6 +22,9 @@ const Switch = React.forwardRef<
|
|
|
22
22
|
// theme. The bg-primary fill is tuned to carry white text and measured
|
|
23
23
|
// 2.4:1 on the dark card, with a bg-background thumb at 2.5:1 on it.
|
|
24
24
|
// brand's switch-contrast.test.ts asserts these pairs for every theme.
|
|
25
|
+
// On a coarse pointer a transparent 44px hit area surrounds the 20px track,
|
|
26
|
+
// as on InfoTip; the insets start inside the 2px border.
|
|
27
|
+
"relative pointer-coarse:before:absolute pointer-coarse:before:-inset-x-1.5 pointer-coarse:before:-inset-y-3.5 pointer-coarse:before:content-['']",
|
|
25
28
|
"peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 shadow-sm transition-[background-color,border-color,box-shadow] disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:border-transparent data-[state=checked]:bg-[var(--accent-text)] data-[state=unchecked]:border-muted-foreground data-[state=unchecked]:bg-transparent",
|
|
26
29
|
focusRing,
|
|
27
30
|
className,
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
3
|
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
|
|
4
|
+
import { Slottable } from "@radix-ui/react-slot";
|
|
4
5
|
import * as React from "react";
|
|
5
6
|
import { SurfaceMotion } from "../lib/surface-motion";
|
|
6
7
|
import { surfaceMotion } from "../lib/motion";
|
|
@@ -48,9 +49,12 @@ const TooltipTrigger = TooltipPrimitive.Trigger;
|
|
|
48
49
|
*/
|
|
49
50
|
const TooltipContent = React.forwardRef<
|
|
50
51
|
React.ElementRef<typeof TooltipPrimitive.Content>,
|
|
51
|
-
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
|
|
52
|
-
|
|
53
|
-
|
|
52
|
+
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content> & {
|
|
53
|
+
/** Render through a portal; use false to keep content in its local owner. */
|
|
54
|
+
portal?: boolean;
|
|
55
|
+
}
|
|
56
|
+
>(({ className, sideOffset = 6, asChild, children, portal = true, ...props }, ref) => {
|
|
57
|
+
const content = (
|
|
54
58
|
<TooltipPrimitive.Content
|
|
55
59
|
ref={ref}
|
|
56
60
|
sideOffset={sideOffset}
|
|
@@ -61,9 +65,13 @@ const TooltipContent = React.forwardRef<
|
|
|
61
65
|
)}
|
|
62
66
|
{...props}
|
|
63
67
|
asChild
|
|
64
|
-
><SurfaceMotion kind="tooltip" asChild={asChild}>
|
|
65
|
-
|
|
66
|
-
|
|
68
|
+
><SurfaceMotion kind="tooltip" asChild={asChild}>
|
|
69
|
+
{/* Keep the caller as the slot target when Radix adds accessibility children. */}
|
|
70
|
+
{asChild ? <Slottable>{children}</Slottable> : children}
|
|
71
|
+
</SurfaceMotion></TooltipPrimitive.Content>
|
|
72
|
+
);
|
|
73
|
+
return portal ? <TooltipPrimitive.Portal>{content}</TooltipPrimitive.Portal> : content;
|
|
74
|
+
});
|
|
67
75
|
TooltipContent.displayName = TooltipPrimitive.Content.displayName;
|
|
68
76
|
|
|
69
77
|
export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger };
|
|
@@ -14,15 +14,56 @@
|
|
|
14
14
|
.tangle-reading-article { width: 100%; max-width: var(--reading-max-width); overflow-wrap: anywhere; }
|
|
15
15
|
.tangle-reading-nav a, .tangle-reading-outline a { display: block; padding: 0.5rem 0; color: hsl(var(--muted-foreground)); text-decoration: none; }
|
|
16
16
|
.tangle-reading-nav a[aria-current]:not([aria-current="false"]), .tangle-reading-outline a[aria-current]:not([aria-current="false"]) { color: var(--accent-text); font-weight: 600; }
|
|
17
|
+
/* The 13rem track exists only beside a nav, so an article with no nav keeps the full column. */
|
|
17
18
|
@media(min-width: 64rem) {
|
|
18
|
-
.tangle-reading-shell { grid-template-columns: 13rem minmax(0,1fr); }
|
|
19
|
+
.tangle-reading-shell:has(> .tangle-reading-nav) { grid-template-columns: 13rem minmax(0,1fr); }
|
|
19
20
|
.tangle-reading-outline { display: none; }
|
|
20
21
|
.tangle-reading-nav { position: sticky; top: var(--shell-header-height); align-self: start; max-height: calc(100dvh - var(--shell-header-height)); overflow: auto; }
|
|
21
22
|
}
|
|
22
23
|
@media(min-width: 80rem) {
|
|
23
|
-
.tangle-reading-shell:has(.tangle-reading-outline) { grid-template-columns:
|
|
24
|
+
.tangle-reading-shell:has(> .tangle-reading-outline) { grid-template-columns: minmax(0,1fr) 11rem; }
|
|
25
|
+
.tangle-reading-shell:where(:has(> .tangle-reading-nav)):has(> .tangle-reading-outline) { grid-template-columns: 13rem minmax(0,1fr) 11rem; }
|
|
24
26
|
.tangle-reading-outline { display: block; position: sticky; top: var(--shell-header-height); align-self: start; }
|
|
25
27
|
}
|
|
28
|
+
.tangle-reading-header { grid-column: 1 / -1; min-width: 0; }
|
|
29
|
+
/* PageShell measure="settings". The frame is the query container, so the layout follows the content area's width, not the viewport's.
|
|
30
|
+
Below 60rem the navigation is a sticky tab strip whose track is the divider; from 60rem it is a sticky 13rem column beside a 48rem article.
|
|
31
|
+
Without a nav the page is the 48rem column alone. */
|
|
32
|
+
.tangle-settings-frame { container: tangle-settings / inline-size; width: 100%; min-width: 0; }
|
|
33
|
+
.tangle-reading-shell[data-measure="settings"] { --tangle-settings-gutter: 1.5rem; grid-template-columns: minmax(0,1fr); gap: 0; max-width: calc(13rem + 2.5rem + 48rem + 4rem); padding: 1.5rem var(--tangle-settings-gutter) 3rem; }
|
|
34
|
+
/* Without a nav the shell is the column and its gutters, so the centred shell centres the column and its header. */
|
|
35
|
+
.tangle-reading-shell[data-measure="settings"]:not(:has(> .tangle-reading-nav)) { max-width: calc(48rem + 2 * var(--tangle-settings-gutter)); }
|
|
36
|
+
.tangle-reading-shell[data-measure="settings"] > .tangle-reading-header { margin-bottom: 0.5rem; }
|
|
37
|
+
.tangle-reading-shell[data-measure="settings"] > .tangle-reading-header > * { margin-bottom: 0; }
|
|
38
|
+
.tangle-reading-shell[data-measure="settings"] > .tangle-reading-outline { display: none; }
|
|
39
|
+
.tangle-reading-shell[data-measure="settings"] > .tangle-reading-article { max-width: 48rem; display: flex; flex-direction: column; gap: 1.5rem; }
|
|
40
|
+
.tangle-reading-shell[data-measure="settings"] > .tangle-reading-nav { position: sticky; top: 0; z-index: 3; display: flex; align-items: flex-end; gap: 0.25rem; height: var(--shell-header-height); max-height: none; margin: 0 calc(-1 * var(--tangle-settings-gutter)) 1.25rem; padding: 0 var(--tangle-settings-gutter); overflow-x: auto; overflow-y: hidden; overscroll-behavior-x: contain; scrollbar-width: none; background: hsl(var(--background)); box-shadow: inset 0 -1px 0 hsl(var(--border)); }
|
|
41
|
+
.tangle-reading-shell[data-measure="settings"] > .tangle-reading-nav::-webkit-scrollbar { display: none; }
|
|
42
|
+
.tangle-reading-shell[data-measure="settings"] > .tangle-reading-nav > :is(ul, ol) { display: flex; flex: none; align-items: flex-end; gap: 0.25rem; margin: 0; padding: 0; list-style: none; }
|
|
43
|
+
.tangle-reading-shell[data-measure="settings"] > .tangle-reading-nav a { flex: none; display: flex; align-items: center; gap: 0.5rem; min-height: var(--control-height-lg, 2.75rem); padding: 0 0.75rem; color: hsl(var(--muted-foreground)); font-size: var(--font-size-label, 0.875rem); font-weight: 500; line-height: 1.25rem; white-space: nowrap; text-decoration: none; transition: color var(--motion-control), background-color var(--motion-control), box-shadow var(--motion-control); }
|
|
44
|
+
.tangle-reading-shell[data-measure="settings"] > .tangle-reading-nav a > svg { display: none; }
|
|
45
|
+
.tangle-reading-shell[data-measure="settings"] > .tangle-reading-nav a > [data-count] { color: var(--text-dim); font-weight: 400; font-variant-numeric: tabular-nums; }
|
|
46
|
+
.tangle-reading-shell[data-measure="settings"] > .tangle-reading-nav a:hover { color: hsl(var(--foreground)); box-shadow: inset 0 -2px 0 hsl(var(--hsl-ring) / 0.4); }
|
|
47
|
+
.tangle-reading-shell[data-measure="settings"] > .tangle-reading-nav a[aria-current="page"] { color: var(--accent-text); font-weight: 500; background: hsl(var(--hsl-ring) / 0.07); box-shadow: inset 0 -2px 0 var(--ix-sel-ring); }
|
|
48
|
+
.tangle-reading-shell[data-measure="settings"] > .tangle-reading-nav a:focus-visible { outline: 1px solid var(--focus-border); outline-offset: -1px; box-shadow: inset 0 0 0 4px var(--focus-halo); }
|
|
49
|
+
@container tangle-settings (max-width: 40rem) {
|
|
50
|
+
.tangle-reading-shell[data-measure="settings"] { --tangle-settings-gutter: 1rem; padding: 1rem var(--tangle-settings-gutter) 2.5rem; }
|
|
51
|
+
.tangle-reading-shell[data-measure="settings"] > .tangle-reading-nav { margin-bottom: 1rem; }
|
|
52
|
+
.tangle-reading-shell[data-measure="settings"] > .tangle-reading-article { gap: 1rem; }
|
|
53
|
+
}
|
|
54
|
+
@container tangle-settings (min-width: 60rem) {
|
|
55
|
+
.tangle-reading-shell[data-measure="settings"] { --tangle-settings-gutter: 2rem; grid-template-columns: minmax(0,48rem); column-gap: 2.5rem; align-items: start; padding: 2rem; }
|
|
56
|
+
.tangle-reading-shell[data-measure="settings"]:has(> .tangle-reading-nav) { grid-template-columns: 13rem minmax(0,48rem); }
|
|
57
|
+
.tangle-reading-shell[data-measure="settings"] > .tangle-reading-header { margin-bottom: 1.75rem; }
|
|
58
|
+
.tangle-reading-shell[data-measure="settings"] > .tangle-reading-nav { top: 2rem; flex-direction: column; align-items: stretch; gap: 2px; height: auto; margin: 0; padding: 0; overflow: visible; background: none; box-shadow: none; }
|
|
59
|
+
.tangle-reading-shell[data-measure="settings"] > .tangle-reading-nav > :is(ul, ol) { flex-direction: column; align-items: stretch; gap: 2px; }
|
|
60
|
+
.tangle-reading-shell[data-measure="settings"] > .tangle-reading-nav a { min-height: var(--control-height, 2.25rem); gap: 0.625rem; padding: 0 0.625rem; border-radius: var(--radius-md); }
|
|
61
|
+
.tangle-reading-shell[data-measure="settings"] > .tangle-reading-nav a > svg { display: block; flex: none; width: 1rem; height: 1rem; }
|
|
62
|
+
.tangle-reading-shell[data-measure="settings"] > .tangle-reading-nav a > [data-count] { margin-inline-start: auto; }
|
|
63
|
+
.tangle-reading-shell[data-measure="settings"] > .tangle-reading-nav a:hover { background: var(--ix-hover-bg); box-shadow: none; }
|
|
64
|
+
.tangle-reading-shell[data-measure="settings"] > .tangle-reading-nav a[aria-current="page"] { background: var(--ix-sel-bg); box-shadow: inset 0 0 0 1px var(--ix-sel-ring); }
|
|
65
|
+
.tangle-reading-shell[data-measure="settings"] > .tangle-reading-nav a:focus-visible { outline-offset: 0; box-shadow: 0 0 0 4px var(--focus-halo); }
|
|
66
|
+
}
|
|
26
67
|
/* Native dialog has the same solid reading surface as React's Dialog/Sheet. */
|
|
27
68
|
.tangle-native-dialog { max-width: min(32rem,calc(100% - 2rem)); max-height: calc(100dvh - 2rem); overflow: auto; margin: auto; padding: 1.5rem; border: 1px solid hsl(var(--border)); border-radius: var(--radius-lg); background: hsl(var(--card)); color: hsl(var(--card-foreground)); }
|
|
28
69
|
.tangle-native-dialog::backdrop { background: var(--scrim); }
|