@mk-kit/ui 0.34.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.
Files changed (63) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +115 -0
  3. package/block-editor/README.md +254 -0
  4. package/fesm2022/mk-kit-ui-block-editor.mjs +2158 -0
  5. package/fesm2022/mk-kit-ui-block-editor.mjs.map +1 -0
  6. package/fesm2022/mk-kit-ui-button.mjs +81 -0
  7. package/fesm2022/mk-kit-ui-button.mjs.map +1 -0
  8. package/fesm2022/mk-kit-ui-checkbox.mjs +136 -0
  9. package/fesm2022/mk-kit-ui-checkbox.mjs.map +1 -0
  10. package/fesm2022/mk-kit-ui-chip.mjs +122 -0
  11. package/fesm2022/mk-kit-ui-chip.mjs.map +1 -0
  12. package/fesm2022/mk-kit-ui-context-menu.mjs +144 -0
  13. package/fesm2022/mk-kit-ui-context-menu.mjs.map +1 -0
  14. package/fesm2022/mk-kit-ui-core.mjs +1576 -0
  15. package/fesm2022/mk-kit-ui-core.mjs.map +1 -0
  16. package/fesm2022/mk-kit-ui-data.mjs +6055 -0
  17. package/fesm2022/mk-kit-ui-data.mjs.map +1 -0
  18. package/fesm2022/mk-kit-ui-datetime.mjs +3409 -0
  19. package/fesm2022/mk-kit-ui-datetime.mjs.map +1 -0
  20. package/fesm2022/mk-kit-ui-directives.mjs +1779 -0
  21. package/fesm2022/mk-kit-ui-directives.mjs.map +1 -0
  22. package/fesm2022/mk-kit-ui-dnd.mjs +1073 -0
  23. package/fesm2022/mk-kit-ui-dnd.mjs.map +1 -0
  24. package/fesm2022/mk-kit-ui-feedback.mjs +2426 -0
  25. package/fesm2022/mk-kit-ui-feedback.mjs.map +1 -0
  26. package/fesm2022/mk-kit-ui-forms.mjs +9208 -0
  27. package/fesm2022/mk-kit-ui-forms.mjs.map +1 -0
  28. package/fesm2022/mk-kit-ui-icon.mjs +470 -0
  29. package/fesm2022/mk-kit-ui-icon.mjs.map +1 -0
  30. package/fesm2022/mk-kit-ui-media.mjs +896 -0
  31. package/fesm2022/mk-kit-ui-media.mjs.map +1 -0
  32. package/fesm2022/mk-kit-ui-navigation.mjs +2542 -0
  33. package/fesm2022/mk-kit-ui-navigation.mjs.map +1 -0
  34. package/fesm2022/mk-kit-ui-rich-text.mjs +565 -0
  35. package/fesm2022/mk-kit-ui-rich-text.mjs.map +1 -0
  36. package/fesm2022/mk-kit-ui-table.mjs +1378 -0
  37. package/fesm2022/mk-kit-ui-table.mjs.map +1 -0
  38. package/fesm2022/mk-kit-ui.mjs +32 -0
  39. package/fesm2022/mk-kit-ui.mjs.map +1 -0
  40. package/package.json +130 -0
  41. package/schematics/collection.json +10 -0
  42. package/schematics/ng-add/index.js +113 -0
  43. package/schematics/ng-add/schema.json +22 -0
  44. package/schematics/package.json +3 -0
  45. package/styles/mk-kit.css +750 -0
  46. package/types/mk-kit-ui-block-editor.d.ts +292 -0
  47. package/types/mk-kit-ui-button.d.ts +40 -0
  48. package/types/mk-kit-ui-checkbox.d.ts +62 -0
  49. package/types/mk-kit-ui-chip.d.ts +59 -0
  50. package/types/mk-kit-ui-context-menu.d.ts +57 -0
  51. package/types/mk-kit-ui-core.d.ts +1105 -0
  52. package/types/mk-kit-ui-data.d.ts +2580 -0
  53. package/types/mk-kit-ui-datetime.d.ts +1171 -0
  54. package/types/mk-kit-ui-directives.d.ts +807 -0
  55. package/types/mk-kit-ui-dnd.d.ts +423 -0
  56. package/types/mk-kit-ui-feedback.d.ts +1270 -0
  57. package/types/mk-kit-ui-forms.d.ts +3586 -0
  58. package/types/mk-kit-ui-icon.d.ts +108 -0
  59. package/types/mk-kit-ui-media.d.ts +549 -0
  60. package/types/mk-kit-ui-navigation.d.ts +1169 -0
  61. package/types/mk-kit-ui-rich-text.d.ts +187 -0
  62. package/types/mk-kit-ui-table.d.ts +739 -0
  63. package/types/mk-kit-ui.d.ts +17 -0
