@glimstone/core 4.0.0 → 4.1.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 CHANGED
@@ -22,6 +22,15 @@ npm install @glimstone/core
22
22
  select's list model, the Info tile's version links and update check, the
23
23
  README button's classes, where a bubble goes, a dialog's keys and the toast
24
24
  queue.
25
+ - What windows and menus share: `windowFoot` adds the way out, `stackWindow`
26
+ gives Escape and Tab to the window in front, and `menu.ts` holds the menu
27
+ model with `tidyMenu`, `menuRowView`, `placeMenu`, `menuMove`,
28
+ `focusFirstMenuRow` and `claimMenu`. `menuIsOpen` and `windowIsOpen` tell an app's own key handler
29
+ to stand back.
30
+ - What fields and bars share: `meterView` and `progressView` for a Meter and a
31
+ ProgressBar, `copyText` with its fallback for plain http, and for the
32
+ ColorPicker `floatColorPicker`, `placeColorPicker` and `nearestColorName`,
33
+ which finds the closest name in OKLab in a list such as `COLOR_NAMES_DE`.
25
34
  - What runs without a framework: `mountSelect`, `wireTooltips` with
26
35
  `infoIcon`, `openColorPickerPopover`, `attachNumberSteppers`,
27
36
  `enableSelectScroll` and `flagEmoji`.
