@raylang/elements 0.0.0-stage → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (95) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +4 -0
  3. package/README.md +12 -2
  4. package/base.css +111 -0
  5. package/components/accordion/accordion.js +197 -0
  6. package/components/alert/alert.js +112 -0
  7. package/components/app-bar/app-bar.js +168 -0
  8. package/components/app-shell/app-shell.js +198 -0
  9. package/components/aspect-ratio/aspect-ratio.js +59 -0
  10. package/components/attachment/attachment.js +333 -0
  11. package/components/avatar/avatar.js +96 -0
  12. package/components/badge/badge.js +63 -0
  13. package/components/blockquote/blockquote.js +128 -0
  14. package/components/bottom-nav/bottom-nav.js +309 -0
  15. package/components/breadcrumbs/breadcrumbs.js +175 -0
  16. package/components/button/button.js +283 -0
  17. package/components/button-group/button-group.js +72 -0
  18. package/components/card/card.js +73 -0
  19. package/components/carousel/carousel.js +435 -0
  20. package/components/center/center.js +26 -0
  21. package/components/checkbox/checkbox.js +152 -0
  22. package/components/checkbox-group/checkbox-group.js +152 -0
  23. package/components/code/code.js +574 -0
  24. package/components/color-picker/color-picker.js +775 -0
  25. package/components/combobox/combobox.js +563 -0
  26. package/components/command-palette/command-palette.js +510 -0
  27. package/components/container/container.js +41 -0
  28. package/components/date-picker/date-picker.js +594 -0
  29. package/components/dialog/dialog.js +329 -0
  30. package/components/drawer/drawer.js +139 -0
  31. package/components/empty-state/empty-state.js +66 -0
  32. package/components/file-upload/file-upload.js +589 -0
  33. package/components/grid/grid.js +96 -0
  34. package/components/group/group.js +40 -0
  35. package/components/icon/icon.js +58 -0
  36. package/components/icon-button/icon-button.js +30 -0
  37. package/components/indicator/indicator.js +112 -0
  38. package/components/input/input.js +85 -0
  39. package/components/kbd/kbd.js +60 -0
  40. package/components/menu/menu.js +296 -0
  41. package/components/nav-link/nav-link.js +159 -0
  42. package/components/notification/notification.js +591 -0
  43. package/components/number-input/number-input.js +241 -0
  44. package/components/pagination/pagination.js +194 -0
  45. package/components/password-input/password-input.js +85 -0
  46. package/components/path-input/path-input.js +137 -0
  47. package/components/popover/popover.js +85 -0
  48. package/components/progress/progress.js +109 -0
  49. package/components/radio/radio.js +253 -0
  50. package/components/scroll-area/scroll-area.js +158 -0
  51. package/components/search/search.js +157 -0
  52. package/components/segmented-control/segmented-control.js +190 -0
  53. package/components/select/select.js +506 -0
  54. package/components/separator/separator.js +58 -0
  55. package/components/sidebar/sidebar.js +252 -0
  56. package/components/skeleton/skeleton.js +90 -0
  57. package/components/slider/slider.js +155 -0
  58. package/components/spinner/spinner.js +39 -0
  59. package/components/split/split.js +221 -0
  60. package/components/stack/stack.js +46 -0
  61. package/components/stat/stat.js +79 -0
  62. package/components/statusbar/statusbar.js +110 -0
  63. package/components/stepper/stepper.js +239 -0
  64. package/components/switch/switch.js +45 -0
  65. package/components/table/table.js +328 -0
  66. package/components/tabs/tabs.js +224 -0
  67. package/components/textarea/textarea.js +105 -0
  68. package/components/theme-toggle/theme-toggle.js +78 -0
  69. package/components/timeline/timeline.js +150 -0
  70. package/components/toggle/toggle.js +130 -0
  71. package/components/toggle-group/toggle-group.js +216 -0
  72. package/components/tooltip/tooltip.js +101 -0
  73. package/core/charts.js +107 -0
  74. package/core/color.js +110 -0
  75. package/core/core.js +556 -0
  76. package/core/dismiss.js +69 -0
  77. package/core/floating.js +195 -0
  78. package/core/focus.js +70 -0
  79. package/core/form.js +235 -0
  80. package/core/host.js +200 -0
  81. package/core/hotkey.js +40 -0
  82. package/core/icons.js +104 -0
  83. package/core/motion.js +73 -0
  84. package/core/position.js +91 -0
  85. package/core/raylang.js +167 -0
  86. package/core/scroll.js +58 -0
  87. package/core/styles.js +211 -0
  88. package/core.js +16 -0
  89. package/custom-elements.json +18545 -0
  90. package/index.js +74 -0
  91. package/package.json +312 -4
  92. package/ray-elements.d.ts +2570 -0
  93. package/tokens.css +796 -0
  94. package/tokens.json +2083 -0
  95. package/vscode.html-custom-data.json +5499 -0
