@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,621 @@
|
|
|
1
|
+
import { IconLinks, KitLink, type IconLink } from "./navigation.js";
|
|
2
|
+
import { useId, useState, type FormEvent, type ReactNode } from "react";
|
|
3
|
+
import { ActionLink, Button, Input, type LinkAction } from "./primitives.js";
|
|
4
|
+
import { Avatar } from "./display.js";
|
|
5
|
+
import type { SectionTone } from "./layout.js";
|
|
6
|
+
import { childLevel, headingTag, type HeadingLevel } from "./heading.js";
|
|
7
|
+
import {
|
|
8
|
+
ActionLinks,
|
|
9
|
+
TermList,
|
|
10
|
+
TitledIntro,
|
|
11
|
+
cx,
|
|
12
|
+
componentRoot,
|
|
13
|
+
sectionTone,
|
|
14
|
+
} from "./internal.js";
|
|
15
|
+
|
|
16
|
+
export function BentoGrid({
|
|
17
|
+
id,
|
|
18
|
+
className,
|
|
19
|
+
eyebrow,
|
|
20
|
+
title,
|
|
21
|
+
description,
|
|
22
|
+
items,
|
|
23
|
+
tone = "default",
|
|
24
|
+
headingLevel = 2,
|
|
25
|
+
}: {
|
|
26
|
+
id?: string;
|
|
27
|
+
className?: string;
|
|
28
|
+
eyebrow?: string;
|
|
29
|
+
title: string;
|
|
30
|
+
description?: string;
|
|
31
|
+
items: {
|
|
32
|
+
title: string;
|
|
33
|
+
description: string;
|
|
34
|
+
visual?: ReactNode;
|
|
35
|
+
/** Columns the tile spans. */
|
|
36
|
+
span?: 1 | 2;
|
|
37
|
+
/** Rows the tile spans, e.g. tall tiles at both ends of a three-column grid. */
|
|
38
|
+
rowSpan?: 1 | 2;
|
|
39
|
+
/** The tile's surface; inverse remaps it to the inverse tokens. */
|
|
40
|
+
tone?: SectionTone;
|
|
41
|
+
}[];
|
|
42
|
+
/** The section's band; inverse gives a dark section whose tiles follow the remapped tokens. */
|
|
43
|
+
tone?: SectionTone;
|
|
44
|
+
/** The section title's level; tile titles sit one level below. */
|
|
45
|
+
headingLevel?: HeadingLevel;
|
|
46
|
+
}) {
|
|
47
|
+
const ItemHeading = headingTag(childLevel(headingLevel));
|
|
48
|
+
return (
|
|
49
|
+
<section
|
|
50
|
+
id={id}
|
|
51
|
+
className={cx("m-marketing-section", className)}
|
|
52
|
+
data-tone={sectionTone(tone)}
|
|
53
|
+
{...componentRoot("BentoGrid")}
|
|
54
|
+
>
|
|
55
|
+
<TitledIntro
|
|
56
|
+
eyebrow={eyebrow}
|
|
57
|
+
title={title}
|
|
58
|
+
description={description}
|
|
59
|
+
headingLevel={headingLevel}
|
|
60
|
+
/>
|
|
61
|
+
<ul className="m-bento">
|
|
62
|
+
{items.map((item, index) => (
|
|
63
|
+
<li
|
|
64
|
+
key={`${item.title}:${index}`}
|
|
65
|
+
data-span={item.span || 1}
|
|
66
|
+
data-row-span={item.rowSpan === 2 ? 2 : undefined}
|
|
67
|
+
>
|
|
68
|
+
<article data-tone={sectionTone(item.tone)}>
|
|
69
|
+
{item.visual && (
|
|
70
|
+
<div className="m-bento-visual">{item.visual}</div>
|
|
71
|
+
)}
|
|
72
|
+
<ItemHeading>{item.title}</ItemHeading>
|
|
73
|
+
<p>{item.description}</p>
|
|
74
|
+
</article>
|
|
75
|
+
</li>
|
|
76
|
+
))}
|
|
77
|
+
</ul>
|
|
78
|
+
</section>
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function StatGroup({
|
|
83
|
+
id,
|
|
84
|
+
className,
|
|
85
|
+
label,
|
|
86
|
+
items,
|
|
87
|
+
layout = "grid",
|
|
88
|
+
background,
|
|
89
|
+
}: {
|
|
90
|
+
id?: string;
|
|
91
|
+
className?: string;
|
|
92
|
+
label: string;
|
|
93
|
+
/** description: optional supporting sentence under the value. */
|
|
94
|
+
items: { value: string; label: string; description?: string }[];
|
|
95
|
+
/** grid: ruled columns. timeline: a dotted line through the items, for dated milestones (value is the date). stepped: cards that rise in height from first to last. */
|
|
96
|
+
layout?: "grid" | "timeline" | "stepped";
|
|
97
|
+
/** Decorative full-cover image behind the stats. The group switches to the inverse tone with a scrim so text keeps its contrast. */
|
|
98
|
+
background?: ReactNode;
|
|
99
|
+
}) {
|
|
100
|
+
const list = (
|
|
101
|
+
<TermList
|
|
102
|
+
id={background ? undefined : id}
|
|
103
|
+
className={cx("m-stat-group", !background && className)}
|
|
104
|
+
aria-label={label}
|
|
105
|
+
data-layout={layout === "grid" ? undefined : layout}
|
|
106
|
+
{...componentRoot("StatGroup", !background)}
|
|
107
|
+
items={items.map((item) => ({
|
|
108
|
+
term: item.label,
|
|
109
|
+
details: item.value,
|
|
110
|
+
after: item.description && (
|
|
111
|
+
<dd className="m-stat-description">{item.description}</dd>
|
|
112
|
+
),
|
|
113
|
+
}))}
|
|
114
|
+
/>
|
|
115
|
+
);
|
|
116
|
+
if (!background) return list;
|
|
117
|
+
return (
|
|
118
|
+
<div
|
|
119
|
+
id={id}
|
|
120
|
+
className={cx("m-stat-band", className)}
|
|
121
|
+
{...componentRoot("StatGroup")}
|
|
122
|
+
data-background="true"
|
|
123
|
+
data-tone="inverse"
|
|
124
|
+
>
|
|
125
|
+
<div className="m-cover-background" aria-hidden="true">
|
|
126
|
+
{background}
|
|
127
|
+
</div>
|
|
128
|
+
{list}
|
|
129
|
+
</div>
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function TestimonialGrid({
|
|
134
|
+
id,
|
|
135
|
+
className,
|
|
136
|
+
title,
|
|
137
|
+
items,
|
|
138
|
+
headingLevel = 2,
|
|
139
|
+
layout = "masonry",
|
|
140
|
+
}: {
|
|
141
|
+
id?: string;
|
|
142
|
+
className?: string;
|
|
143
|
+
title?: string;
|
|
144
|
+
items: {
|
|
145
|
+
quote: string;
|
|
146
|
+
name: string;
|
|
147
|
+
role?: string;
|
|
148
|
+
avatar?: string;
|
|
149
|
+
/** A larger quote that spans the grid's middle columns, or every column in masonry. */
|
|
150
|
+
featured?: boolean;
|
|
151
|
+
}[];
|
|
152
|
+
headingLevel?: HeadingLevel;
|
|
153
|
+
/** masonry: quotes flow down balanced columns at their own heights. grid: rows of equal height. */
|
|
154
|
+
layout?: "masonry" | "grid";
|
|
155
|
+
}) {
|
|
156
|
+
const Heading = headingTag(headingLevel);
|
|
157
|
+
return (
|
|
158
|
+
<section
|
|
159
|
+
id={id}
|
|
160
|
+
className={cx("m-marketing-section", className)}
|
|
161
|
+
aria-label={title ? undefined : "Testimonials"}
|
|
162
|
+
{...componentRoot("TestimonialGrid")}
|
|
163
|
+
>
|
|
164
|
+
{title && <Heading className="m-section-title">{title}</Heading>}
|
|
165
|
+
<div
|
|
166
|
+
className="m-testimonial-grid"
|
|
167
|
+
data-layout={layout === "grid" ? "grid" : undefined}
|
|
168
|
+
>
|
|
169
|
+
{items.map((item) => (
|
|
170
|
+
<figure
|
|
171
|
+
key={item.name}
|
|
172
|
+
data-featured={item.featured ? "true" : undefined}
|
|
173
|
+
>
|
|
174
|
+
<blockquote>{item.quote}</blockquote>
|
|
175
|
+
<figcaption>
|
|
176
|
+
<Avatar decorative name={item.name} src={item.avatar} size="sm" />
|
|
177
|
+
<span>
|
|
178
|
+
<strong>{item.name}</strong>
|
|
179
|
+
{item.role && <span>{item.role}</span>}
|
|
180
|
+
</span>
|
|
181
|
+
</figcaption>
|
|
182
|
+
</figure>
|
|
183
|
+
))}
|
|
184
|
+
</div>
|
|
185
|
+
</section>
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export function PlanComparison({
|
|
190
|
+
id,
|
|
191
|
+
className,
|
|
192
|
+
title,
|
|
193
|
+
description,
|
|
194
|
+
plans,
|
|
195
|
+
featuredPlan,
|
|
196
|
+
groups,
|
|
197
|
+
headingLevel = 2,
|
|
198
|
+
}: {
|
|
199
|
+
id?: string;
|
|
200
|
+
className?: string;
|
|
201
|
+
title: string;
|
|
202
|
+
description?: string;
|
|
203
|
+
plans: string[];
|
|
204
|
+
featuredPlan?: string;
|
|
205
|
+
groups: {
|
|
206
|
+
label: string;
|
|
207
|
+
rows: { feature: string; values: (boolean | string)[] }[];
|
|
208
|
+
}[];
|
|
209
|
+
headingLevel?: HeadingLevel;
|
|
210
|
+
}) {
|
|
211
|
+
const headingId = useId();
|
|
212
|
+
return (
|
|
213
|
+
<section
|
|
214
|
+
id={id}
|
|
215
|
+
className={cx("m-marketing-section", className)}
|
|
216
|
+
aria-labelledby={headingId}
|
|
217
|
+
{...componentRoot("PlanComparison")}
|
|
218
|
+
>
|
|
219
|
+
<TitledIntro
|
|
220
|
+
id={headingId}
|
|
221
|
+
title={title}
|
|
222
|
+
description={description}
|
|
223
|
+
headingLevel={headingLevel}
|
|
224
|
+
/>
|
|
225
|
+
<div
|
|
226
|
+
className="m-table-scroll m-comparison"
|
|
227
|
+
role="region"
|
|
228
|
+
aria-label={`${title} table`}
|
|
229
|
+
tabIndex={0}
|
|
230
|
+
>
|
|
231
|
+
<table>
|
|
232
|
+
<thead>
|
|
233
|
+
<tr>
|
|
234
|
+
<td />
|
|
235
|
+
{plans.map((plan) => (
|
|
236
|
+
<th
|
|
237
|
+
key={plan}
|
|
238
|
+
scope="col"
|
|
239
|
+
data-featured={plan === featuredPlan || undefined}
|
|
240
|
+
>
|
|
241
|
+
{plan}
|
|
242
|
+
</th>
|
|
243
|
+
))}
|
|
244
|
+
</tr>
|
|
245
|
+
</thead>
|
|
246
|
+
{groups.map((group) => (
|
|
247
|
+
<tbody key={group.label}>
|
|
248
|
+
<tr className="m-comparison-group">
|
|
249
|
+
<th scope="colgroup" colSpan={plans.length + 1}>
|
|
250
|
+
{group.label}
|
|
251
|
+
</th>
|
|
252
|
+
</tr>
|
|
253
|
+
{group.rows.map((row) => (
|
|
254
|
+
<tr key={row.feature}>
|
|
255
|
+
<th scope="row">{row.feature}</th>
|
|
256
|
+
{row.values.map((value, i) => (
|
|
257
|
+
<td
|
|
258
|
+
key={plans[i]}
|
|
259
|
+
data-featured={plans[i] === featuredPlan || undefined}
|
|
260
|
+
>
|
|
261
|
+
{typeof value === "boolean" ? (
|
|
262
|
+
// The glyph is decoration; the cell's text is the meaning.
|
|
263
|
+
<>
|
|
264
|
+
<span
|
|
265
|
+
aria-hidden="true"
|
|
266
|
+
className={value ? undefined : "m-comparison-no"}
|
|
267
|
+
>
|
|
268
|
+
{value ? "✓" : "—"}
|
|
269
|
+
</span>
|
|
270
|
+
<span className="m-sr-only">
|
|
271
|
+
{value ? "Included" : "Not included"}
|
|
272
|
+
</span>
|
|
273
|
+
</>
|
|
274
|
+
) : (
|
|
275
|
+
value
|
|
276
|
+
)}
|
|
277
|
+
</td>
|
|
278
|
+
))}
|
|
279
|
+
</tr>
|
|
280
|
+
))}
|
|
281
|
+
</tbody>
|
|
282
|
+
))}
|
|
283
|
+
</table>
|
|
284
|
+
</div>
|
|
285
|
+
</section>
|
|
286
|
+
);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
export function PostList({
|
|
290
|
+
id,
|
|
291
|
+
className,
|
|
292
|
+
title,
|
|
293
|
+
description,
|
|
294
|
+
posts,
|
|
295
|
+
action,
|
|
296
|
+
headingLevel = 2,
|
|
297
|
+
itemHeadingLevel,
|
|
298
|
+
}: {
|
|
299
|
+
id?: string;
|
|
300
|
+
className?: string;
|
|
301
|
+
title: string;
|
|
302
|
+
description?: string;
|
|
303
|
+
posts: {
|
|
304
|
+
href: string;
|
|
305
|
+
title: string;
|
|
306
|
+
excerpt: string;
|
|
307
|
+
date: string;
|
|
308
|
+
dateLabel: string;
|
|
309
|
+
category?: string;
|
|
310
|
+
image?: ReactNode;
|
|
311
|
+
/** Spans the full row with the image beside a larger title; usually the first post. */
|
|
312
|
+
featured?: boolean;
|
|
313
|
+
}[];
|
|
314
|
+
action?: LinkAction;
|
|
315
|
+
headingLevel?: HeadingLevel;
|
|
316
|
+
/** Level of each post title; defaults to one below `headingLevel`. */
|
|
317
|
+
itemHeadingLevel?: HeadingLevel;
|
|
318
|
+
}) {
|
|
319
|
+
const PostHeading = headingTag(itemHeadingLevel ?? childLevel(headingLevel));
|
|
320
|
+
return (
|
|
321
|
+
<section
|
|
322
|
+
id={id}
|
|
323
|
+
className={cx("m-marketing-section", className)}
|
|
324
|
+
{...componentRoot("PostList")}
|
|
325
|
+
>
|
|
326
|
+
<div className="m-section-heading-row">
|
|
327
|
+
<div>
|
|
328
|
+
<TitledIntro
|
|
329
|
+
title={title}
|
|
330
|
+
description={description}
|
|
331
|
+
headingLevel={headingLevel}
|
|
332
|
+
/>
|
|
333
|
+
</div>
|
|
334
|
+
{action && <ActionLink {...action} variant="secondary" />}
|
|
335
|
+
</div>
|
|
336
|
+
<ul className="m-post-list">
|
|
337
|
+
{posts.map((post, index) => (
|
|
338
|
+
<li
|
|
339
|
+
key={`${post.title}:${post.href}:${index}`}
|
|
340
|
+
data-featured={post.featured ? "true" : undefined}
|
|
341
|
+
>
|
|
342
|
+
<article>
|
|
343
|
+
{post.image && <div className="m-post-image">{post.image}</div>}
|
|
344
|
+
<p className="m-post-meta">
|
|
345
|
+
{post.category && <span>{post.category}</span>}
|
|
346
|
+
<time dateTime={post.date}>{post.dateLabel}</time>
|
|
347
|
+
</p>
|
|
348
|
+
<PostHeading className="m-post-title">
|
|
349
|
+
<KitLink href={post.href}>{post.title}</KitLink>
|
|
350
|
+
</PostHeading>
|
|
351
|
+
<p>{post.excerpt}</p>
|
|
352
|
+
</article>
|
|
353
|
+
</li>
|
|
354
|
+
))}
|
|
355
|
+
</ul>
|
|
356
|
+
</section>
|
|
357
|
+
);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
export function TeamGrid({
|
|
361
|
+
id,
|
|
362
|
+
className,
|
|
363
|
+
title,
|
|
364
|
+
description,
|
|
365
|
+
members,
|
|
366
|
+
headingLevel = 2,
|
|
367
|
+
layout = "grid",
|
|
368
|
+
}: {
|
|
369
|
+
id?: string;
|
|
370
|
+
className?: string;
|
|
371
|
+
title: string;
|
|
372
|
+
description?: string;
|
|
373
|
+
members: {
|
|
374
|
+
name: string;
|
|
375
|
+
role: string;
|
|
376
|
+
image?: ReactNode;
|
|
377
|
+
bio?: string;
|
|
378
|
+
/** Profile links, e.g. icon-only social links named "Ana on LinkedIn". */
|
|
379
|
+
links?: IconLink[];
|
|
380
|
+
}[];
|
|
381
|
+
headingLevel?: HeadingLevel;
|
|
382
|
+
/** grid: small square portraits. large: fewer columns of tall portraits. list: each portrait beside the name, role and bio. */
|
|
383
|
+
layout?: "grid" | "large" | "list";
|
|
384
|
+
}) {
|
|
385
|
+
return (
|
|
386
|
+
<section
|
|
387
|
+
id={id}
|
|
388
|
+
className={cx("m-marketing-section", className)}
|
|
389
|
+
{...componentRoot("TeamGrid")}
|
|
390
|
+
>
|
|
391
|
+
<TitledIntro
|
|
392
|
+
title={title}
|
|
393
|
+
description={description}
|
|
394
|
+
headingLevel={headingLevel}
|
|
395
|
+
/>
|
|
396
|
+
<ul
|
|
397
|
+
className="m-team-grid"
|
|
398
|
+
data-layout={layout === "grid" ? undefined : layout}
|
|
399
|
+
>
|
|
400
|
+
{members.map((member) => (
|
|
401
|
+
<li key={member.name}>
|
|
402
|
+
<div className="m-team-portrait">
|
|
403
|
+
{member.image || (
|
|
404
|
+
<Avatar
|
|
405
|
+
decorative
|
|
406
|
+
name={member.name}
|
|
407
|
+
size="lg"
|
|
408
|
+
shape="square"
|
|
409
|
+
/>
|
|
410
|
+
)}
|
|
411
|
+
</div>
|
|
412
|
+
<strong>{member.name}</strong>
|
|
413
|
+
<span>{member.role}</span>
|
|
414
|
+
{member.bio && <p className="m-team-bio">{member.bio}</p>}
|
|
415
|
+
{member.links?.length ? (
|
|
416
|
+
<IconLinks label={`${member.name} links`} links={member.links} />
|
|
417
|
+
) : null}
|
|
418
|
+
</li>
|
|
419
|
+
))}
|
|
420
|
+
</ul>
|
|
421
|
+
</section>
|
|
422
|
+
);
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
export function ContactSection({
|
|
426
|
+
id,
|
|
427
|
+
className,
|
|
428
|
+
title,
|
|
429
|
+
description,
|
|
430
|
+
details = [],
|
|
431
|
+
headingLevel = 2,
|
|
432
|
+
media,
|
|
433
|
+
offices = [],
|
|
434
|
+
children,
|
|
435
|
+
}: {
|
|
436
|
+
id?: string;
|
|
437
|
+
className?: string;
|
|
438
|
+
title: string;
|
|
439
|
+
description?: string;
|
|
440
|
+
details?: { label: string; value: ReactNode }[];
|
|
441
|
+
headingLevel?: HeadingLevel;
|
|
442
|
+
/** An image filling one half of a split layout, beside the intro and form; give it alt text or alt="" if decorative. */
|
|
443
|
+
media?: ReactNode;
|
|
444
|
+
/** Office locations in a grid below; each name is a heading one level below the title. */
|
|
445
|
+
offices?: { name: string; address: ReactNode }[];
|
|
446
|
+
/** The consumer's form. Compose with Input, Select, TextArea and Button. Optional when the section lists offices. */
|
|
447
|
+
children?: ReactNode;
|
|
448
|
+
}) {
|
|
449
|
+
const OfficeHeading = headingTag(childLevel(headingLevel));
|
|
450
|
+
const intro = (
|
|
451
|
+
<>
|
|
452
|
+
<TitledIntro
|
|
453
|
+
title={title}
|
|
454
|
+
description={description}
|
|
455
|
+
headingLevel={headingLevel}
|
|
456
|
+
/>
|
|
457
|
+
{details.length > 0 && (
|
|
458
|
+
<TermList
|
|
459
|
+
items={details.map((d) => ({ term: d.label, details: d.value }))}
|
|
460
|
+
/>
|
|
461
|
+
)}
|
|
462
|
+
</>
|
|
463
|
+
);
|
|
464
|
+
const form = children && <div className="m-contact-form">{children}</div>;
|
|
465
|
+
return (
|
|
466
|
+
<section
|
|
467
|
+
id={id}
|
|
468
|
+
className={cx("m-contact", className)}
|
|
469
|
+
{...componentRoot("ContactSection")}
|
|
470
|
+
data-media={media ? "true" : undefined}
|
|
471
|
+
>
|
|
472
|
+
{media ? (
|
|
473
|
+
<>
|
|
474
|
+
<div className="m-contact-media">{media}</div>
|
|
475
|
+
<div className="m-contact-body">
|
|
476
|
+
<div>{intro}</div>
|
|
477
|
+
{form}
|
|
478
|
+
</div>
|
|
479
|
+
</>
|
|
480
|
+
) : (
|
|
481
|
+
<>
|
|
482
|
+
<div>{intro}</div>
|
|
483
|
+
{form}
|
|
484
|
+
</>
|
|
485
|
+
)}
|
|
486
|
+
{offices.length > 0 && (
|
|
487
|
+
<ul className="m-contact-offices">
|
|
488
|
+
{offices.map((office, index) => (
|
|
489
|
+
<li key={`${office.name}:${index}`}>
|
|
490
|
+
<OfficeHeading>{office.name}</OfficeHeading>
|
|
491
|
+
<address>{office.address}</address>
|
|
492
|
+
</li>
|
|
493
|
+
))}
|
|
494
|
+
</ul>
|
|
495
|
+
)}
|
|
496
|
+
</section>
|
|
497
|
+
);
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
export function Newsletter({
|
|
501
|
+
id,
|
|
502
|
+
className,
|
|
503
|
+
title,
|
|
504
|
+
description,
|
|
505
|
+
onSubmit,
|
|
506
|
+
submitLabel = "Subscribe",
|
|
507
|
+
status = "idle",
|
|
508
|
+
message,
|
|
509
|
+
headingLevel = 2,
|
|
510
|
+
layout = "split",
|
|
511
|
+
variant = "plain",
|
|
512
|
+
}: {
|
|
513
|
+
id?: string;
|
|
514
|
+
className?: string;
|
|
515
|
+
title: string;
|
|
516
|
+
description?: string;
|
|
517
|
+
onSubmit: (email: string) => void;
|
|
518
|
+
submitLabel?: string;
|
|
519
|
+
/** submitting: busy and inert. success: announces `message`. error: shows `message` on the field. */
|
|
520
|
+
status?: "idle" | "submitting" | "success" | "error";
|
|
521
|
+
message?: string;
|
|
522
|
+
headingLevel?: HeadingLevel;
|
|
523
|
+
/** split: the copy beside the form. center: the copy above a centred form. */
|
|
524
|
+
layout?: "split" | "center";
|
|
525
|
+
/** plain: a section divided by a top rule. panel: a rounded, sunken card. */
|
|
526
|
+
variant?: "plain" | "panel";
|
|
527
|
+
}) {
|
|
528
|
+
const [email, setEmail] = useState("");
|
|
529
|
+
const submitting = status === "submitting";
|
|
530
|
+
const submit = (e: FormEvent) => {
|
|
531
|
+
e.preventDefault();
|
|
532
|
+
if (!submitting) onSubmit(email);
|
|
533
|
+
};
|
|
534
|
+
return (
|
|
535
|
+
<section
|
|
536
|
+
id={id}
|
|
537
|
+
className={cx("m-newsletter", className)}
|
|
538
|
+
{...componentRoot("Newsletter")}
|
|
539
|
+
data-layout={layout === "split" ? undefined : layout}
|
|
540
|
+
data-variant={variant === "plain" ? undefined : variant}
|
|
541
|
+
>
|
|
542
|
+
<div>
|
|
543
|
+
<TitledIntro
|
|
544
|
+
title={title}
|
|
545
|
+
description={description}
|
|
546
|
+
descriptionClassName={null}
|
|
547
|
+
headingLevel={headingLevel}
|
|
548
|
+
/>
|
|
549
|
+
</div>
|
|
550
|
+
<form onSubmit={submit} aria-busy={submitting || undefined}>
|
|
551
|
+
<Input
|
|
552
|
+
label="Email address"
|
|
553
|
+
type="email"
|
|
554
|
+
autoComplete="email"
|
|
555
|
+
required
|
|
556
|
+
value={email}
|
|
557
|
+
onChange={(e) => setEmail(e.target.value)}
|
|
558
|
+
error={status === "error" ? message : undefined}
|
|
559
|
+
disabled={submitting}
|
|
560
|
+
/>
|
|
561
|
+
<Button type="submit" loading={submitting}>
|
|
562
|
+
{submitLabel}
|
|
563
|
+
</Button>
|
|
564
|
+
{/* Mounted empty so the success text is announced when it arrives. */}
|
|
565
|
+
<p role="status" className="m-newsletter-status">
|
|
566
|
+
{status === "success" ? message || "Thanks for subscribing." : ""}
|
|
567
|
+
</p>
|
|
568
|
+
</form>
|
|
569
|
+
</section>
|
|
570
|
+
);
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
export function NotFound({
|
|
574
|
+
id,
|
|
575
|
+
className,
|
|
576
|
+
code = "404",
|
|
577
|
+
title,
|
|
578
|
+
description,
|
|
579
|
+
actions,
|
|
580
|
+
illustration,
|
|
581
|
+
background,
|
|
582
|
+
headingLevel = 1,
|
|
583
|
+
}: {
|
|
584
|
+
id?: string;
|
|
585
|
+
className?: string;
|
|
586
|
+
code?: string;
|
|
587
|
+
title: string;
|
|
588
|
+
description: string;
|
|
589
|
+
actions: LinkAction[];
|
|
590
|
+
/** Decorative artwork above the code. */
|
|
591
|
+
illustration?: ReactNode;
|
|
592
|
+
/** Decorative full-cover image behind the message. The section switches to the inverse tone with a scrim so text keeps its contrast. */
|
|
593
|
+
background?: ReactNode;
|
|
594
|
+
headingLevel?: HeadingLevel;
|
|
595
|
+
}) {
|
|
596
|
+
const Heading = headingTag(headingLevel);
|
|
597
|
+
return (
|
|
598
|
+
<section
|
|
599
|
+
id={id}
|
|
600
|
+
className={cx("m-not-found", className)}
|
|
601
|
+
{...componentRoot("NotFound")}
|
|
602
|
+
data-background={background ? true : undefined}
|
|
603
|
+
data-tone={background ? "inverse" : undefined}
|
|
604
|
+
>
|
|
605
|
+
{background && (
|
|
606
|
+
<div className="m-cover-background" aria-hidden="true">
|
|
607
|
+
{background}
|
|
608
|
+
</div>
|
|
609
|
+
)}
|
|
610
|
+
{illustration && (
|
|
611
|
+
<div className="m-not-found-art" aria-hidden="true">
|
|
612
|
+
{illustration}
|
|
613
|
+
</div>
|
|
614
|
+
)}
|
|
615
|
+
<p className="m-eyebrow">{code}</p>
|
|
616
|
+
<Heading className="m-section-title">{title}</Heading>
|
|
617
|
+
<p>{description}</p>
|
|
618
|
+
<ActionLinks actions={actions} />
|
|
619
|
+
</section>
|
|
620
|
+
);
|
|
621
|
+
}
|