@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.
- package/LICENSE +21 -0
- package/README.md +115 -0
- package/block-editor/README.md +254 -0
- package/fesm2022/mk-kit-ui-block-editor.mjs +2158 -0
- package/fesm2022/mk-kit-ui-block-editor.mjs.map +1 -0
- package/fesm2022/mk-kit-ui-button.mjs +81 -0
- package/fesm2022/mk-kit-ui-button.mjs.map +1 -0
- package/fesm2022/mk-kit-ui-checkbox.mjs +136 -0
- package/fesm2022/mk-kit-ui-checkbox.mjs.map +1 -0
- package/fesm2022/mk-kit-ui-chip.mjs +122 -0
- package/fesm2022/mk-kit-ui-chip.mjs.map +1 -0
- package/fesm2022/mk-kit-ui-context-menu.mjs +144 -0
- package/fesm2022/mk-kit-ui-context-menu.mjs.map +1 -0
- package/fesm2022/mk-kit-ui-core.mjs +1576 -0
- package/fesm2022/mk-kit-ui-core.mjs.map +1 -0
- package/fesm2022/mk-kit-ui-data.mjs +6055 -0
- package/fesm2022/mk-kit-ui-data.mjs.map +1 -0
- package/fesm2022/mk-kit-ui-datetime.mjs +3409 -0
- package/fesm2022/mk-kit-ui-datetime.mjs.map +1 -0
- package/fesm2022/mk-kit-ui-directives.mjs +1779 -0
- package/fesm2022/mk-kit-ui-directives.mjs.map +1 -0
- package/fesm2022/mk-kit-ui-dnd.mjs +1073 -0
- package/fesm2022/mk-kit-ui-dnd.mjs.map +1 -0
- package/fesm2022/mk-kit-ui-feedback.mjs +2426 -0
- package/fesm2022/mk-kit-ui-feedback.mjs.map +1 -0
- package/fesm2022/mk-kit-ui-forms.mjs +9208 -0
- package/fesm2022/mk-kit-ui-forms.mjs.map +1 -0
- package/fesm2022/mk-kit-ui-icon.mjs +470 -0
- package/fesm2022/mk-kit-ui-icon.mjs.map +1 -0
- package/fesm2022/mk-kit-ui-media.mjs +896 -0
- package/fesm2022/mk-kit-ui-media.mjs.map +1 -0
- package/fesm2022/mk-kit-ui-navigation.mjs +2542 -0
- package/fesm2022/mk-kit-ui-navigation.mjs.map +1 -0
- package/fesm2022/mk-kit-ui-rich-text.mjs +565 -0
- package/fesm2022/mk-kit-ui-rich-text.mjs.map +1 -0
- package/fesm2022/mk-kit-ui-table.mjs +1378 -0
- package/fesm2022/mk-kit-ui-table.mjs.map +1 -0
- package/fesm2022/mk-kit-ui.mjs +32 -0
- package/fesm2022/mk-kit-ui.mjs.map +1 -0
- package/package.json +130 -0
- package/schematics/collection.json +10 -0
- package/schematics/ng-add/index.js +113 -0
- package/schematics/ng-add/schema.json +22 -0
- package/schematics/package.json +3 -0
- package/styles/mk-kit.css +750 -0
- package/types/mk-kit-ui-block-editor.d.ts +292 -0
- package/types/mk-kit-ui-button.d.ts +40 -0
- package/types/mk-kit-ui-checkbox.d.ts +62 -0
- package/types/mk-kit-ui-chip.d.ts +59 -0
- package/types/mk-kit-ui-context-menu.d.ts +57 -0
- package/types/mk-kit-ui-core.d.ts +1105 -0
- package/types/mk-kit-ui-data.d.ts +2580 -0
- package/types/mk-kit-ui-datetime.d.ts +1171 -0
- package/types/mk-kit-ui-directives.d.ts +807 -0
- package/types/mk-kit-ui-dnd.d.ts +423 -0
- package/types/mk-kit-ui-feedback.d.ts +1270 -0
- package/types/mk-kit-ui-forms.d.ts +3586 -0
- package/types/mk-kit-ui-icon.d.ts +108 -0
- package/types/mk-kit-ui-media.d.ts +549 -0
- package/types/mk-kit-ui-navigation.d.ts +1169 -0
- package/types/mk-kit-ui-rich-text.d.ts +187 -0
- package/types/mk-kit-ui-table.d.ts +739 -0
- package/types/mk-kit-ui.d.ts +17 -0
|
@@ -0,0 +1,1576 @@
|
|
|
1
|
+
import * as i0 from '@angular/core';
|
|
2
|
+
import { inject, DOCUMENT, DestroyRef, PLATFORM_ID, signal, computed, effect, Injectable, InjectionToken, ApplicationRef, EnvironmentInjector, createComponent, Injector, ElementRef, input, output, Directive, untracked } from '@angular/core';
|
|
3
|
+
import { isPlatformBrowser } from '@angular/common';
|
|
4
|
+
import { Observable } from 'rxjs';
|
|
5
|
+
|
|
6
|
+
/** Shared primitive types used across mk-kit components. */
|
|
7
|
+
|
|
8
|
+
const STORAGE_KEY = 'mk-kit-theme';
|
|
9
|
+
const THEME_ATTR = 'data-mk-theme';
|
|
10
|
+
const DENSITY_STORAGE_KEY = 'mk-kit-density';
|
|
11
|
+
const DENSITY_ATTR = 'data-mk-density';
|
|
12
|
+
/**
|
|
13
|
+
* Reactive theme controller for mk-kit.
|
|
14
|
+
*
|
|
15
|
+
* - `preference()` is the user's choice: `light`, `dark`, or `system`.
|
|
16
|
+
* - `resolvedTheme()` is the concrete theme currently applied.
|
|
17
|
+
* - Writes `data-mk-theme` on `<html>` and persists the choice to
|
|
18
|
+
* `localStorage`. When set to `system`, it live-tracks the OS setting and
|
|
19
|
+
* removes the attribute so pure-CSS `prefers-color-scheme` takes over.
|
|
20
|
+
*
|
|
21
|
+
* Provided in root — inject it anywhere and bind to the signals.
|
|
22
|
+
*/
|
|
23
|
+
class MkThemeService {
|
|
24
|
+
document = inject(DOCUMENT);
|
|
25
|
+
destroyRef = inject(DestroyRef);
|
|
26
|
+
isBrowser = isPlatformBrowser(inject(PLATFORM_ID));
|
|
27
|
+
_preference = signal(this.readInitial(), /* @ts-ignore */
|
|
28
|
+
...(ngDevMode ? [{ debugName: "_preference" }] : /* istanbul ignore next */ []));
|
|
29
|
+
/** The user's theme preference. */
|
|
30
|
+
preference = this._preference.asReadonly();
|
|
31
|
+
_systemPrefersDark = signal(this.readSystemDark(), /* @ts-ignore */
|
|
32
|
+
...(ngDevMode ? [{ debugName: "_systemPrefersDark" }] : /* istanbul ignore next */ []));
|
|
33
|
+
/** The concrete theme in effect (`light` or `dark`). */
|
|
34
|
+
resolvedTheme = computed(() => {
|
|
35
|
+
const pref = this._preference();
|
|
36
|
+
if (pref === 'system') {
|
|
37
|
+
return this._systemPrefersDark() ? 'dark' : 'light';
|
|
38
|
+
}
|
|
39
|
+
return pref;
|
|
40
|
+
}, /* @ts-ignore */
|
|
41
|
+
...(ngDevMode ? [{ debugName: "resolvedTheme" }] : /* istanbul ignore next */ []));
|
|
42
|
+
/** Convenience boolean for template bindings. */
|
|
43
|
+
isDark = computed(() => this.resolvedTheme() === 'dark', /* @ts-ignore */
|
|
44
|
+
...(ngDevMode ? [{ debugName: "isDark" }] : /* istanbul ignore next */ []));
|
|
45
|
+
_density = signal(this.readInitialDensity(), /* @ts-ignore */
|
|
46
|
+
...(ngDevMode ? [{ debugName: "_density" }] : /* istanbul ignore next */ []));
|
|
47
|
+
/**
|
|
48
|
+
* The global density mode. `compact` tightens control heights and the core
|
|
49
|
+
* spacing steps via the `data-mk-density` attribute, `touch` enlarges them —
|
|
50
|
+
* every component follows automatically because they read the same tokens.
|
|
51
|
+
*
|
|
52
|
+
* This signal is the GLOBAL mode only. To make one screen or dialog touch-
|
|
53
|
+
* sized inside an otherwise comfortable app, put `data-mk-density="touch"`
|
|
54
|
+
* on that element instead; the tokens inherit and this service stays out of
|
|
55
|
+
* it.
|
|
56
|
+
*/
|
|
57
|
+
density = this._density.asReadonly();
|
|
58
|
+
constructor() {
|
|
59
|
+
if (this.isBrowser) {
|
|
60
|
+
this.watchSystemPreference();
|
|
61
|
+
}
|
|
62
|
+
// Keep the DOM attribute + storage in sync with the signal.
|
|
63
|
+
effect(() => {
|
|
64
|
+
const pref = this._preference();
|
|
65
|
+
if (!this.isBrowser)
|
|
66
|
+
return;
|
|
67
|
+
const root = this.document.documentElement;
|
|
68
|
+
if (pref === 'system') {
|
|
69
|
+
root.removeAttribute(THEME_ATTR);
|
|
70
|
+
}
|
|
71
|
+
else {
|
|
72
|
+
root.setAttribute(THEME_ATTR, pref);
|
|
73
|
+
}
|
|
74
|
+
try {
|
|
75
|
+
localStorage.setItem(STORAGE_KEY, pref);
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
/* storage may be unavailable (private mode) — ignore */
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
// Keep the density attribute + storage in sync.
|
|
82
|
+
effect(() => {
|
|
83
|
+
const density = this._density();
|
|
84
|
+
if (!this.isBrowser)
|
|
85
|
+
return;
|
|
86
|
+
const root = this.document.documentElement;
|
|
87
|
+
// `comfortable` is the token default, so it is the ABSENCE of the
|
|
88
|
+
// attribute — anything else names itself. Written this way so a new mode
|
|
89
|
+
// needs no change here.
|
|
90
|
+
if (density === 'comfortable') {
|
|
91
|
+
root.removeAttribute(DENSITY_ATTR);
|
|
92
|
+
}
|
|
93
|
+
else {
|
|
94
|
+
root.setAttribute(DENSITY_ATTR, density);
|
|
95
|
+
}
|
|
96
|
+
try {
|
|
97
|
+
localStorage.setItem(DENSITY_STORAGE_KEY, density);
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
/* ignore */
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
/** Set the global density mode. */
|
|
105
|
+
setDensity(density) {
|
|
106
|
+
this._density.set(density);
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Toggle between comfortable and compact — the two modes a density switch in
|
|
110
|
+
* a UI offers. `touch` is a deliberate choice for a specific screen, not
|
|
111
|
+
* something to land on by toggling, so from `touch` this returns to
|
|
112
|
+
* comfortable rather than cycling.
|
|
113
|
+
*/
|
|
114
|
+
toggleDensity() {
|
|
115
|
+
this._density.update((d) => (d === 'comfortable' ? 'compact' : 'comfortable'));
|
|
116
|
+
}
|
|
117
|
+
readInitialDensity() {
|
|
118
|
+
if (!this.isBrowser)
|
|
119
|
+
return 'comfortable';
|
|
120
|
+
try {
|
|
121
|
+
const stored = localStorage.getItem(DENSITY_STORAGE_KEY);
|
|
122
|
+
if (stored === 'compact' || stored === 'comfortable' || stored === 'touch') {
|
|
123
|
+
return stored;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
catch {
|
|
127
|
+
/* ignore */
|
|
128
|
+
}
|
|
129
|
+
return 'comfortable';
|
|
130
|
+
}
|
|
131
|
+
/** Set the theme preference explicitly. */
|
|
132
|
+
setTheme(preference) {
|
|
133
|
+
this._preference.set(preference);
|
|
134
|
+
}
|
|
135
|
+
/** Toggle between light and dark (resolving `system` first). */
|
|
136
|
+
toggle() {
|
|
137
|
+
this._preference.set(this.resolvedTheme() === 'dark' ? 'light' : 'dark');
|
|
138
|
+
}
|
|
139
|
+
readInitial() {
|
|
140
|
+
if (!this.isBrowserEnv())
|
|
141
|
+
return 'system';
|
|
142
|
+
try {
|
|
143
|
+
const stored = localStorage.getItem(STORAGE_KEY);
|
|
144
|
+
if (stored === 'light' || stored === 'dark' || stored === 'system') {
|
|
145
|
+
return stored;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
catch {
|
|
149
|
+
/* ignore */
|
|
150
|
+
}
|
|
151
|
+
return 'system';
|
|
152
|
+
}
|
|
153
|
+
readSystemDark() {
|
|
154
|
+
if (!this.isBrowserEnv())
|
|
155
|
+
return false;
|
|
156
|
+
return this.document.defaultView?.matchMedia('(prefers-color-scheme: dark)')
|
|
157
|
+
.matches ?? false;
|
|
158
|
+
}
|
|
159
|
+
watchSystemPreference() {
|
|
160
|
+
const mql = this.document.defaultView?.matchMedia('(prefers-color-scheme: dark)');
|
|
161
|
+
if (!mql)
|
|
162
|
+
return;
|
|
163
|
+
const onChange = (e) => this._systemPrefersDark.set(e.matches);
|
|
164
|
+
mql.addEventListener('change', onChange);
|
|
165
|
+
// Detach when the injector dies (repeated bootstraps in SSR/HMR/tests).
|
|
166
|
+
this.destroyRef.onDestroy(() => mql.removeEventListener('change', onChange));
|
|
167
|
+
}
|
|
168
|
+
isBrowserEnv() {
|
|
169
|
+
return this.isBrowser && typeof this.document !== 'undefined';
|
|
170
|
+
}
|
|
171
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.7", ngImport: i0, type: MkThemeService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
|
|
172
|
+
static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "22.0.7", ngImport: i0, type: MkThemeService, providedIn: 'root' });
|
|
173
|
+
}
|
|
174
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.7", ngImport: i0, type: MkThemeService, decorators: [{
|
|
175
|
+
type: Injectable,
|
|
176
|
+
args: [{ providedIn: 'root' }]
|
|
177
|
+
}], ctorParameters: () => [] });
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Announces messages to assistive technology via a visually-hidden
|
|
181
|
+
* `aria-live` region. Used by toasts, form validation, sort changes, etc.
|
|
182
|
+
* so state changes are perceivable without sight. WCAG 4.1.3 (Status Messages).
|
|
183
|
+
*/
|
|
184
|
+
class MkLiveAnnouncer {
|
|
185
|
+
document = inject(DOCUMENT);
|
|
186
|
+
isBrowser = isPlatformBrowser(inject(PLATFORM_ID));
|
|
187
|
+
region;
|
|
188
|
+
clearTimer;
|
|
189
|
+
/** Announce `message`. `politeness` controls interruption behavior. */
|
|
190
|
+
announce(message, politeness = 'polite') {
|
|
191
|
+
if (!this.isBrowser)
|
|
192
|
+
return;
|
|
193
|
+
const region = this.ensureRegion();
|
|
194
|
+
region.setAttribute('aria-live', politeness);
|
|
195
|
+
// Clear then set on the next frame so identical consecutive messages
|
|
196
|
+
// are still re-announced by screen readers.
|
|
197
|
+
region.textContent = '';
|
|
198
|
+
if (this.clearTimer)
|
|
199
|
+
clearTimeout(this.clearTimer);
|
|
200
|
+
this.document.defaultView?.requestAnimationFrame(() => {
|
|
201
|
+
region.textContent = message;
|
|
202
|
+
});
|
|
203
|
+
// Tidy up after a while to keep the DOM clean.
|
|
204
|
+
this.clearTimer = setTimeout(() => {
|
|
205
|
+
if (this.region)
|
|
206
|
+
this.region.textContent = '';
|
|
207
|
+
}, 1000);
|
|
208
|
+
}
|
|
209
|
+
ensureRegion() {
|
|
210
|
+
if (this.region)
|
|
211
|
+
return this.region;
|
|
212
|
+
const el = this.document.createElement('div');
|
|
213
|
+
el.setAttribute('aria-atomic', 'true');
|
|
214
|
+
el.setAttribute('aria-live', 'polite');
|
|
215
|
+
el.className = 'mk-visually-hidden';
|
|
216
|
+
el.style.cssText =
|
|
217
|
+
'position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap;border:0;';
|
|
218
|
+
this.document.body.appendChild(el);
|
|
219
|
+
this.region = el;
|
|
220
|
+
return el;
|
|
221
|
+
}
|
|
222
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.7", ngImport: i0, type: MkLiveAnnouncer, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
|
|
223
|
+
static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "22.0.7", ngImport: i0, type: MkLiveAnnouncer, providedIn: 'root' });
|
|
224
|
+
}
|
|
225
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.7", ngImport: i0, type: MkLiveAnnouncer, decorators: [{
|
|
226
|
+
type: Injectable,
|
|
227
|
+
args: [{ providedIn: 'root' }]
|
|
228
|
+
}] });
|
|
229
|
+
|
|
230
|
+
const FOCUSABLE_SELECTOR = [
|
|
231
|
+
'a[href]',
|
|
232
|
+
'button:not([disabled])',
|
|
233
|
+
'input:not([disabled]):not([type="hidden"])',
|
|
234
|
+
'select:not([disabled])',
|
|
235
|
+
'textarea:not([disabled])',
|
|
236
|
+
'[tabindex]:not([tabindex="-1"])',
|
|
237
|
+
'[contenteditable="true"]',
|
|
238
|
+
].join(',');
|
|
239
|
+
/** Returns the tabbable elements inside `root`, in DOM order. */
|
|
240
|
+
function mkGetFocusable(root) {
|
|
241
|
+
const win = root.ownerDocument.defaultView;
|
|
242
|
+
return Array.from(root.querySelectorAll(FOCUSABLE_SELECTOR)).filter((el) => {
|
|
243
|
+
if (el === root.ownerDocument.activeElement)
|
|
244
|
+
return true;
|
|
245
|
+
if (el.offsetWidth <= 0 && el.offsetHeight <= 0)
|
|
246
|
+
return false;
|
|
247
|
+
// `visibility:hidden` elements keep their box, so the size check above
|
|
248
|
+
// passes — yet they are NOT tabbable, and counting one as a Tab-wrap
|
|
249
|
+
// boundary makes the wrap focus a dead element. `getComputedStyle`
|
|
250
|
+
// degrades safely in jsdom, where visibility resolves 'visible' by
|
|
251
|
+
// default, so test environments without layout keep working.
|
|
252
|
+
const visibility = win?.getComputedStyle(el).visibility;
|
|
253
|
+
return visibility !== 'hidden' && visibility !== 'collapse';
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
/**
|
|
257
|
+
* Traps keyboard focus within `root` (wrapping Tab / Shift+Tab), moves focus
|
|
258
|
+
* inside on activation, and restores focus to the previously-focused element
|
|
259
|
+
* on release. Essential for accessible modals/menus (WCAG 2.4.3, 2.1.2).
|
|
260
|
+
*/
|
|
261
|
+
class MkFocusTrap {
|
|
262
|
+
root;
|
|
263
|
+
previouslyFocused = null;
|
|
264
|
+
active = false;
|
|
265
|
+
keydownHandler = (e) => this.onKeydown(e);
|
|
266
|
+
constructor(root) {
|
|
267
|
+
this.root = root;
|
|
268
|
+
}
|
|
269
|
+
/** Activate the trap and move focus to the first focusable element (or root). */
|
|
270
|
+
activate(initialFocus) {
|
|
271
|
+
this.previouslyFocused = this.root.ownerDocument
|
|
272
|
+
.activeElement;
|
|
273
|
+
this.active = true;
|
|
274
|
+
this.root.addEventListener('keydown', this.keydownHandler, true);
|
|
275
|
+
const target = initialFocus ??
|
|
276
|
+
mkGetFocusable(this.root)[0] ??
|
|
277
|
+
this.root;
|
|
278
|
+
// Ensure programmatic focus works even on a non-tabbable container.
|
|
279
|
+
if (target === this.root && !this.root.hasAttribute('tabindex')) {
|
|
280
|
+
this.root.setAttribute('tabindex', '-1');
|
|
281
|
+
}
|
|
282
|
+
// Skip if the trap was released within the same tick, so the deferred
|
|
283
|
+
// focus can't steal focus back after release() restored it.
|
|
284
|
+
queueMicrotask(() => {
|
|
285
|
+
if (this.active)
|
|
286
|
+
target.focus();
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
/** Deactivate and restore focus to the trigger element. */
|
|
290
|
+
release() {
|
|
291
|
+
this.active = false;
|
|
292
|
+
this.root.removeEventListener('keydown', this.keydownHandler, true);
|
|
293
|
+
const previous = this.previouslyFocused;
|
|
294
|
+
this.previouslyFocused = null;
|
|
295
|
+
if (!previous)
|
|
296
|
+
return;
|
|
297
|
+
if (previous.isConnected) {
|
|
298
|
+
previous.focus?.();
|
|
299
|
+
}
|
|
300
|
+
else {
|
|
301
|
+
// The trigger left the DOM while the trap was active (a deleted row, a
|
|
302
|
+
// route change). Focusing a detached node is a silent no-op at best —
|
|
303
|
+
// land on the body instead so focus has a real, connected home.
|
|
304
|
+
this.root.ownerDocument.body?.focus?.();
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
onKeydown(e) {
|
|
308
|
+
if (e.key !== 'Tab')
|
|
309
|
+
return;
|
|
310
|
+
const focusable = mkGetFocusable(this.root);
|
|
311
|
+
if (focusable.length === 0) {
|
|
312
|
+
e.preventDefault();
|
|
313
|
+
this.root.focus();
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
const first = focusable[0];
|
|
317
|
+
const last = focusable[focusable.length - 1];
|
|
318
|
+
const active = this.root.ownerDocument.activeElement;
|
|
319
|
+
if (e.shiftKey && active === first) {
|
|
320
|
+
e.preventDefault();
|
|
321
|
+
last.focus();
|
|
322
|
+
}
|
|
323
|
+
else if (!e.shiftKey && active === last) {
|
|
324
|
+
e.preventDefault();
|
|
325
|
+
first.focus();
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
let counter = 0;
|
|
331
|
+
/**
|
|
332
|
+
* Generates a stable, unique DOM id for wiring `aria-*` relationships
|
|
333
|
+
* (labels, descriptions, controls). Prefer this over `Math.random()` so
|
|
334
|
+
* ids are deterministic within a render and SSR-safe.
|
|
335
|
+
*
|
|
336
|
+
* @param prefix short semantic prefix, e.g. `mk-input`.
|
|
337
|
+
*/
|
|
338
|
+
function mkUniqueId(prefix = 'mk') {
|
|
339
|
+
return `${prefix}-${++counter}`;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/** Injection token exposing the data passed to an overlay component. */
|
|
343
|
+
const MK_OVERLAY_DATA = new InjectionToken('MK_OVERLAY_DATA');
|
|
344
|
+
/**
|
|
345
|
+
* Handle to an open overlay. Injected into the rendered component and returned
|
|
346
|
+
* from `MkOverlayService.open`. Resolve the overlay by calling `close`.
|
|
347
|
+
*
|
|
348
|
+
* The close result is exposed three ways so the handle fits whatever style the
|
|
349
|
+
* calling code already uses — they all settle together:
|
|
350
|
+
*
|
|
351
|
+
* - `result` — a signal, for template binding and signal-based components.
|
|
352
|
+
* - `closed$` — an Observable that emits once and completes, for RxJS
|
|
353
|
+
* pipelines. A service that hands a dialog result back to its callers can
|
|
354
|
+
* return this directly instead of wrapping the promise in `from(...)`.
|
|
355
|
+
* - `afterClosed` — a Promise, for `await` / `.then()`.
|
|
356
|
+
*/
|
|
357
|
+
class MkOverlayRef {
|
|
358
|
+
/** The rendered component instance (set by the service after creation). */
|
|
359
|
+
componentRef;
|
|
360
|
+
_closed = signal(false, /* @ts-ignore */
|
|
361
|
+
...(ngDevMode ? [{ debugName: "_closed" }] : /* istanbul ignore next */ []));
|
|
362
|
+
/** Becomes `true` once the overlay has been dismissed. */
|
|
363
|
+
closed = this._closed.asReadonly();
|
|
364
|
+
_result = signal(undefined, /* @ts-ignore */
|
|
365
|
+
...(ngDevMode ? [{ debugName: "_result" }] : /* istanbul ignore next */ []));
|
|
366
|
+
/**
|
|
367
|
+
* The value passed to `close`, or `undefined` while open and for a dismissal
|
|
368
|
+
* (Escape / backdrop). Read `closed()` to tell "closed with no result" apart
|
|
369
|
+
* from "still open".
|
|
370
|
+
*/
|
|
371
|
+
result = this._result.asReadonly();
|
|
372
|
+
resolveClosed;
|
|
373
|
+
/** Resolves with the close result when the overlay is dismissed. */
|
|
374
|
+
afterClosed = new Promise((resolve) => {
|
|
375
|
+
this.resolveClosed = resolve;
|
|
376
|
+
});
|
|
377
|
+
emitClosed;
|
|
378
|
+
/**
|
|
379
|
+
* Emits the close result once, then completes. Subscribing after the overlay
|
|
380
|
+
* has already closed replays the result immediately, so a late subscriber
|
|
381
|
+
* never hangs.
|
|
382
|
+
*/
|
|
383
|
+
closed$ = new Observable((subscriber) => {
|
|
384
|
+
if (this._closed()) {
|
|
385
|
+
subscriber.next(this._result());
|
|
386
|
+
subscriber.complete();
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
389
|
+
this.emitClosed = (result) => {
|
|
390
|
+
subscriber.next(result);
|
|
391
|
+
subscriber.complete();
|
|
392
|
+
};
|
|
393
|
+
return () => {
|
|
394
|
+
this.emitClosed = undefined;
|
|
395
|
+
};
|
|
396
|
+
});
|
|
397
|
+
/** Internal disposer wired up by the service. */
|
|
398
|
+
_dispose = () => { };
|
|
399
|
+
/** Close the overlay, optionally returning a result. */
|
|
400
|
+
close(result) {
|
|
401
|
+
if (this._closed())
|
|
402
|
+
return;
|
|
403
|
+
this._closed.set(true);
|
|
404
|
+
this._result.set(result);
|
|
405
|
+
this._dispose(result);
|
|
406
|
+
this.resolveClosed(result);
|
|
407
|
+
this.emitClosed?.(result);
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* Body-level elements a modal must NOT inert:
|
|
413
|
+
* - `[popover]` — anchored panels / tooltips teleport to `document.body` and
|
|
414
|
+
* may open ABOVE the dialog after it (a select inside a dialog); inerting
|
|
415
|
+
* them would kill the very widget the dialog just opened.
|
|
416
|
+
* - toast / snackbar containers — transient status surfaces that must stay
|
|
417
|
+
* reachable and announceable while a dialog is up.
|
|
418
|
+
* - `[aria-live]` — the live announcer region; an inert live region stops
|
|
419
|
+
* announcing entirely.
|
|
420
|
+
*/
|
|
421
|
+
const INERT_EXEMPT_SELECTOR = '[popover], [aria-live], mk-toast-container, mk-snackbar-container';
|
|
422
|
+
/**
|
|
423
|
+
* Lightweight, dependency-free overlay renderer. Instantiates a standalone
|
|
424
|
+
* component into a body-level host, manages the backdrop, focus trapping,
|
|
425
|
+
* Escape handling, and scroll locking. Powers Dialog, Menu, and others.
|
|
426
|
+
*/
|
|
427
|
+
class MkOverlayService {
|
|
428
|
+
appRef = inject(ApplicationRef);
|
|
429
|
+
envInjector = inject(EnvironmentInjector);
|
|
430
|
+
document = inject(DOCUMENT);
|
|
431
|
+
isBrowser = isPlatformBrowser(inject(PLATFORM_ID));
|
|
432
|
+
// All coordination state lives on the INSTANCE, not the module: the service
|
|
433
|
+
// is a root singleton, so one app still shares one stack — but each test
|
|
434
|
+
// injector gets a fresh world instead of leaking overlays/locks/listeners
|
|
435
|
+
// across spec files that share a worker.
|
|
436
|
+
/** Reference count for the body scroll lock (first open locks, last close unlocks). */
|
|
437
|
+
openOverlays = 0;
|
|
438
|
+
bodyScrollLock = null;
|
|
439
|
+
/**
|
|
440
|
+
* Every overlay currently on screen, in open order (Map preserves insertion
|
|
441
|
+
* order, so the last entry is the topmost). Kept so {@link closeAll} can
|
|
442
|
+
* dismiss them and so the shared Escape listener can find the topmost
|
|
443
|
+
* dismissible overlay. Entries remove themselves on dispose.
|
|
444
|
+
*/
|
|
445
|
+
openRefs = new Map();
|
|
446
|
+
/** The document currently carrying the shared Escape listener, if any. */
|
|
447
|
+
escapeListenerDoc = null;
|
|
448
|
+
/**
|
|
449
|
+
* The one document-level Escape listener, shared by every open overlay.
|
|
450
|
+
*
|
|
451
|
+
* Registered on the BUBBLE phase on purpose: widgets living inside an
|
|
452
|
+
* overlay (a menu, an anchored panel, a picker) handle Escape on their own
|
|
453
|
+
* elements during bubbling and call `preventDefault()`, so a consumed
|
|
454
|
+
* Escape arrives here already `defaultPrevented` and closes nothing. A
|
|
455
|
+
* per-overlay capture listener with `stopPropagation()` cannot do this:
|
|
456
|
+
* stopPropagation does not stop OTHER listeners on the same target, so one
|
|
457
|
+
* Escape used to close every stacked overlay at once.
|
|
458
|
+
*/
|
|
459
|
+
onDocumentEscape = (event) => {
|
|
460
|
+
if (event.key !== 'Escape' || event.defaultPrevented)
|
|
461
|
+
return;
|
|
462
|
+
// Close ONLY the topmost open overlay that opted into Escape dismissal.
|
|
463
|
+
const entries = [...this.openRefs.entries()];
|
|
464
|
+
for (let i = entries.length - 1; i >= 0; i--) {
|
|
465
|
+
const [ref, { closeOnEscape }] = entries[i];
|
|
466
|
+
if (closeOnEscape) {
|
|
467
|
+
ref.close();
|
|
468
|
+
event.preventDefault();
|
|
469
|
+
return;
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
};
|
|
473
|
+
/** (Un)register the shared Escape listener to match the open-overlay set. */
|
|
474
|
+
syncEscapeListener(doc) {
|
|
475
|
+
const needed = [...this.openRefs.values()].some((e) => e.closeOnEscape);
|
|
476
|
+
if (needed && this.escapeListenerDoc === null) {
|
|
477
|
+
doc.addEventListener('keydown', this.onDocumentEscape);
|
|
478
|
+
this.escapeListenerDoc = doc;
|
|
479
|
+
}
|
|
480
|
+
else if (!needed && this.escapeListenerDoc !== null) {
|
|
481
|
+
this.escapeListenerDoc.removeEventListener('keydown', this.onDocumentEscape);
|
|
482
|
+
this.escapeListenerDoc = null;
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
lockBodyScroll(doc) {
|
|
486
|
+
const { style } = doc.body;
|
|
487
|
+
this.bodyScrollLock = {
|
|
488
|
+
scrollY: doc.defaultView?.scrollY ?? 0,
|
|
489
|
+
prev: {
|
|
490
|
+
position: style.position,
|
|
491
|
+
top: style.top,
|
|
492
|
+
left: style.left,
|
|
493
|
+
right: style.right,
|
|
494
|
+
width: style.width,
|
|
495
|
+
overflow: style.overflow,
|
|
496
|
+
},
|
|
497
|
+
};
|
|
498
|
+
// overflow:hidden still does the whole job on desktop (and avoids a
|
|
499
|
+
// scroll jump there); the fixed-body treatment is what actually stops iOS
|
|
500
|
+
// touch scrolling. `top: -scrollY` keeps the page visually where it was.
|
|
501
|
+
style.overflow = 'hidden';
|
|
502
|
+
style.position = 'fixed';
|
|
503
|
+
style.top = `-${this.bodyScrollLock.scrollY}px`;
|
|
504
|
+
style.left = '0';
|
|
505
|
+
style.right = '0';
|
|
506
|
+
style.width = '100%';
|
|
507
|
+
}
|
|
508
|
+
unlockBodyScroll(doc) {
|
|
509
|
+
if (!this.bodyScrollLock)
|
|
510
|
+
return;
|
|
511
|
+
const { scrollY, prev } = this.bodyScrollLock;
|
|
512
|
+
this.bodyScrollLock = null;
|
|
513
|
+
const { style } = doc.body;
|
|
514
|
+
style.position = prev.position;
|
|
515
|
+
style.top = prev.top;
|
|
516
|
+
style.left = prev.left;
|
|
517
|
+
style.right = prev.right;
|
|
518
|
+
style.width = prev.width;
|
|
519
|
+
style.overflow = prev.overflow;
|
|
520
|
+
// Fixing the body collapsed the document's scroll position to 0; jump
|
|
521
|
+
// back instantly (never smooth — the restoration must be invisible).
|
|
522
|
+
// Guarded for jsdom, where scrollTo is unimplemented.
|
|
523
|
+
const view = doc.defaultView;
|
|
524
|
+
if (scrollY !== 0 && typeof view?.scrollTo === 'function') {
|
|
525
|
+
view.scrollTo({ top: scrollY, behavior: 'instant' });
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
/** How many overlays are currently open. */
|
|
529
|
+
get openCount() {
|
|
530
|
+
return this.openRefs.size;
|
|
531
|
+
}
|
|
532
|
+
/**
|
|
533
|
+
* Close every open overlay, newest first. Each is closed with no result, so
|
|
534
|
+
* `afterClosed` resolves undefined exactly as a backdrop click or Escape
|
|
535
|
+
* would. Safe to call when nothing is open.
|
|
536
|
+
*/
|
|
537
|
+
closeAll() {
|
|
538
|
+
// Iterate a COPY: close() disposes synchronously, which mutates the map.
|
|
539
|
+
for (const ref of [...this.openRefs.keys()].reverse())
|
|
540
|
+
ref.close();
|
|
541
|
+
}
|
|
542
|
+
/**
|
|
543
|
+
* Injector teardown closes everything still open. In an app this runs only
|
|
544
|
+
* at shutdown; in tests it runs on every `TestBed.resetTestingModule()`,
|
|
545
|
+
* so an overlay a spec forgot to close cannot leak its scroll lock, focus
|
|
546
|
+
* trap, inert marks, or document listeners into later spec files sharing
|
|
547
|
+
* the worker.
|
|
548
|
+
*/
|
|
549
|
+
ngOnDestroy() {
|
|
550
|
+
this.closeAll();
|
|
551
|
+
}
|
|
552
|
+
open(component, config = {}) {
|
|
553
|
+
const { hasBackdrop = true, closeOnBackdropClick = true, closeOnEscape = true, trapFocus = true, autoFocus = true, role = 'dialog', } = config;
|
|
554
|
+
const overlayRef = new MkOverlayRef();
|
|
555
|
+
if (!this.isBrowser) {
|
|
556
|
+
// Nothing to render on the server; return an inert ref.
|
|
557
|
+
return overlayRef;
|
|
558
|
+
}
|
|
559
|
+
const container = this.document.createElement('div');
|
|
560
|
+
container.className = 'mk-overlay-container';
|
|
561
|
+
container.style.cssText =
|
|
562
|
+
'position:fixed;inset:0;z-index:var(--mk-z-overlay,1000);display:flex;align-items:center;justify-content:center;';
|
|
563
|
+
if (!hasBackdrop) {
|
|
564
|
+
// Without a scrim the fullscreen container must not swallow clicks
|
|
565
|
+
// meant for the page; the panel re-enables pointer events for itself.
|
|
566
|
+
container.style.pointerEvents = 'none';
|
|
567
|
+
}
|
|
568
|
+
let backdrop;
|
|
569
|
+
if (hasBackdrop) {
|
|
570
|
+
backdrop = this.document.createElement('div');
|
|
571
|
+
backdrop.className = 'mk-overlay-backdrop';
|
|
572
|
+
backdrop.style.cssText =
|
|
573
|
+
'position:absolute;inset:0;background:var(--mk-overlay-scrim,rgba(0,0,0,0.45));animation:mk-overlay-fade var(--mk-duration-fast,140ms) var(--mk-ease-out,ease);';
|
|
574
|
+
container.appendChild(backdrop);
|
|
575
|
+
if (closeOnBackdropClick) {
|
|
576
|
+
backdrop.addEventListener('click', () => overlayRef.close());
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
const panel = this.document.createElement('div');
|
|
580
|
+
panel.className = 'mk-overlay-panel';
|
|
581
|
+
if (config.panelClass) {
|
|
582
|
+
const classes = Array.isArray(config.panelClass)
|
|
583
|
+
? config.panelClass
|
|
584
|
+
: [config.panelClass];
|
|
585
|
+
panel.classList.add(...classes);
|
|
586
|
+
}
|
|
587
|
+
panel.style.cssText = 'position:relative;max-width:100%;max-height:100%;';
|
|
588
|
+
if (!hasBackdrop)
|
|
589
|
+
panel.style.pointerEvents = 'auto';
|
|
590
|
+
panel.setAttribute('role', role);
|
|
591
|
+
panel.setAttribute('aria-modal', role === 'menu' ? 'false' : 'true');
|
|
592
|
+
if (config.ariaLabel)
|
|
593
|
+
panel.setAttribute('aria-label', config.ariaLabel);
|
|
594
|
+
container.appendChild(panel);
|
|
595
|
+
// Create the component with an injector exposing the data + ref.
|
|
596
|
+
const componentRef = createComponent(component, {
|
|
597
|
+
environmentInjector: this.envInjector,
|
|
598
|
+
hostElement: panel,
|
|
599
|
+
elementInjector: Injector.create({
|
|
600
|
+
parent: config.injector ?? this.envInjector,
|
|
601
|
+
providers: [
|
|
602
|
+
{ provide: MK_OVERLAY_DATA, useValue: config.data ?? null },
|
|
603
|
+
{ provide: MkOverlayRef, useValue: overlayRef },
|
|
604
|
+
],
|
|
605
|
+
}),
|
|
606
|
+
});
|
|
607
|
+
overlayRef.componentRef = componentRef;
|
|
608
|
+
this.appRef.attachView(componentRef.hostView);
|
|
609
|
+
this.document.body.appendChild(container);
|
|
610
|
+
// Lock body scroll while any overlay is open (see lockBodyScroll for the
|
|
611
|
+
// iOS-proof fixed-body technique and the reference counting).
|
|
612
|
+
if (this.openOverlays++ === 0) {
|
|
613
|
+
this.lockBodyScroll(this.document);
|
|
614
|
+
}
|
|
615
|
+
// Make the rest of the page inert while a MODAL overlay is open —
|
|
616
|
+
// `aria-modal="true"` alone does not stop Tab or a screen reader's
|
|
617
|
+
// reading cursor from reaching background content (WCAG 2.4.3 / 1.3.2).
|
|
618
|
+
// A backdropless overlay is deliberately non-blocking, so it inerts
|
|
619
|
+
// nothing. Only the elements THIS overlay inerted are recorded, so
|
|
620
|
+
// nested modals unwind correctly: the inner dialog skips (and therefore
|
|
621
|
+
// never un-inerts) whatever the outer dialog already inerted.
|
|
622
|
+
const isModal = hasBackdrop && role !== 'menu';
|
|
623
|
+
const inerted = [];
|
|
624
|
+
if (isModal) {
|
|
625
|
+
for (const sibling of Array.from(this.document.body.children)) {
|
|
626
|
+
if (sibling === container ||
|
|
627
|
+
sibling.hasAttribute('inert') ||
|
|
628
|
+
sibling.matches(INERT_EXEMPT_SELECTOR)) {
|
|
629
|
+
continue;
|
|
630
|
+
}
|
|
631
|
+
sibling.setAttribute('inert', '');
|
|
632
|
+
inerted.push(sibling);
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
// Focus management.
|
|
636
|
+
const focusTrap = trapFocus ? new MkFocusTrap(panel) : undefined;
|
|
637
|
+
// `autoFocus: false` focuses the panel rather than its first control, so
|
|
638
|
+
// the trap still contains Tab and restores focus on close.
|
|
639
|
+
focusTrap?.activate(autoFocus ? undefined : panel);
|
|
640
|
+
// Escape handling goes through ONE shared document listener (see
|
|
641
|
+
// `onDocumentEscape`) so stacked overlays close one per keypress,
|
|
642
|
+
// topmost first, and inner widgets can consume Escape before us.
|
|
643
|
+
this.openRefs.set(overlayRef, { closeOnEscape });
|
|
644
|
+
this.syncEscapeListener(this.document);
|
|
645
|
+
overlayRef._dispose = () => {
|
|
646
|
+
this.openRefs.delete(overlayRef);
|
|
647
|
+
this.syncEscapeListener(this.document);
|
|
648
|
+
for (const el of inerted)
|
|
649
|
+
el.removeAttribute('inert');
|
|
650
|
+
focusTrap?.release();
|
|
651
|
+
// ApplicationRef destroys its views before root-injector destroy hooks
|
|
652
|
+
// run — detaching then would throw (same guard as MkTourService).
|
|
653
|
+
if (!this.appRef.destroyed)
|
|
654
|
+
this.appRef.detachView(componentRef.hostView);
|
|
655
|
+
componentRef.destroy();
|
|
656
|
+
container.remove();
|
|
657
|
+
if (--this.openOverlays === 0) {
|
|
658
|
+
this.unlockBodyScroll(this.document);
|
|
659
|
+
}
|
|
660
|
+
};
|
|
661
|
+
return overlayRef;
|
|
662
|
+
}
|
|
663
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.7", ngImport: i0, type: MkOverlayService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
|
|
664
|
+
static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "22.0.7", ngImport: i0, type: MkOverlayService, providedIn: 'root' });
|
|
665
|
+
}
|
|
666
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.7", ngImport: i0, type: MkOverlayService, decorators: [{
|
|
667
|
+
type: Injectable,
|
|
668
|
+
args: [{ providedIn: 'root' }]
|
|
669
|
+
}] });
|
|
670
|
+
|
|
671
|
+
/**
|
|
672
|
+
* Never shrink a panel below this via the viewport size cap — a sub-120px
|
|
673
|
+
* dropdown is unusable; at that point overflowing beats collapsing.
|
|
674
|
+
*/
|
|
675
|
+
const MIN_SIZE_CAP = 120;
|
|
676
|
+
function sideOf(placement) {
|
|
677
|
+
if (placement.startsWith('top'))
|
|
678
|
+
return 'top';
|
|
679
|
+
if (placement.startsWith('bottom'))
|
|
680
|
+
return 'bottom';
|
|
681
|
+
return placement;
|
|
682
|
+
}
|
|
683
|
+
function alignOf(placement) {
|
|
684
|
+
if (placement.endsWith('-start'))
|
|
685
|
+
return 'start';
|
|
686
|
+
if (placement.endsWith('-end'))
|
|
687
|
+
return 'end';
|
|
688
|
+
return 'center';
|
|
689
|
+
}
|
|
690
|
+
/**
|
|
691
|
+
* Pure viewport-positioning maths shared by every anchored overlay
|
|
692
|
+
* ({@link MkAnchoredPanel} and the tooltip). Given the anchor rect, the panel
|
|
693
|
+
* size and the viewport size, returns the top/left for a `position: fixed`
|
|
694
|
+
* panel — flipping to the opposite side when it would overflow and clamping
|
|
695
|
+
* back inside the viewport.
|
|
696
|
+
*/
|
|
697
|
+
function mkComputeAnchoredPosition(anchor, panel, viewport, opts) {
|
|
698
|
+
const { gap, flip, clamp } = opts;
|
|
699
|
+
let side = sideOf(opts.placement);
|
|
700
|
+
const align = alignOf(opts.placement);
|
|
701
|
+
const { width: w, height: h } = panel;
|
|
702
|
+
const { width: vw, height: vh } = viewport;
|
|
703
|
+
// Vertical flip for top/bottom placements.
|
|
704
|
+
if (flip && (side === 'top' || side === 'bottom')) {
|
|
705
|
+
const fitsBelow = anchor.bottom + gap + h <= vh;
|
|
706
|
+
const fitsAbove = anchor.top - gap - h >= 0;
|
|
707
|
+
if (side === 'bottom' && !fitsBelow && fitsAbove)
|
|
708
|
+
side = 'top';
|
|
709
|
+
else if (side === 'top' && !fitsAbove && fitsBelow)
|
|
710
|
+
side = 'bottom';
|
|
711
|
+
}
|
|
712
|
+
// Horizontal flip for left/right placements.
|
|
713
|
+
if (flip && (side === 'left' || side === 'right')) {
|
|
714
|
+
const fitsRight = anchor.right + gap + w <= vw;
|
|
715
|
+
const fitsLeft = anchor.left - gap - w >= 0;
|
|
716
|
+
if (side === 'right' && !fitsRight && fitsLeft)
|
|
717
|
+
side = 'left';
|
|
718
|
+
else if (side === 'left' && !fitsLeft && fitsRight)
|
|
719
|
+
side = 'right';
|
|
720
|
+
}
|
|
721
|
+
let top = 0;
|
|
722
|
+
let left = 0;
|
|
723
|
+
if (side === 'bottom')
|
|
724
|
+
top = anchor.bottom + gap;
|
|
725
|
+
else if (side === 'top')
|
|
726
|
+
top = anchor.top - h - gap;
|
|
727
|
+
else if (side === 'right')
|
|
728
|
+
left = anchor.right + gap;
|
|
729
|
+
else
|
|
730
|
+
left = anchor.left - w - gap;
|
|
731
|
+
if (side === 'top' || side === 'bottom') {
|
|
732
|
+
// Cross axis is horizontal; in RTL, inline-start is the anchor's right edge.
|
|
733
|
+
const startLeft = opts.rtl ? anchor.right - w : anchor.left;
|
|
734
|
+
const endLeft = opts.rtl ? anchor.left : anchor.right - w;
|
|
735
|
+
if (align === 'start')
|
|
736
|
+
left = startLeft;
|
|
737
|
+
else if (align === 'end')
|
|
738
|
+
left = endLeft;
|
|
739
|
+
else
|
|
740
|
+
left = anchor.left + anchor.width / 2 - w / 2;
|
|
741
|
+
}
|
|
742
|
+
else {
|
|
743
|
+
// Left/right: centre on the cross (vertical) axis.
|
|
744
|
+
top = anchor.top + anchor.height / 2 - h / 2;
|
|
745
|
+
}
|
|
746
|
+
if (clamp) {
|
|
747
|
+
left = Math.max(gap, Math.min(left, vw - w - gap));
|
|
748
|
+
top = Math.max(gap, Math.min(top, vh - h - gap));
|
|
749
|
+
}
|
|
750
|
+
return { top: Math.round(top), left: Math.round(left), placement: `${side}${align === 'center' ? '' : `-${align}`}` };
|
|
751
|
+
}
|
|
752
|
+
/**
|
|
753
|
+
* Anchored-overlay directive. Apply it to a floating panel element (a dropdown
|
|
754
|
+
* list, calendar, menu, …) that is rendered inside its component's own template
|
|
755
|
+
* — typically inside an `@if (open()) { … }` block. On init the directive
|
|
756
|
+
* **teleports the panel to `document.body` and into the browser top layer** via
|
|
757
|
+
* the native Popover API (`popover="manual"` + `showPopover()`), then positions
|
|
758
|
+
* it against the anchor with `position: fixed`. Because the element stays part
|
|
759
|
+
* of the component's Angular view, all bindings, `@for` content, events and
|
|
760
|
+
* projected content keep working after the move.
|
|
761
|
+
*
|
|
762
|
+
* The top layer is immune to ancestor `overflow`, `transform` and `z-index`
|
|
763
|
+
* stacking contexts, so the panel can never be clipped by a container or hidden
|
|
764
|
+
* behind sibling content. Where the Popover API is unavailable the panel still
|
|
765
|
+
* renders in a `document.body` portal with a `z-index` fallback.
|
|
766
|
+
*
|
|
767
|
+
* ```html
|
|
768
|
+
* <ul #panel mkAnchoredPanel [mkAnchoredPanelFor]="trigger" [matchWidth]="true"
|
|
769
|
+
* (dismiss)="close()"> … </ul>
|
|
770
|
+
* ```
|
|
771
|
+
*/
|
|
772
|
+
class MkAnchoredPanel {
|
|
773
|
+
host = inject(ElementRef);
|
|
774
|
+
document = inject(DOCUMENT);
|
|
775
|
+
isBrowser = isPlatformBrowser(inject(PLATFORM_ID));
|
|
776
|
+
/** The trigger element to position against. */
|
|
777
|
+
anchor = input(undefined, { ...(ngDevMode ? { debugName: "anchor" } : /* istanbul ignore next */ {}), alias: 'mkAnchoredPanelFor' });
|
|
778
|
+
/** Viewport-point anchor (e.g. a right-click position) — takes precedence. */
|
|
779
|
+
anchorRect = input(undefined, /* @ts-ignore */
|
|
780
|
+
...(ngDevMode ? [{ debugName: "anchorRect" }] : /* istanbul ignore next */ []));
|
|
781
|
+
/** Preferred placement relative to the anchor. */
|
|
782
|
+
placement = input('bottom-start', /* @ts-ignore */
|
|
783
|
+
...(ngDevMode ? [{ debugName: "placement" }] : /* istanbul ignore next */ []));
|
|
784
|
+
/** Distance in px between the anchor and the panel. */
|
|
785
|
+
gap = input(4, /* @ts-ignore */
|
|
786
|
+
...(ngDevMode ? [{ debugName: "gap" }] : /* istanbul ignore next */ []));
|
|
787
|
+
/** Set the panel's `min-width` to the anchor's width (dropdowns). */
|
|
788
|
+
matchWidth = input(false, /* @ts-ignore */
|
|
789
|
+
...(ngDevMode ? [{ debugName: "matchWidth" }] : /* istanbul ignore next */ []));
|
|
790
|
+
/** Flip to the opposite side when the preferred side would overflow. */
|
|
791
|
+
flip = input(true, /* @ts-ignore */
|
|
792
|
+
...(ngDevMode ? [{ debugName: "flip" }] : /* istanbul ignore next */ []));
|
|
793
|
+
/** Clamp the panel inside the viewport. */
|
|
794
|
+
clamp = input(true, /* @ts-ignore */
|
|
795
|
+
...(ngDevMode ? [{ debugName: "clamp" }] : /* istanbul ignore next */ []));
|
|
796
|
+
/** Emitted on an outside pointerdown or when the window loses focus. */
|
|
797
|
+
dismiss = output();
|
|
798
|
+
popover = false;
|
|
799
|
+
/** Whether {@link position} applied an inline viewport size cap (`max-*`). */
|
|
800
|
+
sizeCapped = false;
|
|
801
|
+
repositionRaf = null;
|
|
802
|
+
/**
|
|
803
|
+
* Scroll-driven repositions track the anchor without clamping, so the
|
|
804
|
+
* panel follows its trigger instead of detaching and hugging the viewport
|
|
805
|
+
* edge; open/resize positioning clamps as usual.
|
|
806
|
+
*/
|
|
807
|
+
pendingTrack = true;
|
|
808
|
+
/** rAF-coalesced repositioning — at most one layout pass per frame. */
|
|
809
|
+
reposition(track) {
|
|
810
|
+
// A clamped (open/resize) request in the same frame wins over tracking.
|
|
811
|
+
this.pendingTrack &&= track;
|
|
812
|
+
if (this.repositionRaf != null)
|
|
813
|
+
return;
|
|
814
|
+
this.repositionRaf =
|
|
815
|
+
this.document.defaultView?.requestAnimationFrame(() => {
|
|
816
|
+
this.repositionRaf = null;
|
|
817
|
+
const wasTrack = this.pendingTrack;
|
|
818
|
+
this.pendingTrack = true;
|
|
819
|
+
this.position(wasTrack);
|
|
820
|
+
}) ?? null;
|
|
821
|
+
}
|
|
822
|
+
onScroll = () => this.reposition(true);
|
|
823
|
+
onResize = () => this.reposition(false);
|
|
824
|
+
onDocPointerdown = (e) => {
|
|
825
|
+
const target = e.target;
|
|
826
|
+
if (this.host.nativeElement.contains(target))
|
|
827
|
+
return;
|
|
828
|
+
const anchorEl = this.resolveAnchorEl();
|
|
829
|
+
if (anchorEl?.contains(target))
|
|
830
|
+
return;
|
|
831
|
+
this.dismiss.emit();
|
|
832
|
+
};
|
|
833
|
+
onWindowBlur = () => this.dismiss.emit();
|
|
834
|
+
ngAfterViewInit() {
|
|
835
|
+
if (!this.isBrowser)
|
|
836
|
+
return;
|
|
837
|
+
const el = this.host.nativeElement;
|
|
838
|
+
el.style.position = 'fixed';
|
|
839
|
+
el.style.margin = '0';
|
|
840
|
+
el.style.inset = 'auto';
|
|
841
|
+
// setProperty (not .style.zIndex) so the CSS var() value is accepted. Only
|
|
842
|
+
// matters in the no-Popover fallback; the top layer ignores z-index.
|
|
843
|
+
el.style.setProperty('z-index', 'var(--mk-z-menu)');
|
|
844
|
+
this.document.body.appendChild(el);
|
|
845
|
+
// Promote into the top layer when the Popover API is available.
|
|
846
|
+
const withPopover = el;
|
|
847
|
+
if (typeof withPopover.showPopover === 'function') {
|
|
848
|
+
el.setAttribute('popover', 'manual');
|
|
849
|
+
try {
|
|
850
|
+
withPopover.showPopover();
|
|
851
|
+
this.popover = true;
|
|
852
|
+
}
|
|
853
|
+
catch {
|
|
854
|
+
// Already open or unsupported context — fall back to the body portal.
|
|
855
|
+
// The attribute must go too: with it left on, the UA sheet's
|
|
856
|
+
// `[popover]:not(:popover-open) { display: none }` hides the panel.
|
|
857
|
+
el.removeAttribute('popover');
|
|
858
|
+
this.popover = false;
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
this.position();
|
|
862
|
+
const view = this.document.defaultView;
|
|
863
|
+
view?.addEventListener('scroll', this.onScroll, {
|
|
864
|
+
capture: true,
|
|
865
|
+
passive: true,
|
|
866
|
+
});
|
|
867
|
+
view?.addEventListener('resize', this.onResize);
|
|
868
|
+
view?.addEventListener('blur', this.onWindowBlur);
|
|
869
|
+
// iOS's software keyboard (and pinch-zoom) resizes/pans only the VISUAL
|
|
870
|
+
// viewport — `window` never fires resize/scroll for it — so listen there
|
|
871
|
+
// too, into the same reposition paths. Feature-detected: jsdom and older
|
|
872
|
+
// browsers have no visualViewport.
|
|
873
|
+
const visual = view?.visualViewport;
|
|
874
|
+
visual?.addEventListener('resize', this.onResize);
|
|
875
|
+
visual?.addEventListener('scroll', this.onScroll);
|
|
876
|
+
this.document.addEventListener('pointerdown', this.onDocPointerdown, true);
|
|
877
|
+
// Re-measure once layout has settled (fonts, async content).
|
|
878
|
+
view?.requestAnimationFrame(() => this.position());
|
|
879
|
+
}
|
|
880
|
+
/**
|
|
881
|
+
* Recompute and apply the panel position. Safe to call at any time.
|
|
882
|
+
* With `track` (scroll-driven), the panel follows the anchor unclamped and
|
|
883
|
+
* dismisses once the anchor leaves the viewport (matching the CDK's
|
|
884
|
+
* reposition-with-auto-close scroll behaviour).
|
|
885
|
+
*/
|
|
886
|
+
position(track = false) {
|
|
887
|
+
if (!this.isBrowser)
|
|
888
|
+
return;
|
|
889
|
+
const el = this.host.nativeElement;
|
|
890
|
+
const view = this.document.defaultView;
|
|
891
|
+
if (!view)
|
|
892
|
+
return;
|
|
893
|
+
const anchor = this.resolveAnchorRect();
|
|
894
|
+
if (!anchor)
|
|
895
|
+
return;
|
|
896
|
+
// Prefer the visual viewport when it exists and is measured: on iOS the
|
|
897
|
+
// software keyboard shrinks only `visualViewport`, never the layout
|
|
898
|
+
// viewport, so clamp/flip maths against `documentElement.client*` would
|
|
899
|
+
// keep placing panels underneath the keyboard. Fallback (no visualViewport,
|
|
900
|
+
// or one reporting 0×0 pre-measure) keeps the existing behaviour, including
|
|
901
|
+
// the jsdom "unmeasured viewport" guards below.
|
|
902
|
+
const visual = view.visualViewport;
|
|
903
|
+
const useVisual = visual != null && visual.width > 0 && visual.height > 0;
|
|
904
|
+
const vw = useVisual ? visual.width : this.document.documentElement.clientWidth;
|
|
905
|
+
const vh = useVisual ? visual.height : this.document.documentElement.clientHeight;
|
|
906
|
+
// Anchor scrolled fully out of view — the panel would float detached.
|
|
907
|
+
// Zero-size anchor rects and a zero-size viewport are skipped: they mean
|
|
908
|
+
// "not yet measured" (jsdom, SSR hydration), not "off-screen".
|
|
909
|
+
if (track &&
|
|
910
|
+
!this.anchorRect() &&
|
|
911
|
+
vw > 0 &&
|
|
912
|
+
vh > 0 &&
|
|
913
|
+
(anchor.width > 0 || anchor.height > 0) &&
|
|
914
|
+
(anchor.bottom <= 0 || anchor.top >= vh || anchor.right <= 0 || anchor.left >= vw)) {
|
|
915
|
+
this.dismiss.emit();
|
|
916
|
+
return;
|
|
917
|
+
}
|
|
918
|
+
if (this.matchWidth() && anchor.width > 0) {
|
|
919
|
+
el.style.minWidth = `${Math.round(anchor.width)}px`;
|
|
920
|
+
}
|
|
921
|
+
const gap = this.gap();
|
|
922
|
+
// Viewport size cap: a panel wider or taller than the screen (a fat menu
|
|
923
|
+
// on a 360px phone) gets an inline `max-width`/`max-height` so it scrolls
|
|
924
|
+
// internally instead of overflowing. An inline max-* OVERRIDES any
|
|
925
|
+
// stylesheet max (e.g. a select list's own 16rem max-height), so the cap
|
|
926
|
+
// is applied only when the panel's NATURAL size exceeds it — it never
|
|
927
|
+
// enlarges a panel that already limits itself. A previously applied cap is
|
|
928
|
+
// cleared first so the natural size is re-measured against the current
|
|
929
|
+
// viewport. Scroll-tracking frames skip all of this: the viewport has not
|
|
930
|
+
// resized mid-scroll, and clearing/re-measuring would force two extra
|
|
931
|
+
// reflows per frame.
|
|
932
|
+
if (!track && this.sizeCapped) {
|
|
933
|
+
el.style.removeProperty('max-width');
|
|
934
|
+
el.style.removeProperty('max-height');
|
|
935
|
+
this.sizeCapped = false;
|
|
936
|
+
}
|
|
937
|
+
let rect = el.getBoundingClientRect();
|
|
938
|
+
if (!track && vw > 0 && vh > 0) {
|
|
939
|
+
const capW = Math.max(MIN_SIZE_CAP, Math.floor(vw - 2 * gap));
|
|
940
|
+
const capH = Math.max(MIN_SIZE_CAP, Math.floor(vh - 2 * gap));
|
|
941
|
+
const capWidth = rect.width > capW;
|
|
942
|
+
const capHeight = rect.height > capH;
|
|
943
|
+
if (capWidth)
|
|
944
|
+
el.style.maxWidth = `${capW}px`;
|
|
945
|
+
if (capHeight)
|
|
946
|
+
el.style.maxHeight = `${capH}px`;
|
|
947
|
+
if (capWidth || capHeight) {
|
|
948
|
+
this.sizeCapped = true;
|
|
949
|
+
rect = el.getBoundingClientRect();
|
|
950
|
+
}
|
|
951
|
+
}
|
|
952
|
+
const pos = mkComputeAnchoredPosition(anchor, { width: rect.width, height: rect.height }, { width: vw, height: vh }, {
|
|
953
|
+
placement: this.placement(),
|
|
954
|
+
gap,
|
|
955
|
+
flip: this.flip(),
|
|
956
|
+
clamp: this.clamp() && !track,
|
|
957
|
+
rtl: this.isAnchorRtl(),
|
|
958
|
+
});
|
|
959
|
+
el.style.top = `${pos.top}px`;
|
|
960
|
+
el.style.left = `${pos.left}px`;
|
|
961
|
+
el.setAttribute('data-placement', pos.placement);
|
|
962
|
+
}
|
|
963
|
+
/** Whether the anchor renders in a right-to-left context. */
|
|
964
|
+
isAnchorRtl() {
|
|
965
|
+
const el = this.resolveAnchorEl();
|
|
966
|
+
const view = this.document.defaultView;
|
|
967
|
+
if (!el || !view)
|
|
968
|
+
return false;
|
|
969
|
+
return view.getComputedStyle(el).direction === 'rtl';
|
|
970
|
+
}
|
|
971
|
+
resolveAnchorEl() {
|
|
972
|
+
const a = this.anchor();
|
|
973
|
+
if (!a)
|
|
974
|
+
return null;
|
|
975
|
+
return a instanceof ElementRef ? a.nativeElement : a;
|
|
976
|
+
}
|
|
977
|
+
resolveAnchorRect() {
|
|
978
|
+
const point = this.anchorRect();
|
|
979
|
+
if (point) {
|
|
980
|
+
return {
|
|
981
|
+
top: point.y,
|
|
982
|
+
left: point.x,
|
|
983
|
+
right: point.x,
|
|
984
|
+
bottom: point.y,
|
|
985
|
+
width: 0,
|
|
986
|
+
height: 0,
|
|
987
|
+
};
|
|
988
|
+
}
|
|
989
|
+
return this.resolveAnchorEl()?.getBoundingClientRect() ?? null;
|
|
990
|
+
}
|
|
991
|
+
ngOnDestroy() {
|
|
992
|
+
if (!this.isBrowser)
|
|
993
|
+
return;
|
|
994
|
+
const el = this.host.nativeElement;
|
|
995
|
+
const view = this.document.defaultView;
|
|
996
|
+
if (this.repositionRaf != null) {
|
|
997
|
+
view?.cancelAnimationFrame(this.repositionRaf);
|
|
998
|
+
this.repositionRaf = null;
|
|
999
|
+
}
|
|
1000
|
+
view?.removeEventListener('scroll', this.onScroll, true);
|
|
1001
|
+
view?.removeEventListener('resize', this.onResize);
|
|
1002
|
+
view?.removeEventListener('blur', this.onWindowBlur);
|
|
1003
|
+
const visual = view?.visualViewport;
|
|
1004
|
+
visual?.removeEventListener('resize', this.onResize);
|
|
1005
|
+
visual?.removeEventListener('scroll', this.onScroll);
|
|
1006
|
+
this.document.removeEventListener('pointerdown', this.onDocPointerdown, true);
|
|
1007
|
+
const withPopover = el;
|
|
1008
|
+
if (this.popover && typeof withPopover.hidePopover === 'function') {
|
|
1009
|
+
try {
|
|
1010
|
+
withPopover.hidePopover();
|
|
1011
|
+
}
|
|
1012
|
+
catch {
|
|
1013
|
+
// Ignore — the element may already be disconnected.
|
|
1014
|
+
}
|
|
1015
|
+
el.removeAttribute('popover');
|
|
1016
|
+
}
|
|
1017
|
+
// Remove the teleported node ourselves. It was moved out of the component's
|
|
1018
|
+
// view into `document.body`, so Angular's view teardown removes it by
|
|
1019
|
+
// reference (`node.remove()`); re-inserting it anywhere would orphan a
|
|
1020
|
+
// detached copy, leaking one panel per open/close cycle. A plain remove is
|
|
1021
|
+
// idempotent whichever teardown step runs first.
|
|
1022
|
+
el.remove();
|
|
1023
|
+
}
|
|
1024
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.7", ngImport: i0, type: MkAnchoredPanel, deps: [], target: i0.ɵɵFactoryTarget.Directive });
|
|
1025
|
+
static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "22.0.7", type: MkAnchoredPanel, isStandalone: true, selector: "[mkAnchoredPanel]", inputs: { anchor: { classPropertyName: "anchor", publicName: "mkAnchoredPanelFor", isSignal: true, isRequired: false, transformFunction: null }, anchorRect: { classPropertyName: "anchorRect", publicName: "anchorRect", isSignal: true, isRequired: false, transformFunction: null }, placement: { classPropertyName: "placement", publicName: "placement", isSignal: true, isRequired: false, transformFunction: null }, gap: { classPropertyName: "gap", publicName: "gap", isSignal: true, isRequired: false, transformFunction: null }, matchWidth: { classPropertyName: "matchWidth", publicName: "matchWidth", isSignal: true, isRequired: false, transformFunction: null }, flip: { classPropertyName: "flip", publicName: "flip", isSignal: true, isRequired: false, transformFunction: null }, clamp: { classPropertyName: "clamp", publicName: "clamp", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { dismiss: "dismiss" }, exportAs: ["mkAnchoredPanel"], ngImport: i0 });
|
|
1026
|
+
}
|
|
1027
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.7", ngImport: i0, type: MkAnchoredPanel, decorators: [{
|
|
1028
|
+
type: Directive,
|
|
1029
|
+
args: [{
|
|
1030
|
+
selector: '[mkAnchoredPanel]',
|
|
1031
|
+
exportAs: 'mkAnchoredPanel',
|
|
1032
|
+
}]
|
|
1033
|
+
}], propDecorators: { anchor: [{ type: i0.Input, args: [{ isSignal: true, alias: "mkAnchoredPanelFor", required: false }] }], anchorRect: [{ type: i0.Input, args: [{ isSignal: true, alias: "anchorRect", required: false }] }], placement: [{ type: i0.Input, args: [{ isSignal: true, alias: "placement", required: false }] }], gap: [{ type: i0.Input, args: [{ isSignal: true, alias: "gap", required: false }] }], matchWidth: [{ type: i0.Input, args: [{ isSignal: true, alias: "matchWidth", required: false }] }], flip: [{ type: i0.Input, args: [{ isSignal: true, alias: "flip", required: false }] }], clamp: [{ type: i0.Input, args: [{ isSignal: true, alias: "clamp", required: false }] }], dismiss: [{ type: i0.Output, args: ["dismiss"] }] } });
|
|
1034
|
+
|
|
1035
|
+
/** The built-in English validation messages. */
|
|
1036
|
+
const MK_DEFAULT_VALIDATION = {
|
|
1037
|
+
required: 'This field is required',
|
|
1038
|
+
email: 'Enter a valid email address',
|
|
1039
|
+
min: ({ min }) => `Must be ${min} or more`,
|
|
1040
|
+
max: ({ max }) => `Must be ${max} or less`,
|
|
1041
|
+
minlength: ({ requiredLength }) => `Must be at least ${requiredLength} characters`,
|
|
1042
|
+
maxlength: ({ requiredLength }) => `Must be at most ${requiredLength} characters`,
|
|
1043
|
+
pattern: 'Enter a value in the expected format',
|
|
1044
|
+
mkMinDate: ({ min }) => `Must be on or after ${min.toLocaleDateString()}`,
|
|
1045
|
+
mkMaxDate: ({ max }) => `Must be on or before ${max.toLocaleDateString()}`,
|
|
1046
|
+
mkDateFilter: 'This date is not available',
|
|
1047
|
+
mkDateRangeIncomplete: 'Select both a start and an end date',
|
|
1048
|
+
mkMinTime: ({ min }) => `Must be at or after ${min}`,
|
|
1049
|
+
mkMaxTime: ({ max }) => `Must be at or before ${max}`,
|
|
1050
|
+
mkMaxItems: ({ max }) => `Select at most ${max} ${max === 1 ? 'item' : 'items'}`,
|
|
1051
|
+
mkFileSize: ({ name, maxLabel }) => `${name} is larger than ${maxLabel}`,
|
|
1052
|
+
mkFileType: ({ name }) => `${name} is not an accepted file type`,
|
|
1053
|
+
cardNumber: 'Enter a valid card number',
|
|
1054
|
+
iban: ({ expectedLength }) => expectedLength
|
|
1055
|
+
? `Enter a valid IBAN (${expectedLength} characters)`
|
|
1056
|
+
: 'Enter a valid IBAN',
|
|
1057
|
+
postalCode: ({ example }) => `Enter a valid postal code, e.g. ${example}`,
|
|
1058
|
+
taxId: ({ label, example }) => `Enter a valid ${label}, e.g. ${example}`,
|
|
1059
|
+
unknown: 'This value is not valid',
|
|
1060
|
+
};
|
|
1061
|
+
/** The built-in English date names. */
|
|
1062
|
+
const MK_DEFAULT_DATE_NAMES = {
|
|
1063
|
+
months: [
|
|
1064
|
+
'January', 'February', 'March', 'April', 'May', 'June',
|
|
1065
|
+
'July', 'August', 'September', 'October', 'November', 'December',
|
|
1066
|
+
],
|
|
1067
|
+
monthsShort: [
|
|
1068
|
+
'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
|
|
1069
|
+
'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec',
|
|
1070
|
+
],
|
|
1071
|
+
weekdays: [
|
|
1072
|
+
'Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday',
|
|
1073
|
+
],
|
|
1074
|
+
weekdaysShort: ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'],
|
|
1075
|
+
weekdaysNarrow: ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa'],
|
|
1076
|
+
};
|
|
1077
|
+
const PASSWORD_STRENGTH_LABELS = ['Weak', 'Weak', 'Fair', 'Good', 'Strong'];
|
|
1078
|
+
/** The built-in English strings. */
|
|
1079
|
+
const MK_DEFAULT_I18N = {
|
|
1080
|
+
validation: MK_DEFAULT_VALIDATION,
|
|
1081
|
+
close: 'Close',
|
|
1082
|
+
dismiss: 'Dismiss',
|
|
1083
|
+
clear: 'Clear',
|
|
1084
|
+
confirm: 'Confirm',
|
|
1085
|
+
cancel: 'Cancel',
|
|
1086
|
+
ok: 'OK',
|
|
1087
|
+
save: 'Save',
|
|
1088
|
+
submit: 'Submit',
|
|
1089
|
+
edit: 'Edit',
|
|
1090
|
+
remove: 'Remove',
|
|
1091
|
+
removeItem: (name) => `Remove ${name}`,
|
|
1092
|
+
empty: 'Empty',
|
|
1093
|
+
optional: 'Optional',
|
|
1094
|
+
filter: 'Filter…',
|
|
1095
|
+
confirmMessage: 'Are you sure?',
|
|
1096
|
+
decrease: 'Decrease',
|
|
1097
|
+
increase: 'Increase',
|
|
1098
|
+
loading: 'Loading…',
|
|
1099
|
+
noOptions: 'No options',
|
|
1100
|
+
noResults: 'No results',
|
|
1101
|
+
noData: 'No data to display',
|
|
1102
|
+
resultsCount: (count) => (count === 1 ? '1 result' : `${count} results`),
|
|
1103
|
+
previousPage: 'Go to previous page',
|
|
1104
|
+
nextPage: 'Go to next page',
|
|
1105
|
+
goToPage: (page) => `Go to page ${page}`,
|
|
1106
|
+
paginationLabel: 'Pagination',
|
|
1107
|
+
previousSlide: 'Previous slide',
|
|
1108
|
+
nextSlide: 'Next slide',
|
|
1109
|
+
goToSlide: (slide) => `Go to slide ${slide}`,
|
|
1110
|
+
carouselLabel: 'Carousel',
|
|
1111
|
+
pauseSlideshow: 'Pause slideshow',
|
|
1112
|
+
playSlideshow: 'Play slideshow',
|
|
1113
|
+
slideOf: (slide, total) => `Slide ${slide} of ${total}`,
|
|
1114
|
+
dateNames: MK_DEFAULT_DATE_NAMES,
|
|
1115
|
+
previousMonth: 'Previous month',
|
|
1116
|
+
nextMonth: 'Next month',
|
|
1117
|
+
previousYear: 'Previous year',
|
|
1118
|
+
nextYear: 'Next year',
|
|
1119
|
+
previousYears: 'Previous years',
|
|
1120
|
+
nextYears: 'Next years',
|
|
1121
|
+
selectDate: 'Select date…',
|
|
1122
|
+
selectRange: 'Select range…',
|
|
1123
|
+
selectTime: 'Select time…',
|
|
1124
|
+
selectMonth: 'Select month…',
|
|
1125
|
+
selectYear: 'Select year…',
|
|
1126
|
+
selectWeek: 'Select week…',
|
|
1127
|
+
selectPlaceholder: 'Select…',
|
|
1128
|
+
chooseDate: 'Choose date',
|
|
1129
|
+
chooseDateRange: 'Choose date range',
|
|
1130
|
+
chooseMonth: 'Choose month',
|
|
1131
|
+
chooseYear: 'Choose year',
|
|
1132
|
+
chooseWeek: 'Choose week',
|
|
1133
|
+
openCalendar: 'Open calendar',
|
|
1134
|
+
openTimeList: 'Open time list',
|
|
1135
|
+
daySegment: 'Day',
|
|
1136
|
+
monthSegment: 'Month',
|
|
1137
|
+
yearSegment: 'Year',
|
|
1138
|
+
countdownDays: 'days',
|
|
1139
|
+
countdownHours: 'hrs',
|
|
1140
|
+
countdownMinutes: 'min',
|
|
1141
|
+
countdownSeconds: 'sec',
|
|
1142
|
+
countdownFinished: 'Finished',
|
|
1143
|
+
dayEvents: (count, titles) => `${count} ${count === 1 ? 'event' : 'events'}${titles ? `: ${titles}` : ''}`,
|
|
1144
|
+
moreEvents: (count) => `+${count} more`,
|
|
1145
|
+
selectAllRows: 'Select all rows',
|
|
1146
|
+
selectRow: (row) => (row ? `Select row ${row}` : 'Select row'),
|
|
1147
|
+
expandHeader: 'Expand',
|
|
1148
|
+
expandRow: 'Expand row',
|
|
1149
|
+
collapseRow: 'Collapse row',
|
|
1150
|
+
expandGroup: 'Expand group',
|
|
1151
|
+
collapseGroup: 'Collapse group',
|
|
1152
|
+
groupCount: (count) => `${count} item${count === 1 ? '' : 's'}`,
|
|
1153
|
+
resizeColumn: 'Resize column',
|
|
1154
|
+
columnWidth: (column, width) => `${column} column width ${width} pixels`,
|
|
1155
|
+
columnMoved: (column, position, total) => `${column} moved to position ${position} of ${total}`,
|
|
1156
|
+
editCell: 'Press Enter to edit',
|
|
1157
|
+
cellSaved: (value) => `Saved ${value}`,
|
|
1158
|
+
sortedBy: (column, direction) => `Sorted by ${column} ${direction === 'asc' ? 'ascending' : 'descending'}`,
|
|
1159
|
+
sortingCleared: (column) => `Sorting cleared on ${column}`,
|
|
1160
|
+
showPassword: 'Show password',
|
|
1161
|
+
hidePassword: 'Hide password',
|
|
1162
|
+
passwordRuleMinLength: (length) => `At least ${length} characters`,
|
|
1163
|
+
passwordRuleUppercase: 'An uppercase letter',
|
|
1164
|
+
passwordRuleNumber: 'A number',
|
|
1165
|
+
passwordRuleSymbol: 'A symbol',
|
|
1166
|
+
passwordStrength: (score) => PASSWORD_STRENGTH_LABELS[Math.max(0, Math.min(4, score))],
|
|
1167
|
+
passwordStrengthLabel: 'Password strength:',
|
|
1168
|
+
ruleMet: 'Met:',
|
|
1169
|
+
ruleNotMet: 'Not met:',
|
|
1170
|
+
oneTimeCode: 'One-time code',
|
|
1171
|
+
otpDigit: (position) => `Digit ${position}`,
|
|
1172
|
+
numericKeypadLabel: 'Numeric keypad',
|
|
1173
|
+
keypadClear: 'Clear',
|
|
1174
|
+
keypadBackspace: 'Backspace',
|
|
1175
|
+
keypadDigitsEntered: (count, length) => `${count} of ${length} digits entered`,
|
|
1176
|
+
onScreenKeyboardLabel: 'On-screen keyboard',
|
|
1177
|
+
keyboardShift: 'Shift',
|
|
1178
|
+
keyboardSpace: 'Space',
|
|
1179
|
+
keyboardEnter: 'Enter',
|
|
1180
|
+
keyboardAltLayer: 'More characters',
|
|
1181
|
+
keyboardBaseLayer: 'Letters',
|
|
1182
|
+
ratingLabel: 'Rating',
|
|
1183
|
+
ratingValueText: (value, max) => `${value} of ${max} stars`,
|
|
1184
|
+
minimum: 'Minimum',
|
|
1185
|
+
maximum: 'Maximum',
|
|
1186
|
+
chooseColor: 'Choose color',
|
|
1187
|
+
hexValue: 'Hex value',
|
|
1188
|
+
presetColors: 'Preset colors',
|
|
1189
|
+
chooseCountry: 'Choose country',
|
|
1190
|
+
searchCountries: 'Search countries…',
|
|
1191
|
+
phoneNumber: 'Phone number',
|
|
1192
|
+
postalCode: 'Postal code',
|
|
1193
|
+
amount: 'Amount',
|
|
1194
|
+
cardNumber: 'Card number',
|
|
1195
|
+
cardBrand: (brand) => `Card brand: ${brand}`,
|
|
1196
|
+
iban: 'IBAN',
|
|
1197
|
+
taxId: 'Tax ID',
|
|
1198
|
+
signature: 'Signature',
|
|
1199
|
+
jsonLabel: 'JSON',
|
|
1200
|
+
logViewerLabel: 'Log output',
|
|
1201
|
+
logFollow: 'Follow',
|
|
1202
|
+
logCopyAll: 'Copy log',
|
|
1203
|
+
logWrapLines: 'Wrap lines',
|
|
1204
|
+
eventCalendarGrabbed: (title, from, to) => `${title} grabbed, ${from} – ${to}. Use the arrow keys to move, ` +
|
|
1205
|
+
'Shift with Up or Down to change the end time, Enter to save, ' +
|
|
1206
|
+
'Escape to cancel.',
|
|
1207
|
+
eventCalendarPosition: (title, day, from, to) => `${title}, ${day}, ${from} – ${to}.`,
|
|
1208
|
+
eventCalendarMoved: (title, day, from, to) => `${title} moved to ${day}, ${from} – ${to}.`,
|
|
1209
|
+
eventCalendarResized: (title, to) => `${title} now ends at ${to}.`,
|
|
1210
|
+
eventCalendarEditCancelled: 'Move cancelled. The event keeps its original time.',
|
|
1211
|
+
eventCalendarMovableEvent: 'Movable event',
|
|
1212
|
+
previousImage: 'Previous image',
|
|
1213
|
+
nextImage: 'Next image',
|
|
1214
|
+
imageOf: (index, total) => `Image ${index} of ${total}`,
|
|
1215
|
+
viewImage: (alt) => (alt ? `View ${alt}` : 'View image'),
|
|
1216
|
+
imageFailed: 'Image failed to load',
|
|
1217
|
+
zoom: 'Zoom',
|
|
1218
|
+
zoomIn: 'Zoom in',
|
|
1219
|
+
zoomOut: 'Zoom out',
|
|
1220
|
+
mediaLibrary: 'Media library',
|
|
1221
|
+
dropzoneLabel: 'Drag files here or click to browse',
|
|
1222
|
+
uploadFailed: 'Upload failed',
|
|
1223
|
+
retryUpload: 'Retry upload',
|
|
1224
|
+
errorSummaryTitle: 'There is a problem',
|
|
1225
|
+
available: 'Available',
|
|
1226
|
+
selected: 'Selected',
|
|
1227
|
+
transferSelected: (target) => `Move selected to ${target}`,
|
|
1228
|
+
transferAll: (target) => `Move all to ${target}`,
|
|
1229
|
+
itemsMoved: (count, target) => `Moved ${count} ${count === 1 ? 'item' : 'items'} to ${target}`,
|
|
1230
|
+
notificationsTitle: 'Notifications',
|
|
1231
|
+
allCaughtUp: "You're all caught up",
|
|
1232
|
+
markAllRead: 'Mark all read',
|
|
1233
|
+
notificationsUnread: (count) => `Notifications, ${count} unread`,
|
|
1234
|
+
unread: 'Unread',
|
|
1235
|
+
tourStepOf: (step, total) => `Step ${step} of ${total}`,
|
|
1236
|
+
tourSkip: 'Skip',
|
|
1237
|
+
tourPrevious: 'Previous',
|
|
1238
|
+
tourNext: 'Next',
|
|
1239
|
+
tourDone: 'Done',
|
|
1240
|
+
commandPalettePlaceholder: 'Type a command or search…',
|
|
1241
|
+
commandPaletteLabel: 'Command palette',
|
|
1242
|
+
stepCompleted: 'Completed',
|
|
1243
|
+
stepError: 'Has errors',
|
|
1244
|
+
diffAddedLine: 'Added:',
|
|
1245
|
+
diffRemovedLine: 'Removed:',
|
|
1246
|
+
itemAdded: (name) => `${name} added`,
|
|
1247
|
+
itemRemoved: (name) => `${name} removed`,
|
|
1248
|
+
resizePanes: 'Resize panes',
|
|
1249
|
+
backToTop: 'Back to top',
|
|
1250
|
+
breadcrumbLabel: 'Breadcrumb',
|
|
1251
|
+
fabLabel: 'Actions',
|
|
1252
|
+
diffBefore: 'Before',
|
|
1253
|
+
diffAfter: 'After',
|
|
1254
|
+
diffChanges: 'Changes',
|
|
1255
|
+
skipToContent: 'Skip to content',
|
|
1256
|
+
primaryNav: 'Primary',
|
|
1257
|
+
dndPickedUp: (position, total) => `Picked up. Item ${position} of ${total}. ` +
|
|
1258
|
+
'Use the arrow keys to move, space or enter to drop, escape to cancel.',
|
|
1259
|
+
dndMoved: (position, total) => `Moved to position ${position} of ${total}.`,
|
|
1260
|
+
dndMovedToList: (list, position, total) => `Moved to ${list}, position ${position} of ${total}.`,
|
|
1261
|
+
dndDropped: (position) => `Dropped at position ${position}.`,
|
|
1262
|
+
dndCancelled: 'Movement cancelled. Item returned to its starting position.',
|
|
1263
|
+
repeaterAddRow: 'Add row',
|
|
1264
|
+
repeaterRemoveRow: (index) => `Remove row ${index}`,
|
|
1265
|
+
repeaterReorderRow: (index) => `Reorder row ${index}`,
|
|
1266
|
+
repeaterRowMoved: (from, to) => `Row moved from position ${from} to position ${to}.`,
|
|
1267
|
+
fileRejectedType: (name) => `${name}: unsupported type`,
|
|
1268
|
+
fileRejectedSize: (name, limit) => `${name}: exceeds ${limit}`,
|
|
1269
|
+
fileRejectedCount: (name, max) => `${name}: over the ${max}-file limit`,
|
|
1270
|
+
chartCategory: 'Category',
|
|
1271
|
+
chartValue: 'Value',
|
|
1272
|
+
chartSeries: 'Series',
|
|
1273
|
+
chartSlice: 'Slice',
|
|
1274
|
+
chartStage: 'Stage',
|
|
1275
|
+
chartConversion: 'Conversion',
|
|
1276
|
+
chartAxis: 'Axis',
|
|
1277
|
+
chartShare: 'Share',
|
|
1278
|
+
chartLabel: 'Label',
|
|
1279
|
+
qrCodeLabel: (text) => `QR code: ${text}`,
|
|
1280
|
+
blockEditor: {
|
|
1281
|
+
addBlock: 'Add block',
|
|
1282
|
+
addFirstBlock: 'Add your first block',
|
|
1283
|
+
insertBlockHere: 'Insert a block here',
|
|
1284
|
+
blockInserter: 'Block inserter',
|
|
1285
|
+
searchBlocks: 'Search blocks…',
|
|
1286
|
+
blocks: 'Blocks',
|
|
1287
|
+
moveBlockUp: 'Move block up',
|
|
1288
|
+
moveBlockDown: 'Move block down',
|
|
1289
|
+
blockOptions: 'Block options',
|
|
1290
|
+
duplicate: 'Duplicate',
|
|
1291
|
+
remove: 'Remove',
|
|
1292
|
+
textFormatting: 'Text formatting',
|
|
1293
|
+
altText: 'Alt text',
|
|
1294
|
+
caption: 'Caption',
|
|
1295
|
+
alignment: 'Alignment',
|
|
1296
|
+
replaceImage: 'Replace image',
|
|
1297
|
+
imageUrl: 'Image URL',
|
|
1298
|
+
externalContent: 'External content',
|
|
1299
|
+
embedUrl: 'Embed URL',
|
|
1300
|
+
columnSettings: 'Column settings',
|
|
1301
|
+
columns: 'Columns',
|
|
1302
|
+
ratio: 'Ratio',
|
|
1303
|
+
gap: 'Gap',
|
|
1304
|
+
align: 'Align',
|
|
1305
|
+
justify: 'Justify',
|
|
1306
|
+
headingLevel: (level) => `Heading level ${level}`,
|
|
1307
|
+
editorLabel: 'Block content editor',
|
|
1308
|
+
emptyBlockPlaceholder: 'Type / to choose a block, or start writing…',
|
|
1309
|
+
dragHandle: 'Drag handle',
|
|
1310
|
+
turnInto: (label) => `Turn into ${label}`,
|
|
1311
|
+
unknownBlock: (type) => `Unknown block: ${type}`,
|
|
1312
|
+
blockAdded: (label) => `${label} added`,
|
|
1313
|
+
blockDuplicated: 'Block duplicated',
|
|
1314
|
+
blockDeleted: (label) => `${label} deleted`,
|
|
1315
|
+
blockMovedUp: 'Block moved up',
|
|
1316
|
+
blockMovedDown: 'Block moved down',
|
|
1317
|
+
turnedInto: (label) => `Turned into ${label}`,
|
|
1318
|
+
noBlocksMatch: (query) => `No blocks match “${query}”.`,
|
|
1319
|
+
groupText: 'Text',
|
|
1320
|
+
groupMedia: 'Media',
|
|
1321
|
+
groupLayout: 'Layout',
|
|
1322
|
+
blockParagraph: 'Paragraph',
|
|
1323
|
+
blockParagraphDesc: 'Rich text with inline formatting.',
|
|
1324
|
+
blockHeading: 'Heading',
|
|
1325
|
+
blockHeadingDesc: 'A section title (H1–H4).',
|
|
1326
|
+
blockList: 'List',
|
|
1327
|
+
blockListDesc: 'Bulleted or numbered list.',
|
|
1328
|
+
blockQuote: 'Quote',
|
|
1329
|
+
blockQuoteDesc: 'A blockquote with optional citation.',
|
|
1330
|
+
blockCode: 'Code',
|
|
1331
|
+
blockCodeDesc: 'Preformatted, monospace code.',
|
|
1332
|
+
blockImage: 'Image',
|
|
1333
|
+
blockImageDesc: 'Upload or link an image.',
|
|
1334
|
+
blockEmbed: 'Embed',
|
|
1335
|
+
blockEmbedDesc: 'YouTube, Vimeo or any URL.',
|
|
1336
|
+
blockButton: 'Button',
|
|
1337
|
+
blockButtonDesc: 'A call-to-action link button.',
|
|
1338
|
+
blockDivider: 'Divider',
|
|
1339
|
+
blockDividerDesc: 'A horizontal separator.',
|
|
1340
|
+
blockColumns: 'Columns',
|
|
1341
|
+
blockColumnsDesc: 'A responsive multi-column layout.',
|
|
1342
|
+
bold: 'Bold',
|
|
1343
|
+
italic: 'Italic',
|
|
1344
|
+
underline: 'Underline',
|
|
1345
|
+
strikethrough: 'Strikethrough',
|
|
1346
|
+
inlineCode: 'Inline code',
|
|
1347
|
+
link: 'Link',
|
|
1348
|
+
clearFormatting: 'Clear formatting',
|
|
1349
|
+
linkUrlPrompt: 'Link URL',
|
|
1350
|
+
editableText: 'Editable text',
|
|
1351
|
+
headingPlaceholder: (level) => `Heading ${level}`,
|
|
1352
|
+
headingLevelGroup: 'Heading level',
|
|
1353
|
+
listStyle: 'List style',
|
|
1354
|
+
bulleted: 'Bulleted',
|
|
1355
|
+
numbered: 'Numbered',
|
|
1356
|
+
listItem: 'List item',
|
|
1357
|
+
quoteText: 'Quote text',
|
|
1358
|
+
citation: 'Citation',
|
|
1359
|
+
addCitation: '— Add a citation',
|
|
1360
|
+
codeLanguage: 'Code language',
|
|
1361
|
+
codeLanguagePlaceholder: 'language (optional)',
|
|
1362
|
+
enterCode: 'Enter code…',
|
|
1363
|
+
imageWidth: (percent) => `Width: ${percent}%`,
|
|
1364
|
+
uploading: 'Uploading…',
|
|
1365
|
+
dropImagePrompt: 'Drag & drop an image, or',
|
|
1366
|
+
chooseFile: 'Choose file',
|
|
1367
|
+
pasteImageUrl: '…or paste an image URL',
|
|
1368
|
+
notAnImage: 'Please choose an image file.',
|
|
1369
|
+
uploadFailed: 'Upload failed. Try again or paste a URL.',
|
|
1370
|
+
imageAdded: 'Image added',
|
|
1371
|
+
pasteEmbedUrl: 'Paste a YouTube, Vimeo or other URL…',
|
|
1372
|
+
embedFallbackNote: 'That URL can’t be embedded, but it will render as a link.',
|
|
1373
|
+
embedTitle: (provider) => `${provider} embed`,
|
|
1374
|
+
embedAdded: (provider) => `${provider} embed added`,
|
|
1375
|
+
embeddedContent: 'Embedded content',
|
|
1376
|
+
buttonLabel: 'Label',
|
|
1377
|
+
buttonLink: 'Link (href)',
|
|
1378
|
+
buttonTone: 'Tone',
|
|
1379
|
+
buttonVariant: 'Variant',
|
|
1380
|
+
buttonDefaultLabel: 'Click me',
|
|
1381
|
+
alignLeft: 'Left',
|
|
1382
|
+
alignCenter: 'Center',
|
|
1383
|
+
alignRight: 'Right',
|
|
1384
|
+
tonePrimary: 'Primary',
|
|
1385
|
+
toneNeutral: 'Neutral',
|
|
1386
|
+
toneSuccess: 'Success',
|
|
1387
|
+
toneWarning: 'Warning',
|
|
1388
|
+
toneDanger: 'Danger',
|
|
1389
|
+
toneInfo: 'Info',
|
|
1390
|
+
variantSolid: 'Solid',
|
|
1391
|
+
variantSoft: 'Soft',
|
|
1392
|
+
variantOutline: 'Outline',
|
|
1393
|
+
ratioEqual: 'Equal',
|
|
1394
|
+
alignStretch: 'Stretch',
|
|
1395
|
+
alignTop: 'Top',
|
|
1396
|
+
alignMiddle: 'Middle',
|
|
1397
|
+
alignBottom: 'Bottom',
|
|
1398
|
+
justifyStart: 'Start',
|
|
1399
|
+
justifyCenter: 'Center',
|
|
1400
|
+
justifyEnd: 'End',
|
|
1401
|
+
justifyBetween: 'Space between',
|
|
1402
|
+
},
|
|
1403
|
+
};
|
|
1404
|
+
/**
|
|
1405
|
+
* The active string map. Defaults to {@link MK_DEFAULT_I18N}; override with
|
|
1406
|
+
* {@link provideMkI18n}. Inject it (`inject(MK_I18N)`) wherever a built-in
|
|
1407
|
+
* string is rendered.
|
|
1408
|
+
*/
|
|
1409
|
+
const MK_I18N = new InjectionToken('MK_I18N', {
|
|
1410
|
+
providedIn: 'root',
|
|
1411
|
+
factory: () => MK_DEFAULT_I18N,
|
|
1412
|
+
});
|
|
1413
|
+
/**
|
|
1414
|
+
* Provide localised strings (merged over the English defaults) — pass any
|
|
1415
|
+
* subset. The nested `dateNames`, `blockEditor` and `validation` groups are
|
|
1416
|
+
* merged deeply, so partial overrides of those work too.
|
|
1417
|
+
*
|
|
1418
|
+
* ```ts
|
|
1419
|
+
* bootstrapApplication(App, {
|
|
1420
|
+
* providers: [provideMkI18n({ noResults: 'Brak wyników', close: 'Zamknij' })],
|
|
1421
|
+
* });
|
|
1422
|
+
* ```
|
|
1423
|
+
*/
|
|
1424
|
+
function provideMkI18n(overrides) {
|
|
1425
|
+
const value = {
|
|
1426
|
+
...MK_DEFAULT_I18N,
|
|
1427
|
+
...overrides,
|
|
1428
|
+
dateNames: { ...MK_DEFAULT_DATE_NAMES, ...overrides.dateNames },
|
|
1429
|
+
blockEditor: { ...MK_DEFAULT_I18N.blockEditor, ...overrides.blockEditor },
|
|
1430
|
+
validation: { ...MK_DEFAULT_VALIDATION, ...overrides.validation },
|
|
1431
|
+
};
|
|
1432
|
+
return { provide: MK_I18N, useValue: value };
|
|
1433
|
+
}
|
|
1434
|
+
|
|
1435
|
+
/**
|
|
1436
|
+
* Field context — the contract a field wrapper (`mk-form-field`) exposes to
|
|
1437
|
+
* the control nested inside it. Controls inject it optionally
|
|
1438
|
+
* (`inject(MkFieldContext, { optional: true })`) to adopt the wrapper's
|
|
1439
|
+
* control id and to reflect its required/error state and `aria-describedby`
|
|
1440
|
+
* wiring — without depending on the `@mk-kit/ui/forms` entry point.
|
|
1441
|
+
*
|
|
1442
|
+
* `MkFormField` provides itself under this token; standalone usage (no
|
|
1443
|
+
* wrapper) simply yields `null`.
|
|
1444
|
+
*/
|
|
1445
|
+
class MkFieldContext {
|
|
1446
|
+
}
|
|
1447
|
+
|
|
1448
|
+
/**
|
|
1449
|
+
* Dependency-free syntax highlighting for {@link MkCodeEditor}. Each function
|
|
1450
|
+
* takes source text and returns an HTML string of `<span class="mk-tok-…">`
|
|
1451
|
+
* tokens. Input is HTML-escaped first, so the result is safe to render.
|
|
1452
|
+
*/
|
|
1453
|
+
/** Escape the three characters that could break out of HTML text content. */
|
|
1454
|
+
function mkEscapeHtml(src) {
|
|
1455
|
+
return src.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
1456
|
+
}
|
|
1457
|
+
/**
|
|
1458
|
+
* Highlight a JSON document. Recognises object keys, strings, numbers, the
|
|
1459
|
+
* `true`/`false`/`null` literals and structural punctuation. Invalid JSON is
|
|
1460
|
+
* still highlighted token-by-token (the editor validates separately).
|
|
1461
|
+
*/
|
|
1462
|
+
function mkHighlightJson(src) {
|
|
1463
|
+
const esc = mkEscapeHtml(src);
|
|
1464
|
+
return esc.replace(
|
|
1465
|
+
// 1: string (+ optional following colon → key) · 2: colon
|
|
1466
|
+
// 3: keyword · 4: number · 5: punctuation
|
|
1467
|
+
/("(?:\\.|[^"\\])*")(\s*:)?|\b(true|false|null)\b|(-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?)|([{}\[\],:])/g, (match, str, colon, keyword, num, punc) => {
|
|
1468
|
+
if (str !== undefined) {
|
|
1469
|
+
if (colon !== undefined) {
|
|
1470
|
+
return `<span class="mk-tok-key">${str}</span><span class="mk-tok-punc">${colon}</span>`;
|
|
1471
|
+
}
|
|
1472
|
+
return `<span class="mk-tok-str">${str}</span>`;
|
|
1473
|
+
}
|
|
1474
|
+
if (keyword !== undefined)
|
|
1475
|
+
return `<span class="mk-tok-kw">${keyword}</span>`;
|
|
1476
|
+
if (num !== undefined)
|
|
1477
|
+
return `<span class="mk-tok-num">${num}</span>`;
|
|
1478
|
+
if (punc !== undefined)
|
|
1479
|
+
return `<span class="mk-tok-punc">${punc}</span>`;
|
|
1480
|
+
return match;
|
|
1481
|
+
});
|
|
1482
|
+
}
|
|
1483
|
+
/** Highlight `src` for `language`, falling back to escaped plain text. */
|
|
1484
|
+
function mkHighlight(src, language) {
|
|
1485
|
+
return language === 'json' ? mkHighlightJson(src) : mkEscapeHtml(src);
|
|
1486
|
+
}
|
|
1487
|
+
|
|
1488
|
+
/**
|
|
1489
|
+
* Re-validates the bound form control whenever a validator's inputs change.
|
|
1490
|
+
*
|
|
1491
|
+
* A `Validator` whose constraints come from component inputs (`[min]`,
|
|
1492
|
+
* `[max]`, `[required]`, …) must tell Angular when those inputs change,
|
|
1493
|
+
* otherwise the control keeps the verdict computed under the old constraints.
|
|
1494
|
+
* `NgModel`/`FormControlName` pass a callback to `registerOnValidatorChange`
|
|
1495
|
+
* for exactly this; call it from an effect over the constraint signals.
|
|
1496
|
+
*
|
|
1497
|
+
* Must be called from an injection context (i.e. as a field initialiser).
|
|
1498
|
+
*
|
|
1499
|
+
* ```ts
|
|
1500
|
+
* private readonly validatorChange = mkValidatorChange(() => {
|
|
1501
|
+
* this.min();
|
|
1502
|
+
* this.max();
|
|
1503
|
+
* });
|
|
1504
|
+
*
|
|
1505
|
+
* registerOnValidatorChange(fn: () => void): void {
|
|
1506
|
+
* this.validatorChange.register(fn);
|
|
1507
|
+
* }
|
|
1508
|
+
* ```
|
|
1509
|
+
*
|
|
1510
|
+
* @param deps Reads every signal the validator depends on.
|
|
1511
|
+
*/
|
|
1512
|
+
function mkValidatorChange(deps) {
|
|
1513
|
+
let onChange = () => { };
|
|
1514
|
+
let primed = false;
|
|
1515
|
+
effect(() => {
|
|
1516
|
+
deps();
|
|
1517
|
+
// Skip the effect's initial run: the control validates once on bind
|
|
1518
|
+
// anyway, and re-entering validation during setup is wasted work.
|
|
1519
|
+
if (!primed) {
|
|
1520
|
+
primed = true;
|
|
1521
|
+
return;
|
|
1522
|
+
}
|
|
1523
|
+
// The callback synchronously calls `updateValueAndValidity()`, which reads
|
|
1524
|
+
// and writes signals — keep it out of this effect's dependency graph.
|
|
1525
|
+
untracked(() => onChange());
|
|
1526
|
+
});
|
|
1527
|
+
return {
|
|
1528
|
+
register(fn) {
|
|
1529
|
+
onChange = fn;
|
|
1530
|
+
},
|
|
1531
|
+
notify() {
|
|
1532
|
+
onChange();
|
|
1533
|
+
},
|
|
1534
|
+
};
|
|
1535
|
+
}
|
|
1536
|
+
|
|
1537
|
+
/**
|
|
1538
|
+
* Resolves the message for the first error on a control, mirroring how
|
|
1539
|
+
* `mat-error` shows one message at a time.
|
|
1540
|
+
*
|
|
1541
|
+
* Lookup order per key: the field's `overrides`, then the i18n `validation`
|
|
1542
|
+
* table, then the error payload's own `message` string (so a custom validator
|
|
1543
|
+
* can carry its text), then the generic fallback. Keys are visited in the
|
|
1544
|
+
* order the validators put them on the control, so composing
|
|
1545
|
+
* `[Validators.required, Validators.email]` surfaces "required" while empty.
|
|
1546
|
+
*
|
|
1547
|
+
* @returns The message, or `null` when there are no errors.
|
|
1548
|
+
*/
|
|
1549
|
+
function mkFirstErrorMessage(errors, strings, overrides) {
|
|
1550
|
+
if (!errors)
|
|
1551
|
+
return null;
|
|
1552
|
+
for (const key of Object.keys(errors)) {
|
|
1553
|
+
const payload = errors[key];
|
|
1554
|
+
const override = overrides?.[key];
|
|
1555
|
+
if (typeof override === 'string')
|
|
1556
|
+
return override;
|
|
1557
|
+
if (typeof override === 'function')
|
|
1558
|
+
return override(payload);
|
|
1559
|
+
const builtin = strings[key];
|
|
1560
|
+
if (typeof builtin === 'string')
|
|
1561
|
+
return builtin;
|
|
1562
|
+
if (typeof builtin === 'function')
|
|
1563
|
+
return builtin(payload);
|
|
1564
|
+
if (payload && typeof payload === 'object' && typeof payload.message === 'string') {
|
|
1565
|
+
return payload.message;
|
|
1566
|
+
}
|
|
1567
|
+
}
|
|
1568
|
+
return errors ? strings.unknown : null;
|
|
1569
|
+
}
|
|
1570
|
+
|
|
1571
|
+
/**
|
|
1572
|
+
* Generated bundle index. Do not edit.
|
|
1573
|
+
*/
|
|
1574
|
+
|
|
1575
|
+
export { MK_DEFAULT_DATE_NAMES, MK_DEFAULT_I18N, MK_DEFAULT_VALIDATION, MK_I18N, MK_OVERLAY_DATA, MkAnchoredPanel, MkFieldContext, MkFocusTrap, MkLiveAnnouncer, MkOverlayRef, MkOverlayService, MkThemeService, mkComputeAnchoredPosition, mkFirstErrorMessage, mkGetFocusable, mkHighlight, mkHighlightJson, mkUniqueId, mkValidatorChange, provideMkI18n };
|
|
1576
|
+
//# sourceMappingURL=mk-kit-ui-core.mjs.map
|