@tonycodes-ui/mortar 0.12.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.
@@ -264,6 +264,20 @@
264
264
  .m-stacked-shell > main:focus {
265
265
  outline: none;
266
266
  }
267
+ /* A second row of page tabs under the bar; the row draws the full-width rule. */
268
+ .m-stacked-tabs {
269
+ padding: 0 24px;
270
+ border-bottom: 1px solid var(--m-line);
271
+ background: var(--m-surface);
272
+ }
273
+ .m-stacked-tabs .m-tab-nav ul {
274
+ border-bottom: 0;
275
+ }
276
+ @media (max-width: 900px) {
277
+ .m-stacked-tabs {
278
+ padding: 0 16px;
279
+ }
280
+ }
267
281
 
268
282
  /* == Navbar == */
269
283
  .m-navbar {
@@ -274,6 +288,39 @@
274
288
  padding: 0 24px;
275
289
  border-bottom: 1px solid var(--m-line);
276
290
  background: var(--m-surface);
291
+ color: var(--m-ink);
292
+ }
293
+ /* Accent bar: the brand's accent fill, remapped like the accent bands
294
+ (marketing.css): on-accent text, links and focus, inverted primary
295
+ actions and outlined secondary ones. --m-sunken uses --m-accent-wash, the
296
+ same direction as an accent button's hover, so a selected row or hover
297
+ moves the fill away from the on-accent text. A mix towards --m-on-accent
298
+ drops that text below 4.5:1. Panels open in a portal and keep the page's
299
+ colours. Inverse needs nothing here: [data-tone="inverse"] remaps every
300
+ token (tokens.css). */
301
+ .m-navbar[data-tone="accent"] {
302
+ background-color: var(--m-accent);
303
+ background-image: var(--m-accent-fill);
304
+ --m-ink: var(--m-on-accent);
305
+ --m-secondary: var(--m-on-accent);
306
+ --m-muted: var(--m-on-accent);
307
+ --m-link: var(--m-on-accent);
308
+ --m-focus: var(--m-on-accent);
309
+ --m-line: color-mix(in oklab, var(--m-on-accent) 35%, transparent);
310
+ --m-line-strong: var(--m-on-accent);
311
+ --m-primary: var(--m-on-accent);
312
+ --m-primary-hover: var(--m-on-accent);
313
+ --m-primary-active: var(--m-on-accent);
314
+ --m-on-primary: var(--m-accent);
315
+ --m-surface: transparent;
316
+ --m-sunken: color-mix(
317
+ in oklab,
318
+ var(--m-accent-wash, transparent) 14%,
319
+ transparent
320
+ );
321
+ }
322
+ .m-navbar[data-tone="accent"] .m-button-primary:hover {
323
+ box-shadow: inset 0 0 0 2px var(--m-accent);
277
324
  }
278
325
  .m-navbar > .m-brand {
279
326
  display: inline-flex;
@@ -316,6 +363,27 @@
316
363
  font-weight: var(--m-weight-semibold);
317
364
  box-shadow: inset 0 -2px 0 var(--m-primary);
318
365
  }
366
+ /* A group's trigger looks like the links beside it; its panel is the
367
+ shared flyout (marketing.css). */
368
+ .m-navbar > nav > .m-flyout {
369
+ display: flex;
370
+ }
371
+ .m-navbar .m-flyout-trigger {
372
+ padding: 0 10px;
373
+ color: var(--m-secondary);
374
+ font-size: var(--m-font-size);
375
+ font-weight: var(--m-weight-medium);
376
+ box-shadow: inset 0 -2px 0 transparent;
377
+ }
378
+ .m-navbar .m-flyout-trigger:hover {
379
+ color: var(--m-ink);
380
+ box-shadow: inset 0 -2px 0 var(--m-line-strong);
381
+ }
382
+ .m-navbar .m-flyout[data-current] > .m-flyout-trigger {
383
+ color: var(--m-ink);
384
+ font-weight: var(--m-weight-semibold);
385
+ box-shadow: inset 0 -2px 0 var(--m-primary);
386
+ }
319
387
  @media (max-width: 900px) {
320
388
  .m-navbar {
321
389
  flex-wrap: wrap;
@@ -362,6 +430,25 @@
362
430
  box-shadow: inset 3px 0 0 var(--m-primary);
363
431
  background: var(--m-sunken);
364
432
  }
433
+ .m-navbar > nav > .m-flyout {
434
+ display: block;
435
+ }
436
+ .m-navbar .m-flyout-trigger {
437
+ width: 100%;
438
+ min-height: 44px;
439
+ justify-content: space-between;
440
+ padding: 0 12px;
441
+ border-radius: var(--m-radius);
442
+ box-shadow: none;
443
+ }
444
+ .m-navbar .m-flyout-trigger:hover {
445
+ box-shadow: none;
446
+ background: var(--m-sunken);
447
+ }
448
+ .m-navbar .m-flyout[data-current] > .m-flyout-trigger {
449
+ box-shadow: inset 3px 0 0 var(--m-primary);
450
+ background: var(--m-sunken);
451
+ }
365
452
  }
