@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/internal.tsx CHANGED
@@ -5,14 +5,26 @@ 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,
13
16
  } from "react";
14
- import { Dialog, DialogPanel, DialogTitle } from "@headlessui/react";
15
- import { KitLink, type ItemGroup } from "./navigation.js";
17
+ import {
18
+ CloseButton,
19
+ Dialog,
20
+ DialogPanel,
21
+ DialogTitle,
22
+ Popover,
23
+ PopoverButton,
24
+ PopoverPanel,
25
+ } from "@headlessui/react";
26
+ import { KitLink, type ItemGroup, type LinkItem } from "./navigation.js";
27
+ import { ChevronDown } from "./icons.js";
16
28
  import { ActionLink, Button, type LinkAction } from "./primitives.js";
17
29
  import { headingTag, type HeadingLevel } from "./heading.js";
18
30
 
@@ -71,6 +83,8 @@ export interface IntroProps {
71
83
  eyebrow?: string;
72
84
  /** Omit to render no heading. */
73
85
  title?: string;
86
+ /** A phrase in `title` to emphasise; its first occurrence renders as `<em>`. */
87
+ emphasis?: string;
74
88
  description?: string;
75
89
  /** Id for the heading, e.g. the target of the section's aria-labelledby. */
76
90
  id?: string;
@@ -87,6 +101,7 @@ export interface IntroProps {
87
101
  export function introSlots({
88
102
  eyebrow,
89
103
  title,
104
+ emphasis,
90
105
  description,
91
106
  id,
92
107
  headingLevel = 2,
@@ -95,12 +110,26 @@ export function introSlots({
95
110
  const Heading = headingTag(headingLevel);
96
111
  return [
97
112
  eyebrow && <p className="m-eyebrow">{eyebrow}</p>,
98
- title !== undefined && <Heading id={id}>{title}</Heading>,
113
+ title !== undefined && (
114
+ <Heading id={id}>{emphasise(title, emphasis)}</Heading>
115
+ ),
99
116
  description && (
100
117
  <p className={descriptionClassName ?? undefined}>{description}</p>
101
118
  ),
102
119
  ];
103
120
  }
121
+ /** `title` with the first occurrence of `phrase` in `<em>`; unchanged when absent. */
122
+ function emphasise(title: string, phrase?: string): ReactNode {
123
+ const at = phrase ? title.indexOf(phrase) : -1;
124
+ if (!phrase || at < 0) return title;
125
+ return (
126
+ <>
127
+ {title.slice(0, at)}
128
+ <em className="m-emphasis">{phrase}</em>
129
+ {title.slice(at + phrase.length)}
130
+ </>
131
+ );
132
+ }
104
133
  /** The section intro as one element, for parents where nothing user-supplied follows it. */
105
134
  export function SectionIntro(props: IntroProps) {
106
135
  return <>{...introSlots(props)}</>;
@@ -210,6 +239,72 @@ export function useDomId(prefix: string) {
210
239
  return prefix + useId().replace(/[^a-zA-Z0-9-]/g, "");
211
240
  }
212
241
 
242
+ /** Why an animation is not running; null while it plays. */
243
+ export type PauseReason = "user" | "motion" | "hidden" | "offscreen" | null;
244
+ /**
245
+ * Shared pause and resume for decorative motion. It plays only while the
246
+ * user has not paused it, reduced motion is not requested, the document is
247
+ * visible and `ref` is on screen. The user's pause is kept apart from the
248
+ * automatic reasons, so returning to the page never overrides it. `paused`
249
+ * and `onPausedChange` make the user's choice controlled, for example one
250
+ * state shared by several animations. Every listener is removed on unmount.
251
+ */
252
+ export function usePlayback(
253
+ ref: RefObject<Element | null>,
254
+ {
255
+ paused,
256
+ onPausedChange,
257
+ enabled = true,
258
+ }: {
259
+ paused?: boolean;
260
+ onPausedChange?: (paused: boolean) => void;
261
+ enabled?: boolean;
262
+ },
263
+ ) {
264
+ const [ownPaused, setOwnPaused] = useState(false);
265
+ const [reduced, setReduced] = useState(false);
266
+ const [hidden, setHidden] = useState(false);
267
+ const [offscreen, setOffscreen] = useState(false);
268
+ const userPaused = paused ?? ownPaused;
269
+ useEffect(() => {
270
+ if (!enabled) return;
271
+ const media = window.matchMedia("(prefers-reduced-motion: reduce)");
272
+ const onMedia = () => setReduced(media.matches);
273
+ const onVisibility = () => setHidden(document.visibilityState === "hidden");
274
+ onMedia();
275
+ onVisibility();
276
+ media.addEventListener("change", onMedia);
277
+ document.addEventListener("visibilitychange", onVisibility);
278
+ const observer =
279
+ ref.current && typeof IntersectionObserver !== "undefined"
280
+ ? new IntersectionObserver(([entry]) =>
281
+ setOffscreen(!entry.isIntersecting),
282
+ )
283
+ : null;
284
+ if (observer && ref.current) observer.observe(ref.current);
285
+ return () => {
286
+ media.removeEventListener("change", onMedia);
287
+ document.removeEventListener("visibilitychange", onVisibility);
288
+ observer?.disconnect();
289
+ };
290
+ }, [enabled, ref]);
291
+ const reason: PauseReason = userPaused
292
+ ? "user"
293
+ : reduced
294
+ ? "motion"
295
+ : hidden
296
+ ? "hidden"
297
+ : offscreen
298
+ ? "offscreen"
299
+ : null;
300
+ const toggle = () => {
301
+ const next = !userPaused;
302
+ if (paused === undefined) setOwnPaused(next);
303
+ onPausedChange?.(next);
304
+ };
305
+ return { reason, reduced, userPaused, toggle };
306
+ }
307
+
213
308
  /**
214
309
  * Next index for arrow, Home and End keys in a horizontal set (wrapping), or
215
310
  * null for any other key.
@@ -612,3 +707,85 @@ export function focusAfterRemoval(element: HTMLElement) {
612
707
  }
613
708
  });
614
709
  }
710
+
711
+ /** An item in a flyout panel; `current` marks the page you are on. */
712
+ export type FlyoutItem = Pick<
713
+ LinkItem,
714
+ "label" | "href" | "description" | "icon" | "current"
715
+ >;
716
+ /**
717
+ * A labelled group of links in a header that opens an anchored panel
718
+ * (MarketingHeader and Navbar). The group is marked current when one of its
719
+ * items is the current page.
720
+ */
721
+ export function Flyout({
722
+ label,
723
+ items,
724
+ footer = [],
725
+ layout = "list",
726
+ onNavigate,
727
+ }: {
728
+ label: string;
729
+ items: FlyoutItem[];
730
+ footer?: LinkAction[];
731
+ layout?: "list" | "columns" | "full";
732
+ onNavigate: () => void;
733
+ }) {
734
+ const dataLayout = layout === "list" ? undefined : layout;
735
+ const current = items.some((item) => item.current);
736
+ return (
737
+ <Popover
738
+ className="m-flyout"
739
+ data-current={current || undefined}
740
+ data-layout={dataLayout}
741
+ >
742
+ <PopoverButton className="m-flyout-trigger">
743
+ {label}
744
+ <ChevronDown />
745
+ </PopoverButton>
746
+ {/* Anchored panels render in a portal, so headers inside clipped containers never cut them off. */}
747
+ <PopoverPanel
748
+ anchor="bottom"
749
+ className="m-flyout-panel"
750
+ data-layout={dataLayout}
751
+ >
752
+ <ul>
753
+ {items.map((item, index) => (
754
+ <li key={`${item.label}:${item.href}:${index}`}>
755
+ <CloseButton
756
+ as={KitLink}
757
+ href={item.href}
758
+ aria-current={item.current ? "page" : undefined}
759
+ onClick={onNavigate}
760
+ >
761
+ {item.icon && (
762
+ <span className="m-flyout-icon" aria-hidden="true">
763
+ {item.icon}
764
+ </span>
765
+ )}
766
+ <span className="m-flyout-text">
767
+ <strong>{item.label}</strong>
768
+ {item.description && <span>{item.description}</span>}
769
+ </span>
770
+ </CloseButton>
771
+ </li>
772
+ ))}
773
+ </ul>
774
+ {footer.length > 0 && (
775
+ <div className="m-flyout-footer">
776
+ {footer.map((link, index) => (
777
+ <CloseButton
778
+ as={KitLink}
779
+ key={`${link.label}:${link.href}:${index}`}
780
+ href={link.href}
781
+ onClick={onNavigate}
782
+ >
783
+ {link.label}
784
+ </CloseButton>
785
+ ))}
786
+ </div>
787
+ )}
788
+ </PopoverPanel>
789
+ </Popover>
790
+ );
791
+ }
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%,