@dile/ui 2.27.4 → 2.28.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.
@@ -0,0 +1,3 @@
1
+ import { DileColorPicker } from './src/DileColorPicker.js';
2
+
3
+ window.customElements.define('dile-color-picker', DileColorPicker);
@@ -0,0 +1,117 @@
1
+ import { describe, it, expect, afterEach } from 'vitest';
2
+ import './color-picker.js';
3
+
4
+ describe('dile-color-picker', () => {
5
+ afterEach(() => {
6
+ document.body.innerHTML = '';
7
+ });
8
+
9
+ async function renderColorPicker(html) {
10
+ document.body.innerHTML = html;
11
+ const el = document.body.querySelector('dile-color-picker');
12
+ await el.updateComplete;
13
+ return el;
14
+ }
15
+
16
+ it('renders a trigger swatch matching the initial value', async () => {
17
+ const el = await renderColorPicker('<dile-color-picker name="color" value="#3366ff"></dile-color-picker>');
18
+
19
+ const trigger = el.shadowRoot.getElementById('trigger');
20
+ expect(trigger).toBeTruthy();
21
+ expect(trigger.style.backgroundColor).toBe('rgb(51, 102, 255)');
22
+ });
23
+
24
+ it('normalizes 3-digit hex shorthand set externally', async () => {
25
+ const el = await renderColorPicker('<dile-color-picker name="color" value="#0f0"></dile-color-picker>');
26
+
27
+ expect(el.value).toBe('#00ff00');
28
+ });
29
+
30
+ it('updates value and hue/saturation state when the hex input changes', async () => {
31
+ const el = await renderColorPicker('<dile-color-picker name="color" value="#000000"></dile-color-picker>');
32
+
33
+ const hexInput = el.shadowRoot.querySelector('.hex-input');
34
+ hexInput.value = '#ff0000';
35
+ hexInput.dispatchEvent(new Event('input', { bubbles: true }));
36
+ await el.updateComplete;
37
+
38
+ expect(el.value).toBe('#ff0000');
39
+ expect(el._hue).toBe(0);
40
+ expect(el._saturation).toBe(100);
41
+ expect(el._value).toBe(100);
42
+ });
43
+
44
+ it('dispatches element-changed with name and value when the value changes', async () => {
45
+ const el = await renderColorPicker('<dile-color-picker name="color" value="#000000"></dile-color-picker>');
46
+ let detail = null;
47
+ el.addEventListener('element-changed', (e) => { detail = e.detail; });
48
+
49
+ const hexInput = el.shadowRoot.querySelector('.hex-input');
50
+ hexInput.value = '#00ff00';
51
+ hexInput.dispatchEvent(new Event('input', { bubbles: true }));
52
+ await el.updateComplete;
53
+
54
+ expect(detail).toEqual({ name: 'color', value: '#00ff00' });
55
+ });
56
+
57
+ it('updates value when the hue slider changes', async () => {
58
+ const el = await renderColorPicker('<dile-color-picker name="color" value="#ff0000"></dile-color-picker>');
59
+
60
+ const hueSlider = el.shadowRoot.querySelector('.hue-slider');
61
+ hueSlider.value = '120';
62
+ hueSlider.dispatchEvent(new Event('input', { bubbles: true }));
63
+ await el.updateComplete;
64
+
65
+ expect(el.value).toBe('#00ff00');
66
+ });
67
+
68
+ it('nudges saturation and brightness with arrow keys on the sv square', async () => {
69
+ const el = await renderColorPicker('<dile-color-picker name="color" value="#ff0000"></dile-color-picker>');
70
+
71
+ const svSquare = el.shadowRoot.getElementById('sv-square');
72
+ svSquare.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowLeft', bubbles: true }));
73
+ await el.updateComplete;
74
+
75
+ expect(el._saturation).toBe(99);
76
+ });
77
+
78
+ it('does not change value when disabled', async () => {
79
+ const el = await renderColorPicker('<dile-color-picker name="color" value="#ff0000" disabled></dile-color-picker>');
80
+
81
+ const trigger = el.shadowRoot.getElementById('trigger');
82
+ expect(trigger.disabled).toBe(true);
83
+
84
+ const svSquare = el.shadowRoot.getElementById('sv-square');
85
+ svSquare.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowLeft', bubbles: true }));
86
+ await el.updateComplete;
87
+
88
+ expect(el.value).toBe('#ff0000');
89
+ });
90
+
91
+ it('reverts the hue slider and ignores hex edits when readonly', async () => {
92
+ const el = await renderColorPicker('<dile-color-picker name="color" value="#ff0000" readonly></dile-color-picker>');
93
+
94
+ const hueSlider = el.shadowRoot.querySelector('.hue-slider');
95
+ hueSlider.value = '200';
96
+ hueSlider.dispatchEvent(new Event('input', { bubbles: true }));
97
+ await el.updateComplete;
98
+
99
+ expect(el.value).toBe('#ff0000');
100
+ expect(hueSlider.value).toBe('0');
101
+ });
102
+
103
+ it('participates in native form submission via FormData', async () => {
104
+ document.body.innerHTML = `
105
+ <form id="testForm">
106
+ <dile-color-picker name="color" value="#123456"></dile-color-picker>
107
+ </form>
108
+ `;
109
+ const el = document.body.querySelector('dile-color-picker');
110
+ await el.updateComplete;
111
+
112
+ const form = document.getElementById('testForm');
113
+ const data = new FormData(form);
114
+
115
+ expect(data.get('color')).toBe('#123456');
116
+ });
117
+ });
@@ -0,0 +1 @@
1
+ export { DileColorPicker } from './src/DileColorPicker.js';
@@ -0,0 +1,502 @@
1
+ import { LitElement, html, css } from 'lit';
2
+ import { DileEmmitChange } from '../../../mixins/form/index.js';
3
+ import { DileOverlay } from '../../../mixins/overlay/index.js';
4
+ import { DileCloseDocumentClick } from '../../../mixins/close-document-click/index.js';
5
+ import { DileCloseOnEscPressed } from '../../../mixins/close-on-esc-pressed/index.js';
6
+ import { messageStyles } from '../../input/index.js';
7
+ import { normalizeHex, hexToHsv, hsvToHex } from '../../../lib/color/colorConversion.js';
8
+
9
+ export class DileColorPicker extends DileOverlay(
10
+ DileCloseDocumentClick(
11
+ DileCloseOnEscPressed(
12
+ DileEmmitChange(LitElement)
13
+ )
14
+ )
15
+ ) {
16
+
17
+ /**
18
+ * Listen to the generic `element-changed` event to receive value updates
19
+ *
20
+ * @event element-changed
21
+ */
22
+
23
+ static get properties() {
24
+ return {
25
+ /** Label to the element */
26
+ label: { type: String },
27
+
28
+ /** Name for this input field */
29
+ name: { type: String },
30
+
31
+ /** Set initial value to the input. Must be a 6-digit hex color, e.g. "#3366ff" */
32
+ value: { type: String },
33
+
34
+ /** Disable the input field */
35
+ disabled: { type: Boolean },
36
+
37
+ /** ReadOnly attribute */
38
+ readonly: { type: Boolean },
39
+
40
+ /** Has error on this input field */
41
+ errored: { type: Boolean },
42
+
43
+ /** Message Displayed */
44
+ message: { type: String },
45
+
46
+ /** Hide errors on input */
47
+ hideErrorOnInput: { type: Boolean },
48
+
49
+ /** Set the application focus to this the input component after the initialization */
50
+ focusOnStart: { type: Boolean },
51
+
52
+ _hue: { state: true },
53
+ _saturation: { state: true },
54
+ _value: { state: true },
55
+ _hexInputValue: { state: true },
56
+ };
57
+ }
58
+
59
+ static get formAssociated() {
60
+ return true;
61
+ }
62
+
63
+ constructor() {
64
+ super();
65
+ this.label = '';
66
+ this.name = '';
67
+ this.value = '#000000';
68
+ this.disabled = false;
69
+ this.readonly = false;
70
+ this.errored = false;
71
+ this.message = '';
72
+ this.hideErrorOnInput = false;
73
+ this.focusOnStart = false;
74
+
75
+ const { h, s, v } = hexToHsv(this.value);
76
+ this._hue = h;
77
+ this._saturation = s;
78
+ this._value = v;
79
+ this._hexInputValue = this.value;
80
+ this._skipValueSync = false;
81
+
82
+ this.internals = this.attachInternals();
83
+ }
84
+
85
+ get opened() {
86
+ return this._overlayClass === 'opened';
87
+ }
88
+
89
+ get svSquareEl() {
90
+ return this.shadowRoot.getElementById('sv-square');
91
+ }
92
+
93
+ firstUpdated(changedProperties) {
94
+ super.firstUpdated(changedProperties);
95
+ if (this.focusOnStart) {
96
+ this.focus();
97
+ }
98
+ }
99
+
100
+ willUpdate(changedProperties) {
101
+ const skip = this._skipValueSync;
102
+ this._skipValueSync = false;
103
+ if (changedProperties.has('value') && !skip) {
104
+ const normalized = normalizeHex(this.value);
105
+ if (normalized) {
106
+ const { h, s, v } = hexToHsv(normalized);
107
+ this._hue = h;
108
+ this._saturation = s;
109
+ this._value = v;
110
+ if (normalized !== this.value) {
111
+ this.value = normalized;
112
+ }
113
+ } else {
114
+ console.warn(`dile-color-picker: invalid value "${this.value}", ignoring`);
115
+ }
116
+ this._hexInputValue = this.value;
117
+ }
118
+ }
119
+
120
+ updated(changedProperties) {
121
+ if (changedProperties.has('value')) {
122
+ this.emmitChange();
123
+ this.internals.setFormValue(this.value);
124
+ }
125
+ if (changedProperties.has('_overlayClass')) {
126
+ if (this._overlayClass === 'opened') {
127
+ this.svSquareEl?.focus();
128
+ } else if (changedProperties.get('_overlayClass') === 'opened') {
129
+ this.trigger?.focus();
130
+ }
131
+ }
132
+ }
133
+
134
+ static get styles() {
135
+ return [
136
+ messageStyles,
137
+ css`
138
+ * {
139
+ box-sizing: border-box;
140
+ }
141
+ :host {
142
+ display: inline-block;
143
+ margin-bottom: 10px;
144
+ }
145
+ label {
146
+ display: block;
147
+ margin-bottom: var(--dile-input-label-margin-bottom, 4px);
148
+ font-size: var(--dile-input-label-font-size, 1em);
149
+ color: var(--dile-input-label-color, #59e);
150
+ font-weight: var(--dile-input-label-font-weight, normal);
151
+ }
152
+ #trigger {
153
+ padding: 0;
154
+ width: var(--dile-color-picker-swatch-size, 2em);
155
+ height: var(--dile-color-picker-swatch-size, 2em);
156
+ border-radius: var(--dile-color-picker-swatch-border-radius, 5px);
157
+ border: var(--dile-color-picker-swatch-border-width, 1px) solid var(--dile-color-picker-swatch-border-color, #888);
158
+ cursor: pointer;
159
+ }
160
+ #trigger:disabled {
161
+ cursor: default;
162
+ opacity: 0.6;
163
+ }
164
+ #trigger.errored {
165
+ border-color: var(--dile-color-picker-error-border-color, #c00);
166
+ }
167
+ #trigger:focus-visible {
168
+ outline: none;
169
+ border-width: var(--dile-color-picker-swatch-focus-border-width, 3px);
170
+ border-color: var(--dile-input-focus-border-color, #6af);
171
+ }
172
+ #overlay {
173
+ display: none;
174
+ position: absolute;
175
+ opacity: 0;
176
+ z-index: var(--dile-color-picker-panel-z-index, 999);
177
+ width: var(--dile-color-picker-panel-width, 220px);
178
+ background-color: var(--dile-color-picker-panel-background-color, #fff);
179
+ border-radius: var(--dile-color-picker-panel-border-radius, 5px);
180
+ box-shadow: var(--dile-color-picker-panel-box-shadow, 0 0 20px rgba(102, 102, 102, 0.5));
181
+ padding: var(--dile-color-picker-panel-padding, 1rem);
182
+ transition: var(--dile-color-picker-panel-transition, ease 0.2s);
183
+ transition-property: transform, opacity;
184
+ transform: translateY(-10px);
185
+ }
186
+ #overlay.opened {
187
+ opacity: 1;
188
+ transform: translateY(0);
189
+ }
190
+ #sv-square {
191
+ position: relative;
192
+ width: 100%;
193
+ height: var(--dile-color-picker-sv-square-size, 160px);
194
+ border-radius: var(--dile-color-picker-sv-square-border-radius, 4px);
195
+ background-image: linear-gradient(to top, #000, transparent),
196
+ linear-gradient(to right, #fff, transparent);
197
+ touch-action: none;
198
+ cursor: crosshair;
199
+ }
200
+ #sv-square:focus-visible {
201
+ outline: 2px solid var(--dile-input-focus-border-color, #6af);
202
+ outline-offset: 2px;
203
+ }
204
+ .sv-thumb {
205
+ position: absolute;
206
+ width: var(--dile-color-picker-sv-thumb-size, 14px);
207
+ height: var(--dile-color-picker-sv-thumb-size, 14px);
208
+ border-radius: 50%;
209
+ border: var(--dile-color-picker-sv-thumb-border-width, 2px) solid var(--dile-color-picker-sv-thumb-border-color, #fff);
210
+ box-shadow: 0 0 2px rgba(0, 0, 0, 0.6);
211
+ transform: translate(-50%, -50%);
212
+ pointer-events: none;
213
+ }
214
+ .hue-slider {
215
+ display: block;
216
+ width: 100%;
217
+ margin-top: 0.75rem;
218
+ -webkit-appearance: none;
219
+ -moz-appearance: none;
220
+ appearance: none;
221
+ background: transparent;
222
+ cursor: pointer;
223
+ }
224
+ .hue-slider:focus {
225
+ outline: none;
226
+ }
227
+ .hue-slider::-webkit-slider-runnable-track {
228
+ height: var(--dile-color-picker-hue-track-height, 12px);
229
+ border-radius: var(--dile-color-picker-hue-track-border-radius, 6px);
230
+ background: linear-gradient(
231
+ to right,
232
+ hsl(0, 100%, 50%), hsl(60, 100%, 50%), hsl(120, 100%, 50%),
233
+ hsl(180, 100%, 50%), hsl(240, 100%, 50%), hsl(300, 100%, 50%), hsl(360, 100%, 50%)
234
+ );
235
+ }
236
+ .hue-slider::-webkit-slider-thumb {
237
+ -webkit-appearance: none;
238
+ appearance: none;
239
+ margin-top: calc((var(--dile-color-picker-hue-track-height, 12px) - var(--dile-color-picker-hue-thumb-size, 18px)) / 2);
240
+ width: var(--dile-color-picker-hue-thumb-size, 18px);
241
+ height: var(--dile-color-picker-hue-thumb-size, 18px);
242
+ border-radius: 50%;
243
+ background-color: #fff;
244
+ border: var(--dile-color-picker-hue-thumb-border-width, 2px) solid var(--dile-color-picker-hue-thumb-border-color, #888);
245
+ box-shadow: 0 0 2px rgba(0, 0, 0, 0.4);
246
+ }
247
+ .hue-slider::-moz-range-track {
248
+ height: var(--dile-color-picker-hue-track-height, 12px);
249
+ border-radius: var(--dile-color-picker-hue-track-border-radius, 6px);
250
+ background: linear-gradient(
251
+ to right,
252
+ hsl(0, 100%, 50%), hsl(60, 100%, 50%), hsl(120, 100%, 50%),
253
+ hsl(180, 100%, 50%), hsl(240, 100%, 50%), hsl(300, 100%, 50%), hsl(360, 100%, 50%)
254
+ );
255
+ }
256
+ .hue-slider::-moz-range-thumb {
257
+ width: var(--dile-color-picker-hue-thumb-size, 18px);
258
+ height: var(--dile-color-picker-hue-thumb-size, 18px);
259
+ border-radius: 50%;
260
+ background-color: #fff;
261
+ border: var(--dile-color-picker-hue-thumb-border-width, 2px) solid var(--dile-color-picker-hue-thumb-border-color, #888);
262
+ box-shadow: 0 0 2px rgba(0, 0, 0, 0.4);
263
+ }
264
+ .hex-row {
265
+ display: flex;
266
+ align-items: center;
267
+ gap: 0.5rem;
268
+ margin-top: 0.75rem;
269
+ }
270
+ .hex-swatch {
271
+ display: block;
272
+ flex-shrink: 0;
273
+ width: var(--dile-color-picker-swatch-size, 2em);
274
+ height: var(--dile-color-picker-swatch-size, 2em);
275
+ border-radius: var(--dile-color-picker-swatch-border-radius, 5px);
276
+ border: var(--dile-color-picker-swatch-border-width, 1px) solid var(--dile-color-picker-swatch-border-color, #888);
277
+ }
278
+ .hex-input {
279
+ box-sizing: border-box;
280
+ flex-grow: 1;
281
+ min-width: 0;
282
+ border-radius: var(--dile-color-picker-hex-input-border-radius, 4px);
283
+ border: 1px solid var(--dile-color-picker-hex-input-border-color, #888);
284
+ padding: 5px;
285
+ font-size: var(--dile-color-picker-hex-input-font-size, 0.875rem);
286
+ background-color: var(--dile-color-picker-hex-input-background-color, #fff);
287
+ color: var(--dile-color-picker-hex-input-color, #303030);
288
+ }
289
+ .hex-input:focus {
290
+ outline: none;
291
+ border-color: var(--dile-input-focus-border-color, #6af);
292
+ }
293
+ .hex-input.errored {
294
+ border-color: var(--dile-color-picker-error-border-color, #c00);
295
+ }
296
+ #trigger-overlay {
297
+ display: flex;
298
+ }
299
+ `,
300
+ ];
301
+ }
302
+
303
+ render() {
304
+ return html`
305
+ <main>
306
+ ${this.label ? html`<label id="cp-label">${this.label}</label>` : ''}
307
+ <div id="trigger-overlay">
308
+ <button
309
+ type="button"
310
+ id="trigger"
311
+ class="${this.errored ? 'errored' : ''}"
312
+ style="background-color: ${this.value}"
313
+ ?disabled="${this.disabled}"
314
+ aria-haspopup="dialog"
315
+ aria-expanded="${this.opened}"
316
+ aria-label="${this.label || 'Color picker'}"
317
+ @click="${this.toggle}"
318
+ ></button>
319
+ </div>
320
+ ${this.messageTemplate}
321
+ ${this.overlayTemplate}
322
+ </main>
323
+ `;
324
+ }
325
+
326
+ get overlayTemplate() {
327
+ return html`
328
+ <div
329
+ id="overlay"
330
+ class="${this._overlayClass}"
331
+ role="dialog"
332
+ aria-label="${this.label || 'Color picker'}"
333
+ @click="${this._onOverlayClick}"
334
+ >
335
+ <div
336
+ id="sv-square"
337
+ role="slider"
338
+ tabindex="${this.disabled ? -1 : 0}"
339
+ aria-valuemin="0"
340
+ aria-valuemax="100"
341
+ aria-valuenow="${this._value}"
342
+ aria-valuetext="Saturation ${this._saturation}%, brightness ${this._value}%"
343
+ aria-readonly="${this.readonly}"
344
+ style="background-color: hsl(${this._hue}, 100%, 50%)"
345
+ @pointerdown="${this._onSvPointerDown}"
346
+ @pointermove="${this._onSvPointerMove}"
347
+ @pointerup="${this._onSvPointerUp}"
348
+ @pointercancel="${this._onSvPointerUp}"
349
+ @keydown="${this._onSvKeydown}"
350
+ >
351
+ <div class="sv-thumb" style="left: ${this._saturation}%; top: ${100 - this._value}%"></div>
352
+ </div>
353
+ <input
354
+ type="range"
355
+ class="hue-slider"
356
+ min="0"
357
+ max="360"
358
+ step="1"
359
+ aria-label="Hue"
360
+ .value="${String(this._hue)}"
361
+ ?disabled="${this.disabled}"
362
+ @input="${this._onHueInput}"
363
+ />
364
+ <div class="hex-row">
365
+ <span class="hex-swatch" style="background-color: ${this.value}"></span>
366
+ <input
367
+ type="text"
368
+ class="hex-input ${this.errored ? 'errored' : ''}"
369
+ aria-label="Hex color"
370
+ maxlength="7"
371
+ spellcheck="false"
372
+ autocomplete="off"
373
+ ?disabled="${this.disabled}"
374
+ ?readonly="${this.readonly}"
375
+ .value="${this._hexInputValue}"
376
+ @input="${this._onHexInput}"
377
+ @blur="${this._onHexBlur}"
378
+ @keydown="${this._onHexKeydown}"
379
+ />
380
+ </div>
381
+ </div>
382
+ `;
383
+ }
384
+
385
+ get messageTemplate() {
386
+ return html`
387
+ ${this.message
388
+ ? html`<div class="message ${this.errored ? 'errored-msg' : ''}"><span>${this.message}</span></div>`
389
+ : ''
390
+ }
391
+ `;
392
+ }
393
+
394
+ _onOverlayClick(e) {
395
+ e.stopPropagation();
396
+ }
397
+
398
+ _commitHsv() {
399
+ const hex = hsvToHex(this._hue, this._saturation, this._value);
400
+ this._hexInputValue = hex;
401
+ if (this.value !== hex) {
402
+ this._skipValueSync = true;
403
+ this.value = hex;
404
+ }
405
+ if (this.hideErrorOnInput && this.errored) {
406
+ this.clearError();
407
+ }
408
+ }
409
+
410
+ _onSvPointerDown(e) {
411
+ if (this.disabled || this.readonly) return;
412
+ e.preventDefault();
413
+ this._svDragging = true;
414
+ e.target.setPointerCapture(e.pointerId);
415
+ this._updateSvFromPointer(e);
416
+ }
417
+
418
+ _onSvPointerMove(e) {
419
+ if (!this._svDragging) return;
420
+ this._updateSvFromPointer(e);
421
+ }
422
+
423
+ _onSvPointerUp() {
424
+ this._svDragging = false;
425
+ }
426
+
427
+ _updateSvFromPointer(e) {
428
+ const rect = this.svSquareEl.getBoundingClientRect();
429
+ const x = Math.min(Math.max(e.clientX - rect.left, 0), rect.width);
430
+ const y = Math.min(Math.max(e.clientY - rect.top, 0), rect.height);
431
+ this._saturation = rect.width === 0 ? 0 : Math.round((x / rect.width) * 100);
432
+ this._value = rect.height === 0 ? 100 : Math.round(100 - (y / rect.height) * 100);
433
+ this._commitHsv();
434
+ }
435
+
436
+ _onSvKeydown(e) {
437
+ if (this.disabled || this.readonly) return;
438
+ const step = e.shiftKey ? 10 : 1;
439
+ const clamp = (n) => Math.max(0, Math.min(100, n));
440
+ let handled = true;
441
+ switch (e.key) {
442
+ case 'ArrowRight': this._saturation = clamp(this._saturation + step); break;
443
+ case 'ArrowLeft': this._saturation = clamp(this._saturation - step); break;
444
+ case 'ArrowUp': this._value = clamp(this._value + step); break;
445
+ case 'ArrowDown': this._value = clamp(this._value - step); break;
446
+ case 'Home': this._saturation = 0; break;
447
+ case 'End': this._saturation = 100; break;
448
+ case 'PageUp': this._value = clamp(this._value + 10); break;
449
+ case 'PageDown': this._value = clamp(this._value - 10); break;
450
+ default: handled = false;
451
+ }
452
+ if (handled) {
453
+ e.preventDefault();
454
+ this._commitHsv();
455
+ }
456
+ }
457
+
458
+ _onHueInput(e) {
459
+ if (this.disabled || this.readonly) {
460
+ e.target.value = this._hue;
461
+ return;
462
+ }
463
+ this._hue = Number(e.target.value);
464
+ this._commitHsv();
465
+ }
466
+
467
+ _onHexInput(e) {
468
+ this._hexInputValue = e.target.value;
469
+ if (this.disabled || this.readonly) return;
470
+ const normalized = normalizeHex(this._hexInputValue);
471
+ if (!normalized) return;
472
+ const { h, s, v } = hexToHsv(normalized);
473
+ this._hue = h;
474
+ this._saturation = s;
475
+ this._value = v;
476
+ this._skipValueSync = true;
477
+ this.value = normalized;
478
+ if (this.hideErrorOnInput && this.errored) {
479
+ this.clearError();
480
+ }
481
+ }
482
+
483
+ _onHexBlur() {
484
+ this._hexInputValue = this.value;
485
+ }
486
+
487
+ _onHexKeydown(e) {
488
+ if (e.key === 'Enter') {
489
+ e.preventDefault();
490
+ this._onHexBlur();
491
+ }
492
+ }
493
+
494
+ clearError() {
495
+ this.errored = false;
496
+ this.message = '';
497
+ }
498
+
499
+ focus() {
500
+ this.trigger.focus();
501
+ }
502
+ }
@@ -0,0 +1,87 @@
1
+ export function normalizeHex(input) {
2
+ if (typeof input !== 'string') return null;
3
+ const match = /^#?([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.exec(input.trim());
4
+ if (!match) return null;
5
+ let digits = match[1];
6
+ if (digits.length === 3) {
7
+ digits = digits.split('').map((c) => c + c).join('');
8
+ }
9
+ return `#${digits.toLowerCase()}`;
10
+ }
11
+
12
+ export function hexToRgb(hex) {
13
+ const normalized = normalizeHex(hex);
14
+ if (!normalized) return null;
15
+ const int = parseInt(normalized.slice(1), 16);
16
+ return { r: (int >> 16) & 255, g: (int >> 8) & 255, b: int & 255 };
17
+ }
18
+
19
+ export function rgbToHex({ r, g, b }) {
20
+ const clamp = (n) => Math.max(0, Math.min(255, Math.round(n)));
21
+ const toHex = (n) => clamp(n).toString(16).padStart(2, '0');
22
+ return `#${toHex(r)}${toHex(g)}${toHex(b)}`;
23
+ }
24
+
25
+ export function rgbToHsv({ r, g, b }) {
26
+ const rN = r / 255;
27
+ const gN = g / 255;
28
+ const bN = b / 255;
29
+ const max = Math.max(rN, gN, bN);
30
+ const min = Math.min(rN, gN, bN);
31
+ const delta = max - min;
32
+
33
+ let h = 0;
34
+ if (delta !== 0) {
35
+ if (max === rN) {
36
+ h = 60 * (((gN - bN) / delta) % 6);
37
+ } else if (max === gN) {
38
+ h = 60 * ((bN - rN) / delta + 2);
39
+ } else {
40
+ h = 60 * ((rN - gN) / delta + 4);
41
+ }
42
+ }
43
+ if (h < 0) h += 360;
44
+
45
+ const s = max === 0 ? 0 : delta / max;
46
+ return { h: Math.round(h), s: Math.round(s * 100), v: Math.round(max * 100) };
47
+ }
48
+
49
+ export function hsvToRgb({ h, s, v }) {
50
+ const hN = ((h % 360) + 360) % 360;
51
+ const sN = Math.max(0, Math.min(100, s)) / 100;
52
+ const vN = Math.max(0, Math.min(100, v)) / 100;
53
+
54
+ const c = vN * sN;
55
+ const x = c * (1 - Math.abs(((hN / 60) % 2) - 1));
56
+ const m = vN - c;
57
+
58
+ let r1, g1, b1;
59
+ if (hN < 60) {
60
+ [r1, g1, b1] = [c, x, 0];
61
+ } else if (hN < 120) {
62
+ [r1, g1, b1] = [x, c, 0];
63
+ } else if (hN < 180) {
64
+ [r1, g1, b1] = [0, c, x];
65
+ } else if (hN < 240) {
66
+ [r1, g1, b1] = [0, x, c];
67
+ } else if (hN < 300) {
68
+ [r1, g1, b1] = [x, 0, c];
69
+ } else {
70
+ [r1, g1, b1] = [c, 0, x];
71
+ }
72
+
73
+ return {
74
+ r: Math.round((r1 + m) * 255),
75
+ g: Math.round((g1 + m) * 255),
76
+ b: Math.round((b1 + m) * 255),
77
+ };
78
+ }
79
+
80
+ export function hexToHsv(hex) {
81
+ const rgb = hexToRgb(hex);
82
+ return rgb ? rgbToHsv(rgb) : null;
83
+ }
84
+
85
+ export function hsvToHex(h, s, v) {
86
+ return rgbToHex(hsvToRgb({ h, s, v }));
87
+ }
@@ -0,0 +1,94 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import {
4
+ normalizeHex,
5
+ hexToRgb,
6
+ rgbToHex,
7
+ rgbToHsv,
8
+ hsvToRgb,
9
+ hexToHsv,
10
+ hsvToHex,
11
+ } from './colorConversion.js';
12
+
13
+ test('normalizeHex accepts 6-digit hex with or without the hash', () => {
14
+ assert.equal(normalizeHex('#ff0000'), '#ff0000');
15
+ assert.equal(normalizeHex('ff0000'), '#ff0000');
16
+ });
17
+
18
+ test('normalizeHex lowercases uppercase input', () => {
19
+ assert.equal(normalizeHex('#FF00AA'), '#ff00aa');
20
+ });
21
+
22
+ test('normalizeHex expands 3-digit shorthand', () => {
23
+ assert.equal(normalizeHex('#abc'), '#aabbcc');
24
+ assert.equal(normalizeHex('abc'), '#aabbcc');
25
+ });
26
+
27
+ test('normalizeHex rejects invalid input', () => {
28
+ assert.equal(normalizeHex('red'), null);
29
+ assert.equal(normalizeHex('#ff'), null);
30
+ assert.equal(normalizeHex('#gggggg'), null);
31
+ assert.equal(normalizeHex(''), null);
32
+ assert.equal(normalizeHex(null), null);
33
+ assert.equal(normalizeHex(undefined), null);
34
+ });
35
+
36
+ test('hexToRgb / rgbToHex round trip on reference colors', () => {
37
+ assert.deepEqual(hexToRgb('#ff0000'), { r: 255, g: 0, b: 0 });
38
+ assert.deepEqual(hexToRgb('#00ff00'), { r: 0, g: 255, b: 0 });
39
+ assert.deepEqual(hexToRgb('#0000ff'), { r: 0, g: 0, b: 255 });
40
+ assert.equal(rgbToHex({ r: 255, g: 0, b: 0 }), '#ff0000');
41
+ assert.equal(rgbToHex({ r: 0, g: 255, b: 0 }), '#00ff00');
42
+ assert.equal(rgbToHex({ r: 0, g: 0, b: 255 }), '#0000ff');
43
+ });
44
+
45
+ test('rgbToHex clamps out-of-range channels', () => {
46
+ assert.equal(rgbToHex({ r: 300, g: -10, b: 128 }), '#ff0080');
47
+ });
48
+
49
+ test('hexToRgb returns null for invalid hex', () => {
50
+ assert.equal(hexToRgb('not-a-color'), null);
51
+ });
52
+
53
+ test('rgbToHsv matches known reference hues', () => {
54
+ assert.deepEqual(rgbToHsv({ r: 255, g: 0, b: 0 }), { h: 0, s: 100, v: 100 });
55
+ assert.deepEqual(rgbToHsv({ r: 0, g: 255, b: 0 }), { h: 120, s: 100, v: 100 });
56
+ assert.deepEqual(rgbToHsv({ r: 0, g: 0, b: 255 }), { h: 240, s: 100, v: 100 });
57
+ });
58
+
59
+ test('rgbToHsv reports saturation 0 for grays, including white and black', () => {
60
+ assert.deepEqual(rgbToHsv({ r: 255, g: 255, b: 255 }), { h: 0, s: 0, v: 100 });
61
+ assert.deepEqual(rgbToHsv({ r: 0, g: 0, b: 0 }), { h: 0, s: 0, v: 0 });
62
+ assert.deepEqual(rgbToHsv({ r: 128, g: 128, b: 128 }), { h: 0, s: 0, v: 50 });
63
+ });
64
+
65
+ test('hsvToRgb inverts rgbToHsv for reference colors', () => {
66
+ assert.deepEqual(hsvToRgb({ h: 0, s: 100, v: 100 }), { r: 255, g: 0, b: 0 });
67
+ assert.deepEqual(hsvToRgb({ h: 120, s: 100, v: 100 }), { r: 0, g: 255, b: 0 });
68
+ assert.deepEqual(hsvToRgb({ h: 240, s: 100, v: 100 }), { r: 0, g: 0, b: 255 });
69
+ });
70
+
71
+ test('hexToHsv / hsvToHex round trip for arbitrary colors stays within rounding tolerance', () => {
72
+ for (const hex of ['#7bb93d', '#cc0000', '#336699', '#ffffff', '#000000', '#808080']) {
73
+ const { h, s, v } = hexToHsv(hex);
74
+ const original = hexToRgb(hex);
75
+ const roundTripped = hexToRgb(hsvToHex(h, s, v));
76
+ for (const channel of ['r', 'g', 'b']) {
77
+ assert.ok(
78
+ Math.abs(original[channel] - roundTripped[channel]) <= 2,
79
+ `${hex} channel ${channel} drifted too far after HSV round trip: ${original[channel]} vs ${roundTripped[channel]}`
80
+ );
81
+ }
82
+ }
83
+ });
84
+
85
+ test('hexToHsv / hsvToHex round trip is exact for colors immune to integer HSV rounding', () => {
86
+ for (const hex of ['#ff0000', '#00ff00', '#0000ff', '#ffffff', '#000000', '#808080']) {
87
+ const { h, s, v } = hexToHsv(hex);
88
+ assert.equal(hsvToHex(h, s, v), hex);
89
+ }
90
+ });
91
+
92
+ test('hexToHsv returns null for invalid hex', () => {
93
+ assert.equal(hexToHsv('nope'), null);
94
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dile/ui",
3
- "version": "2.27.4",
3
+ "version": "2.28.0",
4
4
  "description": "UI Core components from dile-components.",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -27,5 +27,5 @@
27
27
  "publishConfig": {
28
28
  "access": "public"
29
29
  },
30
- "gitHead": "d58cfec4206fe5fca4fa634be70f11ab3c9a6899"
30
+ "gitHead": "d90ac8ac1b524d3591fc65ac57a6156131a940bb"
31
31
  }