@tonycodes-ui/mortar 0.11.0 → 0.13.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/src/marketing.tsx CHANGED
@@ -3,17 +3,9 @@ import {
3
3
  KitLink,
4
4
  type IconLink,
5
5
  type ItemGroup,
6
- type LinkItem,
7
6
  } from "./navigation.js";
8
7
  import { useId, useState, type CSSProperties, type ReactNode } from "react";
9
- import {
10
- CloseButton,
11
- DialogPanel,
12
- DialogTitle,
13
- Popover,
14
- PopoverButton,
15
- PopoverPanel,
16
- } from "@headlessui/react";
8
+ import { DialogPanel, DialogTitle } from "@headlessui/react";
17
9
  import { ActionLink, Button, type LinkAction } from "./primitives.js";
18
10
  import { BrandMark, Prose, type SectionTone } from "./layout.js";
19
11
  import { SegmentedControl } from "./inspection.js";
@@ -23,6 +15,8 @@ import { childLevel, headingTag, type HeadingLevel } from "./heading.js";
23
15
  import {
24
16
  ActionLinks,
25
17
  DialogFrame,
18
+ Flyout,
19
+ type FlyoutItem,
26
20
  TermList,
27
21
  TitledIntro,
28
22
  cx,
@@ -32,10 +26,7 @@ import {
32
26
  useDomId,
33
27
  } from "./internal.js";
34
28
 
35
- export type FlyoutItem = Pick<
36
- LinkItem,
37
- "label" | "href" | "description" | "icon"
38
- >;
29
+ export type { FlyoutItem } from "./internal.js";
39
30
  /** A header link, or a labelled group that opens a flyout panel. */
40
31
  export type HeaderLink =
41
32
  | LinkAction
@@ -45,69 +36,9 @@ export type HeaderLink =
45
36
  layout?: "list" | "columns" | "full";
46
37
  });
47
38
 
