@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,460 @@
|
|
|
1
|
+
import {animateTo, prefersReducedMotion} from "../../internal/animate";
|
|
2
|
+
import {classMap} from "lit/directives/class-map.js";
|
|
3
|
+
import {type CSSResultGroup, unsafeCSS} from 'lit';
|
|
4
|
+
import {HasSlotController} from "../../internal/slot";
|
|
5
|
+
import {html, literal} from "lit/static-html.js";
|
|
6
|
+
import {ifDefined} from "lit/directives/if-defined.js";
|
|
7
|
+
import {lockBodyScrolling, unlockBodyScrolling} from "../../internal/scroll";
|
|
8
|
+
import {property, query, state} from 'lit/decorators.js';
|
|
9
|
+
import {styleMap} from "lit/directives/style-map.js";
|
|
10
|
+
import ZincElement from '../../internal/zinc-element';
|
|
11
|
+
import ZnIcon from "../icon";
|
|
12
|
+
|
|
13
|
+
import styles from './thumbnail.scss';
|
|
14
|
+
|
|
15
|
+
const PREVIEW_DURATION = 260;
|
|
16
|
+
const PREVIEW_EASING = 'cubic-bezier(0.2, 0, 0.2, 1)';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* @summary A captioned image tile — a fixed aspect-ratio preview with a title beneath it, optional
|
|
20
|
+
* corner badges and actions, and an optional full-screen preview that grows out of the thumbnail.
|
|
21
|
+
* @documentation https://zinc.style/components/thumbnail
|
|
22
|
+
* @status experimental
|
|
23
|
+
* @since 1.0
|
|
24
|
+
*
|
|
25
|
+
* @dependency zn-icon
|
|
26
|
+
*
|
|
27
|
+
* @event zn-select - Emitted when an enabled thumbnail is activated by click or keyboard. Cancelable —
|
|
28
|
+
* call `preventDefault()` to stop the preview from opening and to stop a parent
|
|
29
|
+
* `zn-thumbnail-group` from changing its selection.
|
|
30
|
+
* @event zn-show - Emitted when the preview overlay starts opening.
|
|
31
|
+
* @event zn-close - Emitted once the preview overlay has finished closing.
|
|
32
|
+
*
|
|
33
|
+
* @slot - Fallback content rendered in place of the media when no `src` is set.
|
|
34
|
+
* @slot image - Replaces the built-in `<img>` (e.g. a video, canvas or `zn-icon`).
|
|
35
|
+
* @slot caption - Replaces the caption text.
|
|
36
|
+
* @slot badge - Content pinned to the bottom-left of the media (e.g. a play indicator). Badges take
|
|
37
|
+
* no pointer events and are held back on opacity, so they read as information about the asset
|
|
38
|
+
* rather than as something to click.
|
|
39
|
+
* @slot actions - Controls pinned to the bottom-right of the media (e.g. a download button). Each
|
|
40
|
+
* slotted element becomes its own chip, so several actions read as separate icons on the thumbnail
|
|
41
|
+
* rather than one grouped pill. This sits outside the thumbnail's link, so buttons and links here
|
|
42
|
+
* behave normally and never select the thumbnail or follow its `href`.
|
|
43
|
+
* @slot preview - Replaces the preview media (e.g. a `<video>` for the full-size asset).
|
|
44
|
+
*
|
|
45
|
+
* @csspart base - The component's base wrapper.
|
|
46
|
+
* @csspart link - The anchor (or div) covering the media and caption.
|
|
47
|
+
* @csspart media - The fixed aspect-ratio media frame.
|
|
48
|
+
* @csspart image - The built-in image element.
|
|
49
|
+
* @csspart overlay - The layer holding the badge, actions and preview button.
|
|
50
|
+
* @csspart badge - The bottom-left badge container.
|
|
51
|
+
* @csspart actions - The bottom-right actions container.
|
|
52
|
+
* @csspart preview-button - The built-in preview trigger, when `preview-trigger="button"`.
|
|
53
|
+
* @csspart caption - The caption beneath the media.
|
|
54
|
+
* @csspart preview - The preview `<dialog>`.
|
|
55
|
+
* @csspart preview-backdrop - The full-screen backdrop behind the preview.
|
|
56
|
+
* @csspart preview-frame - The rounded panel the preview media sits in.
|
|
57
|
+
* @csspart preview-image - The full-size image inside the preview.
|
|
58
|
+
* @csspart preview-close - The preview's close button.
|
|
59
|
+
*
|
|
60
|
+
* @cssproperty --zn-thumbnail-aspect-ratio - Aspect ratio of the media frame. Defaults to 16 / 9. The
|
|
61
|
+
* `aspect-ratio` attribute sets this on the thumbnail itself; set the property on any ancestor
|
|
62
|
+
* (including a `zn-thumbnail-group`) to apply it to every thumbnail beneath it.
|
|
63
|
+
* @cssproperty --zn-thumbnail-radius - Corner radius of the media frame.
|
|
64
|
+
* @cssproperty --zn-thumbnail-preview-aspect-ratio - Overrides the measured aspect ratio of the preview
|
|
65
|
+
* panel. By default the panel takes the asset's own ratio so it wraps the image exactly.
|
|
66
|
+
* @cssproperty --zn-thumbnail-preview-max-width - Largest width the preview may take. Defaults to 70vw.
|
|
67
|
+
* @cssproperty --zn-thumbnail-preview-max-height - Largest height the preview may take. Defaults to 70vh.
|
|
68
|
+
* @cssproperty --zn-thumbnail-preview-radius - Corner radius of the preview panel.
|
|
69
|
+
* @cssproperty --zn-thumbnail-chip-radius - Corner radius of a badge, action or preview chip.
|
|
70
|
+
* @cssproperty --zn-thumbnail-chip-background - Background of a badge, action or preview chip.
|
|
71
|
+
* @cssproperty --zn-thumbnail-chip-background-hover - Hover background of an action or preview chip.
|
|
72
|
+
* @cssproperty --zn-thumbnail-chip-color - Foreground colour of a badge, action or preview chip.
|
|
73
|
+
* @cssproperty --zn-thumbnail-chip-gap - Gap between adjacent chips.
|
|
74
|
+
* @cssproperty --zn-thumbnail-badge-opacity - Opacity of a badge, holding it back from the action
|
|
75
|
+
* chips beside it. Defaults to 0.65.
|
|
76
|
+
* @cssproperty --zn-thumbnail-selected-color - Ring colour for the selected state.
|
|
77
|
+
* @cssproperty --zn-thumbnail-active-color - Ring colour for the active state.
|
|
78
|
+
*/
|
|
79
|
+
export default class ZnThumbnail extends ZincElement {
|
|
80
|
+
static styles: CSSResultGroup = unsafeCSS(styles);
|
|
81
|
+
static dependencies = {'zn-icon': ZnIcon};
|
|
82
|
+
|
|
83
|
+
private readonly hasSlotController = new HasSlotController(this, '[default]', 'image', 'caption', 'badge', 'actions', 'preview');
|
|
84
|
+
|
|
85
|
+
/** Image URL for the built-in `<img>`. */
|
|
86
|
+
@property() src: string;
|
|
87
|
+
|
|
88
|
+
/** Alternative text for the built-in image. Falls back to the caption. */
|
|
89
|
+
@property() alt: string;
|
|
90
|
+
|
|
91
|
+
/** The title shown beneath the media. */
|
|
92
|
+
@property() caption: string = '';
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Aspect ratio of the media frame, as any CSS `aspect-ratio` value (e.g. `1 / 1`, `4 / 3`).
|
|
96
|
+
* Takes precedence over an inherited `--zn-thumbnail-aspect-ratio`. Defaults to `16 / 9`.
|
|
97
|
+
*/
|
|
98
|
+
@property({attribute: 'aspect-ratio', reflect: true}) aspectRatio: string = '';
|
|
99
|
+
|
|
100
|
+
/** Renders the thumbnail as a link to this URL. */
|
|
101
|
+
@property() href: string;
|
|
102
|
+
|
|
103
|
+
/** Where to open `href` (e.g. `_blank`). */
|
|
104
|
+
@property() target: string;
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* URL of the full-size asset. Setting this enables the preview overlay, which grows out of the
|
|
108
|
+
* thumbnail's position over a full-screen backdrop.
|
|
109
|
+
*/
|
|
110
|
+
@property({attribute: 'full-uri'}) fullUri: string = '';
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* How the preview opens. `click` (the default) opens it when the thumbnail is activated; `button`
|
|
114
|
+
* adds a dedicated expand control to the media instead, leaving clicks for selection or the link.
|
|
115
|
+
*/
|
|
116
|
+
@property({attribute: 'preview-trigger'}) previewTrigger: 'click' | 'button' = 'click';
|
|
117
|
+
|
|
118
|
+
/** Accessible label for the built-in preview trigger and the preview overlay. */
|
|
119
|
+
@property({attribute: 'preview-label'}) previewLabel: string = 'Preview';
|
|
120
|
+
|
|
121
|
+
/** Identifier reported in the `zn-select` event detail and used for group selection. */
|
|
122
|
+
@property() value: string = '';
|
|
123
|
+
|
|
124
|
+
/** Draws the selected ring. Managed automatically inside a `selectable` `zn-thumbnail-group`. */
|
|
125
|
+
@property({type: Boolean, reflect: true}) selected: boolean = false;
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Draws the active ring — for the thumbnail currently in use (playing, open, being edited), as
|
|
129
|
+
* distinct from the user's selection. Takes the ring colour when a thumbnail is both active and
|
|
130
|
+
* selected. Recolour it with `--zn-thumbnail-active-color`.
|
|
131
|
+
*/
|
|
132
|
+
@property({type: Boolean, reflect: true}) active: boolean = false;
|
|
133
|
+
|
|
134
|
+
/** Set by `zn-thumbnail-group` when it manages selection, so the thumbnail reads as clickable. */
|
|
135
|
+
@property({type: Boolean, reflect: true}) selectable: boolean = false;
|
|
136
|
+
|
|
137
|
+
/** Dims the thumbnail and blocks selection, navigation and preview. */
|
|
138
|
+
@property({type: Boolean, reflect: true}) disabled: boolean = false;
|
|
139
|
+
|
|
140
|
+
/** Icon rendered in the bottom-left badge when the `badge` slot is empty. */
|
|
141
|
+
@property() icon: string = '';
|
|
142
|
+
|
|
143
|
+
/** Hides the caption row, leaving just the media frame. */
|
|
144
|
+
@property({type: Boolean, attribute: 'hide-caption'}) hideCaption: boolean = false;
|
|
145
|
+
|
|
146
|
+
@state() private _previewOpen: boolean = false;
|
|
147
|
+
|
|
148
|
+
/** Aspect ratio (width / height) measured from the asset, so the preview wraps it exactly. */
|
|
149
|
+
@state() private _previewRatio: number = 0;
|
|
150
|
+
|
|
151
|
+
@query('.thumbnail__media') private _media: HTMLElement;
|
|
152
|
+
@query('.thumbnail__image') private _image: HTMLImageElement;
|
|
153
|
+
@query('.thumbnail__preview-image') private _previewImage: HTMLImageElement;
|
|
154
|
+
@query('.thumbnail__preview') private _preview: HTMLDialogElement;
|
|
155
|
+
@query('.thumbnail__preview-frame') private _previewFrame: HTMLElement;
|
|
156
|
+
@query('.thumbnail__preview-backdrop') private _previewBackdrop: HTMLElement;
|
|
157
|
+
@query('.thumbnail__preview-close') private _previewClose: HTMLElement;
|
|
158
|
+
|
|
159
|
+
/** The media rect captured when the preview opened, so the grow animation starts from the thumbnail. */
|
|
160
|
+
private _origin: DOMRect | null = null;
|
|
161
|
+
private _animating: boolean = false;
|
|
162
|
+
|
|
163
|
+
disconnectedCallback() {
|
|
164
|
+
super.disconnectedCallback();
|
|
165
|
+
if (this._previewOpen) {
|
|
166
|
+
this._preview?.close();
|
|
167
|
+
this._previewOpen = false;
|
|
168
|
+
}
|
|
169
|
+
unlockBodyScrolling(this);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
private _isLink() {
|
|
173
|
+
return Boolean(this.href) && !this.disabled;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
private get _previewable() {
|
|
177
|
+
return (Boolean(this.fullUri) || this.hasSlotController.test('preview')) && !this.disabled;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** True when activating the thumbnail itself should open the preview. */
|
|
181
|
+
private get _previewOnActivate() {
|
|
182
|
+
return this._previewable && this.previewTrigger === 'click';
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* The asset's real aspect ratio, so the preview panel wraps the image instead of letterboxing it
|
|
187
|
+
* inside a fixed-ratio box. The full-size image is preferred, but it usually hasn't loaded when
|
|
188
|
+
* the preview opens — the already-loaded thumbnail shares the asset's ratio, and the rendered
|
|
189
|
+
* media box is the last resort for slotted media that can't be measured.
|
|
190
|
+
*/
|
|
191
|
+
private _resolveRatio(): number {
|
|
192
|
+
const candidates = [this._previewImage, this._image];
|
|
193
|
+
|
|
194
|
+
for (const image of candidates) {
|
|
195
|
+
if (image?.naturalWidth && image.naturalHeight) {
|
|
196
|
+
return image.naturalWidth / image.naturalHeight;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const rect = this._media?.getBoundingClientRect();
|
|
201
|
+
return rect?.width && rect.height ? rect.width / rect.height : 0;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** Opens the preview overlay. */
|
|
205
|
+
async showPreview() {
|
|
206
|
+
if (!this._previewable || this._previewOpen || this._animating) return;
|
|
207
|
+
|
|
208
|
+
// Captured before the dialog opens, while the thumbnail is still where the user clicked.
|
|
209
|
+
this._origin = this._media?.getBoundingClientRect() ?? null;
|
|
210
|
+
// Resolved before the first paint so the panel is already the right size to animate to.
|
|
211
|
+
this._previewRatio = this._resolveRatio();
|
|
212
|
+
|
|
213
|
+
this.emit('zn-show');
|
|
214
|
+
this._previewOpen = true;
|
|
215
|
+
await this.updateComplete;
|
|
216
|
+
|
|
217
|
+
if (!this._preview) return;
|
|
218
|
+
this._preview.showModal();
|
|
219
|
+
lockBodyScrolling(this);
|
|
220
|
+
|
|
221
|
+
await this._animatePreview('in');
|
|
222
|
+
this._previewClose?.focus();
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** Closes the preview overlay. */
|
|
226
|
+
async hidePreview() {
|
|
227
|
+
if (!this._previewOpen || this._animating) return;
|
|
228
|
+
|
|
229
|
+
await this._animatePreview('out');
|
|
230
|
+
|
|
231
|
+
this._preview?.close();
|
|
232
|
+
this._previewOpen = false;
|
|
233
|
+
unlockBodyScrolling(this);
|
|
234
|
+
this.emit('zn-close');
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
private async _animatePreview(direction: 'in' | 'out') {
|
|
238
|
+
const frame = this._previewFrame;
|
|
239
|
+
const backdrop = this._previewBackdrop;
|
|
240
|
+
if (!frame || !backdrop) return;
|
|
241
|
+
|
|
242
|
+
this._animating = true;
|
|
243
|
+
|
|
244
|
+
// On the way out the thumbnail is re-measured: the row may have been scrolled, or the
|
|
245
|
+
// window resized, while the preview was open.
|
|
246
|
+
const origin = direction === 'in' ? this._origin : (this._media?.getBoundingClientRect() ?? this._origin);
|
|
247
|
+
const collapsed = [
|
|
248
|
+
{transform: this._collapsedTransform(origin, frame.getBoundingClientRect()), opacity: '0.4'},
|
|
249
|
+
{transform: 'none', opacity: '1'}
|
|
250
|
+
];
|
|
251
|
+
const fade = [{opacity: '0'}, {opacity: '1'}];
|
|
252
|
+
|
|
253
|
+
const options: KeyframeAnimationOptions = {duration: PREVIEW_DURATION, easing: PREVIEW_EASING};
|
|
254
|
+
const frameKeyframes = direction === 'in' ? collapsed : [...collapsed].reverse();
|
|
255
|
+
const backdropKeyframes = direction === 'in' ? fade : [...fade].reverse();
|
|
256
|
+
|
|
257
|
+
await Promise.all([
|
|
258
|
+
animateTo(frame, frameKeyframes, options),
|
|
259
|
+
animateTo(backdrop, backdropKeyframes, options)
|
|
260
|
+
]);
|
|
261
|
+
|
|
262
|
+
this._animating = false;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** Maps the preview panel onto the thumbnail's box, so the panel appears to grow out of it. */
|
|
266
|
+
private _collapsedTransform(from: DOMRect | null, to: DOMRect) {
|
|
267
|
+
if (!from || !to.width || !to.height || prefersReducedMotion()) {
|
|
268
|
+
return 'scale(0.94)';
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const scaleX = Math.max(from.width / to.width, 0.01);
|
|
272
|
+
const scaleY = Math.max(from.height / to.height, 0.01);
|
|
273
|
+
const x = from.left + from.width / 2 - (to.left + to.width / 2);
|
|
274
|
+
const y = from.top + from.height / 2 - (to.top + to.height / 2);
|
|
275
|
+
|
|
276
|
+
return `translate(${x}px, ${y}px) scale(${scaleX}, ${scaleY})`;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
private _activate(event: Event, allowPreview: boolean) {
|
|
280
|
+
if (this.disabled) {
|
|
281
|
+
event.preventDefault();
|
|
282
|
+
event.stopPropagation();
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
const selectEvent = this.emit('zn-select', {detail: {item: this}});
|
|
287
|
+
|
|
288
|
+
// A canceled selection also cancels navigation and the preview, so a consumer can intercept a
|
|
289
|
+
// linked thumbnail without the browser leaving the page.
|
|
290
|
+
if (selectEvent.defaultPrevented) {
|
|
291
|
+
event.preventDefault();
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
if (allowPreview && this._previewOnActivate) {
|
|
296
|
+
event.preventDefault();
|
|
297
|
+
void this.showPreview();
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
private _handleClick = (event: MouseEvent) => {
|
|
302
|
+
// Modifier and middle clicks are left alone so a linked thumbnail can still be opened in a
|
|
303
|
+
// new tab or window rather than being swallowed by the preview.
|
|
304
|
+
const modified = event.metaKey || event.ctrlKey || event.shiftKey || event.altKey;
|
|
305
|
+
this._activate(event, !modified);
|
|
306
|
+
};
|
|
307
|
+
|
|
308
|
+
private _handleKeyDown = (event: KeyboardEvent) => {
|
|
309
|
+
if (event.key !== 'Enter' && event.key !== ' ') return;
|
|
310
|
+
// A link already activates on Enter; only the space and non-link cases need synthesising.
|
|
311
|
+
if (this._isLink() && event.key === 'Enter') return;
|
|
312
|
+
event.preventDefault();
|
|
313
|
+
this._activate(event, true);
|
|
314
|
+
};
|
|
315
|
+
|
|
316
|
+
private _handlePreviewButtonClick = (event: MouseEvent) => {
|
|
317
|
+
event.preventDefault();
|
|
318
|
+
event.stopPropagation();
|
|
319
|
+
void this.showPreview();
|
|
320
|
+
};
|
|
321
|
+
|
|
322
|
+
private _handlePreviewCancel = (event: Event) => {
|
|
323
|
+
// Escape closes through hidePreview() so the shrink animation still runs.
|
|
324
|
+
event.preventDefault();
|
|
325
|
+
void this.hidePreview();
|
|
326
|
+
};
|
|
327
|
+
|
|
328
|
+
private _handleBackdropClick = () => void this.hidePreview();
|
|
329
|
+
|
|
330
|
+
private _handlePreviewImageLoad = (event: Event) => {
|
|
331
|
+
const image = event.target as HTMLImageElement;
|
|
332
|
+
if (!image.naturalWidth || !image.naturalHeight) return;
|
|
333
|
+
|
|
334
|
+
const ratio = image.naturalWidth / image.naturalHeight;
|
|
335
|
+
// Only resize if the full asset genuinely differs from what the panel was sized against,
|
|
336
|
+
// so the common case doesn't reflow the panel out from under the open animation.
|
|
337
|
+
if (this._previewRatio && Math.abs(ratio - this._previewRatio) / this._previewRatio < 0.01) return;
|
|
338
|
+
|
|
339
|
+
this._previewRatio = ratio;
|
|
340
|
+
};
|
|
341
|
+
|
|
342
|
+
render() {
|
|
343
|
+
const isLink = this._isLink();
|
|
344
|
+
const previewable = this._previewable;
|
|
345
|
+
const showPreviewButton = previewable && this.previewTrigger === 'button';
|
|
346
|
+
// Only links, selection targets and click-to-preview thumbnails take focus; a decorative
|
|
347
|
+
// thumbnail stays out of the tab order.
|
|
348
|
+
const interactive = isLink || this.selectable || this._previewOnActivate;
|
|
349
|
+
const tag = isLink ? literal`a` : literal`div`;
|
|
350
|
+
const hasImage = this.hasSlotController.test('image');
|
|
351
|
+
const hasBadge = this.hasSlotController.test('badge') || Boolean(this.icon);
|
|
352
|
+
const hasActions = this.hasSlotController.test('actions');
|
|
353
|
+
const hasOverlay = hasBadge || hasActions || showPreviewButton;
|
|
354
|
+
const hasCaption = !this.hideCaption && (Boolean(this.caption) || this.hasSlotController.test('caption'));
|
|
355
|
+
|
|
356
|
+
return html`
|
|
357
|
+
<div
|
|
358
|
+
part="base"
|
|
359
|
+
style="${styleMap({'--zn-thumbnail-aspect-ratio': this.aspectRatio || null})}"
|
|
360
|
+
class="${classMap({
|
|
361
|
+
thumbnail: true,
|
|
362
|
+
'thumbnail--link': isLink,
|
|
363
|
+
'thumbnail--interactive': interactive,
|
|
364
|
+
'thumbnail--selected': this.selected,
|
|
365
|
+
'thumbnail--active': this.active,
|
|
366
|
+
'thumbnail--disabled': this.disabled,
|
|
367
|
+
})}">
|
|
368
|
+
<${tag}
|
|
369
|
+
part="link"
|
|
370
|
+
class="thumbnail__link"
|
|
371
|
+
href="${ifDefined(isLink ? this.href : undefined)}"
|
|
372
|
+
target="${ifDefined(isLink ? this.target : undefined)}"
|
|
373
|
+
role="${ifDefined(!isLink && interactive ? 'button' : undefined)}"
|
|
374
|
+
tabindex="${ifDefined(interactive ? (this.disabled ? '-1' : '0') : undefined)}"
|
|
375
|
+
aria-disabled="${ifDefined(this.disabled ? 'true' : undefined)}"
|
|
376
|
+
aria-pressed="${ifDefined(!isLink && this.selectable ? String(this.selected) : undefined)}"
|
|
377
|
+
aria-current="${ifDefined(isLink && this.selected ? 'true' : undefined)}"
|
|
378
|
+
@click="${this._handleClick}"
|
|
379
|
+
@keydown="${this._handleKeyDown}">
|
|
380
|
+
<div part="media" class="thumbnail__media">
|
|
381
|
+
${hasImage || !this.src ? html`
|
|
382
|
+
<slot name="image" class="thumbnail__slotted-media">
|
|
383
|
+
<slot class="thumbnail__slotted-media"></slot>
|
|
384
|
+
</slot>` : html`
|
|
385
|
+
<img
|
|
386
|
+
part="image"
|
|
387
|
+
class="thumbnail__image"
|
|
388
|
+
src="${this.src}"
|
|
389
|
+
alt="${this.alt ?? this.caption}"
|
|
390
|
+
loading="lazy"
|
|
391
|
+
decoding="async">`}
|
|
392
|
+
</div>
|
|
393
|
+
${hasCaption ? html`
|
|
394
|
+
<div part="caption" class="thumbnail__caption">
|
|
395
|
+
<slot name="caption">${this.caption}</slot>
|
|
396
|
+
</div>` : ''}
|
|
397
|
+
</${tag}>
|
|
398
|
+
|
|
399
|
+
${hasOverlay ? html`
|
|
400
|
+
<!-- Sits outside the link so slotted controls keep their own click behaviour. -->
|
|
401
|
+
<div part="overlay" class="thumbnail__overlay">
|
|
402
|
+
${showPreviewButton ? html`
|
|
403
|
+
<button
|
|
404
|
+
part="preview-button"
|
|
405
|
+
type="button"
|
|
406
|
+
class="thumbnail__preview-button"
|
|
407
|
+
aria-label="${this.previewLabel}"
|
|
408
|
+
@click="${this._handlePreviewButtonClick}">
|
|
409
|
+
<zn-icon src="fullscreen" size="16"></zn-icon>
|
|
410
|
+
</button>` : ''}
|
|
411
|
+
${hasBadge ? html`
|
|
412
|
+
<div part="badge" class="thumbnail__badge">
|
|
413
|
+
<slot name="badge" class="thumbnail__badge-items">
|
|
414
|
+
<zn-icon class="thumbnail__badge-icon" src="${this.icon}" size="14"></zn-icon>
|
|
415
|
+
</slot>
|
|
416
|
+
</div>` : ''}
|
|
417
|
+
${hasActions ? html`
|
|
418
|
+
<div part="actions" class="thumbnail__actions">
|
|
419
|
+
<slot name="actions" class="thumbnail__action-items"></slot>
|
|
420
|
+
</div>` : ''}
|
|
421
|
+
</div>` : ''}
|
|
422
|
+
|
|
423
|
+
${previewable ? html`
|
|
424
|
+
<dialog
|
|
425
|
+
part="preview"
|
|
426
|
+
class="thumbnail__preview"
|
|
427
|
+
aria-label="${this.caption || this.previewLabel}"
|
|
428
|
+
@cancel="${this._handlePreviewCancel}">
|
|
429
|
+
<div
|
|
430
|
+
part="preview-backdrop"
|
|
431
|
+
class="thumbnail__preview-backdrop"
|
|
432
|
+
@click="${this._handleBackdropClick}"></div>
|
|
433
|
+
<div
|
|
434
|
+
part="preview-frame"
|
|
435
|
+
class="thumbnail__preview-frame"
|
|
436
|
+
style="${styleMap({'--zn-thumbnail-preview-measured-ratio': this._previewRatio || null})}">
|
|
437
|
+
${this.src ? html`
|
|
438
|
+
<img class="thumbnail__preview-placeholder" src="${this.src}" alt="" aria-hidden="true">` : ''}
|
|
439
|
+
<slot name="preview" class="thumbnail__preview-media">
|
|
440
|
+
<img
|
|
441
|
+
part="preview-image"
|
|
442
|
+
class="thumbnail__preview-image"
|
|
443
|
+
src="${ifDefined(this._previewOpen && this.fullUri ? this.fullUri : undefined)}"
|
|
444
|
+
alt="${this.alt ?? this.caption}"
|
|
445
|
+
decoding="async"
|
|
446
|
+
@load="${this._handlePreviewImageLoad}">
|
|
447
|
+
</slot>
|
|
448
|
+
<button
|
|
449
|
+
part="preview-close"
|
|
450
|
+
type="button"
|
|
451
|
+
class="thumbnail__preview-close"
|
|
452
|
+
aria-label="Close preview"
|
|
453
|
+
@click="${() => void this.hidePreview()}">
|
|
454
|
+
<zn-icon src="close" size="20"></zn-icon>
|
|
455
|
+
</button>
|
|
456
|
+
</div>
|
|
457
|
+
</dialog>` : ''}
|
|
458
|
+
</div>`;
|
|
459
|
+
}
|
|
460
|
+
}
|