@adia-ai/web-components 0.8.41 → 0.8.42

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 (63) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/MIGRATION.md +187 -0
  3. package/USAGE.md +4 -4
  4. package/components/action-list/action-item.a2ui.json +8 -3
  5. package/components/action-list/action-item.yaml +25 -7
  6. package/components/action-list/action-list.class.js +68 -6
  7. package/components/action-list/action-list.d.ts +3 -1
  8. package/components/chart/chart.a2ui.json +18 -3
  9. package/components/chart/chart.class.js +58 -16
  10. package/components/chart/chart.d.ts +9 -3
  11. package/components/chart/chart.yaml +35 -3
  12. package/components/chart-legend/chart-legend.a2ui.json +6 -1
  13. package/components/chart-legend/chart-legend.class.js +59 -6
  14. package/components/chart-legend/chart-legend.css +5 -2
  15. package/components/chart-legend/chart-legend.d.ts +3 -1
  16. package/components/chart-legend/chart-legend.yaml +15 -1
  17. package/components/color-area/color-area.a2ui.json +148 -0
  18. package/components/color-area/color-area.class.js +657 -0
  19. package/components/color-area/color-area.css +188 -0
  20. package/components/color-area/color-area.d.ts +66 -0
  21. package/components/color-area/color-area.examples.md +19 -0
  22. package/components/color-area/color-area.js +17 -0
  23. package/components/color-area/color-area.yaml +183 -0
  24. package/components/color-input/color-input.a2ui.json +5 -5
  25. package/components/color-input/color-input.class.js +9 -6
  26. package/components/color-input/color-input.css +1 -1
  27. package/components/color-input/color-input.js +2 -2
  28. package/components/color-input/color-input.yaml +14 -12
  29. package/components/color-picker/color-picker.a2ui.json +15 -12
  30. package/components/color-picker/color-picker.class.js +24 -633
  31. package/components/color-picker/color-picker.css +14 -182
  32. package/components/color-picker/color-picker.yaml +41 -41
  33. package/components/description-list/description-list.a2ui.json +1 -1
  34. package/components/description-list/description-list.css +1 -1
  35. package/components/description-list/description-list.d.ts +1 -1
  36. package/components/description-list/description-list.yaml +1 -1
  37. package/components/field/field.a2ui.json +1 -1
  38. package/components/field/field.class.js +4 -1
  39. package/components/field/field.yaml +1 -1
  40. package/components/index.js +1 -0
  41. package/components/integration-card/integration-card.examples.md +2 -1
  42. package/components/menu/menu-item.a2ui.json +8 -3
  43. package/components/menu/menu-item.yaml +24 -3
  44. package/components/menu/menu.class.js +34 -6
  45. package/components/menu/menu.d.ts +3 -1
  46. package/components/pane/pane.a2ui.json +11 -1
  47. package/components/pane/pane.class.js +32 -3
  48. package/components/pane/pane.css +15 -10
  49. package/components/pane/pane.d.ts +10 -7
  50. package/components/pane/pane.yaml +15 -1
  51. package/components/swatch/swatch.yaml +1 -1
  52. package/components/table-toolbar/table-toolbar.class.js +15 -3
  53. package/core/icons.js +5 -0
  54. package/custom-elements.json +191 -16
  55. package/dist/theme-provider.min.js +1 -1
  56. package/dist/web-components.min.css +1 -1
  57. package/dist/web-components.min.js +89 -89
  58. package/dist/web-components.sheet.js +1 -1
  59. package/index.d.ts +12 -1
  60. package/package.json +1 -1
  61. package/patterns/admin-shell/admin-shell.examples.html +2 -2
  62. package/patterns/form-system/form-system.examples.html +1 -1
  63. package/styles/components.css +1 -0
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Non-side-effect class export for `<color-picker-ui>`.
3
3
  *
4
- * Importing this file gives you the class(es) without auto-registering the tag.
4
+ * Importing this file gives you the class without auto-registering the tag.
5
5
  * Useful for test isolation, subclassing with tag-name override, or selective
6
6
  * composition.
7
7
  *
@@ -12,644 +12,35 @@
12
12
  */
13
13
 
