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