@dile/ui 2.27.3 → 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.
- package/components/app-drawer/app-drawer.spec.js +262 -0
- package/components/app-drawer/src/DileAppDrawer.js +98 -4
- package/components/color-picker/color-picker.js +3 -0
- package/components/color-picker/color-picker.spec.js +117 -0
- package/components/color-picker/index.js +1 -0
- package/components/color-picker/src/DileColorPicker.js +502 -0
- package/components/hamburger/src/DileHamburger.js +13 -0
- package/components/menu-hamburger/src/DileMenuHamburger.js +9 -1
- package/lib/color/colorConversion.js +87 -0
- package/lib/color/colorConversion.test.js +94 -0
- package/package.json +2 -2
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
import { describe, it, expect, afterEach } from 'vitest';
|
|
2
|
+
import './app-drawer.js';
|
|
3
|
+
import { DILE_APP_DRAWER_EVENT_CLOSED, DILE_APP_DRAWER_EVENT_CLOSE_OUTSIDE } from './index.js';
|
|
4
|
+
|
|
5
|
+
describe('dile-app-drawer', () => {
|
|
6
|
+
afterEach(() => {
|
|
7
|
+
document.body.innerHTML = '';
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
function wait(ms) {
|
|
11
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
async function renderDrawer(html) {
|
|
15
|
+
document.body.innerHTML = html;
|
|
16
|
+
const el = document.body.querySelector('dile-app-drawer');
|
|
17
|
+
await el.updateComplete;
|
|
18
|
+
return el;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
describe('Default state', () => {
|
|
22
|
+
it('defaults to direction "top" and closed', async () => {
|
|
23
|
+
const el = await renderDrawer('<dile-app-drawer></dile-app-drawer>');
|
|
24
|
+
|
|
25
|
+
expect(el.direction).toBe('top');
|
|
26
|
+
expect(el.opened).toBe(false);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('reflects the opened property to the opened attribute', async () => {
|
|
30
|
+
const el = await renderDrawer('<dile-app-drawer></dile-app-drawer>');
|
|
31
|
+
|
|
32
|
+
el.open();
|
|
33
|
+
await el.updateComplete;
|
|
34
|
+
|
|
35
|
+
expect(el.hasAttribute('opened')).toBe(true);
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
describe('Public API', () => {
|
|
40
|
+
it('open() sets opened to true', async () => {
|
|
41
|
+
const el = await renderDrawer('<dile-app-drawer></dile-app-drawer>');
|
|
42
|
+
|
|
43
|
+
el.open();
|
|
44
|
+
await el.updateComplete;
|
|
45
|
+
|
|
46
|
+
expect(el.opened).toBe(true);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('close() sets opened to false and dispatches dile-app-drawer-closed', async () => {
|
|
50
|
+
const el = await renderDrawer('<dile-app-drawer opened></dile-app-drawer>');
|
|
51
|
+
let fired = false;
|
|
52
|
+
el.addEventListener(DILE_APP_DRAWER_EVENT_CLOSED, () => { fired = true; });
|
|
53
|
+
|
|
54
|
+
el.close();
|
|
55
|
+
await el.updateComplete;
|
|
56
|
+
|
|
57
|
+
expect(el.opened).toBe(false);
|
|
58
|
+
expect(fired).toBe(true);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('toggle() flips the opened state', async () => {
|
|
62
|
+
const el = await renderDrawer('<dile-app-drawer></dile-app-drawer>');
|
|
63
|
+
|
|
64
|
+
el.toggle();
|
|
65
|
+
await el.updateComplete;
|
|
66
|
+
expect(el.opened).toBe(true);
|
|
67
|
+
|
|
68
|
+
el.toggle();
|
|
69
|
+
await el.updateComplete;
|
|
70
|
+
expect(el.opened).toBe(false);
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
describe('Modal overlay', () => {
|
|
75
|
+
it('renders a modal overlay by default', async () => {
|
|
76
|
+
const el = await renderDrawer('<dile-app-drawer></dile-app-drawer>');
|
|
77
|
+
|
|
78
|
+
expect(el.shadowRoot.querySelector('.modal')).toBeTruthy();
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('does not render a modal overlay when no-modal is set', async () => {
|
|
82
|
+
const el = await renderDrawer('<dile-app-drawer no-modal></dile-app-drawer>');
|
|
83
|
+
|
|
84
|
+
expect(el.shadowRoot.querySelector('.modal')).toBeNull();
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('clicking the overlay closes the drawer and dispatches closed + click-outside events', async () => {
|
|
88
|
+
const el = await renderDrawer('<dile-app-drawer opened></dile-app-drawer>');
|
|
89
|
+
let closedFired = false;
|
|
90
|
+
let outsideFired = false;
|
|
91
|
+
el.addEventListener(DILE_APP_DRAWER_EVENT_CLOSED, () => { closedFired = true; });
|
|
92
|
+
el.addEventListener(DILE_APP_DRAWER_EVENT_CLOSE_OUTSIDE, () => { outsideFired = true; });
|
|
93
|
+
|
|
94
|
+
el.shadowRoot.querySelector('.modal').click();
|
|
95
|
+
await el.updateComplete;
|
|
96
|
+
|
|
97
|
+
expect(el.opened).toBe(false);
|
|
98
|
+
expect(closedFired).toBe(true);
|
|
99
|
+
expect(outsideFired).toBe(true);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('clicking the overlay while already closed does not dispatch events', async () => {
|
|
103
|
+
const el = await renderDrawer('<dile-app-drawer></dile-app-drawer>');
|
|
104
|
+
let outsideFired = false;
|
|
105
|
+
el.addEventListener(DILE_APP_DRAWER_EVENT_CLOSE_OUTSIDE, () => { outsideFired = true; });
|
|
106
|
+
|
|
107
|
+
el.shadowRoot.querySelector('.modal').click();
|
|
108
|
+
await el.updateComplete;
|
|
109
|
+
|
|
110
|
+
expect(outsideFired).toBe(false);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('stops click events on the menu content from bubbling past the drawer', async () => {
|
|
114
|
+
const el = await renderDrawer('<div id="wrapper"><dile-app-drawer opened></dile-app-drawer></div>');
|
|
115
|
+
const wrapper = document.querySelector('#wrapper');
|
|
116
|
+
let wrapperClicked = false;
|
|
117
|
+
wrapper.addEventListener('click', () => { wrapperClicked = true; });
|
|
118
|
+
|
|
119
|
+
el.shadowRoot.querySelector('.menu').click();
|
|
120
|
+
|
|
121
|
+
expect(wrapperClicked).toBe(false);
|
|
122
|
+
expect(el.opened).toBe(true);
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
describe('Dialog accessibility', () => {
|
|
127
|
+
it('renders the menu as a labeled, described dialog', async () => {
|
|
128
|
+
const el = await renderDrawer('<dile-app-drawer></dile-app-drawer>');
|
|
129
|
+
const menu = el.shadowRoot.querySelector('.menu');
|
|
130
|
+
|
|
131
|
+
expect(menu.getAttribute('role')).toBe('dialog');
|
|
132
|
+
expect(menu.getAttribute('aria-label')).toBe('Application menu');
|
|
133
|
+
expect(menu.getAttribute('aria-description')).toContain('Escape');
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('sets aria-modal to reflect the opened state', async () => {
|
|
137
|
+
const el = await renderDrawer('<dile-app-drawer></dile-app-drawer>');
|
|
138
|
+
const menu = el.shadowRoot.querySelector('.menu');
|
|
139
|
+
|
|
140
|
+
expect(menu.getAttribute('aria-modal')).toBe('false');
|
|
141
|
+
|
|
142
|
+
el.open();
|
|
143
|
+
await el.updateComplete;
|
|
144
|
+
|
|
145
|
+
expect(menu.getAttribute('aria-modal')).toBe('true');
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it('marks the menu (and overlay) inert while closed, interactive while opened', async () => {
|
|
149
|
+
const el = await renderDrawer('<dile-app-drawer></dile-app-drawer>');
|
|
150
|
+
const menu = el.shadowRoot.querySelector('.menu');
|
|
151
|
+
const modal = el.shadowRoot.querySelector('.modal');
|
|
152
|
+
|
|
153
|
+
expect(menu.hasAttribute('inert')).toBe(true);
|
|
154
|
+
expect(modal.hasAttribute('inert')).toBe(true);
|
|
155
|
+
|
|
156
|
+
el.open();
|
|
157
|
+
await el.updateComplete;
|
|
158
|
+
|
|
159
|
+
expect(menu.hasAttribute('inert')).toBe(false);
|
|
160
|
+
expect(modal.hasAttribute('inert')).toBe(false);
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
describe('Keyboard - Escape', () => {
|
|
165
|
+
it('closes the drawer when Escape is pressed while opened', async () => {
|
|
166
|
+
const el = await renderDrawer('<dile-app-drawer opened></dile-app-drawer>');
|
|
167
|
+
|
|
168
|
+
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
|
|
169
|
+
await el.updateComplete;
|
|
170
|
+
|
|
171
|
+
expect(el.opened).toBe(false);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it('does nothing when Escape is pressed while already closed', async () => {
|
|
175
|
+
const el = await renderDrawer('<dile-app-drawer></dile-app-drawer>');
|
|
176
|
+
|
|
177
|
+
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
|
|
178
|
+
await el.updateComplete;
|
|
179
|
+
|
|
180
|
+
expect(el.opened).toBe(false);
|
|
181
|
+
});
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
describe('Focus management', () => {
|
|
185
|
+
it('moves focus into the menu when opened', async () => {
|
|
186
|
+
const el = await renderDrawer('<dile-app-drawer><button>Item</button></dile-app-drawer>');
|
|
187
|
+
|
|
188
|
+
el.open();
|
|
189
|
+
await el.updateComplete;
|
|
190
|
+
|
|
191
|
+
expect(el.shadowRoot.activeElement).toBe(el.shadowRoot.querySelector('.menu'));
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it('restores focus to the trigger element after closing', async () => {
|
|
195
|
+
document.body.innerHTML = '<button id="trigger">Open</button><dile-app-drawer></dile-app-drawer>';
|
|
196
|
+
const trigger = document.querySelector('#trigger');
|
|
197
|
+
const el = document.querySelector('dile-app-drawer');
|
|
198
|
+
await el.updateComplete;
|
|
199
|
+
|
|
200
|
+
trigger.focus();
|
|
201
|
+
el.open();
|
|
202
|
+
await el.updateComplete;
|
|
203
|
+
el.close();
|
|
204
|
+
await el.updateComplete;
|
|
205
|
+
await wait(10);
|
|
206
|
+
|
|
207
|
+
expect(document.activeElement).toBe(trigger);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it('traps Tab focus: wraps from the last focusable element to the first', async () => {
|
|
211
|
+
const el = await renderDrawer(`
|
|
212
|
+
<dile-app-drawer opened>
|
|
213
|
+
<button id="first">First</button>
|
|
214
|
+
<button id="last">Last</button>
|
|
215
|
+
</dile-app-drawer>
|
|
216
|
+
`);
|
|
217
|
+
await el.updateComplete;
|
|
218
|
+
const first = el.querySelector('#first');
|
|
219
|
+
const last = el.querySelector('#last');
|
|
220
|
+
|
|
221
|
+
last.focus();
|
|
222
|
+
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab', bubbles: true, cancelable: true }));
|
|
223
|
+
|
|
224
|
+
expect(document.activeElement).toBe(first);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it('traps Shift+Tab focus: wraps from the first focusable element to the last', async () => {
|
|
228
|
+
const el = await renderDrawer(`
|
|
229
|
+
<dile-app-drawer opened>
|
|
230
|
+
<button id="first">First</button>
|
|
231
|
+
<button id="last">Last</button>
|
|
232
|
+
</dile-app-drawer>
|
|
233
|
+
`);
|
|
234
|
+
await el.updateComplete;
|
|
235
|
+
const first = el.querySelector('#first');
|
|
236
|
+
const last = el.querySelector('#last');
|
|
237
|
+
|
|
238
|
+
first.focus();
|
|
239
|
+
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab', shiftKey: true, bubbles: true, cancelable: true }));
|
|
240
|
+
|
|
241
|
+
expect(document.activeElement).toBe(last);
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it('does not move focus when Tab is pressed with focus outside the drawer', async () => {
|
|
245
|
+
document.body.innerHTML = `
|
|
246
|
+
<input id="outside" />
|
|
247
|
+
<dile-app-drawer opened>
|
|
248
|
+
<button id="first">First</button>
|
|
249
|
+
<button id="last">Last</button>
|
|
250
|
+
</dile-app-drawer>
|
|
251
|
+
`;
|
|
252
|
+
const el = document.querySelector('dile-app-drawer');
|
|
253
|
+
await el.updateComplete;
|
|
254
|
+
const outside = document.querySelector('#outside');
|
|
255
|
+
|
|
256
|
+
outside.focus();
|
|
257
|
+
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab', bubbles: true, cancelable: true }));
|
|
258
|
+
|
|
259
|
+
expect(document.activeElement).toBe(outside);
|
|
260
|
+
});
|
|
261
|
+
});
|
|
262
|
+
});
|
|
@@ -27,6 +27,9 @@ export class DileAppDrawer extends DileCloseOnEscPressed(LitElement) {
|
|
|
27
27
|
this.direction = 'top';
|
|
28
28
|
this.opened = false;
|
|
29
29
|
this._documentClose = this._documentClose.bind(this);
|
|
30
|
+
this._triggerElement = null;
|
|
31
|
+
this._handleKeyDown = this._handleKeyDown.bind(this);
|
|
32
|
+
this._focusTrapActive = false;
|
|
30
33
|
}
|
|
31
34
|
|
|
32
35
|
static get styles() {
|
|
@@ -106,22 +109,113 @@ export class DileAppDrawer extends DileCloseOnEscPressed(LitElement) {
|
|
|
106
109
|
transform: translateX(0);
|
|
107
110
|
box-shadow: var(--dile-app-drawer-box-shadow, 1px 0 8px #000);
|
|
108
111
|
}
|
|
112
|
+
|
|
113
|
+
/* Respect prefers-reduced-motion */
|
|
114
|
+
@media (prefers-reduced-motion: reduce) {
|
|
115
|
+
.modal {
|
|
116
|
+
transition: none;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
.menu {
|
|
120
|
+
transition: none;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
109
123
|
`;
|
|
110
124
|
}
|
|
111
125
|
|
|
112
126
|
render() {
|
|
113
127
|
return html`
|
|
114
128
|
${this.modalTemplate}
|
|
115
|
-
<div
|
|
116
|
-
|
|
129
|
+
<div
|
|
130
|
+
class="menu"
|
|
131
|
+
role="dialog"
|
|
132
|
+
aria-modal="${this.opened ? 'true' : 'false'}"
|
|
133
|
+
aria-label="Application menu"
|
|
134
|
+
aria-description="Press Escape to close. You can also click outside the menu."
|
|
135
|
+
?inert="${!this.opened}"
|
|
136
|
+
tabindex="-1"
|
|
137
|
+
@click="${this._contentClick}"
|
|
138
|
+
@keydown="${this._handleKeyDown}">
|
|
139
|
+
<nav aria-label="Main navigation">
|
|
117
140
|
<slot></slot>
|
|
118
|
-
</
|
|
141
|
+
</nav>
|
|
119
142
|
</div>
|
|
120
143
|
`;
|
|
121
144
|
}
|
|
122
145
|
|
|
123
146
|
get modalTemplate() {
|
|
124
|
-
return this.noModal ? '' : html`<div
|
|
147
|
+
return this.noModal ? '' : html`<div
|
|
148
|
+
class="modal"
|
|
149
|
+
aria-hidden="true"
|
|
150
|
+
?inert="${!this.opened}"
|
|
151
|
+
@click=${this._documentClose}>
|
|
152
|
+
</div>`;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
updated(changedProperties) {
|
|
156
|
+
if (changedProperties.has('opened')) {
|
|
157
|
+
if (this.opened) {
|
|
158
|
+
this._openDrawer();
|
|
159
|
+
} else {
|
|
160
|
+
this._closeDrawer();
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
_openDrawer() {
|
|
166
|
+
this._triggerElement = document.activeElement;
|
|
167
|
+
this.shadowRoot.querySelector('.menu').focus();
|
|
168
|
+
this._focusTrapActive = true;
|
|
169
|
+
document.addEventListener('keydown', this._handleKeyDown);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
_closeDrawer() {
|
|
173
|
+
this._focusTrapActive = false;
|
|
174
|
+
document.removeEventListener('keydown', this._handleKeyDown);
|
|
175
|
+
|
|
176
|
+
if (this._triggerElement && typeof this._triggerElement.focus === 'function') {
|
|
177
|
+
setTimeout(() => {
|
|
178
|
+
this._triggerElement.focus();
|
|
179
|
+
}, 0);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
_getFocusableElements() {
|
|
184
|
+
const focusableSelector = `
|
|
185
|
+
button:not([disabled]),
|
|
186
|
+
[href],
|
|
187
|
+
input:not([disabled]),
|
|
188
|
+
select:not([disabled]),
|
|
189
|
+
textarea:not([disabled]),
|
|
190
|
+
[tabindex]:not([tabindex="-1"]):not([disabled]),
|
|
191
|
+
[role="button"]:not([aria-disabled="true"])
|
|
192
|
+
`;
|
|
193
|
+
return Array.from(this.querySelectorAll(focusableSelector)).filter(el => {
|
|
194
|
+
return el.offsetParent !== null;
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
_handleKeyDown(e) {
|
|
199
|
+
if (!this._focusTrapActive || e.key !== 'Tab') return;
|
|
200
|
+
|
|
201
|
+
const focusableElements = this._getFocusableElements();
|
|
202
|
+
if (focusableElements.length === 0) return;
|
|
203
|
+
|
|
204
|
+
const firstElement = focusableElements[0];
|
|
205
|
+
const lastElement = focusableElements[focusableElements.length - 1];
|
|
206
|
+
const activeElement = document.activeElement;
|
|
207
|
+
|
|
208
|
+
const focusIsInsideDrawer = focusableElements.includes(activeElement);
|
|
209
|
+
if (!focusIsInsideDrawer) return;
|
|
210
|
+
|
|
211
|
+
if (e.shiftKey && activeElement === firstElement) {
|
|
212
|
+
e.preventDefault();
|
|
213
|
+
lastElement.focus();
|
|
214
|
+
}
|
|
215
|
+
else if (!e.shiftKey && activeElement === lastElement) {
|
|
216
|
+
e.preventDefault();
|
|
217
|
+
firstElement.focus();
|
|
218
|
+
}
|
|
125
219
|
}
|
|
126
220
|
|
|
127
221
|
_documentClose() {
|
|
@@ -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
|
+
}
|
|
@@ -125,6 +125,19 @@ export class DileHamburger extends LitElement {
|
|
|
125
125
|
.hamburger:focus-visible .hamburger-inner::after {
|
|
126
126
|
background-color: var(--dile-hamburger-focus-color, var(--dile-primary-dark-color, #0a66ff));
|
|
127
127
|
}
|
|
128
|
+
|
|
129
|
+
/* Respect prefers-reduced-motion */
|
|
130
|
+
@media (prefers-reduced-motion: reduce) {
|
|
131
|
+
.hamburger {
|
|
132
|
+
transition: none;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
.hamburger-inner,
|
|
136
|
+
.hamburger-inner::before,
|
|
137
|
+
.hamburger-inner::after {
|
|
138
|
+
transition: none;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
128
141
|
`;
|
|
129
142
|
}
|
|
130
143
|
|
|
@@ -63,7 +63,15 @@ export class DileMenuHamburger extends LitElement {
|
|
|
63
63
|
|
|
64
64
|
render() {
|
|
65
65
|
return html`
|
|
66
|
-
<dile-hamburger
|
|
66
|
+
<dile-hamburger
|
|
67
|
+
@click="${this.toggle}"
|
|
68
|
+
?active="${this.opened}"
|
|
69
|
+
aria-label="Open main menu"
|
|
70
|
+
aria-expanded="${this.opened}"
|
|
71
|
+
aria-controls="drawer"
|
|
72
|
+
aria-haspopup="dialog"
|
|
73
|
+
role="button">
|
|
74
|
+
</dile-hamburger>
|
|
67
75
|
<dile-app-drawer
|
|
68
76
|
id="drawer"
|
|
69
77
|
?opened="${this.opened}"
|
|
@@ -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.
|
|
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": "
|
|
30
|
+
"gitHead": "d90ac8ac1b524d3591fc65ac57a6156131a940bb"
|
|
31
31
|
}
|