@scalewing/react 1.51.0 → 1.53.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.
Files changed (44) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/README.md +14 -2
  3. package/dist/components/Checkbox.d.ts +18 -1
  4. package/dist/components/Checkbox.js +15 -5
  5. package/dist/components/Dialog.d.ts +14 -1
  6. package/dist/components/Dialog.js +5 -2
  7. package/dist/components/PasteRows.d.ts +55 -0
  8. package/dist/components/PasteRows.js +55 -0
  9. package/dist/components/SiteHeader.d.ts +6 -3
  10. package/dist/components/SiteHeader.js +8 -4
  11. package/dist/components/Table.d.ts +1 -0
  12. package/dist/components/Table.js +1 -0
  13. package/dist/components/checkbox/CheckboxControl.d.ts +1 -1
  14. package/dist/components/checkbox/CheckboxControl.js +4 -3
  15. package/dist/components/dialog/validate.d.ts +3 -1
  16. package/dist/components/dialog/validate.js +3 -1
  17. package/dist/components/paste-rows/parse-pasted-rows.d.ts +26 -0
  18. package/dist/components/paste-rows/parse-pasted-rows.js +123 -0
  19. package/dist/components/site-header/types.d.ts +16 -1
  20. package/dist/components/site-header/validate.d.ts +4 -3
  21. package/dist/components/site-header/validate.js +12 -3
  22. package/dist/components/table/TableSelectCell.d.ts +26 -0
  23. package/dist/components/table/TableSelectCell.js +19 -0
  24. package/dist/css/css-checkbox.js +37 -3
  25. package/dist/css/css-components.js +3 -0
  26. package/dist/css/css-dialog-end.d.ts +13 -0
  27. package/dist/css/css-dialog-end.js +75 -0
  28. package/dist/css/css-select.js +10 -0
  29. package/dist/css/css-site-footer.js +11 -1
  30. package/dist/css/css-site-header-anchors.d.ts +1 -0
  31. package/dist/css/css-site-header-anchors.js +22 -0
  32. package/dist/css/css-site-header-skip-link.d.ts +1 -0
  33. package/dist/css/css-site-header-skip-link.js +49 -0
  34. package/dist/css/css-site-header.js +26 -11
  35. package/dist/css/css-table-select.d.ts +3 -0
  36. package/dist/css/css-table-select.js +24 -0
  37. package/dist/css/css-table.js +4 -0
  38. package/dist/css/site-header-geometry.d.ts +21 -0
  39. package/dist/css/site-header-geometry.js +28 -0
  40. package/dist/css/stylesheet.js +2 -0
  41. package/dist/index.d.ts +5 -3
  42. package/dist/index.js +4 -2
  43. package/dist/styles.css +165 -3
  44. package/package.json +1 -1