package/core/core.js ADDED
@@ -0,0 +1,556 @@
1
+ // ray-elements core runtime: the base class of every component, shared styles and
2
+ // the theme API. No dependencies; loads as a plain ES module (also from
3
+ // ray://app in a std/ui window).
4
+
5
+ import { getIcon, onIconsChange } from "./icons.js";
6
+ import { parseColor, shades, contrastRatio, toHex, toOklch } from "./color.js";
7
+
8
+ export { setIcons, getIcon, iconNames } from "./icons.js";
9
+
10
+ export const PREFIX = "ray";
11
+
12
+ // ---------------------------------------------------------------------------
13
+ // Styles
14
+ // ---------------------------------------------------------------------------
15
+
16
+ const sheets = new Map();
17
+
18
+ /**
19
+ * A constructable stylesheet from CSS text, shared by every instance (and every
20
+ * component) that asks for the same text.
21
+ * @param {TemplateStringsArray | string} strings
22
+ * @param {...unknown} values
23
+ * @returns {CSSStyleSheet}
24
+ */
25
+ export function css(strings, ...values) {
26
+ const text = typeof strings === "string"
27
+ ? strings
28
+ : strings.reduce((acc, s, i) => acc + s + (i < values.length ? String(values[i]) : ""), "");
29
+ let sheet = sheets.get(text);
30
+ if (!sheet) {
31
+ sheet = new CSSStyleSheet();
32
+ sheet.replaceSync(text);
33
+ sheets.set(text, sheet);
34
+ }
35
+ return sheet;
36
+ }
37
+
38
+ /**
39
+ * Applied until a component's first update has been painted: it does not
40
+ * transition into its initial state. Its styles depend on what update()
41
+ * sets (data-variant, color variables); if the browser computed its style
42
+ * before that (a layout read by another component while the page loads),
43
+ * the variant would animate in from no style at all: black text and a
44
+ * border fading out.
45
+ */
46
+ const settlingStyles = css`*, *::before, *::after { transition: none !important; }`;
47
+
48
+ /** Applied to every component before its own styles. */
49
+ export const hostStyles = css`
50
+ :host { box-sizing: border-box; }
51
+ :host([hidden]) { display: none !important; }
52
+ /* hidden always hides, whatever display a component's own rule gives. */
53
+ [hidden] { display: none !important; }
54
+ *, *::before, *::after { box-sizing: inherit; }
55
+ /* Icons of the registry (paintIcons). Zero specificity: components size them. */
56
+ :where([data-ray-icon]) { display: inline-flex; align-items: center; justify-content: center; flex: none; }
57
+ :where([data-ray-icon]) > svg {
58
+ width: var(--ray-icon-size, 1em);
59
+ height: var(--ray-icon-size, 1em);
60
+ stroke-width: var(--ray-icon-stroke, 2);
61
+ }
62
+ .focus-ring:focus-visible {
63
+ outline: var(--ray-focus-ring-width) solid var(--ray-ring);
64
+ outline-offset: var(--ray-focus-ring-offset);
65
+ }
66
+ @media (prefers-reduced-motion: reduce) {
67
+ *, *::before, *::after { transition-duration: 0s !important; animation-duration: 0s !important; }
68
+ }
69
+ `;
70
+
71
+ // ---------------------------------------------------------------------------
72
+ // Base element
73
+ // ---------------------------------------------------------------------------
74
+
75
+ /**
76
+ * @typedef {object} PropertyOptions
77
+ * @property {StringConstructor | NumberConstructor | BooleanConstructor} [type]
78
+ * @property {boolean} [reflect] write the property back to its attribute
79
+ * @property {string | false} [attribute] attribute name (kebab-case of the property by default); false = property only
80
+ * @property {unknown} [default]
81
+ */
82
+
83
+ // Per-class data kept outside the classes, so subclasses cannot clash with it.
84
+ const templates = new WeakMap();
85
+ const attrMaps = new WeakMap();
86
+ const extraStyles = new WeakMap();
87
+ /** Connected ray-elements elements (restyled by extendStyles, repainted by setIcons). */
88
+ const live = new Set();
89
+ /** Tag -> { property: value }, from configure({ defaults }). */
90
+ const componentDefaults = new Map();
91
+
92
+ /**
93
+ * Base class of ray-elements components.
94
+ *
95
+ * Subclasses declare `static properties`, `static styles` and `static template`,
96
+ * and implement `update(changed)`, called once per microtask after any change
97
+ * (and once on first connection).
98
+ *
99
+ * Extension contract: the public and protected members below are stable;
100
+ * every other member of a ray-elements class whose name starts with `_` or `#` is
101
+ * internal and may change. Do not name your own members with a leading `_`.
102
+ */
103
+ export class RayElement extends HTMLElement {
104
+ /** @type {Record<string, PropertyOptions>} */
105
+ static properties = {};
106
+ /** @type {CSSStyleSheet[]} */
107
+ static styles = [];
108
+ /** Shadow DOM markup, cloned per instance. */
109
+ static template = "";
110
+ /** Forward focus into the first focusable element of the shadow root. */
111
+ static delegatesFocus = false;
112
+
113
+ static get observedAttributes() {
114
+ return [...RayElement.#attributes(this).keys()];
115
+ }
116
+
117
+ /** attribute -> property map of a class, built (with its accessors) once. */
118
+ static #attributes(cls) {
119
+ let map = attrMaps.get(cls);
120
+ if (map) return map;
121
+ map = new Map();
122
+ attrMaps.set(cls, map);
123
+ for (const [name, opts] of Object.entries(cls.properties)) {
124
+ const attr = opts.attribute === false ? null : (opts.attribute ?? kebab(name));
125
+ if (attr) map.set(attr, name);
126
+ Object.defineProperty(cls.prototype, name, {
127
+ configurable: true,
128
+ enumerable: true,
129
+ get() { return this.#get(name); },
130
+ set(v) { this.#set(name, v); },
131
+ });
132
+ }
133
+ return map;
134
+ }
135
+
136
+ /**
137
+ * Adds stylesheets to this component (and its subclasses), also to the
138
+ * instances already on the page: custom variants, app-wide tweaks.
139
+ * RayButton.extendStyles(css`[data-variant="brand"] { … }`)
140
+ */
141
+ static extendStyles(...sheets) {
142
+ extraStyles.set(this, [...(extraStyles.get(this) ?? []), ...sheets]);
143
+ for (const el of live) {
144
+ if (el instanceof this) el.#adoptStyles();
145
+ }
146
+ }
147
+
148
+ #values = new Map();
149
+ #changed = new Map();
150
+ #pending = false;
151
+ #reflecting = false;
152
+ #settled = false;
153
+
154
+ constructor() {
155
+ super();
156
+ const ctor = /** @type {typeof RayElement} */ (this.constructor);
157
+ RayElement.#attributes(ctor);
158
+ this.attachShadow({ mode: "open", delegatesFocus: ctor.delegatesFocus });
159
+ this.#adoptStyles();
160
+ if (ctor.template) {
161
+ let tpl = templates.get(ctor);
162
+ if (!tpl) {
163
+ tpl = document.createElement("template");
164
+ tpl.innerHTML = ctor.template;
165
+ templates.set(ctor, tpl);
166
+ }
167
+ this.shadowRoot.append(tpl.content.cloneNode(true));
168
+ this.paintIcons();
169
+ }
170
+ }
171
+
172
+ #adoptStyles() {
173
+ const ctor = /** @type {typeof RayElement} */ (this.constructor);
174
+ const extra = [];
175
+ // Extensions of ancestors first, then the class's own.
176
+ const chain = [];
177
+ for (let c = ctor; c && c !== RayElement; c = Object.getPrototypeOf(c)) chain.unshift(c);
178
+ for (const c of chain) extra.push(...(extraStyles.get(c) ?? []));
179
+ this.shadowRoot.adoptedStyleSheets = [hostStyles, ...ctor.styles, ...extra, ...(this.#settled ? [] : [settlingStyles])];
180
+ }
181
+
182
+ connectedCallback() {
183
+ const ctor = /** @type {typeof RayElement} */ (this.constructor);
184
+ live.add(this);
185
+ // A framework may have set properties before the element was defined:
186
+ // they shadow the accessors as own properties; move them through the setters.
187
+ for (const name of Object.keys(ctor.properties)) {
188
+ if (Object.prototype.hasOwnProperty.call(this, name)) {
189
+ const v = this[name];
190
+ delete this[name];
191
+ this[name] = v;
192
+ }
193
+ }
194
+ // configure({ defaults }) for what the markup and the code left unset.
195
+ const defaults = componentDefaults.get(this.localName);
196
+ if (defaults) {
197
+ const attrs = RayElement.#attributes(ctor);
198
+ for (const [name, value] of Object.entries(defaults)) {
199
+ if (!(name in ctor.properties) || this.hasValue(name)) continue;
200
+ const attr = [...attrs].find(([, prop]) => prop === name)?.[0];
201
+ if (attr && this.hasAttribute(attr)) continue;
202
+ this[name] = value;
203
+ }
204
+ }
205
+ this.requestUpdate();
206
+ }
207
+
208
+ disconnectedCallback() {
209
+ live.delete(this);
210
+ }
211
+
212
+ attributeChangedCallback(attr, _old, value) {
213
+ if (this.#reflecting) return;
214
+ const ctor = /** @type {typeof RayElement} */ (this.constructor);
215
+ const name = RayElement.#attributes(ctor).get(attr);
216
+ if (!name) return;
217
+ this.commitProperty(name, fromAttribute(value, ctor.properties[name].type));
218
+ }
219
+
220
+ /** The stored value, or the declared default when unset (or set to undefined). */
221
+ #get(name) {
222
+ const v = this.#values.get(name);
223
+ if (v !== undefined) return v;
224
+ return /** @type {typeof RayElement} */ (this.constructor).properties[name].default;
225
+ }
226
+
227
+ #set(name, value) {
228
+ const ctor = /** @type {typeof RayElement} */ (this.constructor);
229
+ const opts = ctor.properties[name];
230
+ if (!this.commitProperty(name, value)) return;
231
+ if (opts.reflect && opts.attribute !== false) {
232
+ const attr = opts.attribute ?? kebab(name);
233
+ this.#reflecting = true;
234
+ try {
235
+ if (value === null || value === undefined || value === false) this.removeAttribute(attr);
236
+ else this.setAttribute(attr, value === true ? "" : String(value));
237
+ } finally {
238
+ this.#reflecting = false;
239
+ }
240
+ }
241
+ }
242
+
243
+ /**
244
+ * Protected. Sets a declared property without reflecting it to its
245
+ * attribute and schedules an update — for state the user changed inside
246
+ * the component (typing, choosing). Returns whether the value changed.
247
+ */
248
+ commitProperty(name, value) {
249
+ const old = this.#get(name);
250
+ if (Object.is(old, value)) return false;
251
+ this.#values.set(name, value);
252
+ if (!this.#changed.has(name)) this.#changed.set(name, old);
253
+ this.requestUpdate();
254
+ return true;
255
+ }
256
+
257
+ /** Protected. Whether a declared property has a value of its own (not the default). */
258
+ hasValue(name) {
259
+ return this.#values.get(name) !== undefined;
260
+ }
261
+
262
+ /** Schedules `update` for the next microtask (coalesces many changes). */
263
+ requestUpdate() {
264
+ if (this.#pending) return;
265
+ this.#pending = true;
266
+ queueMicrotask(() => {
267
+ this.#pending = false;
268
+ if (!this.isConnected) return;
269
+ const changed = this.#changed;
270
+ this.#changed = new Map();
271
+ this.update(changed);
272
+ this.#settle();
273
+ });
274
+ }
275
+
276
+ // After the first update: once a frame has been painted with its initial
277
+ // state (two frames: the first may run before that state is styled),
278
+ // transitions are allowed again.
279
+ #settle() {
280
+ if (this.#settled || this.#settling) return;
281
+ this.#settling = true;
282
+ const done = () => {
283
+ this.#settled = true;
284
+ this.#adoptStyles();
285
+ };
286
+ // A hidden page paints nothing (and runs no frames): nothing to animate.
287
+ if (document.visibilityState === "hidden") queueMicrotask(done);
288
+ else requestAnimationFrame(() => requestAnimationFrame(done));
289
+ }
290
+ #settling = false;
291
+
292
+ /**
293
+ * Protected. Called after property changes. `changed` maps each changed
294
+ * property to its previous value (empty on the first call after connecting).
295
+ * @param {Map<string, unknown>} _changed
296
+ */
297
+ update(_changed) {}
298
+
299
+ /**
300
+ * Dispatches a bubbling, composed CustomEvent.
301
+ * @returns {boolean} false if a listener called preventDefault()
302
+ */
303
+ emit(type, detail = undefined, { cancelable = false } = {}) {
304
+ return this.dispatchEvent(new CustomEvent(type, { detail, bubbles: true, composed: true, cancelable }));
305
+ }
306
+
307
+ /** querySelector inside the shadow root. */
308
+ $(selector) {
309
+ return this.shadowRoot.querySelector(selector);
310
+ }
311
+
312
+ /**
313
+ * Protected. Fills every [data-ray-icon="name"] under `root` (default: the
314
+ * shadow root) with that icon of the registry (setIcons). Components call
315
+ * it for markup they create after the template. An empty name or "none"
316
+ * (the value components use to hide an icon) leaves the slot empty.
317
+ */
318
+ paintIcons(root = this.shadowRoot) {
319
+ for (const slot of root.querySelectorAll("[data-ray-icon]")) {
320
+ const name = slot.getAttribute("data-ray-icon");
321
+ slot.innerHTML = name && name !== "none" ? getIcon(name) : "";
322
+ }
323
+ }
324
+ }
325
+
326
+ /**
327
+ * App-wide settings.
328
+ * configure({ defaults: { "ray-button": { variant: "light", size: "md" } } })
329
+ * Defaults apply to elements connected afterwards, to the properties their
330
+ * markup and code leave unset (an attribute or a property set wins).
331
+ * @param {{ defaults?: Record<string, Record<string, unknown>> }} options
332
+ */
333
+ export function configure({ defaults } = {}) {
334
+ if (defaults) {
335
+ for (const [tag, values] of Object.entries(defaults)) {
336
+ componentDefaults.set(tag, { ...(componentDefaults.get(tag) ?? {}), ...values });
337
+ }
338
+ }
339
+ }
340
+
341
+ /** The defaults configure() set for a tag. */
342
+ export function getDefaults(tag) {
343
+ return { ...(componentDefaults.get(tag) ?? {}) };
344
+ }
345
+
346
+ // setIcons() repaints every connected element.
347
+ onIconsChange(() => {
348
+ for (const el of live) el.paintIcons();
349
+ });
350
+
351
+ function kebab(name) {
352
+ return name.replace(/[A-Z]/g, (c) => "-" + c.toLowerCase());
353
+ }
354
+
355
+ function fromAttribute(value, type) {
356
+ if (type === Boolean) return value !== null;
357
+ if (value === null) return undefined;
358
+ if (type === Number) {
359
+ const n = Number(value);
360
+ return Number.isNaN(n) ? undefined : n;
361
+ }
362
+ return value;
363
+ }
364
+
365
+ /**
366
+ * Registers a custom element unless the tag is already taken (several copies
367
+ * of a component may be loaded by different parts of an app).
368
+ * @param {string} tag
369
+ * @param {CustomElementConstructor} cls
370
+ */
371
+ export function define(tag, cls) {
372
+ if (!customElements.get(tag)) customElements.define(tag, cls);
373
+ }
374
+
375
+ let uidCounter = 0;
376
+ /** A document-unique id with the given stem. */
377
+ export function uid(stem = "id") {
378
+ uidCounter += 1;
379
+ return `${PREFIX}-${stem}-${uidCounter}`;
380
+ }
381
+
382
+ // ---------------------------------------------------------------------------
383
+ // Theme
384
+ // ---------------------------------------------------------------------------
385
+
386
+ const THEME_KEY = "ray-elements:theme";
387
+ const PRIMARY_KEY = "ray-elements:primary";
388
+ const CUSTOM_KEY = "ray-elements:primary-color";
389
+ export const THEME_EVENT = "ray-theme-change";
390
+
391
+ /** @typedef {"light" | "dark" | "system"} Theme */
392
+
393
+ function store(key, value) {
394
+ try {
395
+ if (value === null) localStorage.removeItem(key);
396
+ else localStorage.setItem(key, value);
397
+ } catch {
398
+ // Storage may be unavailable (private mode, some webviews): not persisting is fine.
399
+ }
400
+ }
401
+
402
+ function load(key) {
403
+ try {
404
+ return localStorage.getItem(key);
405
+ } catch {
406
+ return null;
407
+ }
408
+ }
409
+
410
+ /**
411
+ * The chosen theme of an element (default: the document root).
412
+ * @returns {Theme}
413
+ */
414
+ export function getTheme(el = document.documentElement) {
415
+ const t = el.getAttribute("data-theme");
416
+ return t === "light" || t === "dark" ? t : "system";
417
+ }
418
+
419
+ /**
420
+ * The theme actually shown: "light" or "dark".
421
+ * @returns {"light" | "dark"}
422
+ */
423
+ export function resolvedTheme(el = document.documentElement) {
424
+ const holder = el.closest("[data-theme]");
425
+ const t = holder ? getTheme(holder) : "system";
426
+ if (t !== "system") return t;
427
+ return matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
428
+ }
429
+
430
+ /**
431
+ * Forces a theme on an element (default: the document root) or returns it to
432
+ * the system's. Persisted for the root unless `persist` is false.
433
+ * @param {Theme} theme
434
+ */
435
+ export function setTheme(theme, { el = document.documentElement, persist = el === document.documentElement } = {}) {
436
+ if (theme === "light" || theme === "dark") el.setAttribute("data-theme", theme);
437
+ else el.removeAttribute("data-theme");
438
+ if (persist) store(THEME_KEY, theme === "system" ? null : theme);
439
+ notifyTheme(el);
440
+ }
441
+
442
+ // The color last given to definePrimary(), per element.
443
+ const customColors = new WeakMap();
444
+
445
+ /**
446
+ * The color last given to definePrimary() for an element, as "#rrggbb" (null
447
+ * if none) — what a color picker for the custom primary should show.
448
+ */
449
+ export function getCustomPrimary(el = document.documentElement) {
450
+ return customColors.get(el) ?? null;
451
+ }
452
+
453
+ /** The primary color of an element (null = the default from tokens.toml). */
454
+ export function getPrimary(el = document.documentElement) {
455
+ return el.getAttribute("data-primary");
456
+ }
457
+
458
+ /**
459
+ * Switches the primary color (any palette color) of an element and its subtree.
460
+ * @param {string | null} color
461
+ */
462
+ export function setPrimary(color, { el = document.documentElement, persist = el === document.documentElement } = {}) {
463
+ if (color) el.setAttribute("data-primary", color);
464
+ else el.removeAttribute("data-primary");
465
+ if (persist) store(PRIMARY_KEY, color);
466
+ notifyTheme(el);
467
+ }
468
+
469
+ /**
470
+ * Makes any CSS color the primary color of an element (default: the
471
+ * document): builds its ten shades (OKLCH, same hue), picks white or black
472
+ * text for its filled shade by contrast, and switches to data-primary="custom".
473
+ * The variants follow the same rules as the palette colors (tokens.toml).
474
+ * Persisted for the document unless `persist` is false.
475
+ * @param {string} color
476
+ * @returns {{ shades: string[], contrast: "white" | "black", ratio: number, aa: boolean }}
477
+ */
478
+ export function definePrimary(color, { el = document.documentElement, persist = el === document.documentElement } = {}) {
479
+ const rgb = parseColor(color);
480
+ if (!rgb) throw new TypeError(`definePrimary: "${color}" is not a CSS color`);
481
+ customColors.set(el, toHex(rgb));
482
+ const scale = shades(rgb);
483
+ const base = toHex(rgb);
484
+ // Filled: the color itself when white text on it reaches AA; a bright color
485
+ // (light, AA with black text) also fills with itself, with black text, like
486
+ // yellow in the palette; otherwise the closest darker shade that reaches AA
487
+ // with white. What the user picked is what they see, as far as AA allows.
488
+ const whiteOk = (hex) => contrastRatio(hex, "#ffffff") >= 4.5;
489
+ const bright = !whiteOk(base) && toOklch(rgb).l >= 0.72 && contrastRatio(base, "#000000") >= 4.5;
490
+ const baseIndex = scale.indexOf(base);
491
+ let index = baseIndex;
492
+ if (!whiteOk(base) && !bright) {
493
+ index = scale.findIndex((hex, i) => i > baseIndex && whiteOk(hex));
494
+ if (index < 0) index = 9;
495
+ }
496
+ const filled = scale[index];
497
+ const hover = index < 9 ? scale[index + 1] : toHex({ r: rgb.r * 0.85, g: rgb.g * 0.85, b: rgb.b * 0.85 });
498
+ const white = contrastRatio(filled, "#ffffff");
499
+ const black = contrastRatio(filled, "#000000");
500
+ const contrast = bright || black > white ? "black" : "white";
501
+ const ratio = contrast === "white" ? white : black;
502
+ scale.forEach((hex, i) => el.style.setProperty(`--ray-custom-src-${i}`, hex));
503
+ el.style.setProperty("--ray-custom-src-contrast", contrast === "white" ? "#ffffff" : "#000000");
504
+ el.style.setProperty("--ray-custom-src-filled", filled);
505
+ el.style.setProperty("--ray-custom-src-filled-hover", hover);
506
+ if (persist) store(CUSTOM_KEY, color);
507
+ setPrimary("custom", { el, persist });
508
+ const aa = ratio >= 4.5;
509
+ if (!aa) console.warn(`ray-elements: definePrimary("${color}"): text on the filled shade is ${ratio.toFixed(1)}:1, below WCAG AA (4.5:1)`);
510
+ return { shades: scale, filled, contrast, ratio, aa };
511
+ }
512
+
513
+ function notifyTheme(el) {
514
+ el.dispatchEvent(new CustomEvent(THEME_EVENT, {
515
+ bubbles: true,
516
+ composed: true,
517
+ detail: { theme: getTheme(el), resolved: resolvedTheme(el), primary: getPrimary(el) },
518
+ }));
519
+ }
520
+
521
+ /**
522
+ * Calls `cb` when the theme of the document changes, by setTheme/setPrimary
523
+ * or by the system. Returns a function that stops listening.
524
+ * @param {(detail: {theme: Theme, resolved: "light" | "dark", primary: string | null}) => void} cb
525
+ */
526
+ export function onThemeChange(cb) {
527
+ const onEvent = (e) => cb(e.detail);
528
+ const mq = matchMedia("(prefers-color-scheme: dark)");
529
+ const onSystem = () => {
530
+ const root = document.documentElement;
531
+ cb({ theme: getTheme(root), resolved: resolvedTheme(root), primary: getPrimary(root) });
532
+ };
533
+ document.addEventListener(THEME_EVENT, onEvent);
534
+ mq.addEventListener("change", onSystem);
535
+ return () => {
536
+ document.removeEventListener(THEME_EVENT, onEvent);
537
+ mq.removeEventListener("change", onSystem);
538
+ };
539
+ }
540
+
541
+ // Restore the persisted choice as soon as the runtime loads. An explicit
542
+ // data-theme / data-primary in the markup wins over the stored one.
543
+ {
544
+ const root = document.documentElement;
545
+ const theme = load(THEME_KEY);
546
+ if (!root.hasAttribute("data-theme") && (theme === "light" || theme === "dark")) {
547
+ root.setAttribute("data-theme", theme);
548
+ }
549
+ const primary = load(PRIMARY_KEY);
550
+ if (!root.hasAttribute("data-primary") && primary) {
551
+ // A custom primary needs its shades back, not just the attribute.
552
+ const custom = primary === "custom" ? load(CUSTOM_KEY) : null;
553
+ if (custom && parseColor(custom)) definePrimary(custom, { persist: false });
554
+ else if (primary !== "custom") root.setAttribute("data-primary", primary);
555
+ }
556
+ }
@@ -0,0 +1,69 @@
1
+ // Light dismiss for floating panels shown as popover="manual": what
2
+ // popover="auto" does (outside click, Escape, one open at a time unless
3
+ // nested), done here so the component decides how to close — and can
4
+ // animate the exit, which an automatic close would not allow.
5
+ //
6
+ // const stop = dismissable({ panel, owner: this, trigger, close: (reason) => … });
7
+ // stop(); // when the panel closed
8
+ //
9
+ // Escape closes the topmost panel and is consumed (preventDefault), so a
10
+ // dialog underneath stays open. A pointer press outside a panel (and its
11
+ // owner and trigger) closes it and every panel above it.
12
+
13
+ const stack = [];
14
+
15
+ /** Whether `node` is inside `container`, crossing shadow roots and slots. */
16
+ function within(container, node) {
17
+ for (let n = node; n; n = n.assignedSlot ?? n.parentNode ?? n.host) {
18
+ if (n === container) return true;
19
+ }
20
+ return false;
21
+ }
22
+
23
+ function inside(entry, path) {
24
+ return path.some((n) => n === entry.panel || n === entry.owner || (entry.trigger && n === entry.trigger));
25
+ }
26
+
27
+ function onPointer(e) {
28
+ if (!stack.length) return;
29
+ const path = e.composedPath();
30
+ let keep = -1;
31
+ for (let i = stack.length - 1; i >= 0; i--) {
32
+ if (inside(stack[i], path)) {
33
+ keep = i;
34
+ break;
35
+ }
36
+ }
37
+ for (let i = stack.length - 1; i > keep; i--) stack[i].close("outside");
38
+ }
39
+
40
+ function onKey(e) {
41
+ if (e.key !== "Escape" || !stack.length || e.defaultPrevented) return;
42
+ e.preventDefault();
43
+ stack[stack.length - 1].close("escape");
44
+ }
45
+
46
+ /**
47
+ * Registers an open panel. Panels that do not contain the new one's owner
48
+ * close (one open at a time, nested ones excepted).
49
+ * @param {{ panel: Element, owner: Element, trigger?: Element | null, close: (reason: "outside" | "escape" | "replaced") => void }} entry
50
+ * @returns {() => void} unregisters it
51
+ */
52
+ export function dismissable(entry) {
53
+ for (let i = stack.length - 1; i >= 0; i--) {
54
+ if (!within(stack[i].panel, entry.owner)) stack[i].close("replaced");
55
+ }
56
+ if (!stack.length) {
57
+ document.addEventListener("pointerdown", onPointer, true);
58
+ document.addEventListener("keydown", onKey);
59
+ }
60
+ stack.push(entry);
61
+ return () => {
62
+ const i = stack.indexOf(entry);
63
+ if (i >= 0) stack.splice(i, 1);
64
+ if (!stack.length) {
65
+ document.removeEventListener("pointerdown", onPointer, true);
66
+ document.removeEventListener("keydown", onKey);
67
+ }
68
+ };
69
+ }