@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,1779 @@
|
|
|
1
|
+
import * as i0 from '@angular/core';
|
|
2
|
+
import { inject, ElementRef, DOCUMENT, PLATFORM_ID, input, booleanAttribute, output, effect, Directive, numberAttribute, signal, afterNextRender, Injector, computed, Injectable, DestroyRef, TemplateRef, ViewContainerRef } from '@angular/core';
|
|
3
|
+
import { isPlatformBrowser } from '@angular/common';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Emits when a pointer press lands outside the host element — the building block
|
|
7
|
+
* for dismissing menus, popovers and custom dropdowns. Listens on the document
|
|
8
|
+
* in the capture phase so it fires before the target handles the event.
|
|
9
|
+
*
|
|
10
|
+
* ```html
|
|
11
|
+
* <div class="panel" (mkClickOutside)="close()">…</div>
|
|
12
|
+
* ```
|
|
13
|
+
*/
|
|
14
|
+
class MkClickOutside {
|
|
15
|
+
host = inject(ElementRef);
|
|
16
|
+
document = inject(DOCUMENT);
|
|
17
|
+
isBrowser = isPlatformBrowser(inject(PLATFORM_ID));
|
|
18
|
+
/** Disable the listener without removing the directive. */
|
|
19
|
+
enabled = input(true, { ...(ngDevMode ? { debugName: "enabled" } : /* istanbul ignore next */ {}), alias: 'mkClickOutsideEnabled',
|
|
20
|
+
transform: booleanAttribute });
|
|
21
|
+
/** Emits the originating event when a pointerdown occurs outside the host. */
|
|
22
|
+
mkClickOutside = output();
|
|
23
|
+
onPointerdown = (event) => {
|
|
24
|
+
// Defensive: the listener detaches on the effect flush after `enabled`
|
|
25
|
+
// flips false, so also ignore any event racing in before that.
|
|
26
|
+
if (!this.enabled())
|
|
27
|
+
return;
|
|
28
|
+
const target = event.target;
|
|
29
|
+
if (target && !this.host.nativeElement.contains(target)) {
|
|
30
|
+
this.mkClickOutside.emit(event);
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
constructor() {
|
|
34
|
+
// The document listener exists only while `enabled()` — an idle directive
|
|
35
|
+
// must not run its handler (and wake change detection) on every pointer
|
|
36
|
+
// press on the page. Effect cleanup also detaches it on destroy.
|
|
37
|
+
effect((onCleanup) => {
|
|
38
|
+
if (!this.isBrowser || !this.enabled())
|
|
39
|
+
return;
|
|
40
|
+
this.document.addEventListener('pointerdown', this.onPointerdown, true);
|
|
41
|
+
onCleanup(() => this.document.removeEventListener('pointerdown', this.onPointerdown, true));
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.7", ngImport: i0, type: MkClickOutside, deps: [], target: i0.ɵɵFactoryTarget.Directive });
|
|
45
|
+
static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "22.0.7", type: MkClickOutside, isStandalone: true, selector: "[mkClickOutside]", inputs: { enabled: { classPropertyName: "enabled", publicName: "mkClickOutsideEnabled", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { mkClickOutside: "mkClickOutside" }, exportAs: ["mkClickOutside"], ngImport: i0 });
|
|
46
|
+
}
|
|
47
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.7", ngImport: i0, type: MkClickOutside, decorators: [{
|
|
48
|
+
type: Directive,
|
|
49
|
+
args: [{
|
|
50
|
+
selector: '[mkClickOutside]',
|
|
51
|
+
exportAs: 'mkClickOutside',
|
|
52
|
+
}]
|
|
53
|
+
}], ctorParameters: () => [], propDecorators: { enabled: [{ type: i0.Input, args: [{ isSignal: true, alias: "mkClickOutsideEnabled", required: false }] }], mkClickOutside: [{ type: i0.Output, args: ["mkClickOutside"] }] } });
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Copies text to the clipboard when the host is clicked. Emits `(copied)` on
|
|
57
|
+
* success and `(copyFailed)` on error, and exposes a transient `copied` signal
|
|
58
|
+
* (via `exportAs`) so a template can show a "Copied!" state without extra code.
|
|
59
|
+
*
|
|
60
|
+
* ```html
|
|
61
|
+
* <button mkButton [mkCopyToClipboard]="token" #c="mkCopyToClipboard">
|
|
62
|
+
* {{ c.justCopied() ? 'Copied!' : 'Copy' }}
|
|
63
|
+
* </button>
|
|
64
|
+
* ```
|
|
65
|
+
*/
|
|
66
|
+
class MkCopyToClipboard {
|
|
67
|
+
document = inject(DOCUMENT);
|
|
68
|
+
isBrowser = isPlatformBrowser(inject(PLATFORM_ID));
|
|
69
|
+
/** The text to copy. */
|
|
70
|
+
text = input.required({ ...(ngDevMode ? { debugName: "text" } : /* istanbul ignore next */ {}), alias: 'mkCopyToClipboard' });
|
|
71
|
+
/** How long (ms) the `copied` signal stays true after a successful copy. */
|
|
72
|
+
feedbackDuration = input(1500, { ...(ngDevMode ? { debugName: "feedbackDuration" } : /* istanbul ignore next */ {}), alias: 'mkCopyFeedbackDuration',
|
|
73
|
+
transform: numberAttribute });
|
|
74
|
+
/** Emits the copied text on success. */
|
|
75
|
+
copied = output({ alias: 'copiedText' });
|
|
76
|
+
/** Emits the error on failure. */
|
|
77
|
+
copyFailed = output();
|
|
78
|
+
_copied = signal(false, /* @ts-ignore */
|
|
79
|
+
...(ngDevMode ? [{ debugName: "_copied" }] : /* istanbul ignore next */ []));
|
|
80
|
+
/** True for `feedbackDuration` ms after a successful copy. */
|
|
81
|
+
justCopied = this._copied.asReadonly();
|
|
82
|
+
resetTimer;
|
|
83
|
+
/** Copy the text now. Bound to the host click, but callable directly too. */
|
|
84
|
+
async copy() {
|
|
85
|
+
if (!this.isBrowser)
|
|
86
|
+
return;
|
|
87
|
+
const value = this.text();
|
|
88
|
+
try {
|
|
89
|
+
await this.write(value);
|
|
90
|
+
this.copied.emit(value);
|
|
91
|
+
this.flagCopied();
|
|
92
|
+
}
|
|
93
|
+
catch (err) {
|
|
94
|
+
this.copyFailed.emit(err);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
async write(value) {
|
|
98
|
+
const clipboard = this.document.defaultView?.navigator?.clipboard;
|
|
99
|
+
if (clipboard?.writeText) {
|
|
100
|
+
await clipboard.writeText(value);
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
// Fallback for non-secure contexts: a hidden textarea + execCommand.
|
|
104
|
+
const ta = this.document.createElement('textarea');
|
|
105
|
+
ta.value = value;
|
|
106
|
+
ta.setAttribute('readonly', '');
|
|
107
|
+
ta.style.cssText = 'position:fixed;top:-9999px;opacity:0;';
|
|
108
|
+
this.document.body.appendChild(ta);
|
|
109
|
+
ta.select();
|
|
110
|
+
const ok = typeof this.document.execCommand === 'function' &&
|
|
111
|
+
this.document.execCommand('copy');
|
|
112
|
+
ta.remove();
|
|
113
|
+
if (!ok)
|
|
114
|
+
throw new Error('Clipboard copy failed');
|
|
115
|
+
}
|
|
116
|
+
flagCopied() {
|
|
117
|
+
this._copied.set(true);
|
|
118
|
+
if (this.resetTimer)
|
|
119
|
+
clearTimeout(this.resetTimer);
|
|
120
|
+
// Reset after the feedback window (guarded so tests without a view work).
|
|
121
|
+
this.resetTimer = setTimeout(() => this._copied.set(false), this.feedbackDuration());
|
|
122
|
+
}
|
|
123
|
+
ngOnDestroy() {
|
|
124
|
+
if (this.resetTimer)
|
|
125
|
+
clearTimeout(this.resetTimer);
|
|
126
|
+
}
|
|
127
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.7", ngImport: i0, type: MkCopyToClipboard, deps: [], target: i0.ɵɵFactoryTarget.Directive });
|
|
128
|
+
static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "22.0.7", type: MkCopyToClipboard, isStandalone: true, selector: "[mkCopyToClipboard]", inputs: { text: { classPropertyName: "text", publicName: "mkCopyToClipboard", isSignal: true, isRequired: true, transformFunction: null }, feedbackDuration: { classPropertyName: "feedbackDuration", publicName: "mkCopyFeedbackDuration", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { copied: "copiedText", copyFailed: "copyFailed" }, host: { listeners: { "click": "copy()" } }, exportAs: ["mkCopyToClipboard"], ngImport: i0 });
|
|
129
|
+
}
|
|
130
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.7", ngImport: i0, type: MkCopyToClipboard, decorators: [{
|
|
131
|
+
type: Directive,
|
|
132
|
+
args: [{
|
|
133
|
+
selector: '[mkCopyToClipboard]',
|
|
134
|
+
exportAs: 'mkCopyToClipboard',
|
|
135
|
+
host: {
|
|
136
|
+
'(click)': 'copy()',
|
|
137
|
+
},
|
|
138
|
+
}]
|
|
139
|
+
}], propDecorators: { text: [{ type: i0.Input, args: [{ isSignal: true, alias: "mkCopyToClipboard", required: true }] }], feedbackDuration: [{ type: i0.Input, args: [{ isSignal: true, alias: "mkCopyFeedbackDuration", required: false }] }], copied: [{ type: i0.Output, args: ["copiedText"] }], copyFailed: [{ type: i0.Output, args: ["copyFailed"] }] } });
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Grows a `<textarea>` to fit its content, between `minRows` and `maxRows`
|
|
143
|
+
* (0 = unbounded). Resizes on input and when the bound value changes.
|
|
144
|
+
*
|
|
145
|
+
* ```html
|
|
146
|
+
* <textarea mkInput mkAutosize [mkAutosizeMaxRows]="8" [(ngModel)]="note"></textarea>
|
|
147
|
+
* ```
|
|
148
|
+
*/
|
|
149
|
+
class MkAutosize {
|
|
150
|
+
el = inject(ElementRef);
|
|
151
|
+
document = inject(DOCUMENT);
|
|
152
|
+
isBrowser = isPlatformBrowser(inject(PLATFORM_ID));
|
|
153
|
+
/** Minimum height in rows. */
|
|
154
|
+
minRows = input(1, { ...(ngDevMode ? { debugName: "minRows" } : /* istanbul ignore next */ {}), alias: 'mkAutosizeMinRows',
|
|
155
|
+
transform: numberAttribute });
|
|
156
|
+
/** Maximum height in rows before scrolling (0 = unbounded). */
|
|
157
|
+
maxRows = input(0, { ...(ngDevMode ? { debugName: "maxRows" } : /* istanbul ignore next */ {}), alias: 'mkAutosizeMaxRows',
|
|
158
|
+
transform: numberAttribute });
|
|
159
|
+
/** Bind the textarea's value so programmatic changes trigger a resize. */
|
|
160
|
+
value = input(undefined, { ...(ngDevMode ? { debugName: "value" } : /* istanbul ignore next */ {}), alias: 'mkAutosizeValue' });
|
|
161
|
+
constructor() {
|
|
162
|
+
afterNextRender(() => this.resize());
|
|
163
|
+
// Re-fit whenever the bound value (or the row bounds) change.
|
|
164
|
+
effect(() => {
|
|
165
|
+
this.value();
|
|
166
|
+
this.minRows();
|
|
167
|
+
this.maxRows();
|
|
168
|
+
if (this.isBrowser)
|
|
169
|
+
queueMicrotask(() => this.resize());
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
/** Recompute the textarea height from its content. */
|
|
173
|
+
resize() {
|
|
174
|
+
if (!this.isBrowser)
|
|
175
|
+
return;
|
|
176
|
+
const ta = this.el.nativeElement;
|
|
177
|
+
const style = this.document.defaultView?.getComputedStyle(ta);
|
|
178
|
+
const line = parseFloat(style?.lineHeight ?? '') || 20;
|
|
179
|
+
const vPad = parseFloat(style?.paddingTop ?? '0') +
|
|
180
|
+
parseFloat(style?.paddingBottom ?? '0') +
|
|
181
|
+
parseFloat(style?.borderTopWidth ?? '0') +
|
|
182
|
+
parseFloat(style?.borderBottomWidth ?? '0');
|
|
183
|
+
ta.style.height = 'auto';
|
|
184
|
+
const content = ta.scrollHeight;
|
|
185
|
+
const min = this.minRows() * line + vPad;
|
|
186
|
+
const max = this.maxRows() > 0 ? this.maxRows() * line + vPad : Infinity;
|
|
187
|
+
const height = Math.min(max, Math.max(min, content));
|
|
188
|
+
ta.style.height = `${height}px`;
|
|
189
|
+
ta.style.overflowY = content > max ? 'auto' : 'hidden';
|
|
190
|
+
}
|
|
191
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.7", ngImport: i0, type: MkAutosize, deps: [], target: i0.ɵɵFactoryTarget.Directive });
|
|
192
|
+
static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "22.0.7", type: MkAutosize, isStandalone: true, selector: "textarea[mkAutosize]", inputs: { minRows: { classPropertyName: "minRows", publicName: "mkAutosizeMinRows", isSignal: true, isRequired: false, transformFunction: null }, maxRows: { classPropertyName: "maxRows", publicName: "mkAutosizeMaxRows", isSignal: true, isRequired: false, transformFunction: null }, value: { classPropertyName: "value", publicName: "mkAutosizeValue", isSignal: true, isRequired: false, transformFunction: null } }, host: { listeners: { "input": "resize()" }, styleAttribute: "resize: none;" }, ngImport: i0 });
|
|
193
|
+
}
|
|
194
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.7", ngImport: i0, type: MkAutosize, decorators: [{
|
|
195
|
+
type: Directive,
|
|
196
|
+
args: [{
|
|
197
|
+
selector: 'textarea[mkAutosize]',
|
|
198
|
+
host: {
|
|
199
|
+
'(input)': 'resize()',
|
|
200
|
+
style: 'resize: none;',
|
|
201
|
+
},
|
|
202
|
+
}]
|
|
203
|
+
}], ctorParameters: () => [], propDecorators: { minRows: [{ type: i0.Input, args: [{ isSignal: true, alias: "mkAutosizeMinRows", required: false }] }], maxRows: [{ type: i0.Input, args: [{ isSignal: true, alias: "mkAutosizeMaxRows", required: false }] }], value: [{ type: i0.Input, args: [{ isSignal: true, alias: "mkAutosizeValue", required: false }] }] } });
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Focuses the host element once it renders. Handy for dialogs, drawers and
|
|
207
|
+
* newly-revealed forms. Disable conditionally with `[mkAutofocus]="cond"`.
|
|
208
|
+
*
|
|
209
|
+
* ```html
|
|
210
|
+
* <input mkInput mkAutofocus />
|
|
211
|
+
* <input mkInput [mkAutofocus]="isEditing()" [mkAutofocusDelay]="100" />
|
|
212
|
+
* ```
|
|
213
|
+
*/
|
|
214
|
+
class MkAutofocus {
|
|
215
|
+
el = inject(ElementRef);
|
|
216
|
+
document = inject(DOCUMENT);
|
|
217
|
+
isBrowser = isPlatformBrowser(inject(PLATFORM_ID));
|
|
218
|
+
/** Whether to focus. Bare `mkAutofocus` = true. */
|
|
219
|
+
enabled = input(true, { ...(ngDevMode ? { debugName: "enabled" } : /* istanbul ignore next */ {}), alias: 'mkAutofocus',
|
|
220
|
+
transform: booleanAttribute });
|
|
221
|
+
/** Delay before focusing, in ms (e.g. to wait out an entrance animation). */
|
|
222
|
+
delay = input(0, { ...(ngDevMode ? { debugName: "delay" } : /* istanbul ignore next */ {}), alias: 'mkAutofocusDelay',
|
|
223
|
+
transform: numberAttribute });
|
|
224
|
+
timeoutId;
|
|
225
|
+
constructor() {
|
|
226
|
+
afterNextRender(() => {
|
|
227
|
+
if (!this.isBrowser || !this.enabled())
|
|
228
|
+
return;
|
|
229
|
+
if (this.delay() > 0) {
|
|
230
|
+
this.timeoutId = this.document.defaultView?.setTimeout(() => this.focus(), this.delay());
|
|
231
|
+
}
|
|
232
|
+
else {
|
|
233
|
+
this.focus();
|
|
234
|
+
}
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
focus() {
|
|
238
|
+
this.el.nativeElement.focus();
|
|
239
|
+
}
|
|
240
|
+
ngOnDestroy() {
|
|
241
|
+
if (this.timeoutId != null) {
|
|
242
|
+
this.document.defaultView?.clearTimeout(this.timeoutId);
|
|
243
|
+
this.timeoutId = undefined;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.7", ngImport: i0, type: MkAutofocus, deps: [], target: i0.ɵɵFactoryTarget.Directive });
|
|
247
|
+
static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "22.0.7", type: MkAutofocus, isStandalone: true, selector: "[mkAutofocus]", inputs: { enabled: { classPropertyName: "enabled", publicName: "mkAutofocus", isSignal: true, isRequired: false, transformFunction: null }, delay: { classPropertyName: "delay", publicName: "mkAutofocusDelay", isSignal: true, isRequired: false, transformFunction: null } }, ngImport: i0 });
|
|
248
|
+
}
|
|
249
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.7", ngImport: i0, type: MkAutofocus, decorators: [{
|
|
250
|
+
type: Directive,
|
|
251
|
+
args: [{
|
|
252
|
+
selector: '[mkAutofocus]',
|
|
253
|
+
}]
|
|
254
|
+
}], ctorParameters: () => [], propDecorators: { enabled: [{ type: i0.Input, args: [{ isSignal: true, alias: "mkAutofocus", required: false }] }], delay: [{ type: i0.Input, args: [{ isSignal: true, alias: "mkAutofocusDelay", required: false }] }] } });
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Scrollspy — tracks which section is currently in view and exposes its `id`,
|
|
258
|
+
* so a table of contents / "on this page" nav can highlight the active link.
|
|
259
|
+
*
|
|
260
|
+
* Apply it to any element (typically the nav) and point `mkScrollspy` at a CSS
|
|
261
|
+
* selector matching the section elements to watch (each needs an `id`). Read the
|
|
262
|
+
* active id via the exported instance, or bind `(activeChange)`:
|
|
263
|
+
*
|
|
264
|
+
* ```html
|
|
265
|
+
* <nav mkScrollspy="main :is(h2, h3)[id]" #spy="mkScrollspy">
|
|
266
|
+
* <a href="#intro" [class.is-active]="spy.activeId() === 'intro'">Intro</a>
|
|
267
|
+
* …
|
|
268
|
+
* </nav>
|
|
269
|
+
* ```
|
|
270
|
+
*
|
|
271
|
+
* A section activates once its top passes `offset` px below the top of the
|
|
272
|
+
* scroll root (default the viewport). When scrolling through a gap between
|
|
273
|
+
* sections the previous one stays active until the next crosses the line.
|
|
274
|
+
*/
|
|
275
|
+
class MkScrollspy {
|
|
276
|
+
document = inject(DOCUMENT);
|
|
277
|
+
isBrowser = isPlatformBrowser(inject(PLATFORM_ID));
|
|
278
|
+
/** CSS selector for the section elements to track (queried within `root`). */
|
|
279
|
+
sections = input.required({ ...(ngDevMode ? { debugName: "sections" } : /* istanbul ignore next */ {}), alias: 'mkScrollspy' });
|
|
280
|
+
/**
|
|
281
|
+
* Scroll container. Sections are queried inside it and its top edge is the
|
|
282
|
+
* activation reference. Defaults to the whole document / viewport.
|
|
283
|
+
*/
|
|
284
|
+
root = input(null, /* @ts-ignore */
|
|
285
|
+
...(ngDevMode ? [{ debugName: "root" }] : /* istanbul ignore next */ []));
|
|
286
|
+
/**
|
|
287
|
+
* Distance (px) below the scroll root's top at which a section becomes
|
|
288
|
+
* active. Raise it to account for a sticky header.
|
|
289
|
+
*/
|
|
290
|
+
offset = input(96, /* @ts-ignore */
|
|
291
|
+
...(ngDevMode ? [{ debugName: "offset" }] : /* istanbul ignore next */ []));
|
|
292
|
+
/** The id of the currently active section (`null` before the first one). */
|
|
293
|
+
activeId = signal(null, /* @ts-ignore */
|
|
294
|
+
...(ngDevMode ? [{ debugName: "activeId" }] : /* istanbul ignore next */ []));
|
|
295
|
+
/** Emitted whenever the active section changes. */
|
|
296
|
+
activeChange = output();
|
|
297
|
+
scrollSource;
|
|
298
|
+
constructor() {
|
|
299
|
+
// afterNextRender runs once inputs are bound, so root()/sections() resolve
|
|
300
|
+
// and the projected sections exist in the DOM.
|
|
301
|
+
afterNextRender(() => {
|
|
302
|
+
if (!this.isBrowser)
|
|
303
|
+
return;
|
|
304
|
+
const view = this.document.defaultView;
|
|
305
|
+
this.scrollSource = this.root() ?? view ?? undefined;
|
|
306
|
+
this.scrollSource?.addEventListener('scroll', this.onScroll, {
|
|
307
|
+
passive: true,
|
|
308
|
+
});
|
|
309
|
+
view?.addEventListener('resize', this.onScroll);
|
|
310
|
+
this.compute();
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
/** Re-evaluate the active section (call after sections change). */
|
|
314
|
+
refresh() {
|
|
315
|
+
if (this.isBrowser)
|
|
316
|
+
this.compute();
|
|
317
|
+
}
|
|
318
|
+
querySections() {
|
|
319
|
+
const rootEl = this.root() ?? this.document;
|
|
320
|
+
return Array.from(rootEl.querySelectorAll(this.sections()));
|
|
321
|
+
}
|
|
322
|
+
computeRaf = null;
|
|
323
|
+
/** rAF-coalesced — several scroll ticks per frame trigger one layout read. */
|
|
324
|
+
onScroll = () => {
|
|
325
|
+
if (this.computeRaf != null)
|
|
326
|
+
return;
|
|
327
|
+
this.computeRaf =
|
|
328
|
+
this.document.defaultView?.requestAnimationFrame(() => {
|
|
329
|
+
this.computeRaf = null;
|
|
330
|
+
this.compute();
|
|
331
|
+
}) ?? null;
|
|
332
|
+
};
|
|
333
|
+
compute() {
|
|
334
|
+
const sections = this.querySections();
|
|
335
|
+
if (!sections.length) {
|
|
336
|
+
this.setActive(null);
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
// At the very bottom the final section may be too short to reach the line —
|
|
340
|
+
// activate it explicitly so the last nav item can still highlight.
|
|
341
|
+
if (this.atBottom()) {
|
|
342
|
+
this.setActive(sections[sections.length - 1].id || null);
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
const rootEl = this.root();
|
|
346
|
+
const line = (rootEl ? rootEl.getBoundingClientRect().top : 0) + this.offset();
|
|
347
|
+
// Active = the last section whose top has crossed the activation line.
|
|
348
|
+
let active = null;
|
|
349
|
+
for (const section of sections) {
|
|
350
|
+
if (section.getBoundingClientRect().top - line <= 1)
|
|
351
|
+
active = section;
|
|
352
|
+
else
|
|
353
|
+
break;
|
|
354
|
+
}
|
|
355
|
+
// Nothing crossed yet (still above the first section) → highlight the first.
|
|
356
|
+
this.setActive((active ?? sections[0]).id || null);
|
|
357
|
+
}
|
|
358
|
+
/** Whether the scroll root is scrolled to (or within 1px of) its bottom. */
|
|
359
|
+
atBottom() {
|
|
360
|
+
const root = this.root();
|
|
361
|
+
if (root) {
|
|
362
|
+
return root.scrollTop + root.clientHeight >= root.scrollHeight - 1;
|
|
363
|
+
}
|
|
364
|
+
const view = this.document.defaultView;
|
|
365
|
+
if (!view)
|
|
366
|
+
return false;
|
|
367
|
+
const doc = this.document.documentElement;
|
|
368
|
+
return view.scrollY + view.innerHeight >= doc.scrollHeight - 1;
|
|
369
|
+
}
|
|
370
|
+
setActive(id) {
|
|
371
|
+
if (this.activeId() === id)
|
|
372
|
+
return;
|
|
373
|
+
this.activeId.set(id);
|
|
374
|
+
this.activeChange.emit(id);
|
|
375
|
+
}
|
|
376
|
+
ngOnDestroy() {
|
|
377
|
+
if (this.computeRaf != null) {
|
|
378
|
+
this.document.defaultView?.cancelAnimationFrame(this.computeRaf);
|
|
379
|
+
this.computeRaf = null;
|
|
380
|
+
}
|
|
381
|
+
this.scrollSource?.removeEventListener('scroll', this.onScroll);
|
|
382
|
+
this.document.defaultView?.removeEventListener('resize', this.onScroll);
|
|
383
|
+
}
|
|
384
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.7", ngImport: i0, type: MkScrollspy, deps: [], target: i0.ɵɵFactoryTarget.Directive });
|
|
385
|
+
static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "22.0.7", type: MkScrollspy, isStandalone: true, selector: "[mkScrollspy]", inputs: { sections: { classPropertyName: "sections", publicName: "mkScrollspy", isSignal: true, isRequired: true, transformFunction: null }, root: { classPropertyName: "root", publicName: "root", isSignal: true, isRequired: false, transformFunction: null }, offset: { classPropertyName: "offset", publicName: "offset", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { activeChange: "activeChange" }, exportAs: ["mkScrollspy"], ngImport: i0 });
|
|
386
|
+
}
|
|
387
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.7", ngImport: i0, type: MkScrollspy, decorators: [{
|
|
388
|
+
type: Directive,
|
|
389
|
+
args: [{
|
|
390
|
+
selector: '[mkScrollspy]',
|
|
391
|
+
exportAs: 'mkScrollspy',
|
|
392
|
+
}]
|
|
393
|
+
}], ctorParameters: () => [], propDecorators: { sections: [{ type: i0.Input, args: [{ isSignal: true, alias: "mkScrollspy", required: true }] }], root: [{ type: i0.Input, args: [{ isSignal: true, alias: "root", required: false }] }], offset: [{ type: i0.Input, args: [{ isSignal: true, alias: "offset", required: false }] }], activeChange: [{ type: i0.Output, args: ["activeChange"] }] } });
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* Intersect — a thin `IntersectionObserver` wrapper. Emits `(mkIntersect)` with
|
|
397
|
+
* the host's visibility whenever it enters or leaves the viewport (or `root`),
|
|
398
|
+
* and exposes an `intersecting` signal via `exportAs`. Set `once` to fire a
|
|
399
|
+
* single time and disconnect — the primitive for reveal-on-scroll animations
|
|
400
|
+
* and lazy loading.
|
|
401
|
+
*
|
|
402
|
+
* ```html
|
|
403
|
+
* <img [attr.src]="loaded() ? src : null" mkIntersect once
|
|
404
|
+
* (mkIntersect)="loaded.set(true)" />
|
|
405
|
+
*
|
|
406
|
+
* <div mkIntersect #io="mkIntersect" [class.in-view]="io.intersecting()">…</div>
|
|
407
|
+
* ```
|
|
408
|
+
*/
|
|
409
|
+
class MkIntersect {
|
|
410
|
+
host = inject(ElementRef);
|
|
411
|
+
document = inject(DOCUMENT);
|
|
412
|
+
injector = inject(Injector);
|
|
413
|
+
isBrowser = isPlatformBrowser(inject(PLATFORM_ID));
|
|
414
|
+
/** Element used as the viewport for the check (defaults to the browser one). */
|
|
415
|
+
root = input(null, /* @ts-ignore */
|
|
416
|
+
...(ngDevMode ? [{ debugName: "root" }] : /* istanbul ignore next */ []));
|
|
417
|
+
/** Margin grown/shrunk around the root before computing intersection. */
|
|
418
|
+
rootMargin = input('0px', /* @ts-ignore */
|
|
419
|
+
...(ngDevMode ? [{ debugName: "rootMargin" }] : /* istanbul ignore next */ []));
|
|
420
|
+
/** Visibility ratio(s) at which the observer fires. */
|
|
421
|
+
threshold = input(0, /* @ts-ignore */
|
|
422
|
+
...(ngDevMode ? [{ debugName: "threshold" }] : /* istanbul ignore next */ []));
|
|
423
|
+
/** Emit the first time the host becomes visible, then disconnect. */
|
|
424
|
+
once = input(false, { ...(ngDevMode ? { debugName: "once" } : /* istanbul ignore next */ {}), transform: booleanAttribute });
|
|
425
|
+
/** Emits the host's `isIntersecting` state on every change. */
|
|
426
|
+
mkIntersect = output();
|
|
427
|
+
/** The current visibility, also readable via `exportAs`. */
|
|
428
|
+
intersecting = signal(false, /* @ts-ignore */
|
|
429
|
+
...(ngDevMode ? [{ debugName: "intersecting" }] : /* istanbul ignore next */ []));
|
|
430
|
+
observer;
|
|
431
|
+
constructor() {
|
|
432
|
+
afterNextRender(() => {
|
|
433
|
+
if (!this.isBrowser || typeof IntersectionObserver === 'undefined')
|
|
434
|
+
return;
|
|
435
|
+
this.observer = new IntersectionObserver((entries) => this.onEntries(entries), {
|
|
436
|
+
root: this.root(),
|
|
437
|
+
rootMargin: this.rootMargin(),
|
|
438
|
+
threshold: this.threshold(),
|
|
439
|
+
});
|
|
440
|
+
this.observer.observe(this.host.nativeElement);
|
|
441
|
+
}, { injector: this.injector });
|
|
442
|
+
}
|
|
443
|
+
onEntries(entries) {
|
|
444
|
+
// Ignore any callback queued before we disconnected (e.g. after `once`).
|
|
445
|
+
if (!this.observer)
|
|
446
|
+
return;
|
|
447
|
+
const entry = entries[entries.length - 1];
|
|
448
|
+
const visible = entry.isIntersecting;
|
|
449
|
+
if (visible === this.intersecting())
|
|
450
|
+
return;
|
|
451
|
+
this.intersecting.set(visible);
|
|
452
|
+
this.mkIntersect.emit(visible);
|
|
453
|
+
if (visible && this.once())
|
|
454
|
+
this.disconnect();
|
|
455
|
+
}
|
|
456
|
+
disconnect() {
|
|
457
|
+
this.observer?.disconnect();
|
|
458
|
+
this.observer = undefined;
|
|
459
|
+
}
|
|
460
|
+
ngOnDestroy() {
|
|
461
|
+
this.disconnect();
|
|
462
|
+
}
|
|
463
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.7", ngImport: i0, type: MkIntersect, deps: [], target: i0.ɵɵFactoryTarget.Directive });
|
|
464
|
+
static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "22.0.7", type: MkIntersect, isStandalone: true, selector: "[mkIntersect]", inputs: { root: { classPropertyName: "root", publicName: "root", isSignal: true, isRequired: false, transformFunction: null }, rootMargin: { classPropertyName: "rootMargin", publicName: "rootMargin", isSignal: true, isRequired: false, transformFunction: null }, threshold: { classPropertyName: "threshold", publicName: "threshold", isSignal: true, isRequired: false, transformFunction: null }, once: { classPropertyName: "once", publicName: "once", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { mkIntersect: "mkIntersect" }, exportAs: ["mkIntersect"], ngImport: i0 });
|
|
465
|
+
}
|
|
466
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.7", ngImport: i0, type: MkIntersect, decorators: [{
|
|
467
|
+
type: Directive,
|
|
468
|
+
args: [{
|
|
469
|
+
selector: '[mkIntersect]',
|
|
470
|
+
exportAs: 'mkIntersect',
|
|
471
|
+
}]
|
|
472
|
+
}], ctorParameters: () => [], propDecorators: { root: [{ type: i0.Input, args: [{ isSignal: true, alias: "root", required: false }] }], rootMargin: [{ type: i0.Input, args: [{ isSignal: true, alias: "rootMargin", required: false }] }], threshold: [{ type: i0.Input, args: [{ isSignal: true, alias: "threshold", required: false }] }], once: [{ type: i0.Input, args: [{ isSignal: true, alias: "once", required: false }] }], mkIntersect: [{ type: i0.Output, args: ["mkIntersect"] }] } });
|
|
473
|
+
|
|
474
|
+
/**
|
|
475
|
+
* InfiniteScroll — emits `(mkInfiniteScroll)` when the host scroll container is
|
|
476
|
+
* scrolled within `distance` px of its bottom, so a list can load its next
|
|
477
|
+
* page. Apply it to the scrollable element (`overflow: auto`). It fires once per
|
|
478
|
+
* approach and re-arms after you scroll back up past the threshold, so a single
|
|
479
|
+
* gesture never triggers repeated loads. Set `disabled` while a page is loading
|
|
480
|
+
* or once everything is loaded.
|
|
481
|
+
*
|
|
482
|
+
* ```html
|
|
483
|
+
* <div class="feed" mkInfiniteScroll [disabled]="loading() || done()"
|
|
484
|
+
* (mkInfiniteScroll)="loadMore()">
|
|
485
|
+
* @for (item of items(); track item.id) { … }
|
|
486
|
+
* </div>
|
|
487
|
+
* ```
|
|
488
|
+
*/
|
|
489
|
+
class MkInfiniteScroll {
|
|
490
|
+
host = inject(ElementRef);
|
|
491
|
+
isBrowser = isPlatformBrowser(inject(PLATFORM_ID));
|
|
492
|
+
/** Distance (px) from the bottom at which the output fires. */
|
|
493
|
+
distance = input(200, { ...(ngDevMode ? { debugName: "distance" } : /* istanbul ignore next */ {}), transform: numberAttribute });
|
|
494
|
+
/** Pause firing (e.g. while a page is loading or all items are loaded). */
|
|
495
|
+
disabled = input(false, { ...(ngDevMode ? { debugName: "disabled" } : /* istanbul ignore next */ {}), transform: booleanAttribute });
|
|
496
|
+
/** Emitted when the host is scrolled near its bottom. */
|
|
497
|
+
mkInfiniteScroll = output();
|
|
498
|
+
/** Whether the next bottom-approach is allowed to fire. */
|
|
499
|
+
armed = true;
|
|
500
|
+
constructor() {
|
|
501
|
+
afterNextRender(() => {
|
|
502
|
+
if (!this.isBrowser)
|
|
503
|
+
return;
|
|
504
|
+
this.host.nativeElement.addEventListener('scroll', this.onScroll, {
|
|
505
|
+
passive: true,
|
|
506
|
+
});
|
|
507
|
+
// Fire immediately if the initial content doesn't fill the container.
|
|
508
|
+
this.check();
|
|
509
|
+
});
|
|
510
|
+
}
|
|
511
|
+
onScroll = () => this.check();
|
|
512
|
+
check() {
|
|
513
|
+
const el = this.host.nativeElement;
|
|
514
|
+
const remaining = el.scrollHeight - el.scrollTop - el.clientHeight;
|
|
515
|
+
if (remaining > this.distance()) {
|
|
516
|
+
// Scrolled away from the bottom — allow the next approach to fire.
|
|
517
|
+
this.armed = true;
|
|
518
|
+
return;
|
|
519
|
+
}
|
|
520
|
+
if (!this.armed || this.disabled())
|
|
521
|
+
return;
|
|
522
|
+
this.armed = false;
|
|
523
|
+
this.mkInfiniteScroll.emit();
|
|
524
|
+
}
|
|
525
|
+
ngOnDestroy() {
|
|
526
|
+
this.host.nativeElement.removeEventListener('scroll', this.onScroll);
|
|
527
|
+
}
|
|
528
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.7", ngImport: i0, type: MkInfiniteScroll, deps: [], target: i0.ɵɵFactoryTarget.Directive });
|
|
529
|
+
static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "22.0.7", type: MkInfiniteScroll, isStandalone: true, selector: "[mkInfiniteScroll]", inputs: { distance: { classPropertyName: "distance", publicName: "distance", isSignal: true, isRequired: false, transformFunction: null }, disabled: { classPropertyName: "disabled", publicName: "disabled", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { mkInfiniteScroll: "mkInfiniteScroll" }, exportAs: ["mkInfiniteScroll"], ngImport: i0 });
|
|
530
|
+
}
|
|
531
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.7", ngImport: i0, type: MkInfiniteScroll, decorators: [{
|
|
532
|
+
type: Directive,
|
|
533
|
+
args: [{
|
|
534
|
+
selector: '[mkInfiniteScroll]',
|
|
535
|
+
exportAs: 'mkInfiniteScroll',
|
|
536
|
+
}]
|
|
537
|
+
}], ctorParameters: () => [], propDecorators: { distance: [{ type: i0.Input, args: [{ isSignal: true, alias: "distance", required: false }] }], disabled: [{ type: i0.Input, args: [{ isSignal: true, alias: "disabled", required: false }] }], mkInfiniteScroll: [{ type: i0.Output, args: ["mkInfiniteScroll"] }] } });
|
|
538
|
+
|
|
539
|
+
/**
|
|
540
|
+
* Ripple — a Material-style touch ripple that expands from the pointer on press.
|
|
541
|
+
* Apply it to any pressable surface (button, list item, card). The directive
|
|
542
|
+
* makes the host a positioned, clipping container and paints a short-lived wave;
|
|
543
|
+
* it respects `prefers-reduced-motion`.
|
|
544
|
+
*
|
|
545
|
+
* ```html
|
|
546
|
+
* <button mkRipple>Save</button>
|
|
547
|
+
* <li mkRipple mkRippleColor="var(--mk-primary)">Item</li>
|
|
548
|
+
* ```
|
|
549
|
+
*/
|
|
550
|
+
class MkRipple {
|
|
551
|
+
host = inject(ElementRef);
|
|
552
|
+
document = inject(DOCUMENT);
|
|
553
|
+
isBrowser = isPlatformBrowser(inject(PLATFORM_ID));
|
|
554
|
+
/** Wave colour (any CSS colour). Defaults to the host's `currentColor`. */
|
|
555
|
+
color = input('', { ...(ngDevMode ? { debugName: "color" } : /* istanbul ignore next */ {}), alias: 'mkRippleColor' });
|
|
556
|
+
/** Start every ripple from the centre instead of the pointer. */
|
|
557
|
+
centered = input(false, { ...(ngDevMode ? { debugName: "centered" } : /* istanbul ignore next */ {}), alias: 'mkRippleCentered',
|
|
558
|
+
transform: booleanAttribute });
|
|
559
|
+
/** Turn the effect off. */
|
|
560
|
+
disabled = input(false, { ...(ngDevMode ? { debugName: "disabled" } : /* istanbul ignore next */ {}), alias: 'mkRippleDisabled',
|
|
561
|
+
transform: booleanAttribute });
|
|
562
|
+
spawn(event) {
|
|
563
|
+
if (!this.isBrowser || this.disabled())
|
|
564
|
+
return;
|
|
565
|
+
const el = this.host.nativeElement;
|
|
566
|
+
const view = this.document.defaultView;
|
|
567
|
+
if (view?.matchMedia?.('(prefers-reduced-motion: reduce)').matches)
|
|
568
|
+
return;
|
|
569
|
+
// Make the host a positioned, clipping container (idempotent).
|
|
570
|
+
const cs = view?.getComputedStyle(el);
|
|
571
|
+
if (cs && cs.position === 'static')
|
|
572
|
+
el.style.position = 'relative';
|
|
573
|
+
el.style.overflow = 'hidden';
|
|
574
|
+
const rect = el.getBoundingClientRect();
|
|
575
|
+
const x = this.centered() ? rect.width / 2 : event.clientX - rect.left;
|
|
576
|
+
const y = this.centered() ? rect.height / 2 : event.clientY - rect.top;
|
|
577
|
+
// Radius that reaches the farthest corner from the origin.
|
|
578
|
+
const radius = Math.hypot(Math.max(x, rect.width - x), Math.max(y, rect.height - y));
|
|
579
|
+
const wave = this.document.createElement('span');
|
|
580
|
+
wave.className = 'mk-ripple__wave';
|
|
581
|
+
Object.assign(wave.style, {
|
|
582
|
+
position: 'absolute',
|
|
583
|
+
left: `${x - radius}px`,
|
|
584
|
+
top: `${y - radius}px`,
|
|
585
|
+
width: `${radius * 2}px`,
|
|
586
|
+
height: `${radius * 2}px`,
|
|
587
|
+
borderRadius: '50%',
|
|
588
|
+
background: this.color() || 'currentColor',
|
|
589
|
+
pointerEvents: 'none',
|
|
590
|
+
transform: 'scale(0)',
|
|
591
|
+
opacity: '0.3',
|
|
592
|
+
});
|
|
593
|
+
el.appendChild(wave);
|
|
594
|
+
const cleanup = () => wave.remove();
|
|
595
|
+
const anim = wave.animate?.([
|
|
596
|
+
{ transform: 'scale(0)', opacity: 0.3 },
|
|
597
|
+
{ transform: 'scale(1)', opacity: 0 },
|
|
598
|
+
], { duration: 500, easing: 'ease-out' });
|
|
599
|
+
if (anim)
|
|
600
|
+
anim.onfinish = cleanup;
|
|
601
|
+
// Fallback removal in case the animation never resolves (or is unsupported).
|
|
602
|
+
view?.setTimeout(cleanup, 650);
|
|
603
|
+
}
|
|
604
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.7", ngImport: i0, type: MkRipple, deps: [], target: i0.ɵɵFactoryTarget.Directive });
|
|
605
|
+
static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "22.0.7", type: MkRipple, isStandalone: true, selector: "[mkRipple]", inputs: { color: { classPropertyName: "color", publicName: "mkRippleColor", isSignal: true, isRequired: false, transformFunction: null }, centered: { classPropertyName: "centered", publicName: "mkRippleCentered", isSignal: true, isRequired: false, transformFunction: null }, disabled: { classPropertyName: "disabled", publicName: "mkRippleDisabled", isSignal: true, isRequired: false, transformFunction: null } }, host: { listeners: { "pointerdown": "spawn($event)" } }, ngImport: i0 });
|
|
606
|
+
}
|
|
607
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.7", ngImport: i0, type: MkRipple, decorators: [{
|
|
608
|
+
type: Directive,
|
|
609
|
+
args: [{
|
|
610
|
+
selector: '[mkRipple]',
|
|
611
|
+
host: {
|
|
612
|
+
'(pointerdown)': 'spawn($event)',
|
|
613
|
+
},
|
|
614
|
+
}]
|
|
615
|
+
}], propDecorators: { color: [{ type: i0.Input, args: [{ isSignal: true, alias: "mkRippleColor", required: false }] }], centered: [{ type: i0.Input, args: [{ isSignal: true, alias: "mkRippleCentered", required: false }] }], disabled: [{ type: i0.Input, args: [{ isSignal: true, alias: "mkRippleDisabled", required: false }] }] } });
|
|
616
|
+
|
|
617
|
+
/** Token → predicate for the character it accepts. */
|
|
618
|
+
const MK_MASK_TOKENS = {
|
|
619
|
+
'0': /[0-9]/, // digit
|
|
620
|
+
A: /[A-Za-z]/, // letter
|
|
621
|
+
'*': /[0-9A-Za-z]/, // alphanumeric
|
|
622
|
+
};
|
|
623
|
+
const ALNUM = /[0-9A-Za-z]/;
|
|
624
|
+
/**
|
|
625
|
+
* Format `value` against a mask `pattern`. Pattern tokens: `0` = digit,
|
|
626
|
+
* `A` = letter, `*` = alphanumeric; any other character is a literal that is
|
|
627
|
+
* inserted automatically. Characters that don't fit the current token are
|
|
628
|
+
* skipped, so pasted / mistyped input is coerced into shape.
|
|
629
|
+
*
|
|
630
|
+
* Returns the `masked` (display) string and the `unmasked` string (only the
|
|
631
|
+
* token characters — the value you usually store).
|
|
632
|
+
*/
|
|
633
|
+
function mkApplyMask(value, pattern) {
|
|
634
|
+
let masked = '';
|
|
635
|
+
let unmasked = '';
|
|
636
|
+
let vi = 0;
|
|
637
|
+
for (let pi = 0; pi < pattern.length; pi++) {
|
|
638
|
+
const pc = pattern[pi];
|
|
639
|
+
const rule = MK_MASK_TOKENS[pc];
|
|
640
|
+
if (rule) {
|
|
641
|
+
while (vi < value.length && !rule.test(value[vi]))
|
|
642
|
+
vi++;
|
|
643
|
+
if (vi >= value.length)
|
|
644
|
+
break;
|
|
645
|
+
masked += value[vi];
|
|
646
|
+
unmasked += value[vi];
|
|
647
|
+
vi++;
|
|
648
|
+
}
|
|
649
|
+
else {
|
|
650
|
+
// Stop rather than emit a trailing literal with no content after it.
|
|
651
|
+
if (vi >= value.length)
|
|
652
|
+
break;
|
|
653
|
+
masked += pc;
|
|
654
|
+
if (value[vi] === pc)
|
|
655
|
+
vi++;
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
return { masked, unmasked };
|
|
659
|
+
}
|
|
660
|
+
/**
|
|
661
|
+
* Compute where the caret belongs after re-masking: after the same number of
|
|
662
|
+
* value (alphanumeric) characters the caret used to sit behind, absorbing any
|
|
663
|
+
* literal(s) that immediately follow — so typing "1" in "(0)" lands the caret
|
|
664
|
+
* after the "(". Shared by {@link MkMask} and the masked form controls.
|
|
665
|
+
*/
|
|
666
|
+
function mkMaskCaret(oldValue, oldCaret, masked) {
|
|
667
|
+
const before = oldValue.slice(0, oldCaret);
|
|
668
|
+
const target = (before.match(/[0-9A-Za-z]/g) ?? []).length;
|
|
669
|
+
let pos = 0;
|
|
670
|
+
let seen = 0;
|
|
671
|
+
while (pos < masked.length && seen < target) {
|
|
672
|
+
if (ALNUM.test(masked[pos]))
|
|
673
|
+
seen++;
|
|
674
|
+
pos++;
|
|
675
|
+
}
|
|
676
|
+
while (pos < masked.length && !ALNUM.test(masked[pos]))
|
|
677
|
+
pos++;
|
|
678
|
+
return pos;
|
|
679
|
+
}
|
|
680
|
+
/**
|
|
681
|
+
* Mask — formats an `<input>` as the user types against a token pattern
|
|
682
|
+
* (phone, card, date, custom). Apply it to any text input; read the raw value
|
|
683
|
+
* via `(unmaskedChange)` (or the exported `unmasked` getter) and the formatted
|
|
684
|
+
* value via `(maskedChange)`.
|
|
685
|
+
*
|
|
686
|
+
* ```html
|
|
687
|
+
* <input mkInput mkMask="(000) 000-0000" (unmaskedChange)="phone.set($event)" />
|
|
688
|
+
* <input mkInput mkMask="0000 0000 0000 0000" />
|
|
689
|
+
* ```
|
|
690
|
+
*/
|
|
691
|
+
class MkMask {
|
|
692
|
+
host = inject(ElementRef);
|
|
693
|
+
/** The mask pattern (token string). */
|
|
694
|
+
pattern = input.required({ ...(ngDevMode ? { debugName: "pattern" } : /* istanbul ignore next */ {}), alias: 'mkMask' });
|
|
695
|
+
/** Override the derived `inputmode` (`numeric` for digit-only masks). */
|
|
696
|
+
maskInputmode = input(null, { ...(ngDevMode ? { debugName: "maskInputmode" } : /* istanbul ignore next */ {}), alias: 'mkMaskInputmode' });
|
|
697
|
+
/** Mobile keyboard hint: numeric for digit-only patterns unless overridden. */
|
|
698
|
+
inputMode = computed(() => this.maskInputmode() ?? (/[A*]/.test(this.pattern()) ? 'text' : 'numeric'), /* @ts-ignore */
|
|
699
|
+
...(ngDevMode ? [{ debugName: "inputMode" }] : /* istanbul ignore next */ []));
|
|
700
|
+
/** The formatted value, on every change. */
|
|
701
|
+
maskedChange = output();
|
|
702
|
+
/** The raw value (token characters only), on every change. */
|
|
703
|
+
unmaskedChange = output();
|
|
704
|
+
_masked = '';
|
|
705
|
+
_unmasked = '';
|
|
706
|
+
/** The current formatted value. */
|
|
707
|
+
get value() {
|
|
708
|
+
return this._masked;
|
|
709
|
+
}
|
|
710
|
+
/** The current raw value (token characters only). */
|
|
711
|
+
get unmasked() {
|
|
712
|
+
return this._unmasked;
|
|
713
|
+
}
|
|
714
|
+
onInput() {
|
|
715
|
+
const el = this.host.nativeElement;
|
|
716
|
+
const oldValue = el.value;
|
|
717
|
+
const oldCaret = el.selectionStart ?? oldValue.length;
|
|
718
|
+
const { masked, unmasked } = mkApplyMask(oldValue, this.pattern());
|
|
719
|
+
el.value = masked;
|
|
720
|
+
this.setCaret(el, oldValue, oldCaret, masked);
|
|
721
|
+
if (masked === this._masked)
|
|
722
|
+
return;
|
|
723
|
+
this._masked = masked;
|
|
724
|
+
this._unmasked = unmasked;
|
|
725
|
+
this.maskedChange.emit(masked);
|
|
726
|
+
this.unmaskedChange.emit(unmasked);
|
|
727
|
+
}
|
|
728
|
+
/** Position the caret after the same number of value chars as before. */
|
|
729
|
+
setCaret(el, oldValue, oldCaret, masked) {
|
|
730
|
+
const pos = mkMaskCaret(oldValue, oldCaret, masked);
|
|
731
|
+
try {
|
|
732
|
+
el.setSelectionRange(pos, pos);
|
|
733
|
+
}
|
|
734
|
+
catch {
|
|
735
|
+
// Some input types (e.g. number, email) don't support selection.
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.7", ngImport: i0, type: MkMask, deps: [], target: i0.ɵɵFactoryTarget.Directive });
|
|
739
|
+
static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "22.0.7", type: MkMask, isStandalone: true, selector: "[mkMask]", inputs: { pattern: { classPropertyName: "pattern", publicName: "mkMask", isSignal: true, isRequired: true, transformFunction: null }, maskInputmode: { classPropertyName: "maskInputmode", publicName: "mkMaskInputmode", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { maskedChange: "maskedChange", unmaskedChange: "unmaskedChange" }, host: { listeners: { "input": "onInput()" }, properties: { "attr.inputmode": "inputMode()" } }, exportAs: ["mkMask"], ngImport: i0 });
|
|
740
|
+
}
|
|
741
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.7", ngImport: i0, type: MkMask, decorators: [{
|
|
742
|
+
type: Directive,
|
|
743
|
+
args: [{
|
|
744
|
+
selector: '[mkMask]',
|
|
745
|
+
exportAs: 'mkMask',
|
|
746
|
+
host: {
|
|
747
|
+
'(input)': 'onInput()',
|
|
748
|
+
'[attr.inputmode]': 'inputMode()',
|
|
749
|
+
},
|
|
750
|
+
}]
|
|
751
|
+
}], propDecorators: { pattern: [{ type: i0.Input, args: [{ isSignal: true, alias: "mkMask", required: true }] }], maskInputmode: [{ type: i0.Input, args: [{ isSignal: true, alias: "mkMaskInputmode", required: false }] }], maskedChange: [{ type: i0.Output, args: ["maskedChange"] }], unmaskedChange: [{ type: i0.Output, args: ["unmaskedChange"] }] } });
|
|
752
|
+
|
|
753
|
+
/** The attributes a field preset can control. */
|
|
754
|
+
const MK_FIELD_ATTRS = [
|
|
755
|
+
'type',
|
|
756
|
+
'inputmode',
|
|
757
|
+
'autocomplete',
|
|
758
|
+
'autocapitalize',
|
|
759
|
+
'autocorrect',
|
|
760
|
+
'spellcheck',
|
|
761
|
+
'enterkeyhint',
|
|
762
|
+
];
|
|
763
|
+
/**
|
|
764
|
+
* The attribute bundle per field kind. Exported so hosts can inspect what a
|
|
765
|
+
* kind resolves to, or spread an entry into their own preset table.
|
|
766
|
+
*
|
|
767
|
+
* `autocorrect` (non-standard, honoured by iOS Safari) is set to `off`
|
|
768
|
+
* wherever `spellcheck` is `false`.
|
|
769
|
+
*/
|
|
770
|
+
const MK_FIELD_PRESETS = {
|
|
771
|
+
email: {
|
|
772
|
+
type: 'email',
|
|
773
|
+
inputmode: 'email',
|
|
774
|
+
autocomplete: 'email',
|
|
775
|
+
autocapitalize: 'off',
|
|
776
|
+
autocorrect: 'off',
|
|
777
|
+
spellcheck: 'false',
|
|
778
|
+
},
|
|
779
|
+
tel: { type: 'tel', inputmode: 'tel', autocomplete: 'tel' },
|
|
780
|
+
url: {
|
|
781
|
+
type: 'url',
|
|
782
|
+
inputmode: 'url',
|
|
783
|
+
autocomplete: 'url',
|
|
784
|
+
autocapitalize: 'off',
|
|
785
|
+
autocorrect: 'off',
|
|
786
|
+
spellcheck: 'false',
|
|
787
|
+
},
|
|
788
|
+
search: {
|
|
789
|
+
type: 'search',
|
|
790
|
+
inputmode: 'search',
|
|
791
|
+
autocomplete: 'off',
|
|
792
|
+
autocapitalize: 'off',
|
|
793
|
+
autocorrect: 'off',
|
|
794
|
+
spellcheck: 'false',
|
|
795
|
+
enterkeyhint: 'search',
|
|
796
|
+
},
|
|
797
|
+
numeric: { inputmode: 'numeric' },
|
|
798
|
+
'given-name': {
|
|
799
|
+
type: 'text',
|
|
800
|
+
autocomplete: 'given-name',
|
|
801
|
+
autocapitalize: 'words',
|
|
802
|
+
},
|
|
803
|
+
'family-name': {
|
|
804
|
+
type: 'text',
|
|
805
|
+
autocomplete: 'family-name',
|
|
806
|
+
autocapitalize: 'words',
|
|
807
|
+
},
|
|
808
|
+
name: { type: 'text', autocomplete: 'name', autocapitalize: 'words' },
|
|
809
|
+
organization: {
|
|
810
|
+
type: 'text',
|
|
811
|
+
autocomplete: 'organization',
|
|
812
|
+
autocapitalize: 'words',
|
|
813
|
+
},
|
|
814
|
+
street: {
|
|
815
|
+
type: 'text',
|
|
816
|
+
autocomplete: 'street-address',
|
|
817
|
+
autocapitalize: 'words',
|
|
818
|
+
},
|
|
819
|
+
'address-line1': {
|
|
820
|
+
type: 'text',
|
|
821
|
+
autocomplete: 'address-line1',
|
|
822
|
+
autocapitalize: 'words',
|
|
823
|
+
},
|
|
824
|
+
'address-line2': {
|
|
825
|
+
type: 'text',
|
|
826
|
+
autocomplete: 'address-line2',
|
|
827
|
+
autocapitalize: 'words',
|
|
828
|
+
},
|
|
829
|
+
city: {
|
|
830
|
+
type: 'text',
|
|
831
|
+
autocomplete: 'address-level2',
|
|
832
|
+
autocapitalize: 'words',
|
|
833
|
+
},
|
|
834
|
+
region: {
|
|
835
|
+
type: 'text',
|
|
836
|
+
autocomplete: 'address-level1',
|
|
837
|
+
autocapitalize: 'words',
|
|
838
|
+
},
|
|
839
|
+
country: {
|
|
840
|
+
type: 'text',
|
|
841
|
+
autocomplete: 'country-name',
|
|
842
|
+
autocapitalize: 'words',
|
|
843
|
+
},
|
|
844
|
+
'postal-code': {
|
|
845
|
+
type: 'text',
|
|
846
|
+
autocomplete: 'postal-code',
|
|
847
|
+
autocapitalize: 'characters',
|
|
848
|
+
autocorrect: 'off',
|
|
849
|
+
spellcheck: 'false',
|
|
850
|
+
},
|
|
851
|
+
username: {
|
|
852
|
+
type: 'text',
|
|
853
|
+
autocomplete: 'username',
|
|
854
|
+
autocapitalize: 'off',
|
|
855
|
+
autocorrect: 'off',
|
|
856
|
+
spellcheck: 'false',
|
|
857
|
+
},
|
|
858
|
+
'one-time-code': {
|
|
859
|
+
type: 'text',
|
|
860
|
+
inputmode: 'numeric',
|
|
861
|
+
autocomplete: 'one-time-code',
|
|
862
|
+
autocapitalize: 'off',
|
|
863
|
+
autocorrect: 'off',
|
|
864
|
+
spellcheck: 'false',
|
|
865
|
+
},
|
|
866
|
+
};
|
|
867
|
+
/**
|
|
868
|
+
* Field — applies the semantic mobile-keyboard and autofill attribute bundle
|
|
869
|
+
* for a kind of field, so you never again ship an email input that iOS
|
|
870
|
+
* capitalises or an address input the browser can't autofill.
|
|
871
|
+
*
|
|
872
|
+
* It sets `type`, `inputmode`, `autocomplete`, `autocapitalize`, `autocorrect`,
|
|
873
|
+
* `spellcheck` and `enterkeyhint` (see {@link MK_FIELD_PRESETS}) and nothing
|
|
874
|
+
* else — no styling, no value handling — so it composes with `mkInput`,
|
|
875
|
+
* `mk-form-field`, `[(ngModel)]` and reactive forms untouched.
|
|
876
|
+
*
|
|
877
|
+
* ```html
|
|
878
|
+
* <input mkInput mkField="email" formControlName="email" />
|
|
879
|
+
* <input mkInput mkField="street" formControlName="street" />
|
|
880
|
+
* <input mkInput [mkField]="isCompany() ? 'organization' : 'name'" />
|
|
881
|
+
* ```
|
|
882
|
+
*
|
|
883
|
+
* ## Precedence
|
|
884
|
+
*
|
|
885
|
+
* A **static** attribute you write on the element wins: the directive reads the
|
|
886
|
+
* seven attributes once at construction (before any host binding has run) and
|
|
887
|
+
* keeps whatever was already there, filling in only what you left out. So
|
|
888
|
+
* `<input mkField="username" type="password" />` stays a password field but
|
|
889
|
+
* still gets `autocomplete="username"`, `autocapitalize="off"`, …
|
|
890
|
+
*
|
|
891
|
+
* A **template binding** for the same attribute (`[attr.autocomplete]="…"`)
|
|
892
|
+
* does *not* win — Angular applies directive host bindings after template
|
|
893
|
+
* bindings, so the preset overwrites it. Use a static attribute to override, or
|
|
894
|
+
* drop `mkField` and set the bundle yourself.
|
|
895
|
+
*/
|
|
896
|
+
class MkField {
|
|
897
|
+
el = inject(ElementRef);
|
|
898
|
+
/** Which kind of field this is. */
|
|
899
|
+
kind = input.required({ ...(ngDevMode ? { debugName: "kind" } : /* istanbul ignore next */ {}), alias: 'mkField' });
|
|
900
|
+
/**
|
|
901
|
+
* Attributes the consumer put on the element themselves, read once before
|
|
902
|
+
* any host binding has run — these always win over the preset.
|
|
903
|
+
*/
|
|
904
|
+
authored = Object.fromEntries(MK_FIELD_ATTRS.map((name) => [name, this.el.nativeElement.getAttribute(name)]).filter(([, value]) => value !== null));
|
|
905
|
+
/** The resolved attribute bundle: authored value, else preset, else unset. */
|
|
906
|
+
attrs = computed(() => {
|
|
907
|
+
const preset = MK_FIELD_PRESETS[this.kind()] ?? {};
|
|
908
|
+
const resolved = {};
|
|
909
|
+
for (const name of MK_FIELD_ATTRS) {
|
|
910
|
+
resolved[name] = this.authored[name] ?? preset[name] ?? null;
|
|
911
|
+
}
|
|
912
|
+
return resolved;
|
|
913
|
+
}, /* @ts-ignore */
|
|
914
|
+
...(ngDevMode ? [{ debugName: "attrs" }] : /* istanbul ignore next */ []));
|
|
915
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.7", ngImport: i0, type: MkField, deps: [], target: i0.ɵɵFactoryTarget.Directive });
|
|
916
|
+
static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "22.0.7", type: MkField, isStandalone: true, selector: "[mkField]", inputs: { kind: { classPropertyName: "kind", publicName: "mkField", isSignal: true, isRequired: true, transformFunction: null } }, host: { properties: { "attr.type": "attrs().type", "attr.inputmode": "attrs().inputmode", "attr.autocomplete": "attrs().autocomplete", "attr.autocapitalize": "attrs().autocapitalize", "attr.autocorrect": "attrs().autocorrect", "attr.spellcheck": "attrs().spellcheck", "attr.enterkeyhint": "attrs().enterkeyhint" } }, ngImport: i0 });
|
|
917
|
+
}
|
|
918
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.7", ngImport: i0, type: MkField, decorators: [{
|
|
919
|
+
type: Directive,
|
|
920
|
+
args: [{
|
|
921
|
+
selector: '[mkField]',
|
|
922
|
+
host: {
|
|
923
|
+
'[attr.type]': 'attrs().type',
|
|
924
|
+
'[attr.inputmode]': 'attrs().inputmode',
|
|
925
|
+
'[attr.autocomplete]': 'attrs().autocomplete',
|
|
926
|
+
'[attr.autocapitalize]': 'attrs().autocapitalize',
|
|
927
|
+
'[attr.autocorrect]': 'attrs().autocorrect',
|
|
928
|
+
'[attr.spellcheck]': 'attrs().spellcheck',
|
|
929
|
+
'[attr.enterkeyhint]': 'attrs().enterkeyhint',
|
|
930
|
+
},
|
|
931
|
+
}]
|
|
932
|
+
}], propDecorators: { kind: [{ type: i0.Input, args: [{ isSignal: true, alias: "mkField", required: true }] }] } });
|
|
933
|
+
|
|
934
|
+
/**
|
|
935
|
+
* MkHistoryStack — a pure, signal-based, linear undo/redo command stack with
|
|
936
|
+
* no Angular DI involved. Instantiate it directly for a local editing session
|
|
937
|
+
* (a dialog, a canvas, a form wizard), or use the app-wide singleton
|
|
938
|
+
* {@link MkHistoryService}.
|
|
939
|
+
*
|
|
940
|
+
* ```ts
|
|
941
|
+
* const stack = new MkHistoryStack();
|
|
942
|
+
* deleteRow(row); // 1. do the work yourself
|
|
943
|
+
* stack.push({ // 2. record how to revert/redo it
|
|
944
|
+
* label: 'Delete row',
|
|
945
|
+
* undo: () => insertRow(row),
|
|
946
|
+
* redo: () => deleteRow(row),
|
|
947
|
+
* });
|
|
948
|
+
* stack.undo(); // row is back
|
|
949
|
+
* ```
|
|
950
|
+
*
|
|
951
|
+
* ### Linear history
|
|
952
|
+
* `push()` clears the redo branch — after undoing twice and pushing a new
|
|
953
|
+
* entry, the two undone entries are gone (the standard editor model).
|
|
954
|
+
*
|
|
955
|
+
* ### ⚠️ Re-entrancy guard — the classic footgun
|
|
956
|
+
* Pushes made **while** `undo()` or `redo()` is executing are **silently
|
|
957
|
+
* ignored**. Naive consumers often record history from a generic change
|
|
958
|
+
* handler (`(cellEdit)`, `valueChanges`, …); when an undo replays the old
|
|
959
|
+
* value, that same handler fires again and would push a mirror entry,
|
|
960
|
+
* corrupting the stack into an undo/redo ping-pong. The guard makes that
|
|
961
|
+
* pattern safe by construction. If you need to record something new in
|
|
962
|
+
* response to an undo, do it after `undo()` returns.
|
|
963
|
+
*/
|
|
964
|
+
class MkHistoryStack {
|
|
965
|
+
undoStack = signal([], /* @ts-ignore */
|
|
966
|
+
...(ngDevMode ? [{ debugName: "undoStack" }] : /* istanbul ignore next */ []));
|
|
967
|
+
redoStack = signal([], /* @ts-ignore */
|
|
968
|
+
...(ngDevMode ? [{ debugName: "redoStack" }] : /* istanbul ignore next */ []));
|
|
969
|
+
/** True while an entry's `undo()`/`redo()` is running (re-entrancy guard). */
|
|
970
|
+
replaying = false;
|
|
971
|
+
/** Depth of nested `batch()` calls; > 0 while a batch is collecting. */
|
|
972
|
+
batchDepth = 0;
|
|
973
|
+
batchLabel = '';
|
|
974
|
+
batchBuffer = [];
|
|
975
|
+
maxSize = 100;
|
|
976
|
+
/** True when at least one entry can be undone. */
|
|
977
|
+
canUndo = computed(() => this.undoStack().length > 0, /* @ts-ignore */
|
|
978
|
+
...(ngDevMode ? [{ debugName: "canUndo" }] : /* istanbul ignore next */ []));
|
|
979
|
+
/** True when at least one undone entry can be redone. */
|
|
980
|
+
canRedo = computed(() => this.redoStack().length > 0, /* @ts-ignore */
|
|
981
|
+
...(ngDevMode ? [{ debugName: "canRedo" }] : /* istanbul ignore next */ []));
|
|
982
|
+
/** Label of the entry `undo()` would revert, or `null` ("Undo *Delete row*"). */
|
|
983
|
+
undoLabel = computed(() => this.undoStack().at(-1)?.label ?? null, /* @ts-ignore */
|
|
984
|
+
...(ngDevMode ? [{ debugName: "undoLabel" }] : /* istanbul ignore next */ []));
|
|
985
|
+
/** Label of the entry `redo()` would re-apply, or `null`. */
|
|
986
|
+
redoLabel = computed(() => this.redoStack().at(-1)?.label ?? null, /* @ts-ignore */
|
|
987
|
+
...(ngDevMode ? [{ debugName: "redoLabel" }] : /* istanbul ignore next */ []));
|
|
988
|
+
/** Number of undoable entries currently on the stack. */
|
|
989
|
+
size = computed(() => this.undoStack().length, /* @ts-ignore */
|
|
990
|
+
...(ngDevMode ? [{ debugName: "size" }] : /* istanbul ignore next */ []));
|
|
991
|
+
/**
|
|
992
|
+
* Maximum number of undoable entries kept (default `100`). When exceeded the
|
|
993
|
+
* **oldest** entries are dropped. Settable at any time; lowering it trims
|
|
994
|
+
* the stack immediately. Values below 1 are clamped to 1.
|
|
995
|
+
*/
|
|
996
|
+
get limit() {
|
|
997
|
+
return this.maxSize;
|
|
998
|
+
}
|
|
999
|
+
set limit(value) {
|
|
1000
|
+
this.maxSize = Math.max(1, Math.floor(value));
|
|
1001
|
+
this.undoStack.update((stack) => stack.length > this.maxSize ? stack.slice(stack.length - this.maxSize) : stack);
|
|
1002
|
+
}
|
|
1003
|
+
/**
|
|
1004
|
+
* Record an already-performed action. Clears the redo branch and evicts the
|
|
1005
|
+
* oldest entry beyond {@link limit}. Ignored while `undo()`/`redo()` is
|
|
1006
|
+
* executing (see the re-entrancy note on the class) — inside a `batch()` the
|
|
1007
|
+
* entry is collected into the composite instead.
|
|
1008
|
+
*/
|
|
1009
|
+
push(entry) {
|
|
1010
|
+
if (this.replaying)
|
|
1011
|
+
return;
|
|
1012
|
+
if (this.batchDepth > 0) {
|
|
1013
|
+
this.batchBuffer.push(entry);
|
|
1014
|
+
return;
|
|
1015
|
+
}
|
|
1016
|
+
this.commit(entry);
|
|
1017
|
+
}
|
|
1018
|
+
/** Undo the most recent entry. Returns `false` when there is nothing to undo. */
|
|
1019
|
+
undo() {
|
|
1020
|
+
const entry = this.undoStack().at(-1);
|
|
1021
|
+
if (!entry)
|
|
1022
|
+
return false;
|
|
1023
|
+
this.undoStack.update((stack) => stack.slice(0, -1));
|
|
1024
|
+
this.replaying = true;
|
|
1025
|
+
try {
|
|
1026
|
+
entry.undo();
|
|
1027
|
+
}
|
|
1028
|
+
finally {
|
|
1029
|
+
this.replaying = false;
|
|
1030
|
+
}
|
|
1031
|
+
this.redoStack.update((stack) => [...stack, entry]);
|
|
1032
|
+
return true;
|
|
1033
|
+
}
|
|
1034
|
+
/** Redo the most recently undone entry. Returns `false` when there is nothing to redo. */
|
|
1035
|
+
redo() {
|
|
1036
|
+
const entry = this.redoStack().at(-1);
|
|
1037
|
+
if (!entry)
|
|
1038
|
+
return false;
|
|
1039
|
+
this.redoStack.update((stack) => stack.slice(0, -1));
|
|
1040
|
+
this.replaying = true;
|
|
1041
|
+
try {
|
|
1042
|
+
entry.redo();
|
|
1043
|
+
}
|
|
1044
|
+
finally {
|
|
1045
|
+
this.replaying = false;
|
|
1046
|
+
}
|
|
1047
|
+
this.undoStack.update((stack) => [...stack, entry]);
|
|
1048
|
+
return true;
|
|
1049
|
+
}
|
|
1050
|
+
/** Drop all entries (both branches) and any in-flight batch collection. */
|
|
1051
|
+
clear() {
|
|
1052
|
+
this.undoStack.set([]);
|
|
1053
|
+
this.redoStack.set([]);
|
|
1054
|
+
this.batchBuffer = [];
|
|
1055
|
+
}
|
|
1056
|
+
/**
|
|
1057
|
+
* Group every `push()` made during `work()` into **one** undoable step.
|
|
1058
|
+
* Undo runs the children in reverse order; redo runs them forward. Nested
|
|
1059
|
+
* `batch()` calls flatten into the outermost batch (its label wins). An
|
|
1060
|
+
* empty batch records nothing. Returns `work()`'s result; if `work()`
|
|
1061
|
+
* throws, entries pushed before the throw are still committed.
|
|
1062
|
+
*/
|
|
1063
|
+
batch(label, work) {
|
|
1064
|
+
if (this.replaying)
|
|
1065
|
+
return work();
|
|
1066
|
+
this.batchDepth++;
|
|
1067
|
+
if (this.batchDepth === 1) {
|
|
1068
|
+
this.batchLabel = label;
|
|
1069
|
+
this.batchBuffer = [];
|
|
1070
|
+
}
|
|
1071
|
+
try {
|
|
1072
|
+
return work();
|
|
1073
|
+
}
|
|
1074
|
+
finally {
|
|
1075
|
+
this.batchDepth--;
|
|
1076
|
+
if (this.batchDepth === 0 && this.batchBuffer.length > 0) {
|
|
1077
|
+
const children = this.batchBuffer;
|
|
1078
|
+
this.batchBuffer = [];
|
|
1079
|
+
this.commit({
|
|
1080
|
+
label: this.batchLabel,
|
|
1081
|
+
undo: () => {
|
|
1082
|
+
for (let i = children.length - 1; i >= 0; i--)
|
|
1083
|
+
children[i].undo();
|
|
1084
|
+
},
|
|
1085
|
+
redo: () => {
|
|
1086
|
+
for (const child of children)
|
|
1087
|
+
child.redo();
|
|
1088
|
+
},
|
|
1089
|
+
});
|
|
1090
|
+
}
|
|
1091
|
+
}
|
|
1092
|
+
}
|
|
1093
|
+
/**
|
|
1094
|
+
* Create an **independent** stack with the same API — e.g. for a dialog's
|
|
1095
|
+
* local editing session that should not pollute the app-wide history.
|
|
1096
|
+
* Discard it (or `clear()` it) when the session ends.
|
|
1097
|
+
*/
|
|
1098
|
+
createScope() {
|
|
1099
|
+
return new MkHistoryStack();
|
|
1100
|
+
}
|
|
1101
|
+
/** Append to the undo stack: clear the redo branch, evict beyond the limit. */
|
|
1102
|
+
commit(entry) {
|
|
1103
|
+
this.redoStack.set([]);
|
|
1104
|
+
this.undoStack.update((stack) => {
|
|
1105
|
+
const next = [...stack, entry];
|
|
1106
|
+
return next.length > this.maxSize ? next.slice(next.length - this.maxSize) : next;
|
|
1107
|
+
});
|
|
1108
|
+
}
|
|
1109
|
+
}
|
|
1110
|
+
/**
|
|
1111
|
+
* MkHistoryService — the app-wide {@link MkHistoryStack} singleton. Inject it
|
|
1112
|
+
* anywhere; call {@link MkHistoryStack.createScope} for an isolated local
|
|
1113
|
+
* stack, or wire `mod+z` / `mod+shift+z` / `mod+y` with
|
|
1114
|
+
* {@link registerHistoryHotkeys}.
|
|
1115
|
+
*
|
|
1116
|
+
* ### Recipe — undoable `mk-table` cell edits
|
|
1117
|
+
* Wire the table's `(cellEdit)` output so each saved cell becomes one
|
|
1118
|
+
* undoable step. Apply the edit yourself first (`push()` records an action
|
|
1119
|
+
* that already happened), and match rows by a stable id — the row object is
|
|
1120
|
+
* replaced by the immutable update:
|
|
1121
|
+
*
|
|
1122
|
+
* ```ts
|
|
1123
|
+
* // <mk-table [rows]="rows()" [columns]="columns" (cellEdit)="onCellEdit($event)" />
|
|
1124
|
+
* private readonly history = inject(MkHistoryService);
|
|
1125
|
+
* readonly rows = signal<Order[]>([...]);
|
|
1126
|
+
*
|
|
1127
|
+
* onCellEdit(edit: MkCellEdit<Order>): void {
|
|
1128
|
+
* const previous = edit.row[edit.key as keyof Order];
|
|
1129
|
+
* const apply = (value: unknown) =>
|
|
1130
|
+
* this.rows.update((rows) =>
|
|
1131
|
+
* rows.map((r) => (r.id === edit.row.id ? { ...r, [edit.key]: value } : r)),
|
|
1132
|
+
* );
|
|
1133
|
+
* apply(edit.value); // first execution is the caller's job
|
|
1134
|
+
* this.history.push({
|
|
1135
|
+
* label: `Edit ${edit.key}`,
|
|
1136
|
+
* undo: () => apply(previous),
|
|
1137
|
+
* redo: () => apply(edit.value),
|
|
1138
|
+
* });
|
|
1139
|
+
* }
|
|
1140
|
+
* ```
|
|
1141
|
+
*
|
|
1142
|
+
* Undoing replays the old value through the same `apply` function; if your
|
|
1143
|
+
* change pipeline re-emits into `onCellEdit` during that replay, the
|
|
1144
|
+
* re-entrancy guard drops the spurious `push()` (see {@link MkHistoryStack}).
|
|
1145
|
+
*/
|
|
1146
|
+
class MkHistoryService extends MkHistoryStack {
|
|
1147
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.7", ngImport: i0, type: MkHistoryService, deps: null, target: i0.ɵɵFactoryTarget.Injectable });
|
|
1148
|
+
static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "22.0.7", ngImport: i0, type: MkHistoryService, providedIn: 'root' });
|
|
1149
|
+
}
|
|
1150
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.7", ngImport: i0, type: MkHistoryService, decorators: [{
|
|
1151
|
+
type: Injectable,
|
|
1152
|
+
args: [{ providedIn: 'root' }]
|
|
1153
|
+
}] });
|
|
1154
|
+
|
|
1155
|
+
/** Tokens that count as modifiers rather than the trigger key. */
|
|
1156
|
+
const MODIFIERS = new Set(['mod', 'ctrl', 'control', 'meta', 'cmd', 'command', 'alt', 'option', 'shift']);
|
|
1157
|
+
/** Human-friendly aliases mapped onto their `KeyboardEvent.key` (lowercased) form. */
|
|
1158
|
+
const KEY_ALIASES = {
|
|
1159
|
+
esc: 'escape',
|
|
1160
|
+
space: ' ',
|
|
1161
|
+
spacebar: ' ',
|
|
1162
|
+
up: 'arrowup',
|
|
1163
|
+
down: 'arrowdown',
|
|
1164
|
+
left: 'arrowleft',
|
|
1165
|
+
right: 'arrowright',
|
|
1166
|
+
del: 'delete',
|
|
1167
|
+
return: 'enter',
|
|
1168
|
+
plus: '+',
|
|
1169
|
+
};
|
|
1170
|
+
/** Keys that are themselves modifiers — ignored as sequence/trigger keys. */
|
|
1171
|
+
const MODIFIER_KEYS = new Set(['control', 'shift', 'alt', 'meta', 'os', 'altgraph']);
|
|
1172
|
+
/** True on Apple platforms, where `mod` maps to the Command (meta) key. */
|
|
1173
|
+
function mkIsMacPlatform() {
|
|
1174
|
+
return typeof navigator !== 'undefined' && (navigator.platform ?? '').includes('Mac');
|
|
1175
|
+
}
|
|
1176
|
+
/**
|
|
1177
|
+
* Parse a single hotkey combo (e.g. `'mod+k'`, `'ctrl+shift+p'`, `'?'`) into a
|
|
1178
|
+
* structured {@link MkParsedHotkey}. Tokens are joined by `+`; the last
|
|
1179
|
+
* non-modifier token is the trigger key. Pure and SSR-safe.
|
|
1180
|
+
*
|
|
1181
|
+
* For a chord (space-separated sequence like `'g i'`), split on whitespace and
|
|
1182
|
+
* parse each step — {@link MkHotkeysService} does this for you.
|
|
1183
|
+
*/
|
|
1184
|
+
function mkParseHotkey(combo) {
|
|
1185
|
+
const parsed = {
|
|
1186
|
+
key: '',
|
|
1187
|
+
mod: false,
|
|
1188
|
+
ctrl: false,
|
|
1189
|
+
meta: false,
|
|
1190
|
+
alt: false,
|
|
1191
|
+
shift: false,
|
|
1192
|
+
};
|
|
1193
|
+
const tokens = combo
|
|
1194
|
+
.trim()
|
|
1195
|
+
.split('+')
|
|
1196
|
+
.map((t) => t.trim().toLowerCase())
|
|
1197
|
+
.filter((t) => t.length > 0);
|
|
1198
|
+
for (const token of tokens) {
|
|
1199
|
+
switch (token) {
|
|
1200
|
+
case 'mod':
|
|
1201
|
+
parsed.mod = true;
|
|
1202
|
+
break;
|
|
1203
|
+
case 'ctrl':
|
|
1204
|
+
case 'control':
|
|
1205
|
+
parsed.ctrl = true;
|
|
1206
|
+
break;
|
|
1207
|
+
case 'meta':
|
|
1208
|
+
case 'cmd':
|
|
1209
|
+
case 'command':
|
|
1210
|
+
parsed.meta = true;
|
|
1211
|
+
break;
|
|
1212
|
+
case 'alt':
|
|
1213
|
+
case 'option':
|
|
1214
|
+
parsed.alt = true;
|
|
1215
|
+
break;
|
|
1216
|
+
case 'shift':
|
|
1217
|
+
parsed.shift = true;
|
|
1218
|
+
break;
|
|
1219
|
+
default:
|
|
1220
|
+
parsed.key = KEY_ALIASES[token] ?? token;
|
|
1221
|
+
}
|
|
1222
|
+
}
|
|
1223
|
+
return parsed;
|
|
1224
|
+
}
|
|
1225
|
+
/**
|
|
1226
|
+
* Does a `keydown` event satisfy a hotkey? `combo` may be a raw string (parsed
|
|
1227
|
+
* on the fly) or an already-parsed {@link MkParsedHotkey}.
|
|
1228
|
+
*
|
|
1229
|
+
* The key is compared case-insensitively; modifier flags must match exactly.
|
|
1230
|
+
* `mod` resolves to Meta on macOS and Ctrl elsewhere. Shift is not enforced for
|
|
1231
|
+
* symbol keys (e.g. `'?'`), whose shifted state varies by keyboard layout.
|
|
1232
|
+
*/
|
|
1233
|
+
function mkMatchesHotkey(event, combo) {
|
|
1234
|
+
const parsed = typeof combo === 'string' ? mkParseHotkey(combo) : combo;
|
|
1235
|
+
if (!parsed.key)
|
|
1236
|
+
return false;
|
|
1237
|
+
if (event.key.toLowerCase() !== parsed.key)
|
|
1238
|
+
return false;
|
|
1239
|
+
const mac = mkIsMacPlatform();
|
|
1240
|
+
const wantCtrl = parsed.ctrl || (parsed.mod && !mac);
|
|
1241
|
+
const wantMeta = parsed.meta || (parsed.mod && mac);
|
|
1242
|
+
if (event.ctrlKey !== wantCtrl)
|
|
1243
|
+
return false;
|
|
1244
|
+
if (event.metaKey !== wantMeta)
|
|
1245
|
+
return false;
|
|
1246
|
+
if (event.altKey !== parsed.alt)
|
|
1247
|
+
return false;
|
|
1248
|
+
// Shift: enforce when requested; for symbol keys (non-alphanumeric single
|
|
1249
|
+
// chars) the shifted state is layout-dependent, so don't enforce its absence.
|
|
1250
|
+
if (parsed.shift) {
|
|
1251
|
+
if (!event.shiftKey)
|
|
1252
|
+
return false;
|
|
1253
|
+
}
|
|
1254
|
+
else {
|
|
1255
|
+
const symbolKey = parsed.key.length === 1 && !/[a-z0-9]/i.test(parsed.key);
|
|
1256
|
+
if (!symbolKey && event.shiftKey)
|
|
1257
|
+
return false;
|
|
1258
|
+
}
|
|
1259
|
+
return true;
|
|
1260
|
+
}
|
|
1261
|
+
/** Window (ms) to complete a two-step chord before the pending key is dropped. */
|
|
1262
|
+
const SEQUENCE_TIMEOUT = 1000;
|
|
1263
|
+
/**
|
|
1264
|
+
* MkHotkeysService — app-wide keyboard shortcuts. Register a combo string and a
|
|
1265
|
+
* handler; a single `keydown` listener (attached lazily on the first
|
|
1266
|
+
* registration, browser only) dispatches to every match.
|
|
1267
|
+
*
|
|
1268
|
+
* Supports single combos (`'mod+k'`, `'ctrl+shift+p'`, `'?'`) and simple
|
|
1269
|
+
* two-step chords (`'g i'`). By default hotkeys are ignored while an editable
|
|
1270
|
+
* field is focused — pass `allowInInput` to opt in per registration.
|
|
1271
|
+
*
|
|
1272
|
+
* ```ts
|
|
1273
|
+
* const off = hotkeys.register('mod+k', (e) => openPalette(), { preventDefault: true });
|
|
1274
|
+
* // …later
|
|
1275
|
+
* off();
|
|
1276
|
+
* ```
|
|
1277
|
+
*/
|
|
1278
|
+
class MkHotkeysService {
|
|
1279
|
+
document = inject(DOCUMENT);
|
|
1280
|
+
isBrowser = isPlatformBrowser(inject(PLATFORM_ID));
|
|
1281
|
+
registrations = new Set();
|
|
1282
|
+
attached = false;
|
|
1283
|
+
/** The first key of an in-progress chord, awaiting its second key. */
|
|
1284
|
+
pendingEvent = null;
|
|
1285
|
+
pendingTimer;
|
|
1286
|
+
/**
|
|
1287
|
+
* Register `combo` → `handler`. Returns a disposer that unregisters it.
|
|
1288
|
+
* `combo` may be a single combo or a space-separated two-step chord.
|
|
1289
|
+
*/
|
|
1290
|
+
register(combo, handler, options = {}) {
|
|
1291
|
+
const steps = combo
|
|
1292
|
+
.trim()
|
|
1293
|
+
.split(/\s+/)
|
|
1294
|
+
.filter((s) => s.length > 0)
|
|
1295
|
+
.map(mkParseHotkey);
|
|
1296
|
+
const registration = {
|
|
1297
|
+
steps,
|
|
1298
|
+
handler,
|
|
1299
|
+
preventDefault: options.preventDefault ?? false,
|
|
1300
|
+
allowInInput: options.allowInInput ?? false,
|
|
1301
|
+
};
|
|
1302
|
+
this.registrations.add(registration);
|
|
1303
|
+
this.ensureAttached();
|
|
1304
|
+
return () => {
|
|
1305
|
+
this.registrations.delete(registration);
|
|
1306
|
+
if (this.registrations.size === 0)
|
|
1307
|
+
this.detach();
|
|
1308
|
+
};
|
|
1309
|
+
}
|
|
1310
|
+
/** Remove every registration and detach the listener. */
|
|
1311
|
+
unregisterAll() {
|
|
1312
|
+
this.registrations.clear();
|
|
1313
|
+
this.clearPending();
|
|
1314
|
+
this.detach();
|
|
1315
|
+
}
|
|
1316
|
+
ensureAttached() {
|
|
1317
|
+
if (this.attached || !this.isBrowser)
|
|
1318
|
+
return;
|
|
1319
|
+
this.document.addEventListener('keydown', this.onKeydown, true);
|
|
1320
|
+
this.attached = true;
|
|
1321
|
+
}
|
|
1322
|
+
detach() {
|
|
1323
|
+
if (!this.attached)
|
|
1324
|
+
return;
|
|
1325
|
+
this.document.removeEventListener('keydown', this.onKeydown, true);
|
|
1326
|
+
this.attached = false;
|
|
1327
|
+
}
|
|
1328
|
+
onKeydown = (event) => {
|
|
1329
|
+
const e = event;
|
|
1330
|
+
// Bare modifier presses neither trigger nor advance a chord.
|
|
1331
|
+
if (MODIFIER_KEYS.has(e.key.toLowerCase()))
|
|
1332
|
+
return;
|
|
1333
|
+
const editable = this.isEditable(e.target);
|
|
1334
|
+
// 1. Try to complete an in-progress chord.
|
|
1335
|
+
if (this.pendingEvent) {
|
|
1336
|
+
const first = this.pendingEvent;
|
|
1337
|
+
let completed = false;
|
|
1338
|
+
for (const reg of this.registrations) {
|
|
1339
|
+
if (reg.steps.length !== 2)
|
|
1340
|
+
continue;
|
|
1341
|
+
if (editable && !reg.allowInInput)
|
|
1342
|
+
continue;
|
|
1343
|
+
if (mkMatchesHotkey(first, reg.steps[0]) && mkMatchesHotkey(e, reg.steps[1])) {
|
|
1344
|
+
this.trigger(reg, e);
|
|
1345
|
+
completed = true;
|
|
1346
|
+
}
|
|
1347
|
+
}
|
|
1348
|
+
this.clearPending();
|
|
1349
|
+
if (completed)
|
|
1350
|
+
return;
|
|
1351
|
+
// Otherwise fall through: this key may fire a single hotkey or open a chord.
|
|
1352
|
+
}
|
|
1353
|
+
// 2. Single-combo matches.
|
|
1354
|
+
let matched = false;
|
|
1355
|
+
for (const reg of this.registrations) {
|
|
1356
|
+
if (reg.steps.length !== 1)
|
|
1357
|
+
continue;
|
|
1358
|
+
if (editable && !reg.allowInInput)
|
|
1359
|
+
continue;
|
|
1360
|
+
if (mkMatchesHotkey(e, reg.steps[0])) {
|
|
1361
|
+
this.trigger(reg, e);
|
|
1362
|
+
matched = true;
|
|
1363
|
+
}
|
|
1364
|
+
}
|
|
1365
|
+
if (matched)
|
|
1366
|
+
return;
|
|
1367
|
+
// 3. Open a chord if this key matches the first step of any two-step combo.
|
|
1368
|
+
for (const reg of this.registrations) {
|
|
1369
|
+
if (reg.steps.length !== 2)
|
|
1370
|
+
continue;
|
|
1371
|
+
if (editable && !reg.allowInInput)
|
|
1372
|
+
continue;
|
|
1373
|
+
if (mkMatchesHotkey(e, reg.steps[0])) {
|
|
1374
|
+
this.pendingEvent = e;
|
|
1375
|
+
this.pendingTimer = setTimeout(() => this.clearPending(), SEQUENCE_TIMEOUT);
|
|
1376
|
+
return;
|
|
1377
|
+
}
|
|
1378
|
+
}
|
|
1379
|
+
};
|
|
1380
|
+
trigger(reg, e) {
|
|
1381
|
+
if (reg.preventDefault)
|
|
1382
|
+
e.preventDefault();
|
|
1383
|
+
reg.handler(e);
|
|
1384
|
+
}
|
|
1385
|
+
clearPending() {
|
|
1386
|
+
this.pendingEvent = null;
|
|
1387
|
+
if (this.pendingTimer) {
|
|
1388
|
+
clearTimeout(this.pendingTimer);
|
|
1389
|
+
this.pendingTimer = undefined;
|
|
1390
|
+
}
|
|
1391
|
+
}
|
|
1392
|
+
isEditable(target) {
|
|
1393
|
+
const el = target;
|
|
1394
|
+
if (!el || typeof el.tagName !== 'string')
|
|
1395
|
+
return false;
|
|
1396
|
+
const tag = el.tagName;
|
|
1397
|
+
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT')
|
|
1398
|
+
return true;
|
|
1399
|
+
return el.isContentEditable === true;
|
|
1400
|
+
}
|
|
1401
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.7", ngImport: i0, type: MkHotkeysService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
|
|
1402
|
+
static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "22.0.7", ngImport: i0, type: MkHotkeysService, providedIn: 'root' });
|
|
1403
|
+
}
|
|
1404
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.7", ngImport: i0, type: MkHotkeysService, decorators: [{
|
|
1405
|
+
type: Injectable,
|
|
1406
|
+
args: [{ providedIn: 'root' }]
|
|
1407
|
+
}] });
|
|
1408
|
+
|
|
1409
|
+
/**
|
|
1410
|
+
* Opt-in helper wiring the standard editor shortcuts to a history stack
|
|
1411
|
+
* through {@link MkHotkeysService}:
|
|
1412
|
+
*
|
|
1413
|
+
* - `mod+z` → `undo()`
|
|
1414
|
+
* - `mod+shift+z` **and** `mod+y` → `redo()`
|
|
1415
|
+
*
|
|
1416
|
+
* Must be called in an **injection context** (constructor, field initializer,
|
|
1417
|
+
* or `runInInjectionContext`). Nothing is registered automatically — apps own
|
|
1418
|
+
* their shortcuts and call this where (and if) they want them:
|
|
1419
|
+
*
|
|
1420
|
+
* ```ts
|
|
1421
|
+
* export class AppShell {
|
|
1422
|
+
* private readonly disposeHistoryKeys = registerHistoryHotkeys();
|
|
1423
|
+
* }
|
|
1424
|
+
* ```
|
|
1425
|
+
*
|
|
1426
|
+
* Pass a specific stack (e.g. a `createScope()` stack for a dialog) to bind
|
|
1427
|
+
* the shortcuts to it instead of the app-wide {@link MkHistoryService}.
|
|
1428
|
+
*
|
|
1429
|
+
* Returns a dispose function that unregisters all three shortcuts; it is also
|
|
1430
|
+
* called automatically when the current injection context is destroyed.
|
|
1431
|
+
*
|
|
1432
|
+
* Notes:
|
|
1433
|
+
* - `MkHotkeysService` ignores keydowns while an editable element
|
|
1434
|
+
* (input/textarea/select/contentEditable) is focused, so native text-field
|
|
1435
|
+
* undo keeps working — no extra handling needed here.
|
|
1436
|
+
* - The shortcuts register with `preventDefault` so the browser's own
|
|
1437
|
+
* history/document undo does not fire alongside yours.
|
|
1438
|
+
*/
|
|
1439
|
+
function registerHistoryHotkeys(history = inject(MkHistoryService)) {
|
|
1440
|
+
const hotkeys = inject(MkHotkeysService);
|
|
1441
|
+
const disposers = [
|
|
1442
|
+
hotkeys.register('mod+z', () => void history.undo(), { preventDefault: true }),
|
|
1443
|
+
hotkeys.register('mod+shift+z', () => void history.redo(), { preventDefault: true }),
|
|
1444
|
+
hotkeys.register('mod+y', () => void history.redo(), { preventDefault: true }),
|
|
1445
|
+
];
|
|
1446
|
+
let disposed = false;
|
|
1447
|
+
const dispose = () => {
|
|
1448
|
+
if (disposed)
|
|
1449
|
+
return;
|
|
1450
|
+
disposed = true;
|
|
1451
|
+
for (const off of disposers)
|
|
1452
|
+
off();
|
|
1453
|
+
};
|
|
1454
|
+
inject(DestroyRef, { optional: true })?.onDestroy(dispose);
|
|
1455
|
+
return dispose;
|
|
1456
|
+
}
|
|
1457
|
+
|
|
1458
|
+
/**
|
|
1459
|
+
* `aria-keyshortcuts` names (UI Events `KeyboardEvent.key` values) keyed by
|
|
1460
|
+
* the lowercase form {@link mkParseHotkey} produces. Space and plus have
|
|
1461
|
+
* dedicated names because they collide with the ARIA separators.
|
|
1462
|
+
*/
|
|
1463
|
+
const ARIA_KEY_NAMES = {
|
|
1464
|
+
' ': 'Space',
|
|
1465
|
+
'+': 'Plus',
|
|
1466
|
+
escape: 'Escape',
|
|
1467
|
+
enter: 'Enter',
|
|
1468
|
+
tab: 'Tab',
|
|
1469
|
+
backspace: 'Backspace',
|
|
1470
|
+
delete: 'Delete',
|
|
1471
|
+
insert: 'Insert',
|
|
1472
|
+
home: 'Home',
|
|
1473
|
+
end: 'End',
|
|
1474
|
+
pageup: 'PageUp',
|
|
1475
|
+
pagedown: 'PageDown',
|
|
1476
|
+
arrowup: 'ArrowUp',
|
|
1477
|
+
arrowdown: 'ArrowDown',
|
|
1478
|
+
arrowleft: 'ArrowLeft',
|
|
1479
|
+
arrowright: 'ArrowRight',
|
|
1480
|
+
};
|
|
1481
|
+
/** Map a parsed (lowercase) key to its `aria-keyshortcuts` spelling. */
|
|
1482
|
+
function ariaKeyName(key) {
|
|
1483
|
+
const mapped = ARIA_KEY_NAMES[key];
|
|
1484
|
+
if (mapped)
|
|
1485
|
+
return mapped;
|
|
1486
|
+
if (/^f\d{1,2}$/.test(key))
|
|
1487
|
+
return key.toUpperCase(); // F1 … F12
|
|
1488
|
+
return key.length === 1 ? key.toUpperCase() : key;
|
|
1489
|
+
}
|
|
1490
|
+
/**
|
|
1491
|
+
* One parsed combo as an ARIA `+`-joined token, e.g. `Control+Shift+P`.
|
|
1492
|
+
* `mod` resolves the same way the matcher does: Meta on macOS, Control
|
|
1493
|
+
* elsewhere ({@link mkIsMacPlatform}).
|
|
1494
|
+
*/
|
|
1495
|
+
function ariaCombo(parsed) {
|
|
1496
|
+
const mac = mkIsMacPlatform();
|
|
1497
|
+
const tokens = [];
|
|
1498
|
+
if (parsed.ctrl || (parsed.mod && !mac))
|
|
1499
|
+
tokens.push('Control');
|
|
1500
|
+
if (parsed.alt)
|
|
1501
|
+
tokens.push('Alt');
|
|
1502
|
+
if (parsed.shift)
|
|
1503
|
+
tokens.push('Shift');
|
|
1504
|
+
if (parsed.meta || (parsed.mod && mac))
|
|
1505
|
+
tokens.push('Meta');
|
|
1506
|
+
if (parsed.key)
|
|
1507
|
+
tokens.push(ariaKeyName(parsed.key));
|
|
1508
|
+
return tokens.join('+');
|
|
1509
|
+
}
|
|
1510
|
+
/**
|
|
1511
|
+
* `[mkHotkey]` — declaratively bind a keyboard shortcut to a host element.
|
|
1512
|
+
*
|
|
1513
|
+
* When the combo fires, a `<button>` or `<a>` host is `click()`ed; any other
|
|
1514
|
+
* host emits `(mkHotkeyPressed)`. Registration is scoped to the element's
|
|
1515
|
+
* lifetime (auto-unregistered on destroy) via {@link MkHotkeysService}.
|
|
1516
|
+
*
|
|
1517
|
+
* ```html
|
|
1518
|
+
* <button mkHotkey="mod+s" (click)="save()">Save</button>
|
|
1519
|
+
* <div mkHotkey="?" (mkHotkeyPressed)="showHelp()">…</div>
|
|
1520
|
+
* ```
|
|
1521
|
+
*/
|
|
1522
|
+
class MkHotkey {
|
|
1523
|
+
host = inject(ElementRef);
|
|
1524
|
+
hotkeys = inject(MkHotkeysService);
|
|
1525
|
+
/** The combo (or space-separated chord) to bind, e.g. `mod+k`, `g i`, `?`. */
|
|
1526
|
+
mkHotkey = input.required(/* @ts-ignore */
|
|
1527
|
+
...(ngDevMode ? [{ debugName: "mkHotkey" }] : /* istanbul ignore next */ []));
|
|
1528
|
+
/** Fire even while an editable field is focused. */
|
|
1529
|
+
mkHotkeyAllowInInput = input(false, { ...(ngDevMode ? { debugName: "mkHotkeyAllowInInput" } : /* istanbul ignore next */ {}), transform: booleanAttribute });
|
|
1530
|
+
/** Call `preventDefault()` when the hotkey fires. Defaults to `true`. */
|
|
1531
|
+
mkHotkeyPreventDefault = input(true, { ...(ngDevMode ? { debugName: "mkHotkeyPreventDefault" } : /* istanbul ignore next */ {}), transform: booleanAttribute });
|
|
1532
|
+
/** Emitted when the hotkey fires and the host is not a button/link. */
|
|
1533
|
+
mkHotkeyPressed = output();
|
|
1534
|
+
/**
|
|
1535
|
+
* The combo in `aria-keyshortcuts` syntax — per-step tokens `+`-joined
|
|
1536
|
+
* (`Control+K`), chord steps space-separated (`G I`). Exposed on the host.
|
|
1537
|
+
*/
|
|
1538
|
+
ariaKeyshortcuts = computed(() => {
|
|
1539
|
+
const combos = this.mkHotkey()
|
|
1540
|
+
.trim()
|
|
1541
|
+
.split(/\s+/)
|
|
1542
|
+
.filter((s) => s.length > 0)
|
|
1543
|
+
.map((step) => ariaCombo(mkParseHotkey(step)))
|
|
1544
|
+
.filter((s) => s.length > 0);
|
|
1545
|
+
return combos.length ? combos.join(' ') : null;
|
|
1546
|
+
}, /* @ts-ignore */
|
|
1547
|
+
...(ngDevMode ? [{ debugName: "ariaKeyshortcuts" }] : /* istanbul ignore next */ []));
|
|
1548
|
+
dispose;
|
|
1549
|
+
constructor() {
|
|
1550
|
+
afterNextRender(() => {
|
|
1551
|
+
this.dispose = this.hotkeys.register(this.mkHotkey(), (e) => this.onTrigger(e), {
|
|
1552
|
+
allowInInput: this.mkHotkeyAllowInInput(),
|
|
1553
|
+
preventDefault: this.mkHotkeyPreventDefault(),
|
|
1554
|
+
});
|
|
1555
|
+
});
|
|
1556
|
+
}
|
|
1557
|
+
onTrigger(e) {
|
|
1558
|
+
const el = this.host.nativeElement;
|
|
1559
|
+
const tag = el.tagName;
|
|
1560
|
+
if (tag === 'BUTTON' || tag === 'A') {
|
|
1561
|
+
el.click();
|
|
1562
|
+
return;
|
|
1563
|
+
}
|
|
1564
|
+
this.mkHotkeyPressed.emit(e);
|
|
1565
|
+
}
|
|
1566
|
+
ngOnDestroy() {
|
|
1567
|
+
this.dispose?.();
|
|
1568
|
+
}
|
|
1569
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.7", ngImport: i0, type: MkHotkey, deps: [], target: i0.ɵɵFactoryTarget.Directive });
|
|
1570
|
+
static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "22.0.7", type: MkHotkey, isStandalone: true, selector: "[mkHotkey]", inputs: { mkHotkey: { classPropertyName: "mkHotkey", publicName: "mkHotkey", isSignal: true, isRequired: true, transformFunction: null }, mkHotkeyAllowInInput: { classPropertyName: "mkHotkeyAllowInInput", publicName: "mkHotkeyAllowInInput", isSignal: true, isRequired: false, transformFunction: null }, mkHotkeyPreventDefault: { classPropertyName: "mkHotkeyPreventDefault", publicName: "mkHotkeyPreventDefault", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { mkHotkeyPressed: "mkHotkeyPressed" }, host: { properties: { "attr.aria-keyshortcuts": "ariaKeyshortcuts()" } }, exportAs: ["mkHotkey"], ngImport: i0 });
|
|
1571
|
+
}
|
|
1572
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.7", ngImport: i0, type: MkHotkey, decorators: [{
|
|
1573
|
+
type: Directive,
|
|
1574
|
+
args: [{
|
|
1575
|
+
selector: '[mkHotkey]',
|
|
1576
|
+
exportAs: 'mkHotkey',
|
|
1577
|
+
host: {
|
|
1578
|
+
'[attr.aria-keyshortcuts]': 'ariaKeyshortcuts()',
|
|
1579
|
+
},
|
|
1580
|
+
}]
|
|
1581
|
+
}], ctorParameters: () => [], propDecorators: { mkHotkey: [{ type: i0.Input, args: [{ isSignal: true, alias: "mkHotkey", required: true }] }], mkHotkeyAllowInInput: [{ type: i0.Input, args: [{ isSignal: true, alias: "mkHotkeyAllowInInput", required: false }] }], mkHotkeyPreventDefault: [{ type: i0.Input, args: [{ isSignal: true, alias: "mkHotkeyPreventDefault", required: false }] }], mkHotkeyPressed: [{ type: i0.Output, args: ["mkHotkeyPressed"] }] } });
|
|
1582
|
+
|
|
1583
|
+
/**
|
|
1584
|
+
* Permission policy — the contract the permission directives (`*mkCan`,
|
|
1585
|
+
* `*mkCannot`, `[mkCanDisable]`) check against. Provide an implementation
|
|
1586
|
+
* once at bootstrap; the directives inject it optionally and, when no policy
|
|
1587
|
+
* is provided at all, treat every permission as **granted** — an app that
|
|
1588
|
+
* never wires permissions must not have its whole UI hidden.
|
|
1589
|
+
*
|
|
1590
|
+
* `can` may return a plain `boolean` (static role checks) or a
|
|
1591
|
+
* `Signal<boolean>` (live permissions). The directives normalise both, and a
|
|
1592
|
+
* signal-returning policy re-evaluates reactively: views appear/disappear and
|
|
1593
|
+
* controls enable/disable the moment the underlying permissions change.
|
|
1594
|
+
*
|
|
1595
|
+
* ```ts
|
|
1596
|
+
* // A signal-based policy fed from an auth service:
|
|
1597
|
+
* @Injectable()
|
|
1598
|
+
* export class AppPermissionPolicy extends MkPermissionPolicy {
|
|
1599
|
+
* private readonly auth = inject(AuthService);
|
|
1600
|
+
*
|
|
1601
|
+
* can(permission: string): Signal<boolean> {
|
|
1602
|
+
* // auth.permissions is a Signal<string[]> that updates on login/logout.
|
|
1603
|
+
* return computed(() => this.auth.permissions().includes(permission));
|
|
1604
|
+
* }
|
|
1605
|
+
* }
|
|
1606
|
+
*
|
|
1607
|
+
* bootstrapApplication(App, {
|
|
1608
|
+
* providers: [
|
|
1609
|
+
* { provide: MkPermissionPolicy, useClass: AppPermissionPolicy },
|
|
1610
|
+
* ],
|
|
1611
|
+
* });
|
|
1612
|
+
* ```
|
|
1613
|
+
*
|
|
1614
|
+
* A static policy is just as valid:
|
|
1615
|
+
*
|
|
1616
|
+
* ```ts
|
|
1617
|
+
* class StaticPolicy extends MkPermissionPolicy {
|
|
1618
|
+
* can(permission: string): boolean {
|
|
1619
|
+
* return CURRENT_USER.permissions.includes(permission);
|
|
1620
|
+
* }
|
|
1621
|
+
* }
|
|
1622
|
+
* ```
|
|
1623
|
+
*/
|
|
1624
|
+
class MkPermissionPolicy {
|
|
1625
|
+
}
|
|
1626
|
+
/**
|
|
1627
|
+
* Resolve a policy verdict to a boolean, unwrapping signal-based results.
|
|
1628
|
+
* Call it inside a reactive context (`computed` / `effect`) so a
|
|
1629
|
+
* signal-returning policy stays live. A missing policy grants everything.
|
|
1630
|
+
*/
|
|
1631
|
+
function mkPermissionGranted(policy, permission) {
|
|
1632
|
+
if (!policy)
|
|
1633
|
+
return true;
|
|
1634
|
+
const verdict = policy.can(permission);
|
|
1635
|
+
return typeof verdict === 'boolean' ? verdict : verdict();
|
|
1636
|
+
}
|
|
1637
|
+
|
|
1638
|
+
/**
|
|
1639
|
+
* Shared view management for `*mkCan` / `*mkCannot`. Must be called from the
|
|
1640
|
+
* directive's constructor (an injection context). Creates the embedded view
|
|
1641
|
+
* while the (possibly negated) verdict holds, the `else` template while it
|
|
1642
|
+
* does not, and destroys/recreates views when a signal-based policy flips.
|
|
1643
|
+
*/
|
|
1644
|
+
function setupPermissionView(permission, fallback, negate) {
|
|
1645
|
+
const policy = inject(MkPermissionPolicy, { optional: true });
|
|
1646
|
+
const templateRef = inject(TemplateRef);
|
|
1647
|
+
const viewContainer = inject(ViewContainerRef);
|
|
1648
|
+
const visible = computed(() => {
|
|
1649
|
+
const granted = mkPermissionGranted(policy, permission());
|
|
1650
|
+
return negate ? !granted : granted;
|
|
1651
|
+
}, /* @ts-ignore */
|
|
1652
|
+
...(ngDevMode ? [{ debugName: "visible" }] : /* istanbul ignore next */ []));
|
|
1653
|
+
// Tracks the currently rendered template so a change to the *other* branch
|
|
1654
|
+
// (e.g. the else template resolving while the view is shown) is a no-op.
|
|
1655
|
+
let rendered = null;
|
|
1656
|
+
effect(() => {
|
|
1657
|
+
const next = visible() ? templateRef : fallback();
|
|
1658
|
+
if (next === rendered)
|
|
1659
|
+
return;
|
|
1660
|
+
rendered = next;
|
|
1661
|
+
viewContainer.clear();
|
|
1662
|
+
if (next)
|
|
1663
|
+
viewContainer.createEmbeddedView(next);
|
|
1664
|
+
});
|
|
1665
|
+
}
|
|
1666
|
+
/**
|
|
1667
|
+
* Structural directive that renders its template only while the app's
|
|
1668
|
+
* {@link MkPermissionPolicy} grants the given permission. With a
|
|
1669
|
+
* signal-based policy the check is live — the view is created and destroyed
|
|
1670
|
+
* as permissions change. When no policy is provided, everything is granted.
|
|
1671
|
+
*
|
|
1672
|
+
* ```html
|
|
1673
|
+
* <button *mkCan="'posts.delete'" (click)="remove()">Delete</button>
|
|
1674
|
+
*
|
|
1675
|
+
* <!-- With a fallback template, like *ngIf's else: -->
|
|
1676
|
+
* <section *mkCan="'billing.view'; else locked">…</section>
|
|
1677
|
+
* <ng-template #locked><mk-empty-state title="No access" /></ng-template>
|
|
1678
|
+
* ```
|
|
1679
|
+
*/
|
|
1680
|
+
class MkCan {
|
|
1681
|
+
/** The permission key to check, e.g. `'posts.delete'`. */
|
|
1682
|
+
permission = input.required({ ...(ngDevMode ? { debugName: "permission" } : /* istanbul ignore next */ {}), alias: 'mkCan' });
|
|
1683
|
+
/** Template rendered while the permission is denied (`; else ref`). */
|
|
1684
|
+
elseTemplate = input(null, { ...(ngDevMode ? { debugName: "elseTemplate" } : /* istanbul ignore next */ {}), alias: 'mkCanElse' });
|
|
1685
|
+
constructor() {
|
|
1686
|
+
setupPermissionView(this.permission, this.elseTemplate, false);
|
|
1687
|
+
}
|
|
1688
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.7", ngImport: i0, type: MkCan, deps: [], target: i0.ɵɵFactoryTarget.Directive });
|
|
1689
|
+
static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "22.0.7", type: MkCan, isStandalone: true, selector: "[mkCan]", inputs: { permission: { classPropertyName: "permission", publicName: "mkCan", isSignal: true, isRequired: true, transformFunction: null }, elseTemplate: { classPropertyName: "elseTemplate", publicName: "mkCanElse", isSignal: true, isRequired: false, transformFunction: null } }, ngImport: i0 });
|
|
1690
|
+
}
|
|
1691
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.7", ngImport: i0, type: MkCan, decorators: [{
|
|
1692
|
+
type: Directive,
|
|
1693
|
+
args: [{ selector: '[mkCan]' }]
|
|
1694
|
+
}], ctorParameters: () => [], propDecorators: { permission: [{ type: i0.Input, args: [{ isSignal: true, alias: "mkCan", required: true }] }], elseTemplate: [{ type: i0.Input, args: [{ isSignal: true, alias: "mkCanElse", required: false }] }] } });
|
|
1695
|
+
/**
|
|
1696
|
+
* The negation of {@link MkCan}: renders its template only while the
|
|
1697
|
+
* permission is **denied** — for upsell hints, "ask an admin" notes and the
|
|
1698
|
+
* like. With no policy provided everything is granted, so `*mkCannot`
|
|
1699
|
+
* renders nothing.
|
|
1700
|
+
*
|
|
1701
|
+
* ```html
|
|
1702
|
+
* <p *mkCannot="'reports.export'">Exporting requires an admin role.</p>
|
|
1703
|
+
* ```
|
|
1704
|
+
*/
|
|
1705
|
+
class MkCannot {
|
|
1706
|
+
/** The permission key to check. */
|
|
1707
|
+
permission = input.required({ ...(ngDevMode ? { debugName: "permission" } : /* istanbul ignore next */ {}), alias: 'mkCannot' });
|
|
1708
|
+
/** Template rendered while the permission is granted (`; else ref`). */
|
|
1709
|
+
elseTemplate = input(null, { ...(ngDevMode ? { debugName: "elseTemplate" } : /* istanbul ignore next */ {}), alias: 'mkCannotElse' });
|
|
1710
|
+
constructor() {
|
|
1711
|
+
setupPermissionView(this.permission, this.elseTemplate, true);
|
|
1712
|
+
}
|
|
1713
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.7", ngImport: i0, type: MkCannot, deps: [], target: i0.ɵɵFactoryTarget.Directive });
|
|
1714
|
+
static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "22.0.7", type: MkCannot, isStandalone: true, selector: "[mkCannot]", inputs: { permission: { classPropertyName: "permission", publicName: "mkCannot", isSignal: true, isRequired: true, transformFunction: null }, elseTemplate: { classPropertyName: "elseTemplate", publicName: "mkCannotElse", isSignal: true, isRequired: false, transformFunction: null } }, ngImport: i0 });
|
|
1715
|
+
}
|
|
1716
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.7", ngImport: i0, type: MkCannot, decorators: [{
|
|
1717
|
+
type: Directive,
|
|
1718
|
+
args: [{ selector: '[mkCannot]' }]
|
|
1719
|
+
}], ctorParameters: () => [], propDecorators: { permission: [{ type: i0.Input, args: [{ isSignal: true, alias: "mkCannot", required: true }] }], elseTemplate: [{ type: i0.Input, args: [{ isSignal: true, alias: "mkCannotElse", required: false }] }] } });
|
|
1720
|
+
|
|
1721
|
+
/**
|
|
1722
|
+
* Disables the host while the app's {@link MkPermissionPolicy} denies the
|
|
1723
|
+
* given permission — the "keep it visible but inert" alternative to `*mkCan`
|
|
1724
|
+
* for buttons, inputs and menu items.
|
|
1725
|
+
*
|
|
1726
|
+
* While denied, the host gets `aria-disabled="true"`, and — when the element
|
|
1727
|
+
* supports it (button, input, select, textarea, fieldset…) — its native
|
|
1728
|
+
* `disabled` property is set too. Non-disableable elements (links, custom
|
|
1729
|
+
* menu items) get `aria-disabled` only; pair it with CSS / a click guard as
|
|
1730
|
+
* needed. The directive owns the disabled state: it also re-enables the host
|
|
1731
|
+
* when the permission is granted. With a signal-based policy the state flips
|
|
1732
|
+
* live; with no policy provided, everything is granted.
|
|
1733
|
+
*
|
|
1734
|
+
* ```html
|
|
1735
|
+
* <button [mkCanDisable]="'posts.delete'" (click)="remove()">Delete</button>
|
|
1736
|
+
* <a [mkCanDisable]="'billing.view'" routerLink="/billing">Billing</a>
|
|
1737
|
+
* ```
|
|
1738
|
+
*/
|
|
1739
|
+
class MkCanDisable {
|
|
1740
|
+
policy = inject(MkPermissionPolicy, { optional: true });
|
|
1741
|
+
host = inject(ElementRef);
|
|
1742
|
+
/** The permission key to check, e.g. `'posts.delete'`. */
|
|
1743
|
+
permission = input.required({ ...(ngDevMode ? { debugName: "permission" } : /* istanbul ignore next */ {}), alias: 'mkCanDisable' });
|
|
1744
|
+
/** Whether the permission is currently denied. */
|
|
1745
|
+
denied = computed(() => !mkPermissionGranted(this.policy, this.permission()), /* @ts-ignore */
|
|
1746
|
+
...(ngDevMode ? [{ debugName: "denied" }] : /* istanbul ignore next */ []));
|
|
1747
|
+
constructor() {
|
|
1748
|
+
const el = this.host.nativeElement;
|
|
1749
|
+
effect(() => {
|
|
1750
|
+
const denied = this.denied();
|
|
1751
|
+
// Only elements with a native `disabled` member (form controls) get the
|
|
1752
|
+
// property; everything else relies on `aria-disabled` alone.
|
|
1753
|
+
if ('disabled' in el)
|
|
1754
|
+
el.disabled = denied;
|
|
1755
|
+
});
|
|
1756
|
+
}
|
|
1757
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.7", ngImport: i0, type: MkCanDisable, deps: [], target: i0.ɵɵFactoryTarget.Directive });
|
|
1758
|
+
static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "22.0.7", type: MkCanDisable, isStandalone: true, selector: "[mkCanDisable]", inputs: { permission: { classPropertyName: "permission", publicName: "mkCanDisable", isSignal: true, isRequired: true, transformFunction: null } }, host: { properties: { "attr.aria-disabled": "denied() ? 'true' : null" } }, ngImport: i0 });
|
|
1759
|
+
}
|
|
1760
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.7", ngImport: i0, type: MkCanDisable, decorators: [{
|
|
1761
|
+
type: Directive,
|
|
1762
|
+
args: [{
|
|
1763
|
+
selector: '[mkCanDisable]',
|
|
1764
|
+
host: {
|
|
1765
|
+
'[attr.aria-disabled]': "denied() ? 'true' : null",
|
|
1766
|
+
},
|
|
1767
|
+
}]
|
|
1768
|
+
}], ctorParameters: () => [], propDecorators: { permission: [{ type: i0.Input, args: [{ isSignal: true, alias: "mkCanDisable", required: true }] }] } });
|
|
1769
|
+
|
|
1770
|
+
/**
|
|
1771
|
+
* DIRECTIVES / utilities group barrel for @mk-kit/ui.
|
|
1772
|
+
*/
|
|
1773
|
+
|
|
1774
|
+
/**
|
|
1775
|
+
* Generated bundle index. Do not edit.
|
|
1776
|
+
*/
|
|
1777
|
+
|
|
1778
|
+
export { MK_FIELD_PRESETS, MkAutofocus, MkAutosize, MkCan, MkCanDisable, MkCannot, MkClickOutside, MkCopyToClipboard, MkField, MkHistoryService, MkHistoryStack, MkHotkey, MkHotkeysService, MkInfiniteScroll, MkIntersect, MkMask, MkPermissionPolicy, MkRipple, MkScrollspy, mkApplyMask, mkMaskCaret, mkMatchesHotkey, mkParseHotkey, mkPermissionGranted, registerHistoryHotkeys };
|
|
1779
|
+
//# sourceMappingURL=mk-kit-ui-directives.mjs.map
|