@@ -0,0 +1,4 @@
1
+ /** How long a copy button says it copied before it reads Copy again, in ms. */
2
+ export declare const COPIED_MS = 1600;
3
+ /** Puts `text` on the clipboard. Resolves false when the browser refused both ways. */
4
+ export declare function copyText(text: string): Promise<boolean>;
@@ -0,0 +1,30 @@
1
+ // Copying a value for a CopyField, or for any button that copies. The
2
+ // Clipboard API exists only in a secure context, and a self-hosted app is
3
+ // often opened over plain http on the local network, so a hidden text area
4
+ // and execCommand stand in there.
5
+ /** How long a copy button says it copied before it reads Copy again, in ms. */
6
+ export const COPIED_MS = 1600;
7
+ /** Puts `text` on the clipboard. Resolves false when the browser refused both ways. */
8
+ export async function copyText(text) {
9
+ if (window.isSecureContext && navigator.clipboard) {
10
+ try {
11
+ await navigator.clipboard.writeText(text);
12
+ return true;
13
+ }
14
+ catch {
15
+ // A denied permission falls through to the old way, which needs none.
16
+ }
17
+ }
18
+ const area = document.createElement("textarea");
19
+ area.value = text;
20
+ area.setAttribute("readonly", "");
21
+ area.style.position = "fixed";
22
+ area.style.opacity = "0";
23
+ const back = document.activeElement instanceof HTMLElement ? document.activeElement : null;
24
+ document.body.appendChild(area);
25
+ area.select();
26
+ const done = document.execCommand("copy");
27
+ area.remove();
28
+ back?.focus();
29
+ return done;
30
+ }
@@ -0,0 +1,10 @@
1
+ /** A colour's name and the hex value that stands for it. */
2
+ export type ColorName = readonly [name: string, hex: string];
3
+ /** About ninety German colour names, from white through the greys, reds, yellows, greens and blues to the browns. */
4
+ export declare const COLOR_NAMES_DE: readonly ColorName[];
5
+ type Lab = readonly [number, number, number];
6
+ /** A `#rrggbb` colour in OKLab, as lightness and the two opponent axes. */
7
+ export declare function oklab(hex: string): Lab;
8
+ /** The name in `names` that looks closest to `hex`, or null for an empty list or a value that is no hex colour. */
9
+ export declare function nearestColorName(hex: string, names?: readonly ColorName[]): string | null;
10
+ export {};
@@ -0,0 +1,61 @@
1
+ // The name a picked colour proposes, such as "Petrol" for #1b6b73. The nearest
2
+ // entry wins by distance in OKLab, which tracks how far apart two colours look
3
+ // far better than RGB or HSV, where a step in blue reads smaller than the same
4
+ // step in green.
5
+ /** About ninety German colour names, from white through the greys, reds, yellows, greens and blues to the browns. */
6
+ export const COLOR_NAMES_DE = [
7
+ ["Weiß", "#f7f7f5"], ["Elfenbein", "#fffff0"], ["Creme", "#f5ecd0"], ["Champagner", "#f1ddb2"], ["Beige", "#d9c8a9"], ["Sand", "#c8b48a"],
8
+ ["Hellgrau", "#c8c8c8"], ["Silber", "#b4b8bc"], ["Grau", "#8a8a8a"], ["Steingrau", "#787366"], ["Schiefer", "#5a6470"], ["Anthrazit", "#383e42"], ["Schwarz", "#141414"],
9
+ ["Rot", "#e53935"], ["Karmin", "#c3002f"], ["Kirschrot", "#b5122e"], ["Rubin", "#9b111e"], ["Weinrot", "#7b1f2e"], ["Bordeaux", "#5e1224"],
10
+ ["Ziegelrot", "#b5452c"], ["Rostrot", "#8f3b1b"], ["Koralle", "#ff7f61"], ["Lachs", "#fa8c73"], ["Altrosa", "#c08081"], ["Rosa", "#f4a6c0"],
11
+ ["Pink", "#ff4fa3"], ["Magenta", "#d6008f"], ["Himbeere", "#c51f5d"],
12
+ ["Orange", "#ff8a1f"], ["Apricot", "#fbb57c"], ["Pfirsich", "#ffcba4"], ["Kupfer", "#b87333"], ["Terrakotta", "#c4622d"], ["Bernstein", "#ffb000"],
13
+ ["Ocker", "#cc9a2e"], ["Senf", "#d2a91f"], ["Gold", "#d4af37"], ["Gelb", "#f7d417"], ["Zitronengelb", "#fff44f"], ["Vanille", "#f3e5ab"],
14
+ ["Limette", "#a6d608"], ["Gelbgrün", "#9acd32"], ["Maigrün", "#4caf50"], ["Grün", "#2e9e4f"], ["Grasgrün", "#5aa832"], ["Smaragd", "#079b6b"],
15
+ ["Tannengrün", "#1e4d36"], ["Olivgrün", "#6b7a2a"], ["Oliv", "#808000"], ["Khaki", "#b5a86a"], ["Salbei", "#9caf88"], ["Mint", "#98e2c6"], ["Pistazie", "#b6d39a"],
16
+ ["Türkis", "#1fc0b4"], ["Aquamarin", "#6fd6c8"], ["Petrol", "#1b6b73"], ["Cyan", "#18c3e6"], ["Himmelblau", "#6ec3f2"], ["Hellblau", "#a7d3f5"],
17
+ ["Eisblau", "#cfe8f7"], ["Azurblau", "#2a8ff7"], ["Blau", "#1e6fe0"], ["Königsblau", "#2340b5"], ["Stahlblau", "#4682b4"], ["Taubenblau", "#7d93a8"],
18
+ ["Jeansblau", "#4a6a94"], ["Marine", "#1b2a5a"], ["Nachtblau", "#0f1b3d"], ["Indigo", "#3f3fb5"],
19
+ ["Lavendel", "#b7a5e3"], ["Flieder", "#c8a2d6"], ["Violett", "#7f3fbf"], ["Lila", "#9b59d0"], ["Purpur", "#80207a"], ["Pflaume", "#6e2a5a"],
20
+ ["Aubergine", "#4b1f3f"], ["Mauve", "#b784a7"],
21
+ ["Braun", "#7b4a2a"], ["Schokobraun", "#4e2c1c"], ["Kaffee", "#6f4e37"], ["Haselnuss", "#a67b5b"], ["Karamell", "#c68e4f"], ["Zimt", "#a5603a"], ["Taupe", "#8b7d6b"],
22
+ ];
23
+ /** A `#rrggbb` colour in OKLab, as lightness and the two opponent axes. */
24
+ export function oklab(hex) {
25
+ const n = parseInt(hex.replace(/^#/, ""), 16);
26
+ const [r, g, b] = [(n >> 16) & 255, (n >> 8) & 255, n & 255].map((v) => {
27
+ const c = v / 255;
28
+ return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
29
+ });
30
+ const l = Math.cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b);
31
+ const m = Math.cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b);
32
+ const s = Math.cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b);
33
+ return [
34
+ 0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s,
35
+ 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s,
36
+ 0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s,
37
+ ];
38
+ }
39
+ // A picker asks on every step of a drag, so each list is converted once.
40
+ const labs = new WeakMap();
41
+ /** The name in `names` that looks closest to `hex`, or null for an empty list or a value that is no hex colour. */
42
+ export function nearestColorName(hex, names = COLOR_NAMES_DE) {
43
+ if (!/^#?[0-9a-f]{6}$/i.test(hex) || names.length === 0)
44
+ return null;
45
+ let table = labs.get(names);
46
+ if (!table) {
47
+ table = names.map(([, h]) => oklab(h));
48
+ labs.set(names, table);
49
+ }
50
+ const [L, A, B] = oklab(hex);
51
+ let best = 0;
52
+ let bestDistance = Infinity;
53
+ table.forEach(([l, a, b], i) => {
54
+ const d = (L - l) ** 2 + (A - a) ** 2 + (B - b) ** 2;
55
+ if (d < bestDistance) {
56
+ bestDistance = d;
57
+ best = i;
58
+ }
59
+ });
60
+ return names[best][0];
61
+ }
@@ -5,13 +5,68 @@ export interface ColorPicker {
5
5
  setValue: (hex: string) => void;
6
6
  /** The picker's current value as a 6-digit lowercase hex string. */
7
7
  getValue: () => string;
8
+ /** Moves the focus to the square, where the arrow keys move the handle. */
9
+ focus: () => void;
8
10
  }
11
+ export interface ColorPickerText {
12
+ /** The square's accessible name. */
13
+ area: string;
14
+ /** The hue bar's accessible name. */
15
+ hue: string;
16
+ }
17
+ /** The words of the ColorPicker components: the widget's two names, the hex field's and Apply. */
18
+ export interface ColorPickerPanelText extends ColorPickerText {
19
+ hex: string;
20
+ apply: string;
21
+ }
22
+ export declare const COLOR_PICKER_TEXT: ColorPickerPanelText;
23
+ export interface Hsv {
24
+ h: number;
25
+ s: number;
26
+ v: number;
27
+ }
28
+ /** A `#rrggbb` value as hue in degrees and saturation and value from 0 to 1, or null if it is no hex colour. */
29
+ export declare function hexToHsv(hex: string): Hsv | null;
30
+ /** The lowercase `#rrggbb` value of a hue, saturation and value. */
31
+ export declare function hsvToHex(h: number, s: number, v: number): string;
32
+ /** Accepts "2f6feb" or "#2F6FEB" and returns "#rrggbb" lowercase, or null if invalid. */
33
+ export declare function normalizeHex(value: string): string | null;
9
34
  /**
10
35
  * Builds an always-visible saturation/value square and hue bar. `onChange`
11
- * fires with a 6-digit lowercase hex string on every mouse or touch drag
12
- * update; a caller that writes to disk should debounce it.
36
+ * fires with a 6-digit lowercase hex string on every step of a drag with
37
+ * mouse, pen or finger and on every arrow key; a caller that writes to disk
38
+ * should debounce it.
39
+ */
40
+ export declare function colorPicker(initialHex: string, onChange: (hex: string) => void, options?: {
41
+ text?: Partial<ColorPickerText>;
42
+ }): ColorPicker;
43
+ interface Box {
44
+ width: number;
45
+ height: number;
46
+ }
47
+ /**
48
+ * Where a picker panel goes beside its trigger: below it when it fits, above
49
+ * it otherwise, and always 8px inside the window.
50
+ */
51
+ export declare function placeColorPicker(trigger: {
52
+ left: number;
53
+ top: number;
54
+ bottom: number;
55
+ }, panel: Box, view: Box): {
56
+ left: number;
57
+ top: number;
58
+ };
59
+ /** The size of the window without its scroll bars, for placing a panel in it. */
60
+ export declare function viewportSize(): Box;
61
+ /**
62
+ * Lets a ColorPicker panel float beside the control that opened it. It is the
63
+ * document's one open menu, so Escape and a click beside it close the picker
64
+ * before a window under it, and opening a menu closes the picker. Tab stays
65
+ * inside the panel, and a scroll or resize closes it, since a fixed panel
66
+ * loses its anchor. `close` hears whether Escape closed it; the focus is then
67
+ * back on the anchor. Returns the cleanup.
13
68
  */
14
- export declare function colorPicker(initialHex: string, onChange: (hex: string) => void): ColorPicker;
69
+ export declare function floatColorPicker(panel: HTMLElement, anchor: HTMLElement, close: (byEscape: boolean) => void): () => void;
15
70
  export interface ColorPickerPopoverHandle {
16
71
  /** Closes the popover; an outside click, Escape, scroll or resize also does. */
17
72
  close: () => void;
@@ -23,3 +78,4 @@ export interface ColorPickerPopoverHandle {
23
78
  * already open.
24
79
  */
25
80
  export declare function openColorPickerPopover(trigger: HTMLElement, initialHex: string, onChange: (hex: string) => void, onClose?: () => void): ColorPickerPopoverHandle;
81
+ export {};
@@ -2,11 +2,22 @@
2
2
  // saturation/value square and a hue bar drawn in the page's own DOM.
3
3
  // `colorPicker()` returns the bare widget; `openColorPickerPopover()` wraps it
4
4
  // in a panel anchored to a trigger, the default for a compact settings card.
5
+ // The ColorPicker components of React and Vue mount the same widget and add the
6
+ // preview, the name and Apply around it.
5
7
  //
6
8
  // A native `<input type="color">` can open the OS picker in a separate window,
7
9
  // outside the page, where no Playwright check can reach it either.
8
- function hexToHsv(hex) {
9
- const m = /^#?([0-9a-f]{6})$/i.exec(hex || '');
10
+ import { keepTabInside } from "./dialog.js";
11
+ import { claimMenu, closeMenuOutside } from "./menu.js";
12
+ export const COLOR_PICKER_TEXT = {
13
+ area: "Saturation and brightness",
14
+ hue: "Hue",
15
+ hex: "Hex",
16
+ apply: "Apply",
17
+ };
18
+ /** A `#rrggbb` value as hue in degrees and saturation and value from 0 to 1, or null if it is no hex colour. */
19
+ export function hexToHsv(hex) {
20
+ const m = /^#?([0-9a-f]{6})$/i.exec(hex || "");
10
21
  const group = m?.[1];
11
22
  if (!group)
12
23
  return null;
@@ -30,7 +41,8 @@ function hexToHsv(hex) {
30
41
  h += 360;
31
42
  return { h, s: mx ? d / mx : 0, v: mx };
32
43
  }
33
- function hsvToHex(h, s, v) {
44
+ /** The lowercase `#rrggbb` value of a hue, saturation and value. */
45
+ export function hsvToHex(h, s, v) {
34
46
  const c = v * s;
35
47
  const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
36
48
  const m = v - c;
@@ -61,89 +73,192 @@ function hsvToHex(h, s, v) {
61
73
  r = c;
62
74
  b = x;
63
75
  }
64
- const f = (u) => Math.round((u + m) * 255).toString(16).padStart(2, '0');
76
+ const f = (u) => Math.round((u + m) * 255).toString(16).padStart(2, "0");
65
77
  return `#${f(r)}${f(g)}${f(b)}`;
66
78
  }
67
79
  /** Accepts "2f6feb" or "#2F6FEB" and returns "#rrggbb" lowercase, or null if invalid. */
68
- function normalizeHex(value) {
69
- const trimmed = value.trim().replace(/^#/, '');
80
+ export function normalizeHex(value) {
81
+ const trimmed = value.trim().replace(/^#/, "");
70
82
  return /^[0-9a-f]{6}$/i.test(trimmed) ? `#${trimmed.toLowerCase()}` : null;
71
83
  }
84
+ const HUE_MAX = 359.9;
85
+ const clamp = (n, lo, hi) => Math.min(hi, Math.max(lo, n));
86
+ /**
87
+ * What an arrow key does to the picker: on the square left and right move the
88
+ * saturation and up and down the brightness, on the hue bar every arrow moves
89
+ * the hue. Shift takes bigger steps, Home and End go to an end of the bar.
90
+ * Returns null for a key the picker leaves alone.
91
+ */
92
+ function pickerKeyStep(part, key, shift, hsv) {
93
+ if (part === "hue") {
94
+ if (key === "Home")
95
+ return { ...hsv, h: 0 };
96
+ if (key === "End")
97
+ return { ...hsv, h: HUE_MAX };
98
+ const dir = { ArrowRight: 1, ArrowUp: 1, ArrowLeft: -1, ArrowDown: -1 }[key];
99
+ return dir ? { ...hsv, h: clamp(hsv.h + dir * (shift ? 30 : 3), 0, HUE_MAX) } : null;
100
+ }
101
+ const step = shift ? 0.1 : 0.02;
102
+ const ds = { ArrowRight: 1, ArrowLeft: -1 }[key] ?? 0;
103
+ const dv = { ArrowUp: 1, ArrowDown: -1 }[key] ?? 0;
104
+ if (!ds && !dv)
105
+ return null;
106
+ return { ...hsv, s: clamp(hsv.s + ds * step, 0, 1), v: clamp(hsv.v + dv * step, 0, 1) };
107
+ }
72
108
  /**
73
109
  * Builds an always-visible saturation/value square and hue bar. `onChange`
74
- * fires with a 6-digit lowercase hex string on every mouse or touch drag
75
- * update; a caller that writes to disk should debounce it.
110
+ * fires with a 6-digit lowercase hex string on every step of a drag with
111
+ * mouse, pen or finger and on every arrow key; a caller that writes to disk
112
+ * should debounce it.
76
113
  */
77
- export function colorPicker(initialHex, onChange) {
78
- const el = document.createElement('div');
79
- el.className = 'glim-picker';
80
- const sv = document.createElement('div');
81
- sv.className = 'glim-picker-sv';
82
- const dot = document.createElement('span');
83
- dot.className = 'glim-picker-dot';
114
+ export function colorPicker(initialHex, onChange, options = {}) {
115
+ const text = { area: COLOR_PICKER_TEXT.area, hue: COLOR_PICKER_TEXT.hue, ...options.text };
116
+ const el = document.createElement("div");
117
+ el.className = "glim-picker";
118
+ const sv = document.createElement("div");
119
+ sv.className = "glim-picker-sv";
120
+ sv.tabIndex = 0;
121
+ sv.setAttribute("role", "slider");
122
+ sv.setAttribute("aria-label", text.area);
123
+ const dot = document.createElement("span");
124
+ dot.className = "glim-picker-dot";
84
125
  sv.appendChild(dot);
85
- const hue = document.createElement('div');
86
- hue.className = 'glim-picker-hue';
87
- const hdot = document.createElement('span');
88
- hdot.className = 'glim-picker-hdot';
126
+ const hue = document.createElement("div");
127
+ hue.className = "glim-picker-hue";
128
+ hue.tabIndex = 0;
129
+ hue.setAttribute("role", "slider");
130
+ hue.setAttribute("aria-label", text.hue);
131
+ hue.setAttribute("aria-valuemin", "0");
132
+ hue.setAttribute("aria-valuemax", "360");
133
+ const hdot = document.createElement("span");
134
+ hdot.className = "glim-picker-hdot";
89
135
  hue.appendChild(hdot);
90
136
  el.append(sv, hue);
91
137
  let state = hexToHsv(initialHex) || { h: 220, s: 0.8, v: 0.9 };
92
138
  function paint() {
139
+ const hex = hsvToHex(state.h, state.s, state.v);
93
140
  sv.style.background = `linear-gradient(to top, #000, rgba(0,0,0,0)), linear-gradient(to right, #fff, hsl(${Math.round(state.h)},100%,50%))`;
94
141
  dot.style.left = `${state.s * 100}%`;
95
142
  dot.style.top = `${(1 - state.v) * 100}%`;
96
143
  hdot.style.left = `${(state.h / 360) * 100}%`;
144
+ el.style.setProperty("--glim-picker-colour", hex);
145
+ // The square moves along two axes, so the one value that names its place is the colour itself.
146
+ sv.setAttribute("aria-valuetext", hex);
147
+ hue.setAttribute("aria-valuenow", String(Math.round(state.h)));
97
148
  }
98
149
  function emit() {
99
150
  onChange(hsvToHex(state.h, state.s, state.v));
100
151
  }
152
+ // Pointer capture keeps the drag going once the pointer leaves the square,
153
+ // for mouse, pen and finger alike, with no listener on the document.
101
154
  function drag(target, apply) {
102
- function move(event) {
155
+ function at(event) {
103
156
  const rect = target.getBoundingClientRect();
104
- const point = 'touches' in event ? (event.touches[0] ?? event.changedTouches[0]) : event;
105
- if (!point)
106
- return;
107
- const x = Math.min(1, Math.max(0, (point.clientX - rect.left) / rect.width));
108
- const y = Math.min(1, Math.max(0, (point.clientY - rect.top) / rect.height));
109
- apply(x, y);
157
+ apply(clamp((event.clientX - rect.left) / rect.width, 0, 1), clamp((event.clientY - rect.top) / rect.height, 0, 1));
110
158
  paint();
111
159
  emit();
112
- event.preventDefault();
113
- }
114
- function up() {
115
- document.removeEventListener('mousemove', move);
116
- document.removeEventListener('mouseup', up);
117
- document.removeEventListener('touchmove', move);
118
- document.removeEventListener('touchend', up);
119
160
  }
120
- function down(event) {
121
- move(event);
122
- document.addEventListener('mousemove', move);
123
- document.addEventListener('mouseup', up);
124
- document.addEventListener('touchmove', move);
125
- document.addEventListener('touchend', up);
126
- }
127
- target.addEventListener('mousedown', down);
128
- target.addEventListener('touchstart', down);
161
+ target.addEventListener("pointerdown", (event) => {
162
+ if (event.button !== 0)
163
+ return;
164
+ event.preventDefault();
165
+ target.focus();
166
+ target.setPointerCapture(event.pointerId);
167
+ at(event);
168
+ });
169
+ target.addEventListener("pointermove", (event) => {
170
+ if (target.hasPointerCapture(event.pointerId))
171
+ at(event);
172
+ });
129
173
  }
130
174
  drag(sv, (x, y) => {
131
175
  state = { ...state, s: x, v: 1 - y };
132
176
  });
133
177
  drag(hue, (x) => {
134
- state = { ...state, h: Math.min(359.9, x * 360) };
178
+ state = { ...state, h: Math.min(HUE_MAX, x * 360) };
135
179
  });
180
+ for (const [part, target] of [["area", sv], ["hue", hue]]) {
181
+ target.addEventListener("keydown", (event) => {
182
+ const next = pickerKeyStep(part, event.key, event.shiftKey, state);
183
+ if (!next)
184
+ return;
185
+ event.preventDefault();
186
+ state = next;
187
+ paint();
188
+ emit();
189
+ });
190
+ }
136
191
  paint();
137
192
  return {
138
193
  el,
139
194
  setValue: (hex) => {
140
195
  const parsed = hexToHsv(hex);
141
- if (parsed) {
142
- state = parsed;
143
- paint();
144
- }
196
+ if (!parsed)
197
+ return;
198
+ // A grey or black has no hue of its own, so the bar stays where it was.
199
+ state = parsed.s === 0 || parsed.v === 0 ? { ...parsed, h: state.h } : parsed;
200
+ paint();
145
201
  },
146
202
  getValue: () => hsvToHex(state.h, state.s, state.v),
203
+ focus: () => sv.focus(),
204
+ };
205
+ }
206
+ /**
207
+ * Where a picker panel goes beside its trigger: below it when it fits, above
208
+ * it otherwise, and always 8px inside the window.
209
+ */
210
+ export function placeColorPicker(trigger, panel, view) {
211
+ const left = Math.max(8, Math.min(view.width - 8 - panel.width, trigger.left));
212
+ const fitsBelow = trigger.bottom + 8 + panel.height <= view.height;
213
+ const top = fitsBelow ? trigger.bottom + 8 : Math.max(8, trigger.top - 8 - panel.height);
214
+ return { left, top };
215
+ }
216
+ /** The size of the window without its scroll bars, for placing a panel in it. */
217
+ export function viewportSize() {
218
+ return {
219
+ width: document.documentElement.clientWidth || window.innerWidth,
220
+ height: document.documentElement.clientHeight || window.innerHeight,
221
+ };
222
+ }
223
+ /**
224
+ * Lets a ColorPicker panel float beside the control that opened it. It is the
225
+ * document's one open menu, so Escape and a click beside it close the picker
226
+ * before a window under it, and opening a menu closes the picker. Tab stays
227
+ * inside the panel, and a scroll or resize closes it, since a fixed panel
228
+ * loses its anchor. `close` hears whether Escape closed it; the focus is then
229
+ * back on the anchor. Returns the cleanup.
230
+ */
231
+ export function floatColorPicker(panel, anchor, close) {
232
+ const at = placeColorPicker(anchor.getBoundingClientRect(), { width: panel.offsetWidth, height: panel.offsetHeight }, viewportSize());
233
+ panel.style.left = `${at.left}px`;
234
+ panel.style.top = `${at.top}px`;
235
+ const release = claimMenu((byEscape) => {
236
+ if (byEscape)
237
+ anchor.focus();
238
+ close(byEscape);
239
+ });
240
+ const outside = closeMenuOutside(() => [panel, anchor], () => close(false));
241
+ const away = (e) => {
242
+ if (!(e.target instanceof Node && panel.contains(e.target)))
243
+ close(false);
244
+ };
245
+ // The panel sits on the body, outside any window, so a window's Tab trap
246
+ // would pull the focus back out of it.
247
+ const onKey = (e) => {
248
+ if (e.key !== "Tab")
249
+ return;
250
+ keepTabInside(panel, e);
251
+ e.stopPropagation();
252
+ };
253
+ window.addEventListener("scroll", away, true);
254
+ window.addEventListener("resize", away);
255
+ panel.addEventListener("keydown", onKey);
256
+ return () => {
257
+ release();
258
+ outside();
259
+ window.removeEventListener("scroll", away, true);
260
+ window.removeEventListener("resize", away);
261
+ panel.removeEventListener("keydown", onKey);
147
262
  };
148
263
  }
149
264
  let openPopover = null;
@@ -155,21 +270,21 @@ let openPopover = null;
155
270
  */
156
271
  export function openColorPickerPopover(trigger, initialHex, onChange, onClose) {
157
272
  openPopover?.close();
158
- const panel = document.createElement('div');
159
- panel.className = 'glim-picker-popover';
273
+ const panel = document.createElement("div");
274
+ panel.className = "glim-picker-popover";
160
275
  // Dragging updates the hex text, and typing a valid hex moves the picker.
161
- const hexInput = document.createElement('input');
162
- hexInput.type = 'text';
163
- hexInput.className = 'glim-picker-hex';
276
+ const hexInput = document.createElement("input");
277
+ hexInput.type = "text";
278
+ hexInput.className = "glim-picker-hex";
164
279
  hexInput.maxLength = 7;
165
280
  hexInput.spellcheck = false;
166
281
  hexInput.value = initialHex;
167
- hexInput.setAttribute('aria-label', 'Hex');
282
+ hexInput.setAttribute("aria-label", "Hex");
168
283
  const picker = colorPicker(initialHex, (hex) => {
169
284
  hexInput.value = hex;
170
285
  onChange(hex);
171
286
  });
172
- hexInput.addEventListener('input', () => {
287
+ hexInput.addEventListener("input", () => {
173
288
  const normalized = normalizeHex(hexInput.value);
174
289
  if (!normalized)
175
290
  return;
@@ -179,16 +294,9 @@ export function openColorPickerPopover(trigger, initialHex, onChange, onClose) {
179
294
  panel.append(picker.el, hexInput);
180
295
  document.body.appendChild(panel);
181
296
  function position() {
182
- const rect = trigger.getBoundingClientRect();
183
- const vw = document.documentElement.clientWidth || window.innerWidth;
184
- const vh = document.documentElement.clientHeight || window.innerHeight;
185
- const width = panel.offsetWidth;
186
- const height = panel.offsetHeight;
187
- const left = Math.max(8, Math.min(vw - 8 - width, rect.left));
188
- const fitsBelow = rect.bottom + 8 + height <= vh;
189
- const top = fitsBelow ? rect.bottom + 8 : Math.max(8, rect.top - 8 - height);
190
- panel.style.left = `${left}px`;
191
- panel.style.top = `${top}px`;
297
+ const at = placeColorPicker(trigger.getBoundingClientRect(), { width: panel.offsetWidth, height: panel.offsetHeight }, viewportSize());
298
+ panel.style.left = `${at.left}px`;
299
+ panel.style.top = `${at.top}px`;
192
300
  }
193
301
  position();
194
302
  let closed = false;
@@ -197,10 +305,10 @@ export function openColorPickerPopover(trigger, initialHex, onChange, onClose) {
197
305
  return;
198
306
  closed = true;
199
307
  panel.remove();
200
- document.removeEventListener('pointerdown', onPointerDown, true);
201
- document.removeEventListener('keydown', onKeyDown);
202
- window.removeEventListener('scroll', close, true);
203
- window.removeEventListener('resize', close);
308
+ document.removeEventListener("pointerdown", onPointerDown, true);
309
+ document.removeEventListener("keydown", onKeyDown);
310
+ window.removeEventListener("scroll", close, true);
311
+ window.removeEventListener("resize", close);
204
312
  if (openPopover?.el === panel)
205
313
  openPopover = null;
206
314
  // Re-rendering the trigger's row while the popover is open strands the
@@ -217,14 +325,14 @@ export function openColorPickerPopover(trigger, initialHex, onChange, onClose) {
217
325
  close();
218
326
  }
219
327
  function onKeyDown(event) {
220
- if (event.key === 'Escape')
328
+ if (event.key === "Escape")
221
329
  close();
222
330
  }
223
- document.addEventListener('pointerdown', onPointerDown, true);
224
- document.addEventListener('keydown', onKeyDown);
331
+ document.addEventListener("pointerdown", onPointerDown, true);
332
+ document.addEventListener("keydown", onKeyDown);
225
333
  // A fixed-position popover loses its anchor on scroll or resize.
226
- window.addEventListener('scroll', close, true);
227
- window.addEventListener('resize', close);
334
+ window.addEventListener("scroll", close, true);
335
+ window.addEventListener("resize", close);
228
336
  openPopover = { el: panel, close };
229
337
  return { close };
230
338
  }
package/dist/dialog.d.ts CHANGED
@@ -1,3 +1,8 @@
1
+ /**
2
+ * Turns a Tab that would leave the card back to its first or last control.
3
+ * Focus that sits outside the card, on <body> for one, is pulled back in.
4
+ */
5
+ export declare function keepTabInside(card: HTMLElement | null, e: KeyboardEvent): void;
1
6
  /**
2
7
  * Escape from anywhere and a Tab trap over the dialog's own controls, so focus
3
8
  * never reaches the page it covers. The listener sits on the document because
package/dist/dialog.js CHANGED
@@ -2,6 +2,31 @@
2
2
  // "Behaviour contracts", the dialog rows): Escape cancels from anywhere, Tab
3
3
  // stays inside, and focus goes back to the control that asked.
4
4
  const FOCUSABLE = 'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
5
+ /**
6
+ * Turns a Tab that would leave the card back to its first or last control.
7
+ * Focus that sits outside the card, on <body> for one, is pulled back in.
8
+ */
9
+ export function keepTabInside(card, e) {
10
+ if (!card)
11
+ return;
12
+ const focusables = Array.from(card.querySelectorAll(FOCUSABLE));
13
+ if (focusables.length === 0)
14
+ return;
15
+ const first = focusables[0];
16
+ const last = focusables[focusables.length - 1];
17
+ const active = document.activeElement;
18
+ const inside = active instanceof Node && card.contains(active);
19
+ if (e.shiftKey) {
20
+ if (!inside || active === first) {
21
+ e.preventDefault();
22
+ last.focus();
23
+ }
24
+ }
25
+ else if (!inside || active === last) {
26
+ e.preventDefault();
27
+ first.focus();
28
+ }
29
+ }
5
30
  /**
6
31
  * Escape from anywhere and a Tab trap over the dialog's own controls, so focus
7
32
  * never reaches the page it covers. The listener sits on the document because
@@ -15,28 +40,8 @@ export function trapDialogKeys(dialog, onCancel) {
15
40
  onCancel();
16
41
  return;
17
42
  }
18
- if (e.key !== "Tab")
19
- return;
20
- const card = dialog();
21
- if (!card)
22
- return;
23
- const focusables = Array.from(card.querySelectorAll(FOCUSABLE));
24
- if (focusables.length === 0)
25
- return;
26
- const first = focusables[0];
27
- const last = focusables[focusables.length - 1];
28
- const active = document.activeElement;
29
- const inside = active instanceof Node && card.contains(active);
30
- if (e.shiftKey) {
31
- if (!inside || active === first) {
32
- e.preventDefault();
33
- last.focus();
34
- }
35
- }
36
- else if (!inside || active === last) {
37
- e.preventDefault();
38
- first.focus();
39
- }
43
+ if (e.key === "Tab")
44
+ keepTabInside(dialog(), e);
40
45
  }
41
46
  document.addEventListener("keydown", onKeyDown);
42
47
  return () => document.removeEventListener("keydown", onKeyDown);
package/dist/index.d.ts CHANGED
@@ -3,6 +3,8 @@ export * from "./appMarks.ts";
3
3
  export * from "./badge.ts";
4
4
  export * from "./bubblePosition.ts";
5
5
  export * from "./button.ts";
6
+ export * from "./clipboard.ts";
7
+ export * from "./colorNames.ts";
6
8
  export * from "./colorPicker.ts";
7
9
  export * from "./controls.ts";
8
10
  export * from "./dialog.ts";
@@ -13,9 +15,12 @@ export * from "./infoTile.ts";
13
15
  export * from "./inputModality.ts";
14
16
  export * from "./marks.ts";
15
17
  export * from "./media.ts";
18
+ export * from "./menu.ts";
19
+ export * from "./meter.ts";
16
20
  export * from "./motionNative.ts";
17
21
  export * from "./numberField.ts";
18
22
  export * from "./paypal.ts";
23
+ export * from "./pill.ts";
19
24
  export * from "./portalHue.ts";
20
25
  export * from "./readmeButton.ts";
21
26
  export * from "./segmentLayout.ts";
@@ -29,3 +34,4 @@ export * from "./tipBubble.ts";
29
34
  export * from "./toastEngine.ts";
30
35
  export * from "./tooltip.ts";
31
36
  export * from "./version.ts";
37
+ export * from "./window.ts";