366
453
 
367
454
  /* == Breadcrumbs == */
@@ -549,14 +636,21 @@
549
636
  --m-tone: var(--m-secondary);
550
637
  }
551
638
  @media (max-width: 560px) {
639
+ /* Two metrics a row, but a value too wide for half a row takes a row of
640
+ its own instead of being clipped; a value wider than the whole row
641
+ wraps. */
552
642
  .m-metrics {
553
- grid-template-columns: repeat(2, minmax(0, 1fr));
643
+ display: flex;
644
+ flex-wrap: wrap;
554
645
  }
555
646
  .m-metrics > div {
647
+ flex: 1 1 40%;
648
+ max-width: 100%;
556
649
  border-bottom: 1px solid var(--m-line);
557
650
  }
558
651
  .m-metrics dd {
559
652
  font-size: 24px;
653
+ overflow-wrap: break-word;
560
654
  }
561
655
  }
562
656
 
@@ -13,6 +13,7 @@ import { AuthHeader, ProviderButtons, type SignInProvider } from "./auth.js";
13
13
  import { ArrowDown, ArrowUp, Check, SortArrows } from "./icons.js";
14
14
  import { headingTag, type HeadingLevel } from "./heading.js";
15
15
  import {
16
+ Flyout,
16
17
  LinkContent,
17
18
  NavGroups,
18
19
  NavTreeLinks,
@@ -21,7 +22,9 @@ import {
21
22
  cx,
22
23
  componentRoot,
23
24
  classedIntroSlots,
25
+ sectionTone,
24
26
  useDomId,
27
+ type FlyoutItem,
25
28
  } from "./internal.js";
26
29
  /**
27
30
  * A navigation link; `icon` replaces the default marker in the sidebar. With
@@ -189,11 +192,16 @@ export interface StackedShellProps {
189
192
  brand: string;
190
193
  logo?: ReactNode;
191
194
  homeHref?: string;
192
- navigation: LinkItem[];
195
+ /** Top-level links, or labelled groups that open a dropdown panel. */
196
+ navigation: NavbarItem[];
193
197
  actions?: ReactNode;
194
198
  /** Page heading shown above the content, e.g. a PageHeading. */
195
199
  header?: ReactNode;
196
200
  width?: "md" | "lg" | "xl" | "full";
201
+ /** The bar's surface: default, inverse (the other appearance) or accent (the brand's accent fill). */
202
+ tone?: NavbarTone;
203
+ /** A second row of page tabs under the bar, rendered as a TabNav named by `label`. */
204
+ tabs?: ItemGroup<TabNavItem>;
197
205
  children: ReactNode;
198
206
  className?: string;
199
207
  }