14
14
  /**
15
- * <color-picker-ui value="#3b82f6" format="hex"></color-picker-ui>
15
+ * <color-picker-ui> — DEPRECATED (ADR-0063, gh#1563, gh#1377 B11).
16
16
  *
17
- * OKLCH-native color picker with 2D area + H/C/L sliders.
17
+ * `-picker` is reserved for the outer, trigger + popover, form-associated
18
+ * composite shape (`calendar-picker-ui` / `date-range-picker-ui` /
19
+ * `datetime-picker-ui` / `time-picker-ui`) — `color-picker-ui` was the sole
20
+ * violator, playing `calendar-grid-ui`'s inline-substrate role under a
21
+ * `-picker` name. The real implementation moved to `<color-area-ui>`
22
+ * (mirrors `calendar-grid-ui`'s own plain, shape-describing substrate
23
+ * naming). `<color-picker-ui>` is now a thin compatibility alias per
24
+ * ADR-0057's `status: deprecated` shape (same pattern as
25
+ * `<datetime-picker-ui>` → `<calendar-picker-ui>`, gh#1372): it subclasses
26
+ * `UIColorArea` directly rather than re-implementing any behavior.
18
27
  *
19
- * Layout:
20
- * [═══════════ color area (chroma x lightness) ═══════════]
21
- * H [────────────── slider ──────────────] 260
22
- * C [────────────── slider ──────────────] 0.188
23
- * L [────────────── slider ──────────────] 0.623
24
- * [───────────────── hue track ──────────────────]
25
- * oklch(0.62 0.188 260) [copy]
26
- * #3b82f6 [copy]
28
+ * Migrate: `<color-picker-ui>` → `<color-area-ui>`. This tag keeps
29
+ * working — it IS `<color-area-ui>` under the hood — but will be removed
30
+ * in a future major; new markup should reach for `<color-area-ui>`
31
+ * directly.
27
32
  *
28
- * Dogfoods: slider-ui (H/C/L channels)
29
- * Form-associated via UIFormElement + ElementInternals.
33
+ * @see ../color-area/color-area.class.js
30
34
  */
31
35
 
32
- import { UIFormElement } from '../../core/form.js';
36
+ import { UIColorArea } from '../color-area/color-area.class.js';
33
37
 
