@dile/ui 2.27.4 → 3.0.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.
@@ -19,6 +19,7 @@ export class DileButtonIcon extends DileButton {
19
19
  --dile-icon-color: var(--dile-button-icon-color, var(--dile-on-primary-color, #ffffff));
20
20
  }
21
21
  dile-icon {
22
+ --dile-icon-size: var(--dile-button-icon-size, calc(var(--dile-button-font-size, 1rem) * 1.125));
22
23
  margin-right: var(--dile-button-icon-separation, 0.3rem);
23
24
  }
24
25
  button:hover {
@@ -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,496 @@
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 { labelStyles, 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
+ labelStyles,
137
+ messageStyles,
138
+ css`
139
+ * {
140
+ box-sizing: border-box;
141
+ }
142
+ :host {
143
+ display: inline-block;
144
+ margin-bottom: 10px;
145
+ }
146
+ #trigger {
147
+ padding: 0;
148
+ width: var(--dile-color-picker-swatch-size, 2em);
149
+ height: var(--dile-color-picker-swatch-size, 2em);
150
+ border-radius: var(--dile-color-picker-swatch-border-radius, 5px);
151
+ border: var(--dile-color-picker-swatch-border-width, 1px) solid var(--dile-color-picker-swatch-border-color, #888);
152
+ cursor: pointer;
153
+ }
154
+ #trigger:disabled {
155
+ cursor: default;
156
+ opacity: 0.6;
157
+ }
158
+ #trigger.errored {
159
+ border-color: var(--dile-color-picker-error-border-color, #c00);
160
+ }
161
+ #trigger:focus-visible {
162
+ outline: none;
163
+ border-width: var(--dile-color-picker-swatch-focus-border-width, 3px);
164
+ border-color: var(--dile-input-focus-border-color, var(--dile-link-color, #6af));
165
+ }
166
+ #overlay {
167
+ display: none;
168
+ position: absolute;
169
+ opacity: 0;
170
+ z-index: var(--dile-color-picker-panel-z-index, 999);
171
+ width: var(--dile-color-picker-panel-width, 220px);
172
+ background-color: var(--dile-color-picker-panel-background-color, #fff);
173
+ border-radius: var(--dile-color-picker-panel-border-radius, 5px);
174
+ box-shadow: var(--dile-color-picker-panel-box-shadow, 0 0 20px rgba(102, 102, 102, 0.5));
175
+ padding: var(--dile-color-picker-panel-padding, 1rem);
176
+ transition: var(--dile-color-picker-panel-transition, ease 0.2s);
177
+ transition-property: transform, opacity;
178
+ transform: translateY(-10px);
179
+ }
180
+ #overlay.opened {
181
+ opacity: 1;
182
+ transform: translateY(0);
183
+ }
184
+ #sv-square {
185
+ position: relative;
186
+ width: 100%;
187
+ height: var(--dile-color-picker-sv-square-size, 160px);
188
+ border-radius: var(--dile-color-picker-sv-square-border-radius, 4px);
189
+ background-image: linear-gradient(to top, #000, transparent),
190
+ linear-gradient(to right, #fff, transparent);
191
+ touch-action: none;
192
+ cursor: crosshair;
193
+ }
194
+ #sv-square:focus-visible {
195
+ outline: 2px solid var(--dile-input-focus-border-color, var(--dile-link-color, #6af));
196
+ outline-offset: 2px;
197
+ }
198
+ .sv-thumb {
199
+ position: absolute;
200
+ width: var(--dile-color-picker-sv-thumb-size, 14px);
201
+ height: var(--dile-color-picker-sv-thumb-size, 14px);
202
+ border-radius: 50%;
203
+ border: var(--dile-color-picker-sv-thumb-border-width, 2px) solid var(--dile-color-picker-sv-thumb-border-color, #fff);
204
+ box-shadow: 0 0 2px rgba(0, 0, 0, 0.6);
205
+ transform: translate(-50%, -50%);
206
+ pointer-events: none;
207
+ }
208
+ .hue-slider {
209
+ display: block;
210
+ width: 100%;
211
+ margin-top: 0.75rem;
212
+ -webkit-appearance: none;
213
+ -moz-appearance: none;
214
+ appearance: none;
215
+ background: transparent;
216
+ cursor: pointer;
217
+ }
218
+ .hue-slider:focus {
219
+ outline: none;
220
+ }
221
+ .hue-slider::-webkit-slider-runnable-track {
222
+ height: var(--dile-color-picker-hue-track-height, 12px);
223
+ border-radius: var(--dile-color-picker-hue-track-border-radius, 6px);
224
+ background: linear-gradient(
225
+ to right,
226
+ hsl(0, 100%, 50%), hsl(60, 100%, 50%), hsl(120, 100%, 50%),
227
+ hsl(180, 100%, 50%), hsl(240, 100%, 50%), hsl(300, 100%, 50%), hsl(360, 100%, 50%)
228
+ );
229
+ }
230
+ .hue-slider::-webkit-slider-thumb {
231
+ -webkit-appearance: none;
232
+ appearance: none;
233
+ margin-top: calc((var(--dile-color-picker-hue-track-height, 12px) - var(--dile-color-picker-hue-thumb-size, 18px)) / 2);
234
+ width: var(--dile-color-picker-hue-thumb-size, 18px);
235
+ height: var(--dile-color-picker-hue-thumb-size, 18px);
236
+ border-radius: 50%;
237
+ background-color: #fff;
238
+ border: var(--dile-color-picker-hue-thumb-border-width, 2px) solid var(--dile-color-picker-hue-thumb-border-color, #888);
239
+ box-shadow: 0 0 2px rgba(0, 0, 0, 0.4);
240
+ }
241
+ .hue-slider::-moz-range-track {
242
+ height: var(--dile-color-picker-hue-track-height, 12px);
243
+ border-radius: var(--dile-color-picker-hue-track-border-radius, 6px);
244
+ background: linear-gradient(
245
+ to right,
246
+ hsl(0, 100%, 50%), hsl(60, 100%, 50%), hsl(120, 100%, 50%),
247
+ hsl(180, 100%, 50%), hsl(240, 100%, 50%), hsl(300, 100%, 50%), hsl(360, 100%, 50%)
248
+ );
249
+ }
250
+ .hue-slider::-moz-range-thumb {
251
+ width: var(--dile-color-picker-hue-thumb-size, 18px);
252
+ height: var(--dile-color-picker-hue-thumb-size, 18px);
253
+ border-radius: 50%;
254
+ background-color: #fff;
255
+ border: var(--dile-color-picker-hue-thumb-border-width, 2px) solid var(--dile-color-picker-hue-thumb-border-color, #888);
256
+ box-shadow: 0 0 2px rgba(0, 0, 0, 0.4);
257
+ }
258
+ .hex-row {
259
+ display: flex;
260
+ align-items: center;
261
+ gap: 0.5rem;
262
+ margin-top: 0.75rem;
263
+ }
264
+ .hex-swatch {
265
+ display: block;
266
+ flex-shrink: 0;
267
+ width: var(--dile-color-picker-swatch-size, 2em);
268
+ height: var(--dile-color-picker-swatch-size, 2em);
269
+ border-radius: var(--dile-color-picker-swatch-border-radius, 5px);
270
+ border: var(--dile-color-picker-swatch-border-width, 1px) solid var(--dile-color-picker-swatch-border-color, #888);
271
+ }
272
+ .hex-input {
273
+ box-sizing: border-box;
274
+ flex-grow: 1;
275
+ min-width: 0;
276
+ border-radius: var(--dile-color-picker-hex-input-border-radius, 4px);
277
+ border: 1px solid var(--dile-color-picker-hex-input-border-color, #888);
278
+ padding: 5px;
279
+ font-size: var(--dile-color-picker-hex-input-font-size, 0.875rem);
280
+ background-color: var(--dile-color-picker-hex-input-background-color, #fff);
281
+ color: var(--dile-color-picker-hex-input-color, #303030);
282
+ }
283
+ .hex-input:focus {
284
+ outline: none;
285
+ border-color: var(--dile-input-focus-border-color, var(--dile-link-color, #6af));
286
+ }
287
+ .hex-input.errored {
288
+ border-color: var(--dile-color-picker-error-border-color, #c00);
289
+ }
290
+ #trigger-overlay {
291
+ display: flex;
292
+ }
293
+ `,
294
+ ];
295
+ }
296
+
297
+ render() {
298
+ return html`
299
+ <main>
300
+ ${this.label ? html`<label id="cp-label">${this.label}</label>` : ''}
301
+ <div id="trigger-overlay">
302
+ <button
303
+ type="button"
304
+ id="trigger"
305
+ class="${this.errored ? 'errored' : ''}"
306
+ style="background-color: ${this.value}"
307
+ ?disabled="${this.disabled}"
308
+ aria-haspopup="dialog"
309
+ aria-expanded="${this.opened}"
310
+ aria-label="${this.label || 'Color picker'}"
311
+ @click="${this.toggle}"
312
+ ></button>
313
+ </div>
314
+ ${this.messageTemplate}
315
+ ${this.overlayTemplate}
316
+ </main>
317
+ `;
318
+ }
319
+
320
+ get overlayTemplate() {
321
+ return html`
322
+ <div
323
+ id="overlay"
324
+ class="${this._overlayClass}"
325
+ role="dialog"
326
+ aria-label="${this.label || 'Color picker'}"
327
+ @click="${this._onOverlayClick}"
328
+ >
329
+ <div
330
+ id="sv-square"
331
+ role="slider"
332
+ tabindex="${this.disabled ? -1 : 0}"
333
+ aria-valuemin="0"
334
+ aria-valuemax="100"
335
+ aria-valuenow="${this._value}"
336
+ aria-valuetext="Saturation ${this._saturation}%, brightness ${this._value}%"
337
+ aria-readonly="${this.readonly}"
338
+ style="background-color: hsl(${this._hue}, 100%, 50%)"
339
+ @pointerdown="${this._onSvPointerDown}"
340
+ @pointermove="${this._onSvPointerMove}"
341
+ @pointerup="${this._onSvPointerUp}"
342
+ @pointercancel="${this._onSvPointerUp}"
343
+ @keydown="${this._onSvKeydown}"
344
+ >
345
+ <div class="sv-thumb" style="left: ${this._saturation}%; top: ${100 - this._value}%"></div>
346
+ </div>
347
+ <input
348
+ type="range"
349
+ class="hue-slider"
350
+ min="0"
351
+ max="360"
352
+ step="1"
353
+ aria-label="Hue"
354
+ .value="${String(this._hue)}"
355
+ ?disabled="${this.disabled}"
356
+ @input="${this._onHueInput}"
357
+ />
358
+ <div class="hex-row">
359
+ <span class="hex-swatch" style="background-color: ${this.value}"></span>
360
+ <input
361
+ type="text"
362
+ class="hex-input ${this.errored ? 'errored' : ''}"
363
+ aria-label="Hex color"
364
+ maxlength="7"
365
+ spellcheck="false"
366
+ autocomplete="off"
367
+ ?disabled="${this.disabled}"
368
+ ?readonly="${this.readonly}"
369
+ .value="${this._hexInputValue}"
370
+ @input="${this._onHexInput}"
371
+ @blur="${this._onHexBlur}"
372
+ @keydown="${this._onHexKeydown}"
373
+ />
374
+ </div>
375
+ </div>
376
+ `;
377
+ }
378
+
379
+ get messageTemplate() {
380
+ return html`
381
+ ${this.message
382
+ ? html`<div class="message ${this.errored ? 'errored-msg' : ''}"><span>${this.message}</span></div>`
383
+ : ''
384
+ }
385
+ `;
386
+ }
387
+
388
+ _onOverlayClick(e) {
389
+ e.stopPropagation();
390
+ }
391
+
392
+ _commitHsv() {
393
+ const hex = hsvToHex(this._hue, this._saturation, this._value);
394
+ this._hexInputValue = hex;
395
+ if (this.value !== hex) {
396
+ this._skipValueSync = true;
397
+ this.value = hex;
398
+ }
399
+ if (this.hideErrorOnInput && this.errored) {
400
+ this.clearError();
401
+ }
402
+ }
403
+
404
+ _onSvPointerDown(e) {
405
+ if (this.disabled || this.readonly) return;
406
+ e.preventDefault();
407
+ this._svDragging = true;
408
+ e.target.setPointerCapture(e.pointerId);
409
+ this._updateSvFromPointer(e);
410
+ }
411
+
412
+ _onSvPointerMove(e) {
413
+ if (!this._svDragging) return;
414
+ this._updateSvFromPointer(e);
415
+ }
416
+
417
+ _onSvPointerUp() {
418
+ this._svDragging = false;
419
+ }
420
+
421
+ _updateSvFromPointer(e) {
422
+ const rect = this.svSquareEl.getBoundingClientRect();
423
+ const x = Math.min(Math.max(e.clientX - rect.left, 0), rect.width);
424
+ const y = Math.min(Math.max(e.clientY - rect.top, 0), rect.height);
425
+ this._saturation = rect.width === 0 ? 0 : Math.round((x / rect.width) * 100);
426
+ this._value = rect.height === 0 ? 100 : Math.round(100 - (y / rect.height) * 100);
427
+ this._commitHsv();
428
+ }
429
+
430
+ _onSvKeydown(e) {
431
+ if (this.disabled || this.readonly) return;
432
+ const step = e.shiftKey ? 10 : 1;
433
+ const clamp = (n) => Math.max(0, Math.min(100, n));
434
+ let handled = true;
435
+ switch (e.key) {
436
+ case 'ArrowRight': this._saturation = clamp(this._saturation + step); break;
437
+ case 'ArrowLeft': this._saturation = clamp(this._saturation - step); break;
438
+ case 'ArrowUp': this._value = clamp(this._value + step); break;
439
+ case 'ArrowDown': this._value = clamp(this._value - step); break;
440
+ case 'Home': this._saturation = 0; break;
441
+ case 'End': this._saturation = 100; break;
442
+ case 'PageUp': this._value = clamp(this._value + 10); break;
443
+ case 'PageDown': this._value = clamp(this._value - 10); break;
444
+ default: handled = false;
445
+ }
446
+ if (handled) {
447
+ e.preventDefault();
448
+ this._commitHsv();
449
+ }
450
+ }
451
+
452
+ _onHueInput(e) {
453
+ if (this.disabled || this.readonly) {
454
+ e.target.value = this._hue;
455
+ return;
456
+ }
457
+ this._hue = Number(e.target.value);
458
+ this._commitHsv();
459
+ }
460
+
461
+ _onHexInput(e) {
462
+ this._hexInputValue = e.target.value;
463
+ if (this.disabled || this.readonly) return;
464
+ const normalized = normalizeHex(this._hexInputValue);
465
+ if (!normalized) return;
466
+ const { h, s, v } = hexToHsv(normalized);
467
+ this._hue = h;
468
+ this._saturation = s;
469
+ this._value = v;
470
+ this._skipValueSync = true;
471
+ this.value = normalized;
472
+ if (this.hideErrorOnInput && this.errored) {
473
+ this.clearError();
474
+ }
475
+ }
476
+
477
+ _onHexBlur() {
478
+ this._hexInputValue = this.value;
479
+ }
480
+
481
+ _onHexKeydown(e) {
482
+ if (e.key === 'Enter') {
483
+ e.preventDefault();
484
+ this._onHexBlur();
485
+ }
486
+ }
487
+
488
+ clearError() {
489
+ this.errored = false;
490
+ this.message = '';
491
+ }
492
+
493
+ focus() {
494
+ this.trigger.focus();
495
+ }
496
+ }
@@ -1,11 +1,13 @@
1
1
  import { LitElement, html, css } from 'lit';
2
2
  import '../file-preview.js';
3
3
  import '../../button/button.js';
4
+ import { labelStyles } from '../../input/src/label-styles.js';
4
5
  import { messageStyles } from '../../input/src/message-styles.js';
5
6
  import { DileEmmitChange } from '../../../mixins/form/index.js';
6
7
 
7
8
  export class DileDropFile extends DileEmmitChange(LitElement) {
8
9
  static styles = [
10
+ labelStyles,
9
11
  messageStyles,
10
12
  css`
11
13
  * { box-sizing: border-box; }
@@ -41,13 +43,6 @@ export class DileDropFile extends DileEmmitChange(LitElement) {
41
43
  display: none;
42
44
  }
43
45
 
44
- label {
45
- display: block;
46
- margin-bottom: var(--dile-input-label-margin-bottom, 4px);
47
- font-size: var(--dile-input-label-font-size, 1em);
48
- color: var(--dile-input-label-color, #59e);
49
- font-weight: var(--dile-input-label-font-weight, normal);
50
- }
51
46
  main {
52
47
  width: var(--dile-input-section-width, 100%);
53
48
  }
@@ -5,5 +5,6 @@ export { DileInputMoney } from './src/DileInputMoney.js';
5
5
  export { DileInputNumberMask } from './src/DileInputNumberMask.js';
6
6
  export { DileInputPercentage } from './src/DileInputPercentage.js';
7
7
  export { DileInputSearch } from './src/DileInputSearch.js';
8
+ export { labelStyles } from './src/label-styles.js';
8
9
  export { messageStyles } from './src/message-styles.js';
9
10
  export { Mask } from './src/Mask.js';
@@ -1,5 +1,6 @@
1
1
  import { LitElement, html, css } from 'lit';
2
2
  import { DileEmmitChange } from '../../../mixins/form/index.js';
3
+ import { labelStyles } from './label-styles.js';
3
4
  import { messageStyles } from './message-styles.js';
4
5
 
5
6
  export class DileInput extends DileEmmitChange(LitElement) {
@@ -76,7 +77,7 @@ export class DileInput extends DileEmmitChange(LitElement) {
76
77
  this.disableAutocomplete = false;
77
78
  this.name = '';
78
79
  this.type = 'text';
79
- this.types = ['text', 'password', 'email', 'number', 'tel', 'url', 'search', 'date', 'time', 'datetime', 'datetime-local', 'month', 'week'];
80
+ this.types = ['text', 'password', 'email', 'number', 'tel', 'url', 'search', 'date', 'time', 'datetime', 'datetime-local', 'month', 'week'];
80
81
  this.internals = this.attachInternals();
81
82
  }
82
83
 
@@ -95,6 +96,7 @@ export class DileInput extends DileEmmitChange(LitElement) {
95
96
 
96
97
  static get styles() {
97
98
  return [
99
+ labelStyles,
98
100
  messageStyles,
99
101
  css`
100
102
  * {
@@ -110,13 +112,6 @@ export class DileInput extends DileEmmitChange(LitElement) {
110
112
  main {
111
113
  width: var(--dile-input-section-width, 100%);
112
114
  }
113
- label {
114
- display: block;
115
- margin-bottom: var(--dile-input-label-margin-bottom, 4px);
116
- font-size: var(--dile-input-label-font-size, 1em);
117
- color: var(--dile-input-label-color, #59e);
118
- font-weight: var(--dile-input-label-font-weight, normal);
119
- }
120
115
  input {
121
116
  box-sizing: border-box;
122
117
  border-radius: var(--dile-input-border-radius, 5px);
@@ -131,8 +126,9 @@ export class DileInput extends DileEmmitChange(LitElement) {
131
126
  flex-grow: 1;
132
127
  }
133
128
  input:focus {
134
- outline: none;
135
- border-color: var(--dile-input-focus-border-color, #6af)
129
+ outline: var(--dile-input-focus-ring-width, 3px) solid var(--dile-input-focus-ring-color, rgba(102, 170, 255, 0.5));
130
+ outline-offset: calc(-1 * var(--dile-input-focus-ring-width, 3px));
131
+ border-color: var(--dile-input-focus-border-color, var(--dile-link-color, #6af));
136
132
  }
137
133
  input::placeholder {
138
134
  color: var(--dile-input-placeholder-color, #ccc);
@@ -152,13 +148,13 @@ export class DileInput extends DileEmmitChange(LitElement) {
152
148
  }
153
149
  .labelright {
154
150
  margin-left: var(--dile-input-label-right-margin-left, 10px);
155
- color: var(--dile-input-label-color, #59e);
151
+ color: var(--dile-input-label-color, var(--dile-on-background-color, #59e));
156
152
  font-size: var(--dile-input-label-right-font-size, 1.2em);
157
153
  }
158
154
  `
159
155
  ];
160
156
  }
161
-
157
+
162
158
  render() {
163
159
  return html`
164
160
  <main>
@@ -180,8 +176,8 @@ export class DileInput extends DileEmmitChange(LitElement) {
180
176
  @input="${this._input}"
181
177
  @blur="${this.doBlur}"
182
178
  @focus="${this.doFocus}"
183
- />
184
- ${this.labelRight
179
+ />
180
+ ${this.labelRight
185
181
  ? html`<span class="labelright">${this.labelRight}</span>`
186
182
  : ''
187
183
  }
@@ -193,7 +189,7 @@ export class DileInput extends DileEmmitChange(LitElement) {
193
189
 
194
190
  get messageTemplate() {
195
191
  return html`
196
- ${this.message
192
+ ${this.message
197
193
  ? html`<div class="message ${this.errored ? 'errored-msg' : ''}"><span>${this.message}</span></div>`
198
194
  : ''
199
195
  }
@@ -212,10 +208,10 @@ export class DileInput extends DileEmmitChange(LitElement) {
212
208
  _input(e) {
213
209
  this.value = e.target.value;
214
210
  if (this.hideErrorOnInput && this.errored) {
215
- this.clearError();
211
+ this.clearError();
216
212
  }
217
213
  }
218
-
214
+
219
215
  clearError() {
220
216
  this.errored = false;
221
217
  this.message = '';
@@ -249,4 +245,4 @@ export class DileInput extends DileEmmitChange(LitElement) {
249
245
  focus() {
250
246
  this.el.focus();
251
247
  }
252
- }
248
+ }
@@ -18,7 +18,7 @@ export class DileInputSearch extends LitElement {
18
18
  background-color: var(--dile-input-background-color, #fff);
19
19
  }
20
20
  div:focus-within {
21
- border-color: var(--dile-input-focus-border-color, #6af);
21
+ border-color: var(--dile-input-focus-border-color, var(--dile-link-color, #6af));
22
22
  }
23
23
  input {
24
24
  flex-grow: 1;
@@ -0,0 +1,11 @@
1
+ import { css } from 'lit';
2
+
3
+ export const labelStyles = css`
4
+ label, .label {
5
+ display: block;
6
+ margin-bottom: var(--dile-input-label-margin-bottom, 4px);
7
+ font-size: var(--dile-input-label-font-size, 1em);
8
+ color: var(--dile-input-label-color, var(--dile-on-background-color, #59e));
9
+ font-weight: var(--dile-input-label-font-weight, normal);
10
+ }
11
+ `;
@@ -1,10 +1,12 @@
1
1
  import { LitElement, html, css } from 'lit';
2
2
  import { DileEmmitChange } from '../../../mixins/form/index.js';
3
3
  import '../number-picker-element.js';
4
+ import { labelStyles } from '../../input/src/label-styles.js';
4
5
  import { messageStyles } from '../../input/src/message-styles.js';
5
6
 
6
7
  export class DileNumberPicker extends DileEmmitChange(LitElement) {
7
8
  static styles = [
9
+ labelStyles,
8
10
  messageStyles,
9
11
  css`
10
12
  :host {
@@ -14,13 +16,6 @@ export class DileNumberPicker extends DileEmmitChange(LitElement) {
14
16
  * {
15
17
  box-sizing: border-box;
16
18
  }
17
- label {
18
- display: block;
19
- margin-bottom: var(--dile-input-label-margin-bottom, 4px);
20
- font-size: var(--dile-input-label-font-size, 1em);
21
- color: var(--dile-input-label-color, #59e);
22
- font-weight: var(--dile-input-label-font-weight, normal);
23
- }
24
19
  `
25
20
  ];
26
21
 
@@ -1,6 +1,6 @@
1
1
  import { LitElement, html, css } from 'lit';
2
2
  import { DileEmmitChange } from '../../../mixins/form/index.js';
3
- import { messageStyles } from '../../input/index.js';
3
+ import { labelStyles, messageStyles } from '../../input/index.js';
4
4
  import { sanitizeOtpValue } from '../../../lib/otp/sanitizeOtpValue.js';
5
5
 
6
6
  export class DileOtpInput extends DileEmmitChange(LitElement) {
@@ -106,6 +106,7 @@ export class DileOtpInput extends DileEmmitChange(LitElement) {
106
106
 
107
107
  static get styles() {
108
108
  return [
109
+ labelStyles,
109
110
  messageStyles,
110
111
  css`
111
112
  * {
@@ -118,13 +119,6 @@ export class DileOtpInput extends DileEmmitChange(LitElement) {
118
119
  main {
119
120
  width: var(--dile-otp-input-section-width, auto);
120
121
  }
121
- label {
122
- display: block;
123
- margin-bottom: var(--dile-otp-input-label-margin-bottom, 4px);
124
- font-size: var(--dile-otp-input-label-font-size, 1em);
125
- color: var(--dile-otp-input-label-color, #59e);
126
- font-weight: var(--dile-otp-input-label-font-weight, normal);
127
- }
128
122
  section.for-input {
129
123
  display: flex;
130
124
  gap: var(--dile-otp-input-gap, 8px);
@@ -143,7 +137,7 @@ export class DileOtpInput extends DileEmmitChange(LitElement) {
143
137
  }
144
138
  input:focus {
145
139
  outline: none;
146
- border-color: var(--dile-otp-input-focus-border-color, #6af);
140
+ border-color: var(--dile-otp-input-focus-border-color, var(--dile-link-color, #6af));
147
141
  }
148
142
  input:disabled {
149
143
  background-color: #f5f5f5;
@@ -2,21 +2,16 @@ import { LitElement, html, css } from 'lit';
2
2
  import { DileEmmitChange } from '../../../mixins/form/index.js';
3
3
  import '../radio.js';
4
4
  import '../../input/input-message.js';
5
+ import { labelStyles } from '../../input/index.js';
5
6
 
6
7
  export class DileRadioGroup extends DileEmmitChange(LitElement) {
7
8
  static styles = [
9
+ labelStyles,
8
10
  css`
9
11
  :host {
10
12
  display: block;
11
13
  margin-bottom: 10px;
12
14
  }
13
- .label {
14
- display: block;
15
- margin-bottom: var(--dile-input-label-margin-bottom, 4px);
16
- font-size: var(--dile-input-label-font-size, 1em);
17
- color: var(--dile-input-label-color, #59e);
18
- font-weight: var(--dile-input-label-font-weight, normal);
19
- }
20
15
  :host([disabled]) {
21
16
  --dile-radio-icon-color: var(--dile-radio-disabled-icon-color, #ccc);
22
17
  }
@@ -1,11 +1,14 @@
1
1
  import { LitElement, html, css } from 'lit';
2
2
  import { DileEmmitChange } from '../../../mixins/form/index.js';
3
3
  import '../../input/input-message.js';
4
+ import { labelStyles } from '../../input/index.js';
4
5
 
5
6
  export class DileRange extends DileEmmitChange(LitElement) {
6
7
 
7
8
  static get styles() {
8
- return css`
9
+ return [
10
+ labelStyles,
11
+ css`
9
12
  :host {
10
13
  display: block;
11
14
  margin-bottom: 10px;
@@ -15,14 +18,6 @@ export class DileRange extends DileEmmitChange(LitElement) {
15
18
  --dile-range-line-color: var(--dile-range-line-errored-color, #c00);
16
19
  }
17
20
 
18
- label {
19
- display: block;
20
- margin-bottom: var(--dile-input-label-margin-bottom, 4px);
21
- font-size: var(--dile-input-label-font-size, 1em);
22
- color: var(--dile-input-label-color, #59e);
23
- font-weight: var(--dile-input-label-font-weight, normal);
24
- }
25
-
26
21
  /*********** Baseline, reset styles ***********/
27
22
  input[type="range"] {
28
23
  -webkit-appearance: none;
@@ -87,7 +82,8 @@ export class DileRange extends DileEmmitChange(LitElement) {
87
82
  }
88
83
 
89
84
 
90
- `;
85
+ `
86
+ ];
91
87
  }
92
88
 
93
89
  static get formAssociated() {
@@ -1,10 +1,11 @@
1
1
  import { html, css, LitElement } from "lit";
2
2
  import { DileEmmitChange } from '../../../mixins/form/index.js';
3
- import { messageStyles } from '../../input/index.js';
3
+ import { labelStyles, messageStyles } from '../../input/index.js';
4
4
 
5
5
  export class DileSelect extends DileEmmitChange(LitElement) {
6
6
  static get styles() {
7
7
  return [
8
+ labelStyles,
8
9
  messageStyles,
9
10
  css`
10
11
  :host {
@@ -14,13 +15,6 @@ export class DileSelect extends DileEmmitChange(LitElement) {
14
15
  * {
15
16
  box-sizing: border-box;
16
17
  }
17
- label {
18
- display: block;
19
- margin-bottom: var(--dile-input-label-margin-bottom, 4px);
20
- font-size: var(--dile-input-label-font-size, 1em);
21
- color: var(--dile-input-label-color, #59e);
22
- font-weight: var(--dile-input-label-font-weight, normal);
23
- }
24
18
  ::slotted(select) {
25
19
  box-sizing: border-box;
26
20
  width: var(--dile-input-width, 100%);
@@ -42,9 +36,10 @@ export class DileSelect extends DileEmmitChange(LitElement) {
42
36
  ::slotted(select.dark) {
43
37
  background-image: url("data:image/svg+xml;charset=US-ASCII,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%20256%20448%22%20enable-background%3D%22new%200%200%20256%20448%22%3E%3Cstyle%20type%3D%22text%2Fcss%22%3E.arrow%7Bfill%3A%23ffffff%3B%7D%3C%2Fstyle%3E%3Cpath%20class%3D%22arrow%22%20d%3D%22M255.9%20168c0-4.2-1.6-7.9-4.8-11.2-3.2-3.2-6.9-4.8-11.2-4.8H16c-4.2%200-7.9%201.6-11.2%204.8S0%20163.8%200%20168c0%204.4%201.6%208.2%204.8%2011.4l112%20112c3.1%203.1%206.8%204.6%2011.2%204.6%204.4%200%208.2-1.5%2011.4-4.6l112-112c3-3.2%204.5-7%204.5-11.4z%22%2F%3E%3C%2Fsvg%3E%0A");
44
38
  }
45
- ::slotted(select):focus {
46
- outline: none;
47
- border-color: var(--dile-input-focus-border-color, #6af);
39
+ :host(:focus-within) ::slotted(select) {
40
+ outline: var(--dile-input-focus-ring-width, 3px) solid var(--dile-input-focus-ring-color, rgba(102, 170, 255, 0.5));
41
+ outline-offset: calc(-1 * var(--dile-input-focus-ring-width, 3px));
42
+ border-color: var(--dile-input-focus-border-color, var(--dile-link-color, #6af));
48
43
  }
49
44
  ::slotted(select.errored) {
50
45
  border-color: var(--dile-input-error-border-color, #c00);
@@ -3,6 +3,7 @@ import '../../input/input-search.js';
3
3
  import '../../spinner/spinner-horizontal.js';
4
4
  import { DileEmmitChange } from '../../../mixins/form/index.js';
5
5
  import '../select.js';
6
+ import { labelStyles } from '../../input/src/label-styles.js';
6
7
  import { messageStyles } from '../../input/src/message-styles.js';
7
8
 
8
9
 
@@ -10,6 +11,7 @@ export class DileSelectAjax extends DileEmmitChange(LitElement) {
10
11
 
11
12
  static get styles() {
12
13
  return [
14
+ labelStyles,
13
15
  messageStyles,
14
16
  css`
15
17
  * {
@@ -23,13 +25,6 @@ export class DileSelectAjax extends DileEmmitChange(LitElement) {
23
25
  #result {
24
26
  margin-bottom: 0.5rem;
25
27
  }
26
- label {
27
- display: block;
28
- margin-bottom: var(--dile-input-label-margin-bottom, 4px);
29
- font-size: var(--dile-input-label-font-size, 1em);
30
- color: var(--dile-input-label-color, #59e);
31
- font-weight: var(--dile-input-label-font-weight, normal);
32
- }
33
28
  .anchor {
34
29
  margin-top: 0.5rem;
35
30
  position: relative;
@@ -1,23 +1,18 @@
1
1
  import { LitElement, html, css } from 'lit';
2
- import { messageStyles } from '../../input/index.js';
2
+ import { labelStyles, messageStyles } from '../../input/index.js';
3
3
  import { DileEmmitChange } from '../../../mixins/form/index.js';
4
4
 
5
5
  export class DileTextarea extends DileEmmitChange(LitElement) {
6
6
 
7
7
  static get styles() {
8
8
  return [
9
- messageStyles,
9
+ labelStyles,
10
+ messageStyles,
10
11
  css`
11
12
  :host {
12
13
  display: block;
13
14
  margin-bottom: 10px;
14
15
  }
15
- label {
16
- margin-bottom: var(--dile-textarea-label-margin-bottom, 4px);
17
- color: var(--dile-textarea-label-color, #59e);
18
- font-size: var(--dile-textarea-label-font-size, 1em);
19
- font-weight: var(--dile-textarea-label-font-weight, normal);
20
- }
21
16
 
22
17
  textarea {
23
18
  border: var(--dile-textarea-border, 1px solid #888);
@@ -32,6 +27,12 @@ export class DileTextarea extends DileEmmitChange(LitElement) {
32
27
  line-height: 1.5rem;
33
28
  }
34
29
 
30
+ textarea:focus {
31
+ outline: var(--dile-input-focus-ring-width, 3px) solid var(--dile-input-focus-ring-color, rgba(102, 170, 255, 0.5));
32
+ outline-offset: calc(-1 * var(--dile-input-focus-ring-width, 3px));
33
+ border-color: var(--dile-input-focus-border-color, var(--dile-link-color, #6af));
34
+ }
35
+
35
36
  textarea::placeholder {
36
37
  color: var(--dile-textarea-placeholder-color, #999);
37
38
  }
@@ -1,23 +1,18 @@
1
1
  import { LitElement, html, css } from 'lit';
2
2
  import { DileEmmitChange } from '../../../mixins/form/index.js';
3
3
  import '../../number-picker/number-picker-element.js';
4
+ import { labelStyles } from '../../input/src/label-styles.js';
4
5
  import { messageStyles } from '../../input/src/message-styles.js';
5
6
 
6
7
  export class DileTimePicker extends DileEmmitChange(LitElement) {
7
8
  static styles = [
9
+ labelStyles,
8
10
  messageStyles,
9
11
  css`
10
12
  :host {
11
13
  display: block;
12
14
  margin-bottom: 10px;
13
15
  }
14
- label {
15
- display: block;
16
- margin-bottom: var(--dile-input-label-margin-bottom, 4px);
17
- font-size: var(--dile-input-label-font-size, 1em);
18
- color: var(--dile-input-label-color, #59e);
19
- font-weight: var(--dile-input-label-font-weight, normal);
20
- }
21
16
  section {
22
17
  display: flex;
23
18
  gap: 0.5rem;
@@ -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": "3.0.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": "8dd04c6978c60eb0a90891ba7a128f4153348c61"
31
31
  }