@tonycodes-ui/mortar 0.11.0 → 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/src/internal.tsx CHANGED
@@ -5,8 +5,11 @@ import {
5
5
  createContext,
6
6
  createElement,
7
7
  useContext,
8
+ useEffect,
8
9
  useId,
10
+ useState,
9
11
  type HTMLAttributes,
12
+ type RefObject,
10
13
  type ReactNode,
11
14
  cloneElement,
12
15
  isValidElement,
@@ -71,6 +74,8 @@ export interface IntroProps {
71
74
  eyebrow?: string;
72
75
  /** Omit to render no heading. */
73
76
  title?: string;
77
+ /** A phrase in `title` to emphasise; its first occurrence renders as `<em>`. */
78
+ emphasis?: string;
74
79
  description?: string;
75
80
  /** Id for the heading, e.g. the target of the section's aria-labelledby. */
76
81
  id?: string;
@@ -87,6 +92,7 @@ export interface IntroProps {
87
92
  export function introSlots({
88
93
  eyebrow,
89
94
  title,
95
+ emphasis,
90
96
  description,
91
97
  id,
92
98
  headingLevel = 2,
@@ -95,12 +101,26 @@ export function introSlots({
95
101
  const Heading = headingTag(headingLevel);
96
102
  return [
97
103
  eyebrow && <p className="m-eyebrow">{eyebrow}</p>,
98
- title !== undefined && <Heading id={id}>{title}</Heading>,
104
+ title !== undefined && (
105
+ <Heading id={id}>{emphasise(title, emphasis)}</Heading>
106
+ ),
99
107
  description && (
100
108
  <p className={descriptionClassName ?? undefined}>{description}</p>
101
109
  ),
102
110
  ];
103
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
+ }
104
124
  /** The section intro as one element, for parents where nothing user-supplied follows it. */
105
125
  export function SectionIntro(props: IntroProps) {
106
126
  return <>{...introSlots(props)}</>;
@@ -210,6 +230,72 @@ export function useDomId(prefix: string) {
210
230
  return prefix + useId().replace(/[^a-zA-Z0-9-]/g, "");
211
231
  }
212
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
+
213
299
  /**
214
300
  * Next index for arrow, Home and End keys in a horizontal set (wrapping), or
215
301
  * null for any other key.
package/src/marketing.css CHANGED
@@ -24,6 +24,21 @@
24
24
  font-weight: var(--m-weight-bold);
25
25
  letter-spacing: var(--m-tracking-heading);
26
26
  }
27
+ /* A short descriptor beside the wordmark, outside the home link. */
28
+ .m-brand-lockup {
29
+ display: flex;
30
+ align-items: center;
31
+ gap: 12px;
32
+ min-width: 0;
33
+ }
34
+ .m-brand-tagline {
35
+ max-width: 28ch;
36
+ padding-left: 12px;
37
+ border-left: 1px solid var(--m-line);
38
+ color: var(--m-muted);
39
+ font-size: 13px;
40
+ line-height: 1.3;
41
+ }
27
42
  .m-marketing-header nav {
28
43
  display: flex;
29
44
  gap: 22px;
@@ -192,7 +207,8 @@
192
207
  grid-column: 1;
193
208
  grid-row: 1;
194
209
  }
195
- .m-marketing-header[data-layout="center"] > .m-brand {
210
+ .m-marketing-header[data-layout="center"] > .m-brand,
211
+ .m-marketing-header[data-layout="center"] > .m-brand-lockup {
196
212
  grid-column: 2;
197
213
  grid-row: 1;
198
214
  }
@@ -253,7 +269,8 @@
253
269
  .m-marketing-header[data-layout="center"] {
254
270
  grid-template-columns: auto minmax(0, 1fr) auto;
255
271
  }
256
- .m-marketing-header[data-layout="center"] > .m-brand {
272
+ .m-marketing-header[data-layout="center"] > .m-brand,
273
+ .m-marketing-header[data-layout="center"] > .m-brand-lockup {
257
274
  justify-self: center;
258
275
  }
259
276
  .m-marketing-header[data-layout="center"] > .m-actions .m-button {
@@ -274,6 +291,32 @@
274
291
  .m-flyout-panel[data-layout="columns"] ul {
275
292
  grid-template-columns: minmax(0, 1fr);
276
293
  }
294
+ .m-brand-tagline {
295
+ display: none;
296
+ }
297
+ }
298
+
299
+ /* == MarketingPage == */
300
+ /* Header, main and footer fill the viewport; main grows, nothing is clipped. */
301
+ .m-marketing-page {
302
+ display: flex;
303
+ flex-direction: column;
304
+ min-height: 100vh;
305
+ min-height: 100dvh;
306
+ }
307
+ /* Header, sections and footer centre themselves with auto margins, which in a
308
+ flex column would shrink them to their content; keep them full width. */
309
+ .m-marketing-page > *,
310
+ .m-marketing-page[data-align="center"] > .m-marketing-main > * {
311
+ width: 100%;
312
+ }
313
+ .m-marketing-main {
314
+ flex: 1 0 auto;
315
+ }
316
+ .m-marketing-page[data-align="center"] > .m-marketing-main {
317
+ display: flex;
318
+ flex-direction: column;
319
+ justify-content: center;
277
320
  }
278
321
 
279
322
  /* == Banner == */
@@ -591,6 +634,30 @@
591
634
  .m-hero-split .m-section-title {
592
635
  font-size: clamp(40px, 5vw, 64px);
593
636
  }
637
+ /* Beside a preview the copy is start-aligned: no auto margins, which would
638
+ centre the description inside a copy column wider than its max width. */
639
+ .m-hero-split .m-hero-description {
640
+ margin-inline: 0;
641
+ }
642
+ .m-hero-split[data-wide="copy"] {
643
+ grid-template-columns: minmax(0, 3fr) minmax(0, 2fr);
644
+ }
645
+ .m-hero-split[data-wide="preview"] {
646
+ grid-template-columns: minmax(0, 2fr) minmax(0, 3fr);
647
+ }
648
+ /* An emphasised phrase in the title: the brand's text-safe link colour. */
649
+ .m-hero .m-emphasis {
650
+ font-style: normal;
651
+ color: var(--m-link);
652
+ }
653
+ .m-hero:is([data-background], [data-tone="accent"]) .m-emphasis {
654
+ color: inherit;
655
+ }
656
+ .m-hero-note {
657
+ margin-top: 20px;
658
+ color: var(--m-muted);
659
+ font-size: 14px;
660
+ }
594
661
  .m-hero-preview {
595
662
  margin-top: 48px;
596
663
  text-align: left;
@@ -694,6 +761,7 @@
694
761
  }
695
762
  @media (max-width: 900px) {
696
763
  .m-hero-split,
764
+ .m-hero-split[data-wide],
697
765
  .m-feature-section,
698
766
  .m-faq {
699
767
  grid-template-columns: minmax(0, 1fr);
@@ -1893,6 +1961,41 @@
1893
1961
  text-align: center;
1894
1962
  margin-top: 40px;
1895
1963
  }
1964
+ /* Footer: one compact row that wraps in reading order. */
1965
+ .m-footer[data-layout="inline"] {
1966
+ padding-block: 24px;
1967
+ border-top: 1px solid var(--m-line);
1968
+ }
1969
+ .m-footer-inline {
1970
+ display: flex;
1971
+ flex-wrap: wrap;
1972
+ align-items: center;
1973
+ gap: 8px 24px;
1974
+ color: var(--m-muted);
1975
+ font-size: 13px;
1976
+ }
1977
+ .m-footer-inline .m-brand {
1978
+ font-size: 14px;
1979
+ color: var(--m-ink);
1980
+ }
1981
+ .m-footer-inline p {
1982
+ margin: 0;
1983
+ }
1984
+ .m-footer-inline > :is(nav, div) {
1985
+ display: flex;
1986
+ flex-wrap: wrap;
1987
+ gap: 8px 20px;
1988
+ }
1989
+ .m-footer-inline a {
1990
+ color: var(--m-muted);
1991
+ text-decoration: none;
1992
+ }
1993
+ .m-footer-inline a:hover {
1994
+ color: var(--m-ink);
1995
+ }
1996
+ .m-footer-inline > .m-icon-links {
1997
+ margin: 0;
1998
+ }
1896
1999
  @media (max-width: 900px) {
1897
2000
  .m-footer-grid {
1898
2001
  grid-template-columns: repeat(2, minmax(0, 1fr));
@@ -2385,6 +2488,82 @@
2385
2488
  background: var(--m-primary);
2386
2489
  color: var(--m-on-primary);
2387
2490
  }
2491
+
2492
+ /* == NetworkIllustration == */
2493
+ /* Nodes and connections from data; signals travel along connections. Every
2494
+ colour is a token, so the drawing follows the brand and appearance. */
2495
+ .m-network {
2496
+ display: flex;
2497
+ flex-direction: column;
2498
+ gap: 8px;
2499
+ }
2500
+ .m-network svg {
2501
+ display: block;
2502
+ width: 100%;
2503
+ height: auto;
2504
+ overflow: visible;
2505
+ }
2506
+ .m-network-link {
2507
+ stroke: var(--m-line-strong);
2508
+ stroke-width: 1.5;
2509
+ /* Lines keep their width however large the drawing is shown. */
2510
+ vector-effect: non-scaling-stroke;
2511
+ }
2512
+ /* The outline is the strong line (3:1) whatever the fill, so nodes stay visible. */
2513
+ .m-network-node {
2514
+ fill: var(--m-surface);
2515
+ stroke: var(--m-line-strong);
2516
+ stroke-width: 1.5;
2517
+ vector-effect: non-scaling-stroke;
2518
+ }
2519
+ .m-network-node[data-tone="accent"] {
2520
+ fill: var(--m-accent);
2521
+ }
2522
+ .m-network-label {
2523
+ fill: var(--m-secondary);
2524
+ font-size: 12px;
2525
+ }
2526
+ .m-network-signal {
2527
+ fill: var(--m-accent);
2528
+ translate: var(--m-signal-from);
2529
+ animation: m-network-signal 3s linear infinite;
2530
+ }
2531
+ .m-network[data-paused] .m-network-signal {
2532
+ animation-play-state: paused;
2533
+ }
2534
+ /* Below the drawing, never over it, so no node is covered at any size. */
2535
+ .m-network-toggle {
2536
+ align-self: flex-end;
2537
+ padding: 4px 10px;
2538
+ border: 1px solid var(--m-line);
2539
+ border-radius: var(--m-radius-sm);
2540
+ background: var(--m-surface);
2541
+ color: var(--m-secondary);
2542
+ font: inherit;
2543
+ font-size: 12px;
2544
+ cursor: pointer;
2545
+ }
2546
+ .m-network-toggle:hover {
2547
+ color: var(--m-ink);
2548
+ }
2549
+ /* Before scripts run, and whenever reduced motion is requested, show the
2550
+ static drawing; the script then stops rendering the signals. */
2551
+ @media (prefers-reduced-motion: reduce) {
2552
+ .m-network-signal {
2553
+ display: none;
2554
+ }
2555
+ .m-network-toggle {
2556
+ display: none;
2557
+ }
2558
+ }
2559
+ }
2560
+ @keyframes m-network-signal {
2561
+ from {
2562
+ translate: var(--m-signal-from);
2563
+ }
2564
+ to {
2565
+ translate: var(--m-signal-to);
2566
+ }
2388
2567
  }
2389
2568
  @keyframes m-chat-dot {
2390
2569
  0%,
package/src/marketing.tsx CHANGED
@@ -108,6 +108,7 @@ function Flyout({
108
108
  }
109
109
  export function MarketingHeader({
110
110
  brand,
111
+ tagline,
111
112
  homeHref = "/",
112
113
  logo,
113
114
  links,
@@ -119,6 +120,8 @@ export function MarketingHeader({
119
120
  id,
120
121
  }: {
121
122
  brand: string;
123
+ /** A short descriptor beside the wordmark, outside the home link. Hidden on narrow screens. */
124
+ tagline?: string;
122
125
  logo?: ReactNode;
123
126
  homeHref?: string;
124
127
  /** Links, or groups of items that open a flyout panel. */
@@ -137,12 +140,21 @@ export function MarketingHeader({
137
140
  const navId = useDomId("m-marketing-nav");
138
141
  const panel = mobileMenu === "panel";
139
142
  const close = () => setOpen(false);
140
- const brandLink = (
143
+ const homeLink = (
141
144
  <KitLink className="m-brand" href={homeHref}>
142
145
  <BrandMark logo={logo} />
143
146
  {brand}
144
147
  </KitLink>
145
148
  );
149
+ // The tagline sits outside the link so the link's name stays the brand.
150
+ const brandLink = tagline ? (
151
+ <div className="m-brand-lockup">
152
+ {homeLink}
153
+ <span className="m-brand-tagline">{tagline}</span>
154
+ </div>
155
+ ) : (
156
+ homeLink
157
+ );
146
158
  const menuButton = (
147
159
  <Button
148
160
  variant="ghost"
@@ -272,15 +284,53 @@ function PanelLinks({
272
284
  </nav>
273
285
  );
274
286
  }
287
+ /**
288
+ * A public page: the header, a main region that grows to fill the viewport, and
289
+ * the footer. Content is never clipped; a longer page simply scrolls.
290
+ */
291
+ export function MarketingPage({
292
+ header,
293
+ footer,
294
+ children,
295
+ align = "start",
296
+ id,
297
+ className,
298
+ }: {
299
+ /** Usually a MarketingHeader. */
300
+ header?: ReactNode;
301
+ /** Usually a Footer. */
302
+ footer?: ReactNode;
303
+ children: ReactNode;
304
+ /** start: content from the top. center: content centred in the space between header and footer, for a single hero. */
305
+ align?: "start" | "center";
306
+ id?: string;
307
+ className?: string;
308
+ }) {
309
+ return (
310
+ <div
311
+ id={id}
312
+ className={cx("m-marketing-page", className)}
313
+ {...componentRoot("MarketingPage")}
314
+ data-align={align === "center" ? "center" : undefined}
315
+ >
316
+ {header}
317
+ <main className="m-marketing-main">{children}</main>
318
+ {footer}
319
+ </div>
320
+ );
321
+ }
275
322
  export function Hero({
276
323
  id,
277
324
  className,
278
325
  eyebrow,
279
326
  title,
327
+ emphasis,
280
328
  description,
281
329
  actions = [],
330
+ note,
282
331
  children,
283
332
  layout = "center",
333
+ wide,
284
334
  headingLevel = 1,
285
335
  background,
286
336
  images = [],
@@ -290,11 +340,17 @@ export function Hero({
290
340
  className?: string;
291
341
  eyebrow?: string;
292
342
  title: string;
343
+ /** 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. */
344
+ emphasis?: string;
293
345
  description: string;
294
346
  actions?: LinkAction[];
347
+ /** Short supporting content below the actions, such as a note or a Notice. */
348
+ note?: ReactNode;
295
349
  children?: ReactNode;
296
350
  /** 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
351
  layout?: "center" | "split" | "angled";
352
+ /** Split only: the column that takes more of the width. Omitted, the columns are even. */
353
+ wide?: "copy" | "preview";
298
354
  headingLevel?: HeadingLevel;
299
355
  /** 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
356
  background?: ReactNode;
@@ -308,6 +364,7 @@ export function Hero({
308
364
  id={id}
309
365
  className={cx("m-hero", `m-hero-${layout}`, className)}
310
366
  {...componentRoot("Hero")}
367
+ data-wide={layout === "split" ? wide : undefined}
311
368
  data-background={background ? true : undefined}
312
369
  data-tone={background ? "inverse" : sectionTone(tone)}
313
370
  >
@@ -320,10 +377,12 @@ export function Hero({
320
377
  <TitledIntro
321
378
  eyebrow={eyebrow}
322
379
  title={title}
380
+ emphasis={emphasis}
323
381
  headingLevel={headingLevel}
324
382
  />
325
383
  <p className="m-hero-description">{description}</p>
326
384
  <ActionLinks actions={actions} />
385
+ {note && <div className="m-hero-note">{note}</div>}
327
386
  </div>
328
387
  {images.length > 0 && (
329
388
  <div className="m-hero-tiles">
@@ -928,7 +987,7 @@ export function Footer({
928
987
  logo,
929
988
  description,
930
989
  note,
931
- columns,
990
+ columns = [],
932
991
  copyright,
933
992
  legal = [],
934
993
  itemHeadingLevel = 2,
@@ -940,30 +999,31 @@ export function Footer({
940
999
  className?: string;
941
1000
  brand: string;
942
1001
  logo?: ReactNode;
943
- description: string;
1002
+ description?: string;
944
1003
  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>[];
1004
+ /** Link columns; each label is the column's visible heading and its navigation name. Centred or inline, the label only names the navigation. */
1005
+ columns?: ItemGroup<LinkAction>[];
947
1006
  copyright: string;
948
1007
  legal?: LinkAction[];
949
1008
  /** Level of the column headings. */
950
1009
  itemHeadingLevel?: HeadingLevel;
951
1010
  /** Social profiles, usually icon-only; each label names its link. */
952
1011
  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";
1012
+ /** 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. */
1013
+ layout?: "columns" | "center" | "inline";
955
1014
  /** A signup row above the copyright, usually a Newsletter; the footer removes its section padding. */
956
1015
  newsletter?: ReactNode;
957
1016
  }) {
958
1017
  const Heading = headingTag(itemHeadingLevel);
959
1018
  const center = layout === "center";
1019
+ const inline = layout === "inline";
960
1020
  const identity = (
961
1021
  <>
962
1022
  <strong className="m-brand">
963
1023
  <BrandMark logo={logo} />
964
1024
  {brand}
965
1025
  </strong>
966
- <p>{description}</p>
1026
+ {description && <p>{description}</p>}
967
1027
  {note && <p>{note}</p>}
968
1028
  </>
969
1029
  );
@@ -972,8 +1032,8 @@ export function Footer({
972
1032
  );
973
1033
  const navs = columns.map((column) => (
974
1034
  <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>}
1035
+ {/* Centred or inline, the links sit in one row and the label only names the navigation. */}
1036
+ {!center && !inline && <Heading>{column.label}</Heading>}
977
1037
  {column.items.map((link, index) => (
978
1038
  <KitLink href={link.href} key={`${link.label}:${link.href}:${index}`}>
979
1039
  {link.label}
@@ -981,6 +1041,34 @@ export function Footer({
981
1041
  ))}
982
1042
  </nav>
983
1043
  ));
1044
+ const legalLinks = (
1045
+ <div>
1046
+ {legal.map((link, index) => (
1047
+ <KitLink key={`${link.label}:${link.href}:${index}`} href={link.href}>
1048
+ {link.label}
1049
+ </KitLink>
1050
+ ))}
1051
+ </div>
1052
+ );
1053
+ if (inline)
1054
+ return (
1055
+ <footer
1056
+ id={id}
1057
+ className={cx("m-footer", className)}
1058
+ {...componentRoot("Footer")}
1059
+ data-layout="inline"
1060
+ >
1061
+ {/* One wrapping row; the DOM order is the reading order at every width. */}
1062
+ <div className="m-footer-inline">
1063
+ {identity}
1064
+ {navs}
1065
+ {socialLinks}
1066
+ <span>{copyright}</span>
1067
+ {legal.length > 0 && legalLinks}
1068
+ </div>
1069
+ {newsletter && <div className="m-footer-newsletter">{newsletter}</div>}
1070
+ </footer>
1071
+ );
984
1072
  return (
985
1073
  <footer
986
1074
  id={id}
@@ -1006,16 +1094,7 @@ export function Footer({
1006
1094
  {newsletter && <div className="m-footer-newsletter">{newsletter}</div>}
1007
1095
  <div className="m-footer-bottom">
1008
1096
  <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>
1097
+ {legalLinks}
1019
1098
  </div>
1020
1099
  </footer>
1021
1100
  );