34
- // ── OKLCH sRGB conversion ──────────────────────────────
35
-
36
- const MAX_CHROMA = 0.4;
37
-
38
- function oklchToOklab(L, C, H) {
39
- const hRad = H * Math.PI / 180;
40
- return { L, a: C * Math.cos(hRad), b: C * Math.sin(hRad) };
41
- }
42
-
43
- function oklabToLinearSrgb(L, a, b) {
44
- const l_ = L + 0.3963377774 * a + 0.2158037573 * b;
45
- const m_ = L - 0.1055613458 * a - 0.0638541728 * b;
46
- const s_ = L - 0.0894841775 * a - 1.2914855480 * b;
47
- const l = l_ * l_ * l_;
48
- const m = m_ * m_ * m_;
49
- const s = s_ * s_ * s_;
50
- return [
51
- +4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s,
52
- -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s,
53
- -0.0041960863 * l - 0.7034186147 * m + 1.7076147010 * s,
54
- ];
55
- }
56
-
57
- function linearToSrgb(c) {
58
- return c <= 0.0031308 ? 12.92 * c : 1.055 * Math.pow(c, 1 / 2.4) - 0.055;
59
- }
60
-
61
- function srgbToLinear(c) {
62
- return c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
63
- }
64
-
65
- function oklchToRgb(L, C, H) {
66
- const { a, b } = oklchToOklab(L, C, H);
67
- const [lr, lg, lb] = oklabToLinearSrgb(L, a, b);
68
- return [
69
- Math.max(0, Math.min(1, linearToSrgb(lr))),
70
- Math.max(0, Math.min(1, linearToSrgb(lg))),
71
- Math.max(0, Math.min(1, linearToSrgb(lb))),
72
- ];
73
- }
74
-
75
- function rgbToHex(r, g, b) {
76
- const h = c => Math.round(c * 255).toString(16).padStart(2, '0');
77
- return `#${h(r)}${h(g)}${h(b)}`;
78
- }
79
-
80
- function oklchToHex(L, C, H) {
81
- const [r, g, b] = oklchToRgb(L, C, H);
82
- return rgbToHex(r, g, b);
83
- }
84
-
85
- function hexToOklch(hex) {
86
- hex = hex.replace('#', '');
87
- if (hex.length === 3) hex = hex[0]+hex[0]+hex[1]+hex[1]+hex[2]+hex[2];
88
- const r = srgbToLinear(parseInt(hex.slice(0,2),16)/255);
89
- const g = srgbToLinear(parseInt(hex.slice(2,4),16)/255);
90
- const b = srgbToLinear(parseInt(hex.slice(4,6),16)/255);
91
- const l_ = Math.cbrt(0.4122214708*r + 0.5363325363*g + 0.0514459929*b);
92
- const m_ = Math.cbrt(0.2119034982*r + 0.6806995451*g + 0.1073969566*b);
93
- const s_ = Math.cbrt(0.0883024619*r + 0.2817188376*g + 0.6299787005*b);
94
- const L = 0.2104542553*l_ + 0.7936177850*m_ - 0.0040720468*s_;
95
- const a = 1.9779984951*l_ - 2.4285922050*m_ + 0.4505937099*s_;
96
- const bv = 0.0259040371*l_ + 0.7827717662*m_ - 0.8086757660*s_;
97
- const C = Math.sqrt(a*a + bv*bv);
98
- let H = Math.atan2(bv, a) * 180 / Math.PI;
99
- if (H < 0) H += 360;
100
- return { L, C, H };
101
- }
102
-
103
- function isInGamut(r, g, b) {
104
- const e = 0.001;
105
- return r >= -e && r <= 1+e && g >= -e && g <= 1+e && b >= -e && b <= 1+e;
106
- }
107
-
108
- function gamutMapChroma(L, C, H) {
109
- const { a, b } = oklchToOklab(L, C, H);
110
- const [lr, lg, lb] = oklabToLinearSrgb(L, a, b);
111
- if (isInGamut(linearToSrgb(lr), linearToSrgb(lg), linearToSrgb(lb))) return C;
112
- let lo = 0, hi = C;
113
- for (let i = 0; i < 8; i++) {
114
- const mid = (lo + hi) / 2;
115
- const { a: ma, b: mb } = oklchToOklab(L, mid, H);
116
- const [mr, mg, mbb] = oklabToLinearSrgb(L, ma, mb);
117
- if (isInGamut(linearToSrgb(mr), linearToSrgb(mg), linearToSrgb(mbb))) lo = mid;
118
- else hi = mid;
119
- }
120
- return lo;
121
- }
122
-
123
- // ── Component ────────────────────────────────────────────
124
-
125
- export class UIColorPicker extends UIFormElement {
126
- // §154 (v0.5.3): Phosphor icons this primitive auto-stamps (without
127
- // consumer markup). Aggregated by installIconLoadersForRegistered()
128
- // across all defined elements. Audited by check-required-icons.mjs
129
- // (slot 11). Per FEEDBACK-06 §4 + FEEDBACK-07 §4.
38
+ export class UIColorPicker extends UIColorArea {
39
+ // Same value as UIColorArea's — the alias stamps no icon of its own —
40
+ // but restated literally because scripts/release/check-required-icons.mjs
41
+ // text-scans each component's own class.js and cannot resolve inheritance
42
+ // (gh#1503, same rationale datetime-picker.class.js documents).
130
43
  static requiredIcons = ['copy', 'check', 'warning'];
131
44
 
132
- // §201 (v0.5.7): once-per-element warn dedup for #parseValue malformed
133
- // input. Matches the v0.5.5 §184 §8 button-ui icon-only safety-net pattern
134
- // + v0.5.7 §215 select-ui parseOptions pattern. Per FEEDBACK-13 §1 +
135
- // FEEDBACK-14 §2 (co-credited).
136
- static #warnedBadParse = new WeakSet();
137
-
138
- static properties = {
139
- ...UIFormElement.properties,
140
- value: { type: String, default: '#3b82f6', reflect: true },
141
- format: { type: String, default: 'hex', reflect: true },
142
- disabled: { type: Boolean, default: false, reflect: true },
143
- /**
144
- * v0.4.9 §99h — generation-constraint props (FEEDBACK-02 #7).
145
- * When set, the picker clamps OKLCH channels to the consumer's
146
- * declared bounds before committing. Out-of-bound mutations
147
- * round-trip to the nearest in-bound equivalent + fire a
148
- * `constraint-clamp` event with `{ axis, requested, clamped, reason }`
149
- * so the consumer can surface UX feedback (toast, validity warning,
150
- * scope-drift indicator). Default values disable the constraint.
151
- */
152
- maxChroma: { type: Number, default: Infinity, reflect: true, attribute: 'max-chroma' },
153
- maxL: { type: Number, default: 1, reflect: true, attribute: 'max-l' },
154
- minL: { type: Number, default: 0, reflect: true, attribute: 'min-l' },
155
- /** Maximum allowed hue deviation in degrees from the picker's `baseHue`. NaN = no constraint. */
156
- hueDriftMax: { type: Number, default: NaN, reflect: true, attribute: 'hue-drift-max' },
157
- /** Reference hue (degrees) for [hue-drift-max]. NaN = use the value parsed at first commit. */
158
- baseHue: { type: Number, default: NaN, reflect: true, attribute: 'base-hue' },
159
- };
160
-
161
- #L = 0.6; #C = 0.15; #H = 230;
162
- /** Cached reference hue for hue-drift constraint. Set on first commit if [base-hue] isn't authored. */
163
- #resolvedBaseHue = NaN;
164
- #bound = false;
165
- #dragging = null; // 'area' | 'hue' | null
166
- #internalUpdate = false;
167
-
168
- // DOM refs
169
- #canvas = null;
170
- #ctx = null;
171
- #areaEl = null;
172
- #areaThumb = null;
173
- #hueTrack = null;
174
- #hueThumb = null;
175
- #oklchVal = null;
176
- #hexVal = null;
177
- #areaResizeObs = null;
178
- #hueSlider = null;
179
- #chromaSlider = null;
180
- #lightnessSlider = null;
181
- #copyButtons = []; // [{ el, handler }]
182
- #copyTimer = null;
183
-
184
- static template = () => null;
185
-
186
- // ── Stable listeners ──
187
-
188
- #onSliderInput = (e) => {
189
- const slider = e.currentTarget;
190
- const channel = slider.getAttribute('data-channel');
191
- if (channel === 'hue') this.#H = +slider.value;
192
- else if (channel === 'chroma') this.#C = +slider.value;
193
- else if (channel === 'lightness') this.#L = +slider.value;
194
- this.#commit('input');
195
- };
196
-
197
- #onAreaPointerDown = (e) => {
198
- if (this.disabled) return;
199
- this.#dragging = 'area';
200
- this.#areaEl.setPointerCapture(e.pointerId);
201
- this.#updateAreaFromPointer(e);
202
- };
203
-
204
- #onAreaPointerMove = (e) => {
205
- if (this.#dragging === 'area') this.#updateAreaFromPointer(e);
206
- };
207
-
208
- #onAreaPointerUp = () => {
209
- if (this.#dragging === 'area') { this.#dragging = null; this.#commit('change'); }
210
- };
211
-
212
- #onHuePointerDown = (e) => {
213
- if (this.disabled) return;
214
- this.#dragging = 'hue';
215
- this.#hueTrack.setPointerCapture(e.pointerId);
216
- this.#updateHueFromPointer(e);
217
- };
218
-
219
- #onHuePointerMove = (e) => {
220
- if (this.#dragging === 'hue') this.#updateHueFromPointer(e);
221
- };
222
-
223
- #onHuePointerUp = () => {
224
- if (this.#dragging === 'hue') { this.#dragging = null; this.#commit('change'); }
225
- };
226
-
227
- #onAreaKeydown = (e) => {
228
- if (this.disabled) return;
229
- const step = e.shiftKey ? 0.05 : 0.01;
230
- const cStep = e.shiftKey ? 0.04 : 0.004;
231
- switch (e.key) {
232
- case 'ArrowRight': e.preventDefault(); this.#C = Math.min(MAX_CHROMA, this.#C + cStep); break;
233
- case 'ArrowLeft': e.preventDefault(); this.#C = Math.max(0, this.#C - cStep); break;
234
- case 'ArrowUp': e.preventDefault(); this.#L = Math.min(1, this.#L + step); break;
235
- case 'ArrowDown': e.preventDefault(); this.#L = Math.max(0, this.#L - step); break;
236
- default: return;
237
- }
238
- this.#commit('change');
239
- };
240
-
241
- #onHueKeydown = (e) => {
242
- if (this.disabled) return;
243
- const step = e.shiftKey ? 10 : 1;
244
- switch (e.key) {
245
- case 'ArrowRight': case 'ArrowUp': e.preventDefault(); this.#H = (this.#H + step) % 360; break;
246
- case 'ArrowLeft': case 'ArrowDown': e.preventDefault(); this.#H = (this.#H - step + 360) % 360; break;
247
- default: return;
248
- }
249
- this.#commit('change');
250
- };
251
-
252
- connected() {
253
- super.connected();
254
- this.#parseValue(this.value);
255
-
256
- if (this.#bound) return;
257
- this.#bound = true;
258
-
259
- // ── Build DOM ──
260
-
261
- // Color area
262
- this.#areaEl = document.createElement('div');
263
- this.#areaEl.setAttribute('data-area', '');
264
- this.#areaEl.setAttribute('tabindex', '0');
265
- this.#areaEl.setAttribute('role', 'slider');
266
- this.#areaEl.setAttribute('aria-label', 'Color area: chroma and lightness');
267
-
268
- this.#canvas = document.createElement('canvas');
269
- this.#canvas.style.cssText = 'width:100%;height:100%;display:block;border-radius:inherit;';
270
- this.#areaEl.appendChild(this.#canvas);
271
- this.#ctx = this.#canvas.getContext('2d', { willReadFrequently: true });
272
-
273
- this.#areaThumb = document.createElement('div');
274
- this.#areaThumb.setAttribute('data-area-thumb', '');
275
- this.#areaEl.appendChild(this.#areaThumb);
276
-
277
- this.appendChild(this.#areaEl);
278
-
279
- // Slider rows
280
- const sliders = document.createElement('div');
281
- sliders.setAttribute('data-sliders', '');
282
-
283
- const makeSlider = (label, channel, min, max, step) => {
284
- const slider = document.createElement('slider-ui');
285
- slider.setAttribute('data-channel', channel);
286
- slider.setAttribute('label', label);
287
- slider.setAttribute('min', min);
288
- slider.setAttribute('max', max);
289
- slider.setAttribute('step', step);
290
-
291
- slider.addEventListener('input', this.#onSliderInput);
292
-
293
- sliders.appendChild(slider);
294
- return slider;
295
- };
296
-
297
- this.#hueSlider = makeSlider('H', 'hue', 0, 360, 1);
298
- this.#chromaSlider = makeSlider('C', 'chroma', 0, MAX_CHROMA, 0.001);
299
- this.#lightnessSlider = makeSlider('L', 'lightness', 0, 1, 0.001);
300
- this.appendChild(sliders);
301
-
302
- // Hue gradient track
303
- this.#hueTrack = document.createElement('div');
304
- this.#hueTrack.setAttribute('data-hue-track', '');
305
- this.#hueTrack.setAttribute('tabindex', '0');
306
- this.#hueTrack.setAttribute('role', 'slider');
307
- this.#hueTrack.setAttribute('aria-label', 'Hue');
308
- this.#hueThumb = document.createElement('div');
309
- this.#hueThumb.setAttribute('data-hue-thumb', '');
310
- this.#hueTrack.appendChild(this.#hueThumb);
311
- this.appendChild(this.#hueTrack);
312
-
313
- // Output rows
314
- const output = document.createElement('div');
315
- output.setAttribute('data-output', '');
316
-
317
- const makeOutput = (format) => {
318
- const group = document.createElement('div');
319
- group.setAttribute('data-output-group', '');
320
-
321
- const val = document.createElement('span');
322
- val.setAttribute('data-output-value', '');
323
- val.setAttribute('data-format', format);
324
-
325
- const btn = document.createElement('div');
326
- btn.setAttribute('role', 'button');
327
- btn.setAttribute('tabindex', '0');
328
- btn.setAttribute('data-copy', format);
329
- btn.setAttribute('aria-label', `Copy ${format}`);
330
- btn.innerHTML = '<icon-ui name="copy"></icon-ui>';
331
- const handler = () => this.#copy(format, btn);
332
- btn.addEventListener('click', handler);
333
- this.#copyButtons.push({ el: btn, handler });
334
-
335
- group.appendChild(val);
336
- group.appendChild(btn);
337
- output.appendChild(group);
338
- return val;
339
- };
340
-
341
- this.#oklchVal = makeOutput('oklch');
342
- this.#hexVal = makeOutput('hex');
343
- this.appendChild(output);
344
-
345
- // ── Area drag ──
346
-
347
- this.#areaEl.addEventListener('pointerdown', this.#onAreaPointerDown);
348
- this.#areaEl.addEventListener('pointermove', this.#onAreaPointerMove);
349
- this.#areaEl.addEventListener('pointerup', this.#onAreaPointerUp);
350
-
351
- // ── Hue track drag ──
352
-
353
- this.#hueTrack.addEventListener('pointerdown', this.#onHuePointerDown);
354
- this.#hueTrack.addEventListener('pointermove', this.#onHuePointerMove);
355
- this.#hueTrack.addEventListener('pointerup', this.#onHuePointerUp);
356
-
357
- // ── Keyboard on area ──
358
-
359
- this.#areaEl.addEventListener('keydown', this.#onAreaKeydown);
360
-
361
- // ── Keyboard on hue ──
362
-
363
- this.#hueTrack.addEventListener('keydown', this.#onHueKeydown);
364
-
365
- // Observe area resize to redraw canvas.
366
- // gh#285 — SSR DOM shims (linkedom) have no ResizeObserver global.
367
- if (typeof ResizeObserver !== 'undefined') {
368
- this.#areaResizeObs = new ResizeObserver(() => this.#drawArea());
369
- this.#areaResizeObs.observe(this.#areaEl);
370
- }
371
- }
372
-
373
- disconnected() {
374
- super.disconnected();
375
- if (this.#copyTimer != null) {
376
- clearTimeout(this.#copyTimer);
377
- this.#copyTimer = null;
378
- }
379
- if (this.#areaResizeObs) {
380
- this.#areaResizeObs.disconnect();
381
- this.#areaResizeObs = null;
382
- }
383
- if (this.#areaEl) {
384
- this.#areaEl.removeEventListener('pointerdown', this.#onAreaPointerDown);
385
- this.#areaEl.removeEventListener('pointermove', this.#onAreaPointerMove);
386
- this.#areaEl.removeEventListener('pointerup', this.#onAreaPointerUp);
387
- this.#areaEl.removeEventListener('keydown', this.#onAreaKeydown);
388
- }
389
- if (this.#hueTrack) {
390
- this.#hueTrack.removeEventListener('pointerdown', this.#onHuePointerDown);
391
- this.#hueTrack.removeEventListener('pointermove', this.#onHuePointerMove);
392
- this.#hueTrack.removeEventListener('pointerup', this.#onHuePointerUp);
393
- this.#hueTrack.removeEventListener('keydown', this.#onHueKeydown);
394
- }
395
- this.#hueSlider?.removeEventListener('input', this.#onSliderInput);
396
- this.#chromaSlider?.removeEventListener('input', this.#onSliderInput);
397
- this.#lightnessSlider?.removeEventListener('input', this.#onSliderInput);
398
- for (const { el, handler } of this.#copyButtons) {
399
- el.removeEventListener('click', handler);
400
- }
401
- this.#copyButtons = [];
402
- this.#bound = false;
403
- }
404
-
405
- // ── Pointer helpers ──
406
-
407
- #updateAreaFromPointer(e) {
408
- const rect = this.#areaEl.getBoundingClientRect();
409
- const x = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
410
- const y = Math.max(0, Math.min(1, (e.clientY - rect.top) / rect.height));
411
- this.#C = x * MAX_CHROMA;
412
- this.#L = 1 - y;
413
- this.#commit('input');
414
- }
415
-
416
- #updateHueFromPointer(e) {
417
- const rect = this.#hueTrack.getBoundingClientRect();
418
- const x = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
419
- this.#H = x * 360;
420
- this.#commit('input');
421
- }
422
-
423
- // ── Value parsing ──
424
-
425
- #parseValue(val) {
426
- if (!val) return;
427
- if (val.startsWith('#')) {
428
- const o = hexToOklch(val);
429
- this.#L = o.L; this.#C = o.C; this.#H = o.H;
430
- return;
431
- }
432
- if (val.startsWith('oklch(')) {
433
- // §201 (v0.5.7, FEEDBACK-13 §1 + FEEDBACK-14 §2): accept floats, percent
434
- // on L, NaN (culori chromaless convention), and 'none' (CSS Color L4
435
- // powerless-component syntax). Channel-level normalization: NaN/none → 0;
436
- // percent on L → divide by 100. Per the CSS Color L4 "powerless" cascade
437
- // (https://www.w3.org/TR/css-color-4/#powerless), zeroing the hue channel
438
- // is the spec-defined resolution when chroma is 0 or near-0.
439
- const m = val.match(
440
- /oklch\(\s*([\d.]+%?|NaN|none)\s+([\d.]+%?|NaN|none)\s+([\d.]+|NaN|none)/i,
441
- );
442
- if (m) {
443
- const parseChan = (s, isL) => {
444
- if (s === 'none' || /^NaN$/i.test(s)) return 0;
445
- if (isL && s.endsWith('%')) return +s.slice(0, -1) / 100;
446
- if (s.endsWith('%')) return +s.slice(0, -1) / 100;
447
- return +s;
448
- };
449
- this.#L = parseChan(m[1], true);
450
- this.#C = parseChan(m[2], false);
451
- this.#H = parseChan(m[3], false);
452
- return;
453
- }
454
- // No regex match — warn once per element so consumers can correct.
455
- // WeakSet dedup matches v0.5.5 §184 §8 button-ui icon-only pattern.
456
- if (!UIColorPicker.#warnedBadParse.has(this)) {
457
- UIColorPicker.#warnedBadParse.add(this);
458
- // eslint-disable-next-line no-console
459
- console.warn(
460
- `<color-picker-ui>: could not parse value=${JSON.stringify(val)}. ` +
461
- `Expected #rrggbb or oklch(L C H) with numeric, NaN, 'none' (CSS L4 ` +
462
- `powerless), or % channels. Picker is keeping prior state.`,
463
- );
464
- }
465
- }
466
- }
467
-
468
- // ── Commit: clamp to consumer constraints + gamut-map + update value + fire events ──
469
-
470
- #commit(eventType) {
471
- // Apply consumer-declared constraints BEFORE gamut mapping. Each constraint
472
- // axis records a `constraint-clamp` event-payload entry when it actually
473
- // moved a value; we dispatch one event per commit so the consumer can
474
- // surface a single UX response (toast / validity warning / etc.).
475
- const clamps = [];
476
- if (this.#L > this.maxL) {
477
- clamps.push({ axis: 'l', requested: this.#L, clamped: this.maxL, reason: 'max-l' });
478
- this.#L = this.maxL;
479
- }
480
- if (this.#L < this.minL) {
481
- clamps.push({ axis: 'l', requested: this.#L, clamped: this.minL, reason: 'min-l' });
482
- this.#L = this.minL;
483
- }
484
- if (this.#C > this.maxChroma) {
485
- clamps.push({ axis: 'c', requested: this.#C, clamped: this.maxChroma, reason: 'max-chroma' });
486
- this.#C = this.maxChroma;
487
- }
488
- if (Number.isFinite(this.hueDriftMax)) {
489
- const base = Number.isFinite(this.baseHue)
490
- ? this.baseHue
491
- : (Number.isFinite(this.#resolvedBaseHue) ? this.#resolvedBaseHue : this.#H);
492
- if (!Number.isFinite(this.#resolvedBaseHue)) this.#resolvedBaseHue = base;
493
- // Hue is circular — find the signed shortest-path drift in [-180, 180].
494
- let drift = ((this.#H - base + 540) % 360) - 180;
495
- const limit = this.hueDriftMax;
496
- if (drift > limit) {
497
- const requestedH = this.#H;
498
- this.#H = (base + limit + 360) % 360;
499
- clamps.push({ axis: 'h', requested: requestedH, clamped: this.#H, reason: 'hue-drift-max' });
500
- } else if (drift < -limit) {
501
- const requestedH = this.#H;
502
- this.#H = (base - limit + 360) % 360;
503
- clamps.push({ axis: 'h', requested: requestedH, clamped: this.#H, reason: 'hue-drift-max' });
504
- }
505
- }
506
-
507
- const clampedC = gamutMapChroma(this.#L, this.#C, this.#H);
508
- const hexVal = oklchToHex(this.#L, clampedC, this.#H);
509
- const oklchStr = this.#oklchStr();
510
-
511
- this.#internalUpdate = true;
512
- this.value = this.format === 'oklch' ? oklchStr : hexVal;
513
- this.syncValue();
514
-
515
- if (clamps.length) {
516
- this.dispatchEvent(new CustomEvent('constraint-clamp', { bubbles: true, detail: { clamps } }));
517
- }
518
-
519
- const detail = { value: this.value, l: this.#L, c: this.#C, h: this.#H, hex: hexVal, oklch: oklchStr };
520
- this.dispatchEvent(new CustomEvent(eventType, { bubbles: true, detail }));
521
- }
522
-
523
- #oklchStr() {
524
- return `oklch(${this.#L.toFixed(2)} ${this.#C.toFixed(3)} ${Math.round(this.#H)})`;
525
- }
526
-
527
- // ── Copy ──
528
-
529
- #copy(fmt, btn) {
530
- const text = fmt === 'oklch'
531
- ? this.#oklchStr()
532
- : oklchToHex(this.#L, gamutMapChroma(this.#L, this.#C, this.#H), this.#H);
533
-
534
- const iconEl = btn.querySelector('icon-ui');
535
- const onDone = (ok) => {
536
- if (iconEl) {
537
- iconEl.setAttribute('name', ok ? 'check' : 'warning');
538
- if (this.#copyTimer != null) clearTimeout(this.#copyTimer);
539
- this.#copyTimer = setTimeout(() => {
540
- this.#copyTimer = null;
541
- iconEl.setAttribute('name', 'copy');
542
- }, 1500);
543
- }
544
- };
545
-
546
- if (navigator.clipboard?.writeText) {
547
- navigator.clipboard.writeText(text).then(
548
- () => { if (!this.isConnected) return; onDone(true); },
549
- () => { if (!this.isConnected) return; onDone(false); },
550
- );
551
- } else {
552
- const ta = Object.assign(document.createElement('textarea'), { value: text });
553
- ta.style.cssText = 'position:fixed;opacity:0';
554
- document.body.appendChild(ta);
555
- ta.select();
556
- onDone(document.execCommand('copy'));
557
- ta.remove();
558
- }
559
- }
560
-
561
- // ── Canvas rendering ──
562
-
563
- #drawArea() {
564
- if (!this.#canvas || !this.#ctx || !this.#areaEl) return;
565
-
566
- const dpr = Math.min(window.devicePixelRatio || 1, 2) * 0.5;
567
- const w = Math.round(this.#areaEl.clientWidth * dpr);
568
- const h = Math.round(this.#areaEl.clientHeight * dpr);
569
- if (w <= 0 || h <= 0) return;
570
-
571
- this.#canvas.width = w;
572
- this.#canvas.height = h;
573
-
574
- const img = this.#ctx.createImageData(w, h);
575
- const data = img.data;
576
- const hue = this.#H;
577
-
578
- for (let y = 0; y < h; y++) {
579
- const L = 1 - y / (h - 1);
580
- for (let x = 0; x < w; x++) {
581
- const C = (x / (w - 1)) * MAX_CHROMA;
582
- const clampedC = gamutMapChroma(L, C, hue);
583
- const [r, g, b] = oklchToRgb(L, clampedC, hue);
584
- const i = (y * w + x) * 4;
585
- data[i] = Math.round(r * 255);
586
- data[i + 1] = Math.round(g * 255);
587
- data[i + 2] = Math.round(b * 255);
588
- data[i + 3] = 255;
589
- }
590
- }
591
-
592
- this.#ctx.putImageData(img, 0, 0);
593
- }
594
-
595
- // ── Render ──
596
-
597
- render() {
598
- if (this.#internalUpdate) {
599
- this.#internalUpdate = false;
600
- } else {
601
- this.#parseValue(this.value);
602
- }
603
-
604
- this.#drawArea();
605
-
606
- // Area thumb
607
- if (this.#areaThumb) {
608
- const xPct = (this.#C / MAX_CHROMA) * 100;
609
- const yPct = (1 - this.#L) * 100;
610
- this.#areaThumb.style.left = `${xPct}%`;
611
- this.#areaThumb.style.top = `${yPct}%`;
612
- this.#areaThumb.style.background = oklchToHex(this.#L, gamutMapChroma(this.#L, this.#C, this.#H), this.#H);
613
- }
614
-
615
- // Hue track gradient
616
- if (this.#hueTrack) {
617
- const stops = [];
618
- for (let h = 0; h <= 360; h += 30) stops.push(oklchToHex(0.7, 0.15, h));
619
- this.#hueTrack.style.background = `linear-gradient(to right, ${stops.join(', ')})`;
620
- this.#hueTrack.setAttribute('aria-valuenow', String(Math.round(this.#H)));
621
- }
622
-
623
- // Hue thumb
624
- if (this.#hueThumb) {
625
- this.#hueThumb.style.left = `${(this.#H / 360) * 100}%`;
626
- this.#hueThumb.style.background = oklchToHex(0.7, 0.15, this.#H);
627
- }
628
-
629
- // ARIA
630
- if (this.#areaEl) {
631
- this.#areaEl.setAttribute('aria-valuetext', this.#oklchStr());
632
- }
633
-
634
- // Slider values
635
- const hueSlider = this.querySelector('slider-ui[data-channel="hue"]');
636
- const chromaSlider = this.querySelector('slider-ui[data-channel="chroma"]');
637
- const lightnessSlider = this.querySelector('slider-ui[data-channel="lightness"]');
638
- if (hueSlider) hueSlider.value = Math.round(this.#H);
639
- if (chromaSlider) chromaSlider.value = +this.#C.toFixed(3);
640
- if (lightnessSlider) lightnessSlider.value = +this.#L.toFixed(3);
641
-
642
- // Disabled
643
- if (this.disabled) this.setAttribute('aria-disabled', 'true');
644
- else this.removeAttribute('aria-disabled');
645
-
646
- // Output values
647
- const hex = oklchToHex(this.#L, gamutMapChroma(this.#L, this.#C, this.#H), this.#H);
648
- if (this.#oklchVal) this.#oklchVal.textContent = this.#oklchStr();
649
- if (this.#hexVal) this.#hexVal.textContent = hex;
650
- }
651
-
652
- onFormReset() {
653
- this.value = '#3b82f6';
654
- }
45
+ static properties = { ...UIColorArea.properties };
655
46
  }