@@ -0,0 +1,123 @@
1
+ export const pastedRowsDelimiters = [
2
+ 'auto',
3
+ 'tab',
4
+ 'comma',
5
+ ];
6
+ function resolveDelimiter(text, delimiter) {
7
+ if (!pastedRowsDelimiters.includes(delimiter))
8
+ throw new RangeError(`Unknown PasteRows delimiter: ${String(delimiter)}`);
9
+ if (delimiter !== 'auto')
10
+ return delimiter;
11
+ return text.includes('\t') ? 'tab' : 'comma';
12
+ }
13
+ /**
14
+ * Reads one quoted cell starting at the opening quote `start`: `""` is
15
+ * one quote, and delimiters and line breaks inside it are its words.
16
+ * Returns the words and the index after the closing quote, or `null` when
17
+ * no lone closing quote follows (the opening quote is then a character).
18
+ */
19
+ function readQuoted(text, start) {
20
+ let words = '';
21
+ let index = start + 1;
22
+ while (index < text.length) {
23
+ const char = text[index];
24
+ if (char === '"') {
25
+ if (text[index + 1] === '"') {
26
+ words += '"';
27
+ index += 2;
28
+ continue;
29
+ }
30
+ return [words, index + 1];
31
+ }
32
+ words += char;
33
+ index += 1;
34
+ }
35
+ return null;
36
+ }
37
+ /**
38
+ * For each index, where a quoted cell's scan starting there would find its
39
+ * lone closing quote (`""` being one escaped quote), or -1 when none
40
+ * follows; one extra entry for the end. Built backwards in one pass, so
41
+ * each opening quote is checked in constant time and the parse stays
42
+ * linear: a run of quotes from i of odd length closes on its last quote,
43
+ * an even run is all escapes and the scan carries on after it.
44
+ */
45
+ function closingQuoteTable(text) {
46
+ const closesFrom = new Int32Array(text.length + 1).fill(-1);
47
+ const runFrom = new Int32Array(text.length + 1);
48
+ for (let index = text.length - 1; index >= 0; index -= 1) {
49
+ if (text[index] !== '"') {
50
+ closesFrom[index] = closesFrom[index + 1];
51
+ continue;
52
+ }
53
+ const run = 1 + runFrom[index + 1];
54
+ runFrom[index] = run;
55
+ closesFrom[index] =
56
+ run % 2 === 1 ? index + run - 1 : closesFrom[index + run];
57
+ }
58
+ return closesFrom;
59
+ }
60
+ /**
61
+ * Splits pasted text into rows of trimmed cells: rows on line breaks
62
+ * (CR, LF or CRLF), cells on the delimiter. A cell that starts with a
63
+ * double quote and has a closing one later is quoted, as spreadsheets and
64
+ * CSV write a cell holding the delimiter, a line break or a quote; any
65
+ * other quote is a plain character. Rows whose cells are all blank are
66
+ * left out. It knows nothing of what the columns mean: the consumer maps
67
+ * and checks them. Linear in the text's length.
68
+ */
69
+ export function parsePastedRows(text, delimiter = 'auto') {
70
+ const used = resolveDelimiter(text, delimiter);
71
+ const separator = used === 'tab' ? '\t' : ',';
72
+ const closesFrom = closingQuoteTable(text);
73
+ const rows = [];
74
+ let row = [];
75
+ let cell = '';
76
+ let cellStart = true;
77
+ const endCell = () => {
78
+ row.push(cell.trim());
79
+ cell = '';
80
+ cellStart = true;
81
+ };
82
+ const endRow = () => {
83
+ endCell();
84
+ if (row.some((value) => value !== ''))
85
+ rows.push(row);
86
+ row = [];
87
+ };
88
+ let index = 0;
89
+ while (index < text.length) {
90
+ const char = text[index];
91
+ const quoted = cellStart && char === '"' && closesFrom[index + 1] >= 0
92
+ ? readQuoted(text, index)
93
+ : null;
94
+ if (quoted) {
95
+ cell += quoted[0];
96
+ index = quoted[1];
97
+ cellStart = false;
98
+ }
99
+ else if (char === separator) {
100
+ endCell();
101
+ index += 1;
102
+ }
103
+ else if (char === '\r' || char === '\n') {
104
+ endRow();
105
+ index += char === '\r' && text[index + 1] === '\n' ? 2 : 1;
106
+ }
107
+ else {
108
+ // Spaces before a quoted cell's opening quote do not unquote it.
109
+ if (!(cellStart && char === ' ')) {
110
+ cell += char;
111
+ cellStart = false;
112
+ }
113
+ index += 1;
114
+ }
115
+ }
116
+ if (cell !== '' || row.length > 0)
117
+ endRow();
118
+ const columnCount = rows.reduce((most, cells) => Math.max(most, cells.length), 0);
119
+ for (const cells of rows)
120
+ while (cells.length < columnCount)
121
+ cells.push('');
122
+ return { rows, columnCount, delimiter: used };
123
+ }
@@ -15,6 +15,16 @@ export type SiteHeaderLabels = {
15
15
  /** The open menu button's name, such as "Close the menu". */
16
16
  closeMenu: string;
17
17
  };
