@charcuterie/ui 2.9.0 → 2.10.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/README.md +9 -0
- package/dist/Header/Header.d.ts +52 -0
- package/dist/Header/Header.d.ts.map +1 -0
- package/dist/Header/Header.js +55 -0
- package/dist/Header/Header.js.map +1 -0
- package/dist/Main/Main.d.ts +113 -0
- package/dist/Main/Main.d.ts.map +1 -0
- package/dist/Main/Main.js +123 -0
- package/dist/Main/Main.js.map +1 -0
- package/dist/Rail/Rail.d.ts +61 -0
- package/dist/Rail/Rail.d.ts.map +1 -0
- package/dist/Rail/Rail.js +64 -0
- package/dist/Rail/Rail.js.map +1 -0
- package/dist/Shell/Shell.d.ts +112 -0
- package/dist/Shell/Shell.d.ts.map +1 -0
- package/dist/Shell/Shell.js +116 -0
- package/dist/Shell/Shell.js.map +1 -0
- package/dist/Shell/contentWidth.d.ts +63 -0
- package/dist/Shell/contentWidth.d.ts.map +1 -0
- package/dist/Shell/contentWidth.js +74 -0
- package/dist/Shell/contentWidth.js.map +1 -0
- package/dist/Shell/shellContext.d.ts +36 -0
- package/dist/Shell/shellContext.d.ts.map +1 -0
- package/dist/Shell/shellContext.js +32 -0
- package/dist/Shell/shellContext.js.map +1 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -1
- package/dist/viewport.testHelpers.d.ts +31 -0
- package/dist/viewport.testHelpers.d.ts.map +1 -0
- package/dist/viewport.testHelpers.js +34 -0
- package/dist/viewport.testHelpers.js.map +1 -0
- package/package.json +1 -1
- package/src/Header/Header.mdx +72 -0
- package/src/Header/Header.stories.tsx +221 -0
- package/src/Header/Header.test.tsx +122 -0
- package/src/Header/Header.tsx +118 -0
- package/src/Main/Main.mdx +149 -0
- package/src/Main/Main.stories.tsx +164 -0
- package/src/Main/Main.test.tsx +167 -0
- package/src/Main/Main.tsx +174 -0
- package/src/Rail/Rail.mdx +75 -0
- package/src/Rail/Rail.stories.tsx +177 -0
- package/src/Rail/Rail.test.tsx +150 -0
- package/src/Rail/Rail.tsx +102 -0
- package/src/Shell/Shell.mdx +186 -0
- package/src/Shell/Shell.stories.tsx +185 -0
- package/src/Shell/Shell.test.tsx +267 -0
- package/src/Shell/Shell.tsx +170 -0
- package/src/Shell/contentWidth.ts +89 -0
- package/src/Shell/shellContext.ts +41 -0
- package/src/index.ts +16 -0
- package/src/shell.storyHelpers.tsx +258 -0
- package/src/sourceRules.test.ts +17 -1
- package/src/viewport.testHelpers.ts +47 -0
package/README.md
CHANGED
|
@@ -38,6 +38,15 @@ The router is **injected** rather than depended on — `RouterLinkProvider` at t
|
|
|
38
38
|
with `@charcuterie/ui/react-router` as an optional subpath adapter and a plain `<a>` when
|
|
39
39
|
nothing is provided. Recipe: **Guides/Routing** in Storybook.
|
|
40
40
|
|
|
41
|
+
**The unified app shell adds four**: `Shell`, `Header`, `Rail`, `Main` — the fleet's largest
|
|
42
|
+
single duplication, where ten of twelve UI repos hand-roll the page chrome and three of them
|
|
43
|
+
have a file named `AppShell.tsx` (two of those headers are a *byte-identical* class string,
|
|
44
|
+
arrived at independently). One `contentWidth` on `Shell` feeds both the header row and the
|
|
45
|
+
content column, `isSticky` writes `position` and `z-index` together, and a `Rail` collapses
|
|
46
|
+
by restyling rather than by rendering itself twice.
|
|
47
|
+
[Decision](../../docs/decisions/2026-08-10-the-app-shell-is-shell-header-rail-main.md);
|
|
48
|
+
`Shell`'s docs page carries three copy-wholesale templates.
|
|
49
|
+
|
|
41
50
|
## Install and wire
|
|
42
51
|
|
|
43
52
|
```ts
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { type ComponentPropsWithRef, type ReactNode } from "react";
|
|
2
|
+
import { type ContentWidth } from "../Shell/contentWidth.ts";
|
|
3
|
+
export type HeaderProps = ComponentPropsWithRef<"header"> & {
|
|
4
|
+
actions?: ReactNode;
|
|
5
|
+
contentWidth?: ContentWidth;
|
|
6
|
+
heading?: string;
|
|
7
|
+
headingLevel?: 1 | 2;
|
|
8
|
+
isSticky?: boolean;
|
|
9
|
+
};
|
|
10
|
+
/**
|
|
11
|
+
* The page header — the `<header>` landmark, the app's name, and
|
|
12
|
+
* the controls that belong to the frame rather than to the page.
|
|
13
|
+
*
|
|
14
|
+
* **`isSticky` defaults to true, and it sets `position` as well as
|
|
15
|
+
* `z-index`.** That pairing is the whole point. mux-magic's
|
|
16
|
+
* `PageHeader` is documented as a sticky header and sets only
|
|
17
|
+
* `z-index` (`PageHeader.tsx:173`) — it is a flex-column shell that
|
|
18
|
+
* scrolls away like any other block, and the z-index it carries
|
|
19
|
+
* has nothing to stack against. The bug is invisible: the CSS is
|
|
20
|
+
* valid, the intent is stated in a comment, and nobody scrolls a
|
|
21
|
+
* component in a review. Here the two cannot come apart, because
|
|
22
|
+
* one boolean writes both.
|
|
23
|
+
*
|
|
24
|
+
* The z-index is `--layer-sticky` from the token scale, never a
|
|
25
|
+
* hand-picked number. The fleet's headers currently sit at `z-40`
|
|
26
|
+
* (mail-sifter, points-market), `z-50` (rip-deck), `z-10`
|
|
27
|
+
* (spoolbuddy) and `z-[100]` (mux-magic), with no ordering
|
|
28
|
+
* guarantee between a header and anything else — which is exactly
|
|
29
|
+
* what the scale exists to settle: `sticky` (100) is below
|
|
30
|
+
* `dropdown` (200), `modal` (400) and `toast` (500), so a menu
|
|
31
|
+
* opened *from* the header paints over it.
|
|
32
|
+
*
|
|
33
|
+
* ## The width comes from `Shell`
|
|
34
|
+
*
|
|
35
|
+
* The `<header>` itself is full-bleed — the border and the
|
|
36
|
+
* translucent fill run edge to edge — and only its **inner row** is
|
|
37
|
+
* capped, which is what makes a constrained header look right on an
|
|
38
|
+
* ultrawide. That cap is `Shell`'s `contentWidth`, not a second
|
|
39
|
+
* opinion: see `shellContext.ts` for the shipped app whose header
|
|
40
|
+
* and `<main>` disagree by 80rem.
|
|
41
|
+
*
|
|
42
|
+
* ## No invented colours
|
|
43
|
+
*
|
|
44
|
+
* `bg-surface-raised/85` + `backdrop-blur-md` + `border-border-subtle`.
|
|
45
|
+
* The fleet's copy of this line is `bg-surface-base/90`, which puts
|
|
46
|
+
* the header at the *same* colour as the page behind it and leans
|
|
47
|
+
* entirely on the blur to separate them — so it vanishes against a
|
|
48
|
+
* page that is not scrolled. `raised` means *more separated from
|
|
49
|
+
* base*, which is the role this element actually wants.
|
|
50
|
+
*/
|
|
51
|
+
export declare const Header: ({ actions, children, className, contentWidth, heading, headingLevel, isSticky, ...headerProps }: HeaderProps) => ReactNode;
|
|
52
|
+
//# sourceMappingURL=Header.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"Header.d.ts","sourceRoot":"","sources":["../../src/Header/Header.tsx"],"names":[],"mappings":"AAAA,OAAO,EACL,KAAK,qBAAqB,EAC1B,KAAK,SAAS,EAEf,MAAM,OAAO,CAAA;AAEd,OAAO,EACL,KAAK,YAAY,EAGlB,MAAM,0BAA0B,CAAA;AAIjC,MAAM,MAAM,WAAW,GACrB,qBAAqB,CAAC,QAAQ,CAAC,GAAG;IAChC,OAAO,CAAC,EAAE,SAAS,CAAA;IACnB,YAAY,CAAC,EAAE,YAAY,CAAA;IAC3B,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB,YAAY,CAAC,EAAE,CAAC,GAAG,CAAC,CAAA;IACpB,QAAQ,CAAC,EAAE,OAAO,CAAA;CACnB,CAAA;AAEH;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAwCG;AACH,eAAO,MAAM,MAAM,GAAI,iGASpB,WAAW,KAAG,SA4ChB,CAAA"}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useContext, } from "react";
|
|
3
|
+
import { DEFAULT_CONTENT_WIDTH, toMaxInlineSize, } from "../Shell/contentWidth.js";
|
|
4
|
+
import { ShellContext } from "../Shell/shellContext.js";
|
|
5
|
+
import { toClassName } from "../toClassName.js";
|
|
6
|
+
/**
|
|
7
|
+
* The page header — the `<header>` landmark, the app's name, and
|
|
8
|
+
* the controls that belong to the frame rather than to the page.
|
|
9
|
+
*
|
|
10
|
+
* **`isSticky` defaults to true, and it sets `position` as well as
|
|
11
|
+
* `z-index`.** That pairing is the whole point. mux-magic's
|
|
12
|
+
* `PageHeader` is documented as a sticky header and sets only
|
|
13
|
+
* `z-index` (`PageHeader.tsx:173`) — it is a flex-column shell that
|
|
14
|
+
* scrolls away like any other block, and the z-index it carries
|
|
15
|
+
* has nothing to stack against. The bug is invisible: the CSS is
|
|
16
|
+
* valid, the intent is stated in a comment, and nobody scrolls a
|
|
17
|
+
* component in a review. Here the two cannot come apart, because
|
|
18
|
+
* one boolean writes both.
|
|
19
|
+
*
|
|
20
|
+
* The z-index is `--layer-sticky` from the token scale, never a
|
|
21
|
+
* hand-picked number. The fleet's headers currently sit at `z-40`
|
|
22
|
+
* (mail-sifter, points-market), `z-50` (rip-deck), `z-10`
|
|
23
|
+
* (spoolbuddy) and `z-[100]` (mux-magic), with no ordering
|
|
24
|
+
* guarantee between a header and anything else — which is exactly
|
|
25
|
+
* what the scale exists to settle: `sticky` (100) is below
|
|
26
|
+
* `dropdown` (200), `modal` (400) and `toast` (500), so a menu
|
|
27
|
+
* opened *from* the header paints over it.
|
|
28
|
+
*
|
|
29
|
+
* ## The width comes from `Shell`
|
|
30
|
+
*
|
|
31
|
+
* The `<header>` itself is full-bleed — the border and the
|
|
32
|
+
* translucent fill run edge to edge — and only its **inner row** is
|
|
33
|
+
* capped, which is what makes a constrained header look right on an
|
|
34
|
+
* ultrawide. That cap is `Shell`'s `contentWidth`, not a second
|
|
35
|
+
* opinion: see `shellContext.ts` for the shipped app whose header
|
|
36
|
+
* and `<main>` disagree by 80rem.
|
|
37
|
+
*
|
|
38
|
+
* ## No invented colours
|
|
39
|
+
*
|
|
40
|
+
* `bg-surface-raised/85` + `backdrop-blur-md` + `border-border-subtle`.
|
|
41
|
+
* The fleet's copy of this line is `bg-surface-base/90`, which puts
|
|
42
|
+
* the header at the *same* colour as the page behind it and leans
|
|
43
|
+
* entirely on the blur to separate them — so it vanishes against a
|
|
44
|
+
* page that is not scrolled. `raised` means *more separated from
|
|
45
|
+
* base*, which is the role this element actually wants.
|
|
46
|
+
*/
|
|
47
|
+
export const Header = ({ actions, children, className, contentWidth, heading, headingLevel = 1, isSticky = true, ...headerProps }) => {
|
|
48
|
+
const shell = useContext(ShellContext);
|
|
49
|
+
const Heading = `h${headingLevel}`;
|
|
50
|
+
const maxInlineSize = toMaxInlineSize(contentWidth ??
|
|
51
|
+
shell?.contentWidth ??
|
|
52
|
+
DEFAULT_CONTENT_WIDTH);
|
|
53
|
+
return (_jsx("header", { ...headerProps, className: toClassName("col-span-full row-start-1 border-border-subtle border-b bg-surface-raised/85 backdrop-blur-md", isSticky && "sticky top-0 z-[var(--layer-sticky)]", className), children: _jsxs("div", { className: "mx-auto flex w-full min-w-0 flex-wrap items-center gap-3 px-4 py-3 sm:px-6", style: { maxInlineSize }, children: [heading ? (_jsx(Heading, { className: "min-w-0 truncate font-semibold text-lg leading-tight", children: heading })) : null, children ? (_jsx("div", { className: "flex min-w-0 flex-1 items-center gap-2", children: children })) : null, actions ? (_jsx("div", { className: "ms-auto flex shrink-0 items-center gap-2", children: actions })) : null] }) }));
|
|
54
|
+
};
|
|
55
|
+
//# sourceMappingURL=Header.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"Header.js","sourceRoot":"","sources":["../../src/Header/Header.tsx"],"names":[],"mappings":";AAAA,OAAO,EAGL,UAAU,GACX,MAAM,OAAO,CAAA;AAEd,OAAO,EAEL,qBAAqB,EACrB,eAAe,GAChB,MAAM,0BAA0B,CAAA;AACjC,OAAO,EAAE,YAAY,EAAE,MAAM,0BAA0B,CAAA;AACvD,OAAO,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAA;AAW/C;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAwCG;AACH,MAAM,CAAC,MAAM,MAAM,GAAG,CAAC,EACrB,OAAO,EACP,QAAQ,EACR,SAAS,EACT,YAAY,EACZ,OAAO,EACP,YAAY,GAAG,CAAC,EAChB,QAAQ,GAAG,IAAI,EACf,GAAG,WAAW,EACF,EAAa,EAAE;IAC3B,MAAM,KAAK,GAAG,UAAU,CAAC,YAAY,CAAC,CAAA;IAEtC,MAAM,OAAO,GAAG,IAAI,YAAY,EAAW,CAAA;IAE3C,MAAM,aAAa,GAAG,eAAe,CACnC,YAAY;QACV,KAAK,EAAE,YAAY;QACnB,qBAAqB,CACxB,CAAA;IAED,OAAO,CACL,oBACM,WAAW,EACf,SAAS,EAAE,WAAW,CACpB,+FAA+F,EAC/F,QAAQ,IAAI,sCAAsC,EAClD,SAAS,CACV,YAED,eACE,SAAS,EAAC,4EAA4E,EACtF,KAAK,EAAE,EAAE,aAAa,EAAE,aAEvB,OAAO,CAAC,CAAC,CAAC,CACT,KAAC,OAAO,IAAC,SAAS,EAAC,sDAAsD,YACtE,OAAO,GACA,CACX,CAAC,CAAC,CAAC,IAAI,EAEP,QAAQ,CAAC,CAAC,CAAC,CACV,cAAK,SAAS,EAAC,wCAAwC,YACpD,QAAQ,GACL,CACP,CAAC,CAAC,CAAC,IAAI,EAEP,OAAO,CAAC,CAAC,CAAC,CACT,cAAK,SAAS,EAAC,0CAA0C,YACtD,OAAO,GACJ,CACP,CAAC,CAAC,CAAC,IAAI,IACJ,GACC,CACV,CAAA;AACH,CAAC,CAAA"}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { type ComponentPropsWithRef, type ReactNode } from "react";
|
|
2
|
+
import { type ContentWidth } from "../Shell/contentWidth.ts";
|
|
3
|
+
export type MainProps = ComponentPropsWithRef<"main"> & {
|
|
4
|
+
contentWidth?: ContentWidth;
|
|
5
|
+
};
|
|
6
|
+
/**
|
|
7
|
+
* The `<main>` landmark and the content column.
|
|
8
|
+
*
|
|
9
|
+
* ## The width is the whole component
|
|
10
|
+
*
|
|
11
|
+
* The owner's complaint, verbatim:
|
|
12
|
+
*
|
|
13
|
+
* > "All these apps seem to be really narrow with a very large
|
|
14
|
+
* > max-width. By that I mean 1 column, but waaaaaaay too wide. In
|
|
15
|
+
* > almost all cases, what I really want is something like Rip-Deck
|
|
16
|
+
* > where the app has a narrower main column but the wrapping grids
|
|
17
|
+
* > are all full-width in most of these apps only when you have too
|
|
18
|
+
* > many items."
|
|
19
|
+
*
|
|
20
|
+
* That is two requirements, and they pull opposite ways: a
|
|
21
|
+
* **narrow** column by default, which **widens** when the content
|
|
22
|
+
* is a grid with enough items to justify it. A fixed `max-w-7xl`
|
|
23
|
+
* answers neither — it is too wide for the one-column case, which
|
|
24
|
+
* is the complaint, and it is a hard ceiling on the many-items
|
|
25
|
+
* case.
|
|
26
|
+
*
|
|
27
|
+
* So the cap is a *value*, not a constant, and it arrives from
|
|
28
|
+
* three places in priority order: this component's own
|
|
29
|
+
* `contentWidth`, then `Shell`'s (the usual answer, so the header
|
|
30
|
+
* matches), then `screen.lg`.
|
|
31
|
+
*
|
|
32
|
+
* ### How it composes with the adaptive-columns hook
|
|
33
|
+
*
|
|
34
|
+
* rip-deck's `useLayoutColumns` — being lifted into this library
|
|
35
|
+
* separately — decides a column count from the viewport and folds
|
|
36
|
+
* it into a page cap with `contentMaxWidthRem(columns)`: 1 column →
|
|
37
|
+
* 56rem, 2 → 72rem, 3 → 106rem. It returns a **number of rem**, and
|
|
38
|
+
* `ContentWidth` accepts `` `${number}rem` `` for exactly this:
|
|
39
|
+
*
|
|
40
|
+
* ```tsx
|
|
41
|
+
* const { columns } = useLayoutColumns({ cardCount: bays.length })
|
|
42
|
+
*
|
|
43
|
+
* <Shell contentWidth={`${contentMaxWidthRem(columns)}rem`}>
|
|
44
|
+
* <Header heading="Rip Deck" />
|
|
45
|
+
* <Main>
|
|
46
|
+
* <BayGrid columns={columns} />
|
|
47
|
+
* </Main>
|
|
48
|
+
* </Shell>
|
|
49
|
+
* ```
|
|
50
|
+
*
|
|
51
|
+
* The seam is deliberately one prop and no hook import: this
|
|
52
|
+
* package must not depend on a layout hook to lay out a page, and
|
|
53
|
+
* the hook must not know that a `Shell` exists. Putting the value
|
|
54
|
+
* on `Shell` rather than on `Main` is what keeps the header's cap
|
|
55
|
+
* in step with the column's as the count changes.
|
|
56
|
+
*
|
|
57
|
+
* ## `@container`, and what `contain: inline-size` costs
|
|
58
|
+
*
|
|
59
|
+
* The content column establishes a container query, so an app's
|
|
60
|
+
* grids inside it respond to **the column's** width rather than
|
|
61
|
+
* the window's — a poster grid beside an open rail and the same
|
|
62
|
+
* grid full-bleed are the pair a media query cannot tell apart,
|
|
63
|
+
* and getting it wrong at intermediate widths is what the fleet's
|
|
64
|
+
* grids do today.
|
|
65
|
+
*
|
|
66
|
+
* `container-type: inline-size` implies `contain: layout style
|
|
67
|
+
* inline-size`, and both halves have consequences worth stating:
|
|
68
|
+
*
|
|
69
|
+
* - **The element may not be sized by its own contents.** Here
|
|
70
|
+
* that is free and in fact wanted: the column has a definite
|
|
71
|
+
* inline size from its parent — itself a grid item with
|
|
72
|
+
* `minmax(0, 1fr)` — so wide content can no longer widen it,
|
|
73
|
+
* which is half of why the page does not scroll sideways.
|
|
74
|
+
* - **`contain: layout` makes it a containing block for
|
|
75
|
+
* `position: fixed` descendants.** An app rendering a fixed
|
|
76
|
+
* element *inside* `Main` gets it positioned against the
|
|
77
|
+
* column, not the viewport. Every overlay in this library
|
|
78
|
+
* portals to `document.body` and is therefore unaffected
|
|
79
|
+
* ([decision](../../../../docs/decisions/2026-08-03-overlays-portal-to-the-body-not-the-top-layer.md));
|
|
80
|
+
* an app's own fixed chrome belongs in `Shell`, beside `Main`
|
|
81
|
+
* rather than in it.
|
|
82
|
+
*
|
|
83
|
+
* ## Two more things it does, both one line
|
|
84
|
+
*
|
|
85
|
+
* `tabIndex={-1}` so `Shell`'s skip link moves **focus** and not
|
|
86
|
+
* merely the reading cursor — without it, Safari and Firefox scroll
|
|
87
|
+
* to `<main>` and leave the next Tab back in the header.
|
|
88
|
+
*
|
|
89
|
+
* `wrap-anywhere` so an unbroken string — a filesystem path, a
|
|
90
|
+
* hash, a URL — wraps instead of widening the page.
|
|
91
|
+
*
|
|
92
|
+
* **`anywhere`, not `break-word`, and the difference is the whole
|
|
93
|
+
* point.** Both break a long word onto the next line, so they look
|
|
94
|
+
* identical in a screenshot. Only `anywhere` also shrinks the
|
|
95
|
+
* element's **min-content size** — which is what a flex or grid
|
|
96
|
+
* item's automatic minimum resolves against, and therefore what
|
|
97
|
+
* decides whether the string can push its own column wider. With
|
|
98
|
+
* `break-word` the ink wraps while the intrinsic contribution
|
|
99
|
+
* stays the full length of the token, so the layout above it can
|
|
100
|
+
* still be forced open. This is the second of the two overflow
|
|
101
|
+
* sources found in plex-channels, and the nastier one: it produces
|
|
102
|
+
* **no overflowing element box at all**, so every
|
|
103
|
+
* `getBoundingClientRect()` in a test reads clean while the page
|
|
104
|
+
* scrolls sideways.
|
|
105
|
+
*
|
|
106
|
+
* It is the cheap half of the horizontal-scroll fix; the other
|
|
107
|
+
* halves are `minmax(0, 1fr)` and `overflow-x: clip` in `Shell`. A
|
|
108
|
+
* `<table>` is the one thing none of them can save: tables do not
|
|
109
|
+
* wrap, so a wide one goes in an `overflow-x-auto` wrapper, which
|
|
110
|
+
* `Main.mdx` shows.
|
|
111
|
+
*/
|
|
112
|
+
export declare const Main: ({ children, className, contentWidth, id, ...mainProps }: MainProps) => ReactNode;
|
|
113
|
+
//# sourceMappingURL=Main.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"Main.d.ts","sourceRoot":"","sources":["../../src/Main/Main.tsx"],"names":[],"mappings":"AACA,OAAO,EACL,KAAK,qBAAqB,EAC1B,KAAK,SAAS,EAEf,MAAM,OAAO,CAAA;AAEd,OAAO,EACL,KAAK,YAAY,EAGlB,MAAM,0BAA0B,CAAA;AAIjC,MAAM,MAAM,SAAS,GAAG,qBAAqB,CAAC,MAAM,CAAC,GAAG;IACtD,YAAY,CAAC,EAAE,YAAY,CAAA;CAC5B,CAAA;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAyGG;AACH,eAAO,MAAM,IAAI,GAAI,yDAMlB,SAAS,KAAG,SA0Cd,CAAA"}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { useUniqueId } from "@charcuterie/logic";
|
|
3
|
+
import { useContext, } from "react";
|
|
4
|
+
import { DEFAULT_CONTENT_WIDTH, toMaxInlineSize, } from "../Shell/contentWidth.js";
|
|
5
|
+
import { ShellContext } from "../Shell/shellContext.js";
|
|
6
|
+
import { toClassName } from "../toClassName.js";
|
|
7
|
+
/**
|
|
8
|
+
* The `<main>` landmark and the content column.
|
|
9
|
+
*
|
|
10
|
+
* ## The width is the whole component
|
|
11
|
+
*
|
|
12
|
+
* The owner's complaint, verbatim:
|
|
13
|
+
*
|
|
14
|
+
* > "All these apps seem to be really narrow with a very large
|
|
15
|
+
* > max-width. By that I mean 1 column, but waaaaaaay too wide. In
|
|
16
|
+
* > almost all cases, what I really want is something like Rip-Deck
|
|
17
|
+
* > where the app has a narrower main column but the wrapping grids
|
|
18
|
+
* > are all full-width in most of these apps only when you have too
|
|
19
|
+
* > many items."
|
|
20
|
+
*
|
|
21
|
+
* That is two requirements, and they pull opposite ways: a
|
|
22
|
+
* **narrow** column by default, which **widens** when the content
|
|
23
|
+
* is a grid with enough items to justify it. A fixed `max-w-7xl`
|
|
24
|
+
* answers neither — it is too wide for the one-column case, which
|
|
25
|
+
* is the complaint, and it is a hard ceiling on the many-items
|
|
26
|
+
* case.
|
|
27
|
+
*
|
|
28
|
+
* So the cap is a *value*, not a constant, and it arrives from
|
|
29
|
+
* three places in priority order: this component's own
|
|
30
|
+
* `contentWidth`, then `Shell`'s (the usual answer, so the header
|
|
31
|
+
* matches), then `screen.lg`.
|
|
32
|
+
*
|
|
33
|
+
* ### How it composes with the adaptive-columns hook
|
|
34
|
+
*
|
|
35
|
+
* rip-deck's `useLayoutColumns` — being lifted into this library
|
|
36
|
+
* separately — decides a column count from the viewport and folds
|
|
37
|
+
* it into a page cap with `contentMaxWidthRem(columns)`: 1 column →
|
|
38
|
+
* 56rem, 2 → 72rem, 3 → 106rem. It returns a **number of rem**, and
|
|
39
|
+
* `ContentWidth` accepts `` `${number}rem` `` for exactly this:
|
|
40
|
+
*
|
|
41
|
+
* ```tsx
|
|
42
|
+
* const { columns } = useLayoutColumns({ cardCount: bays.length })
|
|
43
|
+
*
|
|
44
|
+
* <Shell contentWidth={`${contentMaxWidthRem(columns)}rem`}>
|
|
45
|
+
* <Header heading="Rip Deck" />
|
|
46
|
+
* <Main>
|
|
47
|
+
* <BayGrid columns={columns} />
|
|
48
|
+
* </Main>
|
|
49
|
+
* </Shell>
|
|
50
|
+
* ```
|
|
51
|
+
*
|
|
52
|
+
* The seam is deliberately one prop and no hook import: this
|
|
53
|
+
* package must not depend on a layout hook to lay out a page, and
|
|
54
|
+
* the hook must not know that a `Shell` exists. Putting the value
|
|
55
|
+
* on `Shell` rather than on `Main` is what keeps the header's cap
|
|
56
|
+
* in step with the column's as the count changes.
|
|
57
|
+
*
|
|
58
|
+
* ## `@container`, and what `contain: inline-size` costs
|
|
59
|
+
*
|
|
60
|
+
* The content column establishes a container query, so an app's
|
|
61
|
+
* grids inside it respond to **the column's** width rather than
|
|
62
|
+
* the window's — a poster grid beside an open rail and the same
|
|
63
|
+
* grid full-bleed are the pair a media query cannot tell apart,
|
|
64
|
+
* and getting it wrong at intermediate widths is what the fleet's
|
|
65
|
+
* grids do today.
|
|
66
|
+
*
|
|
67
|
+
* `container-type: inline-size` implies `contain: layout style
|
|
68
|
+
* inline-size`, and both halves have consequences worth stating:
|
|
69
|
+
*
|
|
70
|
+
* - **The element may not be sized by its own contents.** Here
|
|
71
|
+
* that is free and in fact wanted: the column has a definite
|
|
72
|
+
* inline size from its parent — itself a grid item with
|
|
73
|
+
* `minmax(0, 1fr)` — so wide content can no longer widen it,
|
|
74
|
+
* which is half of why the page does not scroll sideways.
|
|
75
|
+
* - **`contain: layout` makes it a containing block for
|
|
76
|
+
* `position: fixed` descendants.** An app rendering a fixed
|
|
77
|
+
* element *inside* `Main` gets it positioned against the
|
|
78
|
+
* column, not the viewport. Every overlay in this library
|
|
79
|
+
* portals to `document.body` and is therefore unaffected
|
|
80
|
+
* ([decision](../../../../docs/decisions/2026-08-03-overlays-portal-to-the-body-not-the-top-layer.md));
|
|
81
|
+
* an app's own fixed chrome belongs in `Shell`, beside `Main`
|
|
82
|
+
* rather than in it.
|
|
83
|
+
*
|
|
84
|
+
* ## Two more things it does, both one line
|
|
85
|
+
*
|
|
86
|
+
* `tabIndex={-1}` so `Shell`'s skip link moves **focus** and not
|
|
87
|
+
* merely the reading cursor — without it, Safari and Firefox scroll
|
|
88
|
+
* to `<main>` and leave the next Tab back in the header.
|
|
89
|
+
*
|
|
90
|
+
* `wrap-anywhere` so an unbroken string — a filesystem path, a
|
|
91
|
+
* hash, a URL — wraps instead of widening the page.
|
|
92
|
+
*
|
|
93
|
+
* **`anywhere`, not `break-word`, and the difference is the whole
|
|
94
|
+
* point.** Both break a long word onto the next line, so they look
|
|
95
|
+
* identical in a screenshot. Only `anywhere` also shrinks the
|
|
96
|
+
* element's **min-content size** — which is what a flex or grid
|
|
97
|
+
* item's automatic minimum resolves against, and therefore what
|
|
98
|
+
* decides whether the string can push its own column wider. With
|
|
99
|
+
* `break-word` the ink wraps while the intrinsic contribution
|
|
100
|
+
* stays the full length of the token, so the layout above it can
|
|
101
|
+
* still be forced open. This is the second of the two overflow
|
|
102
|
+
* sources found in plex-channels, and the nastier one: it produces
|
|
103
|
+
* **no overflowing element box at all**, so every
|
|
104
|
+
* `getBoundingClientRect()` in a test reads clean while the page
|
|
105
|
+
* scrolls sideways.
|
|
106
|
+
*
|
|
107
|
+
* It is the cheap half of the horizontal-scroll fix; the other
|
|
108
|
+
* halves are `minmax(0, 1fr)` and `overflow-x: clip` in `Shell`. A
|
|
109
|
+
* `<table>` is the one thing none of them can save: tables do not
|
|
110
|
+
* wrap, so a wide one goes in an `overflow-x-auto` wrapper, which
|
|
111
|
+
* `Main.mdx` shows.
|
|
112
|
+
*/
|
|
113
|
+
export const Main = ({ children, className, contentWidth, id, ...mainProps }) => {
|
|
114
|
+
const shell = useContext(ShellContext);
|
|
115
|
+
// Always called, never conditionally: a `Main` outside a `Shell`
|
|
116
|
+
// still needs an id an app can point its own skip link at.
|
|
117
|
+
const fallbackId = useUniqueId();
|
|
118
|
+
const maxInlineSize = toMaxInlineSize(contentWidth ??
|
|
119
|
+
shell?.contentWidth ??
|
|
120
|
+
DEFAULT_CONTENT_WIDTH);
|
|
121
|
+
return (_jsx("main", { ...mainProps, className: toClassName("col-start-1 row-start-3 min-w-0 md:col-start-2 md:row-start-2", className), id: id ?? shell?.mainId ?? fallbackId, tabIndex: -1, children: _jsx("div", { className: "@container mx-auto flex w-full min-w-0 flex-col gap-6 wrap-anywhere px-4 py-6 sm:px-6", style: { maxInlineSize }, children: children }) }));
|
|
122
|
+
};
|
|
123
|
+
//# sourceMappingURL=Main.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"Main.js","sourceRoot":"","sources":["../../src/Main/Main.tsx"],"names":[],"mappings":";AAAA,OAAO,EAAE,WAAW,EAAE,MAAM,oBAAoB,CAAA;AAChD,OAAO,EAGL,UAAU,GACX,MAAM,OAAO,CAAA;AAEd,OAAO,EAEL,qBAAqB,EACrB,eAAe,GAChB,MAAM,0BAA0B,CAAA;AACjC,OAAO,EAAE,YAAY,EAAE,MAAM,0BAA0B,CAAA;AACvD,OAAO,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAA;AAM/C;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAyGG;AACH,MAAM,CAAC,MAAM,IAAI,GAAG,CAAC,EACnB,QAAQ,EACR,SAAS,EACT,YAAY,EACZ,EAAE,EACF,GAAG,SAAS,EACF,EAAa,EAAE;IACzB,MAAM,KAAK,GAAG,UAAU,CAAC,YAAY,CAAC,CAAA;IAEtC,iEAAiE;IACjE,2DAA2D;IAC3D,MAAM,UAAU,GAAG,WAAW,EAAE,CAAA;IAEhC,MAAM,aAAa,GAAG,eAAe,CACnC,YAAY;QACV,KAAK,EAAE,YAAY;QACnB,qBAAqB,CACxB,CAAA;IAED,OAAO,CACL,kBACM,SAAS,EACb,SAAS,EAAE,WAAW,CACpB,+DAA+D,EAC/D,SAAS,CACV,EACD,EAAE,EAAE,EAAE,IAAI,KAAK,EAAE,MAAM,IAAI,UAAU,EACrC,QAAQ,EAAE,CAAC,CAAC,YAaZ,cACE,SAAS,EAAC,uFAAuF,EACjG,KAAK,EAAE,EAAE,aAAa,EAAE,YAEvB,QAAQ,GACL,GACD,CACR,CAAA;AACH,CAAC,CAAA"}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import type { ComponentPropsWithRef, ReactNode } from "react";
|
|
2
|
+
export type RailLandmark = "complementary" | "navigation";
|
|
3
|
+
export type RailSide = "end" | "start";
|
|
4
|
+
export type RailProps = ComponentPropsWithRef<"aside"> & {
|
|
5
|
+
label: string;
|
|
6
|
+
landmark?: RailLandmark;
|
|
7
|
+
side?: RailSide;
|
|
8
|
+
};
|
|
9
|
+
/**
|
|
10
|
+
* A side rail — the nav column, the filter panel, the drive list.
|
|
11
|
+
*
|
|
12
|
+
* `side` places it, `landmark` decides what it *is*, and `label`
|
|
13
|
+
* is required for a reason: two unnamed rails in one page are two
|
|
14
|
+
* landmarks of the same type with no way to tell them apart, which
|
|
15
|
+
* is axe's `landmark-unique` and, more to the point, is a screen
|
|
16
|
+
* reader announcing "complementary" twice. It is also what makes
|
|
17
|
+
* the rail scopeable — `getByRole("navigation", { name: "Sections" })`
|
|
18
|
+
* disambiguates a "Settings" link that also exists in the header.
|
|
19
|
+
*
|
|
20
|
+
* ## It collapses without duplicating itself
|
|
21
|
+
*
|
|
22
|
+
* Below `md` the shell is one column and the rail becomes a
|
|
23
|
+
* horizontally-scrolling strip above (`side="start"`) or below
|
|
24
|
+
* (`side="end"`) the content. Above it, the rail is a fixed-width
|
|
25
|
+
* column in the grid.
|
|
26
|
+
*
|
|
27
|
+
* **The same element, restyled — not a second copy.** The fleet's
|
|
28
|
+
* habit is to render the whole thing twice behind `hidden` and
|
|
29
|
+
* `lg:hidden`: mux-magic's `PageHeader` does it across two ~55-line
|
|
30
|
+
* blocks and mail-sifter's `TriageQueue` does it again. The cost is
|
|
31
|
+
* not bytes, it is that every control exists **twice in the DOM at
|
|
32
|
+
* every viewport** — so tests must be defensively scoped, an agent
|
|
33
|
+
* driving the page finds two of each button and cannot tell which
|
|
34
|
+
* is the visible one, and the two copies drift.
|
|
35
|
+
*
|
|
36
|
+
* A media query gets the whole job done here because only the
|
|
37
|
+
* rail's *position* changes, never its contents. plex-channels'
|
|
38
|
+
* `App.tsx` shows the pattern for when that is not true — a
|
|
39
|
+
* `useMediaQuery` over `matchMedia` through `useSyncExternalStore`,
|
|
40
|
+
* mounting exactly one instance and relocating it — and it is
|
|
41
|
+
* strictly more machinery for the same guarantee, so it is not what
|
|
42
|
+
* this component uses.
|
|
43
|
+
*
|
|
44
|
+
* The `md` breakpoint is 48rem, which is `screen.md`: Tailwind v4's
|
|
45
|
+
* default `--breakpoint-*` scale and this repo's `screen.*` scale
|
|
46
|
+
* carry the same five values, so `md:` and the token agree by
|
|
47
|
+
* construction rather than by coincidence maintained by hand.
|
|
48
|
+
*
|
|
49
|
+
* ## The narrow strip scrolls itself
|
|
50
|
+
*
|
|
51
|
+
* `overflow-x-auto` on the rail is what keeps a twelve-item nav
|
|
52
|
+
* from widening the *page* at 390px: the overflow is contained in
|
|
53
|
+
* an element that scrolls, which is the sanctioned answer for
|
|
54
|
+
* content that genuinely cannot wrap. `min-w-0` is on it for the
|
|
55
|
+
* same reason `Shell`'s middle track is `minmax(0, 1fr)` — a flex
|
|
56
|
+
* or grid child's automatic minimum is its content, and one long
|
|
57
|
+
* unbroken string otherwise pushes the whole layout out of the
|
|
58
|
+
* viewport.
|
|
59
|
+
*/
|
|
60
|
+
export declare const Rail: ({ children, className, label, landmark, side, ...railProps }: RailProps) => ReactNode;
|
|
61
|
+
//# sourceMappingURL=Rail.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"Rail.d.ts","sourceRoot":"","sources":["../../src/Rail/Rail.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,qBAAqB,EACrB,SAAS,EACV,MAAM,OAAO,CAAA;AAId,MAAM,MAAM,YAAY,GAAG,eAAe,GAAG,YAAY,CAAA;AAEzD,MAAM,MAAM,QAAQ,GAAG,KAAK,GAAG,OAAO,CAAA;AAEtC,MAAM,MAAM,SAAS,GAAG,qBAAqB,CAAC,OAAO,CAAC,GAAG;IACvD,KAAK,EAAE,MAAM,CAAA;IACb,QAAQ,CAAC,EAAE,YAAY,CAAA;IACvB,IAAI,CAAC,EAAE,QAAQ,CAAA;CAChB,CAAA;AAUD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAkDG;AACH,eAAO,MAAM,IAAI,GAAI,8DAOlB,SAAS,KAAG,SAkBd,CAAA"}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { toClassName } from "../toClassName.js";
|
|
3
|
+
const LANDMARK_ELEMENT = {
|
|
4
|
+
complementary: "aside",
|
|
5
|
+
navigation: "nav",
|
|
6
|
+
};
|
|
7
|
+
/**
|
|
8
|
+
* A side rail — the nav column, the filter panel, the drive list.
|
|
9
|
+
*
|
|
10
|
+
* `side` places it, `landmark` decides what it *is*, and `label`
|
|
11
|
+
* is required for a reason: two unnamed rails in one page are two
|
|
12
|
+
* landmarks of the same type with no way to tell them apart, which
|
|
13
|
+
* is axe's `landmark-unique` and, more to the point, is a screen
|
|
14
|
+
* reader announcing "complementary" twice. It is also what makes
|
|
15
|
+
* the rail scopeable — `getByRole("navigation", { name: "Sections" })`
|
|
16
|
+
* disambiguates a "Settings" link that also exists in the header.
|
|
17
|
+
*
|
|
18
|
+
* ## It collapses without duplicating itself
|
|
19
|
+
*
|
|
20
|
+
* Below `md` the shell is one column and the rail becomes a
|
|
21
|
+
* horizontally-scrolling strip above (`side="start"`) or below
|
|
22
|
+
* (`side="end"`) the content. Above it, the rail is a fixed-width
|
|
23
|
+
* column in the grid.
|
|
24
|
+
*
|
|
25
|
+
* **The same element, restyled — not a second copy.** The fleet's
|
|
26
|
+
* habit is to render the whole thing twice behind `hidden` and
|
|
27
|
+
* `lg:hidden`: mux-magic's `PageHeader` does it across two ~55-line
|
|
28
|
+
* blocks and mail-sifter's `TriageQueue` does it again. The cost is
|
|
29
|
+
* not bytes, it is that every control exists **twice in the DOM at
|
|
30
|
+
* every viewport** — so tests must be defensively scoped, an agent
|
|
31
|
+
* driving the page finds two of each button and cannot tell which
|
|
32
|
+
* is the visible one, and the two copies drift.
|
|
33
|
+
*
|
|
34
|
+
* A media query gets the whole job done here because only the
|
|
35
|
+
* rail's *position* changes, never its contents. plex-channels'
|
|
36
|
+
* `App.tsx` shows the pattern for when that is not true — a
|
|
37
|
+
* `useMediaQuery` over `matchMedia` through `useSyncExternalStore`,
|
|
38
|
+
* mounting exactly one instance and relocating it — and it is
|
|
39
|
+
* strictly more machinery for the same guarantee, so it is not what
|
|
40
|
+
* this component uses.
|
|
41
|
+
*
|
|
42
|
+
* The `md` breakpoint is 48rem, which is `screen.md`: Tailwind v4's
|
|
43
|
+
* default `--breakpoint-*` scale and this repo's `screen.*` scale
|
|
44
|
+
* carry the same five values, so `md:` and the token agree by
|
|
45
|
+
* construction rather than by coincidence maintained by hand.
|
|
46
|
+
*
|
|
47
|
+
* ## The narrow strip scrolls itself
|
|
48
|
+
*
|
|
49
|
+
* `overflow-x-auto` on the rail is what keeps a twelve-item nav
|
|
50
|
+
* from widening the *page* at 390px: the overflow is contained in
|
|
51
|
+
* an element that scrolls, which is the sanctioned answer for
|
|
52
|
+
* content that genuinely cannot wrap. `min-w-0` is on it for the
|
|
53
|
+
* same reason `Shell`'s middle track is `minmax(0, 1fr)` — a flex
|
|
54
|
+
* or grid child's automatic minimum is its content, and one long
|
|
55
|
+
* unbroken string otherwise pushes the whole layout out of the
|
|
56
|
+
* viewport.
|
|
57
|
+
*/
|
|
58
|
+
export const Rail = ({ children, className, label, landmark = "complementary", side = "start", ...railProps }) => {
|
|
59
|
+
const Element = LANDMARK_ELEMENT[landmark];
|
|
60
|
+
return (_jsx(Element, { ...railProps, "aria-label": label, className: toClassName("flex min-w-0 gap-2 overflow-x-auto border-border-subtle p-4 md:row-start-2 md:w-64 md:flex-col md:overflow-x-visible", side === "start"
|
|
61
|
+
? "row-start-2 border-b md:col-start-1 md:border-b-0 md:border-e"
|
|
62
|
+
: "row-start-4 border-t md:col-start-3 md:border-t-0 md:border-s", className), children: children }));
|
|
63
|
+
};
|
|
64
|
+
//# sourceMappingURL=Rail.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"Rail.js","sourceRoot":"","sources":["../../src/Rail/Rail.tsx"],"names":[],"mappings":";AAKA,OAAO,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAA;AAY/C,MAAM,gBAAgB,GAGlB;IACF,aAAa,EAAE,OAAO;IACtB,UAAU,EAAE,KAAK;CAClB,CAAA;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAkDG;AACH,MAAM,CAAC,MAAM,IAAI,GAAG,CAAC,EACnB,QAAQ,EACR,SAAS,EACT,KAAK,EACL,QAAQ,GAAG,eAAe,EAC1B,IAAI,GAAG,OAAO,EACd,GAAG,SAAS,EACF,EAAa,EAAE;IACzB,MAAM,OAAO,GAAG,gBAAgB,CAAC,QAAQ,CAAC,CAAA;IAE1C,OAAO,CACL,KAAC,OAAO,OACF,SAAS,gBACD,KAAK,EACjB,SAAS,EAAE,WAAW,CACpB,sHAAsH,EACtH,IAAI,KAAK,OAAO;YACd,CAAC,CAAC,+DAA+D;YACjE,CAAC,CAAC,+DAA+D,EACnE,SAAS,CACV,YAEA,QAAQ,GACD,CACX,CAAA;AACH,CAAC,CAAA"}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { type ComponentPropsWithRef, type ReactNode } from "react";
|
|
2
|
+
import { type ContentWidth } from "./contentWidth.ts";
|
|
3
|
+
export type ShellProps = ComponentPropsWithRef<"div"> & {
|
|
4
|
+
contentWidth?: ContentWidth;
|
|
5
|
+
skipLinkLabel?: string;
|
|
6
|
+
};
|
|
7
|
+
/**
|
|
8
|
+
* The page frame: one `Header`, up to two `Rail`s, one `Main`.
|
|
9
|
+
*
|
|
10
|
+
* **The largest duplicated surface in the fleet.** Ten of twelve UI
|
|
11
|
+
* repos hand-roll this, and three of them have a file literally
|
|
12
|
+
* called `AppShell.tsx` — mail-sifter's and points-market's headers
|
|
13
|
+
* are a **byte-identical** string
|
|
14
|
+
* (`sticky top-0 z-40 border-b border-border-subtle bg-surface-base/90 backdrop-blur-md`),
|
|
15
|
+
* arrived at independently, and board-games' is the same structure
|
|
16
|
+
* with `sticky` dropped. The remaining seven spell it as
|
|
17
|
+
* `PageChrome`, `Header`, `DashboardHeader`, `Layout` (twice),
|
|
18
|
+
* `TitleBar`, and inline in `App.tsx`.
|
|
19
|
+
*
|
|
20
|
+
* Three things fall out of owning it once, and each is a bug
|
|
21
|
+
* somewhere in that list today:
|
|
22
|
+
*
|
|
23
|
+
* - **A skip link.** All ten are missing one. A keyboard user
|
|
24
|
+
* tabs through the whole header on every page.
|
|
25
|
+
* - **One width, not two.** `Header` and `Main` read the same
|
|
26
|
+
* `contentWidth` through context — see `shellContext.ts` for
|
|
27
|
+
* the app where they disagree.
|
|
28
|
+
* - **Sticky that is actually sticky.** mux-magic's `PageHeader`
|
|
29
|
+
* is *described* as sticky and sets only `z-index`; it never
|
|
30
|
+
* sets `position: sticky`, so the z-index does nothing and the
|
|
31
|
+
* header scrolls away. `Header`'s `isSticky` sets both or
|
|
32
|
+
* neither.
|
|
33
|
+
*
|
|
34
|
+
* ## The layout is one grid, and the rails do not duplicate
|
|
35
|
+
*
|
|
36
|
+
* Three columns at `md` and up — `auto minmax(0, 1fr) auto`, so an
|
|
37
|
+
* absent rail is a **zero-width track** rather than a conditional
|
|
38
|
+
* subtree — collapsing to a single column below it, where the
|
|
39
|
+
* children simply stack in DOM order. The same `Rail` element is
|
|
40
|
+
* in the document at every width; nothing is rendered twice behind
|
|
41
|
+
* `hidden`/`lg:hidden`.
|
|
42
|
+
*
|
|
43
|
+
* That matters beyond tidiness. mux-magic's `PageHeader` renders
|
|
44
|
+
* its entire control set twice (lines 290–345 and 356–410) and
|
|
45
|
+
* mail-sifter's `TriageQueue` does the same, so every action
|
|
46
|
+
* exists twice in the DOM at every viewport: tests have to be
|
|
47
|
+
* defensively scoped with `within(toolbar)`, and any agent driving
|
|
48
|
+
* the page sees phantom controls it cannot click.
|
|
49
|
+
*
|
|
50
|
+
* `minmax(0, 1fr)` on the middle track is the horizontal-scroll
|
|
51
|
+
* fix, not a formality. A grid track's automatic minimum is
|
|
52
|
+
* `min-content`, so **one long unbroken string in `Main` widens
|
|
53
|
+
* the whole page** — which is the bug plex-channels' narrow view
|
|
54
|
+
* has today. rip-deck's `BayGrid` documents the same fix from the
|
|
55
|
+
* other end.
|
|
56
|
+
*
|
|
57
|
+
* There is no `gap`: an empty `auto` track is 0 wide, but a `gap`
|
|
58
|
+
* beside it is not, so a shell with no rails would carry two
|
|
59
|
+
* mystery gutters. `Rail` and `Main` bring their own padding.
|
|
60
|
+
*
|
|
61
|
+
* ## `overflow-x: clip` — the structural guarantee
|
|
62
|
+
*
|
|
63
|
+
* `minmax(0, 1fr)` fixes the tracks and `Main`'s `wrap-anywhere`
|
|
64
|
+
* fixes the text, but neither reaches the shape that actually
|
|
65
|
+
* broke plex-channels: **a closed panel parked at
|
|
66
|
+
* `transform: translateX(110%)`**. A transform does not take a box
|
|
67
|
+
* out of the document's scrollable overflow region — and neither
|
|
68
|
+
* does `visibility: hidden` — so an off-screen drawer that looks
|
|
69
|
+
* absent still makes the page scroll to it. It is a favourite
|
|
70
|
+
* fleet pattern and no amount of `min-width: 0` touches it.
|
|
71
|
+
*
|
|
72
|
+
* `clip`, not `hidden`, and the two are not interchangeable here:
|
|
73
|
+
* `overflow: hidden` creates a **scroll container**, which would
|
|
74
|
+
* become the sticky containing block for `Header` and freeze it in
|
|
75
|
+
* place. `overflow-x: clip` creates no scrollport, so `Header`
|
|
76
|
+
* keeps sticking to the viewport, and `overflow-y` stays `visible`
|
|
77
|
+
* — a pair that is legal for `clip` and illegal for `hidden`,
|
|
78
|
+
* which would compute the other axis to `auto`.
|
|
79
|
+
*
|
|
80
|
+
* The trade is deliberate: content wider than the frame is
|
|
81
|
+
* **clipped rather than reachable by scrolling sideways**, which is
|
|
82
|
+
* exactly the outcome asked for. Content that genuinely needs to
|
|
83
|
+
* be wider owns a scroll container of its own — the pattern
|
|
84
|
+
* `Main.mdx` documents for tables.
|
|
85
|
+
*
|
|
86
|
+
* ### `relative` is what makes the clip reach anything
|
|
87
|
+
*
|
|
88
|
+
* `overflow-x: clip` only clips descendants whose **containing
|
|
89
|
+
* block chain passes through the clipping element**. An absolutely
|
|
90
|
+
* positioned element with no positioned ancestor resolves against
|
|
91
|
+
* the *initial* containing block instead, so it sails straight
|
|
92
|
+
* past every `overflow` in the tree and lands its overflow on
|
|
93
|
+
* `documentElement` — where `document.body.scrollWidth` still
|
|
94
|
+
* reads clean and only `document.documentElement.scrollWidth`
|
|
95
|
+
* shows it.
|
|
96
|
+
*
|
|
97
|
+
* That is not hypothetical: it is measured. Before this
|
|
98
|
+
* `relative`, the parked-drawer fixture reported `shellScroll:
|
|
99
|
+
* 390`, `bodyScroll: 390` and `docScroll: 742` at a 390px
|
|
100
|
+
* viewport — the shell dutifully clipping a box it had no
|
|
101
|
+
* authority over.
|
|
102
|
+
*
|
|
103
|
+
* The obvious candidate to own it, `Main`'s content column, does
|
|
104
|
+
* **not**: `container-type: inline-size` computes `contain` to
|
|
105
|
+
* `none` in Chromium and establishes no containing block for
|
|
106
|
+
* absolute positioning, which is measured too — the drawer's
|
|
107
|
+
* `offsetParent` was `BODY`. So `Shell` takes the job, which is
|
|
108
|
+
* also the honest place for it: the frame is what an app's parked
|
|
109
|
+
* chrome is parked against.
|
|
110
|
+
*/
|
|
111
|
+
export declare const Shell: ({ children, className, contentWidth, skipLinkLabel, ...divProps }: ShellProps) => ReactNode;
|
|
112
|
+
//# sourceMappingURL=Shell.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"Shell.d.ts","sourceRoot":"","sources":["../../src/Shell/Shell.tsx"],"names":[],"mappings":"AACA,OAAO,EACL,KAAK,qBAAqB,EAC1B,KAAK,SAAS,EAEf,MAAM,OAAO,CAAA;AAGd,OAAO,EACL,KAAK,YAAY,EAElB,MAAM,mBAAmB,CAAA;AAG1B,MAAM,MAAM,UAAU,GAAG,qBAAqB,CAAC,KAAK,CAAC,GAAG;IACtD,YAAY,CAAC,EAAE,YAAY,CAAA;IAC3B,aAAa,CAAC,EAAE,MAAM,CAAA;CACvB,CAAA;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAuGG;AACH,eAAO,MAAM,KAAK,GAAI,mEAMnB,UAAU,KAAG,SAwCf,CAAA"}
|