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