@kubex/zinc 1.1.97 → 1.1.99
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/AGENTS.md +5 -0
- package/CLAUDE.md +5 -0
- package/dist/chunks/zn.R2EAU6OS.js +1 -0
- package/dist/custom-elements.json +14747 -12528
- package/dist/vscode.html-custom-data.json +386 -61
- package/dist/web-types.json +1232 -481
- package/dist/zn.d.ts +529 -11
- package/dist/zn.min.js +649 -478
- package/docs/pages/components/background.md +143 -0
- package/docs/pages/components/button.md +11 -1
- package/docs/pages/components/chart.md +165 -3
- package/docs/pages/components/checkbox-group.md +13 -1
- package/docs/pages/components/checkbox.md +59 -1
- package/docs/pages/components/icon-picker.md +8 -0
- package/docs/pages/components/radio-group.md +12 -0
- package/docs/pages/components/radio.md +188 -0
- package/docs/pages/components/remarkd-editor.md +22 -0
- package/docs/pages/components/scroll-container.md +14 -15
- package/docs/pages/components/thumbnail-group.md +216 -0
- package/docs/pages/components/thumbnail.md +349 -0
- package/docs/pages/components/well.md +34 -0
- package/package.json +1 -1
- package/src/components/background/background.component.ts +111 -0
- package/src/components/background/background.scss +236 -0
- package/src/components/background/background.test.ts +120 -0
- package/src/components/background/index.ts +12 -0
- package/src/components/button/button.component.ts +8 -0
- package/src/components/chart/builders.test.ts +190 -1
- package/src/components/chart/builders.ts +246 -4
- package/src/components/chart/chart.component.ts +33 -1
- package/src/components/chart/chart.test.ts +66 -1
- package/src/components/chart/echarts-loader.ts +10 -0
- package/src/components/checkbox/checkbox.component.ts +62 -6
- package/src/components/checkbox/checkbox.scss +1 -0
- package/src/components/checkbox/checkbox.test.ts +56 -0
- package/src/components/checkbox-group/checkbox-group.component.ts +15 -0
- package/src/components/checkbox-group/checkbox-group.scss +23 -5
- package/src/components/checkbox-group/checkbox-group.test.ts +15 -0
- package/src/components/collapsible/collapsible.component.ts +49 -4
- package/src/components/collapsible/collapsible.scss +33 -14
- package/src/components/confirm/confirm.component.ts +3 -3
- package/src/components/confirm/confirm.test.ts +15 -0
- package/src/components/dialog/dialog.component.ts +3 -2
- package/src/components/editor/editor.component.ts +7 -6
- package/src/components/header/header.scss +2 -2
- package/src/components/icon-picker/icon-picker.component.ts +3 -0
- package/src/components/icon-picker/lucide-icons.ts +1756 -0
- package/src/components/navbar/navbar.scss +11 -0
- package/src/components/page/page.scss +1 -1
- package/src/components/radio/radio.component.ts +63 -7
- package/src/components/radio/radio.scss +1 -0
- package/src/components/radio/radio.test.ts +56 -0
- package/src/components/radio-group/radio-group.component.ts +16 -3
- package/src/components/radio-group/radio-group.scss +23 -4
- package/src/components/radio-group/radio-group.test.ts +15 -0
- package/src/components/remarkd-editor/remarkd-editor.component.ts +118 -5
- package/src/components/remarkd-editor/remarkd-editor.scss +8 -1
- package/src/components/remarkd-editor/remarkd-editor.test.ts +135 -0
- package/src/components/scroll-container/scroll-container.component.ts +15 -1
- package/src/components/scroll-container/scroll-container.scss +4 -2
- package/src/components/scroll-container/scroll-container.test.ts +13 -0
- package/src/components/slideout/slideout.component.ts +3 -2
- package/src/components/split-pane/split-pane.scss +21 -15
- package/src/components/thumbnail/index.ts +12 -0
- package/src/components/thumbnail/thumbnail.component.ts +460 -0
- package/src/components/thumbnail/thumbnail.scss +360 -0
- package/src/components/thumbnail/thumbnail.test.ts +379 -0
- package/src/components/thumbnail-group/index.ts +12 -0
- package/src/components/thumbnail-group/thumbnail-group.component.ts +268 -0
- package/src/components/thumbnail-group/thumbnail-group.scss +86 -0
- package/src/components/thumbnail-group/thumbnail-group.test.ts +151 -0
- package/src/components/tile/tile.scss +4 -2
- package/src/components/translation-group/translation-group.test.ts +22 -0
- package/src/components/translations/translations.component.ts +18 -3
- package/src/components/well/well.component.ts +22 -4
- package/src/components/well/well.scss +24 -0
- package/src/components/well/well.test.ts +43 -0
- package/src/form-control.scss +3 -1
- package/src/internal/conditional.ts +2 -2
- package/src/internal/form.ts +2 -1
- package/src/internal/selection-card.ts +19 -0
- package/src/selection-card.scss +187 -0
- package/src/utilities/query.test.ts +18 -1
- package/src/utilities/query.ts +8 -0
- package/src/zinc.ts +3 -0
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
import '../../../dist/zn.min.js';
|
|
2
|
+
import {expect, fixture, html, waitUntil} from '@open-wc/testing';
|
|
3
|
+
import type ZnThumbnail from './thumbnail.component';
|
|
4
|
+
|
|
5
|
+
const link = (el: ZnThumbnail) => el.shadowRoot!.querySelector<HTMLElement>('.thumbnail__link')!;
|
|
6
|
+
|
|
7
|
+
// The test harness loads no theme stylesheet, so tests that assert position or shape stamp on the
|
|
8
|
+
// handful of design tokens a themed page would otherwise provide.
|
|
9
|
+
const tokens = '--zn-spacing-2x-small: 4px; --zn-spacing-x-small: 8px;'
|
|
10
|
+
+ '--zn-border-radius: 6px; --zn-border-radius-small: 2px; --zn-border-radius-pill: 9999px;';
|
|
11
|
+
|
|
12
|
+
describe('<zn-thumbnail>', () => {
|
|
13
|
+
it('should render a component', async () => {
|
|
14
|
+
const el = await fixture(html`
|
|
15
|
+
<zn-thumbnail></zn-thumbnail>`);
|
|
16
|
+
|
|
17
|
+
expect(el).to.exist;
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('should render the caption and the image', async () => {
|
|
21
|
+
const el = await fixture<ZnThumbnail>(html`
|
|
22
|
+
<zn-thumbnail src="/thumb.jpg" caption="Tahoe Day"></zn-thumbnail>`);
|
|
23
|
+
|
|
24
|
+
const img = el.shadowRoot!.querySelector<HTMLImageElement>('.thumbnail__image')!;
|
|
25
|
+
expect(img.getAttribute('src')).to.equal('/thumb.jpg');
|
|
26
|
+
expect(img.getAttribute('alt')).to.equal('Tahoe Day');
|
|
27
|
+
expect(el.shadowRoot!.querySelector('.thumbnail__caption')!.textContent).to.contain('Tahoe Day');
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('should render an anchor when href is set', async () => {
|
|
31
|
+
const el = await fixture<ZnThumbnail>(html`
|
|
32
|
+
<zn-thumbnail href="/media/1" caption="Linked"></zn-thumbnail>`);
|
|
33
|
+
|
|
34
|
+
expect(el.shadowRoot!.querySelector('a.thumbnail__link')).to.exist;
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('should not render an anchor when disabled', async () => {
|
|
38
|
+
const el = await fixture<ZnThumbnail>(html`
|
|
39
|
+
<zn-thumbnail href="/media/1" caption="Linked" disabled></zn-thumbnail>`);
|
|
40
|
+
|
|
41
|
+
expect(el.shadowRoot!.querySelector('a.thumbnail__link')).to.not.exist;
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('should emit zn-select when clicked', async () => {
|
|
45
|
+
const el = await fixture<ZnThumbnail>(html`
|
|
46
|
+
<zn-thumbnail caption="Tahoe Day" value="tahoe"></zn-thumbnail>`);
|
|
47
|
+
|
|
48
|
+
let detail: unknown = null;
|
|
49
|
+
el.addEventListener('zn-select', (e: Event) => {
|
|
50
|
+
detail = (e as CustomEvent).detail;
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
link(el).click();
|
|
54
|
+
await el.updateComplete;
|
|
55
|
+
|
|
56
|
+
expect(detail).to.deep.equal({item: el});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('should not emit zn-select when disabled', async () => {
|
|
60
|
+
const el = await fixture<ZnThumbnail>(html`
|
|
61
|
+
<zn-thumbnail caption="Tahoe Day" disabled></zn-thumbnail>`);
|
|
62
|
+
|
|
63
|
+
let fired = false;
|
|
64
|
+
el.addEventListener('zn-select', () => (fired = true));
|
|
65
|
+
|
|
66
|
+
link(el).click();
|
|
67
|
+
await el.updateComplete;
|
|
68
|
+
|
|
69
|
+
expect(fired).to.be.false;
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
describe('actions', () => {
|
|
73
|
+
it('should render actions outside the link so their own clicks still work', async () => {
|
|
74
|
+
const el = await fixture<ZnThumbnail>(html`
|
|
75
|
+
<zn-thumbnail caption="Tahoe Day">
|
|
76
|
+
<button slot="actions">Download</button>
|
|
77
|
+
</zn-thumbnail>`);
|
|
78
|
+
|
|
79
|
+
// The actions overlay must not be a descendant of the link, or the link's own click
|
|
80
|
+
// handling would have to cancel the action's default behaviour to stay put.
|
|
81
|
+
const overlay = el.shadowRoot!.querySelector('.thumbnail__overlay')!;
|
|
82
|
+
expect(overlay).to.exist;
|
|
83
|
+
expect(link(el).contains(overlay)).to.be.false;
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('should let a slotted action handle its own click', async () => {
|
|
87
|
+
const el = await fixture<ZnThumbnail>(html`
|
|
88
|
+
<zn-thumbnail caption="Tahoe Day">
|
|
89
|
+
<button slot="actions">Download</button>
|
|
90
|
+
</zn-thumbnail>`);
|
|
91
|
+
|
|
92
|
+
let clicked = false;
|
|
93
|
+
let defaultPrevented = false;
|
|
94
|
+
const action = el.querySelector<HTMLButtonElement>('[slot="actions"]')!;
|
|
95
|
+
action.addEventListener('click', (event: MouseEvent) => {
|
|
96
|
+
clicked = true;
|
|
97
|
+
// The thumbnail used to cancel this, which broke every interactive action.
|
|
98
|
+
setTimeout(() => (defaultPrevented = event.defaultPrevented));
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
action.click();
|
|
102
|
+
await waitUntil(() => clicked);
|
|
103
|
+
|
|
104
|
+
expect(clicked).to.be.true;
|
|
105
|
+
expect(defaultPrevented).to.be.false;
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('should not emit zn-select when an action is clicked', async () => {
|
|
109
|
+
const el = await fixture<ZnThumbnail>(html`
|
|
110
|
+
<zn-thumbnail caption="Tahoe Day">
|
|
111
|
+
<button slot="actions">Download</button>
|
|
112
|
+
</zn-thumbnail>`);
|
|
113
|
+
|
|
114
|
+
let fired = false;
|
|
115
|
+
el.addEventListener('zn-select', () => (fired = true));
|
|
116
|
+
|
|
117
|
+
el.querySelector<HTMLElement>('[slot="actions"]')!.click();
|
|
118
|
+
await el.updateComplete;
|
|
119
|
+
|
|
120
|
+
expect(fired).to.be.false;
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('should hold badges back from the action chips beside them', async () => {
|
|
124
|
+
const el = await fixture<ZnThumbnail>(html`
|
|
125
|
+
<zn-thumbnail caption="Tahoe Day" icon="play_arrow" style="width: 200px; ${tokens}">
|
|
126
|
+
<button slot="actions">Download</button>
|
|
127
|
+
</zn-thumbnail>`);
|
|
128
|
+
|
|
129
|
+
const badge = el.shadowRoot!.querySelector<HTMLElement>('.thumbnail__badge-icon')!;
|
|
130
|
+
const action = el.querySelector<HTMLElement>('[slot="actions"]')!;
|
|
131
|
+
|
|
132
|
+
// A badge describes the asset, so it must not behave like a control.
|
|
133
|
+
expect(getComputedStyle(badge).pointerEvents).to.equal('none');
|
|
134
|
+
expect(getComputedStyle(badge).cursor).to.not.equal('pointer');
|
|
135
|
+
expect(getComputedStyle(action).cursor).to.equal('pointer');
|
|
136
|
+
|
|
137
|
+
// It keeps the chip's shape, and is held back on opacity instead.
|
|
138
|
+
expect(getComputedStyle(badge).borderTopLeftRadius)
|
|
139
|
+
.to.equal(getComputedStyle(action).borderTopLeftRadius);
|
|
140
|
+
expect(parseFloat(getComputedStyle(badge).opacity))
|
|
141
|
+
.to.be.lessThan(parseFloat(getComputedStyle(action).opacity));
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it('should lay multiple actions out as separate chips', async () => {
|
|
145
|
+
// The gap is set here rather than left to --zn-spacing-2x-small, so the test measures
|
|
146
|
+
// the layout instead of whether the theme stylesheet is loaded.
|
|
147
|
+
const el = await fixture<ZnThumbnail>(html`
|
|
148
|
+
<zn-thumbnail caption="Tahoe Day" style="width: 200px; --zn-thumbnail-chip-gap: 6px;">
|
|
149
|
+
<button slot="actions" id="one">1</button>
|
|
150
|
+
<button slot="actions" id="two">2</button>
|
|
151
|
+
</zn-thumbnail>`);
|
|
152
|
+
|
|
153
|
+
const actions = el.shadowRoot!.querySelector<HTMLElement>('.thumbnail__actions')!;
|
|
154
|
+
const slot = actions.querySelector<HTMLSlotElement>('slot')!;
|
|
155
|
+
expect(slot.assignedElements()).to.have.lengthOf(2);
|
|
156
|
+
|
|
157
|
+
// The slot collapses so each action is its own gapped flex item of the positioner,
|
|
158
|
+
// rather than several icons sharing one container box.
|
|
159
|
+
expect(getComputedStyle(slot).display).to.equal('contents');
|
|
160
|
+
|
|
161
|
+
const [one, two] = el.querySelectorAll<HTMLElement>('[slot="actions"]');
|
|
162
|
+
const gap = two.getBoundingClientRect().left - one.getBoundingClientRect().right;
|
|
163
|
+
expect(gap).to.be.closeTo(6, 0.5);
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
describe('rings', () => {
|
|
168
|
+
it('should set the aspect ratio from the attribute', async () => {
|
|
169
|
+
const el = await fixture<ZnThumbnail>(html`
|
|
170
|
+
<zn-thumbnail caption="Square" aspect-ratio="1 / 1" style="width: 200px;"></zn-thumbnail>`);
|
|
171
|
+
|
|
172
|
+
const media = el.shadowRoot!.querySelector<HTMLElement>('.thumbnail__media')!;
|
|
173
|
+
const rect = media.getBoundingClientRect();
|
|
174
|
+
expect(rect.height).to.be.closeTo(rect.width, 1);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it('should inherit the aspect ratio from an ancestor custom property', async () => {
|
|
178
|
+
const el = await fixture<ZnThumbnail>(html`
|
|
179
|
+
<div style="width: 200px; --zn-thumbnail-aspect-ratio: 1 / 1;">
|
|
180
|
+
<zn-thumbnail caption="Square"></zn-thumbnail>
|
|
181
|
+
</div>`);
|
|
182
|
+
|
|
183
|
+
const thumbnail = el.querySelector<ZnThumbnail>('zn-thumbnail')!;
|
|
184
|
+
const rect = thumbnail.shadowRoot!.querySelector<HTMLElement>('.thumbnail__media')!.getBoundingClientRect();
|
|
185
|
+
expect(rect.height).to.be.closeTo(rect.width, 1);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it('should let the aspect-ratio attribute win over an inherited custom property', async () => {
|
|
189
|
+
const el = await fixture<ZnThumbnail>(html`
|
|
190
|
+
<div style="width: 200px; --zn-thumbnail-aspect-ratio: 1 / 1;">
|
|
191
|
+
<zn-thumbnail caption="Wide" aspect-ratio="2 / 1"></zn-thumbnail>
|
|
192
|
+
</div>`);
|
|
193
|
+
|
|
194
|
+
const thumbnail = el.querySelector<ZnThumbnail>('zn-thumbnail')!;
|
|
195
|
+
const rect = thumbnail.shadowRoot!.querySelector<HTMLElement>('.thumbnail__media')!.getBoundingClientRect();
|
|
196
|
+
expect(rect.width / rect.height).to.be.closeTo(2, 0.05);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it('should draw a wider ring when selected', async () => {
|
|
200
|
+
const el = await fixture<ZnThumbnail>(html`
|
|
201
|
+
<zn-thumbnail caption="Tahoe Day" style="width: 200px;"></zn-thumbnail>`);
|
|
202
|
+
const media = el.shadowRoot!.querySelector<HTMLElement>('.thumbnail__media')!;
|
|
203
|
+
const width = () => getComputedStyle(media).getPropertyValue('--thumbnail-ring-width').trim();
|
|
204
|
+
|
|
205
|
+
expect(width()).to.equal('1px');
|
|
206
|
+
|
|
207
|
+
el.selected = true;
|
|
208
|
+
await el.updateComplete;
|
|
209
|
+
|
|
210
|
+
expect(width()).to.equal('2px');
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it('should draw a distinct ring colour when active', async () => {
|
|
214
|
+
const el = await fixture<ZnThumbnail>(html`
|
|
215
|
+
<zn-thumbnail caption="Tahoe Day" style="width: 200px; --zn-thumbnail-active-color: rgb(0, 128, 0);"></zn-thumbnail>`);
|
|
216
|
+
const media = el.shadowRoot!.querySelector<HTMLElement>('.thumbnail__media')!;
|
|
217
|
+
const color = () => getComputedStyle(media).getPropertyValue('--thumbnail-ring-color').trim();
|
|
218
|
+
|
|
219
|
+
const idle = color();
|
|
220
|
+
|
|
221
|
+
el.active = true;
|
|
222
|
+
await el.updateComplete;
|
|
223
|
+
|
|
224
|
+
expect(el.hasAttribute('active')).to.be.true;
|
|
225
|
+
expect(color()).to.equal('rgb(0, 128, 0)');
|
|
226
|
+
expect(color()).to.not.equal(idle);
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it('should paint the ring above the media so an image cannot cover it', async () => {
|
|
230
|
+
const el = await fixture<ZnThumbnail>(html`
|
|
231
|
+
<zn-thumbnail
|
|
232
|
+
caption="Tahoe Day"
|
|
233
|
+
src="/thumb.jpg"
|
|
234
|
+
active
|
|
235
|
+
style="width: 200px; --zn-body: 255, 255, 255; --zn-thumbnail-active-color: rgb(0, 128, 0);">
|
|
236
|
+
</zn-thumbnail>`);
|
|
237
|
+
|
|
238
|
+
const media = el.shadowRoot!.querySelector<HTMLElement>('.thumbnail__media')!;
|
|
239
|
+
const ring = getComputedStyle(media, '::after');
|
|
240
|
+
|
|
241
|
+
// An inset shadow on the frame itself paints under the frame's content, so the image would
|
|
242
|
+
// hide it — the ring has to live on a positioned layer over the media.
|
|
243
|
+
expect(getComputedStyle(media).boxShadow).to.match(/none/);
|
|
244
|
+
expect(ring.boxShadow).to.contain('rgb(0, 128, 0)');
|
|
245
|
+
expect(ring.position).to.equal('absolute');
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it('should give the active ring colour precedence over the selected ring colour', async () => {
|
|
249
|
+
const el = await fixture<ZnThumbnail>(html`
|
|
250
|
+
<zn-thumbnail
|
|
251
|
+
caption="Tahoe Day"
|
|
252
|
+
selected
|
|
253
|
+
active
|
|
254
|
+
style="width: 200px; --zn-thumbnail-selected-color: rgb(1, 2, 3); --zn-thumbnail-active-color: rgb(4, 5, 6);">
|
|
255
|
+
</zn-thumbnail>`);
|
|
256
|
+
|
|
257
|
+
const media = el.shadowRoot!.querySelector<HTMLElement>('.thumbnail__media')!;
|
|
258
|
+
expect(getComputedStyle(media).getPropertyValue('--thumbnail-ring-color').trim()).to.equal('rgb(4, 5, 6)');
|
|
259
|
+
});
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
describe('preview', () => {
|
|
263
|
+
it('should not render a preview without full-uri', async () => {
|
|
264
|
+
const el = await fixture<ZnThumbnail>(html`
|
|
265
|
+
<zn-thumbnail caption="Tahoe Day" src="/thumb.jpg"></zn-thumbnail>`);
|
|
266
|
+
|
|
267
|
+
expect(el.shadowRoot!.querySelector('dialog')).to.not.exist;
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
it('should open the preview when clicked', async () => {
|
|
271
|
+
const el = await fixture<ZnThumbnail>(html`
|
|
272
|
+
<zn-thumbnail caption="Tahoe Day" src="/thumb.jpg" full-uri="/full.jpg"></zn-thumbnail>`);
|
|
273
|
+
|
|
274
|
+
let shown = false;
|
|
275
|
+
el.addEventListener('zn-show', () => (shown = true));
|
|
276
|
+
|
|
277
|
+
link(el).click();
|
|
278
|
+
await waitUntil(() => el.shadowRoot!.querySelector<HTMLDialogElement>('dialog')?.open);
|
|
279
|
+
|
|
280
|
+
const dialog = el.shadowRoot!.querySelector<HTMLDialogElement>('dialog')!;
|
|
281
|
+
expect(shown).to.be.true;
|
|
282
|
+
expect(dialog.open).to.be.true;
|
|
283
|
+
expect(el.shadowRoot!.querySelector<HTMLImageElement>('.thumbnail__preview-image')!.getAttribute('src'))
|
|
284
|
+
.to.equal('/full.jpg');
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
it('should size the preview to the asset ratio rather than a fixed one', async () => {
|
|
288
|
+
// A deliberately tall asset: a fixed 16 / 9 panel would letterbox it and leave the close
|
|
289
|
+
// button floating on the panel instead of the image.
|
|
290
|
+
const tall = 'data:image/svg+xml,' + encodeURIComponent(
|
|
291
|
+
'<svg xmlns="http://www.w3.org/2000/svg" width="200" height="400"></svg>');
|
|
292
|
+
|
|
293
|
+
const el = await fixture<ZnThumbnail>(html`
|
|
294
|
+
<zn-thumbnail caption="Tall" src="${tall}" full-uri="${tall}" style="width: 200px;"></zn-thumbnail>`);
|
|
295
|
+
|
|
296
|
+
const image = el.shadowRoot!.querySelector<HTMLImageElement>('.thumbnail__image')!;
|
|
297
|
+
await waitUntil(() => image.complete && image.naturalWidth > 0);
|
|
298
|
+
|
|
299
|
+
await el.showPreview();
|
|
300
|
+
|
|
301
|
+
const frame = el.shadowRoot!.querySelector<HTMLElement>('.thumbnail__preview-frame')!.getBoundingClientRect();
|
|
302
|
+
expect(frame.width / frame.height).to.be.closeTo(0.5, 0.02);
|
|
303
|
+
|
|
304
|
+
// ...and it stays inside 70% of the viewport in both directions.
|
|
305
|
+
expect(frame.width).to.be.at.most(window.innerWidth * 0.7 + 1);
|
|
306
|
+
expect(frame.height).to.be.at.most(window.innerHeight * 0.7 + 1);
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
it('should keep the close button over the preview image', async () => {
|
|
310
|
+
const el = await fixture<ZnThumbnail>(html`
|
|
311
|
+
<zn-thumbnail caption="Tahoe Day" full-uri="/full.jpg" style="width: 200px; ${tokens}"></zn-thumbnail>`);
|
|
312
|
+
|
|
313
|
+
await el.showPreview();
|
|
314
|
+
|
|
315
|
+
const frame = el.shadowRoot!.querySelector<HTMLElement>('.thumbnail__preview-frame')!.getBoundingClientRect();
|
|
316
|
+
const close = el.shadowRoot!.querySelector<HTMLElement>('.thumbnail__preview-close')!.getBoundingClientRect();
|
|
317
|
+
|
|
318
|
+
// The frame is the image, so being inside the frame's top-right puts it on the image.
|
|
319
|
+
expect(close.top).to.be.at.least(frame.top - 1);
|
|
320
|
+
expect(close.right).to.be.at.most(frame.right + 1);
|
|
321
|
+
expect(close.bottom).to.be.at.most(frame.bottom + 1);
|
|
322
|
+
expect(frame.right - close.right).to.be.lessThan(frame.width / 2);
|
|
323
|
+
expect(close.top - frame.top).to.be.lessThan(frame.height / 2);
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
it('should close the preview from the backdrop', async () => {
|
|
327
|
+
const el = await fixture<ZnThumbnail>(html`
|
|
328
|
+
<zn-thumbnail caption="Tahoe Day" full-uri="/full.jpg"></zn-thumbnail>`);
|
|
329
|
+
|
|
330
|
+
await el.showPreview();
|
|
331
|
+
const dialog = el.shadowRoot!.querySelector<HTMLDialogElement>('dialog')!;
|
|
332
|
+
expect(dialog.open).to.be.true;
|
|
333
|
+
|
|
334
|
+
let closed = false;
|
|
335
|
+
el.addEventListener('zn-close', () => (closed = true));
|
|
336
|
+
|
|
337
|
+
el.shadowRoot!.querySelector<HTMLElement>('.thumbnail__preview-backdrop')!.click();
|
|
338
|
+
await waitUntil(() => !dialog.open);
|
|
339
|
+
|
|
340
|
+
expect(closed).to.be.true;
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
it('should not open the preview on click when preview-trigger is button', async () => {
|
|
344
|
+
const el = await fixture<ZnThumbnail>(html`
|
|
345
|
+
<zn-thumbnail caption="Tahoe Day" full-uri="/full.jpg" preview-trigger="button"></zn-thumbnail>`);
|
|
346
|
+
|
|
347
|
+
link(el).click();
|
|
348
|
+
await el.updateComplete;
|
|
349
|
+
|
|
350
|
+
expect(el.shadowRoot!.querySelector<HTMLDialogElement>('dialog')!.open).to.be.false;
|
|
351
|
+
|
|
352
|
+
el.shadowRoot!.querySelector<HTMLElement>('.thumbnail__preview-button')!.click();
|
|
353
|
+
await waitUntil(() => el.shadowRoot!.querySelector<HTMLDialogElement>('dialog')!.open);
|
|
354
|
+
|
|
355
|
+
expect(el.shadowRoot!.querySelector<HTMLDialogElement>('dialog')!.open).to.be.true;
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
it('should not open the preview when zn-select is canceled', async () => {
|
|
359
|
+
const el = await fixture<ZnThumbnail>(html`
|
|
360
|
+
<zn-thumbnail caption="Tahoe Day" full-uri="/full.jpg"></zn-thumbnail>`);
|
|
361
|
+
|
|
362
|
+
el.addEventListener('zn-select', (event: Event) => event.preventDefault());
|
|
363
|
+
|
|
364
|
+
link(el).click();
|
|
365
|
+
await el.updateComplete;
|
|
366
|
+
|
|
367
|
+
expect(el.shadowRoot!.querySelector<HTMLDialogElement>('dialog')!.open).to.be.false;
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
it('should not open the preview when disabled', async () => {
|
|
371
|
+
const el = await fixture<ZnThumbnail>(html`
|
|
372
|
+
<zn-thumbnail caption="Tahoe Day" full-uri="/full.jpg" disabled></zn-thumbnail>`);
|
|
373
|
+
|
|
374
|
+
await el.showPreview();
|
|
375
|
+
|
|
376
|
+
expect(el.shadowRoot!.querySelector('dialog')).to.not.exist;
|
|
377
|
+
});
|
|
378
|
+
});
|
|
379
|
+
});
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import ZnThumbnailGroup from './thumbnail-group.component';
|
|
2
|
+
|
|
3
|
+
export * from './thumbnail-group.component';
|
|
4
|
+
export default ZnThumbnailGroup;
|
|
5
|
+
|
|
6
|
+
ZnThumbnailGroup.define('zn-thumbnail-group');
|
|
7
|
+
|
|
8
|
+
declare global {
|
|
9
|
+
interface HTMLElementTagNameMap {
|
|
10
|
+
'zn-thumbnail-group': ZnThumbnailGroup;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
import {classMap} from "lit/directives/class-map.js";
|
|
2
|
+
import {type CSSResultGroup, html, type PropertyValues, unsafeCSS} from 'lit';
|
|
3
|
+
import {property, query, state} from 'lit/decorators.js';
|
|
4
|
+
import ZincElement from '../../internal/zinc-element';
|
|
5
|
+
import type {ZnSelectEvent} from "../../events/zn-select";
|
|
6
|
+
import type ZnThumbnail from "../thumbnail";
|
|
7
|
+
|
|
8
|
+
import styles from './thumbnail-group.scss';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* @summary Lays a set of `zn-thumbnail` elements out as a single scrollable row, with a
|
|
12
|
+
* "Show All" toggle that expands them into a wrapping grid.
|
|
13
|
+
* @documentation https://zinc.style/components/thumbnail-group
|
|
14
|
+
* @status experimental
|
|
15
|
+
* @since 1.0
|
|
16
|
+
*
|
|
17
|
+
* @dependency zn-thumbnail
|
|
18
|
+
*
|
|
19
|
+
* @event zn-expand - Emitted when the group expands into the full grid.
|
|
20
|
+
* @event zn-collapse - Emitted when the group collapses back to a single row.
|
|
21
|
+
* @event zn-change - Emitted when the selected thumbnail changes (`selectable` groups only).
|
|
22
|
+
*
|
|
23
|
+
* @slot - One or more `zn-thumbnail` elements.
|
|
24
|
+
* @slot caption - Replaces the heading text.
|
|
25
|
+
* @slot actions - Extra content in the header, before the toggle.
|
|
26
|
+
*
|
|
27
|
+
* @csspart base - The component's base wrapper.
|
|
28
|
+
* @csspart header - The header row holding the caption and toggle.
|
|
29
|
+
* @csspart caption - The heading text.
|
|
30
|
+
* @csspart toggle - The show all / show less button.
|
|
31
|
+
* @csspart items - The scrolling row / expanded grid that holds the thumbnails.
|
|
32
|
+
*
|
|
33
|
+
* @cssproperty --zn-thumbnail-width - Track width of a thumbnail. Defaults to 180px. Also settable
|
|
34
|
+
* with the `thumbnail-width` attribute.
|
|
35
|
+
* @cssproperty --zn-thumbnail-gap - Gap between thumbnails. Also settable with the `gap` attribute.
|
|
36
|
+
* @cssproperty --zn-thumbnail-aspect-ratio - Aspect ratio of every thumbnail in the group. Also
|
|
37
|
+
* settable with the `aspect-ratio` attribute. Any `zn-thumbnail` custom property set here is
|
|
38
|
+
* inherited by the slotted thumbnails.
|
|
39
|
+
*/
|
|
40
|
+
export default class ZnThumbnailGroup extends ZincElement {
|
|
41
|
+
static styles: CSSResultGroup = unsafeCSS(styles);
|
|
42
|
+
|
|
43
|
+
/** The heading shown at the top left of the group. */
|
|
44
|
+
@property() caption: string = '';
|
|
45
|
+
|
|
46
|
+
/** Expands the group into the full wrapping grid. */
|
|
47
|
+
@property({type: Boolean, reflect: true}) expanded: boolean = false;
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Total number of thumbnails in the underlying set, shown in the "Show All (n)" label. Set this when
|
|
51
|
+
* the row only holds the first page of a larger set; otherwise the slotted thumbnails are counted.
|
|
52
|
+
*/
|
|
53
|
+
@property({type: Number}) total: number = 0;
|
|
54
|
+
|
|
55
|
+
/** Label of the expand toggle. The count is appended in brackets. */
|
|
56
|
+
@property({attribute: 'show-all-label'}) showAllLabel: string = 'Show All';
|
|
57
|
+
|
|
58
|
+
/** Label of the collapse toggle. */
|
|
59
|
+
@property({attribute: 'show-less-label'}) showLessLabel: string = 'Show Less';
|
|
60
|
+
|
|
61
|
+
/** Never render the toggle, even when the row overflows. */
|
|
62
|
+
@property({type: Boolean, attribute: 'hide-toggle'}) hideToggle: boolean = false;
|
|
63
|
+
|
|
64
|
+
/** Always render the toggle, even when every thumbnail already fits on the row. */
|
|
65
|
+
@property({type: Boolean, attribute: 'always-toggle'}) alwaysToggle: boolean = false;
|
|
66
|
+
|
|
67
|
+
/** Manages single selection across the slotted thumbnails. */
|
|
68
|
+
@property({type: Boolean, reflect: true}) selectable: boolean = false;
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Aspect ratio for every thumbnail in the group, as any CSS `aspect-ratio` value (e.g. `1 / 1`).
|
|
72
|
+
* Shorthand for setting `--zn-thumbnail-aspect-ratio` on the group. A thumbnail's own
|
|
73
|
+
* `aspect-ratio` attribute still wins.
|
|
74
|
+
*/
|
|
75
|
+
@property({attribute: 'aspect-ratio', reflect: true}) aspectRatio: string = '';
|
|
76
|
+
|
|
77
|
+
/** Track width of each thumbnail (e.g. `140px`). Shorthand for `--zn-thumbnail-width`. */
|
|
78
|
+
@property({attribute: 'thumbnail-width', reflect: true}) thumbnailWidth: string = '';
|
|
79
|
+
|
|
80
|
+
/** Gap between thumbnails (e.g. `8px`). Shorthand for `--zn-thumbnail-gap`. */
|
|
81
|
+
@property({reflect: true}) gap: string = '';
|
|
82
|
+
|
|
83
|
+
/** The `value` of the selected thumbnail (`selectable` groups only). */
|
|
84
|
+
@property() value: string = '';
|
|
85
|
+
|
|
86
|
+
@state() private _overflowing: boolean = false;
|
|
87
|
+
|
|
88
|
+
@state() private _count: number = 0;
|
|
89
|
+
|
|
90
|
+
@query('.thumbnail-group__items') private _items: HTMLElement;
|
|
91
|
+
|
|
92
|
+
private _resizeObserver: ResizeObserver | null = null;
|
|
93
|
+
|
|
94
|
+
connectedCallback() {
|
|
95
|
+
super.connectedCallback();
|
|
96
|
+
// Re-observe on reconnect — `firstUpdated` only runs once, so it can't do this on its own.
|
|
97
|
+
this._observe();
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
disconnectedCallback() {
|
|
101
|
+
super.disconnectedCallback();
|
|
102
|
+
this._resizeObserver?.disconnect();
|
|
103
|
+
this._resizeObserver = null;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
protected firstUpdated(changed: PropertyValues) {
|
|
107
|
+
super.firstUpdated(changed);
|
|
108
|
+
this._observe();
|
|
109
|
+
this._sync();
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
private _observe() {
|
|
113
|
+
if (!this._items) return;
|
|
114
|
+
this._resizeObserver ??= new ResizeObserver(() => this._measure());
|
|
115
|
+
this._resizeObserver.observe(this._items);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
protected updated(changed: PropertyValues) {
|
|
119
|
+
super.updated(changed);
|
|
120
|
+
|
|
121
|
+
// Toggling `expanded` swaps the items container between nowrap and wrap. That changes
|
|
122
|
+
// scrollWidth without changing the element's box, so the ResizeObserver never fires and
|
|
123
|
+
// the overflow state has to be re-read by hand.
|
|
124
|
+
if (changed.has('expanded')) {
|
|
125
|
+
this._measure();
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (changed.has('selectable') || changed.has('value')) {
|
|
129
|
+
this._sync();
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
this._syncCustomProperties(changed);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Mirrors the shorthand attributes onto host custom properties. They go on the host rather than
|
|
137
|
+
* anywhere in the shadow root because the thumbnails are light-DOM children — inheriting from the
|
|
138
|
+
* host is the only way the values reach them.
|
|
139
|
+
*/
|
|
140
|
+
private _syncCustomProperties(changed: PropertyValues) {
|
|
141
|
+
const bindings: [string, string, string][] = [
|
|
142
|
+
['aspectRatio', '--zn-thumbnail-aspect-ratio', this.aspectRatio],
|
|
143
|
+
['thumbnailWidth', '--zn-thumbnail-width', this.thumbnailWidth],
|
|
144
|
+
['gap', '--zn-thumbnail-gap', this.gap]
|
|
145
|
+
];
|
|
146
|
+
|
|
147
|
+
for (const [name, customProperty, value] of bindings) {
|
|
148
|
+
if (!changed.has(name)) continue;
|
|
149
|
+
|
|
150
|
+
if (value) {
|
|
151
|
+
this.style.setProperty(customProperty, value);
|
|
152
|
+
} else if (changed.get(name)) {
|
|
153
|
+
// Only clear a property this component previously set, so an inline custom property in
|
|
154
|
+
// the consumer's markup survives the first render.
|
|
155
|
+
this.style.removeProperty(customProperty);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Expands the group into the full grid. */
|
|
161
|
+
show() {
|
|
162
|
+
if (this.expanded) return;
|
|
163
|
+
this.expanded = true;
|
|
164
|
+
this.emit('zn-expand');
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Collapses the group back to a single row. */
|
|
168
|
+
hide() {
|
|
169
|
+
if (!this.expanded) return;
|
|
170
|
+
this.expanded = false;
|
|
171
|
+
this.emit('zn-collapse');
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
private get _thumbnails(): ZnThumbnail[] {
|
|
175
|
+
return Array.from(this.querySelectorAll<ZnThumbnail>(':scope > zn-thumbnail'));
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
private _handleSlotChange = () => {
|
|
179
|
+
this._sync();
|
|
180
|
+
this._measure();
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
// Publish the child count for the toggle label and mirror the group's selection
|
|
184
|
+
// settings onto the thumbnails.
|
|
185
|
+
private _sync() {
|
|
186
|
+
const thumbnails = this._thumbnails;
|
|
187
|
+
this._count = thumbnails.length;
|
|
188
|
+
|
|
189
|
+
if (!this.selectable) return;
|
|
190
|
+
|
|
191
|
+
// Adopt a thumbnail that was marked selected in markup so `value` starts in step.
|
|
192
|
+
if (!this.value) {
|
|
193
|
+
this.value = thumbnails.find(thumbnail => thumbnail.selected)?.value ?? '';
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
for (const thumbnail of thumbnails) {
|
|
197
|
+
thumbnail.selectable = true;
|
|
198
|
+
if (this.value) {
|
|
199
|
+
thumbnail.selected = thumbnail.value === this.value;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
private _measure() {
|
|
205
|
+
// Only the collapsed row can overflow horizontally; the expanded grid always wraps, so
|
|
206
|
+
// measuring it would clear the state that decides whether the toggle is offered at all.
|
|
207
|
+
if (this.expanded || !this._items) return;
|
|
208
|
+
this._overflowing = this._items.scrollWidth - this._items.clientWidth > 1;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
private _handleSelect = (event: ZnSelectEvent) => {
|
|
212
|
+
if (!this.selectable) return;
|
|
213
|
+
|
|
214
|
+
const thumbnail = event.detail.item as ZnThumbnail;
|
|
215
|
+
if (thumbnail.parentElement !== this) return;
|
|
216
|
+
|
|
217
|
+
for (const item of this._thumbnails) {
|
|
218
|
+
item.selected = item === thumbnail;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
this.value = thumbnail.value;
|
|
222
|
+
this.emit('zn-change');
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
private _handleToggle = () => {
|
|
226
|
+
if (this.expanded) {
|
|
227
|
+
this.hide();
|
|
228
|
+
} else {
|
|
229
|
+
this.show();
|
|
230
|
+
}
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
render() {
|
|
234
|
+
const count = this.total || this._count;
|
|
235
|
+
const showToggle = !this.hideToggle && (this.expanded || this.alwaysToggle || this._overflowing || count > this._count);
|
|
236
|
+
const hasHeader = Boolean(this.caption) || showToggle;
|
|
237
|
+
|
|
238
|
+
return html`
|
|
239
|
+
<div
|
|
240
|
+
part="base"
|
|
241
|
+
class="${classMap({
|
|
242
|
+
'thumbnail-group': true,
|
|
243
|
+
'thumbnail-group--expanded': this.expanded,
|
|
244
|
+
})}">
|
|
245
|
+
${hasHeader ? html`
|
|
246
|
+
<div part="header" class="thumbnail-group__header">
|
|
247
|
+
<div part="caption" class="thumbnail-group__caption">
|
|
248
|
+
<slot name="caption">${this.caption}</slot>
|
|
249
|
+
</div>
|
|
250
|
+
<div class="thumbnail-group__header-actions">
|
|
251
|
+
<slot name="actions"></slot>
|
|
252
|
+
${showToggle ? html`
|
|
253
|
+
<button
|
|
254
|
+
part="toggle"
|
|
255
|
+
type="button"
|
|
256
|
+
class="thumbnail-group__toggle"
|
|
257
|
+
aria-expanded="${this.expanded ? 'true' : 'false'}"
|
|
258
|
+
@click="${this._handleToggle}">
|
|
259
|
+
${this.expanded ? this.showLessLabel : `${this.showAllLabel}${count ? ` (${count})` : ''}`}
|
|
260
|
+
</button>` : ''}
|
|
261
|
+
</div>
|
|
262
|
+
</div>` : ''}
|
|
263
|
+
<div part="items" class="thumbnail-group__items">
|
|
264
|
+
<slot @slotchange="${this._handleSlotChange}" @zn-select="${this._handleSelect}"></slot>
|
|
265
|
+
</div>
|
|
266
|
+
</div>`;
|
|
267
|
+
}
|
|
268
|
+
}
|