18
+ /**
19
+ * A "skip to content" link, the header's first focusable element: hidden
20
+ * until it has focus, then shown at the bar's start.
21
+ */
22
+ export type SiteHeaderSkipLink = {
23
+ /** The link's words, such as "Skip to content". */
24
+ label: string;
25
+ /** The main content's fragment, such as `#main`. */
26
+ href: string;
27
+ };
18
28
  export type SiteHeaderProps = {
19
29
  /** The site's mark at the start, such as a logo linked to the home page. */
20
30
  brand: ReactNode;
@@ -24,7 +34,10 @@ export type SiteHeaderProps = {
24
34
  items: readonly SiteHeaderItem[];
25
35
  /** The bar's end from `lg`, such as a language switch, Sign in and a ButtonLink. */
26
36
  actions?: ReactNode;
27
- /** Beside the menu button below `lg`, such as the language switch. */
37
+ /**
38
+ * Beside the menu button below `lg`, such as the language switch. They
39
+ * step aside while the menu is open, which holds its own `menuActions`.
40
+ */
28
41
  compactActions?: ReactNode;
29
42
  /** In the open menu under its links, such as full-width ButtonLinks. */
30
43
  menuActions?: ReactNode;
@@ -35,4 +48,6 @@ export type SiteHeaderProps = {
35
48
  closeIcon: ReactNode;
36
49
  /** Whether the bar sticks to the window's top as the page scrolls (default `true`). */
37
50
  sticky?: boolean;
51
+ /** A "skip to content" link before everything else in the header. */
52
+ skipLink?: SiteHeaderSkipLink;
38
53
  };
@@ -1,7 +1,8 @@
1
1
  import { type SiteHeaderProps } from './types.js';
2
2
  /**
3
3
  * Fails closed on the header's words and destinations: no items, blank or
4
- * repeated ids, blank labels or hrefs, and a blank navigation name or menu
5
- * button name.
4
+ * repeated ids, blank labels or hrefs, a blank navigation name or menu
5
+ * button name, and a skip link with blank words or an address that is not
6
+ * a fragment on the page.
6
7
  */
7
- export declare function assertSiteHeaderProps({ items, labels, navLabel, }: Pick<SiteHeaderProps, 'items' | 'labels' | 'navLabel'>): void;
8
+ export declare function assertSiteHeaderProps({ items, labels, navLabel, skipLink, }: Pick<SiteHeaderProps, 'items' | 'labels' | 'navLabel' | 'skipLink'>): void;
@@ -1,10 +1,11 @@
1
1
  import { assertNavItems } from '../nav-items.js';
2
2
  /**
3
3
  * Fails closed on the header's words and destinations: no items, blank or
4
- * repeated ids, blank labels or hrefs, and a blank navigation name or menu
5
- * button name.
4
+ * repeated ids, blank labels or hrefs, a blank navigation name or menu
5
+ * button name, and a skip link with blank words or an address that is not
6
+ * a fragment on the page.
6
7
  */
7
- export function assertSiteHeaderProps({ items, labels, navLabel, }) {
8
+ export function assertSiteHeaderProps({ items, labels, navLabel, skipLink, }) {
8
9
  if (items.length === 0)
9
10
  throw new RangeError('SiteHeader needs at least one item');
10
11
  assertNavItems('SiteHeader', items);
@@ -14,4 +15,12 @@ export function assertSiteHeaderProps({ items, labels, navLabel, }) {
14
15
  throw new RangeError('SiteHeader needs a nonempty navLabel');
15
16
  if (!labels.openMenu.trim() || !labels.closeMenu.trim())
16
17
  throw new RangeError('SiteHeader labels need nonempty openMenu and closeMenu');
18
+ if (skipLink)
19
+ assertSkipLink(skipLink);
20
+ }
21
+ function assertSkipLink({ href, label }) {
22
+ if (!label.trim())
23
+ throw new RangeError('SiteHeader skipLink needs a nonempty label');
24
+ if (!/^#\S+$/.test(href))
25
+ throw new RangeError('SiteHeader skipLink href must be a fragment such as #main');
17
26
  }
@@ -0,0 +1,26 @@
1
+ export type TableSelectCellProps = {
2
+ /**
3
+ * The checkbox's name, not drawn: "Select Mallard" in a row, "Select
4
+ * all" in the header. Name the row by the words the row shows.
5
+ */
6
+ label: string;
7
+ checked: boolean;
8
+ /** The mixed state, for a header's "Select all" while some rows are chosen. */
9
+ indeterminate?: boolean;
10
+ onCheckedChange: (checked: boolean) => void;
11
+ disabled?: boolean;
12
+ /**
13
+ * `th` in a `TableHeader` row (the column's header cell, `scope="col"`),
14
+ * `td` (the default) in a body row. A fixed table sizes its columns from
15
+ * the header row, so put one in the header too.
16
+ */
17
+ as?: 'td' | 'th';
18
+ };
19
+ /**
20
+ * A table's select column: a cell holding one `Checkbox` whose label is
21
+ * visually hidden. The column is as narrow as the box, which is its own
22
+ * target, at least 44 × 44 px on a coarse pointer. Pair a chosen row with
23
+ * `TableRow selected`; a press on the box is left alone by a
24
+ * `TableRow pressable`.
25
+ */
26
+ export declare const TableSelectCell: import("react").ForwardRefExoticComponent<TableSelectCellProps & import("react").RefAttributes<HTMLTableCellElement>>;
@@ -0,0 +1,19 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { forwardRef } from 'react';
3
+ import { tableSelectCellClass } from '../../css/css-table-select.js';
4
+ import { Checkbox } from '../Checkbox.js';
5
+ /**
6
+ * A table's select column: a cell holding one `Checkbox` whose label is
7
+ * visually hidden. The column is as narrow as the box, which is its own
8
+ * target, at least 44 × 44 px on a coarse pointer. Pair a chosen row with
9
+ * `TableRow selected`; a press on the box is left alone by a
10
+ * `TableRow pressable`.
11
+ */
12
+ export const TableSelectCell = forwardRef(function TableSelectCell({ as = 'td', checked, disabled = false, indeterminate = false, label, onCheckedChange, }, ref) {
13
+ if (typeof label !== 'string' || !label.trim())
14
+ throw new RangeError('TableSelectCell label must not be empty');
15
+ if (as !== 'td' && as !== 'th')
16
+ throw new RangeError(`Unknown TableSelectCell as: ${String(as)}`);
17
+ const Component = as;
18
+ return (_jsx(Component, { ref: ref, className: tableSelectCellClass, scope: as === 'th' ? 'col' : undefined, children: _jsx(Checkbox, { checked: checked, disabled: disabled, indeterminate: indeterminate, label: label, labelVisuallyHidden: true, onCheckedChange: onCheckedChange }) }));
19
+ });
@@ -1,5 +1,6 @@
1
1
  import { typographyVariants } from '@scalewing/tokens';
2
2
  import { checkStrokeIn } from './chevron.js';
3
+ import { coarsePointerQuery, touchTarget } from './touch-target.js';
3
4
  const label = typographyVariants.label;
4
5
  const caption = typographyVariants.caption;
5
6
  export function cssCheckboxClasses() {
@@ -68,6 +69,38 @@ export function cssCheckboxClasses() {
68
69
 
69
70
  .sw-checkbox-input:checked + .sw-checkbox-mark::after { display: block; }
70
71
 
72
+ /* The mixed state: the accent fill with a dash in place of the check. */
73
+ .sw-checkbox-input:indeterminate + .sw-checkbox-mark {
74
+ background: var(--sw-color-accent);
75
+ border-color: var(--sw-color-accent);
76
+ }
77
+
78
+ .sw-checkbox-input:indeterminate + .sw-checkbox-mark::after {
79
+ border-right: 0;
80
+ display: block;
81
+ height: 0;
82
+ inset-block-start: calc(50% - 1px);
83
+ inset-inline-start: calc(50% - var(--sw-space-3) / 2);
84
+ transform: none;
85
+ width: var(--sw-space-3);
86
+ }
87
+
88
+ /* A visually hidden label: the box alone is the target, its own size on a
89
+ fine pointer (so a compact table row keeps its height), the 44 px touch
90
+ target both ways on a coarse one. */
91
+ .sw-checkbox-label-hidden {
92
+ justify-content: center;
93
+ min-height: 0;
94
+ position: relative;
95
+ }
96
+
97
+ @media ${coarsePointerQuery} {
98
+ .sw-checkbox-label-hidden {
99
+ min-height: ${touchTarget};
100
+ min-width: ${touchTarget};
101
+ }
102
+ }
103
+
71
104
  .sw-checkbox-input:focus-visible + .sw-checkbox-mark {
72
105
  outline: var(--sw-focus-ring-width) solid var(--sw-color-accent);
73
106
  outline-offset: var(--sw-focus-ring-offset);
@@ -104,13 +137,13 @@ export function cssCheckboxClasses() {
104
137
 
105
138
  @media (prefers-reduced-transparency: reduce) {
106
139
  .sw-checkbox-mark { background: var(--sw-color-surface); }
107
- .sw-checkbox-input:checked + .sw-checkbox-mark { background: var(--sw-color-accent); }
140
+ .sw-checkbox-input:is(:checked, :indeterminate) + .sw-checkbox-mark { background: var(--sw-color-accent); }
108
141
  }
109
142
 
110
143
  @media (forced-colors: active) {
111
144
  .sw-checkbox-mark { background: Canvas; border-color: CanvasText; }
112
- .sw-checkbox-input:checked + .sw-checkbox-mark { background: Highlight; border-color: Highlight; }
113
- .sw-checkbox-input:checked + .sw-checkbox-mark::after { border-color: HighlightText; }
145
+ .sw-checkbox-input:is(:checked, :indeterminate) + .sw-checkbox-mark { background: Highlight; border-color: Highlight; }
146
+ .sw-checkbox-input:is(:checked, :indeterminate) + .sw-checkbox-mark::after { border-color: HighlightText; }
114
147
  .sw-checkbox-input[aria-invalid='true'] + .sw-checkbox-mark { border-color: CanvasText; }
115
148
  }`;
116
149
  }
@@ -118,6 +151,7 @@ export function checkboxClassCatalog() {
118
151
  return [
119
152
  'sw-checkbox',
120
153
  'sw-checkbox-label',
154
+ 'sw-checkbox-label-hidden',
121
155
  'sw-checkbox-control',
122
156
  'sw-checkbox-input',
123
157
  'sw-checkbox-mark',
@@ -35,6 +35,7 @@ import { cssDialogBandedClasses } from './css-dialog-banded.js';
35
35
  import { cssDialogFrameClasses } from './css-dialog-frame.js';
36
36
  import { cssDialogCanvasClasses } from './css-dialog-canvas.js';
37
37
  import { cssDialogGrabberClasses } from './css-dialog-grabber.js';
38
+ import { cssDialogEndClasses } from './css-dialog-end.js';
38
39
  import { cssComboboxClasses } from './css-combobox.js';
39
40
  import { cssSelectClasses } from './css-select.js';
40
41
  import { cssMultiSelectClasses } from './css-multi-select.js';
@@ -224,6 +225,8 @@ ${cssDialogCanvasClasses()}
224
225
 
225
226
  ${cssDialogGrabberClasses()}
226
227
 
228
+ ${cssDialogEndClasses()}
229
+
227
230
  ${cssAccordionClasses()}
228
231
 
229
232
  ${cssSelectClasses()}
@@ -0,0 +1,13 @@
1
+ import { type Breakpoint } from './breakpoints.js';
2
+ /**
3
+ * Where a Dialog sits from the layout breakpoint up: `center` (the
4
+ * default) in the middle of the window, `end` as a side panel docked to
5
+ * the inline end (the right, the left in right-to-left).
6
+ */
7
+ export declare const dialogPlacements: readonly ["center", "end"];
8
+ export type DialogPlacement = (typeof dialogPlacements)[number];
9
+ export declare const dialogEndClass = "sw-dialog-end";
10
+ /** The side panel takes its place from this breakpoint up. */
11
+ export declare const dialogEndFrom: Breakpoint;
12
+ export declare function cssDialogEndClasses(): string;
13
+ export declare function dialogEndClassCatalog(): string[];
@@ -0,0 +1,75 @@
1
+ import { breakpointQuery } from './breakpoints.js';
2
+ /**
3
+ * Where a Dialog sits from the layout breakpoint up: `center` (the
4
+ * default) in the middle of the window, `end` as a side panel docked to
5
+ * the inline end (the right, the left in right-to-left).
6
+ */
7
+ export const dialogPlacements = ['center', 'end'];
8
+ export const dialogEndClass = 'sw-dialog-end';
9
+ /** The side panel takes its place from this breakpoint up. */
10
+ export const dialogEndFrom = 'md';
11
+ const end = `.${dialogEndClass}`;
12
+ /*
13
+ * From md up, `placement="end"` is a side panel: the window's full height
14
+ * at the inline end, square corners, one hairline on its inline-start
15
+ * edge, the size's width (md 32rem, lg 56rem, xl 65rem) with a space-8
16
+ * strip of backdrop always in view. Its column grows to the full height so
17
+ * the pinned footer sits on the bottom edge even when the content is short;
18
+ * longer content scrolls inside the panel under the sticky footer, as in
19
+ * the centred dialog. It slides in from its edge unless reduced motion is
20
+ * asked for. Below md the class draws nothing: the dialog is centred, or a
21
+ * bottom sheet with `sheetBelow`. Emitted after the banded and grabber
22
+ * rules so its edges win at equal specificity.
23
+ */
24
+ export function cssDialogEndClasses() {
25
+ return `@media ${breakpointQuery('from', dialogEndFrom)} {
26
+ ${end} {
27
+ border-color: var(--sw-color-border);
28
+ border-radius: 0;
29
+ border-width: 0;
30
+ border-inline-start-width: 1px;
31
+ height: 100%;
32
+ inset-block: 0;
33
+ margin: 0;
34
+ margin-inline-start: auto;
35
+ max-height: none;
36
+ }
37
+
38
+ ${end}[open] {
39
+ animation: sw-dialog-end-in var(--sw-motion-default) var(--sw-motion-easing);
40
+ display: flex;
41
+ flex-direction: column;
42
+ }
43
+
44
+ ${end}[open]:dir(rtl) {
45
+ animation-name: sw-dialog-end-in-rtl;
46
+ }
47
+
48
+ ${end} > .sw-stack {
49
+ flex: 1 0 auto;
50
+ }
51
+
52
+ ${end} .sw-dialog-footer {
53
+ margin-top: auto;
54
+ }
55
+ }
56
+
57
+ @keyframes sw-dialog-end-in {
58
+ from { translate: 100% 0; }
59
+ }
60
+
61
+ @keyframes sw-dialog-end-in-rtl {
62
+ from { translate: -100% 0; }
63
+ }
64
+
65
+ @media (prefers-reduced-motion: reduce) {
66
+ ${end}[open] { animation: none; }
67
+ }
68
+
69
+ @media (forced-colors: active) {
70
+ ${end} { border-inline-start-color: CanvasText; }
71
+ }`;
72
+ }
73
+ export function dialogEndClassCatalog() {
74
+ return [dialogEndClass];
75
+ }
@@ -2,6 +2,7 @@ import { typographyVariants } from '@scalewing/tokens';
2
2
  import { chevronStroke } from './chevron.js';
3
3
  import { zIndex } from './stacking.js';
4
4
  import { quietFill } from './quiet-fill.js';
5
+ import { coarsePointerQuery, touchTarget } from './touch-target.js';
5
6
  const caption = typographyVariants.caption;
6
7
  const label = typographyVariants.label;
7
8
  export function cssSelectClasses() {
@@ -186,6 +187,15 @@ export function cssSelectClasses() {
186
187
  border-top: 1px solid var(--sw-color-border);
187
188
  color: var(--sw-color-accent);
188
189
  margin-top: var(--sw-space-1);
190
+ }
191
+
192
+ /* The comfortable touch target on a coarse pointer, at every size: the
193
+ trigger and each option are at least 44 px high (the md control height),
194
+ as every Button is. Type and padding keep the size's; the rule follows
195
+ the size rules, which it outranks only by order. */
196
+ @media ${coarsePointerQuery} {
197
+ .sw-select .sw-select-trigger,
198
+ .sw-select .sw-select-option { min-height: ${touchTarget}; }
189
199
  }`;
190
200
  }
191
201
  export function selectClassCatalog() {
@@ -21,7 +21,10 @@ function type(variant, weight = variant.fontWeight) {
21
21
  * win by coming after it (`cssSiteFooterClasses` follows `cssBandClasses`;
22
22
  * a test holds the order).
23
23
  * Below `md` the brand spans two columns of groups; from `md` the brand
24
- * and the groups share one wrapping row, spread apart.
24
+ * and the groups share one wrapping row, spread apart. On a coarse pointer
25
+ * the group links and a link that is all of a bottom-row slot (`legal` or
26
+ * `end`, such as a language switch) are the 44 px touch target; a link
27
+ * inside running legal words stays inline.
25
28
  */
26
29
  export function cssSiteFooterClasses() {
27
30
  return `.sw-site-footer-top {
@@ -120,6 +123,13 @@ export function cssSiteFooterClasses() {
120
123
  min-height: ${touchTarget};
121
124
  }
122
125
 
126
+ [data-theme] .sw-band-inverse :is(.sw-site-footer-legal, .sw-site-footer-end) > a:not(.sw-button) {
127
+ align-items: center;
128
+ display: inline-flex;
129
+ justify-content: center;
130
+ min-height: ${touchTarget};
131
+ }
132
+
123
133
  .sw-site-footer-links {
124
134
  gap: 0;
125
135
  }
@@ -0,0 +1 @@
1
+ export declare function cssSiteHeaderAnchorRules(): string;
@@ -0,0 +1,22 @@
1
+ import { siteHeaderCompactHeight, siteHeaderWideHeight, siteHeaderWideQuery, } from './site-header-geometry.js';
2
+ /** The room an in-page jump leaves above its target: the bar, its hairline and a gap. */
3
+ function scrollPadding(barHeight) {
4
+ return `calc(${barHeight} + 1px + var(--sw-space-3))`;
5
+ }
6
+ /*
7
+ * While a sticky SiteHeader is on the page, the document's scroll padding
8
+ * clears the bar (its height at each width, its hairline) and a small gap,
9
+ * so a #fragment link, :target and scrollIntoView land below the bar
10
+ * instead of under it. A header in the page flow changes nothing.
11
+ */
12
+ export function cssSiteHeaderAnchorRules() {
13
+ return `html:has(.sw-site-header-sticky) {
14
+ scroll-padding-top: ${scrollPadding(siteHeaderCompactHeight)};
15
+ }
16
+
17
+ @media ${siteHeaderWideQuery} {
18
+ html:has(.sw-site-header-sticky) {
19
+ scroll-padding-top: ${scrollPadding(siteHeaderWideHeight)};
20
+ }
21
+ }`;
22
+ }
@@ -0,0 +1 @@
1
+ export declare function cssSiteHeaderSkipLinkRules(): string;
@@ -0,0 +1,49 @@
1
+ import { typographyVariants } from '@scalewing/tokens';
2
+ import { visuallyHiddenDeclarations } from './css-utilities.js';
3
+ import { siteHeaderContentStart, siteHeaderGutter, siteHeaderGutterQuery, } from './site-header-geometry.js';
4
+ const label = typographyVariants.label;
5
+ /*
6
+ * The header's skip link, its first focusable element: visually hidden
7
+ * until it has focus, then a pill on the surface in the text colour with
8
+ * the accent ring, laid over the bar's start (centred in its height and
9
+ * lined up with the brand), the control height tall only then. Positioned,
10
+ * it paints over the bar's content, which is not, so it needs no layer of
11
+ * its own.
12
+ */
13
+ export function cssSiteHeaderSkipLinkRules() {
14
+ return `.sw-site-header-skip-link {
15
+ align-items: center;
16
+ background: var(--sw-color-surface);
17
+ border-radius: var(--sw-radius-pill);
18
+ box-sizing: border-box;
19
+ display: inline-flex;
20
+ font-family: var(--sw-font-sans);
21
+ font-size: ${label.fontSize}px;
22
+ font-weight: ${label.fontWeight};
23
+ inset-block-start: 50%;
24
+ inset-inline-start: ${siteHeaderContentStart(siteHeaderGutter.compact)};
25
+ letter-spacing: ${label.letterSpacing}px;
26
+ line-height: ${label.lineHeight}px;
27
+ padding-inline: var(--sw-space-4);
28
+ position: absolute;
29
+ transform: translateY(-50%);
30
+ white-space: nowrap;
31
+ }
32
+
33
+ [data-theme] .sw-site-header-skip-link {
34
+ color: var(--sw-color-text);
35
+ text-decoration: none;
36
+ }
37
+
38
+ [data-theme] .sw-site-header-skip-link:focus {
39
+ min-height: var(--sw-control-md-min-height);
40
+ outline: var(--sw-focus-ring-width) solid var(--sw-color-accent);
41
+ outline-offset: calc(-1 * var(--sw-focus-ring-width));
42
+ }
43
+
44
+ .sw-site-header-skip-link:not(:focus) { ${visuallyHiddenDeclarations} }
45
+
46
+ @media ${siteHeaderGutterQuery} {
47
+ .sw-site-header-skip-link { inset-inline-start: ${siteHeaderContentStart(siteHeaderGutter.wide)}; }
48
+ }`;
49
+ }
@@ -1,19 +1,18 @@
1
1
  import { typographyVariants } from '@scalewing/tokens';
2
- import { breakpointQuery } from './breakpoints.js';
2
+ import { cssSiteHeaderAnchorRules } from './css-site-header-anchors.js';
3
+ import { cssSiteHeaderSkipLinkRules } from './css-site-header-skip-link.js';
3
4
  import { quietFill } from './quiet-fill.js';
5
+ import { siteHeaderBarMaxWidth, siteHeaderCompactHeight as compactHeight, siteHeaderGutter, siteHeaderGutterQuery, siteHeaderWideHeight as wideHeight, siteHeaderWideQuery as wide, } from './site-header-geometry.js';
4
6
  import { zIndex } from './stacking.js';
5
7
  const label = typographyVariants.label;
6
8
  const title = typographyVariants.title;
7
- /** The bar's height: 56 px on a phone, 72 px from `lg`. */
8
- const compactHeight = 'calc(var(--sw-control-md-min-height) + var(--sw-space-3))';
9
- const wideHeight = 'calc(var(--sw-control-md-min-height) + var(--sw-space-5) + var(--sw-space-1))';
10
- const wide = breakpointQuery('from', 'lg');
11
9
  /*
12
10
  * The bar: the solid surface with a hairline under it, its content in the
13
11
  * page container with the page gutter. Below `lg` it holds the brand, the
14
12
  * compact actions and the menu button; from `lg` the brand, the
15
13
  * destinations (the current one at label weight over a bar in the words'
16
- * colour, as tall as the bar) and the actions, spread apart.
14
+ * colour, as tall as the bar) and the actions, spread apart. The compact
15
+ * actions' box adds no layout of its own (`display: contents`).
17
16
  */
18
17
  function barRules() {
19
18
  return `.sw-site-header {
@@ -38,9 +37,9 @@ function barRules() {
38
37
  gap: var(--sw-space-5);
39
38
  justify-content: space-between;
40
39
  margin-inline: auto;
41
- max-width: calc(var(--sw-container-max) + 2 * var(--sw-space-5));
40
+ max-width: ${siteHeaderBarMaxWidth};
42
41
  min-height: var(--sw-site-header-height);
43
- padding-inline: var(--sw-space-4);
42
+ padding-inline: ${siteHeaderGutter.compact};
44
43
  }
45
44
 
46
45
  .sw-site-header-brand {
@@ -60,6 +59,10 @@ function barRules() {
60
59
  gap: var(--sw-space-1);
61
60
  }
62
61
 
62
+ .sw-site-header-compact-actions {
63
+ display: contents;
64
+ }
65
+
63
66
  .sw-site-header-list {
64
67
  list-style: none;
65
68
  margin: 0;
@@ -101,8 +104,8 @@ function barRules() {
101
104
  gap: var(--sw-space-4);
102
105
  }
103
106
 
104
- @media ${breakpointQuery('from', 'md')} {
105
- .sw-site-header-bar { padding-inline: var(--sw-space-5); }
107
+ @media ${siteHeaderGutterQuery} {
108
+ .sw-site-header-bar { padding-inline: ${siteHeaderGutter.wide}; }
106
109
  }
107
110
 
108
111
  @media ${wide} {
@@ -118,13 +121,19 @@ function barRules() {
118
121
  * open, the other pair hidden so only one is read), and its panel lies
119
122
  * under the bar over the rest of the window, the destinations as large
120
123
  * rows with hairlines and the menu's actions at the panel's foot. It
121
- * scrolls on its own when the window is short.
124
+ * scrolls on its own when the window is short. While it is open the bar's
125
+ * compact actions step aside, since the menu holds its own actions, so the
126
+ * bar shows only the brand and the close button.
122
127
  */
123
128
  function menuRules() {
124
129
  return `.sw-site-header-menu > summary {
125
130
  list-style: none;
126
131
  }
127
132
 
133
+ .sw-site-header-compact:has(> .sw-site-header-menu[open]) > .sw-site-header-compact-actions {
134
+ display: none;
135
+ }
136
+
128
137
  .sw-site-header-menu > summary::-webkit-details-marker {
129
138
  display: none;
130
139
  }
@@ -214,6 +223,10 @@ export function cssSiteHeaderClasses() {
214
223
 
215
224
  ${menuRules()}
216
225
 
226
+ ${cssSiteHeaderSkipLinkRules()}
227
+
228
+ ${cssSiteHeaderAnchorRules()}
229
+
217
230
  /*
218
231
  * Forced colours drop box shadows, so the current destination's bar is a
219
232
  * thick underline there.
@@ -248,6 +261,8 @@ export function siteHeaderClassCatalog() {
248
261
  'sw-site-header-nav',
249
262
  'sw-site-header-actions',
250
263
  'sw-site-header-compact',
264
+ 'sw-site-header-compact-actions',
265
+ 'sw-site-header-skip-link',
251
266
  'sw-site-header-list',
252
267
  'sw-site-header-link',
253
268
  'sw-site-header-menu',
@@ -0,0 +1,3 @@
1
+ export declare const tableSelectCellClass = "sw-table-select";
2
+ export declare function cssTableSelectClasses(): string;
3
+ export declare function tableSelectClassCatalog(): string[];