@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.
- package/CHANGELOG.md +16 -39
- package/DESIGN.md +1 -1
- package/catalog.json +413 -377
- package/cli/audit.mjs +152 -0
- package/dist/agent-data.json +1 -1
- package/dist/application.d.ts +30 -5
- package/dist/application.js +20 -8
- package/dist/index.d.ts +1 -1
- package/dist/inspection.js +17 -1
- package/dist/internal.d.ts +15 -1
- package/dist/internal.js +12 -1
- package/dist/marketing.d.ts +3 -2
- package/dist/marketing.js +2 -6
- package/dist/primitives.js +20 -11
- package/docs/agents.md +21 -18
- package/docs/theming.md +72 -1
- package/package.json +1 -1
- package/skills/mortar/SKILL.md +1 -1
- package/src/application.css +95 -1
- package/src/application.tsx +88 -19
- package/src/data.css +58 -2
- package/src/foundation.css +10 -0
- package/src/index.ts +3 -0
- package/src/inspection.tsx +15 -0
- package/src/internal.tsx +93 -2
- package/src/marketing.tsx +4 -74
- package/src/primitives.tsx +38 -19
package/src/application.css
CHANGED
|
@@ -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
|
-
|
|
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
|
|
package/src/application.tsx
CHANGED
|
@@ -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
|
-
|
|
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
|
|
240
|
-
items:
|
|
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
|
|
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
|
-
|
|
276
|
-
|
|
277
|
-
|
|
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
|
-
|
|
308
|
+
items={item.items}
|
|
309
|
+
onNavigate={close}
|
|
286
310
|
/>
|
|
287
|
-
|
|
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
|
-
|
|
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 {
|
package/src/foundation.css
CHANGED
|
@@ -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
package/src/inspection.tsx
CHANGED
|
@@ -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 {
|
|
18
|
-
|
|
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
|
+
}
|