@@ -0,0 +1,896 @@
1
+ import * as i0 from '@angular/core';
2
+ import { inject, input, booleanAttribute, output, computed, linkedSignal, ChangeDetectionStrategy, Component, signal, Injectable, numberAttribute, PLATFORM_ID, viewChild, model, effect, untracked, afterNextRender, TemplateRef, Directive, contentChild } from '@angular/core';
3
+ import { MK_I18N, MkOverlayRef, MK_OVERLAY_DATA, MkOverlayService } from '@mk-kit/ui/core';
4
+ import { isPlatformBrowser, NgTemplateOutlet } from '@angular/common';
5
+ import { mkMoveItemInArray, MkDropList, MkDrag } from '@mk-kit/ui/dnd';
6
+
7
+ /**
8
+ * Image — an enhanced image block. While the picture loads it shows a skeleton
9
+ * shimmer, and when loading fails it swaps to a localised fallback panel
10
+ * (icon + text, never colour alone). The picture is wrapped in a `<figure>`
11
+ * with an optional `<figcaption>`, and changing `src` restarts the whole load
12
+ * cycle.
13
+ *
14
+ * ```html
15
+ * <mk-image
16
+ * src="/photos/harbour.jpg"
17
+ * alt="Harbour at dawn"
18
+ * aspectRatio="16 / 9"
19
+ * caption="The old harbour, photographed at dawn"
20
+ * (loaded)="onLoaded()"
21
+ * (errored)="onErrored()"
22
+ * />
23
+ *
24
+ * <!-- The hero: eager, high priority, and sized per breakpoint so a phone
25
+ * does not download the desktop file. -->
26
+ * <mk-image
27
+ * src="/photos/hero-1600.jpg"
28
+ * srcset="/photos/hero-800.jpg 800w, /photos/hero-1600.jpg 1600w"
29
+ * sizes="100vw"
30
+ * alt=""
31
+ * aspectRatio="16 / 9"
32
+ * priority
33
+ * />
34
+ * ```
35
+ *
36
+ * `aspectRatio` is what keeps this from shifting layout: the frame reserves
37
+ * its box before the picture arrives, so nothing below it moves.
38
+ */
39
+ class MkImage {
40
+ /** Localised strings (override globally via `provideMkI18n`). */
41
+ i18n = inject(MK_I18N);
42
+ /** Image URL. Changing it resets the block to its loading state. */
43
+ src = input.required(/* @ts-ignore */
44
+ ...(ngDevMode ? [{ debugName: "src" }] : /* istanbul ignore next */ []));
45
+ /** Alt text, always reflected onto the `<img>`. Keep `''` for decorative images. */
46
+ alt = input('', /* @ts-ignore */
47
+ ...(ngDevMode ? [{ debugName: "alt" }] : /* istanbul ignore next */ []));
48
+ /** Any CSS `aspect-ratio` value for the frame, e.g. `'1 / 1'` or `'16 / 9'`. */
49
+ aspectRatio = input('auto', /* @ts-ignore */
50
+ ...(ngDevMode ? [{ debugName: "aspectRatio" }] : /* istanbul ignore next */ []));
51
+ /** How the picture fills the frame. */
52
+ fit = input('cover', /* @ts-ignore */
53
+ ...(ngDevMode ? [{ debugName: "fit" }] : /* istanbul ignore next */ []));
54
+ /** Corner rounding of the frame. */
55
+ rounded = input('md', /* @ts-ignore */
56
+ ...(ngDevMode ? [{ debugName: "rounded" }] : /* istanbul ignore next */ []));
57
+ /** Caption rendered in a `<figcaption>` below the picture. */
58
+ caption = input('', /* @ts-ignore */
59
+ ...(ngDevMode ? [{ debugName: "caption" }] : /* istanbul ignore next */ []));
60
+ /** Defer fetching until near the viewport (`loading="lazy"` vs `"eager"`). */
61
+ lazy = input(true, { ...(ngDevMode ? { debugName: "lazy" } : /* istanbul ignore next */ {}), transform: booleanAttribute });
62
+ /**
63
+ * Mark this as the page's LCP image: fetched eagerly at high priority and
64
+ * decoded synchronously, and `lazy` is ignored. Use it on exactly ONE image
65
+ * per page — the hero. Marking several is worse than marking none, because
66
+ * they then compete for the same bandwidth the real LCP image needs.
67
+ *
68
+ * This does NOT emit a `<link rel=preload>`; the tag has to be in the HTML
69
+ * the server sends to beat the preload scanner, which a component rendered
70
+ * later cannot guarantee. Pair it with a preload hint for a background or
71
+ * late-rendered hero.
72
+ */
73
+ priority = input(false, { ...(ngDevMode ? { debugName: "priority" } : /* istanbul ignore next */ {}), transform: booleanAttribute });
74
+ /**
75
+ * Responsive candidates, passed through verbatim to the `<img>`. Serving a
76
+ * 2048px file into a 600px slot is one of the most common LCP mistakes, and
77
+ * this is the fix; pair with {@link sizes}.
78
+ */
79
+ srcset = input('', /* @ts-ignore */
80
+ ...(ngDevMode ? [{ debugName: "srcset" }] : /* istanbul ignore next */ []));
81
+ /** `sizes` attribute — how wide the image renders at each breakpoint. */
82
+ sizes = input('', /* @ts-ignore */
83
+ ...(ngDevMode ? [{ debugName: "sizes" }] : /* istanbul ignore next */ []));
84
+ /** Emits once the picture has finished loading. */
85
+ loaded = output();
86
+ /** Emits when the picture fails to load and the fallback panel is shown. */
87
+ errored = output();
88
+ /** `loading` attribute: priority always wins over `lazy`. */
89
+ loadingAttr = computed(() => this.priority() ? 'eager' : this.lazy() ? 'lazy' : 'eager', /* @ts-ignore */
90
+ ...(ngDevMode ? [{ debugName: "loadingAttr" }] : /* istanbul ignore next */ []));
91
+ /** Load lifecycle; snaps back to `'loading'` whenever `src` changes. */
92
+ state = linkedSignal({ ...(ngDevMode ? { debugName: "state" } : /* istanbul ignore next */ {}), source: this.src,
93
+ computation: () => 'loading' });
94
+ onLoad() {
95
+ this.state.set('loaded');
96
+ this.loaded.emit();
97
+ }
98
+ onError() {
99
+ this.state.set('error');
100
+ this.errored.emit();
101
+ }
102
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.7", ngImport: i0, type: MkImage, deps: [], target: i0.ɵɵFactoryTarget.Component });
103
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.0.7", type: MkImage, isStandalone: true, selector: "mk-image", inputs: { src: { classPropertyName: "src", publicName: "src", isSignal: true, isRequired: true, transformFunction: null }, alt: { classPropertyName: "alt", publicName: "alt", isSignal: true, isRequired: false, transformFunction: null }, aspectRatio: { classPropertyName: "aspectRatio", publicName: "aspectRatio", isSignal: true, isRequired: false, transformFunction: null }, fit: { classPropertyName: "fit", publicName: "fit", isSignal: true, isRequired: false, transformFunction: null }, rounded: { classPropertyName: "rounded", publicName: "rounded", isSignal: true, isRequired: false, transformFunction: null }, caption: { classPropertyName: "caption", publicName: "caption", isSignal: true, isRequired: false, transformFunction: null }, lazy: { classPropertyName: "lazy", publicName: "lazy", isSignal: true, isRequired: false, transformFunction: null }, priority: { classPropertyName: "priority", publicName: "priority", isSignal: true, isRequired: false, transformFunction: null }, srcset: { classPropertyName: "srcset", publicName: "srcset", isSignal: true, isRequired: false, transformFunction: null }, sizes: { classPropertyName: "sizes", publicName: "sizes", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { loaded: "loaded", errored: "errored" }, host: { properties: { "attr.data-fit": "fit()", "attr.data-rounded": "rounded()", "attr.data-state": "state()" }, classAttribute: "mk-image" }, exportAs: ["mkImage"], ngImport: i0, template: "<figure class=\"mk-image__figure\">\n <div class=\"mk-image__frame\" [style.aspect-ratio]=\"aspectRatio()\">\n @if (state() !== 'error') {\n <img\n class=\"mk-image__img\"\n [src]=\"src()\"\n [alt]=\"alt()\"\n [attr.srcset]=\"srcset() || null\"\n [attr.sizes]=\"sizes() || null\"\n [attr.loading]=\"loadingAttr()\"\n [attr.fetchpriority]=\"priority() ? 'high' : null\"\n [attr.decoding]=\"priority() ? 'sync' : 'async'\"\n (load)=\"onLoad()\"\n (error)=\"onError()\"\n />\n @if (state() === 'loading') {\n <span class=\"mk-image__skeleton\" aria-hidden=\"true\"></span>\n }\n } @else {\n <div class=\"mk-image__fallback\">\n <svg\n class=\"mk-image__fallback-icon\"\n viewBox=\"0 0 24 24\"\n fill=\"none\"\n stroke=\"currentColor\"\n stroke-width=\"1.5\"\n stroke-linecap=\"round\"\n stroke-linejoin=\"round\"\n aria-hidden=\"true\"\n >\n <rect x=\"3\" y=\"4\" width=\"18\" height=\"16\" rx=\"2\" />\n <circle cx=\"9\" cy=\"10\" r=\"1.5\" />\n <path d=\"m5 17 4-4 3 3 3.5-3.5L20 17\" />\n <path d=\"m4 3 16 18\" />\n </svg>\n <span class=\"mk-image__fallback-text\">{{ i18n.imageFailed }}</span>\n </div>\n }\n </div>\n @if (caption()) {\n <figcaption class=\"mk-image__caption\">{{ caption() }}</figcaption>\n }\n</figure>\n", styles: ["@charset \"UTF-8\";:host{--_radius: var(--mk-radius-md);display:block;min-width:0;font-family:var(--mk-font-sans)}:host([data-rounded=none]){--_radius: var(--mk-radius-none)}:host([data-rounded=lg]){--_radius: var(--mk-radius-lg)}:host([data-rounded=circle]){--_radius: var(--mk-radius-circle)}.mk-image__figure{display:flex;flex-direction:column;gap:var(--mk-space-2);margin:0;min-width:0}.mk-image__frame{position:relative;width:100%;overflow:hidden;border-radius:var(--_radius);background-color:var(--mk-surface-2)}.mk-image__img{display:block;width:100%;height:100%;object-fit:cover}:host([data-fit=contain]) .mk-image__img{object-fit:contain}:host([data-state=loading]) .mk-image__img{opacity:0}.mk-image__skeleton{position:absolute;inset:0;overflow:hidden;background-color:var(--mk-skeleton-base)}.mk-image__skeleton:after{content:\"\";position:absolute;inset:0;width:200%;background-image:linear-gradient(90deg,var(--mk-skeleton-base) 0%,var(--mk-skeleton-shine) 50%,var(--mk-skeleton-base) 100%);animation:mk-image-shimmer 1.4s ease-in-out infinite}@keyframes mk-image-shimmer{0%{transform:translate(-50%)}to{transform:translate(50%)}}.mk-image__fallback{box-sizing:border-box;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:var(--mk-space-2);width:100%;height:100%;min-height:var(--mk-control-height-lg);padding:var(--mk-space-4);background-color:var(--mk-surface-2);border:var(--mk-border-width) dashed var(--mk-border);border-radius:var(--_radius);color:var(--mk-text-muted);text-align:center}.mk-image__fallback-icon{flex:none;width:var(--mk-space-6);height:var(--mk-space-6)}.mk-image__fallback-text{font-size:var(--mk-font-size-sm);line-height:var(--mk-line-height-snug)}.mk-image__caption{font-size:var(--mk-font-size-sm);line-height:var(--mk-line-height-snug);color:var(--mk-text-muted)}@media(prefers-reduced-motion:reduce){.mk-image__skeleton:after{content:none;animation:none}}\n"], changeDetection: i0.ChangeDetectionStrategy.OnPush });
104
+ }
105
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.7", ngImport: i0, type: MkImage, decorators: [{
106
+ type: Component,
107
+ args: [{ selector: 'mk-image', exportAs: 'mkImage', changeDetection: ChangeDetectionStrategy.OnPush, host: {
108
+ class: 'mk-image',
109
+ '[attr.data-fit]': 'fit()',
110
+ '[attr.data-rounded]': 'rounded()',
111
+ '[attr.data-state]': 'state()',
112
+ }, template: "<figure class=\"mk-image__figure\">\n <div class=\"mk-image__frame\" [style.aspect-ratio]=\"aspectRatio()\">\n @if (state() !== 'error') {\n <img\n class=\"mk-image__img\"\n [src]=\"src()\"\n [alt]=\"alt()\"\n [attr.srcset]=\"srcset() || null\"\n [attr.sizes]=\"sizes() || null\"\n [attr.loading]=\"loadingAttr()\"\n [attr.fetchpriority]=\"priority() ? 'high' : null\"\n [attr.decoding]=\"priority() ? 'sync' : 'async'\"\n (load)=\"onLoad()\"\n (error)=\"onError()\"\n />\n @if (state() === 'loading') {\n <span class=\"mk-image__skeleton\" aria-hidden=\"true\"></span>\n }\n } @else {\n <div class=\"mk-image__fallback\">\n <svg\n class=\"mk-image__fallback-icon\"\n viewBox=\"0 0 24 24\"\n fill=\"none\"\n stroke=\"currentColor\"\n stroke-width=\"1.5\"\n stroke-linecap=\"round\"\n stroke-linejoin=\"round\"\n aria-hidden=\"true\"\n >\n <rect x=\"3\" y=\"4\" width=\"18\" height=\"16\" rx=\"2\" />\n <circle cx=\"9\" cy=\"10\" r=\"1.5\" />\n <path d=\"m5 17 4-4 3 3 3.5-3.5L20 17\" />\n <path d=\"m4 3 16 18\" />\n </svg>\n <span class=\"mk-image__fallback-text\">{{ i18n.imageFailed }}</span>\n </div>\n }\n </div>\n @if (caption()) {\n <figcaption class=\"mk-image__caption\">{{ caption() }}</figcaption>\n }\n</figure>\n", styles: ["@charset \"UTF-8\";:host{--_radius: var(--mk-radius-md);display:block;min-width:0;font-family:var(--mk-font-sans)}:host([data-rounded=none]){--_radius: var(--mk-radius-none)}:host([data-rounded=lg]){--_radius: var(--mk-radius-lg)}:host([data-rounded=circle]){--_radius: var(--mk-radius-circle)}.mk-image__figure{display:flex;flex-direction:column;gap:var(--mk-space-2);margin:0;min-width:0}.mk-image__frame{position:relative;width:100%;overflow:hidden;border-radius:var(--_radius);background-color:var(--mk-surface-2)}.mk-image__img{display:block;width:100%;height:100%;object-fit:cover}:host([data-fit=contain]) .mk-image__img{object-fit:contain}:host([data-state=loading]) .mk-image__img{opacity:0}.mk-image__skeleton{position:absolute;inset:0;overflow:hidden;background-color:var(--mk-skeleton-base)}.mk-image__skeleton:after{content:\"\";position:absolute;inset:0;width:200%;background-image:linear-gradient(90deg,var(--mk-skeleton-base) 0%,var(--mk-skeleton-shine) 50%,var(--mk-skeleton-base) 100%);animation:mk-image-shimmer 1.4s ease-in-out infinite}@keyframes mk-image-shimmer{0%{transform:translate(-50%)}to{transform:translate(50%)}}.mk-image__fallback{box-sizing:border-box;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:var(--mk-space-2);width:100%;height:100%;min-height:var(--mk-control-height-lg);padding:var(--mk-space-4);background-color:var(--mk-surface-2);border:var(--mk-border-width) dashed var(--mk-border);border-radius:var(--_radius);color:var(--mk-text-muted);text-align:center}.mk-image__fallback-icon{flex:none;width:var(--mk-space-6);height:var(--mk-space-6)}.mk-image__fallback-text{font-size:var(--mk-font-size-sm);line-height:var(--mk-line-height-snug)}.mk-image__caption{font-size:var(--mk-font-size-sm);line-height:var(--mk-line-height-snug);color:var(--mk-text-muted)}@media(prefers-reduced-motion:reduce){.mk-image__skeleton:after{content:none;animation:none}}\n"] }]
113
+ }], propDecorators: { src: [{ type: i0.Input, args: [{ isSignal: true, alias: "src", required: true }] }], alt: [{ type: i0.Input, args: [{ isSignal: true, alias: "alt", required: false }] }], aspectRatio: [{ type: i0.Input, args: [{ isSignal: true, alias: "aspectRatio", required: false }] }], fit: [{ type: i0.Input, args: [{ isSignal: true, alias: "fit", required: false }] }], rounded: [{ type: i0.Input, args: [{ isSignal: true, alias: "rounded", required: false }] }], caption: [{ type: i0.Input, args: [{ isSignal: true, alias: "caption", required: false }] }], lazy: [{ type: i0.Input, args: [{ isSignal: true, alias: "lazy", required: false }] }], priority: [{ type: i0.Input, args: [{ isSignal: true, alias: "priority", required: false }] }], srcset: [{ type: i0.Input, args: [{ isSignal: true, alias: "srcset", required: false }] }], sizes: [{ type: i0.Input, args: [{ isSignal: true, alias: "sizes", required: false }] }], loaded: [{ type: i0.Output, args: ["loaded"] }], errored: [{ type: i0.Output, args: ["errored"] }] } });
114
+
115
+ /**
116
+ * Internal overlay component backing {@link MkLightboxService}. Renders the
117
+ * current image near-fullscreen over the overlay scrim with a caption bar,
118
+ * an `imageOf` counter, a close button and looping previous/next navigation.
119
+ *
120
+ * Keyboard: `ArrowLeft`/`ArrowRight` navigate, `Home`/`End` jump to the first
121
+ * or last image. `Escape` and backdrop clicks are handled by the overlay
122
+ * itself (`closeOnEscape` / `closeOnBackdropClick`), so they are deliberately
123
+ * not re-handled here.
124
+ *
125
+ * Open it via the service — not by placing `<mk-lightbox>` in a template:
126
+ *
127
+ * ```ts
128
+ * inject(MkLightboxService).open(items, 2);
129
+ * ```
130
+ */
131
+ class MkLightbox {
132
+ ref = inject(MkOverlayRef);
133
+ data = inject(MK_OVERLAY_DATA);
134
+ /** Localised strings (override globally via `provideMkI18n`). */
135
+ i18n = inject(MK_I18N);
136
+ /** The images being paged through. */
137
+ items = this.data?.items ?? [];
138
+ /** Total number of images. */
139
+ total = this.items.length;
140
+ /** Index of the currently displayed image. */
141
+ index = signal(Math.min(Math.max(this.data?.startIndex ?? 0, 0), Math.max(this.total - 1, 0)), /* @ts-ignore */
142
+ ...(ngDevMode ? [{ debugName: "index" }] : /* istanbul ignore next */ []));
143
+ /** The currently displayed item. */
144
+ current = computed(() => this.items[this.index()], /* @ts-ignore */
145
+ ...(ngDevMode ? [{ debugName: "current" }] : /* istanbul ignore next */ []));
146
+ /** Localised "Image i of n" counter text. */
147
+ counter = computed(() => this.i18n.imageOf(this.index() + 1, this.total), /* @ts-ignore */
148
+ ...(ngDevMode ? [{ debugName: "counter" }] : /* istanbul ignore next */ []));
149
+ /** `true` while the current image is still loading. */
150
+ loading = signal(true, /* @ts-ignore */
151
+ ...(ngDevMode ? [{ debugName: "loading" }] : /* istanbul ignore next */ []));
152
+ /** `true` when the current image failed to load. */
153
+ failed = signal(false, /* @ts-ignore */
154
+ ...(ngDevMode ? [{ debugName: "failed" }] : /* istanbul ignore next */ []));
155
+ /** Show the previous image, wrapping from the first to the last. */
156
+ previous() {
157
+ this.goTo((this.index() - 1 + this.total) % this.total);
158
+ }
159
+ /** Show the next image, wrapping from the last back to the first. */
160
+ next() {
161
+ this.goTo((this.index() + 1) % this.total);
162
+ }
163
+ /** Jump to `index`, resetting the loading/error state. */
164
+ goTo(index) {
165
+ if (this.total === 0 || index === this.index())
166
+ return;
167
+ this.index.set(index);
168
+ this.loading.set(true);
169
+ this.failed.set(false);
170
+ }
171
+ /** Close the surrounding overlay. */
172
+ close() {
173
+ this.ref.close();
174
+ }
175
+ /** Current image finished loading — hide the spinner. */
176
+ onLoad() {
177
+ this.loading.set(false);
178
+ }
179
+ /** Current image failed — show the localised failure message instead. */
180
+ onError() {
181
+ this.loading.set(false);
182
+ this.failed.set(true);
183
+ }
184
+ /**
185
+ * Host keydown handler. Escape is intentionally not handled here — the
186
+ * overlay's `closeOnEscape` already closes the lightbox.
187
+ */
188
+ onKeydown(event) {
189
+ const key = event.key;
190
+ switch (key) {
191
+ case 'ArrowLeft':
192
+ event.preventDefault();
193
+ this.previous();
194
+ break;
195
+ case 'ArrowRight':
196
+ event.preventDefault();
197
+ this.next();
198
+ break;
199
+ case 'Home':
200
+ event.preventDefault();
201
+ this.goTo(0);
202
+ break;
203
+ case 'End':
204
+ event.preventDefault();
205
+ this.goTo(this.total - 1);
206
+ break;
207
+ }
208
+ }
209
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.7", ngImport: i0, type: MkLightbox, deps: [], target: i0.ɵɵFactoryTarget.Component });
210
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.0.7", type: MkLightbox, isStandalone: true, selector: "mk-lightbox", host: { listeners: { "keydown": "onKeydown($event)" }, properties: { "class.mk-lightbox": "true" } }, ngImport: i0, template: "@if (current(); as item) {\n <figure class=\"mk-lightbox__figure\">\n <div class=\"mk-lightbox__stage\">\n @if (failed()) {\n <p class=\"mk-lightbox__error\" role=\"alert\">{{ i18n.imageFailed }}</p>\n } @else {\n @if (loading()) {\n <span class=\"mk-lightbox__spinner\" aria-hidden=\"true\"></span>\n }\n <img\n class=\"mk-lightbox__image\"\n [class.mk-lightbox__image--loading]=\"loading()\"\n [src]=\"item.src\"\n [alt]=\"item.alt ?? ''\"\n (load)=\"onLoad()\"\n (error)=\"onError()\"\n />\n }\n </div>\n @if (item.caption) {\n <figcaption class=\"mk-lightbox__caption\">{{ item.caption }}</figcaption>\n }\n <p class=\"mk-lightbox__counter\" aria-live=\"polite\">{{ counter() }}</p>\n </figure>\n}\n\n<button\n type=\"button\"\n class=\"mk-lightbox__button mk-lightbox__close\"\n [attr.aria-label]=\"i18n.close\"\n (click)=\"close()\"\n>\n <svg\n viewBox=\"0 0 24 24\"\n fill=\"none\"\n stroke=\"currentColor\"\n stroke-width=\"2\"\n stroke-linecap=\"round\"\n aria-hidden=\"true\"\n >\n <path d=\"M6 6l12 12M18 6L6 18\" />\n </svg>\n</button>\n\n@if (total > 1) {\n <button\n type=\"button\"\n class=\"mk-lightbox__button mk-lightbox__nav mk-lightbox__nav--prev\"\n [attr.aria-label]=\"i18n.previousImage\"\n (click)=\"previous()\"\n >\n <svg\n viewBox=\"0 0 24 24\"\n fill=\"none\"\n stroke=\"currentColor\"\n stroke-width=\"2\"\n stroke-linecap=\"round\"\n stroke-linejoin=\"round\"\n aria-hidden=\"true\"\n >\n <path d=\"M15 5l-7 7 7 7\" />\n </svg>\n </button>\n <button\n type=\"button\"\n class=\"mk-lightbox__button mk-lightbox__nav mk-lightbox__nav--next\"\n [attr.aria-label]=\"i18n.nextImage\"\n (click)=\"next()\"\n >\n <svg\n viewBox=\"0 0 24 24\"\n fill=\"none\"\n stroke=\"currentColor\"\n stroke-width=\"2\"\n stroke-linecap=\"round\"\n stroke-linejoin=\"round\"\n aria-hidden=\"true\"\n >\n <path d=\"M9 5l7 7-7 7\" />\n </svg>\n </button>\n}\n", styles: [":host{--_chrome-bg: color-mix(in srgb, var(--mk-surface-inverse) 60%, transparent);--_chrome-bg-hover: color-mix( in srgb, var(--mk-surface-inverse) 80%, transparent );--_chrome-fg: var(--mk-text-inverse);display:flex;align-items:center;justify-content:center;width:100vw;height:100dvh;font-family:var(--mk-font-sans)}.mk-lightbox__figure{display:flex;flex-direction:column;align-items:center;gap:var(--mk-space-3);margin:0;max-width:90vw}.mk-lightbox__stage{position:relative;display:grid;place-items:center;min-width:4rem;min-height:4rem}.mk-lightbox__image{display:block;max-width:90vw;max-height:85vh;object-fit:contain;border-radius:var(--mk-radius-md);box-shadow:var(--mk-shadow-xl);transition:opacity var(--mk-duration-normal) var(--mk-ease-out)}.mk-lightbox__image--loading{opacity:0}.mk-lightbox__spinner{position:absolute;inset:0;margin:auto;width:2rem;height:2rem;border:3px solid color-mix(in srgb,var(--mk-surface-inverse) 45%,transparent);border-top-color:var(--_chrome-fg);border-radius:var(--mk-radius-circle);animation:mk-lightbox-spin var(--mk-duration-slow) linear infinite}.mk-lightbox__error{margin:0;padding:var(--mk-space-3) var(--mk-space-5);border-radius:var(--mk-radius-lg);background:var(--_chrome-bg);color:var(--_chrome-fg);font-size:var(--mk-font-size-sm);line-height:var(--mk-line-height-snug)}.mk-lightbox__caption{max-width:90vw;padding:var(--mk-space-2) var(--mk-space-4);border-radius:var(--mk-radius-pill);background:var(--_chrome-bg);color:var(--_chrome-fg);font-size:var(--mk-font-size-sm);line-height:var(--mk-line-height-snug);text-align:center}.mk-lightbox__counter{margin:0;padding:var(--mk-space-1) var(--mk-space-3);border-radius:var(--mk-radius-pill);background:var(--_chrome-bg);color:var(--_chrome-fg);font-size:var(--mk-font-size-xs);font-variant-numeric:tabular-nums}.mk-lightbox__button{position:absolute;display:grid;place-items:center;width:2.75rem;height:2.75rem;padding:0;border:0;border-radius:var(--mk-radius-circle);background:var(--_chrome-bg);color:var(--_chrome-fg);cursor:pointer;transition:background-color var(--mk-duration-fast) var(--mk-ease-standard),scale var(--mk-duration-fast) var(--mk-ease-standard)}.mk-lightbox__button:hover{background:var(--_chrome-bg-hover)}.mk-lightbox__button:active{scale:.94}.mk-lightbox__button:focus-visible{outline:var(--mk-focus-ring-width) solid var(--mk-focus-ring);outline-offset:var(--mk-focus-ring-offset)}.mk-lightbox__button svg{width:1.25rem;height:1.25rem}.mk-lightbox__close{top:var(--mk-space-4);right:var(--mk-space-4)}.mk-lightbox__nav{top:50%;translate:0 -50%}.mk-lightbox__nav--prev{left:var(--mk-space-4)}.mk-lightbox__nav--next{right:var(--mk-space-4)}@keyframes mk-lightbox-spin{to{transform:rotate(360deg)}}@media(prefers-reduced-motion:reduce){.mk-lightbox__image,.mk-lightbox__button{transition:none}.mk-lightbox__spinner{animation:none}}\n"], changeDetection: i0.ChangeDetectionStrategy.OnPush });
211
+ }
212
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.7", ngImport: i0, type: MkLightbox, decorators: [{
213
+ type: Component,
214
+ args: [{ selector: 'mk-lightbox', changeDetection: ChangeDetectionStrategy.OnPush, host: {
215
+ // Bound (not static) so it merges with the overlay's own panel classes —
216
+ // a static host `class` would overwrite `mk-overlay-panel` on the host.
217
+ '[class.mk-lightbox]': 'true',
218
+ '(keydown)': 'onKeydown($event)',
219
+ }, template: "@if (current(); as item) {\n <figure class=\"mk-lightbox__figure\">\n <div class=\"mk-lightbox__stage\">\n @if (failed()) {\n <p class=\"mk-lightbox__error\" role=\"alert\">{{ i18n.imageFailed }}</p>\n } @else {\n @if (loading()) {\n <span class=\"mk-lightbox__spinner\" aria-hidden=\"true\"></span>\n }\n <img\n class=\"mk-lightbox__image\"\n [class.mk-lightbox__image--loading]=\"loading()\"\n [src]=\"item.src\"\n [alt]=\"item.alt ?? ''\"\n (load)=\"onLoad()\"\n (error)=\"onError()\"\n />\n }\n </div>\n @if (item.caption) {\n <figcaption class=\"mk-lightbox__caption\">{{ item.caption }}</figcaption>\n }\n <p class=\"mk-lightbox__counter\" aria-live=\"polite\">{{ counter() }}</p>\n </figure>\n}\n\n<button\n type=\"button\"\n class=\"mk-lightbox__button mk-lightbox__close\"\n [attr.aria-label]=\"i18n.close\"\n (click)=\"close()\"\n>\n <svg\n viewBox=\"0 0 24 24\"\n fill=\"none\"\n stroke=\"currentColor\"\n stroke-width=\"2\"\n stroke-linecap=\"round\"\n aria-hidden=\"true\"\n >\n <path d=\"M6 6l12 12M18 6L6 18\" />\n </svg>\n</button>\n\n@if (total > 1) {\n <button\n type=\"button\"\n class=\"mk-lightbox__button mk-lightbox__nav mk-lightbox__nav--prev\"\n [attr.aria-label]=\"i18n.previousImage\"\n (click)=\"previous()\"\n >\n <svg\n viewBox=\"0 0 24 24\"\n fill=\"none\"\n stroke=\"currentColor\"\n stroke-width=\"2\"\n stroke-linecap=\"round\"\n stroke-linejoin=\"round\"\n aria-hidden=\"true\"\n >\n <path d=\"M15 5l-7 7 7 7\" />\n </svg>\n </button>\n <button\n type=\"button\"\n class=\"mk-lightbox__button mk-lightbox__nav mk-lightbox__nav--next\"\n [attr.aria-label]=\"i18n.nextImage\"\n (click)=\"next()\"\n >\n <svg\n viewBox=\"0 0 24 24\"\n fill=\"none\"\n stroke=\"currentColor\"\n stroke-width=\"2\"\n stroke-linecap=\"round\"\n stroke-linejoin=\"round\"\n aria-hidden=\"true\"\n >\n <path d=\"M9 5l7 7-7 7\" />\n </svg>\n </button>\n}\n", styles: [":host{--_chrome-bg: color-mix(in srgb, var(--mk-surface-inverse) 60%, transparent);--_chrome-bg-hover: color-mix( in srgb, var(--mk-surface-inverse) 80%, transparent );--_chrome-fg: var(--mk-text-inverse);display:flex;align-items:center;justify-content:center;width:100vw;height:100dvh;font-family:var(--mk-font-sans)}.mk-lightbox__figure{display:flex;flex-direction:column;align-items:center;gap:var(--mk-space-3);margin:0;max-width:90vw}.mk-lightbox__stage{position:relative;display:grid;place-items:center;min-width:4rem;min-height:4rem}.mk-lightbox__image{display:block;max-width:90vw;max-height:85vh;object-fit:contain;border-radius:var(--mk-radius-md);box-shadow:var(--mk-shadow-xl);transition:opacity var(--mk-duration-normal) var(--mk-ease-out)}.mk-lightbox__image--loading{opacity:0}.mk-lightbox__spinner{position:absolute;inset:0;margin:auto;width:2rem;height:2rem;border:3px solid color-mix(in srgb,var(--mk-surface-inverse) 45%,transparent);border-top-color:var(--_chrome-fg);border-radius:var(--mk-radius-circle);animation:mk-lightbox-spin var(--mk-duration-slow) linear infinite}.mk-lightbox__error{margin:0;padding:var(--mk-space-3) var(--mk-space-5);border-radius:var(--mk-radius-lg);background:var(--_chrome-bg);color:var(--_chrome-fg);font-size:var(--mk-font-size-sm);line-height:var(--mk-line-height-snug)}.mk-lightbox__caption{max-width:90vw;padding:var(--mk-space-2) var(--mk-space-4);border-radius:var(--mk-radius-pill);background:var(--_chrome-bg);color:var(--_chrome-fg);font-size:var(--mk-font-size-sm);line-height:var(--mk-line-height-snug);text-align:center}.mk-lightbox__counter{margin:0;padding:var(--mk-space-1) var(--mk-space-3);border-radius:var(--mk-radius-pill);background:var(--_chrome-bg);color:var(--_chrome-fg);font-size:var(--mk-font-size-xs);font-variant-numeric:tabular-nums}.mk-lightbox__button{position:absolute;display:grid;place-items:center;width:2.75rem;height:2.75rem;padding:0;border:0;border-radius:var(--mk-radius-circle);background:var(--_chrome-bg);color:var(--_chrome-fg);cursor:pointer;transition:background-color var(--mk-duration-fast) var(--mk-ease-standard),scale var(--mk-duration-fast) var(--mk-ease-standard)}.mk-lightbox__button:hover{background:var(--_chrome-bg-hover)}.mk-lightbox__button:active{scale:.94}.mk-lightbox__button:focus-visible{outline:var(--mk-focus-ring-width) solid var(--mk-focus-ring);outline-offset:var(--mk-focus-ring-offset)}.mk-lightbox__button svg{width:1.25rem;height:1.25rem}.mk-lightbox__close{top:var(--mk-space-4);right:var(--mk-space-4)}.mk-lightbox__nav{top:50%;translate:0 -50%}.mk-lightbox__nav--prev{left:var(--mk-space-4)}.mk-lightbox__nav--next{right:var(--mk-space-4)}@keyframes mk-lightbox-spin{to{transform:rotate(360deg)}}@media(prefers-reduced-motion:reduce){.mk-lightbox__image,.mk-lightbox__button{transition:none}.mk-lightbox__spinner{animation:none}}\n"] }]
220
+ }] });
221
+
222
+ /**
223
+ * Fullscreen image lightbox — opens a set of images in a modal overlay with
224
+ * looping previous/next navigation, captions, a counter, keyboard support
225
+ * (Arrow keys, Home/End, Escape) and focus trapping, all powered by
226
+ * {@link MkOverlayService}.
227
+ *
228
+ * ```ts
229
+ * private readonly lightbox = inject(MkLightboxService);
230
+ *
231
+ * viewPhoto(index: number): void {
232
+ * this.lightbox.open(
233
+ * [
234
+ * { src: '/photos/1.jpg', alt: 'Harbour at dawn', caption: 'Dawn' },
235
+ * { src: '/photos/2.jpg', alt: 'Old town square' },
236
+ * ],
237
+ * index,
238
+ * );
239
+ * }
240
+ * ```
241
+ */
242
+ class MkLightboxService {
243
+ overlay = inject(MkOverlayService);
244
+ i18n = inject(MK_I18N);
245
+ /**
246
+ * Open the lightbox over the page. Does nothing when `items` is empty.
247
+ * `startIndex` is clamped into `[0, items.length - 1]`.
248
+ */
249
+ open(items, startIndex = 0) {
250
+ if (items.length === 0)
251
+ return;
252
+ const start = Math.min(Math.max(Math.trunc(startIndex), 0), items.length - 1);
253
+ this.overlay.open(MkLightbox, {
254
+ data: { items, startIndex: start },
255
+ hasBackdrop: true,
256
+ closeOnBackdropClick: true,
257
+ closeOnEscape: true,
258
+ trapFocus: true,
259
+ role: 'dialog',
260
+ ariaLabel: this.i18n.imageOf(start + 1, items.length),
261
+ panelClass: 'mk-lightbox-panel',
262
+ });
263
+ }
264
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.7", ngImport: i0, type: MkLightboxService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
265
+ static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "22.0.7", ngImport: i0, type: MkLightboxService, providedIn: 'root' });
266
+ }
267
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.7", ngImport: i0, type: MkLightboxService, decorators: [{
268
+ type: Injectable,
269
+ args: [{ providedIn: 'root' }]
270
+ }] });
271
+
272
+ /**
273
+ * Image gallery — a wall of clickable picture tiles built on {@link MkImage}.
274
+ * Choose a uniform `grid`, a natural-ratio `masonry` flow, or a snap-scrolling
275
+ * horizontal `strip`. Clicking a tile opens the shared {@link MkLightboxService}
276
+ * at that picture (full-size `src`, not the thumb) and always emits
277
+ * `itemClick`. With `max` set, only the first `max` tiles render and the last
278
+ * one carries a "+N" overflow scrim that opens the rest in the lightbox.
279
+ *
280
+ * ```html
281
+ * <mk-image-gallery
282
+ * [items]="[
283
+ * { src: '/photos/1.jpg', thumb: '/thumbs/1.jpg', alt: 'Harbour at dawn' },
284
+ * { src: '/photos/2.jpg', alt: 'Old town square', caption: 'Kraków' },
285
+ * ]"
286
+ * [columns]="4"
287
+ * [max]="8"
288
+ * aspectRatio="4 / 3"
289
+ * (itemClick)="onItemClick($event)"
290
+ * />
291
+ * ```
292
+ */
293
+ class MkImageGallery {
294
+ lightboxService = inject(MkLightboxService);
295
+ /** Localised strings (override globally via `provideMkI18n`). */
296
+ i18n = inject(MK_I18N);
297
+ /** Pictures to show. */
298
+ items = input.required(/* @ts-ignore */
299
+ ...(ngDevMode ? [{ debugName: "items" }] : /* istanbul ignore next */ []));
300
+ /** Tile arrangement. */
301
+ layout = input('grid', /* @ts-ignore */
302
+ ...(ngDevMode ? [{ debugName: "layout" }] : /* istanbul ignore next */ []));
303
+ /** Grid/masonry column count; also sets the visible tile count of a strip. */
304
+ columns = input(3, { ...(ngDevMode ? { debugName: "columns" } : /* istanbul ignore next */ {}), transform: numberAttribute });
305
+ /** When set, show only the first `max` tiles plus a "+N" overflow tile. */
306
+ max = input(null, /* @ts-ignore */
307
+ ...(ngDevMode ? [{ debugName: "max" }] : /* istanbul ignore next */ []));
308
+ /** Open the shared lightbox on tile click. */
309
+ lightbox = input(true, { ...(ngDevMode ? { debugName: "lightbox" } : /* istanbul ignore next */ {}), transform: booleanAttribute });
310
+ /** CSS aspect-ratio of grid/strip tiles (masonry keeps natural ratios). */
311
+ aspectRatio = input('1 / 1', /* @ts-ignore */
312
+ ...(ngDevMode ? [{ debugName: "aspectRatio" }] : /* istanbul ignore next */ []));
313
+ /** Corner rounding of the tiles. */
314
+ rounded = input('md', /* @ts-ignore */
315
+ ...(ngDevMode ? [{ debugName: "rounded" }] : /* istanbul ignore next */ []));
316
+ /** Emits on every tile click (also when the lightbox opens). */
317
+ itemClick = output();
318
+ /** Tiles actually rendered — the first `max` when `max` is set. */
319
+ visibleItems = computed(() => {
320
+ const items = this.items();
321
+ const max = this.max();
322
+ return max != null && items.length > max
323
+ ? items.slice(0, Math.max(0, max))
324
+ : items;
325
+ }, /* @ts-ignore */
326
+ ...(ngDevMode ? [{ debugName: "visibleItems" }] : /* istanbul ignore next */ []));
327
+ /** How many pictures the trailing "+N" tile stands for (0 = no overflow). */
328
+ overflowCount = computed(() => this.items().length - this.visibleItems().length, /* @ts-ignore */
329
+ ...(ngDevMode ? [{ debugName: "overflowCount" }] : /* istanbul ignore next */ []));
330
+ /** Frame ratio forwarded to the tiles (`'auto'` in masonry). */
331
+ tileAspectRatio = computed(() => this.layout() === 'masonry' ? 'auto' : this.aspectRatio(), /* @ts-ignore */
332
+ ...(ngDevMode ? [{ debugName: "tileAspectRatio" }] : /* istanbul ignore next */ []));
333
+ /**
334
+ * Accessible label of tile `index`: `viewImage(alt)` when the picture has
335
+ * alt text, else positional `imageOf(...)`; the overflow tile appends "+N".
336
+ */
337
+ tileLabel(index) {
338
+ const items = this.items();
339
+ const alt = items[index]?.alt;
340
+ const base = alt
341
+ ? this.i18n.viewImage(alt)
342
+ : this.i18n.imageOf(index + 1, items.length);
343
+ const overflow = this.overflowCount();
344
+ const isOverflowTile = overflow > 0 && index === this.visibleItems().length - 1;
345
+ return isOverflowTile ? `${base} (+${overflow})` : base;
346
+ }
347
+ onTileClick(index) {
348
+ const items = this.items();
349
+ if (this.lightbox()) {
350
+ const lightboxItems = items.map(({ src, alt, caption }) => ({ src, alt, caption }));
351
+ this.lightboxService.open(lightboxItems, index);
352
+ }
353
+ this.itemClick.emit({ item: items[index], index });
354
+ }
355
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.7", ngImport: i0, type: MkImageGallery, deps: [], target: i0.ɵɵFactoryTarget.Component });
356
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.0.7", type: MkImageGallery, isStandalone: true, selector: "mk-image-gallery", inputs: { items: { classPropertyName: "items", publicName: "items", isSignal: true, isRequired: true, transformFunction: null }, layout: { classPropertyName: "layout", publicName: "layout", isSignal: true, isRequired: false, transformFunction: null }, columns: { classPropertyName: "columns", publicName: "columns", isSignal: true, isRequired: false, transformFunction: null }, max: { classPropertyName: "max", publicName: "max", isSignal: true, isRequired: false, transformFunction: null }, lightbox: { classPropertyName: "lightbox", publicName: "lightbox", isSignal: true, isRequired: false, transformFunction: null }, aspectRatio: { classPropertyName: "aspectRatio", publicName: "aspectRatio", isSignal: true, isRequired: false, transformFunction: null }, rounded: { classPropertyName: "rounded", publicName: "rounded", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { itemClick: "itemClick" }, host: { properties: { "attr.data-layout": "layout()", "attr.data-rounded": "rounded()", "style.--_cols": "columns()" }, classAttribute: "mk-image-gallery" }, exportAs: ["mkImageGallery"], ngImport: i0, template: "@for (item of visibleItems(); track $index; let i = $index; let last = $last) {\n <button\n type=\"button\"\n class=\"mk-gallery__tile\"\n [attr.aria-label]=\"tileLabel(i)\"\n (click)=\"onTileClick(i)\"\n >\n <mk-image\n [src]=\"item.thumb ?? item.src\"\n [aspectRatio]=\"tileAspectRatio()\"\n [rounded]=\"rounded()\"\n />\n @if (last && overflowCount() > 0) {\n <span class=\"mk-gallery__more\" aria-hidden=\"true\"\n >+{{ overflowCount() }}</span\n >\n }\n </button>\n}\n", styles: [":host{--_cols: 3;--_gap: var(--mk-space-2);--_radius: var(--mk-radius-md);display:grid;grid-template-columns:repeat(var(--_cols),1fr);gap:var(--_gap);min-width:0;font-family:var(--mk-font-sans)}:host([data-rounded=none]){--_radius: var(--mk-radius-none)}:host([data-rounded=lg]){--_radius: var(--mk-radius-lg)}:host([data-rounded=circle]){--_radius: var(--mk-radius-circle)}:host([data-layout=masonry]){display:block;columns:var(--_cols);column-gap:var(--_gap)}:host([data-layout=masonry]) .mk-gallery__tile{width:100%;break-inside:avoid;margin-block-end:var(--_gap)}:host([data-layout=strip]){display:flex;gap:var(--_gap);overflow-x:auto;scroll-snap-type:x mandatory;padding-block-end:var(--mk-space-1)}:host([data-layout=strip]) .mk-gallery__tile{flex:0 0 calc((100% - (var(--_cols) - 1) * var(--_gap)) / var(--_cols));scroll-snap-align:start}.mk-gallery__tile{position:relative;display:block;min-width:0;margin:0;padding:0;border:0;background:none;color:inherit;font:inherit;border-radius:var(--_radius);cursor:pointer}.mk-gallery__tile:focus-visible{outline:var(--mk-focus-ring-width) solid var(--mk-focus-ring);outline-offset:var(--mk-focus-ring-offset)}.mk-gallery__tile:after{content:\"\";position:absolute;inset:0;border-radius:var(--_radius);background-color:transparent;transition:background-color var(--mk-duration-fast) var(--mk-ease-standard);pointer-events:none}.mk-gallery__tile:hover:after{background-color:var(--mk-hover-overlay)}.mk-gallery__tile:active:after{background-color:var(--mk-active-overlay)}.mk-gallery__more{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;border-radius:var(--_radius);background-color:var(--mk-overlay-scrim);color:var(--mk-primary-contrast);font-size:var(--mk-font-size-xl);font-weight:var(--mk-font-weight-semibold);pointer-events:none}@media(prefers-reduced-motion:reduce){.mk-gallery__tile:after{transition:none}}\n"], dependencies: [{ kind: "component", type: MkImage, selector: "mk-image", inputs: ["src", "alt", "aspectRatio", "fit", "rounded", "caption", "lazy", "priority", "srcset", "sizes"], outputs: ["loaded", "errored"], exportAs: ["mkImage"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
357
+ }
358
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.7", ngImport: i0, type: MkImageGallery, decorators: [{
359
+ type: Component,
360
+ args: [{ selector: 'mk-image-gallery', exportAs: 'mkImageGallery', changeDetection: ChangeDetectionStrategy.OnPush, imports: [MkImage], host: {
361
+ class: 'mk-image-gallery',
362
+ '[attr.data-layout]': 'layout()',
363
+ '[attr.data-rounded]': 'rounded()',
364
+ '[style.--_cols]': 'columns()',
365
+ }, template: "@for (item of visibleItems(); track $index; let i = $index; let last = $last) {\n <button\n type=\"button\"\n class=\"mk-gallery__tile\"\n [attr.aria-label]=\"tileLabel(i)\"\n (click)=\"onTileClick(i)\"\n >\n <mk-image\n [src]=\"item.thumb ?? item.src\"\n [aspectRatio]=\"tileAspectRatio()\"\n [rounded]=\"rounded()\"\n />\n @if (last && overflowCount() > 0) {\n <span class=\"mk-gallery__more\" aria-hidden=\"true\"\n >+{{ overflowCount() }}</span\n >\n }\n </button>\n}\n", styles: [":host{--_cols: 3;--_gap: var(--mk-space-2);--_radius: var(--mk-radius-md);display:grid;grid-template-columns:repeat(var(--_cols),1fr);gap:var(--_gap);min-width:0;font-family:var(--mk-font-sans)}:host([data-rounded=none]){--_radius: var(--mk-radius-none)}:host([data-rounded=lg]){--_radius: var(--mk-radius-lg)}:host([data-rounded=circle]){--_radius: var(--mk-radius-circle)}:host([data-layout=masonry]){display:block;columns:var(--_cols);column-gap:var(--_gap)}:host([data-layout=masonry]) .mk-gallery__tile{width:100%;break-inside:avoid;margin-block-end:var(--_gap)}:host([data-layout=strip]){display:flex;gap:var(--_gap);overflow-x:auto;scroll-snap-type:x mandatory;padding-block-end:var(--mk-space-1)}:host([data-layout=strip]) .mk-gallery__tile{flex:0 0 calc((100% - (var(--_cols) - 1) * var(--_gap)) / var(--_cols));scroll-snap-align:start}.mk-gallery__tile{position:relative;display:block;min-width:0;margin:0;padding:0;border:0;background:none;color:inherit;font:inherit;border-radius:var(--_radius);cursor:pointer}.mk-gallery__tile:focus-visible{outline:var(--mk-focus-ring-width) solid var(--mk-focus-ring);outline-offset:var(--mk-focus-ring-offset)}.mk-gallery__tile:after{content:\"\";position:absolute;inset:0;border-radius:var(--_radius);background-color:transparent;transition:background-color var(--mk-duration-fast) var(--mk-ease-standard);pointer-events:none}.mk-gallery__tile:hover:after{background-color:var(--mk-hover-overlay)}.mk-gallery__tile:active:after{background-color:var(--mk-active-overlay)}.mk-gallery__more{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;border-radius:var(--_radius);background-color:var(--mk-overlay-scrim);color:var(--mk-primary-contrast);font-size:var(--mk-font-size-xl);font-weight:var(--mk-font-weight-semibold);pointer-events:none}@media(prefers-reduced-motion:reduce){.mk-gallery__tile:after{transition:none}}\n"] }]
366
+ }], propDecorators: { items: [{ type: i0.Input, args: [{ isSignal: true, alias: "items", required: true }] }], layout: [{ type: i0.Input, args: [{ isSignal: true, alias: "layout", required: false }] }], columns: [{ type: i0.Input, args: [{ isSignal: true, alias: "columns", required: false }] }], max: [{ type: i0.Input, args: [{ isSignal: true, alias: "max", required: false }] }], lightbox: [{ type: i0.Input, args: [{ isSignal: true, alias: "lightbox", required: false }] }], aspectRatio: [{ type: i0.Input, args: [{ isSignal: true, alias: "aspectRatio", required: false }] }], rounded: [{ type: i0.Input, args: [{ isSignal: true, alias: "rounded", required: false }] }], itemClick: [{ type: i0.Output, args: ["itemClick"] }] } });
367
+
368
+ // --- Pure crop geometry -----------------------------------------------------
369
+ // jsdom has no canvas and never loads images, so everything numeric lives in
370
+ // these exported functions where it can be unit-tested directly.
371
+ /**
372
+ * Base cover scale for an image in a viewport — the smallest scale at which
373
+ * `imgW × imgH` fully covers `vpW × vpH` (object-fit: cover). Returns `1`
374
+ * for degenerate (non-positive) dimensions.
375
+ */
376
+ function mkCoverScale(imgW, imgH, vpW, vpH) {
377
+ if (imgW <= 0 || imgH <= 0 || vpW <= 0 || vpH <= 0)
378
+ return 1;
379
+ return Math.max(vpW / imgW, vpH / imgH);
380
+ }
381
+ /**
382
+ * Clamp a pan offset (CSS px, measured from the viewport centre to the image
383
+ * centre) so the image scaled by `scale` still covers the viewport — no empty
384
+ * edges. When the scaled image is not larger than the viewport on an axis the
385
+ * offset collapses to `0` (centred).
386
+ */
387
+ function mkClampPan(x, y, imgW, imgH, vpW, vpH, scale) {
388
+ const maxX = Math.max(0, (imgW * scale - vpW) / 2);
389
+ const maxY = Math.max(0, (imgH * scale - vpH) / 2);
390
+ // `+ 0` normalises the -0 produced when an axis has no slack.
391
+ return {
392
+ x: Math.min(maxX, Math.max(-maxX, x)) + 0,
393
+ y: Math.min(maxY, Math.max(-maxY, y)) + 0,
394
+ };
395
+ }
396
+ /**
397
+ * Source rect (in natural image pixels) of the crop region visible through
398
+ * the viewport. `scale` maps natural pixels to CSS pixels (cover scale ×
399
+ * zoom); `panX`/`panY` are the CSS-px offsets of the image centre from the
400
+ * viewport centre. The rect is clamped inside the image bounds.
401
+ */
402
+ function mkCropRect(imgNaturalW, imgNaturalH, vpW, vpH, scale, panX, panY) {
403
+ if (imgNaturalW <= 0 || imgNaturalH <= 0 || vpW <= 0 || vpH <= 0 || scale <= 0) {
404
+ return { sx: 0, sy: 0, sw: Math.max(0, imgNaturalW), sh: Math.max(0, imgNaturalH) };
405
+ }
406
+ const sw = Math.min(vpW / scale, imgNaturalW);
407
+ const sh = Math.min(vpH / scale, imgNaturalH);
408
+ // The image point rendered at the viewport centre.
409
+ const cx = imgNaturalW / 2 - panX / scale;
410
+ const cy = imgNaturalH / 2 - panY / scale;
411
+ const sx = Math.min(Math.max(cx - sw / 2, 0), imgNaturalW - sw);
412
+ const sy = Math.min(Math.max(cy - sh / 2, 0), imgNaturalH - sh);
413
+ return { sx, sy, sw, sh };
414
+ }
415
+ /** Zoom step used by the −/+ buttons. */
416
+ const BUTTON_ZOOM_STEP = 0.25;
417
+ /** Zoom step used by the `+`/`-` keys. */
418
+ const KEY_ZOOM_STEP = 0.1;
419
+ /**
420
+ * ImageCropper — pan-and-zoom crop control for avatars, covers and media uploads.
421
+ * The image covers a fixed-aspect viewport (object-fit-cover style); the user
422
+ * drags to pan, scrolls / pinch-zooms via the slider or `+`/`−` controls, and
423
+ * `crop()` renders the visible region to a data-URL on an offscreen canvas.
424
+ *
425
+ * Pan is always clamped so the image keeps covering the viewport, a
426
+ * rule-of-thirds grid appears while dragging, and `round` overlays a circular
427
+ * scrim for avatar crops (the exported bitmap stays rectangular). The crop
428
+ * area is keyboard operable: Arrow keys pan by 10px (1px with Shift), `+`/`-`
429
+ * zoom.
430
+ *
431
+ * ```html
432
+ * <mk-image-cropper #cropper src="/assets/photo.jpg" [aspect]="1" round />
433
+ * <button mkButton (click)="avatar = cropper.crop({ size: 512 })">Save</button>
434
+ * ```
435
+ */
436
+ class MkImageCropper {
437
+ isBrowser = isPlatformBrowser(inject(PLATFORM_ID));
438
+ /** Localised strings (override globally via `provideMkI18n`). */
439
+ i18n = inject(MK_I18N);
440
+ viewportRef = viewChild.required('viewport', /* @ts-ignore */
441
+ ...(ngDevMode ? [{ debugName: "viewportRef" }] : /* istanbul ignore next */ []));
442
+ imageRef = viewChild('image', /* @ts-ignore */
443
+ ...(ngDevMode ? [{ debugName: "imageRef" }] : /* istanbul ignore next */ []));
444
+ /** Image URL or data-URL to crop. */
445
+ src = input.required(/* @ts-ignore */
446
+ ...(ngDevMode ? [{ debugName: "src" }] : /* istanbul ignore next */ []));
447
+ /**
448
+ * Aspect ratio (width / height) of the crop viewport, e.g. `1` for a
449
+ * square or `16 / 9`. `null` makes the viewport fill its container.
450
+ */
451
+ aspect = input(1, /* @ts-ignore */
452
+ ...(ngDevMode ? [{ debugName: "aspect" }] : /* istanbul ignore next */ []));
453
+ /**
454
+ * Circular mask overlay for avatar crops. Purely visual — the exported
455
+ * bitmap from `crop()` stays rectangular.
456
+ */
457
+ round = input(false, { ...(ngDevMode ? { debugName: "round" } : /* istanbul ignore next */ {}), transform: booleanAttribute });
458
+ /** Lowest allowed zoom factor. */
459
+ minZoom = input(1, { ...(ngDevMode ? { debugName: "minZoom" } : /* istanbul ignore next */ {}), transform: numberAttribute });
460
+ /** Highest allowed zoom factor. */
461
+ maxZoom = input(4, { ...(ngDevMode ? { debugName: "maxZoom" } : /* istanbul ignore next */ {}), transform: numberAttribute });
462
+ /** Disable panning and zooming. */
463
+ disabled = input(false, { ...(ngDevMode ? { debugName: "disabled" } : /* istanbul ignore next */ {}), transform: booleanAttribute });
464
+ /**
465
+ * `crossorigin` for the internal `<img>`. Set to `'anonymous'` when
466
+ * cropping CORS-enabled remote images — without it the canvas is tainted
467
+ * and `crop()` returns `null`. Same-origin and data-URL sources don't
468
+ * need it.
469
+ */
470
+ crossOrigin = input(null, /* @ts-ignore */
471
+ ...(ngDevMode ? [{ debugName: "crossOrigin" }] : /* istanbul ignore next */ []));
472
+ /** Accessible name for the crop area (defaults to the i18n `zoom` label). */
473
+ ariaLabel = input(null, /* @ts-ignore */
474
+ ...(ngDevMode ? [{ debugName: "ariaLabel" }] : /* istanbul ignore next */ []));
475
+ /** Emits on any pan or zoom change. */
476
+ changed = output();
477
+ /** Emits when the image fails to load. */
478
+ imageError = output();
479
+ /** Two-way zoom factor, clamped to `[minZoom, maxZoom]`. */
480
+ zoom = model(1, /* @ts-ignore */
481
+ ...(ngDevMode ? [{ debugName: "zoom" }] : /* istanbul ignore next */ []));
482
+ natW = signal(0, /* @ts-ignore */
483
+ ...(ngDevMode ? [{ debugName: "natW" }] : /* istanbul ignore next */ []));
484
+ natH = signal(0, /* @ts-ignore */
485
+ ...(ngDevMode ? [{ debugName: "natH" }] : /* istanbul ignore next */ []));
486
+ vpW = signal(0, /* @ts-ignore */
487
+ ...(ngDevMode ? [{ debugName: "vpW" }] : /* istanbul ignore next */ []));
488
+ vpH = signal(0, /* @ts-ignore */
489
+ ...(ngDevMode ? [{ debugName: "vpH" }] : /* istanbul ignore next */ []));
490
+ panX = signal(0, /* @ts-ignore */
491
+ ...(ngDevMode ? [{ debugName: "panX" }] : /* istanbul ignore next */ []));
492
+ panY = signal(0, /* @ts-ignore */
493
+ ...(ngDevMode ? [{ debugName: "panY" }] : /* istanbul ignore next */ []));
494
+ status = signal('loading', /* @ts-ignore */
495
+ ...(ngDevMode ? [{ debugName: "status" }] : /* istanbul ignore next */ []));
496
+ dragging = signal(false, /* @ts-ignore */
497
+ ...(ngDevMode ? [{ debugName: "dragging" }] : /* istanbul ignore next */ []));
498
+ dragStart = null;
499
+ resizeObserver;
500
+ coverScale = computed(() => mkCoverScale(this.natW(), this.natH(), this.vpW(), this.vpH()), /* @ts-ignore */
501
+ ...(ngDevMode ? [{ debugName: "coverScale" }] : /* istanbul ignore next */ []));
502
+ totalScale = computed(() => this.coverScale() * this.zoom(), /* @ts-ignore */
503
+ ...(ngDevMode ? [{ debugName: "totalScale" }] : /* istanbul ignore next */ []));
504
+ /** CSS transform applied to the image element. */
505
+ imageTransform = computed(() => `translate(calc(-50% + ${this.panX()}px), calc(-50% + ${this.panY()}px)) ` +
506
+ `scale(${this.totalScale()})`, /* @ts-ignore */
507
+ ...(ngDevMode ? [{ debugName: "imageTransform" }] : /* istanbul ignore next */ []));
508
+ constructor() {
509
+ // Restart from scratch whenever the source changes.
510
+ effect(() => {
511
+ this.src();
512
+ untracked(() => {
513
+ this.status.set('loading');
514
+ this.natW.set(0);
515
+ this.natH.set(0);
516
+ this.reset();
517
+ });
518
+ });
519
+ // Keep an externally-written zoom model inside [minZoom, maxZoom].
520
+ effect(() => {
521
+ const clamped = this.clampZoom(this.zoom());
522
+ if (clamped !== this.zoom())
523
+ this.zoom.set(clamped);
524
+ });
525
+ // Re-clamp pan when zoom, image or viewport geometry changes.
526
+ effect(() => {
527
+ const scale = this.totalScale();
528
+ const [imgW, imgH, vpW, vpH] = [this.natW(), this.natH(), this.vpW(), this.vpH()];
529
+ untracked(() => {
530
+ const p = mkClampPan(this.panX(), this.panY(), imgW, imgH, vpW, vpH, scale);
531
+ this.panX.set(p.x);
532
+ this.panY.set(p.y);
533
+ });
534
+ });
535
+ afterNextRender(() => {
536
+ this.measure();
537
+ const viewport = this.viewportRef().nativeElement;
538
+ // Angular's (wheel) binding may register passively; zooming must be
539
+ // able to preventDefault, so attach by hand.
540
+ viewport.addEventListener('wheel', this.onWheel, { passive: false });
541
+ if ('ResizeObserver' in window) {
542
+ this.resizeObserver = new ResizeObserver(() => this.measure());
543
+ this.resizeObserver.observe(viewport);
544
+ }
545
+ });
546
+ }
547
+ ngOnDestroy() {
548
+ this.resizeObserver?.disconnect();
549
+ if (this.isBrowser) {
550
+ this.viewportRef().nativeElement.removeEventListener('wheel', this.onWheel);
551
+ }
552
+ }
553
+ // --- Public API -----------------------------------------------------------
554
+ /** Centre the image and reset zoom to 1 (clamped to `minZoom`). */
555
+ reset() {
556
+ const zoom = this.clampZoom(1);
557
+ const dirty = this.zoom() !== zoom || this.panX() !== 0 || this.panY() !== 0;
558
+ this.zoom.set(zoom);
559
+ this.panX.set(0);
560
+ this.panY.set(0);
561
+ if (dirty)
562
+ this.changed.emit();
563
+ }
564
+ /**
565
+ * Draw the visible crop region to an offscreen canvas and return it as a
566
+ * data-URL (longest edge capped at `options.size ?? 1024`). Returns `null`
567
+ * when the image is not loaded or the canvas is unavailable (SSR, jsdom).
568
+ */
569
+ /**
570
+ * The visible crop region in natural image pixels — what `crop()` would
571
+ * rasterise, without touching a canvas. Use it when the backend performs
572
+ * the actual crop from the master image (no client-side quality loss).
573
+ * Returns `null` until the image has loaded and the viewport is measured.
574
+ */
575
+ cropRect() {
576
+ if (this.status() !== 'loaded')
577
+ return null;
578
+ const [natW, natH, vpW, vpH] = [this.natW(), this.natH(), this.vpW(), this.vpH()];
579
+ if (!natW || !natH || !vpW || !vpH)
580
+ return null;
581
+ return mkCropRect(natW, natH, vpW, vpH, this.totalScale(), this.panX(), this.panY());
582
+ }
583
+ crop(options) {
584
+ const image = this.imageRef()?.nativeElement;
585
+ if (!this.isBrowser || this.status() !== 'loaded' || !image)
586
+ return null;
587
+ const [natW, natH, vpW, vpH] = [this.natW(), this.natH(), this.vpW(), this.vpH()];
588
+ if (!natW || !natH || !vpW || !vpH)
589
+ return null;
590
+ const { sx, sy, sw, sh } = mkCropRect(natW, natH, vpW, vpH, this.totalScale(), this.panX(), this.panY());
591
+ const cap = options?.size ?? 1024;
592
+ const factor = Math.min(1, cap / Math.max(sw, sh));
593
+ try {
594
+ const canvas = document.createElement('canvas');
595
+ canvas.width = Math.max(1, Math.round(sw * factor));
596
+ canvas.height = Math.max(1, Math.round(sh * factor));
597
+ const ctx = canvas.getContext('2d');
598
+ if (!ctx)
599
+ return null;
600
+ ctx.drawImage(image, sx, sy, sw, sh, 0, 0, canvas.width, canvas.height);
601
+ return canvas.toDataURL(options?.type ?? 'image/png', options?.quality) || null;
602
+ }
603
+ catch {
604
+ // Environments without canvas rasterisation (e.g. jsdom).
605
+ return null;
606
+ }
607
+ }
608
+ // --- Image lifecycle ------------------------------------------------------
609
+ onImageLoad(event) {
610
+ const image = event.target;
611
+ this.natW.set(image.naturalWidth);
612
+ this.natH.set(image.naturalHeight);
613
+ this.status.set('loaded');
614
+ this.reset();
615
+ }
616
+ onImageError() {
617
+ this.status.set('error');
618
+ this.imageError.emit();
619
+ }
620
+ // --- Pointer panning ------------------------------------------------------
621
+ onPointerDown(event) {
622
+ if (this.disabled())
623
+ return;
624
+ try {
625
+ this.viewportRef().nativeElement.setPointerCapture(event.pointerId);
626
+ }
627
+ catch {
628
+ // jsdom / detached element.
629
+ }
630
+ this.dragStart = {
631
+ x: event.clientX,
632
+ y: event.clientY,
633
+ panX: this.panX(),
634
+ panY: this.panY(),
635
+ };
636
+ this.dragging.set(true);
637
+ event.preventDefault();
638
+ }
639
+ onPointerMove(event) {
640
+ const start = this.dragStart;
641
+ if (!start)
642
+ return;
643
+ this.panTo(start.panX + event.clientX - start.x, start.panY + event.clientY - start.y);
644
+ }
645
+ onPointerUp() {
646
+ this.dragStart = null;
647
+ this.dragging.set(false);
648
+ }
649
+ // --- Zooming --------------------------------------------------------------
650
+ onWheel = (event) => {
651
+ if (this.disabled())
652
+ return;
653
+ event.preventDefault();
654
+ // Multiplicative steps feel uniform across the whole zoom range.
655
+ this.zoomTo(this.zoom() * Math.exp(-event.deltaY * 0.0015));
656
+ };
657
+ onRangeInput(event) {
658
+ const value = Number(event.target.value);
659
+ if (!Number.isNaN(value))
660
+ this.zoomTo(value);
661
+ }
662
+ stepZoom(direction) {
663
+ this.zoomTo(this.zoom() + direction * BUTTON_ZOOM_STEP);
664
+ }
665
+ /**
666
+ * Zoom towards the viewport centre: the pan offset scales with the zoom
667
+ * ratio so the image point at the centre stays put, then re-clamps.
668
+ */
669
+ zoomTo(next) {
670
+ const clamped = this.clampZoom(next);
671
+ const previous = this.zoom();
672
+ if (clamped === previous)
673
+ return;
674
+ const ratio = clamped / previous;
675
+ this.zoom.set(clamped);
676
+ const p = mkClampPan(this.panX() * ratio, this.panY() * ratio, this.natW(), this.natH(), this.vpW(), this.vpH(), this.coverScale() * clamped);
677
+ this.panX.set(p.x);
678
+ this.panY.set(p.y);
679
+ this.changed.emit();
680
+ }
681
+ // --- Keyboard -------------------------------------------------------------
682
+ onKeydown(event) {
683
+ if (this.disabled())
684
+ return;
685
+ const step = event.shiftKey ? 1 : 10;
686
+ switch (event.key) {
687
+ case 'ArrowLeft':
688
+ this.panTo(this.panX() - step, this.panY());
689
+ break;
690
+ case 'ArrowRight':
691
+ this.panTo(this.panX() + step, this.panY());
692
+ break;
693
+ case 'ArrowUp':
694
+ this.panTo(this.panX(), this.panY() - step);
695
+ break;
696
+ case 'ArrowDown':
697
+ this.panTo(this.panX(), this.panY() + step);
698
+ break;
699
+ case '+':
700
+ case '=':
701
+ this.zoomTo(this.zoom() + KEY_ZOOM_STEP);
702
+ break;
703
+ case '-':
704
+ case '_':
705
+ this.zoomTo(this.zoom() - KEY_ZOOM_STEP);
706
+ break;
707
+ default:
708
+ return;
709
+ }
710
+ event.preventDefault();
711
+ }
712
+ // --- Internals ------------------------------------------------------------
713
+ clampZoom(value) {
714
+ return Math.min(this.maxZoom(), Math.max(this.minZoom(), value));
715
+ }
716
+ panTo(x, y) {
717
+ const p = mkClampPan(x, y, this.natW(), this.natH(), this.vpW(), this.vpH(), this.totalScale());
718
+ if (p.x === this.panX() && p.y === this.panY())
719
+ return;
720
+ this.panX.set(p.x);
721
+ this.panY.set(p.y);
722
+ this.changed.emit();
723
+ }
724
+ /** Capture the viewport's CSS size (initial render + every resize). */
725
+ measure() {
726
+ if (!this.isBrowser)
727
+ return;
728
+ const rect = this.viewportRef().nativeElement.getBoundingClientRect();
729
+ this.vpW.set(rect.width);
730
+ this.vpH.set(rect.height);
731
+ }
732
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.7", ngImport: i0, type: MkImageCropper, deps: [], target: i0.ɵɵFactoryTarget.Component });
733
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.0.7", type: MkImageCropper, isStandalone: true, selector: "mk-image-cropper", inputs: { src: { classPropertyName: "src", publicName: "src", isSignal: true, isRequired: true, transformFunction: null }, aspect: { classPropertyName: "aspect", publicName: "aspect", isSignal: true, isRequired: false, transformFunction: null }, round: { classPropertyName: "round", publicName: "round", isSignal: true, isRequired: false, transformFunction: null }, minZoom: { classPropertyName: "minZoom", publicName: "minZoom", isSignal: true, isRequired: false, transformFunction: null }, maxZoom: { classPropertyName: "maxZoom", publicName: "maxZoom", isSignal: true, isRequired: false, transformFunction: null }, disabled: { classPropertyName: "disabled", publicName: "disabled", isSignal: true, isRequired: false, transformFunction: null }, crossOrigin: { classPropertyName: "crossOrigin", publicName: "crossOrigin", isSignal: true, isRequired: false, transformFunction: null }, ariaLabel: { classPropertyName: "ariaLabel", publicName: "ariaLabel", isSignal: true, isRequired: false, transformFunction: null }, zoom: { classPropertyName: "zoom", publicName: "zoom", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { changed: "changed", imageError: "imageError", zoom: "zoomChange" }, host: { properties: { "class.mk-image-cropper--disabled": "disabled()", "class.mk-image-cropper--round": "round()" }, classAttribute: "mk-image-cropper" }, viewQueries: [{ propertyName: "viewportRef", first: true, predicate: ["viewport"], descendants: true, isSignal: true }, { propertyName: "imageRef", first: true, predicate: ["image"], descendants: true, isSignal: true }], exportAs: ["mkImageCropper"], ngImport: i0, template: "<div\n #viewport\n class=\"mk-image-cropper__viewport\"\n role=\"application\"\n [attr.tabindex]=\"disabled() ? -1 : 0\"\n [attr.aria-label]=\"ariaLabel() || i18n.zoom\"\n [attr.aria-disabled]=\"disabled() || null\"\n [class.mk-image-cropper__viewport--fill]=\"aspect() === null\"\n [class.mk-image-cropper__viewport--dragging]=\"dragging()\"\n [style.aspect-ratio]=\"aspect()\"\n (pointerdown)=\"onPointerDown($event)\"\n (pointermove)=\"onPointerMove($event)\"\n (pointerup)=\"onPointerUp()\"\n (pointercancel)=\"onPointerUp()\"\n (keydown)=\"onKeydown($event)\"\n>\n @if (status() === 'error') {\n <div class=\"mk-image-cropper__error\">{{ i18n.imageFailed }}</div>\n } @else {\n <img\n #image\n class=\"mk-image-cropper__image\"\n [class.mk-image-cropper__image--ready]=\"status() === 'loaded'\"\n [src]=\"src()\"\n alt=\"\"\n draggable=\"false\"\n [attr.crossorigin]=\"crossOrigin()\"\n [style.transform]=\"imageTransform()\"\n (load)=\"onImageLoad($event)\"\n (error)=\"onImageError()\"\n />\n <div class=\"mk-image-cropper__grid\" aria-hidden=\"true\"></div>\n @if (round()) {\n <div class=\"mk-image-cropper__mask\" aria-hidden=\"true\"></div>\n }\n }\n</div>\n\n<div class=\"mk-image-cropper__controls\">\n <button\n type=\"button\"\n class=\"mk-image-cropper__zoom-btn\"\n [attr.aria-label]=\"i18n.zoomOut\"\n [disabled]=\"disabled() || zoom() <= minZoom()\"\n (click)=\"stepZoom(-1)\"\n >\n <span aria-hidden=\"true\">&minus;</span>\n </button>\n <input\n type=\"range\"\n class=\"mk-image-cropper__range\"\n step=\"0.01\"\n [min]=\"minZoom()\"\n [max]=\"maxZoom()\"\n [value]=\"zoom()\"\n [disabled]=\"disabled()\"\n [attr.aria-label]=\"i18n.zoom\"\n (input)=\"onRangeInput($event)\"\n />\n <button\n type=\"button\"\n class=\"mk-image-cropper__zoom-btn\"\n [attr.aria-label]=\"i18n.zoomIn\"\n [disabled]=\"disabled() || zoom() >= maxZoom()\"\n (click)=\"stepZoom(1)\"\n >\n <span aria-hidden=\"true\">+</span>\n </button>\n</div>\n", styles: [":host{display:block;width:100%;font-family:var(--mk-font-sans)}.mk-image-cropper__viewport{position:relative;width:100%;overflow:hidden;background-color:var(--mk-surface-2);border:var(--mk-border-width) solid var(--mk-border);border-radius:var(--mk-radius-md);cursor:grab;touch-action:none;-webkit-user-select:none;user-select:none}.mk-image-cropper__viewport--fill{height:100%}.mk-image-cropper__viewport--dragging{cursor:grabbing}.mk-image-cropper__viewport:focus-visible{outline:var(--mk-focus-ring-width) solid var(--mk-focus-ring);outline-offset:var(--mk-focus-ring-offset);border-color:var(--mk-primary)}:host(.mk-image-cropper--disabled) .mk-image-cropper__viewport{cursor:not-allowed;opacity:.7}.mk-image-cropper__image{position:absolute;top:50%;left:50%;width:auto;height:auto;max-width:none;max-height:none;transform-origin:center;will-change:transform;pointer-events:none;-webkit-user-select:none;user-select:none;opacity:0;transition:opacity var(--mk-duration-fast) var(--mk-ease-standard)}.mk-image-cropper__image--ready{opacity:1}.mk-image-cropper__grid{--_grid-line: color-mix(in srgb, var(--mk-surface) 75%, transparent);position:absolute;inset:0;pointer-events:none;background-image:linear-gradient(var(--_grid-line),var(--_grid-line)),linear-gradient(var(--_grid-line),var(--_grid-line)),linear-gradient(var(--_grid-line),var(--_grid-line)),linear-gradient(var(--_grid-line),var(--_grid-line));background-repeat:no-repeat;background-position:33.333% 0,66.667% 0,0 33.333%,0 66.667%;background-size:1px 100%,1px 100%,100% 1px,100% 1px;opacity:0;transition:opacity var(--mk-duration-fast) var(--mk-ease-standard)}.mk-image-cropper__viewport--dragging .mk-image-cropper__grid{opacity:1}.mk-image-cropper__mask{position:absolute;inset:0;pointer-events:none;background:radial-gradient(circle closest-side at 50% 50%,transparent calc(100% - 1px),color-mix(in srgb,var(--mk-bg) 50%,transparent) 100%)}.mk-image-cropper__error{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;padding:var(--mk-space-4);color:var(--mk-text-muted);font-size:var(--mk-font-size-sm);text-align:center}.mk-image-cropper__controls{display:flex;align-items:center;gap:var(--mk-space-3);margin-top:var(--mk-space-3)}.mk-image-cropper__zoom-btn{display:inline-flex;flex:none;align-items:center;justify-content:center;width:var(--mk-control-height-sm);height:var(--mk-control-height-sm);padding:0;font-family:inherit;font-size:var(--mk-font-size-lg);font-weight:var(--mk-font-weight-medium);line-height:1;color:var(--mk-text-muted);background-color:var(--mk-surface);border:var(--mk-border-width) solid var(--mk-border);border-radius:var(--mk-radius-sm);cursor:pointer;transition:color var(--mk-duration-fast) var(--mk-ease-standard),border-color var(--mk-duration-fast) var(--mk-ease-standard),background-color var(--mk-duration-fast) var(--mk-ease-standard)}.mk-image-cropper__zoom-btn:hover:not(:disabled){color:var(--mk-text);background-color:var(--mk-surface-2);border-color:var(--mk-border-strong)}.mk-image-cropper__zoom-btn:focus-visible{outline:var(--mk-focus-ring-width) solid var(--mk-focus-ring);outline-offset:var(--mk-focus-ring-offset)}.mk-image-cropper__zoom-btn:disabled{color:var(--mk-text-disabled);cursor:not-allowed}.mk-image-cropper__range{--_track-h: .4rem;--_thumb: 1.15rem;flex:1 1 auto;min-width:0;height:var(--mk-control-height-sm);margin:0;appearance:none;background:transparent;cursor:pointer}.mk-image-cropper__range::-webkit-slider-runnable-track{height:var(--_track-h);background-color:var(--mk-surface-3);border-radius:var(--mk-radius-pill)}.mk-image-cropper__range::-webkit-slider-thumb{appearance:none;width:var(--_thumb);height:var(--_thumb);margin-top:calc((var(--_track-h) - var(--_thumb)) / 2);background-color:var(--mk-surface);border:var(--mk-border-width-strong) solid var(--mk-primary);border-radius:var(--mk-radius-circle);box-shadow:var(--mk-shadow-sm)}.mk-image-cropper__range::-moz-range-track{height:var(--_track-h);background-color:var(--mk-surface-3);border-radius:var(--mk-radius-pill)}.mk-image-cropper__range::-moz-range-thumb{width:var(--_thumb);height:var(--_thumb);background-color:var(--mk-surface);border:var(--mk-border-width-strong) solid var(--mk-primary);border-radius:var(--mk-radius-circle);box-shadow:var(--mk-shadow-sm)}.mk-image-cropper__range:focus-visible{outline:var(--mk-focus-ring-width) solid var(--mk-focus-ring);outline-offset:var(--mk-focus-ring-offset)}.mk-image-cropper__range:disabled{cursor:not-allowed;opacity:.6}@media(prefers-reduced-motion:reduce){.mk-image-cropper__image,.mk-image-cropper__grid,.mk-image-cropper__zoom-btn{transition:none}}\n"], changeDetection: i0.ChangeDetectionStrategy.OnPush });
734
+ }
735
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.7", ngImport: i0, type: MkImageCropper, decorators: [{
736
+ type: Component,
737
+ args: [{ selector: 'mk-image-cropper', exportAs: 'mkImageCropper', changeDetection: ChangeDetectionStrategy.OnPush, host: {
738
+ class: 'mk-image-cropper',
739
+ '[class.mk-image-cropper--disabled]': 'disabled()',
740
+ '[class.mk-image-cropper--round]': 'round()',
741
+ }, template: "<div\n #viewport\n class=\"mk-image-cropper__viewport\"\n role=\"application\"\n [attr.tabindex]=\"disabled() ? -1 : 0\"\n [attr.aria-label]=\"ariaLabel() || i18n.zoom\"\n [attr.aria-disabled]=\"disabled() || null\"\n [class.mk-image-cropper__viewport--fill]=\"aspect() === null\"\n [class.mk-image-cropper__viewport--dragging]=\"dragging()\"\n [style.aspect-ratio]=\"aspect()\"\n (pointerdown)=\"onPointerDown($event)\"\n (pointermove)=\"onPointerMove($event)\"\n (pointerup)=\"onPointerUp()\"\n (pointercancel)=\"onPointerUp()\"\n (keydown)=\"onKeydown($event)\"\n>\n @if (status() === 'error') {\n <div class=\"mk-image-cropper__error\">{{ i18n.imageFailed }}</div>\n } @else {\n <img\n #image\n class=\"mk-image-cropper__image\"\n [class.mk-image-cropper__image--ready]=\"status() === 'loaded'\"\n [src]=\"src()\"\n alt=\"\"\n draggable=\"false\"\n [attr.crossorigin]=\"crossOrigin()\"\n [style.transform]=\"imageTransform()\"\n (load)=\"onImageLoad($event)\"\n (error)=\"onImageError()\"\n />\n <div class=\"mk-image-cropper__grid\" aria-hidden=\"true\"></div>\n @if (round()) {\n <div class=\"mk-image-cropper__mask\" aria-hidden=\"true\"></div>\n }\n }\n</div>\n\n<div class=\"mk-image-cropper__controls\">\n <button\n type=\"button\"\n class=\"mk-image-cropper__zoom-btn\"\n [attr.aria-label]=\"i18n.zoomOut\"\n [disabled]=\"disabled() || zoom() <= minZoom()\"\n (click)=\"stepZoom(-1)\"\n >\n <span aria-hidden=\"true\">&minus;</span>\n </button>\n <input\n type=\"range\"\n class=\"mk-image-cropper__range\"\n step=\"0.01\"\n [min]=\"minZoom()\"\n [max]=\"maxZoom()\"\n [value]=\"zoom()\"\n [disabled]=\"disabled()\"\n [attr.aria-label]=\"i18n.zoom\"\n (input)=\"onRangeInput($event)\"\n />\n <button\n type=\"button\"\n class=\"mk-image-cropper__zoom-btn\"\n [attr.aria-label]=\"i18n.zoomIn\"\n [disabled]=\"disabled() || zoom() >= maxZoom()\"\n (click)=\"stepZoom(1)\"\n >\n <span aria-hidden=\"true\">+</span>\n </button>\n</div>\n", styles: [":host{display:block;width:100%;font-family:var(--mk-font-sans)}.mk-image-cropper__viewport{position:relative;width:100%;overflow:hidden;background-color:var(--mk-surface-2);border:var(--mk-border-width) solid var(--mk-border);border-radius:var(--mk-radius-md);cursor:grab;touch-action:none;-webkit-user-select:none;user-select:none}.mk-image-cropper__viewport--fill{height:100%}.mk-image-cropper__viewport--dragging{cursor:grabbing}.mk-image-cropper__viewport:focus-visible{outline:var(--mk-focus-ring-width) solid var(--mk-focus-ring);outline-offset:var(--mk-focus-ring-offset);border-color:var(--mk-primary)}:host(.mk-image-cropper--disabled) .mk-image-cropper__viewport{cursor:not-allowed;opacity:.7}.mk-image-cropper__image{position:absolute;top:50%;left:50%;width:auto;height:auto;max-width:none;max-height:none;transform-origin:center;will-change:transform;pointer-events:none;-webkit-user-select:none;user-select:none;opacity:0;transition:opacity var(--mk-duration-fast) var(--mk-ease-standard)}.mk-image-cropper__image--ready{opacity:1}.mk-image-cropper__grid{--_grid-line: color-mix(in srgb, var(--mk-surface) 75%, transparent);position:absolute;inset:0;pointer-events:none;background-image:linear-gradient(var(--_grid-line),var(--_grid-line)),linear-gradient(var(--_grid-line),var(--_grid-line)),linear-gradient(var(--_grid-line),var(--_grid-line)),linear-gradient(var(--_grid-line),var(--_grid-line));background-repeat:no-repeat;background-position:33.333% 0,66.667% 0,0 33.333%,0 66.667%;background-size:1px 100%,1px 100%,100% 1px,100% 1px;opacity:0;transition:opacity var(--mk-duration-fast) var(--mk-ease-standard)}.mk-image-cropper__viewport--dragging .mk-image-cropper__grid{opacity:1}.mk-image-cropper__mask{position:absolute;inset:0;pointer-events:none;background:radial-gradient(circle closest-side at 50% 50%,transparent calc(100% - 1px),color-mix(in srgb,var(--mk-bg) 50%,transparent) 100%)}.mk-image-cropper__error{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;padding:var(--mk-space-4);color:var(--mk-text-muted);font-size:var(--mk-font-size-sm);text-align:center}.mk-image-cropper__controls{display:flex;align-items:center;gap:var(--mk-space-3);margin-top:var(--mk-space-3)}.mk-image-cropper__zoom-btn{display:inline-flex;flex:none;align-items:center;justify-content:center;width:var(--mk-control-height-sm);height:var(--mk-control-height-sm);padding:0;font-family:inherit;font-size:var(--mk-font-size-lg);font-weight:var(--mk-font-weight-medium);line-height:1;color:var(--mk-text-muted);background-color:var(--mk-surface);border:var(--mk-border-width) solid var(--mk-border);border-radius:var(--mk-radius-sm);cursor:pointer;transition:color var(--mk-duration-fast) var(--mk-ease-standard),border-color var(--mk-duration-fast) var(--mk-ease-standard),background-color var(--mk-duration-fast) var(--mk-ease-standard)}.mk-image-cropper__zoom-btn:hover:not(:disabled){color:var(--mk-text);background-color:var(--mk-surface-2);border-color:var(--mk-border-strong)}.mk-image-cropper__zoom-btn:focus-visible{outline:var(--mk-focus-ring-width) solid var(--mk-focus-ring);outline-offset:var(--mk-focus-ring-offset)}.mk-image-cropper__zoom-btn:disabled{color:var(--mk-text-disabled);cursor:not-allowed}.mk-image-cropper__range{--_track-h: .4rem;--_thumb: 1.15rem;flex:1 1 auto;min-width:0;height:var(--mk-control-height-sm);margin:0;appearance:none;background:transparent;cursor:pointer}.mk-image-cropper__range::-webkit-slider-runnable-track{height:var(--_track-h);background-color:var(--mk-surface-3);border-radius:var(--mk-radius-pill)}.mk-image-cropper__range::-webkit-slider-thumb{appearance:none;width:var(--_thumb);height:var(--_thumb);margin-top:calc((var(--_track-h) - var(--_thumb)) / 2);background-color:var(--mk-surface);border:var(--mk-border-width-strong) solid var(--mk-primary);border-radius:var(--mk-radius-circle);box-shadow:var(--mk-shadow-sm)}.mk-image-cropper__range::-moz-range-track{height:var(--_track-h);background-color:var(--mk-surface-3);border-radius:var(--mk-radius-pill)}.mk-image-cropper__range::-moz-range-thumb{width:var(--_thumb);height:var(--_thumb);background-color:var(--mk-surface);border:var(--mk-border-width-strong) solid var(--mk-primary);border-radius:var(--mk-radius-circle);box-shadow:var(--mk-shadow-sm)}.mk-image-cropper__range:focus-visible{outline:var(--mk-focus-ring-width) solid var(--mk-focus-ring);outline-offset:var(--mk-focus-ring-offset)}.mk-image-cropper__range:disabled{cursor:not-allowed;opacity:.6}@media(prefers-reduced-motion:reduce){.mk-image-cropper__image,.mk-image-cropper__grid,.mk-image-cropper__zoom-btn{transition:none}}\n"] }]
742
+ }], ctorParameters: () => [], propDecorators: { viewportRef: [{ type: i0.ViewChild, args: ['viewport', { isSignal: true }] }], imageRef: [{ type: i0.ViewChild, args: ['image', { isSignal: true }] }], src: [{ type: i0.Input, args: [{ isSignal: true, alias: "src", required: true }] }], aspect: [{ type: i0.Input, args: [{ isSignal: true, alias: "aspect", required: false }] }], round: [{ type: i0.Input, args: [{ isSignal: true, alias: "round", required: false }] }], minZoom: [{ type: i0.Input, args: [{ isSignal: true, alias: "minZoom", required: false }] }], maxZoom: [{ type: i0.Input, args: [{ isSignal: true, alias: "maxZoom", required: false }] }], disabled: [{ type: i0.Input, args: [{ isSignal: true, alias: "disabled", required: false }] }], crossOrigin: [{ type: i0.Input, args: [{ isSignal: true, alias: "crossOrigin", required: false }] }], ariaLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "ariaLabel", required: false }] }], changed: [{ type: i0.Output, args: ["changed"] }], imageError: [{ type: i0.Output, args: ["imageError"] }], zoom: [{ type: i0.Input, args: [{ isSignal: true, alias: "zoom", required: false }] }, { type: i0.Output, args: ["zoomChange"] }] } });
743
+
744
+ /**
745
+ * Marks an `<ng-template>` as the per-tile action bar for {@link MkMediaGallery}.
746
+ * The template's implicit context is the {@link MkMediaItem}, so consumers can
747
+ * destructure it with `let-item`. The gallery renders it into each tile's
748
+ * top-right action bar (revealed on hover / focus-within, always visible on
749
+ * touch devices); the consumer supplies the buttons and their handlers.
750
+ *
751
+ * ```html
752
+ * <mk-media-gallery [items]="assets()">
753
+ * <ng-template mkMediaActions let-item>
754
+ * <button mkButton size="sm" variant="ghost" (click)="edit(item)">Edit</button>
755
+ * </ng-template>
756
+ * </mk-media-gallery>
757
+ * ```
758
+ */
759
+ class MkMediaActions {
760
+ /** The projected action-bar template, rendered once per tile. */
761
+ template = inject(TemplateRef);
762
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.7", ngImport: i0, type: MkMediaActions, deps: [], target: i0.ɵɵFactoryTarget.Directive });
763
+ static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "22.0.7", type: MkMediaActions, isStandalone: true, selector: "[mkMediaActions]", ngImport: i0 });
764
+ }
765
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.7", ngImport: i0, type: MkMediaActions, decorators: [{
766
+ type: Directive,
767
+ args: [{
768
+ selector: '[mkMediaActions]',
769
+ }]
770
+ }] });
771
+ /**
772
+ * Media gallery — a management grid of image tiles for admin media libraries.
773
+ * Each tile shows a square thumbnail (a labelled preview button emitting
774
+ * {@link itemClick}), the item's name and optional meta line, an optional
775
+ * selection checkbox (two-way `selection` model of selected ids) and an
776
+ * optional consumer-projected action bar ({@link MkMediaActions}).
777
+ *
778
+ * With `reorderable`, tiles become draggable via the dnd group's
779
+ * `[mkDropList]` / `[mkDrag]` directives — pointer and keyboard dragging
780
+ * (WCAG 2.1.1) both included. Each drop immutably updates the two-way `items`
781
+ * model and emits {@link reordered}.
782
+ *
783
+ * Clicking a tile always means "open/preview" — selection only toggles through
784
+ * the checkbox, never on tile click.
785
+ *
786
+ * ```html
787
+ * <mk-media-gallery
788
+ * [(items)]="assets"
789
+ * [(selection)]="selectedIds"
790
+ * selectable
791
+ * reorderable
792
+ * [columns]="5"
793
+ * (itemClick)="openPreview($event)"
794
+ * (reordered)="persistOrder($event)"
795
+ * >
796
+ * <ng-template mkMediaActions let-item>
797
+ * <button mkButton size="sm" variant="ghost" (click)="edit(item)">Edit</button>
798
+ * <button mkButton size="sm" variant="ghost" tone="danger" (click)="remove(item)">
799
+ * Delete
800
+ * </button>
801
+ * </ng-template>
802
+ * </mk-media-gallery>
803
+ * ```
804
+ */
805
+ class MkMediaGallery {
806
+ /** Localized strings (region label, checkbox/preview labels, empty state). */
807
+ i18n = inject(MK_I18N);
808
+ /** The gallery's items (two-way). Replaced immutably on every reorder drop. */
809
+ items = model([], /* @ts-ignore */
810
+ ...(ngDevMode ? [{ debugName: "items" }] : /* istanbul ignore next */ []));
811
+ /** Ids of the currently selected items (two-way). */
812
+ selection = model([], /* @ts-ignore */
813
+ ...(ngDevMode ? [{ debugName: "selection" }] : /* istanbul ignore next */ []));
814
+ /** Show a selection checkbox on every tile. */
815
+ selectable = input(false, { ...(ngDevMode ? { debugName: "selectable" } : /* istanbul ignore next */ {}), transform: booleanAttribute });
816
+ /** Allow drag-and-drop (pointer + keyboard) reordering of the tiles. */
817
+ reorderable = input(false, { ...(ngDevMode ? { debugName: "reorderable" } : /* istanbul ignore next */ {}), transform: booleanAttribute });
818
+ /** Number of grid columns. */
819
+ columns = input(4, { ...(ngDevMode ? { debugName: "columns" } : /* istanbul ignore next */ {}), transform: numberAttribute });
820
+ /** Accessible name of the gallery region. Defaults to `i18n.mediaLibrary`. */
821
+ ariaLabel = input(this.i18n.mediaLibrary, /* @ts-ignore */
822
+ ...(ngDevMode ? [{ debugName: "ariaLabel" }] : /* istanbul ignore next */ []));
823
+ /** Emitted when a tile's preview button is activated (click/Enter/Space). */
824
+ itemClick = output();
825
+ /** Emitted after a drop has updated the `items` model. */
826
+ reordered = output();
827
+ /** Optional consumer-projected per-tile action bar (`$implicit` = item). */
828
+ actions = contentChild(MkMediaActions, /* @ts-ignore */
829
+ ...(ngDevMode ? [{ debugName: "actions" }] : /* istanbul ignore next */ []));
830
+ /** Whether the item with `id` is currently selected. */
831
+ isSelected(id) {
832
+ return this.selection().includes(id);
833
+ }
834
+ /** Sync the two-way `selection` model with the checkbox's native state. */
835
+ onCheckboxChange(item, event) {
836
+ const checked = event.target.checked;
837
+ const current = this.selection();
838
+ if (checked) {
839
+ if (!current.includes(item.id))
840
+ this.selection.set([...current, item.id]);
841
+ }
842
+ else {
843
+ this.selection.set(current.filter((id) => id !== item.id));
844
+ }
845
+ }
846
+ /** Emit {@link itemClick} for the tile's preview button. */
847
+ onPreviewClick(item) {
848
+ this.itemClick.emit(item);
849
+ }
850
+ /**
851
+ * Apply a drop from the dnd module: move the item on a copy of `items`,
852
+ * write the copy back to the two-way model and emit {@link reordered}.
853
+ */
854
+ onDrop(event) {
855
+ const from = event.previousIndex;
856
+ const to = event.currentIndex;
857
+ if (from === to)
858
+ return;
859
+ const next = [...this.items()];
860
+ mkMoveItemInArray(next, from, to);
861
+ this.items.set(next);
862
+ this.reordered.emit({ from, to, items: next });
863
+ }
864
+ /**
865
+ * Keep events from the tile's nested controls (checkbox, action buttons)
866
+ * inside those controls: without this, Space/Enter on the checkbox or an
867
+ * action button would bubble to the surrounding `[mkDrag]` host and pick the
868
+ * tile up instead, and a pointer press would start a drag session. Native
869
+ * behavior is untouched — only propagation stops.
870
+ */
871
+ stopEvent(event) {
872
+ event.stopPropagation();
873
+ }
874
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.7", ngImport: i0, type: MkMediaGallery, deps: [], target: i0.ɵɵFactoryTarget.Component });
875
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.0.7", type: MkMediaGallery, isStandalone: true, selector: "mk-media-gallery", inputs: { items: { classPropertyName: "items", publicName: "items", isSignal: true, isRequired: false, transformFunction: null }, selection: { classPropertyName: "selection", publicName: "selection", isSignal: true, isRequired: false, transformFunction: null }, selectable: { classPropertyName: "selectable", publicName: "selectable", isSignal: true, isRequired: false, transformFunction: null }, reorderable: { classPropertyName: "reorderable", publicName: "reorderable", isSignal: true, isRequired: false, transformFunction: null }, columns: { classPropertyName: "columns", publicName: "columns", isSignal: true, isRequired: false, transformFunction: null }, ariaLabel: { classPropertyName: "ariaLabel", publicName: "ariaLabel", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { items: "itemsChange", selection: "selectionChange", itemClick: "itemClick", reordered: "reordered" }, host: { attributes: { "role": "group" }, properties: { "attr.aria-label": "ariaLabel()" }, classAttribute: "mk-media-gallery" }, queries: [{ propertyName: "actions", first: true, predicate: MkMediaActions, descendants: true, isSignal: true }], exportAs: ["mkMediaGallery"], ngImport: i0, template: "@if (items().length === 0) {\n <div class=\"mk-media-gallery__empty\">{{ i18n.noData }}</div>\n} @else if (reorderable()) {\n <div\n mkDropList\n class=\"mk-media-gallery__grid\"\n [style.--_cols]=\"columns()\"\n [mkDropListData]=\"items()\"\n [mkDropListLabel]=\"ariaLabel()\"\n mkDropListOrientation=\"horizontal\"\n (mkDropListDropped)=\"onDrop($event)\"\n >\n @for (item of items(); track item.id) {\n <div\n mkDrag\n class=\"mk-media-gallery__tile\"\n [class.mk-media-gallery__tile--selected]=\"isSelected(item.id)\"\n [mkDragData]=\"item\"\n >\n <ng-container\n [ngTemplateOutlet]=\"tile\"\n [ngTemplateOutletContext]=\"{ $implicit: item }\"\n />\n </div>\n }\n </div>\n} @else {\n <div class=\"mk-media-gallery__grid\" [style.--_cols]=\"columns()\">\n @for (item of items(); track item.id) {\n <div\n class=\"mk-media-gallery__tile\"\n [class.mk-media-gallery__tile--selected]=\"isSelected(item.id)\"\n >\n <ng-container\n [ngTemplateOutlet]=\"tile\"\n [ngTemplateOutletContext]=\"{ $implicit: item }\"\n />\n </div>\n }\n </div>\n}\n\n<ng-template #tile let-item>\n <div class=\"mk-media-gallery__media\">\n <button\n type=\"button\"\n class=\"mk-media-gallery__preview\"\n [attr.aria-label]=\"i18n.viewImage(item.alt ?? item.name)\"\n (click)=\"onPreviewClick(item)\"\n (keydown)=\"stopEvent($event)\"\n >\n <img\n class=\"mk-media-gallery__img\"\n [src]=\"item.thumb ?? item.src\"\n [alt]=\"item.alt ?? ''\"\n loading=\"lazy\"\n draggable=\"false\"\n />\n </button>\n\n @if (selectable()) {\n <span\n class=\"mk-media-gallery__check\"\n (keydown)=\"stopEvent($event)\"\n (pointerdown)=\"stopEvent($event)\"\n >\n <input\n type=\"checkbox\"\n class=\"mk-media-gallery__check-input\"\n [checked]=\"isSelected(item.id)\"\n [attr.aria-label]=\"i18n.selectRow(item.name)\"\n (change)=\"onCheckboxChange(item, $event)\"\n />\n <span class=\"mk-media-gallery__check-box\" aria-hidden=\"true\">\n <span class=\"mk-media-gallery__check-mark\"></span>\n </span>\n </span>\n }\n\n @if (actions(); as actionsDir) {\n <div\n class=\"mk-media-gallery__actions\"\n (keydown)=\"stopEvent($event)\"\n (pointerdown)=\"stopEvent($event)\"\n >\n <ng-container\n [ngTemplateOutlet]=\"actionsDir.template\"\n [ngTemplateOutletContext]=\"{ $implicit: item }\"\n />\n </div>\n }\n </div>\n\n <div class=\"mk-media-gallery__caption\">\n <span class=\"mk-media-gallery__name\">{{ item.name }}</span>\n @if (item.meta) {\n <span class=\"mk-media-gallery__meta\">{{ item.meta }}</span>\n }\n </div>\n</ng-template>\n", styles: ["@charset \"UTF-8\";:host{display:block;font-family:var(--mk-font-sans);color:var(--mk-text)}.mk-media-gallery__grid{display:grid;grid-template-columns:repeat(var(--_cols, 4),1fr);gap:var(--mk-space-3)}.mk-media-gallery__tile{position:relative;display:flex;flex-direction:column;gap:var(--mk-space-1);min-width:0;border-radius:var(--mk-radius-md)}.mk-media-gallery__tile:focus-visible{outline:var(--mk-focus-ring-width) solid var(--mk-focus-ring);outline-offset:var(--mk-focus-ring-offset)}.mk-media-gallery__media{position:relative;aspect-ratio:1/1;overflow:hidden;background-color:var(--mk-surface-2);border-radius:var(--mk-radius-md)}.mk-media-gallery__tile--selected .mk-media-gallery__media{outline:var(--mk-border-width-strong) solid var(--mk-primary);outline-offset:var(--mk-focus-ring-offset)}.mk-media-gallery__preview{position:absolute;inset:0;display:block;width:100%;height:100%;padding:0;margin:0;cursor:pointer;background:none;border:none;border-radius:inherit}.mk-media-gallery__preview:focus-visible{outline:var(--mk-focus-ring-width) solid var(--mk-focus-ring);outline-offset:calc(-1 * var(--mk-focus-ring-width) - var(--mk-focus-ring-offset))}.mk-media-gallery__img{display:block;width:100%;height:100%;object-fit:cover;transition:transform var(--mk-duration-normal) var(--mk-ease-standard)}.mk-media-gallery__preview:hover .mk-media-gallery__img{transform:scale(1.04)}.mk-media-gallery__check{position:absolute;top:var(--mk-space-2);left:var(--mk-space-2);display:inline-flex;width:1.25rem;height:1.25rem}.mk-media-gallery__check-input{position:absolute;inset:calc(-1 * var(--mk-space-2));width:auto;height:auto;margin:0;cursor:pointer;opacity:0}.mk-media-gallery__check-box{display:inline-flex;align-items:center;justify-content:center;width:100%;height:100%;pointer-events:none;background-color:var(--mk-surface);border:var(--mk-border-width-strong) solid var(--mk-border-strong);border-radius:var(--mk-radius-sm);box-shadow:var(--mk-shadow-sm);transition:background-color var(--mk-duration-fast) var(--mk-ease-standard),border-color var(--mk-duration-fast) var(--mk-ease-standard)}.mk-media-gallery__check-mark{width:30%;height:55%;border-right:2px solid var(--mk-primary-contrast);border-bottom:2px solid var(--mk-primary-contrast);opacity:0;transform:rotate(45deg) translateY(-8%)}.mk-media-gallery__check-input:hover+.mk-media-gallery__check-box{border-color:var(--mk-primary)}.mk-media-gallery__check-input:checked+.mk-media-gallery__check-box{background-color:var(--mk-primary);border-color:var(--mk-primary)}.mk-media-gallery__check-input:checked+.mk-media-gallery__check-box .mk-media-gallery__check-mark{opacity:1}.mk-media-gallery__check-input:focus-visible+.mk-media-gallery__check-box{outline:var(--mk-focus-ring-width) solid var(--mk-focus-ring);outline-offset:var(--mk-focus-ring-offset)}.mk-media-gallery__actions{position:absolute;top:var(--mk-space-2);right:var(--mk-space-2);display:flex;gap:var(--mk-space-1);align-items:center;padding:var(--mk-space-1);background-color:color-mix(in srgb,var(--mk-surface) 85%,transparent);border-radius:var(--mk-radius-sm);box-shadow:var(--mk-shadow-sm);opacity:0;transition:opacity var(--mk-duration-fast) var(--mk-ease-standard)}.mk-media-gallery__tile:hover .mk-media-gallery__actions,.mk-media-gallery__tile:focus-within .mk-media-gallery__actions{opacity:1}@media(hover:none){.mk-media-gallery__actions{opacity:1}}.mk-media-gallery__caption{display:flex;flex-direction:column;min-width:0;padding:0 var(--mk-space-1) var(--mk-space-1)}.mk-media-gallery__name{overflow:hidden;font-size:var(--mk-font-size-sm);font-weight:var(--mk-font-weight-medium);text-overflow:ellipsis;white-space:nowrap}.mk-media-gallery__meta{overflow:hidden;font-size:var(--mk-font-size-xs);color:var(--mk-text-muted);text-overflow:ellipsis;white-space:nowrap}.mk-media-gallery__empty{display:flex;align-items:center;justify-content:center;padding:var(--mk-space-10) var(--mk-space-4);font-size:var(--mk-font-size-sm);color:var(--mk-text-muted);text-align:center;background-color:var(--mk-surface-2);border:var(--mk-border-width) dashed var(--mk-border);border-radius:var(--mk-radius-lg)}@media(prefers-reduced-motion:reduce){.mk-media-gallery__img,.mk-media-gallery__actions,.mk-media-gallery__check-box{transition:none}.mk-media-gallery__preview:hover .mk-media-gallery__img{transform:none}}\n"], dependencies: [{ kind: "component", type: MkDropList, selector: "[mkDropList]", inputs: ["mkDropListData", "mkDropListId", "mkDropListConnectedTo", "mkDropListLabel", "mkDropListOrientation", "mkDropListDisabled"], outputs: ["mkDropListDropped"], exportAs: ["mkDropList"] }, { kind: "component", type: MkDrag, selector: "[mkDrag]", inputs: ["mkDragData", "mkDragDisabled", "mkDragTouchDelay"], exportAs: ["mkDrag"] }, { kind: "directive", type: NgTemplateOutlet, selector: "[ngTemplateOutlet]", inputs: ["ngTemplateOutletContext", "ngTemplateOutlet", "ngTemplateOutletInjector"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
876
+ }
877
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.7", ngImport: i0, type: MkMediaGallery, decorators: [{
878
+ type: Component,
879
+ args: [{ selector: 'mk-media-gallery', exportAs: 'mkMediaGallery', changeDetection: ChangeDetectionStrategy.OnPush, imports: [MkDropList, MkDrag, NgTemplateOutlet], host: {
880
+ class: 'mk-media-gallery',
881
+ role: 'group',
882
+ '[attr.aria-label]': 'ariaLabel()',
883
+ }, template: "@if (items().length === 0) {\n <div class=\"mk-media-gallery__empty\">{{ i18n.noData }}</div>\n} @else if (reorderable()) {\n <div\n mkDropList\n class=\"mk-media-gallery__grid\"\n [style.--_cols]=\"columns()\"\n [mkDropListData]=\"items()\"\n [mkDropListLabel]=\"ariaLabel()\"\n mkDropListOrientation=\"horizontal\"\n (mkDropListDropped)=\"onDrop($event)\"\n >\n @for (item of items(); track item.id) {\n <div\n mkDrag\n class=\"mk-media-gallery__tile\"\n [class.mk-media-gallery__tile--selected]=\"isSelected(item.id)\"\n [mkDragData]=\"item\"\n >\n <ng-container\n [ngTemplateOutlet]=\"tile\"\n [ngTemplateOutletContext]=\"{ $implicit: item }\"\n />\n </div>\n }\n </div>\n} @else {\n <div class=\"mk-media-gallery__grid\" [style.--_cols]=\"columns()\">\n @for (item of items(); track item.id) {\n <div\n class=\"mk-media-gallery__tile\"\n [class.mk-media-gallery__tile--selected]=\"isSelected(item.id)\"\n >\n <ng-container\n [ngTemplateOutlet]=\"tile\"\n [ngTemplateOutletContext]=\"{ $implicit: item }\"\n />\n </div>\n }\n </div>\n}\n\n<ng-template #tile let-item>\n <div class=\"mk-media-gallery__media\">\n <button\n type=\"button\"\n class=\"mk-media-gallery__preview\"\n [attr.aria-label]=\"i18n.viewImage(item.alt ?? item.name)\"\n (click)=\"onPreviewClick(item)\"\n (keydown)=\"stopEvent($event)\"\n >\n <img\n class=\"mk-media-gallery__img\"\n [src]=\"item.thumb ?? item.src\"\n [alt]=\"item.alt ?? ''\"\n loading=\"lazy\"\n draggable=\"false\"\n />\n </button>\n\n @if (selectable()) {\n <span\n class=\"mk-media-gallery__check\"\n (keydown)=\"stopEvent($event)\"\n (pointerdown)=\"stopEvent($event)\"\n >\n <input\n type=\"checkbox\"\n class=\"mk-media-gallery__check-input\"\n [checked]=\"isSelected(item.id)\"\n [attr.aria-label]=\"i18n.selectRow(item.name)\"\n (change)=\"onCheckboxChange(item, $event)\"\n />\n <span class=\"mk-media-gallery__check-box\" aria-hidden=\"true\">\n <span class=\"mk-media-gallery__check-mark\"></span>\n </span>\n </span>\n }\n\n @if (actions(); as actionsDir) {\n <div\n class=\"mk-media-gallery__actions\"\n (keydown)=\"stopEvent($event)\"\n (pointerdown)=\"stopEvent($event)\"\n >\n <ng-container\n [ngTemplateOutlet]=\"actionsDir.template\"\n [ngTemplateOutletContext]=\"{ $implicit: item }\"\n />\n </div>\n }\n </div>\n\n <div class=\"mk-media-gallery__caption\">\n <span class=\"mk-media-gallery__name\">{{ item.name }}</span>\n @if (item.meta) {\n <span class=\"mk-media-gallery__meta\">{{ item.meta }}</span>\n }\n </div>\n</ng-template>\n", styles: ["@charset \"UTF-8\";:host{display:block;font-family:var(--mk-font-sans);color:var(--mk-text)}.mk-media-gallery__grid{display:grid;grid-template-columns:repeat(var(--_cols, 4),1fr);gap:var(--mk-space-3)}.mk-media-gallery__tile{position:relative;display:flex;flex-direction:column;gap:var(--mk-space-1);min-width:0;border-radius:var(--mk-radius-md)}.mk-media-gallery__tile:focus-visible{outline:var(--mk-focus-ring-width) solid var(--mk-focus-ring);outline-offset:var(--mk-focus-ring-offset)}.mk-media-gallery__media{position:relative;aspect-ratio:1/1;overflow:hidden;background-color:var(--mk-surface-2);border-radius:var(--mk-radius-md)}.mk-media-gallery__tile--selected .mk-media-gallery__media{outline:var(--mk-border-width-strong) solid var(--mk-primary);outline-offset:var(--mk-focus-ring-offset)}.mk-media-gallery__preview{position:absolute;inset:0;display:block;width:100%;height:100%;padding:0;margin:0;cursor:pointer;background:none;border:none;border-radius:inherit}.mk-media-gallery__preview:focus-visible{outline:var(--mk-focus-ring-width) solid var(--mk-focus-ring);outline-offset:calc(-1 * var(--mk-focus-ring-width) - var(--mk-focus-ring-offset))}.mk-media-gallery__img{display:block;width:100%;height:100%;object-fit:cover;transition:transform var(--mk-duration-normal) var(--mk-ease-standard)}.mk-media-gallery__preview:hover .mk-media-gallery__img{transform:scale(1.04)}.mk-media-gallery__check{position:absolute;top:var(--mk-space-2);left:var(--mk-space-2);display:inline-flex;width:1.25rem;height:1.25rem}.mk-media-gallery__check-input{position:absolute;inset:calc(-1 * var(--mk-space-2));width:auto;height:auto;margin:0;cursor:pointer;opacity:0}.mk-media-gallery__check-box{display:inline-flex;align-items:center;justify-content:center;width:100%;height:100%;pointer-events:none;background-color:var(--mk-surface);border:var(--mk-border-width-strong) solid var(--mk-border-strong);border-radius:var(--mk-radius-sm);box-shadow:var(--mk-shadow-sm);transition:background-color var(--mk-duration-fast) var(--mk-ease-standard),border-color var(--mk-duration-fast) var(--mk-ease-standard)}.mk-media-gallery__check-mark{width:30%;height:55%;border-right:2px solid var(--mk-primary-contrast);border-bottom:2px solid var(--mk-primary-contrast);opacity:0;transform:rotate(45deg) translateY(-8%)}.mk-media-gallery__check-input:hover+.mk-media-gallery__check-box{border-color:var(--mk-primary)}.mk-media-gallery__check-input:checked+.mk-media-gallery__check-box{background-color:var(--mk-primary);border-color:var(--mk-primary)}.mk-media-gallery__check-input:checked+.mk-media-gallery__check-box .mk-media-gallery__check-mark{opacity:1}.mk-media-gallery__check-input:focus-visible+.mk-media-gallery__check-box{outline:var(--mk-focus-ring-width) solid var(--mk-focus-ring);outline-offset:var(--mk-focus-ring-offset)}.mk-media-gallery__actions{position:absolute;top:var(--mk-space-2);right:var(--mk-space-2);display:flex;gap:var(--mk-space-1);align-items:center;padding:var(--mk-space-1);background-color:color-mix(in srgb,var(--mk-surface) 85%,transparent);border-radius:var(--mk-radius-sm);box-shadow:var(--mk-shadow-sm);opacity:0;transition:opacity var(--mk-duration-fast) var(--mk-ease-standard)}.mk-media-gallery__tile:hover .mk-media-gallery__actions,.mk-media-gallery__tile:focus-within .mk-media-gallery__actions{opacity:1}@media(hover:none){.mk-media-gallery__actions{opacity:1}}.mk-media-gallery__caption{display:flex;flex-direction:column;min-width:0;padding:0 var(--mk-space-1) var(--mk-space-1)}.mk-media-gallery__name{overflow:hidden;font-size:var(--mk-font-size-sm);font-weight:var(--mk-font-weight-medium);text-overflow:ellipsis;white-space:nowrap}.mk-media-gallery__meta{overflow:hidden;font-size:var(--mk-font-size-xs);color:var(--mk-text-muted);text-overflow:ellipsis;white-space:nowrap}.mk-media-gallery__empty{display:flex;align-items:center;justify-content:center;padding:var(--mk-space-10) var(--mk-space-4);font-size:var(--mk-font-size-sm);color:var(--mk-text-muted);text-align:center;background-color:var(--mk-surface-2);border:var(--mk-border-width) dashed var(--mk-border);border-radius:var(--mk-radius-lg)}@media(prefers-reduced-motion:reduce){.mk-media-gallery__img,.mk-media-gallery__actions,.mk-media-gallery__check-box{transition:none}.mk-media-gallery__preview:hover .mk-media-gallery__img{transform:none}}\n"] }]
884
+ }], propDecorators: { items: [{ type: i0.Input, args: [{ isSignal: true, alias: "items", required: false }] }, { type: i0.Output, args: ["itemsChange"] }], selection: [{ type: i0.Input, args: [{ isSignal: true, alias: "selection", required: false }] }, { type: i0.Output, args: ["selectionChange"] }], selectable: [{ type: i0.Input, args: [{ isSignal: true, alias: "selectable", required: false }] }], reorderable: [{ type: i0.Input, args: [{ isSignal: true, alias: "reorderable", required: false }] }], columns: [{ type: i0.Input, args: [{ isSignal: true, alias: "columns", required: false }] }], ariaLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "ariaLabel", required: false }] }], itemClick: [{ type: i0.Output, args: ["itemClick"] }], reordered: [{ type: i0.Output, args: ["reordered"] }], actions: [{ type: i0.ContentChild, args: [i0.forwardRef(() => MkMediaActions), { isSignal: true }] }] } });
885
+
886
+ /**
887
+ * MEDIA components group barrel for @mk-kit/ui.
888
+ * Images, galleries, lightbox, cropping and media management.
889
+ */
890
+
891
+ /**
892
+ * Generated bundle index. Do not edit.
893
+ */
894
+
895
+ export { MkImage, MkImageCropper, MkImageGallery, MkLightbox, MkLightboxService, MkMediaActions, MkMediaGallery, mkClampPan, mkCoverScale, mkCropRect };
896
+ //# sourceMappingURL=mk-kit-ui-media.mjs.map