@@ -206,6 +214,8 @@ export function StackedShell({
206
214
  actions,
207
215
  header,
208
216
  width = "lg",
217
+ tone,
218
+ tabs,
209
219
  children,
210
220
  className,
211
221
  }: StackedShellProps) {
@@ -224,7 +234,13 @@ export function StackedShell({
224
234
  homeHref={homeHref}
225
235
  items={navigation}
226
236
  actions={actions}
237
+ tone={tone}
227
238
  />
239
+ {tabs && (
240
+ <div className="m-stacked-tabs">
241
+ <TabNav label={tabs.label} items={tabs.items} />
242
+ </div>
243
+ )}
228
244
  <main id={contentId} tabIndex={-1} data-width={width}>
229
245
  {header && <div className="m-stacked-header">{header}</div>}
230
246
  {children}
@@ -232,13 +248,20 @@ export function StackedShell({
232
248
  </div>
233
249
  );
234
250
  }
251
+ /** A labelled group of links that opens a dropdown panel from the bar. */
252
+ export type NavbarGroup = ItemGroup<FlyoutItem>;
253
+ /** A top-level link, or a group; a group holding the current page is marked current. */
254
+ export type NavbarItem = LinkItem | NavbarGroup;
255
+ export type NavbarTone = "default" | "inverse" | "accent";
235
256
  export interface NavbarProps {
236
257
  brand: string;
237
258
  logo?: ReactNode;
238
259
  homeHref?: string;
239
- /** Top-level links; the navbar has no sub-menus. */
240
- items: LinkItem[];
260
+ /** Top-level links, or labelled groups that open a dropdown panel. */
261
+ items: NavbarItem[];
241
262
  actions?: ReactNode;
263
+ /** The bar's surface: default, inverse (the other appearance) or accent (the brand's accent fill). */
264
+ tone?: NavbarTone;
242
265
  /** Accessible name of the navigation landmark. */
243
266
  label?: string;
244
267
  className?: string;
@@ -250,13 +273,19 @@ export function Navbar({
250
273
  homeHref = "/",
251
274
  items,
252
275
  actions,
276
+ tone,
253
277
  label = "Primary",
254
278
  className,
255
279
  }: NavbarProps) {
256
280
  const [open, setOpen] = useState(false);
257
281
  const id = useId();
282
+ const close = () => setOpen(false);
258
283
  return (
259
- <header {...componentRoot("Navbar")} className={cx("m-navbar", className)}>
284
+ <header
285
+ {...componentRoot("Navbar")}
286
+ className={cx("m-navbar", className)}
287
+ data-tone={sectionTone(tone)}
288
+ >
260
289
  <KitLink className="m-brand" href={homeHref}>
261
290
  <BrandMark logo={logo} />
262
291
  {brand}
@@ -271,21 +300,30 @@ export function Navbar({
271
300
  Menu
272
301
  </Button>
273
302
  <nav id={id} aria-label={label} data-open={open || undefined}>
274
- {items.map((item, index) => (
275
- <KitLink
276
- key={`${item.label}:${item.href}:${index}`}
277
- href={item.href}
278
- aria-current={item.current ? "page" : undefined}
279
- onClick={() => setOpen(false)}
280
- >
281
- <LinkContent
282
- prefix="nav"
283
- icon={item.icon}
303
+ {items.map((item, index) =>
304
+ "items" in item ? (
305
+ <Flyout
306
+ key={`${item.label}:group:${index}`}
284
307
  label={item.label}
285
- count={item.count}
308
+ items={item.items}
309
+ onNavigate={close}
286
310
  />
287
- </KitLink>
288
- ))}
311
+ ) : (
312
+ <KitLink
313
+ key={`${item.label}:${item.href}:${index}`}
314
+ href={item.href}
315
+ aria-current={item.current ? "page" : undefined}
316
+ onClick={close}
317
+ >
318
+ <LinkContent
319
+ prefix="nav"
320
+ icon={item.icon}
321
+ label={item.label}
322
+ count={item.count}
323
+ />
324
+ </KitLink>
325
+ ),
326
+ )}
289
327
  </nav>
290
328
  {actions && <div className="m-actions">{actions}</div>}
291
329
  </header>
@@ -689,6 +727,28 @@ export interface DataTableColumn<T> {
689
727
  width?: string;
690
728
  /** Clip long values to one line with an ellipsis. */
691
729
  truncate?: boolean;
730
+ /**
731
+ * How long the column stays as the table narrows (its own width, not the
732
+ * screen's): `medium` hides below 480px, `low` below 768px. The first
733
+ * column always shows. Hidden columns leave the accessibility tree too, so
734
+ * keep what people need in visible columns or on the detail page.
735
+ */
736
+ priority?: "high" | "medium" | "low";
737
+ /**
738
+ * `end` pins the column to the trailing edge while the table scrolls
739
+ * sideways, so a row-actions or status column stays reachable.
740
+ */
741
+ pin?: "end";
742
+ }
743
+ /** Responsive attributes shared by a column's `col`, header and cells. */
744
+ function columnLayout<T>(col: DataTableColumn<T>, index: number) {
745
+ return {
746
+ "data-priority":
747
+ index > 0 && col.priority && col.priority !== "high"
748
+ ? col.priority
749
+ : undefined,
750
+ "data-pin": col.pin,
751
+ };
692
752
  }
693
753
  export interface DataTableProps<T extends { id: string }> {
694
754
  caption: string;
@@ -894,6 +954,7 @@ export function DataTable<T extends { id: string }>({
894
954
  <th
895
955
  scope="row"
896
956
  key={col.key}
957
+ {...columnLayout(col, index)}
897
958
  data-align={columnAlign(col.align)}
898
959
  data-truncate={col.truncate || undefined}
899
960
  >
@@ -908,6 +969,7 @@ export function DataTable<T extends { id: string }>({
908
969
  ) : (
909
970
  <td
910
971
  key={col.key}
972
+ {...columnLayout(col, index)}
911
973
  data-align={columnAlign(col.align)}
912
974
  data-truncate={col.truncate || undefined}
913
975
  >
@@ -926,6 +988,11 @@ export function DataTable<T extends { id: string }>({
926
988
  aria-label={caption}
927
989
  data-density={density === "default" ? undefined : density}
928
990
  data-striped={striped || undefined}
991
+ data-prioritised={
992
+ columns.some((col, index) => columnLayout(col, index)["data-priority"])
993
+ ? true
994
+ : undefined
995
+ }
929
996
  >
930
997
  {(toolbar || selectedIds.length > 0) && (
931
998
  <div className="m-table-toolbar">
@@ -977,9 +1044,10 @@ export function DataTable<T extends { id: string }>({
977
1044
  {columns.some((col) => col.width) && (
978
1045
  <colgroup>
979
1046
  {onSelectionChange && <col className="m-table-select-col" />}
980
- {columns.map((col) => (
1047
+ {columns.map((col, index) => (
981
1048
  <col
982
1049
  key={col.key}
1050
+ {...columnLayout(col, index)}
983
1051
  style={col.width ? { width: col.width } : undefined}
984
1052
  />
985
1053
  ))}
@@ -996,13 +1064,14 @@ export function DataTable<T extends { id: string }>({
996
1064
  />
997
1065
  </th>
998
1066
  )}
999
- {columns.map((col) => {
1067
+ {columns.map((col, index) => {
1000
1068
  const direction =
1001
1069
  sort?.key === col.key ? sort.direction : undefined;
1002
1070
  return (
1003
1071
  <th
1004
1072
  key={col.key}
1005
1073
  scope="col"
1074
+ {...columnLayout(col, index)}
1006
1075
  data-align={columnAlign(col.align)}
1007
1076
  aria-sort={col.sortable ? direction || "none" : undefined}
1008
1077
  >
package/src/data.css CHANGED
@@ -9,6 +9,7 @@
9
9
  /* One table base for DataTable, Table, PlanComparison and prose tables. Each sets
10
10
  its cell padding; DataTable and Table follow density through --m-row-pad. */
11
11
  .m-data-table {
12
+ container: m-data-table / inline-size;
12
13
  overflow: hidden;
13
14
  min-width: 0;
14
15
  --m-cell-pad-y: var(--m-row-pad);
@@ -255,9 +256,64 @@
255
256
  .m-data-table[data-density="compact"] .m-table-group th {
256
257
  padding-block: 4px;
257
258
  }
259
+ /* Pinned columns stay at the trailing edge while the table scrolls. Sticky
260
+ cells need an opaque background: the surface, or the row's own state.
261
+ Zero-specificity base, so stripes (after it) and hover and selection win. */
262
+ :where(.m-data-table) [data-pin="end"] {
263
+ position: sticky;
264
+ inset-inline-end: 0;
265
+ z-index: 2;
266
+ background-color: var(--m-surface);
267
+ box-shadow: inset 1px 0 0 var(--m-line);
268
+ }
269
+ :where(.m-data-table:dir(rtl)) [data-pin="end"] {
270
+ box-shadow: inset -1px 0 0 var(--m-line);
271
+ }
272
+ :where(
273
+ .m-data-table[data-striped]
274
+ tbody
275
+ tr:nth-child(even of :not(.m-table-group))
276
+ )
277
+ > [data-pin] {
278
+ background-color: var(--m-sunken);
279
+ }
280
+ .m-data-table thead th[data-pin] {
281
+ background-color: var(--m-paper);
282
+ }
283
+ .m-table-scroll[data-sticky] thead th[data-pin] {
284
+ z-index: 3;
285
+ }
286
+ /* (0,2,1): above the pinned stripe, below forced-colours selection (0,3,0).
287
+ :where(.m-data-table) does that for a selected cell. Hover also zeroes
288
+ [data-linked]; :hover alone would stay at (0,3,1) and keep HighlightText
289
+ on the author's background. */
290
+ :where(.m-data-table) tr:where([data-linked]):hover > [data-pin] {
291
+ background-color: var(--m-sunken);
292
+ }
293
+ :where(.m-data-table) tr[data-selected] > [data-pin] {
294
+ background-color: var(--m-selected);
295
+ }
296
+ /* Column priority follows the table's own width, so a table in a narrow
297
+ panel collapses as it would on a phone. */
298
+ @container m-data-table (max-width: 767px) {
299
+ .m-data-table [data-priority="low"] {
300
+ display: none;
301
+ }
302
+ }
303
+ @container m-data-table (max-width: 479px) {
304
+ .m-data-table [data-priority="medium"] {
305
+ display: none;
306
+ }
307
+ }
308
+ /* A table with column priorities is meant to fit, so truncated columns keep
309
+ shrinking on narrow screens, down to a readable floor. */
310
+ .m-data-table[data-prioritised] [data-truncate] .m-truncate {
311
+ min-width: 5rem;
312
+ }
258
313
  @media (max-width: 640px) {
259
- /* Narrow screens scroll the table instead of collapsing truncated columns. */
260
- .m-data-table [data-truncate] {
314
+ /* Narrow screens scroll the table instead of collapsing truncated columns,
315
+ unless it has column priorities (above). */
316
+ .m-data-table:not([data-prioritised]) [data-truncate] {
261
317
  max-width: none;
262
318
  }
263
319
  .m-truncate {
@@ -1128,9 +1128,13 @@
1128
1128
  }
1129
1129
 
1130
1130
  /* == SegmentedControl == */
1131
+ /* Never wider than its container: long or many options scroll inside the
1132
+ control, which keeps the selected option in view (inspection.tsx). */
1131
1133
  .m-segmented {
1132
1134
  display: inline-flex;
1133
1135
  width: fit-content;
1136
+ max-width: 100%;
1137
+ overflow-x: auto;
1134
1138
  background: var(--m-sunken);
1135
1139
  padding: 3px;
1136
1140
  gap: 2px;
@@ -1146,6 +1150,12 @@
1146
1150
  padding: 5px 10px;
1147
1151
  cursor: pointer;
1148
1152
  min-height: max(28px, var(--m-touch-target));
1153
+ flex-shrink: 0;
1154
+ white-space: nowrap;
1155
+ }
1156
+ /* Inside the 3px padding, so the scroll container doesn't clip the ring. */
1157
+ .m-segmented button:focus-visible {
1158
+ outline-offset: 1px;
1149
1159
  }
1150
1160
  .m-segmented [aria-pressed="true"] {
1151
1161
  background: var(--m-surface);
package/src/index.ts CHANGED
@@ -66,6 +66,9 @@ export type {
66
66
  TabNavItem,
67
67
  StackedShellProps,
68
68
  NavbarProps,
69
+ NavbarItem,
70
+ NavbarGroup,
71
+ NavbarTone,
69
72
  BreadcrumbsProps,
70
73
  TabNavProps,
71
74
  ProgressStepsProps,
@@ -1,3 +1,4 @@
1
+ import { useLayoutEffect, useRef } from "react";
1
2
  import { KitLink } from "./navigation.js";
2
3
  import { componentRoot, cx, useComponentName } from "./internal.js";
3
4
  export function SegmentedControl({
@@ -14,8 +15,22 @@ export function SegmentedControl({
14
15
  className?: string;
15
16
  }) {
16
17
  const name = useComponentName("SegmentedControl");
18
+ const group = useRef<HTMLDivElement>(null);
19
+ // When the options scroll (narrow screens), keep the selected one in view,
20
+ // on mount and on change. Only the control scrolls, never the page.
21
+ useLayoutEffect(() => {
22
+ const root = group.current;
23
+ const selected = root?.querySelector<HTMLElement>('[aria-pressed="true"]');
24
+ if (!root || !selected || root.scrollWidth <= root.clientWidth) return;
25
+ const outer = root.getBoundingClientRect();
26
+ const inner = selected.getBoundingClientRect();
27
+ if (inner.left < outer.left) root.scrollLeft -= outer.left - inner.left;
28
+ else if (inner.right > outer.right)
29
+ root.scrollLeft += inner.right - outer.right;
30
+ }, [value]);
17
31
  return (
18
32
  <div
33
+ ref={group}
19
34
  {...componentRoot(name)}
20
35
  className={cx("m-segmented", className)}
21
36
  role="group"
package/src/internal.tsx CHANGED
@@ -14,8 +14,17 @@ import {
14
14
  cloneElement,
15
15
  isValidElement,
16
16
  } from "react";
17
- import { Dialog, DialogPanel, DialogTitle } from "@headlessui/react";
18
- 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";
19
28
  import { ActionLink, Button, type LinkAction } from "./primitives.js";
20
29
  import { headingTag, type HeadingLevel } from "./heading.js";
21
30
 
@@ -698,3 +707,85 @@ export function focusAfterRemoval(element: HTMLElement) {
698
707
  }
699
708
  });
700
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
+ }