48
- function Flyout({
49
- label,
50
- items,
51
- footer = [],
52
- layout = "list",
53
- onNavigate,
54
- }: {
55
- label: string;
56
- items: FlyoutItem[];
57
- footer?: LinkAction[];
58
- layout?: "list" | "columns" | "full";
59
- onNavigate: () => void;
60
- }) {
61
- const dataLayout = layout === "list" ? undefined : layout;
62
- return (
63
- <Popover className="m-flyout" data-layout={dataLayout}>
64
- <PopoverButton className="m-flyout-trigger">
65
- {label}
66
- <ChevronDown />
67
- </PopoverButton>
68
- {/* Anchored panels render in a portal, so headers inside clipped containers never cut them off. */}
69
- <PopoverPanel
70
- anchor="bottom"
71
- className="m-flyout-panel"
72
- data-layout={dataLayout}
73
- >
74
- <ul>
75
- {items.map((item, index) => (
76
- <li key={`${item.label}:${item.href}:${index}`}>
77
- <CloseButton as={KitLink} href={item.href} onClick={onNavigate}>
78
- {item.icon && (
79
- <span className="m-flyout-icon" aria-hidden="true">
80
- {item.icon}
81
- </span>
82
- )}
83
- <span className="m-flyout-text">
84
- <strong>{item.label}</strong>
85
- {item.description && <span>{item.description}</span>}
86
- </span>
87
- </CloseButton>
88
- </li>
89
- ))}
90
- </ul>
91
- {footer.length > 0 && (
92
- <div className="m-flyout-footer">
93
- {footer.map((link, index) => (
94
- <CloseButton
95
- as={KitLink}
96
- key={`${link.label}:${link.href}:${index}`}
97
- href={link.href}
98
- onClick={onNavigate}
99
- >
100
- {link.label}
101
- </CloseButton>
102
- ))}
103
- </div>
104
- )}
105
- </PopoverPanel>
106
- </Popover>
107
- );
108
- }
109
39
  export function MarketingHeader({
110
40
  brand,
41
+ tagline,
111
42
  homeHref = "/",
112
43
  logo,
113
44
  links,
@@ -119,6 +50,8 @@ export function MarketingHeader({
119
50
  id,
120
51
  }: {
121
52
  brand: string;
53
+ /** A short descriptor beside the wordmark, outside the home link. Hidden on narrow screens. */
54
+ tagline?: string;
122
55
  logo?: ReactNode;
123
56
  homeHref?: string;
124
57
  /** Links, or groups of items that open a flyout panel. */
@@ -137,12 +70,21 @@ export function MarketingHeader({
137
70
  const navId = useDomId("m-marketing-nav");
138
71
  const panel = mobileMenu === "panel";
139
72
  const close = () => setOpen(false);
140
- const brandLink = (
73
+ const homeLink = (
141
74
  <KitLink className="m-brand" href={homeHref}>
142
75
  <BrandMark logo={logo} />
143
76
  {brand}
144
77
  </KitLink>
145
78
  );
79
+ // The tagline sits outside the link so the link's name stays the brand.
80
+ const brandLink = tagline ? (
81
+ <div className="m-brand-lockup">
82
+ {homeLink}
83
+ <span className="m-brand-tagline">{tagline}</span>
84
+ </div>
85
+ ) : (
86
+ homeLink
87
+ );
146
88
  const menuButton = (
147
89
  <Button
148
90
  variant="ghost"
@@ -272,15 +214,53 @@ function PanelLinks({
272
214
  </nav>
273
215
  );
274
216
  }
217
+ /**
218
+ * A public page: the header, a main region that grows to fill the viewport, and
219
+ * the footer. Content is never clipped; a longer page simply scrolls.
220
+ */
221
+ export function MarketingPage({
222
+ header,
223
+ footer,
224
+ children,
225
+ align = "start",
226
+ id,
227
+ className,
228
+ }: {
229
+ /** Usually a MarketingHeader. */
230
+ header?: ReactNode;
231
+ /** Usually a Footer. */
232
+ footer?: ReactNode;
233
+ children: ReactNode;
234
+ /** start: content from the top. center: content centred in the space between header and footer, for a single hero. */
235
+ align?: "start" | "center";
236
+ id?: string;
237
+ className?: string;
238
+ }) {
239
+ return (
240
+ <div
241
+ id={id}
242
+ className={cx("m-marketing-page", className)}
243
+ {...componentRoot("MarketingPage")}
244
+ data-align={align === "center" ? "center" : undefined}
245
+ >
246
+ {header}
247
+ <main className="m-marketing-main">{children}</main>
248
+ {footer}
249
+ </div>
250
+ );
251
+ }
275
252
  export function Hero({
276
253
  id,
277
254
  className,
278
255
  eyebrow,
279
256
  title,
257
+ emphasis,
280
258
  description,
281
259
  actions = [],
260
+ note,
282
261
  children,
283
262
  layout = "center",
263
+ wide,
284
264
  headingLevel = 1,
285
265
  background,
286
266
  images = [],
@@ -290,11 +270,17 @@ export function Hero({
290
270
  className?: string;
291
271
  eyebrow?: string;
292
272
  title: string;
273
+ /** A phrase in the title to emphasise. Its first occurrence renders as `<em>` inside the one heading; a phrase not in the title is ignored. */
274
+ emphasis?: string;
293
275
  description: string;
294
276
  actions?: LinkAction[];
277
+ /** Short supporting content below the actions, such as a note or a Notice. */
278
+ note?: ReactNode;
295
279
  children?: ReactNode;
296
280
  /** center: copy above the preview. split: copy beside the preview. angled: copy beside an image that fills the end half to the edge, cut on a slant (pass one img or video as children). */
297
281
  layout?: "center" | "split" | "angled";
282
+ /** Split only: the column that takes more of the width. Omitted, the columns are even. */
283
+ wide?: "copy" | "preview";
298
284
  headingLevel?: HeadingLevel;
299
285
  /** Decorative full-cover image or video behind the copy. The hero switches to the inverse tone with a scrim so text keeps its contrast. */
300
286
  background?: ReactNode;
@@ -308,6 +294,7 @@ export function Hero({
308
294
  id={id}
309
295
  className={cx("m-hero", `m-hero-${layout}`, className)}
310
296
  {...componentRoot("Hero")}
297
+ data-wide={layout === "split" ? wide : undefined}
311
298
  data-background={background ? true : undefined}
312
299
  data-tone={background ? "inverse" : sectionTone(tone)}
313
300
  >
@@ -320,10 +307,12 @@ export function Hero({
320
307
  <TitledIntro
321
308
  eyebrow={eyebrow}
322
309
  title={title}
310
+ emphasis={emphasis}
323
311
  headingLevel={headingLevel}
324
312
  />
325
313
  <p className="m-hero-description">{description}</p>
326
314
  <ActionLinks actions={actions} />
315
+ {note && <div className="m-hero-note">{note}</div>}
327
316
  </div>
328
317
  {images.length > 0 && (
329
318
  <div className="m-hero-tiles">
@@ -928,7 +917,7 @@ export function Footer({
928
917
  logo,
929
918
  description,
930
919
  note,
931
- columns,
920
+ columns = [],
932
921
  copyright,
933
922
  legal = [],
934
923
  itemHeadingLevel = 2,
@@ -940,30 +929,31 @@ export function Footer({
940
929
  className?: string;
941
930
  brand: string;
942
931
  logo?: ReactNode;
943
- description: string;
932
+ description?: string;
944
933
  note?: ReactNode;
945
- /** Link columns; each label is the column's visible heading and its navigation name. Centred, the label only names the navigation. */
946
- columns: ItemGroup<LinkAction>[];
934
+ /** Link columns; each label is the column's visible heading and its navigation name. Centred or inline, the label only names the navigation. */
935
+ columns?: ItemGroup<LinkAction>[];
947
936
  copyright: string;
948
937
  legal?: LinkAction[];
949
938
  /** Level of the column headings. */
950
939
  itemHeadingLevel?: HeadingLevel;
951
940
  /** Social profiles, usually icon-only; each label names its link. */
952
941
  social?: IconLink[];
953
- /** columns: the brand beside headed link columns. center: brand, a row of links and social links, all centred. */
954
- layout?: "columns" | "center";
942
+ /** columns: the brand beside headed link columns. center: brand, a row of links and social links, all centred. inline: one compact row of brand, notes, links and copyright that wraps in reading order. */
943
+ layout?: "columns" | "center" | "inline";
955
944
  /** A signup row above the copyright, usually a Newsletter; the footer removes its section padding. */
956
945
  newsletter?: ReactNode;
957
946
  }) {
958
947
  const Heading = headingTag(itemHeadingLevel);
959
948
  const center = layout === "center";
949
+ const inline = layout === "inline";
960
950
  const identity = (
961
951
  <>
962
952
  <strong className="m-brand">
963
953
  <BrandMark logo={logo} />
964
954
  {brand}
965
955
  </strong>
966
- <p>{description}</p>
956
+ {description && <p>{description}</p>}
967
957
  {note && <p>{note}</p>}
968
958
  </>
969
959
  );
@@ -972,8 +962,8 @@ export function Footer({
972
962
  );
973
963
  const navs = columns.map((column) => (
974
964
  <nav key={column.label} aria-label={column.label}>
975
- {/* Centred, the links sit in one row and the label only names the navigation. */}
976
- {!center && <Heading>{column.label}</Heading>}
965
+ {/* Centred or inline, the links sit in one row and the label only names the navigation. */}
966
+ {!center && !inline && <Heading>{column.label}</Heading>}
977
967
  {column.items.map((link, index) => (
978
968
  <KitLink href={link.href} key={`${link.label}:${link.href}:${index}`}>
979
969
  {link.label}
@@ -981,6 +971,34 @@ export function Footer({
981
971
  ))}
982
972
  </nav>
983
973
  ));
974
+ const legalLinks = (
975
+ <div>
976
+ {legal.map((link, index) => (
977
+ <KitLink key={`${link.label}:${link.href}:${index}`} href={link.href}>
978
+ {link.label}
979
+ </KitLink>
980
+ ))}
981
+ </div>
982
+ );
983
+ if (inline)
984
+ return (
985
+ <footer
986
+ id={id}
987
+ className={cx("m-footer", className)}
988
+ {...componentRoot("Footer")}
989
+ data-layout="inline"
990
+ >
991
+ {/* One wrapping row; the DOM order is the reading order at every width. */}
992
+ <div className="m-footer-inline">
993
+ {identity}
994
+ {navs}
995
+ {socialLinks}
996
+ <span>{copyright}</span>
997
+ {legal.length > 0 && legalLinks}
998
+ </div>
999
+ {newsletter && <div className="m-footer-newsletter">{newsletter}</div>}
1000
+ </footer>
1001
+ );
984
1002
  return (
985
1003
  <footer
986
1004
  id={id}
@@ -1006,16 +1024,7 @@ export function Footer({
1006
1024
  {newsletter && <div className="m-footer-newsletter">{newsletter}</div>}
1007
1025
  <div className="m-footer-bottom">
1008
1026
  <span>{copyright}</span>
1009
- <div>
1010
- {legal.map((link, index) => (
1011
- <KitLink
1012
- key={`${link.label}:${link.href}:${index}`}
1013
- href={link.href}
1014
- >
1015
- {link.label}
1016
- </KitLink>
1017
- ))}
1018
- </div>
1027
+ {legalLinks}
1019
1028
  </div>
1020
1029
  </footer>
1021
1030
  );
@@ -444,6 +444,31 @@ function DescribedChoice({
444
444
  </div>
445
445
  );
446
446
  }
447
+ /**
448
+ * Keeps a choice in the described layout once it has had a description or
449
+ * error. Switching back to the plain label would remount the input and drop
450
+ * focus mid-interaction (for example a description that changes with the
451
+ * value). If the layout changes while the input has focus, focus follows it.
452
+ */
453
+ function useDescribedLayout(
454
+ active: boolean,
455
+ input: { current: HTMLInputElement | null },
456
+ ) {
457
+ const [described, setDescribed] = useState(false);
458
+ const refocus = useRef(false);
459
+ if (active && !described) {
460
+ refocus.current =
461
+ typeof document !== "undefined" &&
462
+ !!input.current &&
463
+ input.current === document.activeElement;
464
+ setDescribed(true);
465
+ }
466
+ useLayoutEffect(() => {
467
+ if (refocus.current) input.current?.focus();
468
+ refocus.current = false;
469
+ }, [described, input]);
470
+ return described;
471
+ }
447
472
  export function Checkbox({
448
473
  label,
449
474
  description,
@@ -458,23 +483,8 @@ export function Checkbox({
458
483
  error?: string;
459
484
  }) {
460
485
  const input = useRef<HTMLInputElement>(null);
461
- // Once an error has shown, keep the described layout: switching back to the
462
- // plain label would remount the input and drop focus while the person fixes
463
- // it. If the layout changes while the input has focus, focus follows it.
464
- const [described, setDescribed] = useState(false);
465
- const refocus = useRef(false);
466
- if (error && !described) {
467
- refocus.current =
468
- typeof document !== "undefined" &&
469
- !!input.current &&
470
- input.current === document.activeElement;
471
- setDescribed(true);
472
- }
473
- useLayoutEffect(() => {
474
- if (refocus.current) input.current?.focus();
475
- refocus.current = false;
476
- }, [described]);
477
- if (description || error || described)
486
+ const described = useDescribedLayout(!!(description || error), input);
487
+ if (described)
478
488
  return (
479
489
  <DescribedChoice
480
490
  component="Checkbox"
@@ -514,7 +524,9 @@ export function Switch({
514
524
  /** Secondary line under the label, announced as the description. */
515
525
  description?: ReactNode;
516
526
  }) {
517
- if (description)
527
+ const input = useRef<HTMLInputElement>(null);
528
+ const described = useDescribedLayout(!!description, input);
529
+ if (described)
518
530
  return (
519
531
  <DescribedChoice
520
532
  component="Switch"
@@ -526,6 +538,7 @@ export function Switch({
526
538
  <input
527
539
  {...props}
528
540
  {...attrs}
541
+ ref={input}
529
542
  type="checkbox"
530
543
  role="switch"
531
544
  className="m-switch"
@@ -535,7 +548,13 @@ export function Switch({
535
548
  );
536
549
  return (
537
550
  <label {...componentRoot("Switch")} className={cx("m-choice", className)}>
538
- <input {...props} type="checkbox" role="switch" className="m-switch" />
551
+ <input
552
+ {...props}
553
+ ref={input}
554
+ type="checkbox"
555
+ role="switch"
556
+ className="m-switch"
557
+ />
539
558
  {label}
540
559
  </label>
541
560
  );