@tonycodes-ui/mortar 0.0.0-stage → 0.12.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/AGENTS.md +10 -0
- package/CHANGELOG.md +268 -0
- package/CONTRIBUTING.md +60 -0
- package/DESIGN.md +51 -0
- package/LICENSE +21 -0
- package/README.md +155 -2
- package/TESTING.md +83 -0
- package/baseline-policy.json +16 -0
- package/bin/mortar.mjs +258 -0
- package/catalog.json +9900 -0
- package/cli/audit.mjs +943 -0
- package/cli/data.mjs +176 -0
- package/cli/mcp.mjs +216 -0
- package/cli/migrations.mjs +128 -0
- package/cli/packet.mjs +267 -0
- package/cli/search.mjs +246 -0
- package/cli/tools.mjs +229 -0
- package/design-principles.json +30 -0
- package/dist/agent-data.json +1 -0
- package/dist/application.d.ts +340 -0
- package/dist/application.js +240 -0
- package/dist/auth.d.ts +121 -0
- package/dist/auth.js +97 -0
- package/dist/brand.d.ts +69 -0
- package/dist/brand.js +266 -0
- package/dist/calendar.d.ts +75 -0
- package/dist/calendar.js +310 -0
- package/dist/choice.d.ts +36 -0
- package/dist/choice.js +36 -0
- package/dist/composer.d.ts +28 -0
- package/dist/composer.js +47 -0
- package/dist/display.d.ts +136 -0
- package/dist/display.js +75 -0
- package/dist/docs.d.ts +145 -0
- package/dist/docs.js +141 -0
- package/dist/ecommerce.d.ts +261 -0
- package/dist/ecommerce.js +177 -0
- package/dist/feedback.d.ts +80 -0
- package/dist/feedback.js +98 -0
- package/dist/foundation.d.ts +42 -0
- package/dist/foundation.js +121 -0
- package/dist/heading.d.ts +5 -0
- package/dist/heading.js +9 -0
- package/dist/icons.d.ts +50 -0
- package/dist/icons.js +70 -0
- package/dist/illustration.d.ts +47 -0
- package/dist/illustration.js +47 -0
- package/dist/index.d.ts +36 -0
- package/dist/index.js +18 -0
- package/dist/inspection.d.ts +24 -0
- package/dist/inspection.js +14 -0
- package/dist/internal.d.ts +514 -0
- package/dist/internal.js +332 -0
- package/dist/layout.d.ts +107 -0
- package/dist/layout.js +66 -0
- package/dist/marketing-sections.d.ts +161 -0
- package/dist/marketing-sections.js +58 -0
- package/dist/marketing.d.ts +277 -0
- package/dist/marketing.js +163 -0
- package/dist/navigation.d.ts +35 -0
- package/dist/navigation.js +44 -0
- package/dist/primitives.d.ts +258 -0
- package/dist/primitives.js +244 -0
- package/dist/showcase.d.ts +29 -0
- package/dist/showcase.js +10 -0
- package/dist/workspace.d.ts +80 -0
- package/dist/workspace.js +48 -0
- package/docs/agents.md +127 -0
- package/docs/auth-flows.md +71 -0
- package/docs/migrating-to-0.6.md +94 -0
- package/docs/theming.md +170 -0
- package/package.json +94 -4
- package/skills/mortar/SKILL.md +36 -0
- package/src/application.css +1487 -0
- package/src/application.tsx +1413 -0
- package/src/auth.tsx +452 -0
- package/src/brand.ts +335 -0
- package/src/calendar.css +468 -0
- package/src/calendar.tsx +868 -0
- package/src/choice.tsx +240 -0
- package/src/composer.tsx +158 -0
- package/src/data.css +690 -0
- package/src/display.tsx +452 -0
- package/src/docs.css +704 -0
- package/src/docs.tsx +737 -0
- package/src/ecommerce.css +810 -0
- package/src/ecommerce.tsx +1033 -0
- package/src/feedback.tsx +388 -0
- package/src/foundation.css +1867 -0
- package/src/foundation.tsx +227 -0
- package/src/heading.ts +13 -0
- package/src/icons.tsx +237 -0
- package/src/illustration.tsx +169 -0
- package/src/index.ts +233 -0
- package/src/inspection.tsx +91 -0
- package/src/internal.tsx +700 -0
- package/src/layout.tsx +342 -0
- package/src/marketing-sections.tsx +621 -0
- package/src/marketing.css +2579 -0
- package/src/marketing.tsx +1235 -0
- package/src/navigation.tsx +118 -0
- package/src/overlays.css +350 -0
- package/src/primitives.tsx +1135 -0
- package/src/showcase.tsx +120 -0
- package/src/tokens.css +449 -0
- package/src/workspace.tsx +333 -0
- package/styles.css +56 -0
package/src/internal.tsx
ADDED
|
@@ -0,0 +1,700 @@
|
|
|
1
|
+
// Shared building blocks for the kit's components. Internal: nothing here is
|
|
2
|
+
// exported from the package entry, so these can change without a release note.
|
|
3
|
+
import {
|
|
4
|
+
Fragment,
|
|
5
|
+
createContext,
|
|
6
|
+
createElement,
|
|
7
|
+
useContext,
|
|
8
|
+
useEffect,
|
|
9
|
+
useId,
|
|
10
|
+
useState,
|
|
11
|
+
type HTMLAttributes,
|
|
12
|
+
type RefObject,
|
|
13
|
+
type ReactNode,
|
|
14
|
+
cloneElement,
|
|
15
|
+
isValidElement,
|
|
16
|
+
} from "react";
|
|
17
|
+
import { Dialog, DialogPanel, DialogTitle } from "@headlessui/react";
|
|
18
|
+
import { KitLink, type ItemGroup } from "./navigation.js";
|
|
19
|
+
import { ActionLink, Button, type LinkAction } from "./primitives.js";
|
|
20
|
+
import { headingTag, type HeadingLevel } from "./heading.js";
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Names a catalogue component on its root: spread it on the outermost element
|
|
24
|
+
* the component renders, `<nav {...componentRoot("Pagination")}>`. A dialog
|
|
25
|
+
* marks its dialog root (portalled), and a component that renders sibling
|
|
26
|
+
* elements marks the first one it always renders. Pass `root = false` when
|
|
27
|
+
* this element is not the outermost one for the current props, e.g. a wrapper
|
|
28
|
+
* then takes the name. The attribute is a hook for tests and consumers, not
|
|
29
|
+
* for styling; the only selector on it is the internal Prose boundary.
|
|
30
|
+
*/
|
|
31
|
+
export function componentRoot(name: string, root = true) {
|
|
32
|
+
return { "data-m-component": root ? name : undefined };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const ComponentRootContext = createContext<
|
|
36
|
+
{ name: string; of: string } | undefined
|
|
37
|
+
>(undefined);
|
|
38
|
+
/**
|
|
39
|
+
* Renders `children`, one `of` component, as the root of the component `name`:
|
|
40
|
+
* that root then carries `name` (SearchDialog renders a CommandPalette). Only
|
|
41
|
+
* an `of` component that reads `useComponentName` takes the name, so keep the
|
|
42
|
+
* same kind of component out of its slots.
|
|
43
|
+
*/
|
|
44
|
+
export function AsComponentRoot({
|
|
45
|
+
name,
|
|
46
|
+
of,
|
|
47
|
+
children,
|
|
48
|
+
}: {
|
|
49
|
+
name: string;
|
|
50
|
+
of: string;
|
|
51
|
+
children: ReactNode;
|
|
52
|
+
}) {
|
|
53
|
+
return (
|
|
54
|
+
<ComponentRootContext.Provider value={{ name, of }}>
|
|
55
|
+
{children}
|
|
56
|
+
</ComponentRootContext.Provider>
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* The root name of a component that another can render as its root: `name`,
|
|
61
|
+
* or the outer component's name inside its `AsComponentRoot`.
|
|
62
|
+
*/
|
|
63
|
+
export function useComponentName(name: string) {
|
|
64
|
+
const outer = useContext(ComponentRootContext);
|
|
65
|
+
return outer?.of === name ? outer.name : name;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Joins the truthy class names with single spaces. */
|
|
69
|
+
export function cx(...classes: (string | false | null | undefined)[]) {
|
|
70
|
+
return classes.filter(Boolean).join(" ");
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface IntroProps {
|
|
74
|
+
eyebrow?: string;
|
|
75
|
+
/** Omit to render no heading. */
|
|
76
|
+
title?: string;
|
|
77
|
+
/** A phrase in `title` to emphasise; its first occurrence renders as `<em>`. */
|
|
78
|
+
emphasis?: string;
|
|
79
|
+
description?: string;
|
|
80
|
+
/** Id for the heading, e.g. the target of the section's aria-labelledby. */
|
|
81
|
+
id?: string;
|
|
82
|
+
headingLevel?: HeadingLevel;
|
|
83
|
+
/** Class on the description paragraph; `null` leaves it unstyled. */
|
|
84
|
+
descriptionClassName?: string | null;
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* The eyebrow, heading and description that open a section, as three child
|
|
88
|
+
* slots. Spread them (`{...introSlots(props)}`) when consumer content follows
|
|
89
|
+
* in the same parent: React derives useId values from each child's position,
|
|
90
|
+
* so keeping the slots flat keeps the ids of that content unchanged.
|
|
91
|
+
*/
|
|
92
|
+
export function introSlots({
|
|
93
|
+
eyebrow,
|
|
94
|
+
title,
|
|
95
|
+
emphasis,
|
|
96
|
+
description,
|
|
97
|
+
id,
|
|
98
|
+
headingLevel = 2,
|
|
99
|
+
descriptionClassName = "m-section-description",
|
|
100
|
+
}: IntroProps): ReactNode[] {
|
|
101
|
+
const Heading = headingTag(headingLevel);
|
|
102
|
+
return [
|
|
103
|
+
eyebrow && <p className="m-eyebrow">{eyebrow}</p>,
|
|
104
|
+
title !== undefined && (
|
|
105
|
+
<Heading id={id}>{emphasise(title, emphasis)}</Heading>
|
|
106
|
+
),
|
|
107
|
+
description && (
|
|
108
|
+
<p className={descriptionClassName ?? undefined}>{description}</p>
|
|
109
|
+
),
|
|
110
|
+
];
|
|
111
|
+
}
|
|
112
|
+
/** `title` with the first occurrence of `phrase` in `<em>`; unchanged when absent. */
|
|
113
|
+
function emphasise(title: string, phrase?: string): ReactNode {
|
|
114
|
+
const at = phrase ? title.indexOf(phrase) : -1;
|
|
115
|
+
if (!phrase || at < 0) return title;
|
|
116
|
+
return (
|
|
117
|
+
<>
|
|
118
|
+
{title.slice(0, at)}
|
|
119
|
+
<em className="m-emphasis">{phrase}</em>
|
|
120
|
+
{title.slice(at + phrase.length)}
|
|
121
|
+
</>
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
/** The section intro as one element, for parents where nothing user-supplied follows it. */
|
|
125
|
+
export function SectionIntro(props: IntroProps) {
|
|
126
|
+
return <>{...introSlots(props)}</>;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** A row of link actions: the first primary, the rest secondary. */
|
|
130
|
+
export function ActionLinks({ actions }: { actions: LinkAction[] }) {
|
|
131
|
+
return (
|
|
132
|
+
<div className="m-actions">
|
|
133
|
+
{actions.map((action, i) => (
|
|
134
|
+
<ActionLink
|
|
135
|
+
key={`${action.label}:${action.href}:${i}`}
|
|
136
|
+
{...action}
|
|
137
|
+
variant={i === 0 ? "primary" : "secondary"}
|
|
138
|
+
/>
|
|
139
|
+
))}
|
|
140
|
+
</div>
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export interface Term {
|
|
145
|
+
term: ReactNode;
|
|
146
|
+
details: ReactNode;
|
|
147
|
+
/** React key; defaults to the term. */
|
|
148
|
+
key?: string;
|
|
149
|
+
/** Extra content after the details, e.g. a second dd. */
|
|
150
|
+
after?: ReactNode;
|
|
151
|
+
/** Attributes for the term's wrapping div. */
|
|
152
|
+
attributes?: HTMLAttributes<HTMLDivElement> &
|
|
153
|
+
Record<`data-${string}`, unknown>;
|
|
154
|
+
}
|
|
155
|
+
/** A description list whose terms each sit in a div with their details. */
|
|
156
|
+
export function TermList({
|
|
157
|
+
items,
|
|
158
|
+
children,
|
|
159
|
+
...props
|
|
160
|
+
}: HTMLAttributes<HTMLDListElement> &
|
|
161
|
+
Record<`data-${string}`, unknown> & {
|
|
162
|
+
items: Term[];
|
|
163
|
+
/** Content after the terms, e.g. a total row. */
|
|
164
|
+
children?: ReactNode;
|
|
165
|
+
}) {
|
|
166
|
+
// createElement keeps each element's child count the same as the hand-written
|
|
167
|
+
// lists this replaced (no empty `after` or `children` slot), so useId values
|
|
168
|
+
// inside the details do not move.
|
|
169
|
+
const rows = items.map((item) =>
|
|
170
|
+
createElement(
|
|
171
|
+
"div",
|
|
172
|
+
{ key: item.key ?? String(item.term), ...item.attributes },
|
|
173
|
+
<dt>{item.term}</dt>,
|
|
174
|
+
<dd>{item.details}</dd>,
|
|
175
|
+
...("after" in item ? [item.after] : []),
|
|
176
|
+
),
|
|
177
|
+
);
|
|
178
|
+
return createElement(
|
|
179
|
+
"dl",
|
|
180
|
+
props,
|
|
181
|
+
rows,
|
|
182
|
+
...(children === undefined ? [] : [children]),
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Icon, label and count inside a navigation link or tab. `prefix` picks the
|
|
188
|
+
* class family (`m-nav-*` or `m-tab-*`). A count shows whenever it is defined.
|
|
189
|
+
*/
|
|
190
|
+
export function LinkContent({
|
|
191
|
+
prefix,
|
|
192
|
+
label,
|
|
193
|
+
icon,
|
|
194
|
+
count,
|
|
195
|
+
marker = false,
|
|
196
|
+
}: {
|
|
197
|
+
prefix: "nav" | "tab";
|
|
198
|
+
label: ReactNode;
|
|
199
|
+
icon?: ReactNode;
|
|
200
|
+
count?: number | string;
|
|
201
|
+
/** Show the dot marker when there is no icon (sidebar links). */
|
|
202
|
+
marker?: boolean;
|
|
203
|
+
}) {
|
|
204
|
+
const glyph = (
|
|
205
|
+
<span className={`m-${prefix}-icon`} aria-hidden="true">
|
|
206
|
+
{icon}
|
|
207
|
+
</span>
|
|
208
|
+
);
|
|
209
|
+
// createElement keeps a fixed child count, so useId values inside a
|
|
210
|
+
// consumer's icon stay put.
|
|
211
|
+
return createElement(
|
|
212
|
+
Fragment,
|
|
213
|
+
null,
|
|
214
|
+
marker ? (
|
|
215
|
+
icon ? (
|
|
216
|
+
glyph
|
|
217
|
+
) : (
|
|
218
|
+
<span className="m-nav-marker" aria-hidden="true" />
|
|
219
|
+
)
|
|
220
|
+
) : (
|
|
221
|
+
icon && glyph
|
|
222
|
+
),
|
|
223
|
+
label,
|
|
224
|
+
count !== undefined && <span className={`m-${prefix}-count`}>{count}</span>,
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** A generated id that is safe in CSS selectors and URL fragments. */
|
|
229
|
+
export function useDomId(prefix: string) {
|
|
230
|
+
return prefix + useId().replace(/[^a-zA-Z0-9-]/g, "");
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** Why an animation is not running; null while it plays. */
|
|
234
|
+
export type PauseReason = "user" | "motion" | "hidden" | "offscreen" | null;
|
|
235
|
+
/**
|
|
236
|
+
* Shared pause and resume for decorative motion. It plays only while the
|
|
237
|
+
* user has not paused it, reduced motion is not requested, the document is
|
|
238
|
+
* visible and `ref` is on screen. The user's pause is kept apart from the
|
|
239
|
+
* automatic reasons, so returning to the page never overrides it. `paused`
|
|
240
|
+
* and `onPausedChange` make the user's choice controlled, for example one
|
|
241
|
+
* state shared by several animations. Every listener is removed on unmount.
|
|
242
|
+
*/
|
|
243
|
+
export function usePlayback(
|
|
244
|
+
ref: RefObject<Element | null>,
|
|
245
|
+
{
|
|
246
|
+
paused,
|
|
247
|
+
onPausedChange,
|
|
248
|
+
enabled = true,
|
|
249
|
+
}: {
|
|
250
|
+
paused?: boolean;
|
|
251
|
+
onPausedChange?: (paused: boolean) => void;
|
|
252
|
+
enabled?: boolean;
|
|
253
|
+
},
|
|
254
|
+
) {
|
|
255
|
+
const [ownPaused, setOwnPaused] = useState(false);
|
|
256
|
+
const [reduced, setReduced] = useState(false);
|
|
257
|
+
const [hidden, setHidden] = useState(false);
|
|
258
|
+
const [offscreen, setOffscreen] = useState(false);
|
|
259
|
+
const userPaused = paused ?? ownPaused;
|
|
260
|
+
useEffect(() => {
|
|
261
|
+
if (!enabled) return;
|
|
262
|
+
const media = window.matchMedia("(prefers-reduced-motion: reduce)");
|
|
263
|
+
const onMedia = () => setReduced(media.matches);
|
|
264
|
+
const onVisibility = () => setHidden(document.visibilityState === "hidden");
|
|
265
|
+
onMedia();
|
|
266
|
+
onVisibility();
|
|
267
|
+
media.addEventListener("change", onMedia);
|
|
268
|
+
document.addEventListener("visibilitychange", onVisibility);
|
|
269
|
+
const observer =
|
|
270
|
+
ref.current && typeof IntersectionObserver !== "undefined"
|
|
271
|
+
? new IntersectionObserver(([entry]) =>
|
|
272
|
+
setOffscreen(!entry.isIntersecting),
|
|
273
|
+
)
|
|
274
|
+
: null;
|
|
275
|
+
if (observer && ref.current) observer.observe(ref.current);
|
|
276
|
+
return () => {
|
|
277
|
+
media.removeEventListener("change", onMedia);
|
|
278
|
+
document.removeEventListener("visibilitychange", onVisibility);
|
|
279
|
+
observer?.disconnect();
|
|
280
|
+
};
|
|
281
|
+
}, [enabled, ref]);
|
|
282
|
+
const reason: PauseReason = userPaused
|
|
283
|
+
? "user"
|
|
284
|
+
: reduced
|
|
285
|
+
? "motion"
|
|
286
|
+
: hidden
|
|
287
|
+
? "hidden"
|
|
288
|
+
: offscreen
|
|
289
|
+
? "offscreen"
|
|
290
|
+
: null;
|
|
291
|
+
const toggle = () => {
|
|
292
|
+
const next = !userPaused;
|
|
293
|
+
if (paused === undefined) setOwnPaused(next);
|
|
294
|
+
onPausedChange?.(next);
|
|
295
|
+
};
|
|
296
|
+
return { reason, reduced, userPaused, toggle };
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Next index for arrow, Home and End keys in a horizontal set (wrapping), or
|
|
301
|
+
* null for any other key.
|
|
302
|
+
*/
|
|
303
|
+
export function rovingIndex(key: string, index: number, count: number) {
|
|
304
|
+
if (key === "ArrowRight") return (index + 1) % count;
|
|
305
|
+
if (key === "ArrowLeft") return (index - 1 + count) % count;
|
|
306
|
+
if (key === "Home") return 0;
|
|
307
|
+
if (key === "End") return count - 1;
|
|
308
|
+
return null;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/** Colour swatch whose label is announced in place of the colour. */
|
|
312
|
+
export function Swatch({ color, label }: { color?: string; label: ReactNode }) {
|
|
313
|
+
return (
|
|
314
|
+
<>
|
|
315
|
+
<span
|
|
316
|
+
className="m-swatch"
|
|
317
|
+
aria-hidden="true"
|
|
318
|
+
style={{ background: color }}
|
|
319
|
+
/>
|
|
320
|
+
<span className="m-sr-only">{label}</span>
|
|
321
|
+
</>
|
|
322
|
+
);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/** Ids and describedby wiring for a labelled field with a hint or error message. */
|
|
326
|
+
export function useField({
|
|
327
|
+
id,
|
|
328
|
+
hint,
|
|
329
|
+
error,
|
|
330
|
+
describedBy,
|
|
331
|
+
}: {
|
|
332
|
+
id?: string;
|
|
333
|
+
hint?: string;
|
|
334
|
+
error?: string;
|
|
335
|
+
/** The consumer's own aria-describedby, kept before the message id. */
|
|
336
|
+
describedBy?: string;
|
|
337
|
+
}) {
|
|
338
|
+
const generated = useId();
|
|
339
|
+
const fieldId = id || generated;
|
|
340
|
+
const messageId = fieldId + "-message";
|
|
341
|
+
return {
|
|
342
|
+
fieldId,
|
|
343
|
+
messageId,
|
|
344
|
+
describedBy:
|
|
345
|
+
[describedBy, (error || hint) && messageId].filter(Boolean).join(" ") ||
|
|
346
|
+
undefined,
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/** The hint or error under a field; the error wins and is styled as one. */
|
|
351
|
+
export function FieldMessage({
|
|
352
|
+
id,
|
|
353
|
+
hint,
|
|
354
|
+
error,
|
|
355
|
+
}: {
|
|
356
|
+
id: string;
|
|
357
|
+
hint?: string;
|
|
358
|
+
error?: string;
|
|
359
|
+
}) {
|
|
360
|
+
return error || hint ? (
|
|
361
|
+
<p id={id} className={error ? "m-error" : ""}>
|
|
362
|
+
{error || hint}
|
|
363
|
+
</p>
|
|
364
|
+
) : null;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/** Headless dialog root with the shared backdrop. */
|
|
368
|
+
export function DialogFrame({
|
|
369
|
+
open,
|
|
370
|
+
onClose,
|
|
371
|
+
component,
|
|
372
|
+
children,
|
|
373
|
+
}: {
|
|
374
|
+
open: boolean;
|
|
375
|
+
onClose: () => void;
|
|
376
|
+
/** The catalogue component this dialog is the root of, if any. */
|
|
377
|
+
component?: string;
|
|
378
|
+
children: ReactNode;
|
|
379
|
+
}) {
|
|
380
|
+
return (
|
|
381
|
+
<Dialog
|
|
382
|
+
open={open}
|
|
383
|
+
onClose={onClose}
|
|
384
|
+
className="m-dialog"
|
|
385
|
+
{...componentRoot(component ?? "", component !== undefined)}
|
|
386
|
+
>
|
|
387
|
+
<ThemeScope>
|
|
388
|
+
<div className="m-backdrop" aria-hidden="true" />
|
|
389
|
+
{children}
|
|
390
|
+
</ThemeScope>
|
|
391
|
+
</Dialog>
|
|
392
|
+
);
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/** Narrow-screen navigation drawer used by AppShell and DocsShell. */
|
|
396
|
+
export function MobileNavDialog({
|
|
397
|
+
open,
|
|
398
|
+
onClose,
|
|
399
|
+
title,
|
|
400
|
+
children,
|
|
401
|
+
}: {
|
|
402
|
+
open: boolean;
|
|
403
|
+
onClose: () => void;
|
|
404
|
+
title: string;
|
|
405
|
+
children: ReactNode;
|
|
406
|
+
}) {
|
|
407
|
+
return (
|
|
408
|
+
<DialogFrame open={open} onClose={onClose}>
|
|
409
|
+
<DialogPanel className="m-mobile-sidebar">
|
|
410
|
+
<div className="m-dialog-heading">
|
|
411
|
+
<DialogTitle>{title}</DialogTitle>
|
|
412
|
+
<Button variant="ghost" onClick={onClose}>
|
|
413
|
+
Close navigation
|
|
414
|
+
</Button>
|
|
415
|
+
</div>
|
|
416
|
+
{children}
|
|
417
|
+
</DialogPanel>
|
|
418
|
+
</DialogFrame>
|
|
419
|
+
);
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/** `introSlots` with the shared `m-section-title` class on the heading. */
|
|
423
|
+
export function titledIntroSlots(props: IntroProps): ReactNode[] {
|
|
424
|
+
return classedIntroSlots(props, "m-section-title");
|
|
425
|
+
}
|
|
426
|
+
/** `data-tone` for a section surface tone; the default tone sets no attribute. */
|
|
427
|
+
export function sectionTone(tone?: "default" | "muted" | "inverse" | "accent") {
|
|
428
|
+
return tone && tone !== "default" ? tone : undefined;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/** `SectionIntro` with the `m-section-title` heading class. */
|
|
432
|
+
export function TitledIntro(props: IntroProps) {
|
|
433
|
+
return <>{...titledIntroSlots(props)}</>;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/** A navigation link with optional sub-pages (NavigationItem's shape). */
|
|
437
|
+
export interface NavTreeItem {
|
|
438
|
+
label: string;
|
|
439
|
+
href: string;
|
|
440
|
+
current?: boolean;
|
|
441
|
+
icon?: ReactNode;
|
|
442
|
+
count?: number | string;
|
|
443
|
+
children?: NavTreeItem[];
|
|
444
|
+
}
|
|
445
|
+
/**
|
|
446
|
+
* Navigation links with icon and count. An item with children becomes a
|
|
447
|
+
* disclosure, open when it or a child is current; its own page is the first
|
|
448
|
+
* link inside.
|
|
449
|
+
*/
|
|
450
|
+
export function NavTreeLinks({
|
|
451
|
+
items,
|
|
452
|
+
marker = false,
|
|
453
|
+
onNavigate,
|
|
454
|
+
}: {
|
|
455
|
+
items: NavTreeItem[];
|
|
456
|
+
/** Show the dot marker when an item has no icon (sidebar links). */
|
|
457
|
+
marker?: boolean;
|
|
458
|
+
onNavigate?: () => void;
|
|
459
|
+
}) {
|
|
460
|
+
const link = (item: NavTreeItem, key: string) => (
|
|
461
|
+
<KitLink
|
|
462
|
+
key={key}
|
|
463
|
+
href={item.href}
|
|
464
|
+
aria-current={item.current ? "page" : undefined}
|
|
465
|
+
onClick={onNavigate}
|
|
466
|
+
>
|
|
467
|
+
<LinkContent
|
|
468
|
+
prefix="nav"
|
|
469
|
+
marker={marker}
|
|
470
|
+
icon={item.icon}
|
|
471
|
+
label={item.label}
|
|
472
|
+
count={item.count}
|
|
473
|
+
/>
|
|
474
|
+
</KitLink>
|
|
475
|
+
);
|
|
476
|
+
return (
|
|
477
|
+
<>
|
|
478
|
+
{items.map((item, index) => {
|
|
479
|
+
const key = `${item.label}:${item.href}:${index}`;
|
|
480
|
+
return item.children?.length ? (
|
|
481
|
+
<details
|
|
482
|
+
key={key}
|
|
483
|
+
className="m-nav-group"
|
|
484
|
+
open={
|
|
485
|
+
item.current ||
|
|
486
|
+
item.children.some((child) => child.current) ||
|
|
487
|
+
undefined
|
|
488
|
+
}
|
|
489
|
+
>
|
|
490
|
+
<summary>
|
|
491
|
+
<LinkContent
|
|
492
|
+
prefix="nav"
|
|
493
|
+
marker={marker}
|
|
494
|
+
icon={item.icon}
|
|
495
|
+
label={item.label}
|
|
496
|
+
/>
|
|
497
|
+
</summary>
|
|
498
|
+
<div className="m-nav-children">
|
|
499
|
+
{item.href && link({ ...item, icon: undefined }, "parent")}
|
|
500
|
+
<NavTreeLinks
|
|
501
|
+
items={item.children}
|
|
502
|
+
marker={marker}
|
|
503
|
+
onNavigate={onNavigate}
|
|
504
|
+
/>
|
|
505
|
+
</div>
|
|
506
|
+
</details>
|
|
507
|
+
) : (
|
|
508
|
+
link(item, key)
|
|
509
|
+
);
|
|
510
|
+
})}
|
|
511
|
+
</>
|
|
512
|
+
);
|
|
513
|
+
}
|
|
514
|
+
/**
|
|
515
|
+
* Labelled groups of navigation links. Group labels are not headings, so the
|
|
516
|
+
* page's own h1 stays first in the heading outline.
|
|
517
|
+
*/
|
|
518
|
+
export function NavGroups({
|
|
519
|
+
groups,
|
|
520
|
+
marker,
|
|
521
|
+
onNavigate,
|
|
522
|
+
}: {
|
|
523
|
+
groups: ItemGroup<NavTreeItem>[];
|
|
524
|
+
marker?: boolean;
|
|
525
|
+
onNavigate?: () => void;
|
|
526
|
+
}) {
|
|
527
|
+
const groupId = useDomId("m-nav-group-");
|
|
528
|
+
return (
|
|
529
|
+
<>
|
|
530
|
+
{groups.map((group, index) => (
|
|
531
|
+
<div
|
|
532
|
+
key={group.label}
|
|
533
|
+
role="group"
|
|
534
|
+
aria-labelledby={`${groupId}-${index}`}
|
|
535
|
+
>
|
|
536
|
+
<p id={`${groupId}-${index}`} className="m-nav-group-title">
|
|
537
|
+
{group.label}
|
|
538
|
+
</p>
|
|
539
|
+
<NavTreeLinks
|
|
540
|
+
items={group.items}
|
|
541
|
+
marker={marker}
|
|
542
|
+
onNavigate={onNavigate}
|
|
543
|
+
/>
|
|
544
|
+
</div>
|
|
545
|
+
))}
|
|
546
|
+
</>
|
|
547
|
+
);
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
/**
|
|
551
|
+
* The hint or error under a field. Unlike FieldMessage, the error is its own
|
|
552
|
+
* role="alert" element, so it is announced when it appears.
|
|
553
|
+
*/
|
|
554
|
+
export function FieldFeedback({
|
|
555
|
+
id,
|
|
556
|
+
hint,
|
|
557
|
+
error,
|
|
558
|
+
}: {
|
|
559
|
+
id: string;
|
|
560
|
+
hint?: string;
|
|
561
|
+
error?: string;
|
|
562
|
+
}) {
|
|
563
|
+
if (error)
|
|
564
|
+
return (
|
|
565
|
+
<p key="error" id={id} className="m-error" role="alert">
|
|
566
|
+
{error}
|
|
567
|
+
</p>
|
|
568
|
+
);
|
|
569
|
+
return hint ? (
|
|
570
|
+
<p key="hint" id={id}>
|
|
571
|
+
{hint}
|
|
572
|
+
</p>
|
|
573
|
+
) : null;
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
/** Decorative required marker after a field label; the control itself is marked required. */
|
|
577
|
+
export function RequiredMark() {
|
|
578
|
+
return (
|
|
579
|
+
<span className="m-required" aria-hidden="true">
|
|
580
|
+
*
|
|
581
|
+
</span>
|
|
582
|
+
);
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
const TABBABLE =
|
|
586
|
+
'a[href], button:not(:disabled), input:not(:disabled):not([type="hidden"]), select:not(:disabled), textarea:not(:disabled), [tabindex]:not([tabindex="-1"])';
|
|
587
|
+
/**
|
|
588
|
+
* Call before `element` is removed. If focus is inside it, move focus to the
|
|
589
|
+
* next tabbable element after it (within `scope`, default the document), else
|
|
590
|
+
* the previous one, else `fallback`, so focus never drops to the body.
|
|
591
|
+
*/
|
|
592
|
+
export function focusAway(
|
|
593
|
+
element: HTMLElement | null,
|
|
594
|
+
{
|
|
595
|
+
scope,
|
|
596
|
+
fallback,
|
|
597
|
+
}: { scope?: HTMLElement | null; fallback?: HTMLElement | null } = {},
|
|
598
|
+
) {
|
|
599
|
+
if (!element) return;
|
|
600
|
+
const document = element.ownerDocument;
|
|
601
|
+
if (!element.contains(document.activeElement)) return;
|
|
602
|
+
const candidates = [
|
|
603
|
+
...(scope ?? document.body).querySelectorAll<HTMLElement>(TABBABLE),
|
|
604
|
+
].filter(
|
|
605
|
+
(el) =>
|
|
606
|
+
!element.contains(el) &&
|
|
607
|
+
!el.closest("[hidden], [inert]") &&
|
|
608
|
+
el.getClientRects().length > 0,
|
|
609
|
+
);
|
|
610
|
+
const following = (el: HTMLElement) =>
|
|
611
|
+
element.compareDocumentPosition(el) & Node.DOCUMENT_POSITION_FOLLOWING;
|
|
612
|
+
// Nearest first: following elements, then preceding ones from the closest.
|
|
613
|
+
// Some candidates have boxes but cannot take focus (content of a closed
|
|
614
|
+
// <details>), so keep trying until one does.
|
|
615
|
+
const ordered = [
|
|
616
|
+
...candidates.filter(following),
|
|
617
|
+
...candidates.filter((el) => !following(el)).reverse(),
|
|
618
|
+
];
|
|
619
|
+
for (const el of fallback ? [...ordered, fallback] : ordered) {
|
|
620
|
+
el.focus();
|
|
621
|
+
if (document.activeElement === el) return;
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
/**
|
|
626
|
+
* The data-mortar-* attributes of the nearest container-scoped ThemeProvider.
|
|
627
|
+
* Optional (undefined outside one), so it does not make dialogs depend on it.
|
|
628
|
+
*/
|
|
629
|
+
export const ThemeScopeContext = createContext<
|
|
630
|
+
Record<string, string> | undefined
|
|
631
|
+
>(undefined);
|
|
632
|
+
/**
|
|
633
|
+
* Re-applies a container-scoped theme to content rendered outside its wrapper
|
|
634
|
+
* (dialogs portal to the document body). The wrapper is display: contents, so
|
|
635
|
+
* it carries the tokens without adding a box.
|
|
636
|
+
*/
|
|
637
|
+
export function ThemeScope({ children }: { children: ReactNode }) {
|
|
638
|
+
const attributes = useContext(ThemeScopeContext);
|
|
639
|
+
if (!attributes) return <>{children}</>;
|
|
640
|
+
return (
|
|
641
|
+
<div {...attributes} className="m-theme-scope">
|
|
642
|
+
{children}
|
|
643
|
+
</div>
|
|
644
|
+
);
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
/**
|
|
648
|
+
* introSlots with a class on the heading, so a title keeps its styles at any
|
|
649
|
+
* `headingLevel` instead of only as an h2.
|
|
650
|
+
*/
|
|
651
|
+
export function classedIntroSlots(
|
|
652
|
+
props: IntroProps,
|
|
653
|
+
titleClassName: string,
|
|
654
|
+
): ReactNode[] {
|
|
655
|
+
const [eyebrow, heading, description] = introSlots(props);
|
|
656
|
+
return [
|
|
657
|
+
eyebrow,
|
|
658
|
+
isValidElement<{ className?: string }>(heading)
|
|
659
|
+
? cloneElement(heading, { className: titleClassName })
|
|
660
|
+
: heading,
|
|
661
|
+
description,
|
|
662
|
+
];
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
/**
|
|
666
|
+
* Call before a consumer callback that removes `element` (e.g. a chip's
|
|
667
|
+
* remove button). Once the removal has rendered, if focus fell to the body,
|
|
668
|
+
* it moves to the nearest focusable element that took the removed one's place,
|
|
669
|
+
* or the one before it, searching up through the ancestors that remain.
|
|
670
|
+
*/
|
|
671
|
+
export function focusAfterRemoval(element: HTMLElement) {
|
|
672
|
+
const trail: [HTMLElement, number][] = [];
|
|
673
|
+
for (let node: HTMLElement | null = element; node?.parentElement;) {
|
|
674
|
+
const parent: HTMLElement = node.parentElement;
|
|
675
|
+
trail.push([parent, [...parent.children].indexOf(node)]);
|
|
676
|
+
node = parent;
|
|
677
|
+
}
|
|
678
|
+
setTimeout(() => {
|
|
679
|
+
const active = element.ownerDocument.activeElement;
|
|
680
|
+
if (
|
|
681
|
+
element.isConnected ||
|
|
682
|
+
(active && active !== element.ownerDocument.body)
|
|
683
|
+
)
|
|
684
|
+
return;
|
|
685
|
+
for (const [parent, index] of trail) {
|
|
686
|
+
if (!parent.isConnected) continue;
|
|
687
|
+
const siblings = [...parent.children] as HTMLElement[];
|
|
688
|
+
const candidates = [
|
|
689
|
+
...siblings.slice(index),
|
|
690
|
+
...siblings.slice(0, index).reverse(),
|
|
691
|
+
];
|
|
692
|
+
for (const candidate of candidates) {
|
|
693
|
+
const target = candidate.matches(TABBABLE)
|
|
694
|
+
? candidate
|
|
695
|
+
: candidate.querySelector<HTMLElement>(TABBABLE);
|
|
696
|
+
if (target) return target.focus();
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
});
|
|
700
|
+
}
|