@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 +9 -0
- package/dist/clipboard.d.ts +4 -0
- package/dist/clipboard.js +30 -0
- package/dist/colorNames.d.ts +10 -0
- package/dist/colorNames.js +61 -0
- package/dist/colorPicker.d.ts +59 -3
- package/dist/colorPicker.js +181 -73
- package/dist/dialog.d.ts +5 -0
- package/dist/dialog.js +27 -22
- package/dist/index.d.ts +6 -0
- package/dist/index.js +6 -0
- package/dist/marks.d.ts +24 -0
- package/dist/marks.js +24 -0
- package/dist/menu.d.ts +107 -0
- package/dist/menu.js +163 -0
- package/dist/meter.d.ts +32 -0
- package/dist/meter.js +44 -0
- package/dist/pill.d.ts +3 -0
- package/dist/pill.js +7 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/window.d.ts +29 -0
- package/dist/window.js +62 -0
- package/glyph-map.md +48 -0
- package/glyphs.json +247 -0
- package/glyphs.md +28 -2
- package/package.json +1 -1
- package/tokens.css +530 -1
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
|
+
}
|
package/dist/colorPicker.d.ts
CHANGED
|
@@ -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
|
|
12
|
-
*
|
|
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
|
|
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 {};
|
package/dist/colorPicker.js
CHANGED
|
@@ -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
|
-
|
|
9
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
|
75
|
-
*
|
|
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
|
|
79
|
-
el
|
|
80
|
-
|
|
81
|
-
sv
|
|
82
|
-
|
|
83
|
-
|
|
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(
|
|
86
|
-
hue.className =
|
|
87
|
-
|
|
88
|
-
|
|
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
|
|
155
|
+
function at(event) {
|
|
103
156
|
const rect = target.getBoundingClientRect();
|
|
104
|
-
|
|
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
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
target.addEventListener(
|
|
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(
|
|
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
|
-
|
|
143
|
-
|
|
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(
|
|
159
|
-
panel.className =
|
|
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(
|
|
162
|
-
hexInput.type =
|
|
163
|
-
hexInput.className =
|
|
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(
|
|
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(
|
|
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
|
|
183
|
-
|
|
184
|
-
|
|
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(
|
|
201
|
-
document.removeEventListener(
|
|
202
|
-
window.removeEventListener(
|
|
203
|
-
window.removeEventListener(
|
|
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 ===
|
|
328
|
+
if (event.key === "Escape")
|
|
221
329
|
close();
|
|
222
330
|
}
|
|
223
|
-
document.addEventListener(
|
|
224
|
-
document.addEventListener(
|
|
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(
|
|
227
|
-
window.addEventListener(
|
|
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
|
|
19
|
-
|
|
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";
|