@machfivetechchicago/machvive-neodigm55-ux 0.1.1 → 0.1.2
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 +52 -1
- package/index.d.ts +2 -0
- package/index.js +3 -0
- package/package.json +7 -1
- package/src/conductor/index.js +1 -0
- package/src/conductor/theme.d.ts +40 -3
- package/src/conductor/theme.js +116 -13
- package/src/wc/neodigm-buffet/neodigm-buffet.d.ts +59 -0
- package/src/wc/neodigm-buffet/neodigm-buffet.js +403 -0
- package/src/wc/neodigm-tulip/neodigm-tulip.js +2 -4
package/README.md
CHANGED
|
@@ -74,7 +74,7 @@ is the difference that makes a theme builder possible:
|
|
|
74
74
|
```js
|
|
75
75
|
import { installTheme, themeCss } from '@machfivetechchicago/machvive-neodigm55-ux/conductor';
|
|
76
76
|
|
|
77
|
-
installTheme(); // --n55-brand, --n55-brand-deep, --n55-brand-
|
|
77
|
+
installTheme(); // --n55-brand, --n55-brand-deep, --n55-brand-ink, …
|
|
78
78
|
themeCss(myPalette, '.promo'); // or emit a stylesheet for a section, or for a build
|
|
79
79
|
```
|
|
80
80
|
|
|
@@ -82,6 +82,28 @@ Override `--n55-brand` on a page and every component follows, with no rebuild an
|
|
|
82
82
|
component aware it happened. `disabled` doubles as the opt-out: an element themed
|
|
83
83
|
`disabled` is never choreographed.
|
|
84
84
|
|
|
85
|
+
Text on a theme colour uses `--n55-<name>-ink` (and `-deep-ink` on the deep shade):
|
|
86
|
+
near-black or white, whichever contrasts more, computed by `inkFor()` so a custom
|
|
87
|
+
palette stays readable. `contrast()` is exported for auditing a palette in CI.
|
|
88
|
+
|
|
89
|
+
### Light and dark
|
|
90
|
+
|
|
91
|
+
Components follow the `color-scheme` they inherit, per instance, with no JavaScript.
|
|
92
|
+
Surfaces that are not a theme colour — Buffet's top row, an unthemed selection — come
|
|
93
|
+
from `--n55-surface`, `--n55-surface-ink`, `--n55-line`, `--n55-inverse` and
|
|
94
|
+
`--n55-inverse-ink`, each a `light-dark()` pair.
|
|
95
|
+
|
|
96
|
+
```html
|
|
97
|
+
<html style="color-scheme: light dark"> <!-- follow the OS -->
|
|
98
|
+
<html data-n55-scheme="dark"> <!-- pin the page -->
|
|
99
|
+
<aside data-n55-scheme="dark"> <!-- or one section -->
|
|
100
|
+
<neodigm-buffet data-n55-scheme="light"> <!-- or one instance -->
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
The palette never sets `color-scheme` on `:root` itself, because that also restyles
|
|
104
|
+
the page's scrollbars and form controls. Theme colours and their inks are the same in
|
|
105
|
+
both modes.
|
|
106
|
+
|
|
85
107
|
## Toast
|
|
86
108
|
|
|
87
109
|
```html
|
|
@@ -99,6 +121,35 @@ Queued, with the original's debounce — the same message twice in a row is the
|
|
|
99
121
|
notification. `|` breaks the line. **Message text is never parsed as HTML**, unlike the
|
|
100
122
|
original's `innerHTML`, because toast text is routinely built from user input.
|
|
101
123
|
|
|
124
|
+
## Buffet
|
|
125
|
+
|
|
126
|
+
A full-width, two-level menu driven by metadata. The top row divides its
|
|
127
|
+
container's width evenly; the selection's children sit below at half the height.
|
|
128
|
+
Both levels keep a selection, and each item remembers its last child.
|
|
129
|
+
|
|
130
|
+
```html
|
|
131
|
+
<neodigm-buffet data-n55-theme="brand" data-n55-buffet='[
|
|
132
|
+
{"token":"shows","caption":"Shows","selected":true,"secondary":[
|
|
133
|
+
{"token":"live","caption":"Live"},{"token":"vod","caption":"VOD"}]},
|
|
134
|
+
{"token":"people","caption":"People","count":3,"theme":"marcom"}
|
|
135
|
+
]'></neodigm-buffet>
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
```js
|
|
139
|
+
const menu = document.querySelector('neodigm-buffet');
|
|
140
|
+
menu.addEventListener('neodigm-buffet-select', (e) => {
|
|
141
|
+
if (hasUnsavedWork()) e.preventDefault(); // cancelable, at either level
|
|
142
|
+
else route(e.detail.primary, e.detail.secondary);
|
|
143
|
+
});
|
|
144
|
+
menu.select('shows', 'vod'); // sync to a route, silently
|
|
145
|
+
menu.items = relabelled; // selection survives where tokens do
|
|
146
|
+
menu.updateCount('people', 4);
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
The theme on the host colours the whole menu, so a sweep moves across it as one
|
|
150
|
+
instrument. An item's own `theme` is an accent that a sweep does not reach. Height
|
|
151
|
+
is `--n55-buffet-height` (default `64px`). Sticky positioning is left to the page.
|
|
152
|
+
|
|
102
153
|
## What changed in the port
|
|
103
154
|
|
|
104
155
|
| | Original | Here |
|
package/index.d.ts
CHANGED
|
@@ -4,4 +4,6 @@ export { NeodigmMarquee, DEFAULT_SPEED, parseRuns, plainText } from './src/wc/ne
|
|
|
4
4
|
export type { MarqueeCell } from './src/wc/neodigm-marquee/neodigm-marquee.js';
|
|
5
5
|
export { NeodigmTulip, TULIP_DEFAULTS, readConfig, configFrom } from './src/wc/neodigm-tulip/neodigm-tulip.js';
|
|
6
6
|
export type { TulipConfig } from './src/wc/neodigm-tulip/neodigm-tulip.js';
|
|
7
|
+
export { NeodigmBuffet, readItems } from './src/wc/neodigm-buffet/neodigm-buffet.js';
|
|
8
|
+
export type { BuffetItem, BuffetSelection, BuffetSelectDetail } from './src/wc/neodigm-buffet/neodigm-buffet.js';
|
|
7
9
|
export * from './src/conductor/index.js';
|
package/index.js
CHANGED
|
@@ -10,12 +10,15 @@
|
|
|
10
10
|
import { NeodigmToast, DEFAULT_DURATION } from './src/wc/neodigm-toast/neodigm-toast.js';
|
|
11
11
|
import { NeodigmMarquee, DEFAULT_SPEED, parseRuns, plainText } from './src/wc/neodigm-marquee/neodigm-marquee.js';
|
|
12
12
|
import { NeodigmTulip, TULIP_DEFAULTS, readConfig, configFrom } from './src/wc/neodigm-tulip/neodigm-tulip.js';
|
|
13
|
+
import { NeodigmBuffet, readItems } from './src/wc/neodigm-buffet/neodigm-buffet.js';
|
|
13
14
|
|
|
14
15
|
export { NeodigmToast, DEFAULT_DURATION };
|
|
15
16
|
export { NeodigmMarquee, DEFAULT_SPEED, parseRuns, plainText };
|
|
16
17
|
export { NeodigmTulip, TULIP_DEFAULTS, readConfig, configFrom };
|
|
18
|
+
export { NeodigmBuffet, readItems };
|
|
17
19
|
export {
|
|
18
20
|
THEMES, THEME_NAMES, OPT_OUT, THEME_ATTR, PULSE_ATTR,
|
|
21
|
+
INK_DARK, INK_LIGHT, contrast, inkFor, NEUTRALS, SCHEME_ATTR,
|
|
19
22
|
themeCss, installTheme, resolveTheme,
|
|
20
23
|
motionAllowed, setMotionAllowed, pause, play, onMotionChange, resetMotion,
|
|
21
24
|
robinTheme, pulseTheme, flashTitle, resetTitle
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@machfivetechchicago/machvive-neodigm55-ux",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"description": "Neodigm 55 as dependency-free Web Components — a page-level choreographer that moves colour and attention across every themed element, plus the components it conducts.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"web-components",
|
|
@@ -12,6 +12,8 @@
|
|
|
12
12
|
"theme",
|
|
13
13
|
"toast",
|
|
14
14
|
"snackbar",
|
|
15
|
+
"navigation",
|
|
16
|
+
"menu",
|
|
15
17
|
"neodigm",
|
|
16
18
|
"no-dependencies",
|
|
17
19
|
"esm"
|
|
@@ -41,6 +43,10 @@
|
|
|
41
43
|
"types": "./src/wc/neodigm-tulip/neodigm-tulip.d.ts",
|
|
42
44
|
"default": "./src/wc/neodigm-tulip/neodigm-tulip.js"
|
|
43
45
|
},
|
|
46
|
+
"./buffet": {
|
|
47
|
+
"types": "./src/wc/neodigm-buffet/neodigm-buffet.d.ts",
|
|
48
|
+
"default": "./src/wc/neodigm-buffet/neodigm-buffet.js"
|
|
49
|
+
},
|
|
44
50
|
"./package.json": "./package.json"
|
|
45
51
|
},
|
|
46
52
|
"sideEffects": true,
|
package/src/conductor/index.js
CHANGED
package/src/conductor/theme.d.ts
CHANGED
|
@@ -1,12 +1,25 @@
|
|
|
1
1
|
export interface NeodigmTheme {
|
|
2
2
|
/** The theme's colour. */
|
|
3
3
|
color: string;
|
|
4
|
-
/** Its darker companion, for
|
|
4
|
+
/** Its darker companion, for a second surface or for hover. */
|
|
5
5
|
deep: string;
|
|
6
6
|
/** The emoji the tab title flashes. */
|
|
7
7
|
glyph: string;
|
|
8
|
+
/** Text on `color`. Computed with `inkFor` when omitted. */
|
|
9
|
+
ink?: string;
|
|
10
|
+
/** Text on `deep`. Computed with `inkFor` when omitted. */
|
|
11
|
+
deepInk?: string;
|
|
8
12
|
}
|
|
9
13
|
|
|
14
|
+
/** The components' neutral dark ink. */
|
|
15
|
+
export const INK_DARK: '#1a1a1a';
|
|
16
|
+
/** White ink. */
|
|
17
|
+
export const INK_LIGHT: '#fff';
|
|
18
|
+
/** WCAG contrast ratio between two hex colours, 1–21; NaN if either is not hex. Pure. */
|
|
19
|
+
export function contrast(a: string, b: string): number;
|
|
20
|
+
/** INK_DARK or INK_LIGHT, whichever contrasts more with `surface`. Pure. */
|
|
21
|
+
export function inkFor(surface: string): '#1a1a1a' | '#fff';
|
|
22
|
+
|
|
10
23
|
/** The thirteen Neodigm 55 themes, in round-robin order. */
|
|
11
24
|
export const THEMES: Readonly<Record<string, NeodigmTheme>>;
|
|
12
25
|
export const THEME_NAMES: readonly string[];
|
|
@@ -16,15 +29,39 @@ export const OPT_OUT: 'disabled';
|
|
|
16
29
|
export const THEME_ATTR: 'data-n55-theme';
|
|
17
30
|
export const PULSE_ATTR: 'data-n55-theme-pulse';
|
|
18
31
|
|
|
32
|
+
/** A `[light, dark]` pair, emitted as `light-dark(light, dark)`. */
|
|
33
|
+
export type SchemePair = readonly [light: string, dark: string];
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The page surfaces components paint when not wearing a theme, as
|
|
37
|
+
* `--n55-surface`, `--n55-surface-ink`, `--n55-line`, `--n55-inverse`,
|
|
38
|
+
* `--n55-inverse-ink`. Each resolves against the using element's color-scheme.
|
|
39
|
+
*/
|
|
40
|
+
export const NEUTRALS: Readonly<{
|
|
41
|
+
surface: SchemePair;
|
|
42
|
+
surfaceInk: SchemePair;
|
|
43
|
+
line: SchemePair;
|
|
44
|
+
inverse: SchemePair;
|
|
45
|
+
inverseInk: SchemePair;
|
|
46
|
+
}>;
|
|
47
|
+
|
|
48
|
+
/** `data-n55-scheme="light|dark"` pins a subtree, or one component, to a mode. */
|
|
49
|
+
export const SCHEME_ATTR: 'data-n55-scheme';
|
|
50
|
+
|
|
19
51
|
/** Renders the palette as CSS custom properties, for a stylesheet or a builder. */
|
|
20
|
-
export function themeCss(
|
|
52
|
+
export function themeCss(
|
|
53
|
+
themes?: Record<string, NeodigmTheme>,
|
|
54
|
+
selector?: string,
|
|
55
|
+
neutrals?: Record<string, SchemePair>
|
|
56
|
+
): string;
|
|
21
57
|
|
|
22
58
|
/** Puts the palette on the document. Idempotent; a second call replaces the first. */
|
|
23
59
|
export function installTheme(options?: {
|
|
24
60
|
themes?: Record<string, NeodigmTheme>;
|
|
25
61
|
selector?: string;
|
|
62
|
+
neutrals?: Record<string, SchemePair>;
|
|
26
63
|
doc?: Document;
|
|
27
64
|
}): HTMLStyleElement | null;
|
|
28
65
|
|
|
29
|
-
/** Reads a theme's *resolved* values, honouring any page override. */
|
|
66
|
+
/** Reads a theme's *resolved* values, honouring any page override. Inks always present. */
|
|
30
67
|
export function resolveTheme(name: string, options?: { doc?: Document }): NeodigmTheme | null;
|
package/src/conductor/theme.js
CHANGED
|
@@ -11,13 +11,24 @@
|
|
|
11
11
|
* follows, with no rebuild and no component aware it happened.
|
|
12
12
|
*
|
|
13
13
|
* Each theme carries three values, matching the original's `[light, dark, glyph]`
|
|
14
|
-
* triple:
|
|
15
|
-
* --n55-<name>
|
|
16
|
-
* --n55-<name>-deep
|
|
17
|
-
* --n55-<name>-glyph
|
|
14
|
+
* triple, plus two inks derived from them:
|
|
15
|
+
* --n55-<name> the colour
|
|
16
|
+
* --n55-<name>-deep its darker companion, for a second surface or hover
|
|
17
|
+
* --n55-<name>-glyph the emoji the tab title flashes (see flashTitle)
|
|
18
|
+
* --n55-<name>-ink text on the colour
|
|
19
|
+
* --n55-<name>-deep-ink text on the deep companion
|
|
20
|
+
*
|
|
21
|
+
* The inks exist because `-deep` was used as text on the colour, and measured
|
|
22
|
+
* that falls below WCAG AA (4.5:1) on twelve of the thirteen themes — party
|
|
23
|
+
* manages 1.76. Each ink is whichever of two neutrals contrasts more with its
|
|
24
|
+
* surface, computed rather than hand-picked so a custom palette gets readable
|
|
25
|
+
* text without anyone measuring it.
|
|
18
26
|
*/
|
|
19
27
|
|
|
20
|
-
/**
|
|
28
|
+
/**
|
|
29
|
+
* @typedef {{ color: string, deep: string, glyph: string,
|
|
30
|
+
* ink?: string, deepInk?: string }} NeodigmTheme
|
|
31
|
+
*/
|
|
21
32
|
|
|
22
33
|
/**
|
|
23
34
|
* Ported verbatim from `N55_THEME_COLORS` so an existing page's colours do not
|
|
@@ -42,6 +53,83 @@ export const THEMES = Object.freeze({
|
|
|
42
53
|
party: { color: '#FF6F61', deep: '#C93F60', glyph: '🟪' }
|
|
43
54
|
});
|
|
44
55
|
|
|
56
|
+
/** The two inks a surface can take: the components' neutral dark, and white. */
|
|
57
|
+
export const INK_DARK = '#1a1a1a';
|
|
58
|
+
export const INK_LIGHT = '#fff';
|
|
59
|
+
|
|
60
|
+
/** WCAG relative luminance of `#rgb` or `#rrggbb`; null for anything else. */
|
|
61
|
+
function luminance(hex) {
|
|
62
|
+
const m = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(String(hex).trim());
|
|
63
|
+
if (!m) return null;
|
|
64
|
+
const h = m[1].length === 3 ? [...m[1]].map((c) => c + c).join('') : m[1];
|
|
65
|
+
const [r, g, b] = [0, 2, 4].map((i) => {
|
|
66
|
+
const v = parseInt(h.slice(i, i + 2), 16) / 255;
|
|
67
|
+
return v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4;
|
|
68
|
+
});
|
|
69
|
+
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* WCAG contrast ratio between two hex colours, 1 to 21. Pure, so a theme
|
|
74
|
+
* builder or a CI check can audit a palette with no DOM. NaN if either is not
|
|
75
|
+
* a hex colour.
|
|
76
|
+
*/
|
|
77
|
+
export function contrast(a, b) {
|
|
78
|
+
const [la, lb] = [luminance(a), luminance(b)];
|
|
79
|
+
if (la === null || lb === null) return NaN;
|
|
80
|
+
return (Math.max(la, lb) + 0.05) / (Math.min(la, lb) + 0.05);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* The readable ink for text on `surface`: INK_DARK or INK_LIGHT, whichever
|
|
85
|
+
* contrasts more. A surface that is not a hex colour gets INK_DARK — there is
|
|
86
|
+
* nothing to measure, so the palette author should pass `ink` explicitly.
|
|
87
|
+
*/
|
|
88
|
+
export function inkFor(surface) {
|
|
89
|
+
const dark = contrast(INK_DARK, surface);
|
|
90
|
+
if (Number.isNaN(dark)) return INK_DARK;
|
|
91
|
+
return contrast(INK_LIGHT, surface) > dark ? INK_LIGHT : INK_DARK;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* The page's own surfaces, as opposed to a theme's: what a component paints
|
|
96
|
+
* when it is not wearing a theme. Each is a `[light, dark]` pair, emitted as
|
|
97
|
+
* `light-dark(light, dark)`.
|
|
98
|
+
*
|
|
99
|
+
* That function resolves on the element that *uses* the variable, against the
|
|
100
|
+
* `color-scheme` that element inherits — and `color-scheme` inherits through a
|
|
101
|
+
* shadow boundary just as custom properties do. So every instance works out
|
|
102
|
+
* its own mode where it stands, with no listener and no JavaScript: a dark
|
|
103
|
+
* sidebar in a light page needs nothing but `color-scheme: dark` on the
|
|
104
|
+
* sidebar. A browser without `light-dark()` drops the declaration and the
|
|
105
|
+
* component's own `var()` fallback — the light value — applies.
|
|
106
|
+
*
|
|
107
|
+
* --n55-surface a resting surface, such as Buffet's top row
|
|
108
|
+
* --n55-surface-ink text on it
|
|
109
|
+
* --n55-line hairlines and dividers on it
|
|
110
|
+
* --n55-inverse the emphasis surface when there is no theme
|
|
111
|
+
* --n55-inverse-ink text on that
|
|
112
|
+
*/
|
|
113
|
+
export const NEUTRALS = Object.freeze({
|
|
114
|
+
surface: ['#fff', '#1a1a1a'],
|
|
115
|
+
surfaceInk: ['#1a1a1a', '#fff'],
|
|
116
|
+
line: ['rgb(0 0 0 / .12)', 'rgb(255 255 255 / .16)'],
|
|
117
|
+
inverse: ['#1a1a1a', '#e8e8e8'],
|
|
118
|
+
inverseInk: ['#fff', '#1a1a1a']
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* The attribute that pins a subtree to light or dark: `data-n55-scheme="dark"`
|
|
123
|
+
* on `<html>`, a section, or a single component. Without it, components follow
|
|
124
|
+
* whatever `color-scheme` the page already sets — `light dark` follows the OS.
|
|
125
|
+
* The palette deliberately does not set `color-scheme` on `:root` itself: that
|
|
126
|
+
* also restyles the page's own scrollbars and form controls, which is the
|
|
127
|
+
* page's decision.
|
|
128
|
+
*/
|
|
129
|
+
export const SCHEME_ATTR = 'data-n55-scheme';
|
|
130
|
+
|
|
131
|
+
const kebab = (key) => key.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`);
|
|
132
|
+
|
|
45
133
|
/** Theme names in round-robin order. */
|
|
46
134
|
export const THEME_NAMES = Object.freeze(Object.keys(THEMES));
|
|
47
135
|
|
|
@@ -68,15 +156,22 @@ export const PULSE_ATTR = 'data-n55-theme-pulse';
|
|
|
68
156
|
* @param {Record<string, NeodigmTheme>} [themes]
|
|
69
157
|
* @param {string} [selector] Where to hang the variables. A section-scoped
|
|
70
158
|
* selector gives that subtree its own palette, since custom properties cascade.
|
|
159
|
+
* @param {Record<string, [string, string]>} [neutrals] `[light, dark]` pairs.
|
|
71
160
|
* @returns {string}
|
|
72
161
|
*/
|
|
73
|
-
export function themeCss(themes = THEMES, selector = ':root') {
|
|
162
|
+
export function themeCss(themes = THEMES, selector = ':root', neutrals = NEUTRALS) {
|
|
74
163
|
const lines = Object.entries(themes).flatMap(([name, t]) => [
|
|
75
164
|
` --n55-${name}: ${t.color};`,
|
|
76
165
|
` --n55-${name}-deep: ${t.deep};`,
|
|
77
|
-
` --n55-${name}-glyph: "${t.glyph}"
|
|
166
|
+
` --n55-${name}-glyph: "${t.glyph}";`,
|
|
167
|
+
` --n55-${name}-ink: ${t.ink ?? inkFor(t.color)};`,
|
|
168
|
+
` --n55-${name}-deep-ink: ${t.deepInk ?? inkFor(t.deep)};`
|
|
78
169
|
]);
|
|
79
|
-
|
|
170
|
+
const surfaces = Object.entries(neutrals).map(([key, [light, dark]]) =>
|
|
171
|
+
` --n55-${kebab(key)}: light-dark(${light}, ${dark});`);
|
|
172
|
+
return `${selector} {\n${[...lines, ...surfaces].join('\n')}\n}\n` +
|
|
173
|
+
`[${SCHEME_ATTR}="light"] { color-scheme: light; }\n` +
|
|
174
|
+
`[${SCHEME_ATTR}="dark"] { color-scheme: dark; }\n`;
|
|
80
175
|
}
|
|
81
176
|
|
|
82
177
|
/** The id of the stylesheet this installs, so a second call can replace it. */
|
|
@@ -88,14 +183,17 @@ const STYLE_ID = 'n55-theme-tokens';
|
|
|
88
183
|
* Idempotent: calling it again replaces the stylesheet rather than stacking a
|
|
89
184
|
* second one, which is what lets a theme builder preview a palette live.
|
|
90
185
|
*
|
|
91
|
-
* @param {{ themes?: Record<string, NeodigmTheme>, selector?: string,
|
|
186
|
+
* @param {{ themes?: Record<string, NeodigmTheme>, selector?: string,
|
|
187
|
+
* neutrals?: Record<string, [string, string]>, doc?: Document }} [options]
|
|
92
188
|
* @returns {HTMLStyleElement|null} null where there is no document.
|
|
93
189
|
*/
|
|
94
|
-
export function installTheme({
|
|
190
|
+
export function installTheme({
|
|
191
|
+
themes = THEMES, selector = ':root', neutrals = NEUTRALS, doc = globalThis.document
|
|
192
|
+
} = {}) {
|
|
95
193
|
if (!doc?.head) return null;
|
|
96
194
|
const style = doc.getElementById(STYLE_ID) ?? doc.createElement('style');
|
|
97
195
|
style.id = STYLE_ID;
|
|
98
|
-
style.textContent = themeCss(themes, selector);
|
|
196
|
+
style.textContent = themeCss(themes, selector, neutrals);
|
|
99
197
|
if (!style.isConnected) doc.head.append(style);
|
|
100
198
|
return style;
|
|
101
199
|
}
|
|
@@ -115,13 +213,18 @@ export function installTheme({ themes = THEMES, selector = ':root', doc = global
|
|
|
115
213
|
export function resolveTheme(name, { doc = globalThis.document } = {}) {
|
|
116
214
|
if (!THEMES[name]) return null;
|
|
117
215
|
const root = doc?.documentElement;
|
|
118
|
-
|
|
216
|
+
const built = THEMES[name];
|
|
217
|
+
if (!root || typeof globalThis.getComputedStyle !== 'function') {
|
|
218
|
+
return { ...built, ink: inkFor(built.color), deepInk: inkFor(built.deep) };
|
|
219
|
+
}
|
|
119
220
|
const read = (suffix) =>
|
|
120
221
|
globalThis.getComputedStyle(root).getPropertyValue(`--n55-${name}${suffix}`).trim();
|
|
121
222
|
return {
|
|
122
223
|
color: read('') || THEMES[name].color,
|
|
123
224
|
deep: read('-deep') || THEMES[name].deep,
|
|
124
225
|
// The glyph round-trips through CSS as a quoted string.
|
|
125
|
-
glyph: (read('-glyph') || '').replace(/^["']|["']$/g, '') || THEMES[name].glyph
|
|
226
|
+
glyph: (read('-glyph') || '').replace(/^["']|["']$/g, '') || THEMES[name].glyph,
|
|
227
|
+
ink: read('-ink') || inkFor(THEMES[name].color),
|
|
228
|
+
deepInk: read('-deep-ink') || inkFor(THEMES[name].deep)
|
|
126
229
|
};
|
|
127
230
|
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/** One menu entry. The schema `m5t-primary-nav` uses, so existing data drops in. */
|
|
2
|
+
export interface BuffetItem {
|
|
3
|
+
/** Unique identifier. Selection, events and `updateCount` are keyed by it. */
|
|
4
|
+
token: string;
|
|
5
|
+
/** Display text. Falls back to the token. Shown as text, never parsed as markup. */
|
|
6
|
+
caption?: string;
|
|
7
|
+
/** Only `false` disables; a missing value means enabled. */
|
|
8
|
+
enabled?: boolean;
|
|
9
|
+
/** Selects this item initially. */
|
|
10
|
+
selected?: boolean;
|
|
11
|
+
/** Accepted for compatibility and ignored. Icons are deferred; no icon fonts. */
|
|
12
|
+
icon?: string;
|
|
13
|
+
/** Shown as the native tooltip. */
|
|
14
|
+
tagline?: string;
|
|
15
|
+
/** A badge. */
|
|
16
|
+
count?: number;
|
|
17
|
+
/** A palette theme used as this item's accent. Not reached by sweeps. */
|
|
18
|
+
theme?: string;
|
|
19
|
+
/** Children, shown in the half-height row when this item is selected. */
|
|
20
|
+
secondary?: BuffetItem[];
|
|
21
|
+
[key: string]: unknown;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface BuffetSelection {
|
|
25
|
+
primary: string | null;
|
|
26
|
+
secondary: string | null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface BuffetSelectDetail extends BuffetSelection {
|
|
30
|
+
level: 'primary' | 'secondary';
|
|
31
|
+
token: string;
|
|
32
|
+
item: BuffetItem;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Parses metadata: JSON or an array. Never throws; drops items without a token. */
|
|
36
|
+
export function readItems(raw: string | unknown[] | null): BuffetItem[];
|
|
37
|
+
|
|
38
|
+
export class NeodigmBuffet extends HTMLElement {
|
|
39
|
+
/** The metadata, as a copy. Assign to change it; selection survives where tokens do. */
|
|
40
|
+
items: BuffetItem[];
|
|
41
|
+
/** The current selection at both levels. */
|
|
42
|
+
readonly selection: BuffetSelection;
|
|
43
|
+
/**
|
|
44
|
+
* Selects without dispatching an event, for keeping in step with a route.
|
|
45
|
+
* Omit `secondary` for the child last chosen under `primary`.
|
|
46
|
+
* False if either token is unknown or disabled.
|
|
47
|
+
*/
|
|
48
|
+
select(primary: string, secondary?: string | null): boolean;
|
|
49
|
+
/** Updates a badge in place; `null` removes it. False if no item has that token. */
|
|
50
|
+
updateCount(token: string, count: number | null): boolean;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
declare global {
|
|
54
|
+
interface HTMLElementTagNameMap { 'neodigm-buffet': NeodigmBuffet }
|
|
55
|
+
interface HTMLElementEventMap {
|
|
56
|
+
/** Cancelable. Fires on every click, including the current selection. */
|
|
57
|
+
'neodigm-buffet-select': CustomEvent<BuffetSelectDetail>;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
@@ -0,0 +1,403 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `<neodigm-buffet>` — a full-width, two-level menu, driven by metadata.
|
|
3
|
+
*
|
|
4
|
+
* The top row splits the width of its container evenly between its buttons.
|
|
5
|
+
* Below it sits the selected button's children, at half the height and in a
|
|
6
|
+
* smaller type. Both levels keep a selection, and each top-level button
|
|
7
|
+
* remembers which child was last chosen under it.
|
|
8
|
+
*
|
|
9
|
+
* ```html
|
|
10
|
+
* <neodigm-buffet data-n55-theme="brand" data-n55-buffet='[
|
|
11
|
+
* {"token":"shows","caption":"Shows","selected":true,"secondary":[
|
|
12
|
+
* {"token":"live","caption":"Live"},{"token":"vod","caption":"VOD"}]},
|
|
13
|
+
* {"token":"people","caption":"People","count":3}
|
|
14
|
+
* ]'></neodigm-buffet>
|
|
15
|
+
* ```
|
|
16
|
+
*
|
|
17
|
+
* The metadata is the schema `m5t-primary-nav` already uses — `token`,
|
|
18
|
+
* `caption`, `enabled`, `selected`, `icon`, `tagline`, `count`, children under
|
|
19
|
+
* `secondary` — so existing navigation data drops straight in. `data-buttons`
|
|
20
|
+
* is read as an alias for the same reason. What did not come across, and why:
|
|
21
|
+
*
|
|
22
|
+
* - **The logo, PubSub, the i18n listener and `admin_role`** belong to the app.
|
|
23
|
+
* An app that re-resolves its labels sets `items` again; selection survives
|
|
24
|
+
* wherever the tokens do.
|
|
25
|
+
* - **Re-rendering on every click.** That one rebuilt its whole shadow root per
|
|
26
|
+
* selection, which dropped keyboard focus. Here the top row is built once per
|
|
27
|
+
* `items` and selection only moves attributes.
|
|
28
|
+
* - **`innerHTML` with captions and taglines**, which made every label an
|
|
29
|
+
* injection point. Labels are text nodes and the tagline is a `title`.
|
|
30
|
+
* - **A missing `enabled` meant disabled.** Only `enabled: false` disables.
|
|
31
|
+
* - **`icon` is not rendered.** It held a Font Awesome class there, which
|
|
32
|
+
* cannot style anything inside a shadow root, and icon fonts are not
|
|
33
|
+
* wanted here at all. Icons are deferred; until then the field is accepted
|
|
34
|
+
* and ignored, so existing data neither breaks nor prints class names.
|
|
35
|
+
*
|
|
36
|
+
* **Theming.** The whole menu is one instrument: `data-n55-theme` on the host
|
|
37
|
+
* colours the selection and the child row, so a `robinTheme()` sweep recolours
|
|
38
|
+
* the menu as a unit. An item may carry a `theme` of its own as an accent; that
|
|
39
|
+
* lives inside the shadow root, so a sweep passes it by — deliberately, it is
|
|
40
|
+
* the item's identity rather than page state.
|
|
41
|
+
*/
|
|
42
|
+
import { THEMES, THEME_ATTR } from '../../conductor/theme.js';
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The four colours the menu is painted from, for one theme: two surfaces and
|
|
46
|
+
* the palette's ink for each. Every theme sets all four, so an accent inside a
|
|
47
|
+
* menu of another theme never inherits the wrong ink.
|
|
48
|
+
*/
|
|
49
|
+
const colours = (name) =>
|
|
50
|
+
`--_fill: var(--n55-${name}); --_ink: var(--n55-${name}-ink); ` +
|
|
51
|
+
`--_row: var(--n55-${name}-deep); --_row-ink: var(--n55-${name}-deep-ink);`;
|
|
52
|
+
|
|
53
|
+
const STYLES = `
|
|
54
|
+
:host {
|
|
55
|
+
/* Unthemed: the palette's inverse surface for the selection — dark on a
|
|
56
|
+
light page, light on a dark one — and a dark child row in both. The
|
|
57
|
+
literals are fallbacks for a page that never called installTheme(). */
|
|
58
|
+
--_fill: var(--n55-inverse, #1a1a1a);
|
|
59
|
+
--_ink: var(--n55-inverse-ink, #fff);
|
|
60
|
+
--_row: #2b2b2b;
|
|
61
|
+
--_row-ink: #fff;
|
|
62
|
+
|
|
63
|
+
display: block;
|
|
64
|
+
inline-size: 100%;
|
|
65
|
+
box-sizing: border-box;
|
|
66
|
+
font: 600 .875rem/1.2 system-ui, sans-serif;
|
|
67
|
+
user-select: none;
|
|
68
|
+
-webkit-user-select: none;
|
|
69
|
+
}
|
|
70
|
+
:host([hidden]) { display: none; }
|
|
71
|
+
|
|
72
|
+
/* Theme comes from the host attribute, which is what a sweep sets. Generated
|
|
73
|
+
from the palette so a new theme cannot be missed here. */
|
|
74
|
+
${Object.keys(THEMES).map((name) => `
|
|
75
|
+
:host([${THEME_ATTR}="${name}"]) { ${colours(name)} }`).join('')}
|
|
76
|
+
|
|
77
|
+
/* Per-item accents. Not data-n55-theme: that attribute is the page's
|
|
78
|
+
contract, and putting it on a shadow-internal node implies a sweep can
|
|
79
|
+
reach it, which it cannot. */
|
|
80
|
+
${Object.keys(THEMES).map((name) => `
|
|
81
|
+
[data-accent="${name}"] { ${colours(name)} }`).join('')}
|
|
82
|
+
|
|
83
|
+
.primary, .secondary {
|
|
84
|
+
display: grid;
|
|
85
|
+
grid-auto-flow: column;
|
|
86
|
+
/* minmax(0, …) rather than 1fr, so a long caption truncates instead of
|
|
87
|
+
widening its column: the width is the container's divided by the count. */
|
|
88
|
+
grid-auto-columns: minmax(0, 1fr);
|
|
89
|
+
}
|
|
90
|
+
.primary {
|
|
91
|
+
block-size: var(--n55-buffet-height, 64px);
|
|
92
|
+
/* The page's surface, so the top row follows light and dark. */
|
|
93
|
+
background: var(--n55-surface, #fff);
|
|
94
|
+
border-block-end: 1px solid var(--n55-line, rgb(0 0 0 / .12));
|
|
95
|
+
}
|
|
96
|
+
.secondary {
|
|
97
|
+
block-size: calc(var(--n55-buffet-height, 64px) / 2);
|
|
98
|
+
background: var(--_row);
|
|
99
|
+
color: var(--_row-ink);
|
|
100
|
+
font-size: .8em;
|
|
101
|
+
font-weight: 500;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
.item {
|
|
105
|
+
position: relative;
|
|
106
|
+
display: flex;
|
|
107
|
+
align-items: center;
|
|
108
|
+
justify-content: center;
|
|
109
|
+
gap: .4em;
|
|
110
|
+
min-inline-size: 0;
|
|
111
|
+
margin: 0;
|
|
112
|
+
padding: 0 .75em;
|
|
113
|
+
border: 0;
|
|
114
|
+
border-inline-start: 1px solid var(--n55-line, rgb(0 0 0 / .12));
|
|
115
|
+
background: transparent;
|
|
116
|
+
color: inherit;
|
|
117
|
+
font: inherit;
|
|
118
|
+
cursor: pointer;
|
|
119
|
+
transition: background-color .2s ease, color .2s ease;
|
|
120
|
+
}
|
|
121
|
+
.item:first-child { border-inline-start: 0; }
|
|
122
|
+
.primary .item { color: var(--n55-surface-ink, #1a1a1a); }
|
|
123
|
+
.secondary .item { border-inline-start-color: rgb(255 255 255 / .14); }
|
|
124
|
+
|
|
125
|
+
.primary .item:hover:not(:disabled):not([aria-current]) {
|
|
126
|
+
background: color-mix(in srgb, var(--_fill) 14%, transparent);
|
|
127
|
+
}
|
|
128
|
+
.secondary .item:hover:not(:disabled):not([aria-current]) {
|
|
129
|
+
background: color-mix(in srgb, var(--_row-ink) 12%, transparent);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/* Selected, at either level: the theme's colour with its own ink, plus a bar
|
|
133
|
+
along the bottom so selection never rests on colour alone — white on a
|
|
134
|
+
white row would otherwise be invisible. */
|
|
135
|
+
.item[aria-current] {
|
|
136
|
+
background: var(--_fill);
|
|
137
|
+
color: var(--_ink);
|
|
138
|
+
box-shadow: inset 0 -3px 0 var(--_ink);
|
|
139
|
+
}
|
|
140
|
+
.secondary .item[aria-current] { box-shadow: inset 0 -2px 0 var(--_ink); }
|
|
141
|
+
|
|
142
|
+
.item:focus-visible { outline: 2px solid currentColor; outline-offset: -5px; }
|
|
143
|
+
.item:disabled { opacity: .45; cursor: not-allowed; }
|
|
144
|
+
|
|
145
|
+
.caption { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
146
|
+
.count {
|
|
147
|
+
position: absolute;
|
|
148
|
+
inset-block-start: 6px;
|
|
149
|
+
inset-inline-end: 8px;
|
|
150
|
+
min-inline-size: 1.5em;
|
|
151
|
+
padding: .15em .45em;
|
|
152
|
+
border-radius: 1em;
|
|
153
|
+
box-sizing: border-box;
|
|
154
|
+
background: var(--_row);
|
|
155
|
+
color: var(--_row-ink);
|
|
156
|
+
font-size: .75em;
|
|
157
|
+
line-height: 1.2;
|
|
158
|
+
}
|
|
159
|
+
.secondary .count { inset-block-start: 2px; }
|
|
160
|
+
|
|
161
|
+
@media (prefers-reduced-motion: reduce) {
|
|
162
|
+
.item { transition: none; }
|
|
163
|
+
}
|
|
164
|
+
`;
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Reads menu metadata: a JSON array, or an array already parsed.
|
|
168
|
+
*
|
|
169
|
+
* Never throws. A menu that renders empty is easier to diagnose than one that
|
|
170
|
+
* takes the page down, and anything without a string `token` is dropped,
|
|
171
|
+
* because selection, events and `updateCount` are all keyed by it.
|
|
172
|
+
*
|
|
173
|
+
* @param {string|unknown[]|null} raw
|
|
174
|
+
* @returns {object[]}
|
|
175
|
+
*/
|
|
176
|
+
export function readItems(raw) {
|
|
177
|
+
let list = raw;
|
|
178
|
+
if (typeof raw === 'string') {
|
|
179
|
+
try { list = JSON.parse(raw); } catch { return []; }
|
|
180
|
+
}
|
|
181
|
+
if (!Array.isArray(list)) return [];
|
|
182
|
+
const clean = (item) => item && typeof item === 'object' && typeof item.token === 'string';
|
|
183
|
+
return list.filter(clean).map((item) => ({
|
|
184
|
+
...item,
|
|
185
|
+
secondary: Array.isArray(item.secondary)
|
|
186
|
+
? item.secondary.filter(clean).map((child) => ({ ...child }))
|
|
187
|
+
: []
|
|
188
|
+
}));
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const usable = (item) => item && item.enabled !== false;
|
|
192
|
+
const copy = (item) => (item ? JSON.parse(JSON.stringify(item)) : null);
|
|
193
|
+
|
|
194
|
+
export class NeodigmBuffet extends HTMLElement {
|
|
195
|
+
#items = [];
|
|
196
|
+
#primary = null;
|
|
197
|
+
#secondary = null;
|
|
198
|
+
/** primary token → the child last selected under it */
|
|
199
|
+
#memory = new Map();
|
|
200
|
+
|
|
201
|
+
static get observedAttributes() { return ['data-n55-buffet', 'data-buttons']; }
|
|
202
|
+
|
|
203
|
+
constructor() {
|
|
204
|
+
super();
|
|
205
|
+
this.attachShadow({ mode: 'open' });
|
|
206
|
+
this.shadowRoot.addEventListener('click', this.#onClick);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
connectedCallback() {
|
|
210
|
+
if (!this.shadowRoot.firstChild) {
|
|
211
|
+
this.shadowRoot.innerHTML =
|
|
212
|
+
`<style>${STYLES}</style>` +
|
|
213
|
+
`<div class="primary" part="primary"></div>` +
|
|
214
|
+
`<div class="secondary" part="secondary"></div>`;
|
|
215
|
+
}
|
|
216
|
+
if (!this.hasAttribute('role')) this.setAttribute('role', 'navigation');
|
|
217
|
+
if (!this.#items.length) this.#setItems(readItems(this.#source()));
|
|
218
|
+
else this.#render();
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
attributeChangedCallback(name, before, after) {
|
|
222
|
+
if (before === after) return;
|
|
223
|
+
this.#setItems(readItems(this.#source()));
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** The menu's metadata. A copy: change it by assigning, not by mutating. */
|
|
227
|
+
get items() { return copy(this.#items); }
|
|
228
|
+
set items(value) { this.#setItems(readItems(value)); }
|
|
229
|
+
|
|
230
|
+
/** The current selection at both levels. */
|
|
231
|
+
get selection() { return { primary: this.#primary, secondary: this.#secondary }; }
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Selects by token without announcing it, for keeping the menu in step with
|
|
235
|
+
* a route. Clicks announce; this does not, or syncing would loop.
|
|
236
|
+
*
|
|
237
|
+
* @param {string} primary
|
|
238
|
+
* @param {string|null} [secondary] Omitted: the child last chosen under it.
|
|
239
|
+
* @returns {boolean} false if either token is unknown or disabled.
|
|
240
|
+
*/
|
|
241
|
+
select(primary, secondary) {
|
|
242
|
+
const item = this.#find(primary);
|
|
243
|
+
if (!usable(item)) return false;
|
|
244
|
+
let child = secondary === undefined ? this.#remembered(item) : secondary;
|
|
245
|
+
if (child !== null && !usable(item.secondary.find((c) => c.token === child))) return false;
|
|
246
|
+
this.#apply(primary, child);
|
|
247
|
+
return true;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Updates a badge in place. `null` removes it.
|
|
252
|
+
* @returns {boolean} false if no item has that token.
|
|
253
|
+
*/
|
|
254
|
+
updateCount(token, count) {
|
|
255
|
+
for (const item of this.#items) {
|
|
256
|
+
const hit = item.token === token ? item : item.secondary.find((c) => c.token === token);
|
|
257
|
+
if (!hit) continue;
|
|
258
|
+
if (typeof count === 'number') hit.count = count; else delete hit.count;
|
|
259
|
+
const level = hit === item ? 'primary' : 'secondary';
|
|
260
|
+
const button = [...this.shadowRoot.querySelectorAll(`.${level} .item`)]
|
|
261
|
+
.find((b) => b.dataset.token === token);
|
|
262
|
+
if (button) this.#fill(button, hit);
|
|
263
|
+
return true;
|
|
264
|
+
}
|
|
265
|
+
return false;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
#source() {
|
|
269
|
+
return this.getAttribute('data-n55-buffet') ?? this.getAttribute('data-buttons');
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
#find(token) { return this.#items.find((item) => item.token === token) ?? null; }
|
|
273
|
+
|
|
274
|
+
#remembered(item) {
|
|
275
|
+
const token = this.#memory.get(item.token);
|
|
276
|
+
return usable(item.secondary.find((c) => c.token === token)) ? token : null;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Takes new metadata, keeping the selection wherever its tokens survive —
|
|
281
|
+
* which is what lets an app swap in relabelled items without the menu
|
|
282
|
+
* jumping.
|
|
283
|
+
*/
|
|
284
|
+
#setItems(items) {
|
|
285
|
+
this.#items = items;
|
|
286
|
+
for (const item of items) {
|
|
287
|
+
const flagged = item.secondary.find((c) => c.selected && usable(c));
|
|
288
|
+
if (flagged) this.#memory.set(item.token, flagged.token);
|
|
289
|
+
}
|
|
290
|
+
const kept = usable(this.#find(this.#primary)) ? this.#primary : null;
|
|
291
|
+
const primary = kept
|
|
292
|
+
?? items.find((i) => i.selected && usable(i))?.token
|
|
293
|
+
?? items.find(usable)?.token
|
|
294
|
+
?? null;
|
|
295
|
+
const item = this.#find(primary);
|
|
296
|
+
const child = item?.secondary.find((c) => c.token === this.#secondary && usable(c))
|
|
297
|
+
? this.#secondary
|
|
298
|
+
: item ? this.#remembered(item) : null;
|
|
299
|
+
this.#primary = primary;
|
|
300
|
+
this.#secondary = child;
|
|
301
|
+
this.#render();
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
#render() {
|
|
305
|
+
const row = this.shadowRoot.querySelector('.primary');
|
|
306
|
+
if (!row) return; // not connected yet; connectedCallback renders
|
|
307
|
+
row.replaceChildren(...this.#items.map((item) => this.#button(item, 'primary')));
|
|
308
|
+
this.#renderChildren();
|
|
309
|
+
this.#paint();
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
#renderChildren() {
|
|
313
|
+
const row = this.shadowRoot.querySelector('.secondary');
|
|
314
|
+
if (!row) return;
|
|
315
|
+
const item = this.#find(this.#primary);
|
|
316
|
+
row.replaceChildren(...(item?.secondary ?? []).map((c) => this.#button(c, 'secondary')));
|
|
317
|
+
// The child row belongs to its parent, so it takes the parent's accent.
|
|
318
|
+
this.#accent(row, item?.theme);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
#apply(primary, secondary) {
|
|
322
|
+
const moved = primary !== this.#primary;
|
|
323
|
+
this.#primary = primary;
|
|
324
|
+
this.#secondary = secondary;
|
|
325
|
+
if (secondary !== null) this.#memory.set(primary, secondary);
|
|
326
|
+
if (moved) this.#renderChildren();
|
|
327
|
+
this.#paint();
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/** Moves the selection markers. Nothing is rebuilt, so focus stays put. */
|
|
331
|
+
#paint() {
|
|
332
|
+
for (const [level, token] of [['primary', this.#primary], ['secondary', this.#secondary]]) {
|
|
333
|
+
for (const button of this.shadowRoot.querySelectorAll(`.${level} .item`)) {
|
|
334
|
+
if (button.dataset.token === token) button.setAttribute('aria-current', 'true');
|
|
335
|
+
else button.removeAttribute('aria-current');
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
#button(item, level) {
|
|
341
|
+
const button = this.ownerDocument.createElement('button');
|
|
342
|
+
button.type = 'button';
|
|
343
|
+
button.className = 'item';
|
|
344
|
+
button.setAttribute('part', `item ${level}-item`);
|
|
345
|
+
button.dataset.token = item.token;
|
|
346
|
+
button.dataset.level = level;
|
|
347
|
+
button.disabled = item.enabled === false;
|
|
348
|
+
if (typeof item.tagline === 'string' && item.tagline) button.title = item.tagline;
|
|
349
|
+
this.#accent(button, item.theme);
|
|
350
|
+
this.#fill(button, item);
|
|
351
|
+
return button;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
#accent(node, theme) {
|
|
355
|
+
if (theme && Object.hasOwn(THEMES, theme)) node.dataset.accent = theme;
|
|
356
|
+
else delete node.dataset.accent;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/** Writes a button's contents as text nodes, never markup. */
|
|
360
|
+
#fill(button, item) {
|
|
361
|
+
const doc = this.ownerDocument;
|
|
362
|
+
const span = (cls, text) => {
|
|
363
|
+
const s = doc.createElement('span');
|
|
364
|
+
s.className = cls;
|
|
365
|
+
s.textContent = text;
|
|
366
|
+
return s;
|
|
367
|
+
};
|
|
368
|
+
const parts = [span('caption', String(item.caption ?? item.token))];
|
|
369
|
+
if (typeof item.count === 'number') parts.push(span('count', String(item.count)));
|
|
370
|
+
button.replaceChildren(...parts);
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
#onClick = (ev) => {
|
|
374
|
+
const button = ev.target.closest?.('.item');
|
|
375
|
+
if (!button || button.disabled) return;
|
|
376
|
+
const { token, level } = button.dataset;
|
|
377
|
+
|
|
378
|
+
let primary = this.#primary;
|
|
379
|
+
let secondary = token;
|
|
380
|
+
let item;
|
|
381
|
+
if (level === 'primary') {
|
|
382
|
+
item = this.#find(token);
|
|
383
|
+
primary = token;
|
|
384
|
+
secondary = item ? this.#remembered(item) : null;
|
|
385
|
+
} else {
|
|
386
|
+
item = this.#find(this.#primary)?.secondary.find((c) => c.token === token);
|
|
387
|
+
}
|
|
388
|
+
if (!item) return;
|
|
389
|
+
|
|
390
|
+
// Cancelable, as Tulip's open is: a page with unsaved work can veto the
|
|
391
|
+
// move. It fires on every click, including the current selection, because
|
|
392
|
+
// re-choosing where you are is how a user asks to go back to its start.
|
|
393
|
+
const proceed = this.dispatchEvent(new CustomEvent('neodigm-buffet-select', {
|
|
394
|
+
bubbles: true, composed: true, cancelable: true,
|
|
395
|
+
detail: { level, token, item: copy(item), primary, secondary }
|
|
396
|
+
}));
|
|
397
|
+
if (proceed) this.#apply(primary, secondary);
|
|
398
|
+
};
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
if (!customElements.get('neodigm-buffet')) {
|
|
402
|
+
customElements.define('neodigm-buffet', NeodigmBuffet);
|
|
403
|
+
}
|
|
@@ -109,7 +109,7 @@ const STYLES = `
|
|
|
109
109
|
text-align: center;
|
|
110
110
|
box-shadow: 0 4px 16px rgb(0 0 0 / .24);
|
|
111
111
|
background: var(--n55-brand, #2b2b2b);
|
|
112
|
-
color: var(--n55-brand-
|
|
112
|
+
color: var(--n55-brand-ink, #fff);
|
|
113
113
|
}
|
|
114
114
|
:host([open]) { opacity: 1; visibility: visible; }
|
|
115
115
|
:host([hidden]) { display: none; }
|
|
@@ -122,10 +122,8 @@ const STYLES = `
|
|
|
122
122
|
${Object.keys(THEMES).map((name) => `
|
|
123
123
|
:host([${THEME_ATTR}="${name}"]) {
|
|
124
124
|
background: var(--n55-${name});
|
|
125
|
-
color: var(--n55-${name}-
|
|
125
|
+
color: var(--n55-${name}-ink);
|
|
126
126
|
}`).join('')}
|
|
127
|
-
:host([${THEME_ATTR}="white"]),
|
|
128
|
-
:host([${THEME_ATTR}="ghost"]) { color: #1a1a1a; }
|
|
129
127
|
|
|
130
128
|
.msg { margin: 0; }
|
|
131
129
|
.icon { margin-inline-end: 6px; font-style: normal; }
|