@juwel-development/design-system 3.9.1 → 3.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 -298
- package/dist/design-system.js +1026 -582
- package/dist/index.css +1 -1
- package/dist/types/Arrangement/ColumnLayout/ColumnLayout.d.ts +92 -0
- package/dist/types/Arrangement/ColumnLayout/ColumnLayoutCompositionError.d.ts +3 -0
- package/dist/types/Arrangement/ColumnLayout/ColumnLayoutConfigurationError.d.ts +3 -0
- package/dist/types/Arrangement/FieldRow/FieldRow.d.ts +87 -0
- package/dist/types/Arrangement/FieldRow/FieldRowCompositionError.d.ts +3 -0
- package/dist/types/Arrangement/FieldRow/FieldRowConfigurationError.d.ts +3 -0
- package/dist/types/Arrangement/FieldRow/IControlEdge.d.ts +6 -0
- package/dist/types/Arrangement/FieldRow/alignControlEdges.d.ts +7 -0
- package/dist/types/Display/Box/Box.d.ts +41 -0
- package/dist/types/Display/Brandmark/Brandmark.d.ts +1 -1
- package/dist/types/Display/DefinitionList/DefinitionList.d.ts +59 -9
- package/dist/types/Display/DefinitionList/DefinitionListConfigurationError.d.ts +3 -0
- package/dist/types/Display/Figure/Figure.d.ts +2 -2
- package/dist/types/Display/Icon/Icon.d.ts +26 -0
- package/dist/types/Display/Table/Table.d.ts +118 -8
- package/dist/types/Display/Table/TableConfigurationError.d.ts +3 -0
- package/dist/types/Display/Typography/Eyebrow/Eyebrow.d.ts +1 -1
- package/dist/types/Display/Typography/H1/H1.d.ts +3 -2
- package/dist/types/Display/Typography/H2/H2.d.ts +3 -2
- package/dist/types/Display/Typography/H3/H3.d.ts +3 -2
- package/dist/types/Display/Typography/H4/H4.d.ts +3 -2
- package/dist/types/Display/Typography/H5/H5.d.ts +3 -2
- package/dist/types/Display/Typography/H6/H6.d.ts +3 -2
- package/dist/types/Display/Typography/Note/Note.d.ts +1 -1
- package/dist/types/Display/Typography/P/P.d.ts +3 -2
- package/dist/types/Display/Typography/Prose/Prose.d.ts +1 -1
- package/dist/types/Interaction/Button/Button.d.ts +23 -3
- package/dist/types/Interaction/Tabs/Tabs.d.ts +31 -6
- package/dist/types/Layout/Header/Header.d.ts +67 -8
- package/dist/types/Layout/ScrollContainer/ScrollContainer.d.ts +37 -0
- package/dist/types/Layout/Section/Section.d.ts +1 -1
- package/dist/types/Layout/Sidebar/Sidebar.d.ts +4 -0
- package/dist/types/Theme/Palette.d.ts +31 -7
- package/dist/types/index.d.ts +6 -0
- package/package.json +1 -1
- package/src/Arrangement/ColumnLayout/ColumnLayout.tsx +249 -0
- package/src/Arrangement/ColumnLayout/ColumnLayoutCompositionError.ts +8 -0
- package/src/Arrangement/ColumnLayout/ColumnLayoutConfigurationError.ts +6 -0
- package/src/Arrangement/FieldRow/FieldRow.tsx +313 -0
- package/src/Arrangement/FieldRow/FieldRowCompositionError.ts +6 -0
- package/src/Arrangement/FieldRow/FieldRowConfigurationError.ts +6 -0
- package/src/Arrangement/FieldRow/IControlEdge.ts +6 -0
- package/src/Arrangement/FieldRow/alignControlEdges.ts +35 -0
- package/src/Display/Box/Box.tsx +77 -0
- package/src/Display/Checklist/Checklist.tsx +1 -1
- package/src/Display/DefinitionList/DefinitionList.tsx +164 -22
- package/src/Display/DefinitionList/DefinitionListConfigurationError.ts +6 -0
- package/src/Display/Icon/Icon.tsx +63 -0
- package/src/Display/Table/Table.tsx +434 -44
- package/src/Display/Table/TableConfigurationError.ts +6 -0
- package/src/Display/Typography/H1/H1.tsx +3 -2
- package/src/Display/Typography/H2/H2.tsx +3 -2
- package/src/Display/Typography/H3/H3.tsx +3 -2
- package/src/Display/Typography/H4/H4.tsx +3 -2
- package/src/Display/Typography/H5/H5.tsx +3 -2
- package/src/Display/Typography/H6/H6.tsx +3 -2
- package/src/Display/Typography/P/P.tsx +3 -2
- package/src/Display/Typography/Prose/Prose.tsx +3 -3
- package/src/Interaction/Button/Button.tsx +45 -17
- package/src/Interaction/Input/Input.tsx +1 -1
- package/src/Interaction/MultiSelect/MultiSelect.tsx +1 -1
- package/src/Interaction/NumberInput/NumberInput.tsx +1 -1
- package/src/Interaction/Select/Select.tsx +1 -1
- package/src/Interaction/Tabs/Tabs.tsx +38 -11
- package/src/Interaction/TextArea/TextArea.tsx +1 -1
- package/src/Layout/Dialog/Dialog.tsx +4 -3
- package/src/Layout/Header/Header.tsx +139 -39
- package/src/Layout/PageHead/PageHead.tsx +6 -5
- package/src/Layout/ScrollContainer/ScrollContainer.tsx +149 -0
- package/src/Layout/Sidebar/Sidebar.tsx +8 -4
- package/src/Theme/Palette.ts +37 -9
- package/src/Theme/renderTokens.ts +101 -5
- package/src/index.ts +6 -0
- package/src/tokens.css +68 -4
- package/src/tokens.dark.css +66 -4
- package/src/tokens.light.css +64 -2
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { VariantProps } from 'class-variance-authority';
|
|
2
|
+
import { type FunctionComponent, type ReactNode } from 'react';
|
|
3
|
+
declare const scrollContainer: (props?: ({
|
|
4
|
+
axis?: "both" | "horizontal" | "vertical" | null | undefined;
|
|
5
|
+
} & import("class-variance-authority/types").ClassProp) | undefined) => string;
|
|
6
|
+
export interface IScrollContainerProps extends VariantProps<typeof scrollContainer> {
|
|
7
|
+
/** The group's accessible name while it can be scrolled. Required: a tab stop with no name is a
|
|
8
|
+
* mystery to a screen reader. The consuming app words it, usually after the heading above. */
|
|
9
|
+
ariaLabel: string;
|
|
10
|
+
children?: ReactNode;
|
|
11
|
+
testId?: string;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Makes overflowing content reachable along the chosen axes, within the space its parent allocates.
|
|
15
|
+
* It owns the scrolling; the consumer owns the content and the allocation of space.
|
|
16
|
+
*
|
|
17
|
+
* @Guarantees — enforced on every render
|
|
18
|
+
* - Scrolls only an enabled axis and only once content overflows it; overflow on a disabled axis is
|
|
19
|
+
* clipped. Content wraps as it would anywhere else.
|
|
20
|
+
* - Keyboard-reachable - a named `group` with a tab stop - exactly while an enabled axis overflows,
|
|
21
|
+
* static content included; no stop and no group while everything fits. Scrollability is re-read on
|
|
22
|
+
* resize and on content change without moving focus.
|
|
23
|
+
* - Native scrolling: scrollbars, wheel, touch and the browser's own arrow/page keys on the focused
|
|
24
|
+
* container. No key of a control inside it is intercepted and focus is never trapped.
|
|
25
|
+
* - Invents no bound: no height, width or viewport unit of its own. The only space it adds is the
|
|
26
|
+
* focus ring's room around its content, so a focusable child flush with its edge - a Table's
|
|
27
|
+
* scroll region, a button - keeps a visible ring instead of having it clipped at the edge.
|
|
28
|
+
*
|
|
29
|
+
* @CallerMustEnsure
|
|
30
|
+
* - The parent allocates finite space on every axis the container should scroll - a sized box, or a
|
|
31
|
+
* flex/grid item allowed to shrink (`flex: 1 1 0; min-height: 0` in a column). Under an unbounded
|
|
32
|
+
* parent it simply grows with its content, as any block would.
|
|
33
|
+
* - Content fits a disabled axis. Clipping is not a way to hide essential content or controls.
|
|
34
|
+
* - `ariaLabel` is wording the viewer would recognise, typically the heading above the content.
|
|
35
|
+
*/
|
|
36
|
+
export declare const ScrollContainer: FunctionComponent<IScrollContainerProps>;
|
|
37
|
+
export {};
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { VariantProps } from 'class-variance-authority';
|
|
2
2
|
import type { FunctionComponent, ReactNode } from 'react';
|
|
3
3
|
declare const section: (props?: ({
|
|
4
|
-
bleed?: "
|
|
4
|
+
bleed?: "inset" | "full" | null | undefined;
|
|
5
5
|
} & import("class-variance-authority/types").ClassProp) | undefined) => string;
|
|
6
6
|
export interface ISectionProps extends VariantProps<typeof section> {
|
|
7
7
|
/** Names the section as a region a screen-reader user can jump to. Omit for an ordinary section: an
|
|
@@ -41,6 +41,10 @@ export interface ISidebarContentProps {
|
|
|
41
41
|
* - Activating the active entry or an inert one emits nothing; no render emits anything. Entries are
|
|
42
42
|
* non-submitting `type="button"` buttons with native Tab/Enter/Space behaviour - no tabs/menu model.
|
|
43
43
|
* - An inert entry stays visible but muted and disabled, so Tab skips it and activation is inert too.
|
|
44
|
+
* - A label is rendered in full inside its entry, in either arrangement: a phrase wraps at its spaces
|
|
45
|
+
* and a word wider than the track breaks within itself. Nothing is truncated, renamed or hidden
|
|
46
|
+
* behind a tooltip, no horizontal scrolling is introduced, and a translated label neither widens
|
|
47
|
+
* the 12rem track nor displaces the content.
|
|
44
48
|
* - At and above 64rem the nav is a fixed 12rem track, sticky at the top of the scrolling area with no
|
|
45
49
|
* assumed top-bar offset, capped to the screen/scrolling-area height with independent entry scrolling.
|
|
46
50
|
* Content sits beside it in `minmax(0,1fr)`, so wide content cannot displace the track.
|
|
@@ -9,7 +9,8 @@
|
|
|
9
9
|
*
|
|
10
10
|
* Light and dark are two complete sets of the same roles rather than a set of `dark:` overrides
|
|
11
11
|
* scattered through the components. A component therefore carries no dark-mode classes at all:
|
|
12
|
-
* swapping the `.dark` class re-points the variables underneath it.
|
|
12
|
+
* swapping the `.dark` class re-points the variables underneath it. Complete means `Required`: the
|
|
13
|
+
* two roles the type marks optional are optional for a consumer's palette object, never here.
|
|
13
14
|
*
|
|
14
15
|
* The values below are still depot-tracker's brand (primary is its violet). They are carried over
|
|
15
16
|
* so nothing changed visually during the extraction - a starting point to replace, not a decision.
|
|
@@ -113,8 +114,28 @@ export type PaletteTokens = {
|
|
|
113
114
|
* constraint stated on `success`. */
|
|
114
115
|
warning: string;
|
|
115
116
|
/** The error status tone. Carries the general status-tone contract and 4.5:1-against-`surface`
|
|
116
|
-
* constraint stated on `success`, plus the depletion-path constraint stated on `meterFill`.
|
|
117
|
+
* constraint stated on `success`, plus the depletion-path constraint stated on `meterFill`. It is
|
|
118
|
+
* also Button's destructive fill (#119), identified like `primary` by the fill alone: at least 3:1
|
|
119
|
+
* against `surface` in the same theme, `errorHover` included - which the 4.5:1 text floor already
|
|
120
|
+
* clears - and constrained from the other side by the ink it carries, stated on `errorForeground`.
|
|
121
|
+
* See docs/adr/0011-status-tones-are-general-roles.md, Amendments. */
|
|
117
122
|
error: string;
|
|
123
|
+
/** The destructive fill's hover step. Constraint (WCAG 2.2 SC 1.4.11): at least 3:1 against
|
|
124
|
+
* `surface` in the same theme, and at least 4.5:1 against `errorForeground`, since a hovered
|
|
125
|
+
* control has to stay identifiable and readable too. Not a status tone: it carries no text of its
|
|
126
|
+
* own and the status-tone text floor does not apply to it. Optional in the type so a palette
|
|
127
|
+
* object written before #119 keeps compiling: the shipped stylesheet declares `--color-error-hover`,
|
|
128
|
+
* and a theme that omits the role inherits that default - which pairs with the shipped `error`,
|
|
129
|
+
* so a theme that re-points `error` re-points this too. */
|
|
130
|
+
errorHover?: string;
|
|
131
|
+
/** Text and icons drawn on top of `error` and `errorHover`. Constraint (WCAG 2.2 SC 1.4.3): at
|
|
132
|
+
* least 4.5:1 against both in the same theme, hover included. Optional in the type for the same
|
|
133
|
+
* reason as `errorHover`, with the same obligation: the shipped default is the ink for the
|
|
134
|
+
* shipped `error`. The ink inverts with the theme as `primaryForeground` does, and in light it is
|
|
135
|
+
* pure white rather than slate-50 because the shipped `error` sits exactly on the 4.5:1 floor
|
|
136
|
+
* against white (4.501:1) and slate-50 measures 4.30:1 - under it. Not required against
|
|
137
|
+
* `disabled`, which SC 1.4.3 exempts. */
|
|
138
|
+
errorForeground?: string;
|
|
118
139
|
/** The informational status tone. Carries the general status-tone contract and
|
|
119
140
|
* 4.5:1-against-`surface` constraint stated on `success`. */
|
|
120
141
|
info: string;
|
|
@@ -138,7 +159,7 @@ export type PaletteTokens = {
|
|
|
138
159
|
* against `#0f172a`, the dark set's own `surface`, sky-600 lands at 4.36 and fails. It also buys
|
|
139
160
|
* a light theme with white text on one button and black on the one beside it.
|
|
140
161
|
*/
|
|
141
|
-
export declare const light: PaletteTokens
|
|
162
|
+
export declare const light: Required<PaletteTokens>;
|
|
142
163
|
/**
|
|
143
164
|
* Dark reverses the direction the fills step: it sits higher up the ramp than light does and hovers
|
|
144
165
|
* *up* into lighter still, where light sits lower and hovers down. That is what the components' old
|
|
@@ -150,8 +171,11 @@ export declare const light: PaletteTokens;
|
|
|
150
171
|
* under its darker ones. The pair is the two ends of the one neutral ramp the rest of the palette is
|
|
151
172
|
* already built from - `#f8fafc` is slate-50, `surface` slate-900, `muted` slate-500 - rather than a
|
|
152
173
|
* new colour arriving for a single job. `#020617` is also the lightest slate step that still admits
|
|
153
|
-
* violet-500
|
|
154
|
-
*
|
|
155
|
-
*
|
|
174
|
+
* violet-500: slate-900 draws 4.22 against it and fails. The `secondary` pair sits one ramp step
|
|
175
|
+
* above where it first shipped - sky-500 at rest, sky-400 on hover, where it was sky-600 and sky-500 -
|
|
176
|
+
* because #119 made `secondary` an ink as well as a fill: the outlined Button draws its text and edge
|
|
177
|
+
* in it, and sky-600 measures 4.36:1 against this surface, under the 4.5:1 text floor (now 6.44:1
|
|
178
|
+
* and 8.33:1, both still clearing `secondaryForeground` at 7.28:1 and 9.42:1). Why the ink follows
|
|
179
|
+
* the theme at all, and the two routes rejected in getting here: see the light set.
|
|
156
180
|
*/
|
|
157
|
-
export declare const dark: PaletteTokens
|
|
181
|
+
export declare const dark: Required<PaletteTokens>;
|
package/dist/types/index.d.ts
CHANGED
|
@@ -1,13 +1,18 @@
|
|
|
1
1
|
import './styles.css';
|
|
2
2
|
export { Cluster } from 'Arrangement/Cluster/Cluster';
|
|
3
|
+
export { ColumnLayout } from 'Arrangement/ColumnLayout/ColumnLayout';
|
|
4
|
+
export { FieldRow } from 'Arrangement/FieldRow/FieldRow';
|
|
3
5
|
export { Stack } from 'Arrangement/Stack/Stack';
|
|
6
|
+
export { Box } from 'Display/Box/Box';
|
|
4
7
|
export { Brandmark } from 'Display/Brandmark/Brandmark';
|
|
5
8
|
export { Checklist } from 'Display/Checklist/Checklist';
|
|
6
9
|
export { Collection } from 'Display/Collection/Collection';
|
|
7
10
|
export { DefinitionList } from 'Display/DefinitionList/DefinitionList';
|
|
8
11
|
export { Figure } from 'Display/Figure/Figure';
|
|
12
|
+
export { Icon } from 'Display/Icon/Icon';
|
|
9
13
|
export { Meter } from 'Display/Meter/Meter';
|
|
10
14
|
export { Rail } from 'Display/Rail/Rail';
|
|
15
|
+
export type { TableColumnAllocation } from 'Display/Table/Table';
|
|
11
16
|
export { Table } from 'Display/Table/Table';
|
|
12
17
|
export { Eyebrow } from 'Display/Typography/Eyebrow/Eyebrow';
|
|
13
18
|
export { H1 } from 'Display/Typography/H1/H1';
|
|
@@ -37,6 +42,7 @@ export { Form } from 'Layout/Form/Form';
|
|
|
37
42
|
export { Header } from 'Layout/Header/Header';
|
|
38
43
|
export { Hero } from 'Layout/Hero/Hero';
|
|
39
44
|
export { PageHead } from 'Layout/PageHead/PageHead';
|
|
45
|
+
export { ScrollContainer } from 'Layout/ScrollContainer/ScrollContainer';
|
|
40
46
|
export { Section } from 'Layout/Section/Section';
|
|
41
47
|
export { Sidebar } from 'Layout/Sidebar/Sidebar';
|
|
42
48
|
export type { PaletteTokens } from 'Theme/Palette';
|
package/package.json
CHANGED
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
import type { VariantProps } from 'class-variance-authority';
|
|
2
|
+
import { cva } from 'class-variance-authority';
|
|
3
|
+
import {
|
|
4
|
+
Children,
|
|
5
|
+
type CSSProperties,
|
|
6
|
+
createContext,
|
|
7
|
+
Fragment,
|
|
8
|
+
type FunctionComponent,
|
|
9
|
+
isValidElement,
|
|
10
|
+
type ReactElement,
|
|
11
|
+
type ReactNode,
|
|
12
|
+
useContext,
|
|
13
|
+
} from 'react';
|
|
14
|
+
import { ColumnLayoutCompositionError } from './ColumnLayoutCompositionError';
|
|
15
|
+
import { ColumnLayoutConfigurationError } from './ColumnLayoutConfigurationError';
|
|
16
|
+
|
|
17
|
+
// A wrapping flex row that aligns its tracks to the top. The gap variant also publishes the chosen
|
|
18
|
+
// role as `--column-layout-gap`, which the threshold below reads, so the switch and the paint take
|
|
19
|
+
// the role from one declaration. Nothing here measures: the arrangement needs no JavaScript.
|
|
20
|
+
const columnLayout = cva('flex flex-wrap items-start', {
|
|
21
|
+
variants: {
|
|
22
|
+
gap: {
|
|
23
|
+
stack:
|
|
24
|
+
'gap-[var(--space-stack)] [--column-layout-gap:var(--space-stack)]',
|
|
25
|
+
region:
|
|
26
|
+
'gap-[var(--space-region)] [--column-layout-gap:var(--space-region)]',
|
|
27
|
+
},
|
|
28
|
+
},
|
|
29
|
+
defaultVariants: { gap: 'region' },
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
// The all-or-one switch, after Heydon Pickering's "Holy Albatross": `100%` is the Root's width W
|
|
33
|
+
// and the threshold T sits on the Root, so W >= T clamps the basis to 0 (one line, grown by weight)
|
|
34
|
+
// and W < T amplifies the shortfall past 100% (a line each). The factor turns a 1/64px shortfall,
|
|
35
|
+
// Chrome's layout grain, into a full switch; `0px` and `100%` are the two states, not measurements.
|
|
36
|
+
const columnLayoutColumn = cva(
|
|
37
|
+
'min-w-0 grow-[var(--column-layout-weight)] basis-[clamp(0px,(var(--column-layout-threshold)-100%)*1000000,100%)]',
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
// React's CSSProperties is closed over known properties; the two custom properties the recipes
|
|
41
|
+
// read are declared here so the style objects stay typed without an assertion.
|
|
42
|
+
type ColumnLayoutRootStyle = CSSProperties & {
|
|
43
|
+
'--column-layout-threshold': string;
|
|
44
|
+
};
|
|
45
|
+
type ColumnLayoutColumnStyle = CSSProperties & {
|
|
46
|
+
'--column-layout-weight': number;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
/** The name of a CSS custom property, as written in a stylesheet: `--main-column-min-width`. */
|
|
50
|
+
type MinWidthToken = `--${string}`;
|
|
51
|
+
|
|
52
|
+
type Track = { weight: number; minWidth: MinWidthToken };
|
|
53
|
+
|
|
54
|
+
// Granted only to a Column the Root counted: the Root wraps each direct Column child in it, and a
|
|
55
|
+
// Column revokes it for its own descendants. Anything else is outside the composition.
|
|
56
|
+
const ColumnLayoutMembership = createContext<boolean>(false);
|
|
57
|
+
|
|
58
|
+
// The ident grammar CSS gives a custom property name, restricted to ASCII so a `var()`, a length,
|
|
59
|
+
// a space or a brace can never ride in on the name.
|
|
60
|
+
const TOKEN_NAME = /^--[A-Za-z0-9_-]+$/;
|
|
61
|
+
|
|
62
|
+
const toTrack = (
|
|
63
|
+
element: ReactElement<IColumnLayoutColumnProps>,
|
|
64
|
+
index: number,
|
|
65
|
+
): Track => {
|
|
66
|
+
const { weight, minWidth } = element.props;
|
|
67
|
+
if (!Number.isFinite(weight) || weight <= 0) {
|
|
68
|
+
throw new ColumnLayoutConfigurationError(
|
|
69
|
+
`column ${index + 1} needs a positive finite weight (got ${weight})`,
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
if (!TOKEN_NAME.test(minWidth)) {
|
|
73
|
+
throw new ColumnLayoutConfigurationError(
|
|
74
|
+
`column ${index + 1} needs a custom-property name such as --main-column-min-width for minWidth (got ${JSON.stringify(minWidth)})`,
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
return { weight, minWidth };
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
// The documented threshold, `(n - 1) * g + max(m_i * S / w_i)`, left to the browser to resolve
|
|
81
|
+
// so a theme re-pointing a minimum - or the gap - moves it with no script in between.
|
|
82
|
+
const thresholdOf = (tracks: readonly Track[]): string => {
|
|
83
|
+
const total = tracks.reduce((sum, track) => sum + track.weight, 0);
|
|
84
|
+
const shares = tracks.map(
|
|
85
|
+
(track) => `var(${track.minWidth}) * ${total / track.weight}`,
|
|
86
|
+
);
|
|
87
|
+
return `calc(${tracks.length - 1} * var(--column-layout-gap) + max(${shares.join(', ')}))`;
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
const isFragment = (
|
|
91
|
+
node: ReactNode,
|
|
92
|
+
): node is ReactElement<{ children?: ReactNode }> =>
|
|
93
|
+
isValidElement(node) && node.type === Fragment;
|
|
94
|
+
|
|
95
|
+
export interface IColumnLayoutRootProps
|
|
96
|
+
extends VariantProps<typeof columnLayout> {
|
|
97
|
+
/** The columns, as `ColumnLayout.Column` elements: direct children, or arrays and fragments of
|
|
98
|
+
* them. A conditional that renders nothing reserves nothing. */
|
|
99
|
+
children?: ReactNode;
|
|
100
|
+
testId?: string;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export interface IColumnLayoutColumnProps {
|
|
104
|
+
/** This column's share of the width left after the gaps, relative to its siblings' weights: a
|
|
105
|
+
* positive finite number. `2` beside `1` is a two-thirds/one-third arrangement. */
|
|
106
|
+
weight: number;
|
|
107
|
+
/** The name of the consumer's custom property holding this column's minimum readable width,
|
|
108
|
+
* such as `--main-column-min-width`. A token name, never a length or a `var()`: the consumer
|
|
109
|
+
* declares it in the theme with a nonnegative CSS length. */
|
|
110
|
+
minWidth: MinWidthToken;
|
|
111
|
+
/** The column's content. Rendered unmodified: the column imposes no anatomy. */
|
|
112
|
+
children?: ReactNode;
|
|
113
|
+
testId?: string;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const isColumn = (
|
|
117
|
+
node: ReactNode,
|
|
118
|
+
): node is ReactElement<IColumnLayoutColumnProps> =>
|
|
119
|
+
isValidElement(node) && node.type === ColumnLayoutColumn;
|
|
120
|
+
|
|
121
|
+
const collectColumns = (
|
|
122
|
+
children: ReactNode,
|
|
123
|
+
): ReactElement<IColumnLayoutColumnProps>[] =>
|
|
124
|
+
Children.toArray(children).flatMap((child) => {
|
|
125
|
+
if (isFragment(child)) {
|
|
126
|
+
return collectColumns(child.props.children);
|
|
127
|
+
}
|
|
128
|
+
return isColumn(child) ? [child] : [];
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
// Marks every counted Column and nothing else, keeping React's own keys for each position so a
|
|
132
|
+
// conditional column appearing later neither remounts its siblings nor moves their focus.
|
|
133
|
+
const markColumns = (children: ReactNode): ReactNode =>
|
|
134
|
+
Children.map(children, (child) => {
|
|
135
|
+
if (isFragment(child)) {
|
|
136
|
+
return <Fragment>{markColumns(child.props.children)}</Fragment>;
|
|
137
|
+
}
|
|
138
|
+
if (isColumn(child)) {
|
|
139
|
+
return (
|
|
140
|
+
<ColumnLayoutMembership.Provider value={true}>
|
|
141
|
+
{child}
|
|
142
|
+
</ColumnLayoutMembership.Provider>
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
return child;
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
const ColumnLayoutRoot: FunctionComponent<IColumnLayoutRootProps> = ({
|
|
149
|
+
gap,
|
|
150
|
+
children,
|
|
151
|
+
testId,
|
|
152
|
+
}) => {
|
|
153
|
+
const tracks = collectColumns(children).map(toTrack);
|
|
154
|
+
const style: ColumnLayoutRootStyle | undefined =
|
|
155
|
+
tracks.length === 0
|
|
156
|
+
? undefined
|
|
157
|
+
: { '--column-layout-threshold': thresholdOf(tracks) };
|
|
158
|
+
return (
|
|
159
|
+
<div className={columnLayout({ gap })} style={style} data-testid={testId}>
|
|
160
|
+
{markColumns(children)}
|
|
161
|
+
</div>
|
|
162
|
+
);
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
const ColumnLayoutColumn: FunctionComponent<IColumnLayoutColumnProps> = ({
|
|
166
|
+
weight,
|
|
167
|
+
children,
|
|
168
|
+
testId,
|
|
169
|
+
}) => {
|
|
170
|
+
const isCounted = useContext(ColumnLayoutMembership);
|
|
171
|
+
if (!isCounted) {
|
|
172
|
+
throw new ColumnLayoutCompositionError();
|
|
173
|
+
}
|
|
174
|
+
const style: ColumnLayoutColumnStyle = { '--column-layout-weight': weight };
|
|
175
|
+
return (
|
|
176
|
+
<div className={columnLayoutColumn()} style={style} data-testid={testId}>
|
|
177
|
+
<ColumnLayoutMembership.Provider value={false}>
|
|
178
|
+
{children}
|
|
179
|
+
</ColumnLayoutMembership.Provider>
|
|
180
|
+
</div>
|
|
181
|
+
);
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* An arrangement of weighted columns that becomes one column when the space it is given cannot
|
|
186
|
+
* satisfy every column's minimum at the requested proportions. It owns the arrangement and nothing
|
|
187
|
+
* else - no landmark, no band, no join, no gutter, no fill - and takes no outer space, so whatever
|
|
188
|
+
* holds it owns the rhythm around it. `Stack`'s `split` keeps its own viewport-keyed contract; this
|
|
189
|
+
* one answers to the width of its holder (docs/adr/0008, Amendments).
|
|
190
|
+
*
|
|
191
|
+
* @Guarantees — enforced on every render
|
|
192
|
+
* - `Root` renders a `div` with no landmark role, no heading and no margin. Each `Column` is a
|
|
193
|
+
* `div` directly inside it, in the order written, so DOM, reading and keyboard order are the
|
|
194
|
+
* order of the children in both arrangements, and no column is ever remounted merely because
|
|
195
|
+
* the arrangement changed: resizing keeps every column's state and focus.
|
|
196
|
+
* - In the horizontal arrangement the gaps are taken off the Root's content width and the rest is
|
|
197
|
+
* divided in proportion to the weights: `2`, `1`, `1` is one half and two quarters of it. The
|
|
198
|
+
* columns align at the top and keep their own heights.
|
|
199
|
+
* - The row holds only while every proportional share is at least its own minimum. The moment one
|
|
200
|
+
* falls short, every column takes a line of its own and fills the Root's width - a column
|
|
201
|
+
* narrower than its minimum included: the minimum decides the switch and never floors a width.
|
|
202
|
+
* There is no in-between: no column wraps alone, no share is clamped, nothing is redistributed to
|
|
203
|
+
* postpone the switch. For n columns, gap g, total weight S and minimums m_i the row fits at and
|
|
204
|
+
* above `(n - 1) * g + max(m_i * S / w_i)`.
|
|
205
|
+
* - The space measured is the Root's own width, never the viewport, so two instances on one page
|
|
206
|
+
* switch independently, and a narrow holder on a wide screen stacks.
|
|
207
|
+
* - Every minimum is read from the theme as a CSS length each time layout runs, so re-pointing a
|
|
208
|
+
* token - in a theme class, a media query, or a scope around the Root - moves the threshold
|
|
209
|
+
* with it, and a font-relative length moves it as the type does.
|
|
210
|
+
* - `gap` selects which space role separates the columns, across the row and between the stacked
|
|
211
|
+
* lines alike: `region` (the default), the gap between groups of blocks, or `stack`, the gap
|
|
212
|
+
* between siblings within one block. Nothing else.
|
|
213
|
+
* - Only rendered columns count: a conditional that renders nothing reserves neither width nor
|
|
214
|
+
* gap, a single column fills the width, and an empty Root holds no tracks and no gaps.
|
|
215
|
+
* Allocations follow every change of columns, weights, gap, holder width or theme.
|
|
216
|
+
* - A column adds no scrolling, truncation or overflow treatment of its own and does not widen its
|
|
217
|
+
* track for its content: the content keeps whatever wrapping or scrolling contract it has.
|
|
218
|
+
* - Every value it emits is a role the token layer or the consumer's theme already names; the
|
|
219
|
+
* only literals are the switch's two states.
|
|
220
|
+
* - A non-positive or non-finite `weight`, or a `minWidth` that is not a custom-property name,
|
|
221
|
+
* throws {@link ColumnLayoutConfigurationError}; a `Column` that is not a direct child of a
|
|
222
|
+
* `Root` - loose, nested in another column, or reached through a wrapping component - throws
|
|
223
|
+
* {@link ColumnLayoutCompositionError}. Both are programmer errors, raised loud and early.
|
|
224
|
+
*
|
|
225
|
+
* @CallerMustEnsure — the component cannot see these and does not check them
|
|
226
|
+
* - Every `minWidth` token is declared, on the Root or an ancestor of it, as a valid nonnegative
|
|
227
|
+
* CSS length: `24rem`, `20em`, `40ch`, `320px`. A `rem` resolves against the document root, an
|
|
228
|
+
* `em` or `ch` against the Root's inherited type. A missing or invalid token is not a
|
|
229
|
+
* responsive configuration: the switch has nothing to compare and the columns size from their
|
|
230
|
+
* content instead.
|
|
231
|
+
* - The holder gives the Root a definite width, as any block does. Inside a shrink-to-fit frame
|
|
232
|
+
* there is no width to measure the shares against.
|
|
233
|
+
* - Content that cannot wrap - an unbroken string, a fixed-width control - needs its own overflow
|
|
234
|
+
* contract, as `Table.Root` has; the column will not widen to hold it.
|
|
235
|
+
* - `Root`'s children are `Column`s. Anything else placed beside them is rendered as given but is
|
|
236
|
+
* not a column: it takes no weight, counts for no gap or threshold, and sits in the row as
|
|
237
|
+
* content of its own width, which breaks the proportions. Content belongs inside a `Column`.
|
|
238
|
+
*
|
|
239
|
+
* @UXGuidelines
|
|
240
|
+
* - A minimum is the width below which the column's content stops being readable or operable -
|
|
241
|
+
* the narrowest a comparison table can be scanned, the narrowest a summary's labels keep their
|
|
242
|
+
* lines - not the width the designer would like. The stack is the readable fallback.
|
|
243
|
+
* - Weights are structural relationships: `2` and `1` say the table is the subject and the summary
|
|
244
|
+
* its support. A ratio chosen to hit a pixel width is a measurement, and the theme owns those.
|
|
245
|
+
*/
|
|
246
|
+
export const ColumnLayout = {
|
|
247
|
+
Root: ColumnLayoutRoot,
|
|
248
|
+
Column: ColumnLayoutColumn,
|
|
249
|
+
} as const;
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export class ColumnLayoutCompositionError extends Error {
|
|
2
|
+
constructor() {
|
|
3
|
+
super(
|
|
4
|
+
'ColumnLayout.Column must be a direct child of ColumnLayout.Root (arrays and fragments are fine; a wrapping component or a nested Column is not)',
|
|
5
|
+
);
|
|
6
|
+
this.name = 'ColumnLayoutCompositionError';
|
|
7
|
+
}
|
|
8
|
+
}
|