@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,2426 @@
|
|
|
1
|
+
import * as i0 from '@angular/core';
|
|
2
|
+
import { inject, input, booleanAttribute, output, computed, ChangeDetectionStrategy, Component, ElementRef, ApplicationRef, EnvironmentInjector, DOCUMENT, PLATFORM_ID, createComponent, Directive, Injector, viewChild, signal, afterNextRender, numberAttribute, model, ViewEncapsulation, Injectable, InjectionToken, DestroyRef } from '@angular/core';
|
|
3
|
+
import { MK_I18N, mkUniqueId, mkComputeAnchoredPosition, mkGetFocusable, MkAnchoredPanel, MkOverlayRef, MK_OVERLAY_DATA, MkOverlayService } from '@mk-kit/ui/core';
|
|
4
|
+
import { isPlatformBrowser } from '@angular/common';
|
|
5
|
+
import { MkButton } from '@mk-kit/ui/button';
|
|
6
|
+
import { MkBadge, MkEmptyState } from '@mk-kit/ui/data';
|
|
7
|
+
import { MkIcon } from '@mk-kit/ui/icon';
|
|
8
|
+
import { MkInput, MkFormField } from '@mk-kit/ui/forms';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Alert — an inline banner that communicates a contextual status message.
|
|
12
|
+
* Fully themed via `--mk-*` tokens using the same tone/local-var pattern as
|
|
13
|
+
* the Button. Danger/warning alerts use `role="alert"` (assertive), all other
|
|
14
|
+
* tones use `role="status"` (polite).
|
|
15
|
+
*
|
|
16
|
+
* ```html
|
|
17
|
+
* <mk-alert tone="success" title="Saved">Your changes were saved.</mk-alert>
|
|
18
|
+
*
|
|
19
|
+
* <mk-alert tone="danger" variant="outline" dismissible (dismissed)="hide()">
|
|
20
|
+
* <svg mkAlertIcon>…</svg>
|
|
21
|
+
* Could not reach the server.
|
|
22
|
+
* </mk-alert>
|
|
23
|
+
* ```
|
|
24
|
+
*/
|
|
25
|
+
class MkAlert {
|
|
26
|
+
/** Localised strings (override globally via `provideMkI18n`). */
|
|
27
|
+
i18n = inject(MK_I18N);
|
|
28
|
+
/** Semantic color tone. */
|
|
29
|
+
tone = input('info', /* @ts-ignore */
|
|
30
|
+
...(ngDevMode ? [{ debugName: "tone" }] : /* istanbul ignore next */ []));
|
|
31
|
+
/** Visual treatment. */
|
|
32
|
+
variant = input('soft', /* @ts-ignore */
|
|
33
|
+
...(ngDevMode ? [{ debugName: "variant" }] : /* istanbul ignore next */ []));
|
|
34
|
+
/** Optional bold heading rendered above the message. */
|
|
35
|
+
title = input(/* @ts-ignore */
|
|
36
|
+
...(ngDevMode ? [undefined, { debugName: "title" }] : /* istanbul ignore next */ []));
|
|
37
|
+
/** When set, renders a keyboard-accessible close button that emits `dismissed`. */
|
|
38
|
+
dismissible = input(false, { ...(ngDevMode ? { debugName: "dismissible" } : /* istanbul ignore next */ {}), transform: booleanAttribute });
|
|
39
|
+
/** Emitted when the user activates the close button. */
|
|
40
|
+
dismissed = output();
|
|
41
|
+
/** Assertive roles for urgent tones, polite `status` otherwise. */
|
|
42
|
+
role = computed(() => this.tone() === 'danger' || this.tone() === 'warning' ? 'alert' : 'status', /* @ts-ignore */
|
|
43
|
+
...(ngDevMode ? [{ debugName: "role" }] : /* istanbul ignore next */ []));
|
|
44
|
+
onDismiss() {
|
|
45
|
+
this.dismissed.emit();
|
|
46
|
+
}
|
|
47
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.7", ngImport: i0, type: MkAlert, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
48
|
+
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.0.7", type: MkAlert, isStandalone: true, selector: "mk-alert", inputs: { tone: { classPropertyName: "tone", publicName: "tone", isSignal: true, isRequired: false, transformFunction: null }, variant: { classPropertyName: "variant", publicName: "variant", isSignal: true, isRequired: false, transformFunction: null }, title: { classPropertyName: "title", publicName: "title", isSignal: true, isRequired: false, transformFunction: null }, dismissible: { classPropertyName: "dismissible", publicName: "dismissible", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { dismissed: "dismissed" }, host: { properties: { "attr.data-tone": "tone()", "class.mk-alert--soft": "variant() === 'soft'", "class.mk-alert--solid": "variant() === 'solid'", "class.mk-alert--outline": "variant() === 'outline'", "attr.role": "role()" }, classAttribute: "mk-alert" }, ngImport: i0, template: "<span class=\"mk-alert__icon\" aria-hidden=\"true\">\n <ng-content select=\"[mkAlertIcon]\" />\n</span>\n\n<div class=\"mk-alert__content\">\n @if (title(); as heading) {\n <p class=\"mk-alert__title\">{{ heading }}</p>\n }\n <div class=\"mk-alert__message\"><ng-content /></div>\n</div>\n\n@if (dismissible()) {\n <button\n type=\"button\"\n class=\"mk-alert__close\"\n [attr.aria-label]=\"i18n.dismiss\"\n (click)=\"onDismiss()\"\n >\n <svg viewBox=\"0 0 16 16\" width=\"16\" height=\"16\" aria-hidden=\"true\" focusable=\"false\">\n <path\n d=\"M4 4l8 8M12 4l-8 8\"\n fill=\"none\"\n stroke=\"currentColor\"\n stroke-width=\"1.5\"\n stroke-linecap=\"round\"\n />\n </svg>\n </button>\n}\n", styles: ["@charset \"UTF-8\";:host{--_main: var(--mk-info);--_hover: var(--mk-info-hover);--_contrast: var(--mk-info-contrast);--_subtle: var(--mk-info-subtle);--_subtle-text: var(--mk-info-subtle-text);display:flex;align-items:flex-start;gap:var(--mk-space-3);padding:var(--mk-space-3) var(--mk-space-4);border:var(--mk-border-width) solid transparent;border-radius:var(--mk-radius-md);font-size:var(--mk-font-size-sm);line-height:var(--mk-line-height-normal);color:var(--mk-text)}:host([data-tone=info]){--_main: var(--mk-info);--_hover: var(--mk-info-hover);--_contrast: var(--mk-info-contrast);--_subtle: var(--mk-info-subtle);--_subtle-text: var(--mk-info-subtle-text)}:host([data-tone=success]){--_main: var(--mk-success);--_hover: var(--mk-success-hover);--_contrast: var(--mk-success-contrast);--_subtle: var(--mk-success-subtle);--_subtle-text: var(--mk-success-subtle-text)}:host([data-tone=warning]){--_main: var(--mk-warning);--_hover: var(--mk-warning-hover);--_contrast: var(--mk-warning-contrast);--_subtle: var(--mk-warning-subtle);--_subtle-text: var(--mk-warning-subtle-text)}:host([data-tone=danger]){--_main: var(--mk-danger);--_hover: var(--mk-danger-hover);--_contrast: var(--mk-danger-contrast);--_subtle: var(--mk-danger-subtle);--_subtle-text: var(--mk-danger-subtle-text)}:host([data-tone=neutral]){--_main: var(--mk-surface-inverse);--_hover: var(--mk-surface-inverse);--_contrast: var(--mk-text-inverse);--_subtle: var(--mk-neutral-subtle);--_subtle-text: var(--mk-neutral-subtle-text)}:host(.mk-alert--soft){background-color:var(--_subtle);border-color:color-mix(in srgb,var(--_main) 22%,transparent);color:var(--_subtle-text)}:host(.mk-alert--outline){background-color:var(--mk-surface);border-color:color-mix(in srgb,var(--_main) 42%,var(--mk-border-strong));color:var(--mk-text)}:host(.mk-alert--solid){background-color:var(--_main);border-color:var(--_main);color:var(--_contrast)}.mk-alert__icon{display:inline-flex;align-items:center;justify-content:center;flex-shrink:0;width:1.25rem;height:1.25rem;margin-top:1px;color:var(--_main)}:host(.mk-alert--solid) .mk-alert__icon{color:currentColor}.mk-alert__icon:empty{display:none}.mk-alert__content{flex:1;min-width:0;display:flex;flex-direction:column;gap:var(--mk-space-1)}.mk-alert__title{margin:0;font-weight:var(--mk-font-weight-semibold);font-size:var(--mk-font-size-sm);line-height:var(--mk-line-height-snug)}.mk-alert__message{margin:0}.mk-alert__close{flex-shrink:0;display:inline-flex;align-items:center;justify-content:center;width:1.5rem;height:1.5rem;margin:-2px calc(var(--mk-space-1) * -1) 0 0;padding:0;background:transparent;border:0;border-radius:var(--mk-radius-sm);color:inherit;cursor:pointer;opacity:.7;transition:opacity var(--mk-duration-fast) var(--mk-ease-standard),background-color var(--mk-duration-fast) var(--mk-ease-standard)}.mk-alert__close:hover{opacity:1;background-image:linear-gradient(var(--mk-hover-overlay),var(--mk-hover-overlay))}.mk-alert__close:focus-visible{opacity:1;outline:var(--mk-focus-ring-width) solid var(--mk-focus-ring);outline-offset:var(--mk-focus-ring-offset)}\n"], changeDetection: i0.ChangeDetectionStrategy.OnPush });
|
|
49
|
+
}
|
|
50
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.7", ngImport: i0, type: MkAlert, decorators: [{
|
|
51
|
+
type: Component,
|
|
52
|
+
args: [{ selector: 'mk-alert', changeDetection: ChangeDetectionStrategy.OnPush, host: {
|
|
53
|
+
class: 'mk-alert',
|
|
54
|
+
'[attr.data-tone]': 'tone()',
|
|
55
|
+
'[class.mk-alert--soft]': "variant() === 'soft'",
|
|
56
|
+
'[class.mk-alert--solid]': "variant() === 'solid'",
|
|
57
|
+
'[class.mk-alert--outline]': "variant() === 'outline'",
|
|
58
|
+
'[attr.role]': 'role()',
|
|
59
|
+
}, template: "<span class=\"mk-alert__icon\" aria-hidden=\"true\">\n <ng-content select=\"[mkAlertIcon]\" />\n</span>\n\n<div class=\"mk-alert__content\">\n @if (title(); as heading) {\n <p class=\"mk-alert__title\">{{ heading }}</p>\n }\n <div class=\"mk-alert__message\"><ng-content /></div>\n</div>\n\n@if (dismissible()) {\n <button\n type=\"button\"\n class=\"mk-alert__close\"\n [attr.aria-label]=\"i18n.dismiss\"\n (click)=\"onDismiss()\"\n >\n <svg viewBox=\"0 0 16 16\" width=\"16\" height=\"16\" aria-hidden=\"true\" focusable=\"false\">\n <path\n d=\"M4 4l8 8M12 4l-8 8\"\n fill=\"none\"\n stroke=\"currentColor\"\n stroke-width=\"1.5\"\n stroke-linecap=\"round\"\n />\n </svg>\n </button>\n}\n", styles: ["@charset \"UTF-8\";:host{--_main: var(--mk-info);--_hover: var(--mk-info-hover);--_contrast: var(--mk-info-contrast);--_subtle: var(--mk-info-subtle);--_subtle-text: var(--mk-info-subtle-text);display:flex;align-items:flex-start;gap:var(--mk-space-3);padding:var(--mk-space-3) var(--mk-space-4);border:var(--mk-border-width) solid transparent;border-radius:var(--mk-radius-md);font-size:var(--mk-font-size-sm);line-height:var(--mk-line-height-normal);color:var(--mk-text)}:host([data-tone=info]){--_main: var(--mk-info);--_hover: var(--mk-info-hover);--_contrast: var(--mk-info-contrast);--_subtle: var(--mk-info-subtle);--_subtle-text: var(--mk-info-subtle-text)}:host([data-tone=success]){--_main: var(--mk-success);--_hover: var(--mk-success-hover);--_contrast: var(--mk-success-contrast);--_subtle: var(--mk-success-subtle);--_subtle-text: var(--mk-success-subtle-text)}:host([data-tone=warning]){--_main: var(--mk-warning);--_hover: var(--mk-warning-hover);--_contrast: var(--mk-warning-contrast);--_subtle: var(--mk-warning-subtle);--_subtle-text: var(--mk-warning-subtle-text)}:host([data-tone=danger]){--_main: var(--mk-danger);--_hover: var(--mk-danger-hover);--_contrast: var(--mk-danger-contrast);--_subtle: var(--mk-danger-subtle);--_subtle-text: var(--mk-danger-subtle-text)}:host([data-tone=neutral]){--_main: var(--mk-surface-inverse);--_hover: var(--mk-surface-inverse);--_contrast: var(--mk-text-inverse);--_subtle: var(--mk-neutral-subtle);--_subtle-text: var(--mk-neutral-subtle-text)}:host(.mk-alert--soft){background-color:var(--_subtle);border-color:color-mix(in srgb,var(--_main) 22%,transparent);color:var(--_subtle-text)}:host(.mk-alert--outline){background-color:var(--mk-surface);border-color:color-mix(in srgb,var(--_main) 42%,var(--mk-border-strong));color:var(--mk-text)}:host(.mk-alert--solid){background-color:var(--_main);border-color:var(--_main);color:var(--_contrast)}.mk-alert__icon{display:inline-flex;align-items:center;justify-content:center;flex-shrink:0;width:1.25rem;height:1.25rem;margin-top:1px;color:var(--_main)}:host(.mk-alert--solid) .mk-alert__icon{color:currentColor}.mk-alert__icon:empty{display:none}.mk-alert__content{flex:1;min-width:0;display:flex;flex-direction:column;gap:var(--mk-space-1)}.mk-alert__title{margin:0;font-weight:var(--mk-font-weight-semibold);font-size:var(--mk-font-size-sm);line-height:var(--mk-line-height-snug)}.mk-alert__message{margin:0}.mk-alert__close{flex-shrink:0;display:inline-flex;align-items:center;justify-content:center;width:1.5rem;height:1.5rem;margin:-2px calc(var(--mk-space-1) * -1) 0 0;padding:0;background:transparent;border:0;border-radius:var(--mk-radius-sm);color:inherit;cursor:pointer;opacity:.7;transition:opacity var(--mk-duration-fast) var(--mk-ease-standard),background-color var(--mk-duration-fast) var(--mk-ease-standard)}.mk-alert__close:hover{opacity:1;background-image:linear-gradient(var(--mk-hover-overlay),var(--mk-hover-overlay))}.mk-alert__close:focus-visible{opacity:1;outline:var(--mk-focus-ring-width) solid var(--mk-focus-ring);outline-offset:var(--mk-focus-ring-offset)}\n"] }]
|
|
60
|
+
}], propDecorators: { tone: [{ type: i0.Input, args: [{ isSignal: true, alias: "tone", required: false }] }], variant: [{ type: i0.Input, args: [{ isSignal: true, alias: "variant", required: false }] }], title: [{ type: i0.Input, args: [{ isSignal: true, alias: "title", required: false }] }], dismissible: [{ type: i0.Input, args: [{ isSignal: true, alias: "dismissible", required: false }] }], dismissed: [{ type: i0.Output, args: ["dismissed"] }] } });
|
|
61
|
+
|
|
62
|
+
/** Delay (ms) before a pointer-triggered tooltip opens. Focus opens instantly. */
|
|
63
|
+
const OPEN_DELAY = 400;
|
|
64
|
+
/**
|
|
65
|
+
* Grace period (ms) after the pointer leaves the trigger (or the panel) before
|
|
66
|
+
* the tooltip hides. Long enough to cross the 8px gap onto the panel — WCAG
|
|
67
|
+
* 1.4.13 "hoverable" — without making the tip feel sticky.
|
|
68
|
+
*/
|
|
69
|
+
const CLOSE_DELAY = 150;
|
|
70
|
+
/**
|
|
71
|
+
* Body-level presentation surface for {@link MkTooltip}. Rendered imperatively
|
|
72
|
+
* by the directive; not intended for direct template use.
|
|
73
|
+
*/
|
|
74
|
+
class MkTooltipPanel {
|
|
75
|
+
/** Text content shown inside the tooltip. */
|
|
76
|
+
text = input('', /* @ts-ignore */
|
|
77
|
+
...(ngDevMode ? [{ debugName: "text" }] : /* istanbul ignore next */ []));
|
|
78
|
+
/** DOM id used to wire `aria-describedby` on the trigger. */
|
|
79
|
+
id = input(/* @ts-ignore */
|
|
80
|
+
...(ngDevMode ? [undefined, { debugName: "id" }] : /* istanbul ignore next */ []));
|
|
81
|
+
/** Resolved placement relative to the trigger. */
|
|
82
|
+
placement = input('top', /* @ts-ignore */
|
|
83
|
+
...(ngDevMode ? [{ debugName: "placement" }] : /* istanbul ignore next */ []));
|
|
84
|
+
/** Drives the entrance/exit transition. */
|
|
85
|
+
visible = input(false, { ...(ngDevMode ? { debugName: "visible" } : /* istanbul ignore next */ {}), transform: booleanAttribute });
|
|
86
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.7", ngImport: i0, type: MkTooltipPanel, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
87
|
+
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.1.0", version: "22.0.7", type: MkTooltipPanel, isStandalone: true, selector: "mk-tooltip", inputs: { text: { classPropertyName: "text", publicName: "text", isSignal: true, isRequired: false, transformFunction: null }, id: { classPropertyName: "id", publicName: "id", isSignal: true, isRequired: false, transformFunction: null }, placement: { classPropertyName: "placement", publicName: "placement", isSignal: true, isRequired: false, transformFunction: null }, visible: { classPropertyName: "visible", publicName: "visible", isSignal: true, isRequired: false, transformFunction: null } }, host: { attributes: { "role": "tooltip" }, properties: { "attr.id": "id()", "attr.data-placement": "placement()", "class.mk-tooltip--visible": "visible()" }, classAttribute: "mk-tooltip" }, ngImport: i0, template: "{{ text() }}\n", styles: [":host{position:fixed;z-index:var(--mk-z-tooltip);max-width:16rem;padding:var(--mk-space-1) var(--mk-space-2);background-color:var(--mk-surface-inverse);color:var(--mk-text-inverse);font-family:var(--mk-font-sans);font-size:var(--mk-font-size-xs);font-weight:var(--mk-font-weight-medium);line-height:var(--mk-line-height-snug);text-align:center;border-radius:var(--mk-radius-sm);box-shadow:var(--mk-shadow-md);pointer-events:none;white-space:normal;overflow-wrap:break-word;opacity:0;transform:scale(.96);transition:opacity var(--mk-duration-fast) var(--mk-ease-out),transform var(--mk-duration-fast) var(--mk-ease-out)}:host(.mk-tooltip--visible){opacity:1;transform:scale(1)}@media(prefers-reduced-motion:reduce){:host{transition:none;transform:none}}\n"], changeDetection: i0.ChangeDetectionStrategy.OnPush });
|
|
88
|
+
}
|
|
89
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.7", ngImport: i0, type: MkTooltipPanel, decorators: [{
|
|
90
|
+
type: Component,
|
|
91
|
+
args: [{ selector: 'mk-tooltip', changeDetection: ChangeDetectionStrategy.OnPush, host: {
|
|
92
|
+
class: 'mk-tooltip',
|
|
93
|
+
role: 'tooltip',
|
|
94
|
+
'[attr.id]': 'id()',
|
|
95
|
+
'[attr.data-placement]': 'placement()',
|
|
96
|
+
'[class.mk-tooltip--visible]': 'visible()',
|
|
97
|
+
}, template: "{{ text() }}\n", styles: [":host{position:fixed;z-index:var(--mk-z-tooltip);max-width:16rem;padding:var(--mk-space-1) var(--mk-space-2);background-color:var(--mk-surface-inverse);color:var(--mk-text-inverse);font-family:var(--mk-font-sans);font-size:var(--mk-font-size-xs);font-weight:var(--mk-font-weight-medium);line-height:var(--mk-line-height-snug);text-align:center;border-radius:var(--mk-radius-sm);box-shadow:var(--mk-shadow-md);pointer-events:none;white-space:normal;overflow-wrap:break-word;opacity:0;transform:scale(.96);transition:opacity var(--mk-duration-fast) var(--mk-ease-out),transform var(--mk-duration-fast) var(--mk-ease-out)}:host(.mk-tooltip--visible){opacity:1;transform:scale(1)}@media(prefers-reduced-motion:reduce){:host{transition:none;transform:none}}\n"] }]
|
|
98
|
+
}], propDecorators: { text: [{ type: i0.Input, args: [{ isSignal: true, alias: "text", required: false }] }], id: [{ type: i0.Input, args: [{ isSignal: true, alias: "id", required: false }] }], placement: [{ type: i0.Input, args: [{ isSignal: true, alias: "placement", required: false }] }], visible: [{ type: i0.Input, args: [{ isSignal: true, alias: "visible", required: false }] }] } });
|
|
99
|
+
/**
|
|
100
|
+
* Tooltip directive — shows a themed tooltip on hover AND keyboard focus, and
|
|
101
|
+
* hides on blur, mouse-leave (after a short grace period — the pointer may
|
|
102
|
+
* move onto the tooltip itself, which keeps it open, per WCAG 1.4.13), or
|
|
103
|
+
* Escape pressed anywhere while it is visible. The tooltip is appended to
|
|
104
|
+
* `document.body`, positioned relative to the host, and wired to the host via
|
|
105
|
+
* `aria-describedby` for screen-reader users. Respects `prefers-reduced-motion`.
|
|
106
|
+
*
|
|
107
|
+
* ```html
|
|
108
|
+
* <button mkButton [mkTooltip]="'Delete row'" mkTooltipPlacement="bottom">
|
|
109
|
+
* <svg>…</svg>
|
|
110
|
+
* </button>
|
|
111
|
+
* ```
|
|
112
|
+
*/
|
|
113
|
+
class MkTooltip {
|
|
114
|
+
host = inject(ElementRef);
|
|
115
|
+
appRef = inject(ApplicationRef);
|
|
116
|
+
envInjector = inject(EnvironmentInjector);
|
|
117
|
+
document = inject(DOCUMENT);
|
|
118
|
+
isBrowser = isPlatformBrowser(inject(PLATFORM_ID));
|
|
119
|
+
/** Set by pointerdown so the focusin it causes doesn't re-open the tip. */
|
|
120
|
+
suppressFocusShow = false;
|
|
121
|
+
/** Tooltip text. When empty the tooltip is suppressed. */
|
|
122
|
+
mkTooltip = input('', /* @ts-ignore */
|
|
123
|
+
...(ngDevMode ? [{ debugName: "mkTooltip" }] : /* istanbul ignore next */ []));
|
|
124
|
+
/** Preferred placement relative to the host. */
|
|
125
|
+
mkTooltipPlacement = input('top', /* @ts-ignore */
|
|
126
|
+
...(ngDevMode ? [{ debugName: "mkTooltipPlacement" }] : /* istanbul ignore next */ []));
|
|
127
|
+
tooltipId = mkUniqueId('mk-tooltip');
|
|
128
|
+
ref;
|
|
129
|
+
openTimer;
|
|
130
|
+
hideTimer;
|
|
131
|
+
previousDescribedBy = null;
|
|
132
|
+
/**
|
|
133
|
+
* Hovering the tooltip itself keeps it open (WCAG 1.4.13 "hoverable"): the
|
|
134
|
+
* pointer may cross the gap between trigger and panel, so leaving either one
|
|
135
|
+
* only SCHEDULES a hide, and entering either one cancels it.
|
|
136
|
+
*/
|
|
137
|
+
onPanelEnter = () => {
|
|
138
|
+
clearTimeout(this.hideTimer);
|
|
139
|
+
};
|
|
140
|
+
onPanelLeave = () => {
|
|
141
|
+
this.scheduleHide();
|
|
142
|
+
};
|
|
143
|
+
/**
|
|
144
|
+
* WCAG 1.4.13 "dismissible": Escape hides a visible tooltip no matter where
|
|
145
|
+
* focus is. Attached to the document only while the tooltip is shown, and
|
|
146
|
+
* preventDefault only fires when a tooltip was actually hidden.
|
|
147
|
+
*/
|
|
148
|
+
onDocumentKeydown = (event) => {
|
|
149
|
+
if (event.key !== 'Escape' || !this.ref)
|
|
150
|
+
return;
|
|
151
|
+
event.preventDefault();
|
|
152
|
+
this.hide();
|
|
153
|
+
};
|
|
154
|
+
onPointerEnter() {
|
|
155
|
+
// Re-entering the trigger during the hide grace period keeps the tip up.
|
|
156
|
+
clearTimeout(this.hideTimer);
|
|
157
|
+
this.scheduleShow(OPEN_DELAY);
|
|
158
|
+
}
|
|
159
|
+
onPointerLeave() {
|
|
160
|
+
clearTimeout(this.openTimer);
|
|
161
|
+
this.scheduleHide();
|
|
162
|
+
}
|
|
163
|
+
scheduleHide() {
|
|
164
|
+
if (!this.ref)
|
|
165
|
+
return;
|
|
166
|
+
clearTimeout(this.hideTimer);
|
|
167
|
+
this.hideTimer = setTimeout(() => this.hide(), CLOSE_DELAY);
|
|
168
|
+
}
|
|
169
|
+
onFocus() {
|
|
170
|
+
// Pointer-driven focus already dismissed the tip on pointerdown; showing
|
|
171
|
+
// it again here would defeat that. Keyboard focus still gets a tooltip.
|
|
172
|
+
if (this.suppressFocusShow) {
|
|
173
|
+
this.suppressFocusShow = false;
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
this.scheduleShow(0);
|
|
177
|
+
}
|
|
178
|
+
onPointerDown(event) {
|
|
179
|
+
this.suppressFocusShow = true;
|
|
180
|
+
// Touch toggle: hover never happens on a touchscreen, so a tap on the
|
|
181
|
+
// trigger is the show gesture. Tap again (ref exists → fall through to
|
|
182
|
+
// hide) or tap elsewhere (document pointerdown) dismisses. Mouse and pen
|
|
183
|
+
// keep the plain dismiss-on-pointerdown behaviour.
|
|
184
|
+
if (event.pointerType === 'touch' && !this.ref) {
|
|
185
|
+
this.scheduleShow(0);
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
this.hide();
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* Outside-tap dismissal (attached to the document only while visible): a
|
|
192
|
+
* pointerdown that lands on neither the trigger nor the panel hides the
|
|
193
|
+
* tooltip. The trigger's own pointerdown handles show/hide/toggle itself,
|
|
194
|
+
* and a press on the panel keeps it up (WCAG 1.4.13 "hoverable").
|
|
195
|
+
*/
|
|
196
|
+
onDocumentPointerdown = (event) => {
|
|
197
|
+
const target = event.target;
|
|
198
|
+
if (!target || this.host.nativeElement.contains(target))
|
|
199
|
+
return;
|
|
200
|
+
const panel = this.ref?.location.nativeElement;
|
|
201
|
+
if (panel?.contains(target))
|
|
202
|
+
return;
|
|
203
|
+
this.hide();
|
|
204
|
+
};
|
|
205
|
+
scheduleShow(delay) {
|
|
206
|
+
if (!this.isBrowser || !this.mkTooltip().trim() || this.ref)
|
|
207
|
+
return;
|
|
208
|
+
clearTimeout(this.openTimer);
|
|
209
|
+
if (delay === 0) {
|
|
210
|
+
this.show();
|
|
211
|
+
}
|
|
212
|
+
else {
|
|
213
|
+
this.openTimer = setTimeout(() => this.show(), delay);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
hide() {
|
|
217
|
+
clearTimeout(this.openTimer);
|
|
218
|
+
clearTimeout(this.hideTimer);
|
|
219
|
+
if (!this.ref)
|
|
220
|
+
return;
|
|
221
|
+
this.document.removeEventListener('keydown', this.onDocumentKeydown, true);
|
|
222
|
+
this.document.removeEventListener('pointerdown', this.onDocumentPointerdown, true);
|
|
223
|
+
// Restore the trigger's original aria-describedby.
|
|
224
|
+
const el = this.host.nativeElement;
|
|
225
|
+
if (this.previousDescribedBy === null) {
|
|
226
|
+
el.removeAttribute('aria-describedby');
|
|
227
|
+
}
|
|
228
|
+
else {
|
|
229
|
+
el.setAttribute('aria-describedby', this.previousDescribedBy);
|
|
230
|
+
}
|
|
231
|
+
this.previousDescribedBy = null;
|
|
232
|
+
const panel = this.ref.location.nativeElement;
|
|
233
|
+
if (panel.matches('[popover]') && typeof panel.hidePopover === 'function') {
|
|
234
|
+
try {
|
|
235
|
+
panel.hidePopover();
|
|
236
|
+
}
|
|
237
|
+
catch {
|
|
238
|
+
// Already disconnected — nothing to do.
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
this.appRef.detachView(this.ref.hostView);
|
|
242
|
+
this.ref.destroy();
|
|
243
|
+
this.ref = undefined;
|
|
244
|
+
}
|
|
245
|
+
show() {
|
|
246
|
+
if (this.ref)
|
|
247
|
+
return;
|
|
248
|
+
const ref = createComponent(MkTooltipPanel, {
|
|
249
|
+
environmentInjector: this.envInjector,
|
|
250
|
+
});
|
|
251
|
+
ref.setInput('text', this.mkTooltip());
|
|
252
|
+
ref.setInput('id', this.tooltipId);
|
|
253
|
+
ref.setInput('placement', this.mkTooltipPlacement());
|
|
254
|
+
this.appRef.attachView(ref.hostView);
|
|
255
|
+
const panel = ref.location.nativeElement;
|
|
256
|
+
this.document.body.appendChild(panel);
|
|
257
|
+
this.ref = ref;
|
|
258
|
+
// Hoverable (the listeners die with the panel element on hide) …
|
|
259
|
+
panel.addEventListener('mouseenter', this.onPanelEnter);
|
|
260
|
+
panel.addEventListener('mouseleave', this.onPanelLeave);
|
|
261
|
+
// … and dismissible from anywhere while visible (removed in hide()).
|
|
262
|
+
// Capture phase so the Escape that hides the tooltip is marked
|
|
263
|
+
// defaultPrevented before any bubbling handler (e.g. a dialog deciding
|
|
264
|
+
// whether to close) sees it — layered dismissal, tooltip first.
|
|
265
|
+
this.document.addEventListener('keydown', this.onDocumentKeydown, true);
|
|
266
|
+
// … and from an outside tap — the touch analogue of mouseleave. Capture
|
|
267
|
+
// phase so the tooltip is gone before the tap's own target reacts.
|
|
268
|
+
this.document.addEventListener('pointerdown', this.onDocumentPointerdown, true);
|
|
269
|
+
// Promote into the top layer so the tooltip is never clipped by an
|
|
270
|
+
// ancestor's overflow/transform or hidden behind a stacking context.
|
|
271
|
+
const withPopover = panel;
|
|
272
|
+
if (typeof withPopover.showPopover === 'function') {
|
|
273
|
+
panel.setAttribute('popover', 'manual');
|
|
274
|
+
try {
|
|
275
|
+
withPopover.showPopover();
|
|
276
|
+
}
|
|
277
|
+
catch {
|
|
278
|
+
// Unsupported context — fall back to the plain body portal.
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
// Wire aria-describedby, preserving any pre-existing value.
|
|
282
|
+
const trigger = this.host.nativeElement;
|
|
283
|
+
this.previousDescribedBy = trigger.getAttribute('aria-describedby');
|
|
284
|
+
trigger.setAttribute('aria-describedby', this.previousDescribedBy
|
|
285
|
+
? `${this.previousDescribedBy} ${this.tooltipId}`
|
|
286
|
+
: this.tooltipId);
|
|
287
|
+
this.position(panel);
|
|
288
|
+
// Reveal on the next frame so the entrance transition runs.
|
|
289
|
+
this.document.defaultView?.requestAnimationFrame(() => {
|
|
290
|
+
this.ref?.setInput('visible', true);
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
position(panel) {
|
|
294
|
+
const rect = this.host.nativeElement.getBoundingClientRect();
|
|
295
|
+
const tip = panel.getBoundingClientRect();
|
|
296
|
+
const pos = mkComputeAnchoredPosition(rect, { width: tip.width, height: tip.height }, {
|
|
297
|
+
width: this.document.documentElement.clientWidth,
|
|
298
|
+
height: this.document.documentElement.clientHeight,
|
|
299
|
+
},
|
|
300
|
+
// Clamp back on-screen but keep the requested side (no flip) so the
|
|
301
|
+
// panel's `data-placement` styling stays correct.
|
|
302
|
+
{
|
|
303
|
+
placement: this.mkTooltipPlacement(),
|
|
304
|
+
gap: 8,
|
|
305
|
+
flip: false,
|
|
306
|
+
clamp: true,
|
|
307
|
+
rtl: this.document.defaultView?.getComputedStyle(this.host.nativeElement)
|
|
308
|
+
.direction === 'rtl',
|
|
309
|
+
});
|
|
310
|
+
panel.style.position = 'fixed';
|
|
311
|
+
panel.style.margin = '0';
|
|
312
|
+
panel.style.inset = 'auto';
|
|
313
|
+
panel.style.top = `${pos.top}px`;
|
|
314
|
+
panel.style.left = `${pos.left}px`;
|
|
315
|
+
}
|
|
316
|
+
ngOnDestroy() {
|
|
317
|
+
this.hide();
|
|
318
|
+
}
|
|
319
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.7", ngImport: i0, type: MkTooltip, deps: [], target: i0.ɵɵFactoryTarget.Directive });
|
|
320
|
+
static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "22.0.7", type: MkTooltip, isStandalone: true, selector: "[mkTooltip]", inputs: { mkTooltip: { classPropertyName: "mkTooltip", publicName: "mkTooltip", isSignal: true, isRequired: false, transformFunction: null }, mkTooltipPlacement: { classPropertyName: "mkTooltipPlacement", publicName: "mkTooltipPlacement", isSignal: true, isRequired: false, transformFunction: null } }, host: { listeners: { "mouseenter": "onPointerEnter()", "mouseleave": "onPointerLeave()", "focusin": "onFocus()", "focusout": "hide()", "keydown.escape": "hide()", "pointerdown": "onPointerDown($event)" } }, ngImport: i0 });
|
|
321
|
+
}
|
|
322
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.7", ngImport: i0, type: MkTooltip, decorators: [{
|
|
323
|
+
type: Directive,
|
|
324
|
+
args: [{
|
|
325
|
+
selector: '[mkTooltip]',
|
|
326
|
+
host: {
|
|
327
|
+
'(mouseenter)': 'onPointerEnter()',
|
|
328
|
+
'(mouseleave)': 'onPointerLeave()',
|
|
329
|
+
'(focusin)': 'onFocus()',
|
|
330
|
+
'(focusout)': 'hide()',
|
|
331
|
+
'(keydown.escape)': 'hide()',
|
|
332
|
+
// A tooltip must not outlive the interaction that spawned it. Without
|
|
333
|
+
// this, tapping a tooltipped control leaves the tip on screen — and since
|
|
334
|
+
// tooltips deliberately sit ABOVE dialogs (so a tip inside a modal is
|
|
335
|
+
// visible), a stale one floats over whatever the tap just opened.
|
|
336
|
+
// Exception: a TOUCH tap on the trigger of a hidden tooltip SHOWS it —
|
|
337
|
+
// touch has no hover, so this is the only way a finger reaches the tip.
|
|
338
|
+
'(pointerdown)': 'onPointerDown($event)',
|
|
339
|
+
},
|
|
340
|
+
}]
|
|
341
|
+
}], propDecorators: { mkTooltip: [{ type: i0.Input, args: [{ isSignal: true, alias: "mkTooltip", required: false }] }], mkTooltipPlacement: [{ type: i0.Input, args: [{ isSignal: true, alias: "mkTooltipPlacement", required: false }] }] } });
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* Popover — a non-modal floating panel for rich (non-text) content, anchored to
|
|
345
|
+
* a trigger. Unlike {@link MkTooltip} (text, hover) it holds arbitrary projected
|
|
346
|
+
* content and is opened by click via the `mkPopoverTriggerFor` directive (or the
|
|
347
|
+
* `open`/`toggle` API).
|
|
348
|
+
*
|
|
349
|
+
* The panel renders in the browser top layer through {@link MkAnchoredPanel}, so
|
|
350
|
+
* it is never clipped by an ancestor's `overflow`/`transform`. On open, focus
|
|
351
|
+
* moves into the panel; Escape closes and restores focus to the trigger, an
|
|
352
|
+
* outside click or Tabbing out closes it (non-modal — no keyboard trap).
|
|
353
|
+
*
|
|
354
|
+
* ```html
|
|
355
|
+
* <button mkButton [mkPopoverTriggerFor]="info">Details</button>
|
|
356
|
+
* <mk-popover #info ariaLabel="Details">
|
|
357
|
+
* <h4>Shipping</h4><p>Ships in 2–3 business days.</p>
|
|
358
|
+
* </mk-popover>
|
|
359
|
+
* ```
|
|
360
|
+
*/
|
|
361
|
+
class MkPopover {
|
|
362
|
+
injector = inject(Injector);
|
|
363
|
+
isBrowser = isPlatformBrowser(inject(PLATFORM_ID));
|
|
364
|
+
panelRef = viewChild('panel', /* @ts-ignore */
|
|
365
|
+
...(ngDevMode ? [{ debugName: "panelRef" }] : /* istanbul ignore next */ []));
|
|
366
|
+
/** Stable id for `aria-controls` on the trigger. */
|
|
367
|
+
panelId = mkUniqueId('mk-popover');
|
|
368
|
+
/** Preferred placement relative to the trigger. */
|
|
369
|
+
placement = input('bottom', /* @ts-ignore */
|
|
370
|
+
...(ngDevMode ? [{ debugName: "placement" }] : /* istanbul ignore next */ []));
|
|
371
|
+
/** Accessible label for the dialog panel. */
|
|
372
|
+
ariaLabel = input(/* @ts-ignore */
|
|
373
|
+
...(ngDevMode ? [undefined, { debugName: "ariaLabel" }] : /* istanbul ignore next */ []));
|
|
374
|
+
/** Move focus into the panel on open. Default `true`. */
|
|
375
|
+
autoFocus = input(true, { ...(ngDevMode ? { debugName: "autoFocus" } : /* istanbul ignore next */ {}), transform: booleanAttribute });
|
|
376
|
+
/** Emitted whenever the popover closes (any reason). */
|
|
377
|
+
closed = output();
|
|
378
|
+
_open = signal(false, /* @ts-ignore */
|
|
379
|
+
...(ngDevMode ? [{ debugName: "_open" }] : /* istanbul ignore next */ []));
|
|
380
|
+
/** Whether the popover is open. */
|
|
381
|
+
opened = this._open.asReadonly();
|
|
382
|
+
anchorEl = signal(undefined, /* @ts-ignore */
|
|
383
|
+
...(ngDevMode ? [{ debugName: "anchorEl" }] : /* istanbul ignore next */ []));
|
|
384
|
+
triggerEl = null;
|
|
385
|
+
/** Open the popover anchored to `trigger`. */
|
|
386
|
+
open(trigger) {
|
|
387
|
+
if (!this.isBrowser || this._open())
|
|
388
|
+
return;
|
|
389
|
+
this.triggerEl = trigger;
|
|
390
|
+
this.anchorEl.set(trigger);
|
|
391
|
+
this._open.set(true);
|
|
392
|
+
if (this.autoFocus()) {
|
|
393
|
+
afterNextRender(() => this.focusInitial(), { injector: this.injector });
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
/** Close the popover; optionally restore focus to the trigger. */
|
|
397
|
+
close(restoreFocus = true) {
|
|
398
|
+
if (!this._open())
|
|
399
|
+
return;
|
|
400
|
+
this._open.set(false);
|
|
401
|
+
this.anchorEl.set(undefined);
|
|
402
|
+
const trigger = this.triggerEl;
|
|
403
|
+
this.triggerEl = null;
|
|
404
|
+
if (restoreFocus)
|
|
405
|
+
trigger?.focus();
|
|
406
|
+
this.closed.emit();
|
|
407
|
+
}
|
|
408
|
+
/** Toggle open/closed from a trigger. */
|
|
409
|
+
toggle(trigger) {
|
|
410
|
+
this._open() ? this.close(true) : this.open(trigger);
|
|
411
|
+
}
|
|
412
|
+
focusInitial() {
|
|
413
|
+
const panel = this.panelRef()?.nativeElement;
|
|
414
|
+
if (!panel)
|
|
415
|
+
return;
|
|
416
|
+
(mkGetFocusable(panel)[0] ?? panel).focus();
|
|
417
|
+
}
|
|
418
|
+
onEscape(event) {
|
|
419
|
+
event.preventDefault();
|
|
420
|
+
event.stopPropagation();
|
|
421
|
+
this.close(true);
|
|
422
|
+
}
|
|
423
|
+
onPanelFocusout(event) {
|
|
424
|
+
const related = event.relatedTarget;
|
|
425
|
+
// Focus moved to a non-focusable spot (stays in document.body) — keep open;
|
|
426
|
+
// outside pointerdown is handled separately by the anchored panel.
|
|
427
|
+
if (!related)
|
|
428
|
+
return;
|
|
429
|
+
const panel = this.panelRef()?.nativeElement;
|
|
430
|
+
if (panel?.contains(related) || this.triggerEl?.contains(related))
|
|
431
|
+
return;
|
|
432
|
+
// Tabbed out of the panel — close without yanking focus back.
|
|
433
|
+
this.close(false);
|
|
434
|
+
}
|
|
435
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.7", ngImport: i0, type: MkPopover, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
436
|
+
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.0.7", type: MkPopover, isStandalone: true, selector: "mk-popover", inputs: { placement: { classPropertyName: "placement", publicName: "placement", isSignal: true, isRequired: false, transformFunction: null }, ariaLabel: { classPropertyName: "ariaLabel", publicName: "ariaLabel", isSignal: true, isRequired: false, transformFunction: null }, autoFocus: { classPropertyName: "autoFocus", publicName: "autoFocus", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { closed: "closed" }, host: { classAttribute: "mk-popover" }, viewQueries: [{ propertyName: "panelRef", first: true, predicate: ["panel"], descendants: true, isSignal: true }], exportAs: ["mkPopover"], ngImport: i0, template: "@if (opened()) {\n <div\n #panel\n class=\"mk-popover__panel\"\n role=\"dialog\"\n tabindex=\"-1\"\n [id]=\"panelId\"\n [attr.aria-label]=\"ariaLabel()\"\n mkAnchoredPanel\n [mkAnchoredPanelFor]=\"anchorEl()\"\n [placement]=\"placement()\"\n [gap]=\"8\"\n (dismiss)=\"close(false)\"\n (keydown.escape)=\"onEscape($event)\"\n (focusout)=\"onPanelFocusout($event)\"\n >\n <ng-content />\n </div>\n}\n", styles: [":host{display:contents}.mk-popover__panel{min-width:12rem;max-width:min(22rem,100vw - var(--mk-space-4));padding:var(--mk-space-3) var(--mk-space-4);color:var(--mk-text);background:var(--mk-surface);border:var(--mk-border-width) solid var(--mk-border);border-radius:var(--mk-radius-lg);box-shadow:var(--mk-shadow-lg);font-family:var(--mk-font-sans);font-size:var(--mk-font-size-md);animation:mk-popover-in var(--mk-duration-fast) var(--mk-ease-out)}.mk-popover__panel:focus-visible{outline:none}@keyframes mk-popover-in{0%{opacity:0;transform:translateY(calc(-1 * var(--mk-space-1))) scale(.98)}to{opacity:1;transform:none}}@media(prefers-reduced-motion:reduce){.mk-popover__panel{animation:none}}\n"], dependencies: [{ kind: "directive", type: MkAnchoredPanel, selector: "[mkAnchoredPanel]", inputs: ["mkAnchoredPanelFor", "anchorRect", "placement", "gap", "matchWidth", "flip", "clamp"], outputs: ["dismiss"], exportAs: ["mkAnchoredPanel"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
|
|
437
|
+
}
|
|
438
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.7", ngImport: i0, type: MkPopover, decorators: [{
|
|
439
|
+
type: Component,
|
|
440
|
+
args: [{ selector: 'mk-popover', exportAs: 'mkPopover', changeDetection: ChangeDetectionStrategy.OnPush, imports: [MkAnchoredPanel], host: {
|
|
441
|
+
class: 'mk-popover',
|
|
442
|
+
}, template: "@if (opened()) {\n <div\n #panel\n class=\"mk-popover__panel\"\n role=\"dialog\"\n tabindex=\"-1\"\n [id]=\"panelId\"\n [attr.aria-label]=\"ariaLabel()\"\n mkAnchoredPanel\n [mkAnchoredPanelFor]=\"anchorEl()\"\n [placement]=\"placement()\"\n [gap]=\"8\"\n (dismiss)=\"close(false)\"\n (keydown.escape)=\"onEscape($event)\"\n (focusout)=\"onPanelFocusout($event)\"\n >\n <ng-content />\n </div>\n}\n", styles: [":host{display:contents}.mk-popover__panel{min-width:12rem;max-width:min(22rem,100vw - var(--mk-space-4));padding:var(--mk-space-3) var(--mk-space-4);color:var(--mk-text);background:var(--mk-surface);border:var(--mk-border-width) solid var(--mk-border);border-radius:var(--mk-radius-lg);box-shadow:var(--mk-shadow-lg);font-family:var(--mk-font-sans);font-size:var(--mk-font-size-md);animation:mk-popover-in var(--mk-duration-fast) var(--mk-ease-out)}.mk-popover__panel:focus-visible{outline:none}@keyframes mk-popover-in{0%{opacity:0;transform:translateY(calc(-1 * var(--mk-space-1))) scale(.98)}to{opacity:1;transform:none}}@media(prefers-reduced-motion:reduce){.mk-popover__panel{animation:none}}\n"] }]
|
|
443
|
+
}], propDecorators: { panelRef: [{ type: i0.ViewChild, args: ['panel', { isSignal: true }] }], placement: [{ type: i0.Input, args: [{ isSignal: true, alias: "placement", required: false }] }], ariaLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "ariaLabel", required: false }] }], autoFocus: [{ type: i0.Input, args: [{ isSignal: true, alias: "autoFocus", required: false }] }], closed: [{ type: i0.Output, args: ["closed"] }] } });
|
|
444
|
+
|
|
445
|
+
/**
|
|
446
|
+
* Turns its host element into a click trigger for an `<mk-popover>`. Wires
|
|
447
|
+
* `aria-haspopup="dialog"` / `aria-expanded` / `aria-controls`, toggles on click
|
|
448
|
+
* and closes on Escape.
|
|
449
|
+
*
|
|
450
|
+
* ```html
|
|
451
|
+
* <button mkButton [mkPopoverTriggerFor]="info">Details</button>
|
|
452
|
+
* <mk-popover #info>…</mk-popover>
|
|
453
|
+
* ```
|
|
454
|
+
*/
|
|
455
|
+
class MkPopoverTrigger {
|
|
456
|
+
host = inject(ElementRef);
|
|
457
|
+
/** The popover instance to control. */
|
|
458
|
+
popover = input.required({ ...(ngDevMode ? { debugName: "popover" } : /* istanbul ignore next */ {}), alias: 'mkPopoverTriggerFor' });
|
|
459
|
+
onClick(event) {
|
|
460
|
+
event.preventDefault();
|
|
461
|
+
this.popover().toggle(this.host.nativeElement);
|
|
462
|
+
}
|
|
463
|
+
onKeydown(event) {
|
|
464
|
+
if (event.key === 'Escape' && this.popover().opened()) {
|
|
465
|
+
event.preventDefault();
|
|
466
|
+
this.popover().close(true);
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.7", ngImport: i0, type: MkPopoverTrigger, deps: [], target: i0.ɵɵFactoryTarget.Directive });
|
|
470
|
+
static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "22.0.7", type: MkPopoverTrigger, isStandalone: true, selector: "[mkPopoverTriggerFor]", inputs: { popover: { classPropertyName: "popover", publicName: "mkPopoverTriggerFor", isSignal: true, isRequired: true, transformFunction: null } }, host: { attributes: { "aria-haspopup": "dialog" }, listeners: { "click": "onClick($event)", "keydown": "onKeydown($event)" }, properties: { "attr.aria-expanded": "popover().opened()", "attr.aria-controls": "popover().panelId" } }, exportAs: ["mkPopoverTrigger"], ngImport: i0 });
|
|
471
|
+
}
|
|
472
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.7", ngImport: i0, type: MkPopoverTrigger, decorators: [{
|
|
473
|
+
type: Directive,
|
|
474
|
+
args: [{
|
|
475
|
+
selector: '[mkPopoverTriggerFor]',
|
|
476
|
+
exportAs: 'mkPopoverTrigger',
|
|
477
|
+
host: {
|
|
478
|
+
'aria-haspopup': 'dialog',
|
|
479
|
+
'[attr.aria-expanded]': 'popover().opened()',
|
|
480
|
+
'[attr.aria-controls]': 'popover().panelId',
|
|
481
|
+
'(click)': 'onClick($event)',
|
|
482
|
+
'(keydown)': 'onKeydown($event)',
|
|
483
|
+
},
|
|
484
|
+
}]
|
|
485
|
+
}], propDecorators: { popover: [{ type: i0.Input, args: [{ isSignal: true, alias: "mkPopoverTriggerFor", required: true }] }] } });
|
|
486
|
+
|
|
487
|
+
/**
|
|
488
|
+
* Popconfirm — an inline confirmation popover (e.g. a row-level "Delete?").
|
|
489
|
+
* Composes {@link MkPopover} with a message and cancel/confirm buttons, so it
|
|
490
|
+
* inherits the top-layer positioning + focus behaviour. Attach it with the
|
|
491
|
+
* `mkPopconfirmFor` directive.
|
|
492
|
+
*
|
|
493
|
+
* ```html
|
|
494
|
+
* <button mkButton tone="danger" [mkPopconfirmFor]="del">Delete</button>
|
|
495
|
+
* <mk-popconfirm #del message="Delete this item?" (confirm)="remove()" />
|
|
496
|
+
* ```
|
|
497
|
+
*/
|
|
498
|
+
class MkPopconfirm {
|
|
499
|
+
popover = viewChild(MkPopover, /* @ts-ignore */
|
|
500
|
+
...(ngDevMode ? [{ debugName: "popover" }] : /* istanbul ignore next */ []));
|
|
501
|
+
i18n = inject(MK_I18N);
|
|
502
|
+
/** Confirmation message. */
|
|
503
|
+
message = input(this.i18n.confirmMessage, /* @ts-ignore */
|
|
504
|
+
...(ngDevMode ? [{ debugName: "message" }] : /* istanbul ignore next */ []));
|
|
505
|
+
/** Optional bold heading above the message. */
|
|
506
|
+
title = input(/* @ts-ignore */
|
|
507
|
+
...(ngDevMode ? [undefined, { debugName: "title" }] : /* istanbul ignore next */ []));
|
|
508
|
+
/** Confirm button label. */
|
|
509
|
+
confirmText = input(this.i18n.confirm, /* @ts-ignore */
|
|
510
|
+
...(ngDevMode ? [{ debugName: "confirmText" }] : /* istanbul ignore next */ []));
|
|
511
|
+
/** Cancel button label. */
|
|
512
|
+
cancelText = input(this.i18n.cancel, /* @ts-ignore */
|
|
513
|
+
...(ngDevMode ? [{ debugName: "cancelText" }] : /* istanbul ignore next */ []));
|
|
514
|
+
/** Tone of the confirm button (destructive actions use `danger`). */
|
|
515
|
+
tone = input('danger', /* @ts-ignore */
|
|
516
|
+
...(ngDevMode ? [{ debugName: "tone" }] : /* istanbul ignore next */ []));
|
|
517
|
+
/** Preferred placement relative to the trigger. */
|
|
518
|
+
placement = input('top', /* @ts-ignore */
|
|
519
|
+
...(ngDevMode ? [{ debugName: "placement" }] : /* istanbul ignore next */ []));
|
|
520
|
+
/** Emitted when the user confirms. */
|
|
521
|
+
confirm = output();
|
|
522
|
+
/** Emitted when the user cancels (button, Escape or outside click). */
|
|
523
|
+
cancel = output();
|
|
524
|
+
/** Id of the underlying dialog panel (for the trigger's `aria-controls`). */
|
|
525
|
+
get panelId() {
|
|
526
|
+
return this.popover()?.panelId ?? null;
|
|
527
|
+
}
|
|
528
|
+
/** Whether the popconfirm is open. */
|
|
529
|
+
opened() {
|
|
530
|
+
return this.popover()?.opened() ?? false;
|
|
531
|
+
}
|
|
532
|
+
/** Open anchored to `trigger`. */
|
|
533
|
+
open(trigger) {
|
|
534
|
+
this.popover()?.open(trigger);
|
|
535
|
+
}
|
|
536
|
+
/** Toggle from a trigger. */
|
|
537
|
+
toggle(trigger) {
|
|
538
|
+
this.popover()?.toggle(trigger);
|
|
539
|
+
}
|
|
540
|
+
/** Close the popconfirm. */
|
|
541
|
+
close() {
|
|
542
|
+
this.popover()?.close(true);
|
|
543
|
+
}
|
|
544
|
+
/** Set when the user confirms, so the close handler doesn't also cancel. */
|
|
545
|
+
confirmed = false;
|
|
546
|
+
onConfirm() {
|
|
547
|
+
this.confirmed = true;
|
|
548
|
+
this.confirm.emit();
|
|
549
|
+
this.popover()?.close(true);
|
|
550
|
+
}
|
|
551
|
+
onCancel() {
|
|
552
|
+
// Closing emits `closed`, which routes to `cancel` (see onPopoverClosed).
|
|
553
|
+
this.popover()?.close(true);
|
|
554
|
+
}
|
|
555
|
+
/** Any close that wasn't a confirm counts as a cancel (Escape / outside). */
|
|
556
|
+
onPopoverClosed() {
|
|
557
|
+
if (this.confirmed) {
|
|
558
|
+
this.confirmed = false;
|
|
559
|
+
return;
|
|
560
|
+
}
|
|
561
|
+
this.cancel.emit();
|
|
562
|
+
}
|
|
563
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.7", ngImport: i0, type: MkPopconfirm, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
564
|
+
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.0.7", type: MkPopconfirm, isStandalone: true, selector: "mk-popconfirm", inputs: { message: { classPropertyName: "message", publicName: "message", isSignal: true, isRequired: false, transformFunction: null }, title: { classPropertyName: "title", publicName: "title", isSignal: true, isRequired: false, transformFunction: null }, confirmText: { classPropertyName: "confirmText", publicName: "confirmText", isSignal: true, isRequired: false, transformFunction: null }, cancelText: { classPropertyName: "cancelText", publicName: "cancelText", isSignal: true, isRequired: false, transformFunction: null }, tone: { classPropertyName: "tone", publicName: "tone", isSignal: true, isRequired: false, transformFunction: null }, placement: { classPropertyName: "placement", publicName: "placement", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { confirm: "confirm", cancel: "cancel" }, host: { classAttribute: "mk-popconfirm" }, viewQueries: [{ propertyName: "popover", first: true, predicate: MkPopover, descendants: true, isSignal: true }], exportAs: ["mkPopconfirm"], ngImport: i0, template: "<mk-popover\n [placement]=\"placement()\"\n [ariaLabel]=\"title() || message()\"\n (closed)=\"onPopoverClosed()\"\n>\n <div class=\"mk-popconfirm__body\">\n @if (title()) {\n <p class=\"mk-popconfirm__title\">{{ title() }}</p>\n }\n <p class=\"mk-popconfirm__message\">{{ message() }}</p>\n <div class=\"mk-popconfirm__actions\">\n <button mkButton variant=\"ghost\" tone=\"neutral\" size=\"sm\" (click)=\"onCancel()\">\n {{ cancelText() }}\n </button>\n <button mkButton [tone]=\"tone()\" size=\"sm\" (click)=\"onConfirm()\">\n {{ confirmText() }}\n </button>\n </div>\n </div>\n</mk-popover>\n", styles: [":host{display:contents}.mk-popconfirm__body{display:flex;flex-direction:column;gap:var(--mk-space-2);min-width:12rem;max-width:18rem}.mk-popconfirm__title{margin:0;font-size:var(--mk-font-size-md);font-weight:var(--mk-font-weight-semibold);color:var(--mk-text)}.mk-popconfirm__message{margin:0;font-size:var(--mk-font-size-sm);color:var(--mk-text-muted);line-height:var(--mk-line-height-normal)}.mk-popconfirm__actions{display:flex;justify-content:flex-end;gap:var(--mk-space-2);margin-top:var(--mk-space-1)}\n"], dependencies: [{ kind: "component", type: MkPopover, selector: "mk-popover", inputs: ["placement", "ariaLabel", "autoFocus"], outputs: ["closed"], exportAs: ["mkPopover"] }, { kind: "component", type: MkButton, selector: "button[mkButton], a[mkButton]", inputs: ["variant", "tone", "size", "loading", "fullWidth", "iconOnly", "disabled"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
|
|
565
|
+
}
|
|
566
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.7", ngImport: i0, type: MkPopconfirm, decorators: [{
|
|
567
|
+
type: Component,
|
|
568
|
+
args: [{ selector: 'mk-popconfirm', exportAs: 'mkPopconfirm', changeDetection: ChangeDetectionStrategy.OnPush, imports: [MkPopover, MkButton], host: {
|
|
569
|
+
class: 'mk-popconfirm',
|
|
570
|
+
}, template: "<mk-popover\n [placement]=\"placement()\"\n [ariaLabel]=\"title() || message()\"\n (closed)=\"onPopoverClosed()\"\n>\n <div class=\"mk-popconfirm__body\">\n @if (title()) {\n <p class=\"mk-popconfirm__title\">{{ title() }}</p>\n }\n <p class=\"mk-popconfirm__message\">{{ message() }}</p>\n <div class=\"mk-popconfirm__actions\">\n <button mkButton variant=\"ghost\" tone=\"neutral\" size=\"sm\" (click)=\"onCancel()\">\n {{ cancelText() }}\n </button>\n <button mkButton [tone]=\"tone()\" size=\"sm\" (click)=\"onConfirm()\">\n {{ confirmText() }}\n </button>\n </div>\n </div>\n</mk-popover>\n", styles: [":host{display:contents}.mk-popconfirm__body{display:flex;flex-direction:column;gap:var(--mk-space-2);min-width:12rem;max-width:18rem}.mk-popconfirm__title{margin:0;font-size:var(--mk-font-size-md);font-weight:var(--mk-font-weight-semibold);color:var(--mk-text)}.mk-popconfirm__message{margin:0;font-size:var(--mk-font-size-sm);color:var(--mk-text-muted);line-height:var(--mk-line-height-normal)}.mk-popconfirm__actions{display:flex;justify-content:flex-end;gap:var(--mk-space-2);margin-top:var(--mk-space-1)}\n"] }]
|
|
571
|
+
}], propDecorators: { popover: [{ type: i0.ViewChild, args: [i0.forwardRef(() => MkPopover), { isSignal: true }] }], message: [{ type: i0.Input, args: [{ isSignal: true, alias: "message", required: false }] }], title: [{ type: i0.Input, args: [{ isSignal: true, alias: "title", required: false }] }], confirmText: [{ type: i0.Input, args: [{ isSignal: true, alias: "confirmText", required: false }] }], cancelText: [{ type: i0.Input, args: [{ isSignal: true, alias: "cancelText", required: false }] }], tone: [{ type: i0.Input, args: [{ isSignal: true, alias: "tone", required: false }] }], placement: [{ type: i0.Input, args: [{ isSignal: true, alias: "placement", required: false }] }], confirm: [{ type: i0.Output, args: ["confirm"] }], cancel: [{ type: i0.Output, args: ["cancel"] }] } });
|
|
572
|
+
|
|
573
|
+
/**
|
|
574
|
+
* Turns its host element into a click trigger for an `<mk-popconfirm>`. Wires
|
|
575
|
+
* `aria-haspopup="dialog"` / `aria-expanded` / `aria-controls`, toggles on click
|
|
576
|
+
* and closes on Escape.
|
|
577
|
+
*
|
|
578
|
+
* ```html
|
|
579
|
+
* <button mkButton tone="danger" [mkPopconfirmFor]="del">Delete</button>
|
|
580
|
+
* <mk-popconfirm #del message="Delete this item?" (confirm)="remove()" />
|
|
581
|
+
* ```
|
|
582
|
+
*/
|
|
583
|
+
class MkPopconfirmTrigger {
|
|
584
|
+
host = inject(ElementRef);
|
|
585
|
+
/** The popconfirm instance to control. */
|
|
586
|
+
popconfirm = input.required({ ...(ngDevMode ? { debugName: "popconfirm" } : /* istanbul ignore next */ {}), alias: 'mkPopconfirmFor' });
|
|
587
|
+
onClick(event) {
|
|
588
|
+
event.preventDefault();
|
|
589
|
+
this.popconfirm().toggle(this.host.nativeElement);
|
|
590
|
+
}
|
|
591
|
+
onKeydown(event) {
|
|
592
|
+
if (event.key === 'Escape' && this.popconfirm().opened()) {
|
|
593
|
+
event.preventDefault();
|
|
594
|
+
this.popconfirm().close();
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.7", ngImport: i0, type: MkPopconfirmTrigger, deps: [], target: i0.ɵɵFactoryTarget.Directive });
|
|
598
|
+
static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "22.0.7", type: MkPopconfirmTrigger, isStandalone: true, selector: "[mkPopconfirmFor]", inputs: { popconfirm: { classPropertyName: "popconfirm", publicName: "mkPopconfirmFor", isSignal: true, isRequired: true, transformFunction: null } }, host: { attributes: { "aria-haspopup": "dialog" }, listeners: { "click": "onClick($event)", "keydown": "onKeydown($event)" }, properties: { "attr.aria-expanded": "popconfirm().opened()", "attr.aria-controls": "popconfirm().panelId" } }, exportAs: ["mkPopconfirmTrigger"], ngImport: i0 });
|
|
599
|
+
}
|
|
600
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.7", ngImport: i0, type: MkPopconfirmTrigger, decorators: [{
|
|
601
|
+
type: Directive,
|
|
602
|
+
args: [{
|
|
603
|
+
selector: '[mkPopconfirmFor]',
|
|
604
|
+
exportAs: 'mkPopconfirmTrigger',
|
|
605
|
+
host: {
|
|
606
|
+
'aria-haspopup': 'dialog',
|
|
607
|
+
'[attr.aria-expanded]': 'popconfirm().opened()',
|
|
608
|
+
'[attr.aria-controls]': 'popconfirm().panelId',
|
|
609
|
+
'(click)': 'onClick($event)',
|
|
610
|
+
'(keydown)': 'onKeydown($event)',
|
|
611
|
+
},
|
|
612
|
+
}]
|
|
613
|
+
}], propDecorators: { popconfirm: [{ type: i0.Input, args: [{ isSignal: true, alias: "mkPopconfirmFor", required: true }] }] } });
|
|
614
|
+
|
|
615
|
+
/**
|
|
616
|
+
* Result — a centred, full-page status state: a success confirmation, an error,
|
|
617
|
+
* an empty 404/403/500 page. Renders a large status icon (chosen from
|
|
618
|
+
* `status`), a title, a muted subtitle, projected body content, and a row of
|
|
619
|
+
* action buttons at the bottom.
|
|
620
|
+
*
|
|
621
|
+
* Slots: `[mkResultIcon]` (custom icon, overrides the built-in glyph),
|
|
622
|
+
* `[mkResultTitle]` (custom heading, used when `resultTitle` is unset), default
|
|
623
|
+
* content (extra body), `[mkResultActions]` (buttons).
|
|
624
|
+
*
|
|
625
|
+
* ```html
|
|
626
|
+
* <mk-result status="success" resultTitle="Payment complete"
|
|
627
|
+
* subtitle="Your order is on its way.">
|
|
628
|
+
* <button mkButton mkResultActions>Back home</button>
|
|
629
|
+
* </mk-result>
|
|
630
|
+
* ```
|
|
631
|
+
*/
|
|
632
|
+
class MkResult {
|
|
633
|
+
/** Status preset that selects the built-in icon glyph and its tone colour. */
|
|
634
|
+
status = input('info', /* @ts-ignore */
|
|
635
|
+
...(ngDevMode ? [{ debugName: "status" }] : /* istanbul ignore next */ []));
|
|
636
|
+
/** Main heading rendered as an `<h2>` (falls back to the `[mkResultTitle]` slot). */
|
|
637
|
+
resultTitle = input('', /* @ts-ignore */
|
|
638
|
+
...(ngDevMode ? [{ debugName: "resultTitle" }] : /* istanbul ignore next */ []));
|
|
639
|
+
/** Supporting muted sentence under the title. */
|
|
640
|
+
subtitle = input('', /* @ts-ignore */
|
|
641
|
+
...(ngDevMode ? [{ debugName: "subtitle" }] : /* istanbul ignore next */ []));
|
|
642
|
+
/** Built-in glyph for the current status. */
|
|
643
|
+
iconName = computed(() => {
|
|
644
|
+
switch (this.status()) {
|
|
645
|
+
case 'success':
|
|
646
|
+
return 'check';
|
|
647
|
+
case 'error':
|
|
648
|
+
case '500':
|
|
649
|
+
return 'x';
|
|
650
|
+
case 'warning':
|
|
651
|
+
case '403':
|
|
652
|
+
return 'alert';
|
|
653
|
+
case '404':
|
|
654
|
+
return 'search';
|
|
655
|
+
default:
|
|
656
|
+
return 'info';
|
|
657
|
+
}
|
|
658
|
+
}, /* @ts-ignore */
|
|
659
|
+
...(ngDevMode ? [{ debugName: "iconName" }] : /* istanbul ignore next */ []));
|
|
660
|
+
/** Tone colour applied to the status icon. */
|
|
661
|
+
tone = computed(() => {
|
|
662
|
+
switch (this.status()) {
|
|
663
|
+
case 'success':
|
|
664
|
+
return 'success';
|
|
665
|
+
case 'error':
|
|
666
|
+
case '500':
|
|
667
|
+
return 'danger';
|
|
668
|
+
case 'warning':
|
|
669
|
+
case '403':
|
|
670
|
+
return 'warning';
|
|
671
|
+
case '404':
|
|
672
|
+
return 'muted';
|
|
673
|
+
default:
|
|
674
|
+
return 'info';
|
|
675
|
+
}
|
|
676
|
+
}, /* @ts-ignore */
|
|
677
|
+
...(ngDevMode ? [{ debugName: "tone" }] : /* istanbul ignore next */ []));
|
|
678
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.7", ngImport: i0, type: MkResult, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
679
|
+
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.0.7", type: MkResult, isStandalone: true, selector: "mk-result", inputs: { status: { classPropertyName: "status", publicName: "status", isSignal: true, isRequired: false, transformFunction: null }, resultTitle: { classPropertyName: "resultTitle", publicName: "resultTitle", isSignal: true, isRequired: false, transformFunction: null }, subtitle: { classPropertyName: "subtitle", publicName: "subtitle", isSignal: true, isRequired: false, transformFunction: null } }, host: { attributes: { "role": "status" }, properties: { "attr.data-status": "status()", "attr.data-tone": "tone()" }, classAttribute: "mk-result" }, ngImport: i0, template: "<div class=\"mk-result__icon\" aria-hidden=\"true\">\n <span class=\"mk-result__icon-custom\"><ng-content select=\"[mkResultIcon]\" /></span>\n <span class=\"mk-result__icon-default\">\n @switch (iconName()) {\n @case ('check') {\n <svg viewBox=\"0 0 24 24\" width=\"64\" height=\"64\" fill=\"none\" focusable=\"false\">\n <circle cx=\"12\" cy=\"12\" r=\"9\" stroke=\"currentColor\" stroke-width=\"1.6\" />\n <path d=\"M8 12.5l2.5 2.5L16 9.5\" stroke=\"currentColor\" stroke-width=\"1.6\"\n stroke-linecap=\"round\" stroke-linejoin=\"round\" />\n </svg>\n }\n @case ('x') {\n <svg viewBox=\"0 0 24 24\" width=\"64\" height=\"64\" fill=\"none\" focusable=\"false\">\n <circle cx=\"12\" cy=\"12\" r=\"9\" stroke=\"currentColor\" stroke-width=\"1.6\" />\n <path d=\"M9 9l6 6M15 9l-6 6\" stroke=\"currentColor\" stroke-width=\"1.6\"\n stroke-linecap=\"round\" />\n </svg>\n }\n @case ('alert') {\n <svg viewBox=\"0 0 24 24\" width=\"64\" height=\"64\" fill=\"none\" focusable=\"false\">\n <path d=\"M12 3.5l9 15.5H3z\" stroke=\"currentColor\" stroke-width=\"1.6\"\n stroke-linejoin=\"round\" />\n <path d=\"M12 10v4\" stroke=\"currentColor\" stroke-width=\"1.6\" stroke-linecap=\"round\" />\n <circle cx=\"12\" cy=\"16.5\" r=\"1\" fill=\"currentColor\" />\n </svg>\n }\n @case ('search') {\n <svg viewBox=\"0 0 24 24\" width=\"64\" height=\"64\" fill=\"none\" focusable=\"false\">\n <circle cx=\"10.5\" cy=\"10.5\" r=\"6.5\" stroke=\"currentColor\" stroke-width=\"1.6\" />\n <path d=\"M15.5 15.5L21 21\" stroke=\"currentColor\" stroke-width=\"1.6\"\n stroke-linecap=\"round\" />\n </svg>\n }\n @default {\n <svg viewBox=\"0 0 24 24\" width=\"64\" height=\"64\" fill=\"none\" focusable=\"false\">\n <circle cx=\"12\" cy=\"12\" r=\"9\" stroke=\"currentColor\" stroke-width=\"1.6\" />\n <path d=\"M12 11v5\" stroke=\"currentColor\" stroke-width=\"1.6\" stroke-linecap=\"round\" />\n <circle cx=\"12\" cy=\"8\" r=\"1\" fill=\"currentColor\" />\n </svg>\n }\n }\n </span>\n</div>\n\n@if (resultTitle(); as heading) {\n <h2 class=\"mk-result__title\">{{ heading }}</h2>\n} @else {\n <ng-content select=\"[mkResultTitle]\" />\n}\n\n@if (subtitle(); as text) {\n <p class=\"mk-result__subtitle\">{{ text }}</p>\n}\n\n<div class=\"mk-result__body\">\n <ng-content />\n</div>\n\n<div class=\"mk-result__actions\">\n <ng-content select=\"[mkResultActions]\" />\n</div>\n", styles: [":host{display:flex;flex-direction:column;align-items:center;justify-content:center;gap:var(--mk-space-2);max-width:32rem;margin-inline:auto;padding:var(--mk-space-12) var(--mk-space-6);text-align:center;font-family:var(--mk-font-sans);color:var(--mk-text)}.mk-result__icon{display:inline-flex;align-items:center;justify-content:center;margin-bottom:var(--mk-space-2);color:var(--mk-text-muted)}:host([data-tone=success]) .mk-result__icon{color:var(--mk-success)}:host([data-tone=danger]) .mk-result__icon{color:var(--mk-danger-text)}:host([data-tone=warning]) .mk-result__icon{color:var(--mk-warning)}:host([data-tone=info]) .mk-result__icon{color:var(--mk-info, var(--mk-primary))}:host([data-tone=muted]) .mk-result__icon{color:var(--mk-text-muted)}.mk-result__icon-custom:empty{display:none}.mk-result__icon:has(.mk-result__icon-custom>*) .mk-result__icon-default{display:none}.mk-result__title{margin:0;font-size:var(--mk-font-size-xl);font-weight:var(--mk-font-weight-semibold);line-height:var(--mk-line-height-snug);color:var(--mk-text)}.mk-result__subtitle{margin:0;max-width:40ch;font-size:var(--mk-font-size-md);line-height:var(--mk-line-height-normal);color:var(--mk-text-muted)}.mk-result__body:empty{display:none}.mk-result__actions{display:flex;flex-wrap:wrap;align-items:center;justify-content:center;gap:var(--mk-space-2);margin-top:var(--mk-space-4)}.mk-result__actions:empty{display:none}\n"], changeDetection: i0.ChangeDetectionStrategy.OnPush });
|
|
680
|
+
}
|
|
681
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.7", ngImport: i0, type: MkResult, decorators: [{
|
|
682
|
+
type: Component,
|
|
683
|
+
args: [{ selector: 'mk-result', changeDetection: ChangeDetectionStrategy.OnPush, host: {
|
|
684
|
+
class: 'mk-result',
|
|
685
|
+
role: 'status',
|
|
686
|
+
'[attr.data-status]': 'status()',
|
|
687
|
+
'[attr.data-tone]': 'tone()',
|
|
688
|
+
}, template: "<div class=\"mk-result__icon\" aria-hidden=\"true\">\n <span class=\"mk-result__icon-custom\"><ng-content select=\"[mkResultIcon]\" /></span>\n <span class=\"mk-result__icon-default\">\n @switch (iconName()) {\n @case ('check') {\n <svg viewBox=\"0 0 24 24\" width=\"64\" height=\"64\" fill=\"none\" focusable=\"false\">\n <circle cx=\"12\" cy=\"12\" r=\"9\" stroke=\"currentColor\" stroke-width=\"1.6\" />\n <path d=\"M8 12.5l2.5 2.5L16 9.5\" stroke=\"currentColor\" stroke-width=\"1.6\"\n stroke-linecap=\"round\" stroke-linejoin=\"round\" />\n </svg>\n }\n @case ('x') {\n <svg viewBox=\"0 0 24 24\" width=\"64\" height=\"64\" fill=\"none\" focusable=\"false\">\n <circle cx=\"12\" cy=\"12\" r=\"9\" stroke=\"currentColor\" stroke-width=\"1.6\" />\n <path d=\"M9 9l6 6M15 9l-6 6\" stroke=\"currentColor\" stroke-width=\"1.6\"\n stroke-linecap=\"round\" />\n </svg>\n }\n @case ('alert') {\n <svg viewBox=\"0 0 24 24\" width=\"64\" height=\"64\" fill=\"none\" focusable=\"false\">\n <path d=\"M12 3.5l9 15.5H3z\" stroke=\"currentColor\" stroke-width=\"1.6\"\n stroke-linejoin=\"round\" />\n <path d=\"M12 10v4\" stroke=\"currentColor\" stroke-width=\"1.6\" stroke-linecap=\"round\" />\n <circle cx=\"12\" cy=\"16.5\" r=\"1\" fill=\"currentColor\" />\n </svg>\n }\n @case ('search') {\n <svg viewBox=\"0 0 24 24\" width=\"64\" height=\"64\" fill=\"none\" focusable=\"false\">\n <circle cx=\"10.5\" cy=\"10.5\" r=\"6.5\" stroke=\"currentColor\" stroke-width=\"1.6\" />\n <path d=\"M15.5 15.5L21 21\" stroke=\"currentColor\" stroke-width=\"1.6\"\n stroke-linecap=\"round\" />\n </svg>\n }\n @default {\n <svg viewBox=\"0 0 24 24\" width=\"64\" height=\"64\" fill=\"none\" focusable=\"false\">\n <circle cx=\"12\" cy=\"12\" r=\"9\" stroke=\"currentColor\" stroke-width=\"1.6\" />\n <path d=\"M12 11v5\" stroke=\"currentColor\" stroke-width=\"1.6\" stroke-linecap=\"round\" />\n <circle cx=\"12\" cy=\"8\" r=\"1\" fill=\"currentColor\" />\n </svg>\n }\n }\n </span>\n</div>\n\n@if (resultTitle(); as heading) {\n <h2 class=\"mk-result__title\">{{ heading }}</h2>\n} @else {\n <ng-content select=\"[mkResultTitle]\" />\n}\n\n@if (subtitle(); as text) {\n <p class=\"mk-result__subtitle\">{{ text }}</p>\n}\n\n<div class=\"mk-result__body\">\n <ng-content />\n</div>\n\n<div class=\"mk-result__actions\">\n <ng-content select=\"[mkResultActions]\" />\n</div>\n", styles: [":host{display:flex;flex-direction:column;align-items:center;justify-content:center;gap:var(--mk-space-2);max-width:32rem;margin-inline:auto;padding:var(--mk-space-12) var(--mk-space-6);text-align:center;font-family:var(--mk-font-sans);color:var(--mk-text)}.mk-result__icon{display:inline-flex;align-items:center;justify-content:center;margin-bottom:var(--mk-space-2);color:var(--mk-text-muted)}:host([data-tone=success]) .mk-result__icon{color:var(--mk-success)}:host([data-tone=danger]) .mk-result__icon{color:var(--mk-danger-text)}:host([data-tone=warning]) .mk-result__icon{color:var(--mk-warning)}:host([data-tone=info]) .mk-result__icon{color:var(--mk-info, var(--mk-primary))}:host([data-tone=muted]) .mk-result__icon{color:var(--mk-text-muted)}.mk-result__icon-custom:empty{display:none}.mk-result__icon:has(.mk-result__icon-custom>*) .mk-result__icon-default{display:none}.mk-result__title{margin:0;font-size:var(--mk-font-size-xl);font-weight:var(--mk-font-weight-semibold);line-height:var(--mk-line-height-snug);color:var(--mk-text)}.mk-result__subtitle{margin:0;max-width:40ch;font-size:var(--mk-font-size-md);line-height:var(--mk-line-height-normal);color:var(--mk-text-muted)}.mk-result__body:empty{display:none}.mk-result__actions{display:flex;flex-wrap:wrap;align-items:center;justify-content:center;gap:var(--mk-space-2);margin-top:var(--mk-space-4)}.mk-result__actions:empty{display:none}\n"] }]
|
|
689
|
+
}], propDecorators: { status: [{ type: i0.Input, args: [{ isSignal: true, alias: "status", required: false }] }], resultTitle: [{ type: i0.Input, args: [{ isSignal: true, alias: "resultTitle", required: false }] }], subtitle: [{ type: i0.Input, args: [{ isSignal: true, alias: "subtitle", required: false }] }] } });
|
|
690
|
+
|
|
691
|
+
/**
|
|
692
|
+
* Hovercard — a rich hover-preview panel anchored to a trigger, in the spirit of
|
|
693
|
+
* GitHub's user hovercards. Unlike {@link MkTooltip} (short text) it projects
|
|
694
|
+
* arbitrary content, and unlike {@link MkPopover} (click) it opens on hover/focus
|
|
695
|
+
* after a short delay and closes when the pointer leaves both the trigger and the
|
|
696
|
+
* card. It is driven by the `mkHovercardFor` directive (or the `open`/`close` API).
|
|
697
|
+
*
|
|
698
|
+
* The panel renders in the browser top layer through {@link MkAnchoredPanel}, so
|
|
699
|
+
* it is never clipped by an ancestor's `overflow`/`transform`. It is non-modal —
|
|
700
|
+
* no focus trap. Moving the pointer from the trigger onto the card keeps it open;
|
|
701
|
+
* Escape closes it.
|
|
702
|
+
*
|
|
703
|
+
* ```html
|
|
704
|
+
* <a href="/u/ada" [mkHovercardFor]="ada">@ada</a>
|
|
705
|
+
* <mk-hovercard #ada ariaLabel="Ada Lovelace">
|
|
706
|
+
* <img src="/ada.png" alt="" /><strong>Ada Lovelace</strong>
|
|
707
|
+
* </mk-hovercard>
|
|
708
|
+
* ```
|
|
709
|
+
*/
|
|
710
|
+
class MkHovercard {
|
|
711
|
+
document = inject(DOCUMENT);
|
|
712
|
+
isBrowser = isPlatformBrowser(inject(PLATFORM_ID));
|
|
713
|
+
/** Stable id for `aria-controls` on the trigger. */
|
|
714
|
+
panelId = mkUniqueId('mk-hovercard');
|
|
715
|
+
/** Preferred placement relative to the trigger. */
|
|
716
|
+
placement = input('bottom-start', /* @ts-ignore */
|
|
717
|
+
...(ngDevMode ? [{ debugName: "placement" }] : /* istanbul ignore next */ []));
|
|
718
|
+
/** Accessible label for the panel. */
|
|
719
|
+
ariaLabel = input(/* @ts-ignore */
|
|
720
|
+
...(ngDevMode ? [undefined, { debugName: "ariaLabel" }] : /* istanbul ignore next */ []));
|
|
721
|
+
/** Delay in ms before the card opens on hover/focus. Default `300`. */
|
|
722
|
+
openDelay = input(300, { ...(ngDevMode ? { debugName: "openDelay" } : /* istanbul ignore next */ {}), transform: numberAttribute });
|
|
723
|
+
/** Delay in ms before the card closes after the pointer leaves. Default `200`. */
|
|
724
|
+
closeDelay = input(200, { ...(ngDevMode ? { debugName: "closeDelay" } : /* istanbul ignore next */ {}), transform: numberAttribute });
|
|
725
|
+
/** Close the card when Escape is pressed while it is open. Default `true`. */
|
|
726
|
+
closeOnEscape = input(true, { ...(ngDevMode ? { debugName: "closeOnEscape" } : /* istanbul ignore next */ {}), transform: booleanAttribute });
|
|
727
|
+
/** Emitted whenever the hovercard closes (any reason). */
|
|
728
|
+
closed = output();
|
|
729
|
+
_open = signal(false, /* @ts-ignore */
|
|
730
|
+
...(ngDevMode ? [{ debugName: "_open" }] : /* istanbul ignore next */ []));
|
|
731
|
+
/** Whether the hovercard is open. */
|
|
732
|
+
opened = this._open.asReadonly();
|
|
733
|
+
anchorEl = signal(undefined, /* @ts-ignore */
|
|
734
|
+
...(ngDevMode ? [{ debugName: "anchorEl" }] : /* istanbul ignore next */ []));
|
|
735
|
+
triggerEl = null;
|
|
736
|
+
openTimer = null;
|
|
737
|
+
closeTimer = null;
|
|
738
|
+
/** Open the card anchored to `trigger` after `openDelay` (cancels a pending close). */
|
|
739
|
+
scheduleOpen(trigger) {
|
|
740
|
+
if (!this.isBrowser)
|
|
741
|
+
return;
|
|
742
|
+
this.clearCloseTimer();
|
|
743
|
+
if (this._open()) {
|
|
744
|
+
// Already open — just make sure the anchor is current.
|
|
745
|
+
this.triggerEl = trigger;
|
|
746
|
+
this.anchorEl.set(trigger);
|
|
747
|
+
return;
|
|
748
|
+
}
|
|
749
|
+
if (this.openTimer !== null)
|
|
750
|
+
return;
|
|
751
|
+
this.openTimer = this.setTimer(() => {
|
|
752
|
+
this.openTimer = null;
|
|
753
|
+
this.open(trigger);
|
|
754
|
+
}, this.openDelay());
|
|
755
|
+
}
|
|
756
|
+
/** Close the card after `closeDelay` (cancels a pending open). */
|
|
757
|
+
scheduleClose() {
|
|
758
|
+
if (!this.isBrowser)
|
|
759
|
+
return;
|
|
760
|
+
this.clearOpenTimer();
|
|
761
|
+
if (!this._open() || this.closeTimer !== null)
|
|
762
|
+
return;
|
|
763
|
+
this.closeTimer = this.setTimer(() => {
|
|
764
|
+
this.closeTimer = null;
|
|
765
|
+
this.close();
|
|
766
|
+
}, this.closeDelay());
|
|
767
|
+
}
|
|
768
|
+
/**
|
|
769
|
+
* Keep the card open: cancel any pending open/close. Called when the pointer
|
|
770
|
+
* enters the card itself, so moving from the trigger to the card is seamless.
|
|
771
|
+
*/
|
|
772
|
+
keepOpen() {
|
|
773
|
+
this.clearCloseTimer();
|
|
774
|
+
}
|
|
775
|
+
/** Open the card immediately, anchored to `trigger`. */
|
|
776
|
+
open(trigger) {
|
|
777
|
+
if (!this.isBrowser || this._open())
|
|
778
|
+
return;
|
|
779
|
+
this.clearOpenTimer();
|
|
780
|
+
this.triggerEl = trigger;
|
|
781
|
+
this.anchorEl.set(trigger);
|
|
782
|
+
this._open.set(true);
|
|
783
|
+
}
|
|
784
|
+
/** Close the card immediately. */
|
|
785
|
+
close() {
|
|
786
|
+
this.clearOpenTimer();
|
|
787
|
+
this.clearCloseTimer();
|
|
788
|
+
if (!this._open())
|
|
789
|
+
return;
|
|
790
|
+
this._open.set(false);
|
|
791
|
+
this.anchorEl.set(undefined);
|
|
792
|
+
this.triggerEl = null;
|
|
793
|
+
this.closed.emit();
|
|
794
|
+
}
|
|
795
|
+
onCardEnter() {
|
|
796
|
+
this.keepOpen();
|
|
797
|
+
}
|
|
798
|
+
onCardLeave() {
|
|
799
|
+
this.scheduleClose();
|
|
800
|
+
}
|
|
801
|
+
/**
|
|
802
|
+
* Keyboard mirror of `mouseleave`: when focus moves somewhere outside BOTH
|
|
803
|
+
* the card and its trigger, schedule a close. Focus hopping between the
|
|
804
|
+
* trigger and the card (either direction) keeps it open, so the card's
|
|
805
|
+
* content stays reachable with Tab.
|
|
806
|
+
*/
|
|
807
|
+
onCardFocusout(event) {
|
|
808
|
+
const related = event.relatedTarget;
|
|
809
|
+
if (!related)
|
|
810
|
+
return;
|
|
811
|
+
const panel = event.currentTarget;
|
|
812
|
+
if (panel?.contains(related) || this.triggerEl?.contains(related))
|
|
813
|
+
return;
|
|
814
|
+
this.scheduleClose();
|
|
815
|
+
}
|
|
816
|
+
onEscape(event) {
|
|
817
|
+
if (!this.closeOnEscape())
|
|
818
|
+
return;
|
|
819
|
+
event.preventDefault();
|
|
820
|
+
event.stopPropagation();
|
|
821
|
+
// Escape may be pressed while focus is INSIDE the panel; closing destroys
|
|
822
|
+
// it, which would otherwise drop focus on <body>. Hand it back to the
|
|
823
|
+
// trigger so the keyboard user keeps their place.
|
|
824
|
+
const trigger = this.triggerEl;
|
|
825
|
+
this.close();
|
|
826
|
+
trigger?.focus();
|
|
827
|
+
}
|
|
828
|
+
setTimer(fn, delay) {
|
|
829
|
+
const view = this.document.defaultView;
|
|
830
|
+
return view ? view.setTimeout(fn, delay) : null;
|
|
831
|
+
}
|
|
832
|
+
clearOpenTimer() {
|
|
833
|
+
if (this.openTimer !== null) {
|
|
834
|
+
this.document.defaultView?.clearTimeout(this.openTimer);
|
|
835
|
+
this.openTimer = null;
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
clearCloseTimer() {
|
|
839
|
+
if (this.closeTimer !== null) {
|
|
840
|
+
this.document.defaultView?.clearTimeout(this.closeTimer);
|
|
841
|
+
this.closeTimer = null;
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
ngOnDestroy() {
|
|
845
|
+
this.clearOpenTimer();
|
|
846
|
+
this.clearCloseTimer();
|
|
847
|
+
}
|
|
848
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.7", ngImport: i0, type: MkHovercard, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
849
|
+
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.0.7", type: MkHovercard, isStandalone: true, selector: "mk-hovercard", inputs: { placement: { classPropertyName: "placement", publicName: "placement", isSignal: true, isRequired: false, transformFunction: null }, ariaLabel: { classPropertyName: "ariaLabel", publicName: "ariaLabel", isSignal: true, isRequired: false, transformFunction: null }, openDelay: { classPropertyName: "openDelay", publicName: "openDelay", isSignal: true, isRequired: false, transformFunction: null }, closeDelay: { classPropertyName: "closeDelay", publicName: "closeDelay", isSignal: true, isRequired: false, transformFunction: null }, closeOnEscape: { classPropertyName: "closeOnEscape", publicName: "closeOnEscape", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { closed: "closed" }, host: { classAttribute: "mk-hovercard" }, exportAs: ["mkHovercard"], ngImport: i0, template: "@if (opened()) {\n <div\n #panel\n class=\"mk-hovercard__panel\"\n role=\"dialog\"\n tabindex=\"-1\"\n [id]=\"panelId\"\n [attr.aria-label]=\"ariaLabel()\"\n mkAnchoredPanel\n [mkAnchoredPanelFor]=\"anchorEl()\"\n [placement]=\"placement()\"\n [gap]=\"8\"\n (dismiss)=\"close()\"\n (mouseenter)=\"onCardEnter()\"\n (mouseleave)=\"onCardLeave()\"\n (focusin)=\"onCardEnter()\"\n (focusout)=\"onCardFocusout($event)\"\n (keydown.escape)=\"onEscape($event)\"\n >\n <ng-content />\n </div>\n}\n", styles: [":host{display:contents}.mk-hovercard__panel{pointer-events:auto;min-width:16rem;max-width:min(24rem,100vw - var(--mk-space-4));padding:var(--mk-space-4);color:var(--mk-text);background:var(--mk-surface);border:var(--mk-border-width) solid var(--mk-border);border-radius:var(--mk-radius-lg);box-shadow:var(--mk-shadow-lg);font-family:var(--mk-font-sans);font-size:var(--mk-font-size-md);animation:mk-hovercard-in var(--mk-duration-fast) var(--mk-ease-out)}.mk-hovercard__panel:focus-visible{outline:none}@keyframes mk-hovercard-in{0%{opacity:0;transform:translateY(calc(-1 * var(--mk-space-1))) scale(.98)}to{opacity:1;transform:none}}@media(prefers-reduced-motion:reduce){.mk-hovercard__panel{animation:none}}\n"], dependencies: [{ kind: "directive", type: MkAnchoredPanel, selector: "[mkAnchoredPanel]", inputs: ["mkAnchoredPanelFor", "anchorRect", "placement", "gap", "matchWidth", "flip", "clamp"], outputs: ["dismiss"], exportAs: ["mkAnchoredPanel"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
|
|
850
|
+
}
|
|
851
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.7", ngImport: i0, type: MkHovercard, decorators: [{
|
|
852
|
+
type: Component,
|
|
853
|
+
args: [{ selector: 'mk-hovercard', exportAs: 'mkHovercard', changeDetection: ChangeDetectionStrategy.OnPush, imports: [MkAnchoredPanel], host: {
|
|
854
|
+
class: 'mk-hovercard',
|
|
855
|
+
}, template: "@if (opened()) {\n <div\n #panel\n class=\"mk-hovercard__panel\"\n role=\"dialog\"\n tabindex=\"-1\"\n [id]=\"panelId\"\n [attr.aria-label]=\"ariaLabel()\"\n mkAnchoredPanel\n [mkAnchoredPanelFor]=\"anchorEl()\"\n [placement]=\"placement()\"\n [gap]=\"8\"\n (dismiss)=\"close()\"\n (mouseenter)=\"onCardEnter()\"\n (mouseleave)=\"onCardLeave()\"\n (focusin)=\"onCardEnter()\"\n (focusout)=\"onCardFocusout($event)\"\n (keydown.escape)=\"onEscape($event)\"\n >\n <ng-content />\n </div>\n}\n", styles: [":host{display:contents}.mk-hovercard__panel{pointer-events:auto;min-width:16rem;max-width:min(24rem,100vw - var(--mk-space-4));padding:var(--mk-space-4);color:var(--mk-text);background:var(--mk-surface);border:var(--mk-border-width) solid var(--mk-border);border-radius:var(--mk-radius-lg);box-shadow:var(--mk-shadow-lg);font-family:var(--mk-font-sans);font-size:var(--mk-font-size-md);animation:mk-hovercard-in var(--mk-duration-fast) var(--mk-ease-out)}.mk-hovercard__panel:focus-visible{outline:none}@keyframes mk-hovercard-in{0%{opacity:0;transform:translateY(calc(-1 * var(--mk-space-1))) scale(.98)}to{opacity:1;transform:none}}@media(prefers-reduced-motion:reduce){.mk-hovercard__panel{animation:none}}\n"] }]
|
|
856
|
+
}], propDecorators: { placement: [{ type: i0.Input, args: [{ isSignal: true, alias: "placement", required: false }] }], ariaLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "ariaLabel", required: false }] }], openDelay: [{ type: i0.Input, args: [{ isSignal: true, alias: "openDelay", required: false }] }], closeDelay: [{ type: i0.Input, args: [{ isSignal: true, alias: "closeDelay", required: false }] }], closeOnEscape: [{ type: i0.Input, args: [{ isSignal: true, alias: "closeOnEscape", required: false }] }], closed: [{ type: i0.Output, args: ["closed"] }] } });
|
|
857
|
+
|
|
858
|
+
/**
|
|
859
|
+
* A touch tap fires emulated `mouseenter` + `focus` right after `pointerdown`;
|
|
860
|
+
* for this long after a touch toggle those synthetic enters are ignored, so a
|
|
861
|
+
* tap that just CLOSED the card is not undone by its own compatibility events.
|
|
862
|
+
*/
|
|
863
|
+
const TOUCH_ENTER_SUPPRESS_MS = 500;
|
|
864
|
+
/**
|
|
865
|
+
* Turns its host element into a hover/focus trigger for an `<mk-hovercard>`.
|
|
866
|
+
* On `mouseenter`/`focus` it opens the card (after the card's `openDelay`),
|
|
867
|
+
* anchored to the host; on `mouseleave`/`blur` it schedules a close (after the
|
|
868
|
+
* card's `closeDelay`). Moving the pointer — or keyboard focus — from the host
|
|
869
|
+
* onto the card cancels that close, so the card stays open and its content is
|
|
870
|
+
* reachable with Tab. Escape closes it.
|
|
871
|
+
*
|
|
872
|
+
* On touch there is no hover, so a tap toggles instead: tap opens the card
|
|
873
|
+
* immediately, tapping the trigger again closes it, and a tap anywhere else
|
|
874
|
+
* closes it through the panel's outside-pointerdown dismissal.
|
|
875
|
+
*
|
|
876
|
+
* ```html
|
|
877
|
+
* <a href="/u/ada" [mkHovercardFor]="ada">@ada</a>
|
|
878
|
+
* <mk-hovercard #ada>…</mk-hovercard>
|
|
879
|
+
* ```
|
|
880
|
+
*/
|
|
881
|
+
class MkHovercardTrigger {
|
|
882
|
+
host = inject(ElementRef);
|
|
883
|
+
/** Timestamp of the last touch toggle — guards `onEnter` (see above). */
|
|
884
|
+
lastTouchToggle = Number.NEGATIVE_INFINITY;
|
|
885
|
+
/** The hovercard instance to control. */
|
|
886
|
+
hovercard = input.required({ ...(ngDevMode ? { debugName: "hovercard" } : /* istanbul ignore next */ {}), alias: 'mkHovercardFor' });
|
|
887
|
+
onEnter() {
|
|
888
|
+
if (Date.now() - this.lastTouchToggle < TOUCH_ENTER_SUPPRESS_MS)
|
|
889
|
+
return;
|
|
890
|
+
this.hovercard().scheduleOpen(this.host.nativeElement);
|
|
891
|
+
}
|
|
892
|
+
/**
|
|
893
|
+
* Touch tap-toggle. Mouse and pen are ignored here — they keep the pure
|
|
894
|
+
* hover/focus behaviour. A tap while the card is open closes it (a tap
|
|
895
|
+
* OUTSIDE both trigger and panel is handled by the anchored panel's own
|
|
896
|
+
* outside-pointerdown dismiss, which excludes the anchor, so the two paths
|
|
897
|
+
* never double-fire).
|
|
898
|
+
*/
|
|
899
|
+
onPointerDown(event) {
|
|
900
|
+
if (event.pointerType !== 'touch')
|
|
901
|
+
return;
|
|
902
|
+
this.lastTouchToggle = Date.now();
|
|
903
|
+
const card = this.hovercard();
|
|
904
|
+
if (card.opened()) {
|
|
905
|
+
card.close();
|
|
906
|
+
}
|
|
907
|
+
else {
|
|
908
|
+
// No openDelay on touch — the tap is deliberate, not a passing pointer.
|
|
909
|
+
card.open(this.host.nativeElement);
|
|
910
|
+
}
|
|
911
|
+
}
|
|
912
|
+
onLeave() {
|
|
913
|
+
this.hovercard().scheduleClose();
|
|
914
|
+
}
|
|
915
|
+
onKeydown(event) {
|
|
916
|
+
if (event.key === 'Escape' && this.hovercard().opened()) {
|
|
917
|
+
event.preventDefault();
|
|
918
|
+
this.hovercard().close();
|
|
919
|
+
}
|
|
920
|
+
}
|
|
921
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.7", ngImport: i0, type: MkHovercardTrigger, deps: [], target: i0.ɵɵFactoryTarget.Directive });
|
|
922
|
+
static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "22.0.7", type: MkHovercardTrigger, isStandalone: true, selector: "[mkHovercardFor]", inputs: { hovercard: { classPropertyName: "hovercard", publicName: "mkHovercardFor", isSignal: true, isRequired: true, transformFunction: null } }, host: { attributes: { "aria-haspopup": "dialog" }, listeners: { "mouseenter": "onEnter()", "focus": "onEnter()", "mouseleave": "onLeave()", "blur": "onLeave()", "pointerdown": "onPointerDown($event)", "keydown": "onKeydown($event)" }, properties: { "attr.aria-expanded": "hovercard().opened()", "attr.aria-controls": "hovercard().panelId" } }, exportAs: ["mkHovercardTrigger"], ngImport: i0 });
|
|
923
|
+
}
|
|
924
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.7", ngImport: i0, type: MkHovercardTrigger, decorators: [{
|
|
925
|
+
type: Directive,
|
|
926
|
+
args: [{
|
|
927
|
+
selector: '[mkHovercardFor]',
|
|
928
|
+
exportAs: 'mkHovercardTrigger',
|
|
929
|
+
host: {
|
|
930
|
+
'aria-haspopup': 'dialog',
|
|
931
|
+
'[attr.aria-expanded]': 'hovercard().opened()',
|
|
932
|
+
'[attr.aria-controls]': 'hovercard().panelId',
|
|
933
|
+
'(mouseenter)': 'onEnter()',
|
|
934
|
+
'(focus)': 'onEnter()',
|
|
935
|
+
'(mouseleave)': 'onLeave()',
|
|
936
|
+
'(blur)': 'onLeave()',
|
|
937
|
+
'(pointerdown)': 'onPointerDown($event)',
|
|
938
|
+
'(keydown)': 'onKeydown($event)',
|
|
939
|
+
},
|
|
940
|
+
}]
|
|
941
|
+
}], propDecorators: { hovercard: [{ type: i0.Input, args: [{ isSignal: true, alias: "mkHovercardFor", required: true }] }] } });
|
|
942
|
+
|
|
943
|
+
/**
|
|
944
|
+
* NotificationCenter — a bell trigger with an unread-count badge that opens a
|
|
945
|
+
* dropdown panel listing notifications, each markable as read.
|
|
946
|
+
*
|
|
947
|
+
* The panel renders in the browser top layer through {@link MkAnchoredPanel},
|
|
948
|
+
* so it is never clipped by an ancestor's `overflow`/`transform`. Clicking the
|
|
949
|
+
* bell toggles it; an outside click, Tabbing out or Escape closes it and
|
|
950
|
+
* restores focus to the bell. Clicking a row marks that one read (updating the
|
|
951
|
+
* two-way `notifications` immutably); "Mark all read" clears every unread item.
|
|
952
|
+
*
|
|
953
|
+
* ```html
|
|
954
|
+
* <mk-notification-center
|
|
955
|
+
* [(notifications)]="items"
|
|
956
|
+
* (itemClick)="open($event)"
|
|
957
|
+
* (markedAllRead)="sync()" />
|
|
958
|
+
* ```
|
|
959
|
+
*/
|
|
960
|
+
class MkNotificationCenter {
|
|
961
|
+
injector = inject(Injector);
|
|
962
|
+
isBrowser = isPlatformBrowser(inject(PLATFORM_ID));
|
|
963
|
+
panelRef = viewChild('panel', /* @ts-ignore */
|
|
964
|
+
...(ngDevMode ? [{ debugName: "panelRef" }] : /* istanbul ignore next */ []));
|
|
965
|
+
triggerRef = viewChild('trigger', /* @ts-ignore */
|
|
966
|
+
...(ngDevMode ? [{ debugName: "triggerRef" }] : /* istanbul ignore next */ []));
|
|
967
|
+
i18n = inject(MK_I18N);
|
|
968
|
+
/** Stable id linking the trigger to the panel via `aria-controls`. */
|
|
969
|
+
panelId = mkUniqueId('mk-notification-center');
|
|
970
|
+
/** Stable id for the panel's labelling heading. */
|
|
971
|
+
titleId = mkUniqueId('mk-notification-center-title');
|
|
972
|
+
/** Two-way list of notifications rendered in the panel. */
|
|
973
|
+
notifications = model([], /* @ts-ignore */
|
|
974
|
+
...(ngDevMode ? [{ debugName: "notifications" }] : /* istanbul ignore next */ []));
|
|
975
|
+
/** Heading shown at the top of the panel. */
|
|
976
|
+
panelTitle = input(this.i18n.notificationsTitle, /* @ts-ignore */
|
|
977
|
+
...(ngDevMode ? [{ debugName: "panelTitle" }] : /* istanbul ignore next */ []));
|
|
978
|
+
/** Message shown by the empty state when there are no notifications. */
|
|
979
|
+
emptyText = input(this.i18n.allCaughtUp, /* @ts-ignore */
|
|
980
|
+
...(ngDevMode ? [{ debugName: "emptyText" }] : /* istanbul ignore next */ []));
|
|
981
|
+
/** Max height of the scrollable list (any CSS length). */
|
|
982
|
+
maxHeight = input('24rem', /* @ts-ignore */
|
|
983
|
+
...(ngDevMode ? [{ debugName: "maxHeight" }] : /* istanbul ignore next */ []));
|
|
984
|
+
/** Emitted with a notification when its row is clicked. */
|
|
985
|
+
itemClick = output();
|
|
986
|
+
/** Emitted with a notification when it transitions to read. */
|
|
987
|
+
read = output();
|
|
988
|
+
/** Emitted when every notification is marked read at once. */
|
|
989
|
+
markedAllRead = output();
|
|
990
|
+
_open = signal(false, /* @ts-ignore */
|
|
991
|
+
...(ngDevMode ? [{ debugName: "_open" }] : /* istanbul ignore next */ []));
|
|
992
|
+
/** Whether the panel is open. */
|
|
993
|
+
open = this._open.asReadonly();
|
|
994
|
+
/** Count of notifications that are not yet read. */
|
|
995
|
+
unreadCount = computed(() => this.notifications().filter((n) => !n.read).length, /* @ts-ignore */
|
|
996
|
+
...(ngDevMode ? [{ debugName: "unreadCount" }] : /* istanbul ignore next */ []));
|
|
997
|
+
/** Accessible label for the bell trigger. */
|
|
998
|
+
triggerLabel = computed(() => this.i18n.notificationsUnread(this.unreadCount()), /* @ts-ignore */
|
|
999
|
+
...(ngDevMode ? [{ debugName: "triggerLabel" }] : /* istanbul ignore next */ []));
|
|
1000
|
+
/** Badge text, capped at "99+". */
|
|
1001
|
+
badgeText = computed(() => {
|
|
1002
|
+
const n = this.unreadCount();
|
|
1003
|
+
return n > 99 ? '99+' : `${n}`;
|
|
1004
|
+
}, /* @ts-ignore */
|
|
1005
|
+
...(ngDevMode ? [{ debugName: "badgeText" }] : /* istanbul ignore next */ []));
|
|
1006
|
+
/** Toggle the panel open/closed. */
|
|
1007
|
+
toggle() {
|
|
1008
|
+
this._open() ? this.close() : this.openPanel();
|
|
1009
|
+
}
|
|
1010
|
+
/** Open the panel, moving focus into it on the next render. */
|
|
1011
|
+
openPanel() {
|
|
1012
|
+
if (!this.isBrowser || this._open())
|
|
1013
|
+
return;
|
|
1014
|
+
this._open.set(true);
|
|
1015
|
+
afterNextRender(() => this.focusInitial(), { injector: this.injector });
|
|
1016
|
+
}
|
|
1017
|
+
/** Close the panel; optionally restore focus to the bell trigger. */
|
|
1018
|
+
close(restoreFocus = true) {
|
|
1019
|
+
if (!this._open())
|
|
1020
|
+
return;
|
|
1021
|
+
this._open.set(false);
|
|
1022
|
+
if (restoreFocus)
|
|
1023
|
+
this.triggerRef()?.nativeElement.focus();
|
|
1024
|
+
}
|
|
1025
|
+
/** Mark every notification read and emit {@link markedAllRead}. */
|
|
1026
|
+
markAllRead() {
|
|
1027
|
+
if (this.unreadCount() === 0)
|
|
1028
|
+
return;
|
|
1029
|
+
this.notifications.update((list) => list.map((n) => (n.read ? n : { ...n, read: true })));
|
|
1030
|
+
this.markedAllRead.emit();
|
|
1031
|
+
}
|
|
1032
|
+
/** Handle a row click: mark it read (if needed) and emit {@link itemClick}. */
|
|
1033
|
+
onRowClick(item) {
|
|
1034
|
+
if (!item.read)
|
|
1035
|
+
this.markRead(item);
|
|
1036
|
+
this.itemClick.emit(item);
|
|
1037
|
+
}
|
|
1038
|
+
markRead(item) {
|
|
1039
|
+
let updated;
|
|
1040
|
+
this.notifications.update((list) => list.map((n) => {
|
|
1041
|
+
if (n !== item || n.read)
|
|
1042
|
+
return n;
|
|
1043
|
+
updated = { ...n, read: true };
|
|
1044
|
+
return updated;
|
|
1045
|
+
}));
|
|
1046
|
+
if (updated)
|
|
1047
|
+
this.read.emit(updated);
|
|
1048
|
+
}
|
|
1049
|
+
onEscape(event) {
|
|
1050
|
+
event.preventDefault();
|
|
1051
|
+
event.stopPropagation();
|
|
1052
|
+
this.close(true);
|
|
1053
|
+
}
|
|
1054
|
+
onPanelFocusout(event) {
|
|
1055
|
+
const related = event.relatedTarget;
|
|
1056
|
+
if (!related)
|
|
1057
|
+
return;
|
|
1058
|
+
const panel = this.panelRef()?.nativeElement;
|
|
1059
|
+
const trigger = this.triggerRef()?.nativeElement;
|
|
1060
|
+
if (panel?.contains(related) || trigger?.contains(related))
|
|
1061
|
+
return;
|
|
1062
|
+
this.close(false);
|
|
1063
|
+
}
|
|
1064
|
+
focusInitial() {
|
|
1065
|
+
const panel = this.panelRef()?.nativeElement;
|
|
1066
|
+
if (!panel)
|
|
1067
|
+
return;
|
|
1068
|
+
(mkGetFocusable(panel)[0] ?? panel).focus();
|
|
1069
|
+
}
|
|
1070
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.7", ngImport: i0, type: MkNotificationCenter, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
1071
|
+
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.0.7", type: MkNotificationCenter, isStandalone: true, selector: "mk-notification-center", inputs: { notifications: { classPropertyName: "notifications", publicName: "notifications", isSignal: true, isRequired: false, transformFunction: null }, panelTitle: { classPropertyName: "panelTitle", publicName: "panelTitle", isSignal: true, isRequired: false, transformFunction: null }, emptyText: { classPropertyName: "emptyText", publicName: "emptyText", isSignal: true, isRequired: false, transformFunction: null }, maxHeight: { classPropertyName: "maxHeight", publicName: "maxHeight", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { notifications: "notificationsChange", itemClick: "itemClick", read: "read", markedAllRead: "markedAllRead" }, host: { classAttribute: "mk-notification-center" }, viewQueries: [{ propertyName: "panelRef", first: true, predicate: ["panel"], descendants: true, isSignal: true }, { propertyName: "triggerRef", first: true, predicate: ["trigger"], descendants: true, isSignal: true }], ngImport: i0, template: "<button\n #trigger\n type=\"button\"\n class=\"mk-notification-center__trigger\"\n aria-haspopup=\"dialog\"\n [attr.aria-expanded]=\"open()\"\n [attr.aria-controls]=\"open() ? panelId : null\"\n [attr.aria-label]=\"triggerLabel()\"\n (click)=\"toggle()\"\n>\n <mk-icon name=\"bell\" />\n @if (unreadCount() > 0) {\n <mk-badge\n class=\"mk-notification-center__count\"\n tone=\"danger\"\n variant=\"solid\"\n size=\"sm\"\n aria-hidden=\"true\"\n >\n {{ badgeText() }}\n </mk-badge>\n }\n</button>\n\n@if (open()) {\n <div\n #panel\n class=\"mk-notification-center__panel\"\n role=\"dialog\"\n tabindex=\"-1\"\n [id]=\"panelId\"\n [attr.aria-labelledby]=\"titleId\"\n mkAnchoredPanel\n [mkAnchoredPanelFor]=\"trigger\"\n placement=\"bottom-end\"\n [gap]=\"8\"\n (dismiss)=\"close(false)\"\n (keydown.escape)=\"onEscape($event)\"\n (focusout)=\"onPanelFocusout($event)\"\n >\n <header class=\"mk-notification-center__header\">\n <h2 [id]=\"titleId\" class=\"mk-notification-center__title\">\n {{ panelTitle() }}\n </h2>\n <!--\n aria-disabled (not [disabled]) so the button stays focusable when it\n disables itself after activation; markAllRead() guards the no-op case.\n -->\n <button\n type=\"button\"\n class=\"mk-notification-center__mark-all\"\n [attr.aria-disabled]=\"unreadCount() === 0\"\n (click)=\"markAllRead()\"\n >\n {{ i18n.markAllRead }}\n </button>\n </header>\n\n @if (notifications().length > 0) {\n <ul\n class=\"mk-notification-center__list\"\n [style.max-height]=\"maxHeight()\"\n >\n @for (item of notifications(); track item.id) {\n <li>\n <button\n type=\"button\"\n class=\"mk-notification-center__item\"\n [class.mk-notification-center__item--unread]=\"!item.read\"\n (click)=\"onRowClick(item)\"\n >\n <span\n class=\"mk-notification-center__dot\"\n aria-hidden=\"true\"\n ></span>\n @if (!item.read) {\n <span class=\"mk-visually-hidden\">{{ i18n.unread }}</span>\n }\n <span class=\"mk-notification-center__body\">\n <span class=\"mk-notification-center__item-title\">\n {{ item.title }}\n </span>\n @if (item.body) {\n <span class=\"mk-notification-center__item-text\">\n {{ item.body }}\n </span>\n }\n @if (item.time) {\n <span class=\"mk-notification-center__item-time\">\n {{ item.time }}\n </span>\n }\n </span>\n </button>\n </li>\n }\n </ul>\n } @else {\n <mk-empty-state\n class=\"mk-notification-center__empty\"\n icon=\"bell\"\n size=\"sm\"\n [title]=\"emptyText()\"\n />\n }\n </div>\n}\n", styles: [":host{display:inline-block}.mk-notification-center__trigger{position:relative;display:inline-flex;align-items:center;justify-content:center;width:2.5rem;height:2.5rem;padding:0;color:var(--mk-text);background:transparent;border:var(--mk-border-width) solid transparent;border-radius:var(--mk-radius-md);cursor:pointer;font-family:var(--mk-font-sans);line-height:1}.mk-notification-center__trigger:hover{background:var(--mk-hover-overlay)}.mk-notification-center__trigger:focus-visible{outline:var(--mk-focus-ring-width) solid var(--mk-focus-ring);outline-offset:var(--mk-focus-ring-offset)}.mk-notification-center__count{position:absolute;top:.125rem;inset-inline-end:.125rem;pointer-events:none;transform:translate(35%,-35%)}.mk-notification-center__count:dir(rtl){transform:translate(-35%,-35%)}.mk-notification-center__panel{display:flex;flex-direction:column;width:min(22rem,100vw - var(--mk-space-4));color:var(--mk-text);background:var(--mk-surface);border:var(--mk-border-width) solid var(--mk-border);border-radius:var(--mk-radius-lg);box-shadow:var(--mk-shadow-lg);font-family:var(--mk-font-sans);font-size:var(--mk-font-size-md);overflow:hidden;animation:mk-notification-center-in var(--mk-duration-fast) var(--mk-ease-out)}.mk-notification-center__panel:focus-visible{outline:none}.mk-notification-center__header{display:flex;align-items:center;justify-content:space-between;gap:var(--mk-space-3);padding:var(--mk-space-3) var(--mk-space-4);border-bottom:var(--mk-border-width) solid var(--mk-border-subtle)}.mk-notification-center__title{margin:0;font-size:var(--mk-font-size-md);font-weight:var(--mk-font-weight-semibold);line-height:var(--mk-line-height-snug)}.mk-notification-center__mark-all{padding:0;color:var(--mk-primary);background:transparent;border:none;cursor:pointer;font:inherit;font-size:var(--mk-font-size-sm);white-space:nowrap}.mk-notification-center__mark-all:hover:not([aria-disabled=true]){text-decoration:underline}.mk-notification-center__mark-all:focus-visible{outline:var(--mk-focus-ring-width) solid var(--mk-focus-ring);outline-offset:var(--mk-focus-ring-offset);border-radius:var(--mk-radius-sm)}.mk-notification-center__mark-all[aria-disabled=true]{color:var(--mk-text-muted);cursor:default}.mk-notification-center__list{margin:0;padding:0;list-style:none;overflow-y:auto}.mk-notification-center__list li+li{border-top:var(--mk-border-width) solid var(--mk-border-subtle)}.mk-notification-center__item{display:flex;align-items:flex-start;gap:var(--mk-space-2);width:100%;padding:var(--mk-space-3) var(--mk-space-4);color:inherit;background:transparent;border:none;cursor:pointer;font:inherit;text-align:start}.mk-notification-center__item:hover{background:var(--mk-hover-overlay)}.mk-notification-center__item:focus-visible{outline:var(--mk-focus-ring-width) solid var(--mk-focus-ring);outline-offset:var(--mk-focus-ring-offset);outline-offset:calc(-1 * var(--mk-focus-ring-width))}.mk-notification-center__dot{flex:none;width:.5rem;height:.5rem;margin-top:.375rem;border-radius:var(--mk-radius-circle);background:transparent}.mk-notification-center__item--unread .mk-notification-center__dot{background:var(--mk-primary)}.mk-notification-center__body{display:flex;flex-direction:column;gap:var(--mk-space-1);min-width:0}.mk-notification-center__item-title{font-size:var(--mk-font-size-md);line-height:var(--mk-line-height-snug);color:var(--mk-text)}.mk-notification-center__item--unread .mk-notification-center__item-title{font-weight:var(--mk-font-weight-semibold)}.mk-notification-center__item-text{font-size:var(--mk-font-size-sm);line-height:var(--mk-line-height-normal);color:var(--mk-text-muted)}.mk-notification-center__item-time{font-size:var(--mk-font-size-xs);color:var(--mk-text-muted)}@keyframes mk-notification-center-in{0%{opacity:0;transform:translateY(calc(-1 * var(--mk-space-1))) scale(.98)}to{opacity:1;transform:none}}@media(prefers-reduced-motion:reduce){.mk-notification-center__panel{animation:none}}\n"], dependencies: [{ kind: "directive", type: MkAnchoredPanel, selector: "[mkAnchoredPanel]", inputs: ["mkAnchoredPanelFor", "anchorRect", "placement", "gap", "matchWidth", "flip", "clamp"], outputs: ["dismiss"], exportAs: ["mkAnchoredPanel"] }, { kind: "component", type: MkBadge, selector: "mk-badge", inputs: ["tone", "variant", "size", "dot"] }, { kind: "component", type: MkEmptyState, selector: "mk-empty-state", inputs: ["icon", "title", "description", "size"] }, { kind: "component", type: MkIcon, selector: "mk-icon", inputs: ["name", "size", "label"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
|
|
1072
|
+
}
|
|
1073
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.7", ngImport: i0, type: MkNotificationCenter, decorators: [{
|
|
1074
|
+
type: Component,
|
|
1075
|
+
args: [{ selector: 'mk-notification-center', changeDetection: ChangeDetectionStrategy.OnPush, imports: [MkAnchoredPanel, MkBadge, MkEmptyState, MkIcon], host: {
|
|
1076
|
+
class: 'mk-notification-center',
|
|
1077
|
+
}, template: "<button\n #trigger\n type=\"button\"\n class=\"mk-notification-center__trigger\"\n aria-haspopup=\"dialog\"\n [attr.aria-expanded]=\"open()\"\n [attr.aria-controls]=\"open() ? panelId : null\"\n [attr.aria-label]=\"triggerLabel()\"\n (click)=\"toggle()\"\n>\n <mk-icon name=\"bell\" />\n @if (unreadCount() > 0) {\n <mk-badge\n class=\"mk-notification-center__count\"\n tone=\"danger\"\n variant=\"solid\"\n size=\"sm\"\n aria-hidden=\"true\"\n >\n {{ badgeText() }}\n </mk-badge>\n }\n</button>\n\n@if (open()) {\n <div\n #panel\n class=\"mk-notification-center__panel\"\n role=\"dialog\"\n tabindex=\"-1\"\n [id]=\"panelId\"\n [attr.aria-labelledby]=\"titleId\"\n mkAnchoredPanel\n [mkAnchoredPanelFor]=\"trigger\"\n placement=\"bottom-end\"\n [gap]=\"8\"\n (dismiss)=\"close(false)\"\n (keydown.escape)=\"onEscape($event)\"\n (focusout)=\"onPanelFocusout($event)\"\n >\n <header class=\"mk-notification-center__header\">\n <h2 [id]=\"titleId\" class=\"mk-notification-center__title\">\n {{ panelTitle() }}\n </h2>\n <!--\n aria-disabled (not [disabled]) so the button stays focusable when it\n disables itself after activation; markAllRead() guards the no-op case.\n -->\n <button\n type=\"button\"\n class=\"mk-notification-center__mark-all\"\n [attr.aria-disabled]=\"unreadCount() === 0\"\n (click)=\"markAllRead()\"\n >\n {{ i18n.markAllRead }}\n </button>\n </header>\n\n @if (notifications().length > 0) {\n <ul\n class=\"mk-notification-center__list\"\n [style.max-height]=\"maxHeight()\"\n >\n @for (item of notifications(); track item.id) {\n <li>\n <button\n type=\"button\"\n class=\"mk-notification-center__item\"\n [class.mk-notification-center__item--unread]=\"!item.read\"\n (click)=\"onRowClick(item)\"\n >\n <span\n class=\"mk-notification-center__dot\"\n aria-hidden=\"true\"\n ></span>\n @if (!item.read) {\n <span class=\"mk-visually-hidden\">{{ i18n.unread }}</span>\n }\n <span class=\"mk-notification-center__body\">\n <span class=\"mk-notification-center__item-title\">\n {{ item.title }}\n </span>\n @if (item.body) {\n <span class=\"mk-notification-center__item-text\">\n {{ item.body }}\n </span>\n }\n @if (item.time) {\n <span class=\"mk-notification-center__item-time\">\n {{ item.time }}\n </span>\n }\n </span>\n </button>\n </li>\n }\n </ul>\n } @else {\n <mk-empty-state\n class=\"mk-notification-center__empty\"\n icon=\"bell\"\n size=\"sm\"\n [title]=\"emptyText()\"\n />\n }\n </div>\n}\n", styles: [":host{display:inline-block}.mk-notification-center__trigger{position:relative;display:inline-flex;align-items:center;justify-content:center;width:2.5rem;height:2.5rem;padding:0;color:var(--mk-text);background:transparent;border:var(--mk-border-width) solid transparent;border-radius:var(--mk-radius-md);cursor:pointer;font-family:var(--mk-font-sans);line-height:1}.mk-notification-center__trigger:hover{background:var(--mk-hover-overlay)}.mk-notification-center__trigger:focus-visible{outline:var(--mk-focus-ring-width) solid var(--mk-focus-ring);outline-offset:var(--mk-focus-ring-offset)}.mk-notification-center__count{position:absolute;top:.125rem;inset-inline-end:.125rem;pointer-events:none;transform:translate(35%,-35%)}.mk-notification-center__count:dir(rtl){transform:translate(-35%,-35%)}.mk-notification-center__panel{display:flex;flex-direction:column;width:min(22rem,100vw - var(--mk-space-4));color:var(--mk-text);background:var(--mk-surface);border:var(--mk-border-width) solid var(--mk-border);border-radius:var(--mk-radius-lg);box-shadow:var(--mk-shadow-lg);font-family:var(--mk-font-sans);font-size:var(--mk-font-size-md);overflow:hidden;animation:mk-notification-center-in var(--mk-duration-fast) var(--mk-ease-out)}.mk-notification-center__panel:focus-visible{outline:none}.mk-notification-center__header{display:flex;align-items:center;justify-content:space-between;gap:var(--mk-space-3);padding:var(--mk-space-3) var(--mk-space-4);border-bottom:var(--mk-border-width) solid var(--mk-border-subtle)}.mk-notification-center__title{margin:0;font-size:var(--mk-font-size-md);font-weight:var(--mk-font-weight-semibold);line-height:var(--mk-line-height-snug)}.mk-notification-center__mark-all{padding:0;color:var(--mk-primary);background:transparent;border:none;cursor:pointer;font:inherit;font-size:var(--mk-font-size-sm);white-space:nowrap}.mk-notification-center__mark-all:hover:not([aria-disabled=true]){text-decoration:underline}.mk-notification-center__mark-all:focus-visible{outline:var(--mk-focus-ring-width) solid var(--mk-focus-ring);outline-offset:var(--mk-focus-ring-offset);border-radius:var(--mk-radius-sm)}.mk-notification-center__mark-all[aria-disabled=true]{color:var(--mk-text-muted);cursor:default}.mk-notification-center__list{margin:0;padding:0;list-style:none;overflow-y:auto}.mk-notification-center__list li+li{border-top:var(--mk-border-width) solid var(--mk-border-subtle)}.mk-notification-center__item{display:flex;align-items:flex-start;gap:var(--mk-space-2);width:100%;padding:var(--mk-space-3) var(--mk-space-4);color:inherit;background:transparent;border:none;cursor:pointer;font:inherit;text-align:start}.mk-notification-center__item:hover{background:var(--mk-hover-overlay)}.mk-notification-center__item:focus-visible{outline:var(--mk-focus-ring-width) solid var(--mk-focus-ring);outline-offset:var(--mk-focus-ring-offset);outline-offset:calc(-1 * var(--mk-focus-ring-width))}.mk-notification-center__dot{flex:none;width:.5rem;height:.5rem;margin-top:.375rem;border-radius:var(--mk-radius-circle);background:transparent}.mk-notification-center__item--unread .mk-notification-center__dot{background:var(--mk-primary)}.mk-notification-center__body{display:flex;flex-direction:column;gap:var(--mk-space-1);min-width:0}.mk-notification-center__item-title{font-size:var(--mk-font-size-md);line-height:var(--mk-line-height-snug);color:var(--mk-text)}.mk-notification-center__item--unread .mk-notification-center__item-title{font-weight:var(--mk-font-weight-semibold)}.mk-notification-center__item-text{font-size:var(--mk-font-size-sm);line-height:var(--mk-line-height-normal);color:var(--mk-text-muted)}.mk-notification-center__item-time{font-size:var(--mk-font-size-xs);color:var(--mk-text-muted)}@keyframes mk-notification-center-in{0%{opacity:0;transform:translateY(calc(-1 * var(--mk-space-1))) scale(.98)}to{opacity:1;transform:none}}@media(prefers-reduced-motion:reduce){.mk-notification-center__panel{animation:none}}\n"] }]
|
|
1078
|
+
}], propDecorators: { panelRef: [{ type: i0.ViewChild, args: ['panel', { isSignal: true }] }], triggerRef: [{ type: i0.ViewChild, args: ['trigger', { isSignal: true }] }], notifications: [{ type: i0.Input, args: [{ isSignal: true, alias: "notifications", required: false }] }, { type: i0.Output, args: ["notificationsChange"] }], panelTitle: [{ type: i0.Input, args: [{ isSignal: true, alias: "panelTitle", required: false }] }], emptyText: [{ type: i0.Input, args: [{ isSignal: true, alias: "emptyText", required: false }] }], maxHeight: [{ type: i0.Input, args: [{ isSignal: true, alias: "maxHeight", required: false }] }], itemClick: [{ type: i0.Output, args: ["itemClick"] }], read: [{ type: i0.Output, args: ["read"] }], markedAllRead: [{ type: i0.Output, args: ["markedAllRead"] }] } });
|
|
1079
|
+
|
|
1080
|
+
/**
|
|
1081
|
+
* Accessible heading for a dialog. Renders as a level-2 heading and exposes a
|
|
1082
|
+
* stable id that {@link MkDialog} wires to the panel's `aria-labelledby`.
|
|
1083
|
+
*
|
|
1084
|
+
* ```html
|
|
1085
|
+
* <mk-dialog>
|
|
1086
|
+
* <mk-dialog-title>Delete workspace</mk-dialog-title>
|
|
1087
|
+
* …
|
|
1088
|
+
* </mk-dialog>
|
|
1089
|
+
* ```
|
|
1090
|
+
*/
|
|
1091
|
+
class MkDialogTitle {
|
|
1092
|
+
/** Stable id used for `aria-labelledby` wiring. */
|
|
1093
|
+
id = input(mkUniqueId('mk-dialog-title'), /* @ts-ignore */
|
|
1094
|
+
...(ngDevMode ? [{ debugName: "id" }] : /* istanbul ignore next */ []));
|
|
1095
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.7", ngImport: i0, type: MkDialogTitle, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
1096
|
+
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.1.0", version: "22.0.7", type: MkDialogTitle, isStandalone: true, selector: "mk-dialog-title", inputs: { id: { classPropertyName: "id", publicName: "id", isSignal: true, isRequired: false, transformFunction: null } }, host: { attributes: { "role": "heading", "aria-level": "2" }, properties: { "attr.id": "id()" }, classAttribute: "mk-dialog__title" }, ngImport: i0, template: '<ng-content />', isInline: true, changeDetection: i0.ChangeDetectionStrategy.OnPush });
|
|
1097
|
+
}
|
|
1098
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.7", ngImport: i0, type: MkDialogTitle, decorators: [{
|
|
1099
|
+
type: Component,
|
|
1100
|
+
args: [{
|
|
1101
|
+
selector: 'mk-dialog-title',
|
|
1102
|
+
template: '<ng-content />',
|
|
1103
|
+
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
1104
|
+
host: {
|
|
1105
|
+
class: 'mk-dialog__title',
|
|
1106
|
+
role: 'heading',
|
|
1107
|
+
'aria-level': '2',
|
|
1108
|
+
'[attr.id]': 'id()',
|
|
1109
|
+
},
|
|
1110
|
+
}]
|
|
1111
|
+
}], propDecorators: { id: [{ type: i0.Input, args: [{ isSignal: true, alias: "id", required: false }] }] } });
|
|
1112
|
+
/**
|
|
1113
|
+
* Dialog layout — a surface with a sticky header, scrollable body, and sticky
|
|
1114
|
+
* footer, designed to be rendered inside an {@link MkOverlayService} panel. The
|
|
1115
|
+
* header exposes a close button wired to the injected {@link MkOverlayRef} and
|
|
1116
|
+
* the dialog's title is linked to the panel via `aria-labelledby`.
|
|
1117
|
+
*
|
|
1118
|
+
* Slots: `[mkDialogHeader]` / `<mk-dialog-title>` (header), default content
|
|
1119
|
+
* (body), `[mkDialogFooter]` (footer).
|
|
1120
|
+
*
|
|
1121
|
+
* ```html
|
|
1122
|
+
* <mk-dialog>
|
|
1123
|
+
* <mk-dialog-title>Rename</mk-dialog-title>
|
|
1124
|
+
* <p>Choose a new name.</p>
|
|
1125
|
+
* <div mkDialogFooter>
|
|
1126
|
+
* <button mkButton variant="ghost">Cancel</button>
|
|
1127
|
+
* <button mkButton>Save</button>
|
|
1128
|
+
* </div>
|
|
1129
|
+
* </mk-dialog>
|
|
1130
|
+
* ```
|
|
1131
|
+
*/
|
|
1132
|
+
class MkDialog {
|
|
1133
|
+
host = inject(ElementRef);
|
|
1134
|
+
/** Localised strings (override globally via `provideMkI18n`). */
|
|
1135
|
+
i18n = inject(MK_I18N);
|
|
1136
|
+
overlayRef = inject(MkOverlayRef, { optional: true });
|
|
1137
|
+
/** Optional plain-text title. Ignored when a `<mk-dialog-title>` is projected. */
|
|
1138
|
+
dialogTitle = input(/* @ts-ignore */
|
|
1139
|
+
...(ngDevMode ? [undefined, { debugName: "dialogTitle" }] : /* istanbul ignore next */ []));
|
|
1140
|
+
/** Stable id for the built-in title element. */
|
|
1141
|
+
titleId = input(mkUniqueId('mk-dialog-title'), /* @ts-ignore */
|
|
1142
|
+
...(ngDevMode ? [{ debugName: "titleId" }] : /* istanbul ignore next */ []));
|
|
1143
|
+
/** Hide the header close button. */
|
|
1144
|
+
hideClose = input(false, { ...(ngDevMode ? { debugName: "hideClose" } : /* istanbul ignore next */ {}), transform: booleanAttribute });
|
|
1145
|
+
constructor() {
|
|
1146
|
+
afterNextRender(() => this.wireLabelledBy());
|
|
1147
|
+
}
|
|
1148
|
+
/** Close the surrounding overlay, if any. */
|
|
1149
|
+
close() {
|
|
1150
|
+
this.overlayRef?.close();
|
|
1151
|
+
}
|
|
1152
|
+
/** Point the owning dialog panel's `aria-labelledby` at the title element. */
|
|
1153
|
+
wireLabelledBy() {
|
|
1154
|
+
const el = this.host.nativeElement;
|
|
1155
|
+
const panel = el.closest('[role="dialog"], [role="alertdialog"]');
|
|
1156
|
+
if (!panel)
|
|
1157
|
+
return;
|
|
1158
|
+
const titleEl = el.querySelector('mk-dialog-title, .mk-dialog__title');
|
|
1159
|
+
if (!titleEl)
|
|
1160
|
+
return;
|
|
1161
|
+
if (!titleEl.id)
|
|
1162
|
+
titleEl.id = this.titleId();
|
|
1163
|
+
panel.setAttribute('aria-labelledby', titleEl.id);
|
|
1164
|
+
}
|
|
1165
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.7", ngImport: i0, type: MkDialog, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
1166
|
+
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.0.7", type: MkDialog, isStandalone: true, selector: "mk-dialog", inputs: { dialogTitle: { classPropertyName: "dialogTitle", publicName: "dialogTitle", isSignal: true, isRequired: false, transformFunction: null }, titleId: { classPropertyName: "titleId", publicName: "titleId", isSignal: true, isRequired: false, transformFunction: null }, hideClose: { classPropertyName: "hideClose", publicName: "hideClose", isSignal: true, isRequired: false, transformFunction: null } }, host: { classAttribute: "mk-dialog" }, ngImport: i0, template: "<header class=\"mk-dialog__header\">\n <div class=\"mk-dialog__heading\">\n <ng-content select=\"[mkDialogHeader], mk-dialog-title\" />\n @if (dialogTitle(); as heading) {\n <h2 class=\"mk-dialog__title\" [id]=\"titleId()\">{{ heading }}</h2>\n }\n </div>\n @if (!hideClose()) {\n <button\n type=\"button\"\n class=\"mk-dialog__close\"\n [attr.aria-label]=\"i18n.close\"\n (click)=\"close()\"\n >\n <svg viewBox=\"0 0 16 16\" width=\"16\" height=\"16\" aria-hidden=\"true\" focusable=\"false\">\n <path\n d=\"M4 4l8 8M12 4l-8 8\"\n fill=\"none\"\n stroke=\"currentColor\"\n stroke-width=\"1.5\"\n stroke-linecap=\"round\"\n />\n </svg>\n </button>\n }\n</header>\n\n<div class=\"mk-dialog__body\">\n <ng-content />\n</div>\n\n<footer class=\"mk-dialog__footer\">\n <ng-content select=\"[mkDialogFooter]\" />\n</footer>\n", styles: ["@charset \"UTF-8\";.mk-dialog{display:flex;flex-direction:column;min-height:0;max-height:inherit;flex:1 1 auto;font-family:var(--mk-font-sans)}.mk-dialog__header{position:sticky;top:0;z-index:1;display:flex;align-items:flex-start;gap:var(--mk-space-3);flex-shrink:0;padding:var(--mk-space-4) var(--mk-space-5);border-bottom:var(--mk-border-width) solid var(--mk-border-subtle);background-color:var(--mk-surface)}.mk-dialog__heading{flex:1;min-width:0;display:flex;flex-direction:column;gap:var(--mk-space-1)}.mk-dialog__title{margin:0;min-width:0;overflow-wrap:anywhere;font-size:var(--mk-font-size-lg);font-weight:var(--mk-font-weight-semibold);line-height:var(--mk-line-height-snug);color:var(--mk-text)}.mk-dialog__body{flex:1 1 auto;min-height:0;overflow-y:auto;overscroll-behavior:contain;padding:var(--mk-space-5);font-size:var(--mk-font-size-md);line-height:var(--mk-line-height-normal);color:var(--mk-text)}.mk-dialog__footer{position:sticky;bottom:0;flex-shrink:0;display:flex;align-items:center;justify-content:flex-end;gap:var(--mk-space-2);padding:var(--mk-space-4) var(--mk-space-5);border-top:var(--mk-border-width) solid var(--mk-border-subtle);background-color:var(--mk-surface)}.mk-dialog__footer:empty{display:none}.mk-dialog__footer>*{display:flex;flex:1 1 auto;align-items:center;justify-content:flex-end;gap:var(--mk-space-2);flex-wrap:wrap}.mk-dialog__footer>[data-align=start]{justify-content:flex-start}.mk-dialog__footer>[data-align=center]{justify-content:center}.mk-dialog__footer>[data-align=between]{justify-content:space-between}.mk-dialog__close{flex-shrink:0;display:inline-flex;align-items:center;justify-content:center;width:2rem;height:2rem;margin:-4px -4px 0 0;padding:0;background:transparent;border:0;border-radius:var(--mk-radius-sm);color:var(--mk-text-muted);cursor:pointer;transition:color var(--mk-duration-fast) var(--mk-ease-standard),background-color var(--mk-duration-fast) var(--mk-ease-standard)}.mk-dialog__close:hover{color:var(--mk-text);background-color:var(--mk-hover-overlay)}.mk-dialog__close:focus-visible{color:var(--mk-text);outline:var(--mk-focus-ring-width) solid var(--mk-focus-ring);outline-offset:var(--mk-focus-ring-offset)}\n"], changeDetection: i0.ChangeDetectionStrategy.OnPush, encapsulation: i0.ViewEncapsulation.None });
|
|
1167
|
+
}
|
|
1168
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.7", ngImport: i0, type: MkDialog, decorators: [{
|
|
1169
|
+
type: Component,
|
|
1170
|
+
args: [{ selector: 'mk-dialog', changeDetection: ChangeDetectionStrategy.OnPush, encapsulation: ViewEncapsulation.None, host: { class: 'mk-dialog' }, template: "<header class=\"mk-dialog__header\">\n <div class=\"mk-dialog__heading\">\n <ng-content select=\"[mkDialogHeader], mk-dialog-title\" />\n @if (dialogTitle(); as heading) {\n <h2 class=\"mk-dialog__title\" [id]=\"titleId()\">{{ heading }}</h2>\n }\n </div>\n @if (!hideClose()) {\n <button\n type=\"button\"\n class=\"mk-dialog__close\"\n [attr.aria-label]=\"i18n.close\"\n (click)=\"close()\"\n >\n <svg viewBox=\"0 0 16 16\" width=\"16\" height=\"16\" aria-hidden=\"true\" focusable=\"false\">\n <path\n d=\"M4 4l8 8M12 4l-8 8\"\n fill=\"none\"\n stroke=\"currentColor\"\n stroke-width=\"1.5\"\n stroke-linecap=\"round\"\n />\n </svg>\n </button>\n }\n</header>\n\n<div class=\"mk-dialog__body\">\n <ng-content />\n</div>\n\n<footer class=\"mk-dialog__footer\">\n <ng-content select=\"[mkDialogFooter]\" />\n</footer>\n", styles: ["@charset \"UTF-8\";.mk-dialog{display:flex;flex-direction:column;min-height:0;max-height:inherit;flex:1 1 auto;font-family:var(--mk-font-sans)}.mk-dialog__header{position:sticky;top:0;z-index:1;display:flex;align-items:flex-start;gap:var(--mk-space-3);flex-shrink:0;padding:var(--mk-space-4) var(--mk-space-5);border-bottom:var(--mk-border-width) solid var(--mk-border-subtle);background-color:var(--mk-surface)}.mk-dialog__heading{flex:1;min-width:0;display:flex;flex-direction:column;gap:var(--mk-space-1)}.mk-dialog__title{margin:0;min-width:0;overflow-wrap:anywhere;font-size:var(--mk-font-size-lg);font-weight:var(--mk-font-weight-semibold);line-height:var(--mk-line-height-snug);color:var(--mk-text)}.mk-dialog__body{flex:1 1 auto;min-height:0;overflow-y:auto;overscroll-behavior:contain;padding:var(--mk-space-5);font-size:var(--mk-font-size-md);line-height:var(--mk-line-height-normal);color:var(--mk-text)}.mk-dialog__footer{position:sticky;bottom:0;flex-shrink:0;display:flex;align-items:center;justify-content:flex-end;gap:var(--mk-space-2);padding:var(--mk-space-4) var(--mk-space-5);border-top:var(--mk-border-width) solid var(--mk-border-subtle);background-color:var(--mk-surface)}.mk-dialog__footer:empty{display:none}.mk-dialog__footer>*{display:flex;flex:1 1 auto;align-items:center;justify-content:flex-end;gap:var(--mk-space-2);flex-wrap:wrap}.mk-dialog__footer>[data-align=start]{justify-content:flex-start}.mk-dialog__footer>[data-align=center]{justify-content:center}.mk-dialog__footer>[data-align=between]{justify-content:space-between}.mk-dialog__close{flex-shrink:0;display:inline-flex;align-items:center;justify-content:center;width:2rem;height:2rem;margin:-4px -4px 0 0;padding:0;background:transparent;border:0;border-radius:var(--mk-radius-sm);color:var(--mk-text-muted);cursor:pointer;transition:color var(--mk-duration-fast) var(--mk-ease-standard),background-color var(--mk-duration-fast) var(--mk-ease-standard)}.mk-dialog__close:hover{color:var(--mk-text);background-color:var(--mk-hover-overlay)}.mk-dialog__close:focus-visible{color:var(--mk-text);outline:var(--mk-focus-ring-width) solid var(--mk-focus-ring);outline-offset:var(--mk-focus-ring-offset)}\n"] }]
|
|
1171
|
+
}], ctorParameters: () => [], propDecorators: { dialogTitle: [{ type: i0.Input, args: [{ isSignal: true, alias: "dialogTitle", required: false }] }], titleId: [{ type: i0.Input, args: [{ isSignal: true, alias: "titleId", required: false }] }], hideClose: [{ type: i0.Input, args: [{ isSignal: true, alias: "hideClose", required: false }] }] } });
|
|
1172
|
+
|
|
1173
|
+
/**
|
|
1174
|
+
* Internal component backing `MkDialogService.confirm`. Renders a title,
|
|
1175
|
+
* message, and cancel/confirm buttons. Resolves the overlay ref with `true`
|
|
1176
|
+
* on confirm and `false` on cancel. Uses `alertdialog` semantics for the
|
|
1177
|
+
* `danger` tone (set on the panel by the service).
|
|
1178
|
+
*/
|
|
1179
|
+
class MkConfirmDialog {
|
|
1180
|
+
ref = inject(MkOverlayRef);
|
|
1181
|
+
data = inject(MK_OVERLAY_DATA);
|
|
1182
|
+
i18n = inject(MK_I18N);
|
|
1183
|
+
confirmTone = computed(() => this.data.tone ?? 'primary', /* @ts-ignore */
|
|
1184
|
+
...(ngDevMode ? [{ debugName: "confirmTone" }] : /* istanbul ignore next */ []));
|
|
1185
|
+
confirmText = computed(() => this.data.confirmText ?? this.i18n.confirm, /* @ts-ignore */
|
|
1186
|
+
...(ngDevMode ? [{ debugName: "confirmText" }] : /* istanbul ignore next */ []));
|
|
1187
|
+
cancelText = computed(() => this.data.cancelText ?? this.i18n.cancel, /* @ts-ignore */
|
|
1188
|
+
...(ngDevMode ? [{ debugName: "cancelText" }] : /* istanbul ignore next */ []));
|
|
1189
|
+
confirm() {
|
|
1190
|
+
this.ref.close(true);
|
|
1191
|
+
}
|
|
1192
|
+
cancel() {
|
|
1193
|
+
this.ref.close(false);
|
|
1194
|
+
}
|
|
1195
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.7", ngImport: i0, type: MkConfirmDialog, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
1196
|
+
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.0.7", type: MkConfirmDialog, isStandalone: true, selector: "mk-confirm-dialog", ngImport: i0, template: "<mk-dialog [dialogTitle]=\"data.title\" hideClose>\n <div class=\"mk-confirm-dialog__body\">\n @if (data.icon; as icon) {\n <mk-icon\n class=\"mk-confirm-dialog__icon\"\n [class.mk-confirm-dialog__icon--danger]=\"data.tone === 'danger'\"\n [name]=\"icon\"\n [size]=\"22\"\n />\n }\n <p class=\"mk-confirm-dialog__message\">{{ data.message }}</p>\n </div>\n\n <div mkDialogFooter>\n @if (!data.hideCancel) {\n <button mkButton variant=\"ghost\" tone=\"neutral\" (click)=\"cancel()\">\n {{ cancelText() }}\n </button>\n }\n <button mkButton [tone]=\"confirmTone()\" (click)=\"confirm()\">\n {{ confirmText() }}\n </button>\n </div>\n</mk-dialog>\n", styles: [".mk-confirm-dialog__message{margin:0;color:var(--mk-text-muted);font-size:var(--mk-font-size-md);line-height:var(--mk-line-height-relaxed)}.mk-confirm-dialog__body{display:flex;align-items:flex-start;gap:var(--mk-space-3)}.mk-confirm-dialog__icon{flex-shrink:0;margin-top:.125rem;color:var(--mk-text-muted)}.mk-confirm-dialog__icon--danger{color:var(--mk-danger-text)}\n"], dependencies: [{ kind: "component", type: MkDialog, selector: "mk-dialog", inputs: ["dialogTitle", "titleId", "hideClose"] }, { kind: "component", type: MkButton, selector: "button[mkButton], a[mkButton]", inputs: ["variant", "tone", "size", "loading", "fullWidth", "iconOnly", "disabled"] }, { kind: "component", type: MkIcon, selector: "mk-icon", inputs: ["name", "size", "label"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
|
|
1197
|
+
}
|
|
1198
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.7", ngImport: i0, type: MkConfirmDialog, decorators: [{
|
|
1199
|
+
type: Component,
|
|
1200
|
+
args: [{ selector: 'mk-confirm-dialog', imports: [MkDialog, MkButton, MkIcon], changeDetection: ChangeDetectionStrategy.OnPush, template: "<mk-dialog [dialogTitle]=\"data.title\" hideClose>\n <div class=\"mk-confirm-dialog__body\">\n @if (data.icon; as icon) {\n <mk-icon\n class=\"mk-confirm-dialog__icon\"\n [class.mk-confirm-dialog__icon--danger]=\"data.tone === 'danger'\"\n [name]=\"icon\"\n [size]=\"22\"\n />\n }\n <p class=\"mk-confirm-dialog__message\">{{ data.message }}</p>\n </div>\n\n <div mkDialogFooter>\n @if (!data.hideCancel) {\n <button mkButton variant=\"ghost\" tone=\"neutral\" (click)=\"cancel()\">\n {{ cancelText() }}\n </button>\n }\n <button mkButton [tone]=\"confirmTone()\" (click)=\"confirm()\">\n {{ confirmText() }}\n </button>\n </div>\n</mk-dialog>\n", styles: [".mk-confirm-dialog__message{margin:0;color:var(--mk-text-muted);font-size:var(--mk-font-size-md);line-height:var(--mk-line-height-relaxed)}.mk-confirm-dialog__body{display:flex;align-items:flex-start;gap:var(--mk-space-3)}.mk-confirm-dialog__icon{flex-shrink:0;margin-top:.125rem;color:var(--mk-text-muted)}.mk-confirm-dialog__icon--danger{color:var(--mk-danger-text)}\n"] }]
|
|
1201
|
+
}] });
|
|
1202
|
+
|
|
1203
|
+
/**
|
|
1204
|
+
* Internal component backing `MkDialogService.prompt`. Renders a single text
|
|
1205
|
+
* field and resolves the overlay ref with the entered string on confirm, or
|
|
1206
|
+
* `null` on cancel / Escape / backdrop. Submitting the form (Enter) confirms.
|
|
1207
|
+
*/
|
|
1208
|
+
class MkPromptDialog {
|
|
1209
|
+
ref = inject(MkOverlayRef);
|
|
1210
|
+
data = inject(MK_OVERLAY_DATA);
|
|
1211
|
+
i18n = inject(MK_I18N);
|
|
1212
|
+
inputRef = viewChild('input', /* @ts-ignore */
|
|
1213
|
+
...(ngDevMode ? [{ debugName: "inputRef" }] : /* istanbul ignore next */ []));
|
|
1214
|
+
value = signal(this.data.value ?? '', /* @ts-ignore */
|
|
1215
|
+
...(ngDevMode ? [{ debugName: "value" }] : /* istanbul ignore next */ []));
|
|
1216
|
+
confirmText = computed(() => this.data.confirmText ?? this.i18n.ok, /* @ts-ignore */
|
|
1217
|
+
...(ngDevMode ? [{ debugName: "confirmText" }] : /* istanbul ignore next */ []));
|
|
1218
|
+
cancelText = computed(() => this.data.cancelText ?? this.i18n.cancel, /* @ts-ignore */
|
|
1219
|
+
...(ngDevMode ? [{ debugName: "cancelText" }] : /* istanbul ignore next */ []));
|
|
1220
|
+
canConfirm = computed(() => !this.data.required || this.value().trim().length > 0, /* @ts-ignore */
|
|
1221
|
+
...(ngDevMode ? [{ debugName: "canConfirm" }] : /* istanbul ignore next */ []));
|
|
1222
|
+
constructor() {
|
|
1223
|
+
afterNextRender(() => this.inputRef()?.nativeElement.focus());
|
|
1224
|
+
}
|
|
1225
|
+
onInput(event) {
|
|
1226
|
+
this.value.set(event.target.value);
|
|
1227
|
+
}
|
|
1228
|
+
submit() {
|
|
1229
|
+
if (this.canConfirm())
|
|
1230
|
+
this.ref.close(this.value());
|
|
1231
|
+
}
|
|
1232
|
+
cancel() {
|
|
1233
|
+
this.ref.close(null);
|
|
1234
|
+
}
|
|
1235
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.7", ngImport: i0, type: MkPromptDialog, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
1236
|
+
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.0.7", type: MkPromptDialog, isStandalone: true, selector: "mk-prompt-dialog", viewQueries: [{ propertyName: "inputRef", first: true, predicate: ["input"], descendants: true, isSignal: true }], ngImport: i0, template: "<mk-dialog [dialogTitle]=\"data.title\" hideClose>\n @if (data.message) {\n <p class=\"mk-confirm-dialog__message\">{{ data.message }}</p>\n }\n\n <mk-form-field [label]=\"data.label ?? ''\">\n <input\n #input\n mkInput\n [attr.type]=\"data.inputType ?? 'text'\"\n [value]=\"value()\"\n [attr.placeholder]=\"data.placeholder ?? null\"\n [attr.required]=\"data.required ? '' : null\"\n (input)=\"onInput($event)\"\n (keydown.enter)=\"submit()\"\n />\n </mk-form-field>\n\n <div mkDialogFooter>\n <button mkButton variant=\"ghost\" tone=\"neutral\" (click)=\"cancel()\">\n {{ cancelText() }}\n </button>\n <button mkButton (click)=\"submit()\" [disabled]=\"!canConfirm()\">\n {{ confirmText() }}\n </button>\n </div>\n</mk-dialog>\n", styles: [".mk-confirm-dialog__message{margin:0;color:var(--mk-text-muted);font-size:var(--mk-font-size-md);line-height:var(--mk-line-height-relaxed)}.mk-confirm-dialog__body{display:flex;align-items:flex-start;gap:var(--mk-space-3)}.mk-confirm-dialog__icon{flex-shrink:0;margin-top:.125rem;color:var(--mk-text-muted)}.mk-confirm-dialog__icon--danger{color:var(--mk-danger-text)}\n"], dependencies: [{ kind: "component", type: MkDialog, selector: "mk-dialog", inputs: ["dialogTitle", "titleId", "hideClose"] }, { kind: "component", type: MkButton, selector: "button[mkButton], a[mkButton]", inputs: ["variant", "tone", "size", "loading", "fullWidth", "iconOnly", "disabled"] }, { kind: "component", type: MkInput, selector: "input[mkInput], textarea[mkInput]", inputs: ["size", "invalid"] }, { kind: "component", type: MkFormField, selector: "mk-form-field", inputs: ["label", "hint", "error", "errorMessages", "errorOn", "required", "disabled", "size"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
|
|
1237
|
+
}
|
|
1238
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.7", ngImport: i0, type: MkPromptDialog, decorators: [{
|
|
1239
|
+
type: Component,
|
|
1240
|
+
args: [{ selector: 'mk-prompt-dialog', imports: [MkDialog, MkButton, MkInput, MkFormField], changeDetection: ChangeDetectionStrategy.OnPush, template: "<mk-dialog [dialogTitle]=\"data.title\" hideClose>\n @if (data.message) {\n <p class=\"mk-confirm-dialog__message\">{{ data.message }}</p>\n }\n\n <mk-form-field [label]=\"data.label ?? ''\">\n <input\n #input\n mkInput\n [attr.type]=\"data.inputType ?? 'text'\"\n [value]=\"value()\"\n [attr.placeholder]=\"data.placeholder ?? null\"\n [attr.required]=\"data.required ? '' : null\"\n (input)=\"onInput($event)\"\n (keydown.enter)=\"submit()\"\n />\n </mk-form-field>\n\n <div mkDialogFooter>\n <button mkButton variant=\"ghost\" tone=\"neutral\" (click)=\"cancel()\">\n {{ cancelText() }}\n </button>\n <button mkButton (click)=\"submit()\" [disabled]=\"!canConfirm()\">\n {{ confirmText() }}\n </button>\n </div>\n</mk-dialog>\n", styles: [".mk-confirm-dialog__message{margin:0;color:var(--mk-text-muted);font-size:var(--mk-font-size-md);line-height:var(--mk-line-height-relaxed)}.mk-confirm-dialog__body{display:flex;align-items:flex-start;gap:var(--mk-space-3)}.mk-confirm-dialog__icon{flex-shrink:0;margin-top:.125rem;color:var(--mk-text-muted)}.mk-confirm-dialog__icon--danger{color:var(--mk-danger-text)}\n"] }]
|
|
1241
|
+
}], ctorParameters: () => [], propDecorators: { inputRef: [{ type: i0.ViewChild, args: ['input', { isSignal: true }] }] } });
|
|
1242
|
+
|
|
1243
|
+
const DIALOG_PANEL_CLASS = 'mk-dialog-panel';
|
|
1244
|
+
/**
|
|
1245
|
+
* Dialog service — a thin, opinionated layer over {@link MkOverlayService} that
|
|
1246
|
+
* renders content on a themed `--mk-surface` panel (shadow, radius, entrance
|
|
1247
|
+
* animation) and adds a `confirm()` convenience.
|
|
1248
|
+
*
|
|
1249
|
+
* ```ts
|
|
1250
|
+
* const ref = dialog.open(EditUserDialog, { data: user });
|
|
1251
|
+
* const saved = await ref.afterClosed;
|
|
1252
|
+
*
|
|
1253
|
+
* if (await dialog.confirm({ title: 'Delete?', message: 'This cannot be undone.', tone: 'danger' })) {
|
|
1254
|
+
* remove();
|
|
1255
|
+
* }
|
|
1256
|
+
* ```
|
|
1257
|
+
*/
|
|
1258
|
+
class MkDialogService {
|
|
1259
|
+
overlay = inject(MkOverlayService);
|
|
1260
|
+
i18n = inject(MK_I18N);
|
|
1261
|
+
/** Open `component` inside a themed dialog panel. */
|
|
1262
|
+
open(component, config = {}) {
|
|
1263
|
+
const sizeClass = `mk-dialog-panel--${config.size ?? 'sm'}`;
|
|
1264
|
+
return this.overlay.open(component, {
|
|
1265
|
+
...config,
|
|
1266
|
+
role: config.role ?? 'dialog',
|
|
1267
|
+
panelClass: this.mergePanelClass(config.panelClass, sizeClass),
|
|
1268
|
+
});
|
|
1269
|
+
}
|
|
1270
|
+
/**
|
|
1271
|
+
* Open a confirmation dialog. Resolves `true` if confirmed, `false` if the
|
|
1272
|
+
* user cancels, presses Escape, or clicks the backdrop.
|
|
1273
|
+
*/
|
|
1274
|
+
confirm(data) {
|
|
1275
|
+
const ref = this.open(MkConfirmDialog, {
|
|
1276
|
+
data,
|
|
1277
|
+
role: data.tone === 'danger' ? 'alertdialog' : 'dialog',
|
|
1278
|
+
ariaLabel: data.title,
|
|
1279
|
+
});
|
|
1280
|
+
return ref.afterClosed.then((result) => result === true);
|
|
1281
|
+
}
|
|
1282
|
+
/**
|
|
1283
|
+
* Open a single-button acknowledgement dialog. Resolves when the user
|
|
1284
|
+
* dismisses it (button, Escape, or backdrop).
|
|
1285
|
+
*/
|
|
1286
|
+
alert(data) {
|
|
1287
|
+
const ref = this.open(MkConfirmDialog, {
|
|
1288
|
+
data: {
|
|
1289
|
+
...data,
|
|
1290
|
+
hideCancel: true,
|
|
1291
|
+
confirmText: data.confirmText ?? this.i18n.ok,
|
|
1292
|
+
},
|
|
1293
|
+
role: data.tone === 'danger' ? 'alertdialog' : 'dialog',
|
|
1294
|
+
ariaLabel: data.title,
|
|
1295
|
+
});
|
|
1296
|
+
return ref.afterClosed.then(() => undefined);
|
|
1297
|
+
}
|
|
1298
|
+
/**
|
|
1299
|
+
* Open a single-field prompt dialog. Resolves with the entered string, or
|
|
1300
|
+
* `null` if the user cancels, presses Escape, or clicks the backdrop.
|
|
1301
|
+
*/
|
|
1302
|
+
prompt(data) {
|
|
1303
|
+
const ref = this.open(MkPromptDialog, { data, ariaLabel: data.title });
|
|
1304
|
+
return ref.afterClosed.then((result) => result ?? null);
|
|
1305
|
+
}
|
|
1306
|
+
mergePanelClass(panelClass, sizeClass) {
|
|
1307
|
+
const base = [DIALOG_PANEL_CLASS, sizeClass];
|
|
1308
|
+
if (!panelClass)
|
|
1309
|
+
return base;
|
|
1310
|
+
const extra = Array.isArray(panelClass) ? panelClass : [panelClass];
|
|
1311
|
+
return [...base, ...extra];
|
|
1312
|
+
}
|
|
1313
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.7", ngImport: i0, type: MkDialogService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
|
|
1314
|
+
static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "22.0.7", ngImport: i0, type: MkDialogService, providedIn: 'root' });
|
|
1315
|
+
}
|
|
1316
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.7", ngImport: i0, type: MkDialogService, decorators: [{
|
|
1317
|
+
type: Injectable,
|
|
1318
|
+
args: [{ providedIn: 'root' }]
|
|
1319
|
+
}] });
|
|
1320
|
+
|
|
1321
|
+
/**
|
|
1322
|
+
* A single toast card. Internal to the toast system — rendered by
|
|
1323
|
+
* {@link MkToastContainer}. Displays a tone accent, optional title, message,
|
|
1324
|
+
* optional action button, and (when dismissible) a close button. Auto-dismiss
|
|
1325
|
+
* pauses while the toast is hovered or focused.
|
|
1326
|
+
*/
|
|
1327
|
+
class MkToast {
|
|
1328
|
+
service = inject(MkToastService);
|
|
1329
|
+
i18n = inject(MK_I18N);
|
|
1330
|
+
/** The toast model to render. */
|
|
1331
|
+
item = input.required(/* @ts-ignore */
|
|
1332
|
+
...(ngDevMode ? [{ debugName: "item" }] : /* istanbul ignore next */ []));
|
|
1333
|
+
/** Assertive alert for danger, polite status otherwise. */
|
|
1334
|
+
role = computed(() => this.item().tone === 'danger' ? 'alert' : 'status', /* @ts-ignore */
|
|
1335
|
+
...(ngDevMode ? [{ debugName: "role" }] : /* istanbul ignore next */ []));
|
|
1336
|
+
dismiss() {
|
|
1337
|
+
this.service.dismiss(this.item().id);
|
|
1338
|
+
}
|
|
1339
|
+
runAction() {
|
|
1340
|
+
this.service.runAction(this.item());
|
|
1341
|
+
}
|
|
1342
|
+
pause() {
|
|
1343
|
+
this.service.pause(this.item().id);
|
|
1344
|
+
}
|
|
1345
|
+
resume() {
|
|
1346
|
+
this.service.resume(this.item().id);
|
|
1347
|
+
}
|
|
1348
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.7", ngImport: i0, type: MkToast, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
1349
|
+
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.0.7", type: MkToast, isStandalone: true, selector: "mk-toast", inputs: { item: { classPropertyName: "item", publicName: "item", isSignal: true, isRequired: true, transformFunction: null } }, host: { listeners: { "mouseenter": "pause()", "mouseleave": "resume()", "focusin": "pause()", "focusout": "resume()" }, properties: { "attr.data-tone": "item().tone", "attr.role": "role()" }, classAttribute: "mk-toast" }, ngImport: i0, template: "<div class=\"mk-toast__body\">\n @if (item().title; as title) {\n <p class=\"mk-toast__title\">{{ title }}</p>\n }\n <p class=\"mk-toast__message\">{{ item().message }}</p>\n\n @if (item().action; as action) {\n <div class=\"mk-toast__actions\">\n <button mkButton variant=\"link\" size=\"sm\" (click)=\"runAction()\">\n {{ action.label }}\n </button>\n </div>\n }\n</div>\n\n@if (item().dismissible) {\n <button\n type=\"button\"\n class=\"mk-toast__close\"\n [attr.aria-label]=\"i18n.dismiss\"\n (click)=\"dismiss()\"\n >\n <svg viewBox=\"0 0 16 16\" width=\"16\" height=\"16\" aria-hidden=\"true\" focusable=\"false\">\n <path\n d=\"M4 4l8 8M12 4l-8 8\"\n fill=\"none\"\n stroke=\"currentColor\"\n stroke-width=\"1.5\"\n stroke-linecap=\"round\"\n />\n </svg>\n </button>\n}\n", styles: ["@charset \"UTF-8\";:host{--_accent: var(--mk-info);display:flex;align-items:flex-start;gap:var(--mk-space-3);width:22rem;max-width:calc(100vw - var(--mk-space-8));padding:var(--mk-space-3) var(--mk-space-4);background-color:var(--mk-surface);color:var(--mk-text);border:var(--mk-border-width) solid var(--mk-border-subtle);border-inline-start:3px solid var(--_accent);border-radius:var(--mk-radius-md);box-shadow:var(--mk-shadow-lg);font-family:var(--mk-font-sans);pointer-events:auto;animation:mk-toast-in var(--mk-duration-normal) var(--mk-ease-emphasized)}@media(prefers-reduced-motion:reduce){:host{animation:none}}:host([data-tone=info]){--_accent: var(--mk-info)}:host([data-tone=success]){--_accent: var(--mk-success)}:host([data-tone=warning]){--_accent: var(--mk-warning)}:host([data-tone=danger]){--_accent: var(--mk-danger)}:host([data-tone=neutral]){--_accent: var(--mk-border-strong)}.mk-toast__body{flex:1;min-width:0;display:flex;flex-direction:column;gap:var(--mk-space-1)}.mk-toast__title{margin:0;font-size:var(--mk-font-size-sm);font-weight:var(--mk-font-weight-semibold);line-height:var(--mk-line-height-snug);color:var(--mk-text)}.mk-toast__message{margin:0;font-size:var(--mk-font-size-sm);line-height:var(--mk-line-height-normal);color:var(--mk-text-muted)}.mk-toast__actions{margin-top:var(--mk-space-1)}.mk-toast__close{flex-shrink:0;display:inline-flex;align-items:center;justify-content:center;width:1.5rem;height:1.5rem;margin:-2px calc(var(--mk-space-1) * -1) 0 0;padding:0;background:transparent;border:0;border-radius:var(--mk-radius-sm);color:var(--mk-text-subtle);cursor:pointer;transition:color var(--mk-duration-fast) var(--mk-ease-standard),background-color var(--mk-duration-fast) var(--mk-ease-standard)}.mk-toast__close:hover{color:var(--mk-text);background-color:var(--mk-hover-overlay)}.mk-toast__close:focus-visible{color:var(--mk-text);outline:var(--mk-focus-ring-width) solid var(--mk-focus-ring);outline-offset:var(--mk-focus-ring-offset)}\n"], dependencies: [{ kind: "component", type: MkButton, selector: "button[mkButton], a[mkButton]", inputs: ["variant", "tone", "size", "loading", "fullWidth", "iconOnly", "disabled"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
|
|
1350
|
+
}
|
|
1351
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.7", ngImport: i0, type: MkToast, decorators: [{
|
|
1352
|
+
type: Component,
|
|
1353
|
+
args: [{ selector: 'mk-toast', imports: [MkButton], changeDetection: ChangeDetectionStrategy.OnPush, host: {
|
|
1354
|
+
class: 'mk-toast',
|
|
1355
|
+
'[attr.data-tone]': 'item().tone',
|
|
1356
|
+
'[attr.role]': 'role()',
|
|
1357
|
+
'(mouseenter)': 'pause()',
|
|
1358
|
+
'(mouseleave)': 'resume()',
|
|
1359
|
+
'(focusin)': 'pause()',
|
|
1360
|
+
'(focusout)': 'resume()',
|
|
1361
|
+
}, template: "<div class=\"mk-toast__body\">\n @if (item().title; as title) {\n <p class=\"mk-toast__title\">{{ title }}</p>\n }\n <p class=\"mk-toast__message\">{{ item().message }}</p>\n\n @if (item().action; as action) {\n <div class=\"mk-toast__actions\">\n <button mkButton variant=\"link\" size=\"sm\" (click)=\"runAction()\">\n {{ action.label }}\n </button>\n </div>\n }\n</div>\n\n@if (item().dismissible) {\n <button\n type=\"button\"\n class=\"mk-toast__close\"\n [attr.aria-label]=\"i18n.dismiss\"\n (click)=\"dismiss()\"\n >\n <svg viewBox=\"0 0 16 16\" width=\"16\" height=\"16\" aria-hidden=\"true\" focusable=\"false\">\n <path\n d=\"M4 4l8 8M12 4l-8 8\"\n fill=\"none\"\n stroke=\"currentColor\"\n stroke-width=\"1.5\"\n stroke-linecap=\"round\"\n />\n </svg>\n </button>\n}\n", styles: ["@charset \"UTF-8\";:host{--_accent: var(--mk-info);display:flex;align-items:flex-start;gap:var(--mk-space-3);width:22rem;max-width:calc(100vw - var(--mk-space-8));padding:var(--mk-space-3) var(--mk-space-4);background-color:var(--mk-surface);color:var(--mk-text);border:var(--mk-border-width) solid var(--mk-border-subtle);border-inline-start:3px solid var(--_accent);border-radius:var(--mk-radius-md);box-shadow:var(--mk-shadow-lg);font-family:var(--mk-font-sans);pointer-events:auto;animation:mk-toast-in var(--mk-duration-normal) var(--mk-ease-emphasized)}@media(prefers-reduced-motion:reduce){:host{animation:none}}:host([data-tone=info]){--_accent: var(--mk-info)}:host([data-tone=success]){--_accent: var(--mk-success)}:host([data-tone=warning]){--_accent: var(--mk-warning)}:host([data-tone=danger]){--_accent: var(--mk-danger)}:host([data-tone=neutral]){--_accent: var(--mk-border-strong)}.mk-toast__body{flex:1;min-width:0;display:flex;flex-direction:column;gap:var(--mk-space-1)}.mk-toast__title{margin:0;font-size:var(--mk-font-size-sm);font-weight:var(--mk-font-weight-semibold);line-height:var(--mk-line-height-snug);color:var(--mk-text)}.mk-toast__message{margin:0;font-size:var(--mk-font-size-sm);line-height:var(--mk-line-height-normal);color:var(--mk-text-muted)}.mk-toast__actions{margin-top:var(--mk-space-1)}.mk-toast__close{flex-shrink:0;display:inline-flex;align-items:center;justify-content:center;width:1.5rem;height:1.5rem;margin:-2px calc(var(--mk-space-1) * -1) 0 0;padding:0;background:transparent;border:0;border-radius:var(--mk-radius-sm);color:var(--mk-text-subtle);cursor:pointer;transition:color var(--mk-duration-fast) var(--mk-ease-standard),background-color var(--mk-duration-fast) var(--mk-ease-standard)}.mk-toast__close:hover{color:var(--mk-text);background-color:var(--mk-hover-overlay)}.mk-toast__close:focus-visible{color:var(--mk-text);outline:var(--mk-focus-ring-width) solid var(--mk-focus-ring);outline-offset:var(--mk-focus-ring-offset)}\n"] }]
|
|
1362
|
+
}], propDecorators: { item: [{ type: i0.Input, args: [{ isSignal: true, alias: "item", required: true }] }] } });
|
|
1363
|
+
|
|
1364
|
+
/**
|
|
1365
|
+
* Fixed, bottom-right stack that renders the active toasts from
|
|
1366
|
+
* {@link MkToastService}. Mounted once, lazily, into `document.body` by the
|
|
1367
|
+
* service — not intended for direct template use.
|
|
1368
|
+
*/
|
|
1369
|
+
class MkToastContainer {
|
|
1370
|
+
service = inject(MkToastService);
|
|
1371
|
+
/** Live list of toasts to render. */
|
|
1372
|
+
toasts = this.service.toasts;
|
|
1373
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.7", ngImport: i0, type: MkToastContainer, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
1374
|
+
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.0.7", type: MkToastContainer, isStandalone: true, selector: "mk-toast-container", host: { attributes: { "aria-label": "Notifications" }, classAttribute: "mk-toast-container" }, ngImport: i0, template: "@for (toast of toasts(); track toast.id) {\n <mk-toast [item]=\"toast\" />\n}\n", styles: [":host{position:fixed;bottom:calc(var(--mk-space-4) + env(safe-area-inset-bottom,0px));inset-inline-end:var(--mk-space-4);z-index:var(--mk-z-toast);display:flex;flex-direction:column;gap:var(--mk-space-2);align-items:flex-end;max-height:calc(100vh - var(--mk-space-8));max-height:calc(100dvh - var(--mk-space-8));overflow:visible;pointer-events:none}\n"], dependencies: [{ kind: "component", type: MkToast, selector: "mk-toast", inputs: ["item"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
|
|
1375
|
+
}
|
|
1376
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.7", ngImport: i0, type: MkToastContainer, decorators: [{
|
|
1377
|
+
type: Component,
|
|
1378
|
+
args: [{ selector: 'mk-toast-container', imports: [MkToast], changeDetection: ChangeDetectionStrategy.OnPush, host: {
|
|
1379
|
+
class: 'mk-toast-container',
|
|
1380
|
+
'aria-label': 'Notifications',
|
|
1381
|
+
}, template: "@for (toast of toasts(); track toast.id) {\n <mk-toast [item]=\"toast\" />\n}\n", styles: [":host{position:fixed;bottom:calc(var(--mk-space-4) + env(safe-area-inset-bottom,0px));inset-inline-end:var(--mk-space-4);z-index:var(--mk-z-toast);display:flex;flex-direction:column;gap:var(--mk-space-2);align-items:flex-end;max-height:calc(100vh - var(--mk-space-8));max-height:calc(100dvh - var(--mk-space-8));overflow:visible;pointer-events:none}\n"] }]
|
|
1382
|
+
}] });
|
|
1383
|
+
|
|
1384
|
+
const DEFAULT_DURATION$1 = 5000;
|
|
1385
|
+
let nextId$1 = 0;
|
|
1386
|
+
/**
|
|
1387
|
+
* Toast service — enqueues transient notifications rendered by a single,
|
|
1388
|
+
* lazily-mounted {@link MkToastContainer} at the bottom-right of the viewport.
|
|
1389
|
+
* Each rendered toast is itself a live region (`role="alert"` for `danger`,
|
|
1390
|
+
* else `role="status"`), so assistive tech announces it exactly once. Toasts
|
|
1391
|
+
* auto-dismiss after `duration` ms unless it is `0`.
|
|
1392
|
+
*
|
|
1393
|
+
* ```ts
|
|
1394
|
+
* toasts.success('Saved');
|
|
1395
|
+
* toasts.show({ title: 'Upload failed', message: 'Retry?', tone: 'danger',
|
|
1396
|
+
* duration: 0, action: { label: 'Retry', handler: () => retry() } });
|
|
1397
|
+
* ```
|
|
1398
|
+
*/
|
|
1399
|
+
class MkToastService {
|
|
1400
|
+
appRef = inject(ApplicationRef);
|
|
1401
|
+
envInjector = inject(EnvironmentInjector);
|
|
1402
|
+
document = inject(DOCUMENT);
|
|
1403
|
+
isBrowser = isPlatformBrowser(inject(PLATFORM_ID));
|
|
1404
|
+
_toasts = signal([], /* @ts-ignore */
|
|
1405
|
+
...(ngDevMode ? [{ debugName: "_toasts" }] : /* istanbul ignore next */ []));
|
|
1406
|
+
/** Reactive list of currently visible toasts. */
|
|
1407
|
+
toasts = this._toasts.asReadonly();
|
|
1408
|
+
timers = new Map();
|
|
1409
|
+
containerRef;
|
|
1410
|
+
/** Show a toast, returning its id (usable with {@link dismiss}). */
|
|
1411
|
+
show(config) {
|
|
1412
|
+
const item = {
|
|
1413
|
+
id: nextId$1++,
|
|
1414
|
+
title: config.title,
|
|
1415
|
+
message: config.message,
|
|
1416
|
+
tone: config.tone ?? 'info',
|
|
1417
|
+
duration: config.duration ?? DEFAULT_DURATION$1,
|
|
1418
|
+
dismissible: config.dismissible ?? true,
|
|
1419
|
+
action: config.action,
|
|
1420
|
+
};
|
|
1421
|
+
if (!this.isBrowser)
|
|
1422
|
+
return item.id;
|
|
1423
|
+
this.ensureContainer();
|
|
1424
|
+
// The toast element itself carries role="alert"/"status" — no separate
|
|
1425
|
+
// live-announcer call, or screen readers would read everything twice.
|
|
1426
|
+
this._toasts.update((list) => [...list, item]);
|
|
1427
|
+
if (item.duration > 0)
|
|
1428
|
+
this.startTimer(item.id, item.duration);
|
|
1429
|
+
return item.id;
|
|
1430
|
+
}
|
|
1431
|
+
/** Show an `info` toast. */
|
|
1432
|
+
info(message, config = {}) {
|
|
1433
|
+
return this.show({ ...config, message, tone: 'info' });
|
|
1434
|
+
}
|
|
1435
|
+
/** Show a `success` toast. */
|
|
1436
|
+
success(message, config = {}) {
|
|
1437
|
+
return this.show({ ...config, message, tone: 'success' });
|
|
1438
|
+
}
|
|
1439
|
+
/** Show a `warning` toast. */
|
|
1440
|
+
warning(message, config = {}) {
|
|
1441
|
+
return this.show({ ...config, message, tone: 'warning' });
|
|
1442
|
+
}
|
|
1443
|
+
/** Show a `danger` toast. */
|
|
1444
|
+
danger(message, config = {}) {
|
|
1445
|
+
return this.show({ ...config, message, tone: 'danger' });
|
|
1446
|
+
}
|
|
1447
|
+
/** Show a `neutral` toast. */
|
|
1448
|
+
neutral(message, config = {}) {
|
|
1449
|
+
return this.show({ ...config, message, tone: 'neutral' });
|
|
1450
|
+
}
|
|
1451
|
+
/** Dismiss a toast by id. */
|
|
1452
|
+
dismiss(id) {
|
|
1453
|
+
this.clearTimer(id);
|
|
1454
|
+
this._toasts.update((list) => list.filter((t) => t.id !== id));
|
|
1455
|
+
}
|
|
1456
|
+
/** Dismiss every visible toast. */
|
|
1457
|
+
clear() {
|
|
1458
|
+
this.timers.forEach((t) => clearTimeout(t.handle));
|
|
1459
|
+
this.timers.clear();
|
|
1460
|
+
this._toasts.set([]);
|
|
1461
|
+
}
|
|
1462
|
+
/** Run a toast action, then dismiss it. */
|
|
1463
|
+
runAction(item) {
|
|
1464
|
+
item.action?.handler?.();
|
|
1465
|
+
this.dismiss(item.id);
|
|
1466
|
+
}
|
|
1467
|
+
/** Pause a toast's auto-dismiss countdown (e.g. on hover/focus). */
|
|
1468
|
+
pause(id) {
|
|
1469
|
+
const timer = this.timers.get(id);
|
|
1470
|
+
if (!timer)
|
|
1471
|
+
return;
|
|
1472
|
+
clearTimeout(timer.handle);
|
|
1473
|
+
timer.remaining -= Date.now() - timer.startedAt;
|
|
1474
|
+
}
|
|
1475
|
+
/** Resume a paused countdown. */
|
|
1476
|
+
resume(id) {
|
|
1477
|
+
const timer = this.timers.get(id);
|
|
1478
|
+
if (!timer)
|
|
1479
|
+
return;
|
|
1480
|
+
this.startTimer(id, Math.max(timer.remaining, 0));
|
|
1481
|
+
}
|
|
1482
|
+
startTimer(id, duration) {
|
|
1483
|
+
this.timers.set(id, {
|
|
1484
|
+
handle: setTimeout(() => this.dismiss(id), duration),
|
|
1485
|
+
remaining: duration,
|
|
1486
|
+
startedAt: Date.now(),
|
|
1487
|
+
});
|
|
1488
|
+
}
|
|
1489
|
+
clearTimer(id) {
|
|
1490
|
+
const timer = this.timers.get(id);
|
|
1491
|
+
if (timer)
|
|
1492
|
+
clearTimeout(timer.handle);
|
|
1493
|
+
this.timers.delete(id);
|
|
1494
|
+
}
|
|
1495
|
+
ensureContainer() {
|
|
1496
|
+
if (this.containerRef)
|
|
1497
|
+
return;
|
|
1498
|
+
const ref = createComponent(MkToastContainer, {
|
|
1499
|
+
environmentInjector: this.envInjector,
|
|
1500
|
+
});
|
|
1501
|
+
this.appRef.attachView(ref.hostView);
|
|
1502
|
+
this.document.body.appendChild(ref.location.nativeElement);
|
|
1503
|
+
this.containerRef = ref;
|
|
1504
|
+
}
|
|
1505
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.7", ngImport: i0, type: MkToastService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
|
|
1506
|
+
static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "22.0.7", ngImport: i0, type: MkToastService, providedIn: 'root' });
|
|
1507
|
+
}
|
|
1508
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.7", ngImport: i0, type: MkToastService, decorators: [{
|
|
1509
|
+
type: Injectable,
|
|
1510
|
+
args: [{ providedIn: 'root' }]
|
|
1511
|
+
}] });
|
|
1512
|
+
|
|
1513
|
+
/**
|
|
1514
|
+
* The visual snackbar bar. Internal to the snackbar system — rendered by
|
|
1515
|
+
* {@link MkSnackbarContainer}. Shows a message, an optional action button and
|
|
1516
|
+
* (when `dismissible`) a close button. Auto-dismiss pauses on hover/focus.
|
|
1517
|
+
*/
|
|
1518
|
+
class MkSnackbar {
|
|
1519
|
+
service = inject(MkSnackbarService);
|
|
1520
|
+
i18n = inject(MK_I18N);
|
|
1521
|
+
/** The snackbar model to render. */
|
|
1522
|
+
item = input.required(/* @ts-ignore */
|
|
1523
|
+
...(ngDevMode ? [{ debugName: "item" }] : /* istanbul ignore next */ []));
|
|
1524
|
+
/** Assertive alert for danger/warning, polite status otherwise. */
|
|
1525
|
+
role = computed(() => this.item().tone === 'danger' || this.item().tone === 'warning'
|
|
1526
|
+
? 'alert'
|
|
1527
|
+
: 'status', /* @ts-ignore */
|
|
1528
|
+
...(ngDevMode ? [{ debugName: "role" }] : /* istanbul ignore next */ []));
|
|
1529
|
+
runAction() {
|
|
1530
|
+
this.service.runAction(this.item().id);
|
|
1531
|
+
}
|
|
1532
|
+
dismiss() {
|
|
1533
|
+
this.service.dismiss(this.item().id);
|
|
1534
|
+
}
|
|
1535
|
+
pause() {
|
|
1536
|
+
this.service.pause();
|
|
1537
|
+
}
|
|
1538
|
+
resume() {
|
|
1539
|
+
this.service.resume();
|
|
1540
|
+
}
|
|
1541
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.7", ngImport: i0, type: MkSnackbar, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
1542
|
+
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.0.7", type: MkSnackbar, isStandalone: true, selector: "mk-snackbar", inputs: { item: { classPropertyName: "item", publicName: "item", isSignal: true, isRequired: true, transformFunction: null } }, host: { listeners: { "mouseenter": "pause()", "mouseleave": "resume()", "focusin": "pause()", "focusout": "resume()" }, properties: { "attr.data-tone": "item().tone", "attr.role": "role()" }, classAttribute: "mk-snackbar" }, ngImport: i0, template: "<span class=\"mk-snackbar__message\">{{ item().message }}</span>\n\n@if (item().actionLabel) {\n <button type=\"button\" class=\"mk-snackbar__action\" (click)=\"runAction()\">\n {{ item().actionLabel }}\n </button>\n}\n\n@if (item().dismissible) {\n <button\n type=\"button\"\n class=\"mk-snackbar__close\"\n [attr.aria-label]=\"i18n.dismiss\"\n (click)=\"dismiss()\"\n ></button>\n}\n", styles: ["@charset \"UTF-8\";:host{display:flex;align-items:center;gap:var(--mk-space-3);min-width:18rem;max-width:min(36rem,100vw - 2 * var(--mk-space-4));padding:var(--mk-space-3) var(--mk-space-4);background-color:var(--mk-surface-inverse);color:var(--mk-text-inverse);border-radius:var(--mk-radius-md);box-shadow:var(--mk-shadow-lg);font-family:var(--mk-font-sans);font-size:var(--mk-font-size-md);pointer-events:auto;animation:mk-snackbar-in var(--mk-duration-normal) var(--mk-ease-emphasized)}:host([data-tone=info]){border-inline-start:3px solid var(--mk-info)}:host([data-tone=success]){border-inline-start:3px solid var(--mk-success)}:host([data-tone=warning]){border-inline-start:3px solid var(--mk-warning)}:host([data-tone=danger]){border-inline-start:3px solid var(--mk-danger)}.mk-snackbar__message{flex:1 1 auto;min-width:0;line-height:var(--mk-line-height-snug)}.mk-snackbar__action{flex:none;padding:var(--mk-space-1) var(--mk-space-2);margin:calc(-1 * var(--mk-space-1)) 0;background:transparent;border:none;border-radius:var(--mk-radius-sm);color:color-mix(in srgb,var(--mk-primary) 65%,white);font-family:inherit;font-size:var(--mk-font-size-sm);font-weight:var(--mk-font-weight-semibold);text-transform:uppercase;letter-spacing:.02em;cursor:pointer;transition:background-color var(--mk-duration-fast) var(--mk-ease-standard)}.mk-snackbar__action:hover{background-color:color-mix(in srgb,transparent 88%,white)}.mk-snackbar__action:focus-visible{outline:var(--mk-focus-ring-width) solid var(--mk-focus-ring);outline-offset:var(--mk-focus-ring-offset)}.mk-snackbar__close{position:relative;flex:none;width:1.5rem;height:1.5rem;padding:0;background:transparent;border:none;border-radius:var(--mk-radius-circle);color:inherit;opacity:.7;cursor:pointer;transition:opacity var(--mk-duration-fast) var(--mk-ease-standard),background-color var(--mk-duration-fast) var(--mk-ease-standard)}.mk-snackbar__close:hover{opacity:1;background-color:color-mix(in srgb,transparent 85%,white)}.mk-snackbar__close:focus-visible{outline:var(--mk-focus-ring-width) solid var(--mk-focus-ring);outline-offset:var(--mk-focus-ring-offset)}.mk-snackbar__close:before,.mk-snackbar__close:after{content:\"\";position:absolute;top:50%;left:50%;width:.75rem;height:2px;background-color:currentColor;border-radius:var(--mk-radius-pill)}.mk-snackbar__close:before{transform:translate(-50%,-50%) rotate(45deg)}.mk-snackbar__close:after{transform:translate(-50%,-50%) rotate(-45deg)}@keyframes mk-snackbar-in{0%{opacity:0;transform:translateY(.75rem)}to{opacity:1;transform:translateY(0)}}@media(prefers-reduced-motion:reduce){:host{animation:none}.mk-snackbar__action,.mk-snackbar__close{transition:none}}\n"], changeDetection: i0.ChangeDetectionStrategy.OnPush });
|
|
1543
|
+
}
|
|
1544
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.7", ngImport: i0, type: MkSnackbar, decorators: [{
|
|
1545
|
+
type: Component,
|
|
1546
|
+
args: [{ selector: 'mk-snackbar', changeDetection: ChangeDetectionStrategy.OnPush, host: {
|
|
1547
|
+
class: 'mk-snackbar',
|
|
1548
|
+
'[attr.data-tone]': 'item().tone',
|
|
1549
|
+
'[attr.role]': 'role()',
|
|
1550
|
+
'(mouseenter)': 'pause()',
|
|
1551
|
+
'(mouseleave)': 'resume()',
|
|
1552
|
+
'(focusin)': 'pause()',
|
|
1553
|
+
'(focusout)': 'resume()',
|
|
1554
|
+
}, template: "<span class=\"mk-snackbar__message\">{{ item().message }}</span>\n\n@if (item().actionLabel) {\n <button type=\"button\" class=\"mk-snackbar__action\" (click)=\"runAction()\">\n {{ item().actionLabel }}\n </button>\n}\n\n@if (item().dismissible) {\n <button\n type=\"button\"\n class=\"mk-snackbar__close\"\n [attr.aria-label]=\"i18n.dismiss\"\n (click)=\"dismiss()\"\n ></button>\n}\n", styles: ["@charset \"UTF-8\";:host{display:flex;align-items:center;gap:var(--mk-space-3);min-width:18rem;max-width:min(36rem,100vw - 2 * var(--mk-space-4));padding:var(--mk-space-3) var(--mk-space-4);background-color:var(--mk-surface-inverse);color:var(--mk-text-inverse);border-radius:var(--mk-radius-md);box-shadow:var(--mk-shadow-lg);font-family:var(--mk-font-sans);font-size:var(--mk-font-size-md);pointer-events:auto;animation:mk-snackbar-in var(--mk-duration-normal) var(--mk-ease-emphasized)}:host([data-tone=info]){border-inline-start:3px solid var(--mk-info)}:host([data-tone=success]){border-inline-start:3px solid var(--mk-success)}:host([data-tone=warning]){border-inline-start:3px solid var(--mk-warning)}:host([data-tone=danger]){border-inline-start:3px solid var(--mk-danger)}.mk-snackbar__message{flex:1 1 auto;min-width:0;line-height:var(--mk-line-height-snug)}.mk-snackbar__action{flex:none;padding:var(--mk-space-1) var(--mk-space-2);margin:calc(-1 * var(--mk-space-1)) 0;background:transparent;border:none;border-radius:var(--mk-radius-sm);color:color-mix(in srgb,var(--mk-primary) 65%,white);font-family:inherit;font-size:var(--mk-font-size-sm);font-weight:var(--mk-font-weight-semibold);text-transform:uppercase;letter-spacing:.02em;cursor:pointer;transition:background-color var(--mk-duration-fast) var(--mk-ease-standard)}.mk-snackbar__action:hover{background-color:color-mix(in srgb,transparent 88%,white)}.mk-snackbar__action:focus-visible{outline:var(--mk-focus-ring-width) solid var(--mk-focus-ring);outline-offset:var(--mk-focus-ring-offset)}.mk-snackbar__close{position:relative;flex:none;width:1.5rem;height:1.5rem;padding:0;background:transparent;border:none;border-radius:var(--mk-radius-circle);color:inherit;opacity:.7;cursor:pointer;transition:opacity var(--mk-duration-fast) var(--mk-ease-standard),background-color var(--mk-duration-fast) var(--mk-ease-standard)}.mk-snackbar__close:hover{opacity:1;background-color:color-mix(in srgb,transparent 85%,white)}.mk-snackbar__close:focus-visible{outline:var(--mk-focus-ring-width) solid var(--mk-focus-ring);outline-offset:var(--mk-focus-ring-offset)}.mk-snackbar__close:before,.mk-snackbar__close:after{content:\"\";position:absolute;top:50%;left:50%;width:.75rem;height:2px;background-color:currentColor;border-radius:var(--mk-radius-pill)}.mk-snackbar__close:before{transform:translate(-50%,-50%) rotate(45deg)}.mk-snackbar__close:after{transform:translate(-50%,-50%) rotate(-45deg)}@keyframes mk-snackbar-in{0%{opacity:0;transform:translateY(.75rem)}to{opacity:1;transform:translateY(0)}}@media(prefers-reduced-motion:reduce){:host{animation:none}.mk-snackbar__action,.mk-snackbar__close{transition:none}}\n"] }]
|
|
1555
|
+
}], propDecorators: { item: [{ type: i0.Input, args: [{ isSignal: true, alias: "item", required: true }] }] } });
|
|
1556
|
+
|
|
1557
|
+
/**
|
|
1558
|
+
* Fixed, bottom-centre host that renders the single active snackbar from
|
|
1559
|
+
* {@link MkSnackbarService}. Mounted once, lazily, into `document.body` by the
|
|
1560
|
+
* service — not intended for direct template use.
|
|
1561
|
+
*/
|
|
1562
|
+
class MkSnackbarContainer {
|
|
1563
|
+
service = inject(MkSnackbarService);
|
|
1564
|
+
/** The active snackbar to render, or `null`. */
|
|
1565
|
+
active = this.service.active;
|
|
1566
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.7", ngImport: i0, type: MkSnackbarContainer, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
1567
|
+
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.0.7", type: MkSnackbarContainer, isStandalone: true, selector: "mk-snackbar-container", host: { classAttribute: "mk-snackbar-container" }, ngImport: i0, template: "@if (active(); as item) {\n <mk-snackbar [item]=\"item\" />\n}\n", styles: [":host{position:fixed;bottom:calc(var(--mk-space-5) + env(safe-area-inset-bottom,0px));left:50%;transform:translate(-50%);z-index:var(--mk-z-toast);display:flex;justify-content:center;pointer-events:none}\n"], dependencies: [{ kind: "component", type: MkSnackbar, selector: "mk-snackbar", inputs: ["item"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
|
|
1568
|
+
}
|
|
1569
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.7", ngImport: i0, type: MkSnackbarContainer, decorators: [{
|
|
1570
|
+
type: Component,
|
|
1571
|
+
args: [{ selector: 'mk-snackbar-container', imports: [MkSnackbar], changeDetection: ChangeDetectionStrategy.OnPush, host: {
|
|
1572
|
+
class: 'mk-snackbar-container',
|
|
1573
|
+
}, template: "@if (active(); as item) {\n <mk-snackbar [item]=\"item\" />\n}\n", styles: [":host{position:fixed;bottom:calc(var(--mk-space-5) + env(safe-area-inset-bottom,0px));left:50%;transform:translate(-50%);z-index:var(--mk-z-toast);display:flex;justify-content:center;pointer-events:none}\n"] }]
|
|
1574
|
+
}] });
|
|
1575
|
+
|
|
1576
|
+
const DEFAULT_DURATION = 5000;
|
|
1577
|
+
let nextId = 0;
|
|
1578
|
+
/**
|
|
1579
|
+
* Handle to an opened snackbar. Await {@link onAction} to react to the action
|
|
1580
|
+
* button, or {@link afterDismissed} to run code once it closes.
|
|
1581
|
+
*/
|
|
1582
|
+
class MkSnackbarRef {
|
|
1583
|
+
id;
|
|
1584
|
+
service;
|
|
1585
|
+
actionResolve;
|
|
1586
|
+
dismissResolve;
|
|
1587
|
+
actionPromise = new Promise((r) => {
|
|
1588
|
+
this.actionResolve = r;
|
|
1589
|
+
});
|
|
1590
|
+
dismissPromise = new Promise((r) => {
|
|
1591
|
+
this.dismissResolve = r;
|
|
1592
|
+
});
|
|
1593
|
+
constructor(
|
|
1594
|
+
/** Unique id of the snackbar this ref controls. */
|
|
1595
|
+
id, service) {
|
|
1596
|
+
this.id = id;
|
|
1597
|
+
this.service = service;
|
|
1598
|
+
}
|
|
1599
|
+
/** Dismiss the snackbar programmatically. */
|
|
1600
|
+
dismiss() {
|
|
1601
|
+
this.service.dismiss(this.id);
|
|
1602
|
+
}
|
|
1603
|
+
/** Resolves when the action button is activated. */
|
|
1604
|
+
onAction() {
|
|
1605
|
+
return this.actionPromise;
|
|
1606
|
+
}
|
|
1607
|
+
/** Resolves when the snackbar has closed, with the reason it closed. */
|
|
1608
|
+
afterDismissed() {
|
|
1609
|
+
return this.dismissPromise;
|
|
1610
|
+
}
|
|
1611
|
+
/** @internal */
|
|
1612
|
+
_notifyAction() {
|
|
1613
|
+
this.actionResolve();
|
|
1614
|
+
}
|
|
1615
|
+
/** @internal */
|
|
1616
|
+
_notifyDismissed(reason) {
|
|
1617
|
+
this.dismissResolve(reason);
|
|
1618
|
+
}
|
|
1619
|
+
}
|
|
1620
|
+
/**
|
|
1621
|
+
* Snackbar service — shows a single, brief message at the bottom-centre of the
|
|
1622
|
+
* viewport with an optional action button, following the Material snackbar
|
|
1623
|
+
* model: only one is visible at a time, and opening a new one replaces the
|
|
1624
|
+
* current. For stacked, persistent notifications use `MkToastService` instead.
|
|
1625
|
+
*
|
|
1626
|
+
* ```ts
|
|
1627
|
+
* const ref = snackbar.open('Message archived', 'Undo');
|
|
1628
|
+
* ref.onAction().then(() => restore());
|
|
1629
|
+
*
|
|
1630
|
+
* snackbar.open('Changes saved', undefined, { tone: 'success' });
|
|
1631
|
+
* ```
|
|
1632
|
+
*/
|
|
1633
|
+
class MkSnackbarService {
|
|
1634
|
+
appRef = inject(ApplicationRef);
|
|
1635
|
+
envInjector = inject(EnvironmentInjector);
|
|
1636
|
+
document = inject(DOCUMENT);
|
|
1637
|
+
isBrowser = isPlatformBrowser(inject(PLATFORM_ID));
|
|
1638
|
+
_active = signal(null, /* @ts-ignore */
|
|
1639
|
+
...(ngDevMode ? [{ debugName: "_active" }] : /* istanbul ignore next */ []));
|
|
1640
|
+
/** The currently visible snackbar, or `null`. */
|
|
1641
|
+
active = this._active.asReadonly();
|
|
1642
|
+
refs = new Map();
|
|
1643
|
+
timer;
|
|
1644
|
+
remaining = 0;
|
|
1645
|
+
startedAt = 0;
|
|
1646
|
+
containerRef;
|
|
1647
|
+
/**
|
|
1648
|
+
* Open a snackbar. `action` is the (optional) button label — react to it via
|
|
1649
|
+
* the returned ref's {@link MkSnackbarRef.onAction}.
|
|
1650
|
+
*/
|
|
1651
|
+
open(message, action, config = {}) {
|
|
1652
|
+
const id = nextId++;
|
|
1653
|
+
const ref = new MkSnackbarRef(id, this);
|
|
1654
|
+
if (!this.isBrowser)
|
|
1655
|
+
return ref;
|
|
1656
|
+
// Replace any currently-open snackbar (Material single-slot behaviour).
|
|
1657
|
+
const current = this._active();
|
|
1658
|
+
if (current)
|
|
1659
|
+
this.dismiss(current.id);
|
|
1660
|
+
const item = {
|
|
1661
|
+
id,
|
|
1662
|
+
message,
|
|
1663
|
+
actionLabel: action,
|
|
1664
|
+
tone: config.tone ?? 'neutral',
|
|
1665
|
+
dismissible: config.dismissible ?? false,
|
|
1666
|
+
};
|
|
1667
|
+
this.ensureContainer();
|
|
1668
|
+
this.refs.set(id, ref);
|
|
1669
|
+
// The snackbar element itself carries role="alert"/"status" — no separate
|
|
1670
|
+
// live-announcer call, or screen readers would read everything twice.
|
|
1671
|
+
this._active.set(item);
|
|
1672
|
+
const duration = config.duration ?? DEFAULT_DURATION;
|
|
1673
|
+
if (duration > 0)
|
|
1674
|
+
this.startTimer(id, duration);
|
|
1675
|
+
return ref;
|
|
1676
|
+
}
|
|
1677
|
+
/** Dismiss the snackbar with the given id (if it is the active one). */
|
|
1678
|
+
dismiss(id) {
|
|
1679
|
+
this.close(id, 'dismiss');
|
|
1680
|
+
}
|
|
1681
|
+
/** Run the action for the active snackbar, then close it. */
|
|
1682
|
+
runAction(id) {
|
|
1683
|
+
this.refs.get(id)?._notifyAction();
|
|
1684
|
+
this.close(id, 'action');
|
|
1685
|
+
}
|
|
1686
|
+
/** Pause the auto-dismiss countdown (e.g. on hover/focus). */
|
|
1687
|
+
pause() {
|
|
1688
|
+
if (!this.timer)
|
|
1689
|
+
return;
|
|
1690
|
+
clearTimeout(this.timer);
|
|
1691
|
+
this.timer = undefined;
|
|
1692
|
+
this.remaining -= Date.now() - this.startedAt;
|
|
1693
|
+
}
|
|
1694
|
+
/** Resume a paused countdown. */
|
|
1695
|
+
resume() {
|
|
1696
|
+
const active = this._active();
|
|
1697
|
+
if (!active || this.timer || this.remaining <= 0)
|
|
1698
|
+
return;
|
|
1699
|
+
this.startTimer(active.id, this.remaining);
|
|
1700
|
+
}
|
|
1701
|
+
close(id, reason) {
|
|
1702
|
+
if (this._active()?.id !== id)
|
|
1703
|
+
return;
|
|
1704
|
+
this.clearTimer();
|
|
1705
|
+
this._active.set(null);
|
|
1706
|
+
const ref = this.refs.get(id);
|
|
1707
|
+
this.refs.delete(id);
|
|
1708
|
+
ref?._notifyDismissed(reason);
|
|
1709
|
+
}
|
|
1710
|
+
startTimer(id, duration) {
|
|
1711
|
+
this.remaining = duration;
|
|
1712
|
+
this.startedAt = Date.now();
|
|
1713
|
+
this.timer = setTimeout(() => this.close(id, 'timeout'), duration);
|
|
1714
|
+
}
|
|
1715
|
+
clearTimer() {
|
|
1716
|
+
if (this.timer)
|
|
1717
|
+
clearTimeout(this.timer);
|
|
1718
|
+
this.timer = undefined;
|
|
1719
|
+
this.remaining = 0;
|
|
1720
|
+
}
|
|
1721
|
+
ensureContainer() {
|
|
1722
|
+
if (this.containerRef)
|
|
1723
|
+
return;
|
|
1724
|
+
const ref = createComponent(MkSnackbarContainer, {
|
|
1725
|
+
environmentInjector: this.envInjector,
|
|
1726
|
+
});
|
|
1727
|
+
this.appRef.attachView(ref.hostView);
|
|
1728
|
+
this.document.body.appendChild(ref.location.nativeElement);
|
|
1729
|
+
this.containerRef = ref;
|
|
1730
|
+
}
|
|
1731
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.7", ngImport: i0, type: MkSnackbarService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
|
|
1732
|
+
static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "22.0.7", ngImport: i0, type: MkSnackbarService, providedIn: 'root' });
|
|
1733
|
+
}
|
|
1734
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.7", ngImport: i0, type: MkSnackbarService, decorators: [{
|
|
1735
|
+
type: Injectable,
|
|
1736
|
+
args: [{ providedIn: 'root' }]
|
|
1737
|
+
}] });
|
|
1738
|
+
|
|
1739
|
+
/** Distance in px the sheet must be dragged down before it dismisses. */
|
|
1740
|
+
const DISMISS_THRESHOLD = 120;
|
|
1741
|
+
/**
|
|
1742
|
+
* BottomSheet layout — the surface rendered inside an {@link MkOverlayService}
|
|
1743
|
+
* panel by {@link MkBottomSheetService}. Provides a drag handle (swipe down to
|
|
1744
|
+
* dismiss), an optional titled header with a close button, and a scrollable
|
|
1745
|
+
* body for projected content.
|
|
1746
|
+
*
|
|
1747
|
+
* Slots: `[mkBottomSheetHeader]` (extra header content), default content
|
|
1748
|
+
* (body), `[mkBottomSheetFooter]` (sticky footer).
|
|
1749
|
+
*
|
|
1750
|
+
* ```html
|
|
1751
|
+
* <mk-bottom-sheet sheetTitle="Share">
|
|
1752
|
+
* <button mkButton fullWidth variant="ghost">Copy link</button>
|
|
1753
|
+
* <button mkButton fullWidth variant="ghost">Email</button>
|
|
1754
|
+
* </mk-bottom-sheet>
|
|
1755
|
+
* ```
|
|
1756
|
+
*/
|
|
1757
|
+
class MkBottomSheet {
|
|
1758
|
+
host = inject(ElementRef);
|
|
1759
|
+
/** Localised strings (override globally via `provideMkI18n`). */
|
|
1760
|
+
i18n = inject(MK_I18N);
|
|
1761
|
+
overlayRef = inject(MkOverlayRef, { optional: true });
|
|
1762
|
+
/** Optional plain-text title rendered in the header. */
|
|
1763
|
+
sheetTitle = input(/* @ts-ignore */
|
|
1764
|
+
...(ngDevMode ? [undefined, { debugName: "sheetTitle" }] : /* istanbul ignore next */ []));
|
|
1765
|
+
/** Stable id for the built-in title element. */
|
|
1766
|
+
titleId = input(mkUniqueId('mk-bottom-sheet-title'), /* @ts-ignore */
|
|
1767
|
+
...(ngDevMode ? [{ debugName: "titleId" }] : /* istanbul ignore next */ []));
|
|
1768
|
+
/** Hide the drag handle (also disables swipe-to-dismiss). */
|
|
1769
|
+
hideHandle = input(false, { ...(ngDevMode ? { debugName: "hideHandle" } : /* istanbul ignore next */ {}), transform: booleanAttribute });
|
|
1770
|
+
/** Hide the header close button. */
|
|
1771
|
+
hideClose = input(false, { ...(ngDevMode ? { debugName: "hideClose" } : /* istanbul ignore next */ {}), transform: booleanAttribute });
|
|
1772
|
+
panel = null;
|
|
1773
|
+
dragging = false;
|
|
1774
|
+
startY = 0;
|
|
1775
|
+
constructor() {
|
|
1776
|
+
afterNextRender(() => this.wireLabelledBy());
|
|
1777
|
+
}
|
|
1778
|
+
/** Close the surrounding overlay, if any. */
|
|
1779
|
+
close() {
|
|
1780
|
+
this.overlayRef?.close();
|
|
1781
|
+
}
|
|
1782
|
+
onHandlePointerDown(event) {
|
|
1783
|
+
this.panel ??= this.host.nativeElement.closest('.mk-bottom-sheet-panel');
|
|
1784
|
+
if (!this.panel)
|
|
1785
|
+
return;
|
|
1786
|
+
this.dragging = true;
|
|
1787
|
+
this.startY = event.clientY;
|
|
1788
|
+
event.target.setPointerCapture?.(event.pointerId);
|
|
1789
|
+
this.panel.style.transition = 'none';
|
|
1790
|
+
}
|
|
1791
|
+
onHandlePointerMove(event) {
|
|
1792
|
+
if (!this.dragging || !this.panel)
|
|
1793
|
+
return;
|
|
1794
|
+
const dy = Math.max(0, event.clientY - this.startY);
|
|
1795
|
+
this.panel.style.transform = `translateY(${dy}px)`;
|
|
1796
|
+
}
|
|
1797
|
+
onHandlePointerUp(event) {
|
|
1798
|
+
if (!this.dragging || !this.panel)
|
|
1799
|
+
return;
|
|
1800
|
+
this.dragging = false;
|
|
1801
|
+
const dy = Math.max(0, event.clientY - this.startY);
|
|
1802
|
+
this.panel.style.transition = '';
|
|
1803
|
+
if (dy > DISMISS_THRESHOLD) {
|
|
1804
|
+
this.overlayRef?.close();
|
|
1805
|
+
}
|
|
1806
|
+
else {
|
|
1807
|
+
this.panel.style.transform = '';
|
|
1808
|
+
}
|
|
1809
|
+
}
|
|
1810
|
+
/** Point the owning panel's `aria-labelledby` at the title element. */
|
|
1811
|
+
wireLabelledBy() {
|
|
1812
|
+
const el = this.host.nativeElement;
|
|
1813
|
+
const panel = el.closest('[role="dialog"], [role="alertdialog"]');
|
|
1814
|
+
const titleEl = el.querySelector('.mk-bottom-sheet__title');
|
|
1815
|
+
if (!panel || !titleEl)
|
|
1816
|
+
return;
|
|
1817
|
+
if (!titleEl.id)
|
|
1818
|
+
titleEl.id = this.titleId();
|
|
1819
|
+
panel.setAttribute('aria-labelledby', titleEl.id);
|
|
1820
|
+
}
|
|
1821
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.7", ngImport: i0, type: MkBottomSheet, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
1822
|
+
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.0.7", type: MkBottomSheet, isStandalone: true, selector: "mk-bottom-sheet", inputs: { sheetTitle: { classPropertyName: "sheetTitle", publicName: "sheetTitle", isSignal: true, isRequired: false, transformFunction: null }, titleId: { classPropertyName: "titleId", publicName: "titleId", isSignal: true, isRequired: false, transformFunction: null }, hideHandle: { classPropertyName: "hideHandle", publicName: "hideHandle", isSignal: true, isRequired: false, transformFunction: null }, hideClose: { classPropertyName: "hideClose", publicName: "hideClose", isSignal: true, isRequired: false, transformFunction: null } }, host: { classAttribute: "mk-bottom-sheet" }, ngImport: i0, template: "@if (!hideHandle()) {\n <div\n class=\"mk-bottom-sheet__handle-area\"\n (pointerdown)=\"onHandlePointerDown($event)\"\n (pointermove)=\"onHandlePointerMove($event)\"\n (pointerup)=\"onHandlePointerUp($event)\"\n (pointercancel)=\"onHandlePointerUp($event)\"\n >\n <span class=\"mk-bottom-sheet__handle\" aria-hidden=\"true\"></span>\n </div>\n}\n\n@if (sheetTitle() || !hideClose()) {\n <header class=\"mk-bottom-sheet__header\">\n @if (sheetTitle(); as heading) {\n <h2 class=\"mk-bottom-sheet__title\" [id]=\"titleId()\">{{ heading }}</h2>\n }\n <ng-content select=\"[mkBottomSheetHeader]\" />\n @if (!hideClose()) {\n <button\n type=\"button\"\n class=\"mk-bottom-sheet__close\"\n [attr.aria-label]=\"i18n.close\"\n (click)=\"close()\"\n >\n <svg viewBox=\"0 0 16 16\" width=\"16\" height=\"16\" aria-hidden=\"true\" focusable=\"false\">\n <path\n d=\"M4 4l8 8M12 4l-8 8\"\n fill=\"none\"\n stroke=\"currentColor\"\n stroke-width=\"1.5\"\n stroke-linecap=\"round\"\n />\n </svg>\n </button>\n }\n </header>\n}\n\n<div class=\"mk-bottom-sheet__body\">\n <ng-content />\n</div>\n\n<footer class=\"mk-bottom-sheet__footer\">\n <ng-content select=\"[mkBottomSheetFooter]\" />\n</footer>\n", styles: ["@charset \"UTF-8\";.mk-bottom-sheet{display:flex;flex-direction:column;min-height:0;max-height:inherit;flex:1 1 auto;font-family:var(--mk-font-sans)}.mk-bottom-sheet__handle-area{flex-shrink:0;display:flex;align-items:center;justify-content:center;padding:var(--mk-space-2) 0;cursor:grab;touch-action:none}.mk-bottom-sheet__handle-area:active{cursor:grabbing}.mk-bottom-sheet__handle{width:2.25rem;height:.25rem;border-radius:var(--mk-radius-pill);background-color:var(--mk-border-strong)}.mk-bottom-sheet__header{position:sticky;top:0;z-index:1;display:flex;align-items:center;gap:var(--mk-space-3);flex-shrink:0;padding:var(--mk-space-2) var(--mk-space-5) var(--mk-space-3);background-color:var(--mk-surface)}.mk-bottom-sheet__title{flex:1;min-width:0;margin:0;font-size:var(--mk-font-size-lg);font-weight:var(--mk-font-weight-semibold);line-height:var(--mk-line-height-snug);color:var(--mk-text)}.mk-bottom-sheet__close{flex-shrink:0;display:inline-flex;align-items:center;justify-content:center;width:2rem;height:2rem;padding:0;background:transparent;border:0;border-radius:var(--mk-radius-sm);color:var(--mk-text-muted);cursor:pointer;transition:color var(--mk-duration-fast) var(--mk-ease-standard),background-color var(--mk-duration-fast) var(--mk-ease-standard)}.mk-bottom-sheet__close:hover{color:var(--mk-text);background-color:var(--mk-hover-overlay)}.mk-bottom-sheet__close:focus-visible{color:var(--mk-text);outline:var(--mk-focus-ring-width) solid var(--mk-focus-ring);outline-offset:var(--mk-focus-ring-offset)}.mk-bottom-sheet__body{flex:1 1 auto;min-height:0;overflow-y:auto;overscroll-behavior:contain;padding:var(--mk-space-2) var(--mk-space-5) var(--mk-space-5);font-size:var(--mk-font-size-md);line-height:var(--mk-line-height-normal);color:var(--mk-text)}.mk-bottom-sheet__footer{position:sticky;bottom:0;flex-shrink:0;display:flex;align-items:center;justify-content:flex-end;gap:var(--mk-space-2);padding:var(--mk-space-4) var(--mk-space-5);border-top:var(--mk-border-width) solid var(--mk-border-subtle);background-color:var(--mk-surface)}.mk-bottom-sheet__footer:empty{display:none}@media(prefers-reduced-motion:reduce){.mk-bottom-sheet__close{transition:none}}\n"], changeDetection: i0.ChangeDetectionStrategy.OnPush, encapsulation: i0.ViewEncapsulation.None });
|
|
1823
|
+
}
|
|
1824
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.7", ngImport: i0, type: MkBottomSheet, decorators: [{
|
|
1825
|
+
type: Component,
|
|
1826
|
+
args: [{ selector: 'mk-bottom-sheet', changeDetection: ChangeDetectionStrategy.OnPush, encapsulation: ViewEncapsulation.None, host: { class: 'mk-bottom-sheet' }, template: "@if (!hideHandle()) {\n <div\n class=\"mk-bottom-sheet__handle-area\"\n (pointerdown)=\"onHandlePointerDown($event)\"\n (pointermove)=\"onHandlePointerMove($event)\"\n (pointerup)=\"onHandlePointerUp($event)\"\n (pointercancel)=\"onHandlePointerUp($event)\"\n >\n <span class=\"mk-bottom-sheet__handle\" aria-hidden=\"true\"></span>\n </div>\n}\n\n@if (sheetTitle() || !hideClose()) {\n <header class=\"mk-bottom-sheet__header\">\n @if (sheetTitle(); as heading) {\n <h2 class=\"mk-bottom-sheet__title\" [id]=\"titleId()\">{{ heading }}</h2>\n }\n <ng-content select=\"[mkBottomSheetHeader]\" />\n @if (!hideClose()) {\n <button\n type=\"button\"\n class=\"mk-bottom-sheet__close\"\n [attr.aria-label]=\"i18n.close\"\n (click)=\"close()\"\n >\n <svg viewBox=\"0 0 16 16\" width=\"16\" height=\"16\" aria-hidden=\"true\" focusable=\"false\">\n <path\n d=\"M4 4l8 8M12 4l-8 8\"\n fill=\"none\"\n stroke=\"currentColor\"\n stroke-width=\"1.5\"\n stroke-linecap=\"round\"\n />\n </svg>\n </button>\n }\n </header>\n}\n\n<div class=\"mk-bottom-sheet__body\">\n <ng-content />\n</div>\n\n<footer class=\"mk-bottom-sheet__footer\">\n <ng-content select=\"[mkBottomSheetFooter]\" />\n</footer>\n", styles: ["@charset \"UTF-8\";.mk-bottom-sheet{display:flex;flex-direction:column;min-height:0;max-height:inherit;flex:1 1 auto;font-family:var(--mk-font-sans)}.mk-bottom-sheet__handle-area{flex-shrink:0;display:flex;align-items:center;justify-content:center;padding:var(--mk-space-2) 0;cursor:grab;touch-action:none}.mk-bottom-sheet__handle-area:active{cursor:grabbing}.mk-bottom-sheet__handle{width:2.25rem;height:.25rem;border-radius:var(--mk-radius-pill);background-color:var(--mk-border-strong)}.mk-bottom-sheet__header{position:sticky;top:0;z-index:1;display:flex;align-items:center;gap:var(--mk-space-3);flex-shrink:0;padding:var(--mk-space-2) var(--mk-space-5) var(--mk-space-3);background-color:var(--mk-surface)}.mk-bottom-sheet__title{flex:1;min-width:0;margin:0;font-size:var(--mk-font-size-lg);font-weight:var(--mk-font-weight-semibold);line-height:var(--mk-line-height-snug);color:var(--mk-text)}.mk-bottom-sheet__close{flex-shrink:0;display:inline-flex;align-items:center;justify-content:center;width:2rem;height:2rem;padding:0;background:transparent;border:0;border-radius:var(--mk-radius-sm);color:var(--mk-text-muted);cursor:pointer;transition:color var(--mk-duration-fast) var(--mk-ease-standard),background-color var(--mk-duration-fast) var(--mk-ease-standard)}.mk-bottom-sheet__close:hover{color:var(--mk-text);background-color:var(--mk-hover-overlay)}.mk-bottom-sheet__close:focus-visible{color:var(--mk-text);outline:var(--mk-focus-ring-width) solid var(--mk-focus-ring);outline-offset:var(--mk-focus-ring-offset)}.mk-bottom-sheet__body{flex:1 1 auto;min-height:0;overflow-y:auto;overscroll-behavior:contain;padding:var(--mk-space-2) var(--mk-space-5) var(--mk-space-5);font-size:var(--mk-font-size-md);line-height:var(--mk-line-height-normal);color:var(--mk-text)}.mk-bottom-sheet__footer{position:sticky;bottom:0;flex-shrink:0;display:flex;align-items:center;justify-content:flex-end;gap:var(--mk-space-2);padding:var(--mk-space-4) var(--mk-space-5);border-top:var(--mk-border-width) solid var(--mk-border-subtle);background-color:var(--mk-surface)}.mk-bottom-sheet__footer:empty{display:none}@media(prefers-reduced-motion:reduce){.mk-bottom-sheet__close{transition:none}}\n"] }]
|
|
1827
|
+
}], ctorParameters: () => [], propDecorators: { sheetTitle: [{ type: i0.Input, args: [{ isSignal: true, alias: "sheetTitle", required: false }] }], titleId: [{ type: i0.Input, args: [{ isSignal: true, alias: "titleId", required: false }] }], hideHandle: [{ type: i0.Input, args: [{ isSignal: true, alias: "hideHandle", required: false }] }], hideClose: [{ type: i0.Input, args: [{ isSignal: true, alias: "hideClose", required: false }] }] } });
|
|
1828
|
+
|
|
1829
|
+
const BOTTOM_SHEET_PANEL_CLASS = 'mk-bottom-sheet-panel';
|
|
1830
|
+
/**
|
|
1831
|
+
* BottomSheet service — a thin layer over {@link MkOverlayService} that renders
|
|
1832
|
+
* a component in a panel anchored to the bottom edge of the viewport: full
|
|
1833
|
+
* width on small screens, a centred card capped at ~42rem on large ones. It
|
|
1834
|
+
* slides up on open and can be swiped down to dismiss (via {@link MkBottomSheet}).
|
|
1835
|
+
*
|
|
1836
|
+
* Use it for contextual actions, pickers and mobile-style menus. For a centred
|
|
1837
|
+
* modal use `MkDialogService` instead.
|
|
1838
|
+
*
|
|
1839
|
+
* ```ts
|
|
1840
|
+
* const ref = bottomSheet.open(ShareSheet, { data: { url } });
|
|
1841
|
+
* const choice = await ref.afterClosed;
|
|
1842
|
+
* ```
|
|
1843
|
+
*/
|
|
1844
|
+
class MkBottomSheetService {
|
|
1845
|
+
overlay = inject(MkOverlayService);
|
|
1846
|
+
/** Open `component` inside a bottom-anchored sheet panel. */
|
|
1847
|
+
open(component, config = {}) {
|
|
1848
|
+
return this.overlay.open(component, {
|
|
1849
|
+
...config,
|
|
1850
|
+
role: config.role ?? 'dialog',
|
|
1851
|
+
panelClass: this.mergePanelClass(config.panelClass),
|
|
1852
|
+
});
|
|
1853
|
+
}
|
|
1854
|
+
mergePanelClass(panelClass) {
|
|
1855
|
+
if (!panelClass)
|
|
1856
|
+
return [BOTTOM_SHEET_PANEL_CLASS];
|
|
1857
|
+
const extra = Array.isArray(panelClass) ? panelClass : [panelClass];
|
|
1858
|
+
return [BOTTOM_SHEET_PANEL_CLASS, ...extra];
|
|
1859
|
+
}
|
|
1860
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.7", ngImport: i0, type: MkBottomSheetService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
|
|
1861
|
+
static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "22.0.7", ngImport: i0, type: MkBottomSheetService, providedIn: 'root' });
|
|
1862
|
+
}
|
|
1863
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.7", ngImport: i0, type: MkBottomSheetService, decorators: [{
|
|
1864
|
+
type: Injectable,
|
|
1865
|
+
args: [{ providedIn: 'root' }]
|
|
1866
|
+
}] });
|
|
1867
|
+
|
|
1868
|
+
/**
|
|
1869
|
+
* Banner — a persistent, full-width page/section message with a tone, optional
|
|
1870
|
+
* title, an actions slot and optional dismiss. Heavier than an inline
|
|
1871
|
+
* {@link MkAlert} toast; use it for standing notices ("Trial ends in 3 days").
|
|
1872
|
+
*
|
|
1873
|
+
* Project an icon with `[mkBannerIcon]` and buttons with `[mkBannerActions]`.
|
|
1874
|
+
* Danger/warning banners announce assertively (`role="alert"`).
|
|
1875
|
+
*
|
|
1876
|
+
* ```html
|
|
1877
|
+
* <mk-banner tone="warning" title="Storage almost full" dismissible>
|
|
1878
|
+
* You've used 90% of your quota.
|
|
1879
|
+
* <button mkButton size="sm" mkBannerActions>Upgrade</button>
|
|
1880
|
+
* </mk-banner>
|
|
1881
|
+
* ```
|
|
1882
|
+
*/
|
|
1883
|
+
class MkBanner {
|
|
1884
|
+
/** Localised strings (override globally via `provideMkI18n`). */
|
|
1885
|
+
i18n = inject(MK_I18N);
|
|
1886
|
+
/** Semantic color tone. */
|
|
1887
|
+
tone = input('info', /* @ts-ignore */
|
|
1888
|
+
...(ngDevMode ? [{ debugName: "tone" }] : /* istanbul ignore next */ []));
|
|
1889
|
+
/** Optional bold title above the message. */
|
|
1890
|
+
title = input('', /* @ts-ignore */
|
|
1891
|
+
...(ngDevMode ? [{ debugName: "title" }] : /* istanbul ignore next */ []));
|
|
1892
|
+
/** Show a dismiss (×) button. */
|
|
1893
|
+
dismissible = input(false, { ...(ngDevMode ? { debugName: "dismissible" } : /* istanbul ignore next */ {}), transform: booleanAttribute });
|
|
1894
|
+
/** Two-way open state; set false to hide. */
|
|
1895
|
+
open = model(true, /* @ts-ignore */
|
|
1896
|
+
...(ngDevMode ? [{ debugName: "open" }] : /* istanbul ignore next */ []));
|
|
1897
|
+
/** Emitted when the banner is dismissed. */
|
|
1898
|
+
dismissed = output();
|
|
1899
|
+
role = computed(() => {
|
|
1900
|
+
const t = this.tone();
|
|
1901
|
+
return t === 'danger' || t === 'warning' ? 'alert' : 'status';
|
|
1902
|
+
}, /* @ts-ignore */
|
|
1903
|
+
...(ngDevMode ? [{ debugName: "role" }] : /* istanbul ignore next */ []));
|
|
1904
|
+
dismiss() {
|
|
1905
|
+
this.open.set(false);
|
|
1906
|
+
this.dismissed.emit();
|
|
1907
|
+
}
|
|
1908
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.7", ngImport: i0, type: MkBanner, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
1909
|
+
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.0.7", type: MkBanner, isStandalone: true, selector: "mk-banner", inputs: { tone: { classPropertyName: "tone", publicName: "tone", isSignal: true, isRequired: false, transformFunction: null }, title: { classPropertyName: "title", publicName: "title", isSignal: true, isRequired: false, transformFunction: null }, dismissible: { classPropertyName: "dismissible", publicName: "dismissible", isSignal: true, isRequired: false, transformFunction: null }, open: { classPropertyName: "open", publicName: "open", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { open: "openChange", dismissed: "dismissed" }, host: { properties: { "attr.data-tone": "tone()" }, classAttribute: "mk-banner" }, ngImport: i0, template: "@if (open()) {\n <div class=\"mk-banner__box\">\n <span class=\"mk-banner__icon\" aria-hidden=\"true\"\n ><ng-content select=\"[mkBannerIcon]\"\n /></span>\n <!-- The live region wraps only the title + message, keeping the actions\n and the dismiss button out of what gets announced. -->\n <div class=\"mk-banner__body\" [attr.role]=\"role()\">\n @if (title()) {\n <p class=\"mk-banner__title\">{{ title() }}</p>\n }\n <div class=\"mk-banner__message\"><ng-content /></div>\n </div>\n <div class=\"mk-banner__actions\"><ng-content select=\"[mkBannerActions]\" /></div>\n @if (dismissible()) {\n <button\n type=\"button\"\n class=\"mk-banner__close\"\n [attr.aria-label]=\"i18n.dismiss\"\n (click)=\"dismiss()\"\n >\n <span aria-hidden=\"true\">×</span>\n </button>\n }\n </div>\n}\n", styles: [":host{display:contents;--_main: var(--mk-info);--_hover: var(--mk-info-hover);--_active: var(--mk-info-hover);--_contrast: var(--mk-info-contrast);--_subtle: var(--mk-info-subtle);--_subtle-text: var(--mk-info-subtle-text)}:host([data-tone=primary]){--_main: var(--mk-primary);--_hover: var(--mk-primary-hover);--_active: var(--mk-primary-active);--_contrast: var(--mk-primary-contrast);--_subtle: var(--mk-primary-subtle);--_subtle-text: var(--mk-primary-subtle-text)}:host([data-tone=success]){--_main: var(--mk-success);--_hover: var(--mk-success-hover);--_active: var(--mk-success-hover);--_contrast: var(--mk-success-contrast);--_subtle: var(--mk-success-subtle);--_subtle-text: var(--mk-success-subtle-text)}:host([data-tone=warning]){--_main: var(--mk-warning);--_hover: var(--mk-warning-hover);--_active: var(--mk-warning-hover);--_contrast: var(--mk-warning-contrast);--_subtle: var(--mk-warning-subtle);--_subtle-text: var(--mk-warning-subtle-text)}:host([data-tone=danger]){--_main: var(--mk-danger);--_hover: var(--mk-danger-hover);--_active: var(--mk-danger-hover);--_contrast: var(--mk-danger-contrast);--_subtle: var(--mk-danger-subtle);--_subtle-text: var(--mk-danger-subtle-text)}:host([data-tone=info]){--_main: var(--mk-info);--_hover: var(--mk-info-hover);--_active: var(--mk-info-hover);--_contrast: var(--mk-info-contrast);--_subtle: var(--mk-info-subtle);--_subtle-text: var(--mk-info-subtle-text)}:host([data-tone=neutral]){--_main: var(--mk-surface-inverse);--_hover: var(--mk-surface-inverse);--_active: var(--mk-surface-inverse);--_contrast: var(--mk-text-inverse);--_subtle: var(--mk-neutral-subtle);--_subtle-text: var(--mk-neutral-subtle-text)}.mk-banner__box{display:flex;align-items:flex-start;gap:var(--mk-space-3);padding:var(--mk-space-3) var(--mk-space-4);background-color:var(--_subtle);border:var(--mk-border-width) solid transparent;border-inline-start:3px solid var(--_main);border-radius:var(--mk-radius-md);color:var(--mk-text);font-family:var(--mk-font-sans);font-size:var(--mk-font-size-sm);line-height:var(--mk-line-height-normal)}.mk-banner__icon{flex:none;display:grid;place-items:center;color:var(--_main);line-height:0}.mk-banner__icon:empty{display:none}.mk-banner__body{flex:1 1 auto;min-width:0}.mk-banner__title{margin:0 0 var(--mk-space-1);font-weight:var(--mk-font-weight-semibold);color:var(--_subtle-text)}.mk-banner__message{color:var(--mk-text)}.mk-banner__actions{flex:none;display:flex;align-items:center;gap:var(--mk-space-2)}.mk-banner__actions:empty{display:none}.mk-banner__close{flex:none;display:grid;place-items:center;width:1.5rem;height:1.5rem;padding:0;font-size:var(--mk-font-size-lg);line-height:1;color:var(--mk-text-muted);background:transparent;border:none;border-radius:var(--mk-radius-circle);cursor:pointer;transition:background-color var(--mk-duration-fast) var(--mk-ease-standard)}.mk-banner__close:hover{background-color:var(--mk-hover-overlay);color:var(--mk-text)}.mk-banner__close:focus-visible{outline:var(--mk-focus-ring-width) solid var(--mk-focus-ring);outline-offset:var(--mk-focus-ring-offset)}@media(prefers-reduced-motion:reduce){.mk-banner__close{transition:none}}\n"], changeDetection: i0.ChangeDetectionStrategy.OnPush });
|
|
1910
|
+
}
|
|
1911
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.7", ngImport: i0, type: MkBanner, decorators: [{
|
|
1912
|
+
type: Component,
|
|
1913
|
+
args: [{ selector: 'mk-banner', changeDetection: ChangeDetectionStrategy.OnPush, host: {
|
|
1914
|
+
class: 'mk-banner',
|
|
1915
|
+
'[attr.data-tone]': 'tone()',
|
|
1916
|
+
}, template: "@if (open()) {\n <div class=\"mk-banner__box\">\n <span class=\"mk-banner__icon\" aria-hidden=\"true\"\n ><ng-content select=\"[mkBannerIcon]\"\n /></span>\n <!-- The live region wraps only the title + message, keeping the actions\n and the dismiss button out of what gets announced. -->\n <div class=\"mk-banner__body\" [attr.role]=\"role()\">\n @if (title()) {\n <p class=\"mk-banner__title\">{{ title() }}</p>\n }\n <div class=\"mk-banner__message\"><ng-content /></div>\n </div>\n <div class=\"mk-banner__actions\"><ng-content select=\"[mkBannerActions]\" /></div>\n @if (dismissible()) {\n <button\n type=\"button\"\n class=\"mk-banner__close\"\n [attr.aria-label]=\"i18n.dismiss\"\n (click)=\"dismiss()\"\n >\n <span aria-hidden=\"true\">×</span>\n </button>\n }\n </div>\n}\n", styles: [":host{display:contents;--_main: var(--mk-info);--_hover: var(--mk-info-hover);--_active: var(--mk-info-hover);--_contrast: var(--mk-info-contrast);--_subtle: var(--mk-info-subtle);--_subtle-text: var(--mk-info-subtle-text)}:host([data-tone=primary]){--_main: var(--mk-primary);--_hover: var(--mk-primary-hover);--_active: var(--mk-primary-active);--_contrast: var(--mk-primary-contrast);--_subtle: var(--mk-primary-subtle);--_subtle-text: var(--mk-primary-subtle-text)}:host([data-tone=success]){--_main: var(--mk-success);--_hover: var(--mk-success-hover);--_active: var(--mk-success-hover);--_contrast: var(--mk-success-contrast);--_subtle: var(--mk-success-subtle);--_subtle-text: var(--mk-success-subtle-text)}:host([data-tone=warning]){--_main: var(--mk-warning);--_hover: var(--mk-warning-hover);--_active: var(--mk-warning-hover);--_contrast: var(--mk-warning-contrast);--_subtle: var(--mk-warning-subtle);--_subtle-text: var(--mk-warning-subtle-text)}:host([data-tone=danger]){--_main: var(--mk-danger);--_hover: var(--mk-danger-hover);--_active: var(--mk-danger-hover);--_contrast: var(--mk-danger-contrast);--_subtle: var(--mk-danger-subtle);--_subtle-text: var(--mk-danger-subtle-text)}:host([data-tone=info]){--_main: var(--mk-info);--_hover: var(--mk-info-hover);--_active: var(--mk-info-hover);--_contrast: var(--mk-info-contrast);--_subtle: var(--mk-info-subtle);--_subtle-text: var(--mk-info-subtle-text)}:host([data-tone=neutral]){--_main: var(--mk-surface-inverse);--_hover: var(--mk-surface-inverse);--_active: var(--mk-surface-inverse);--_contrast: var(--mk-text-inverse);--_subtle: var(--mk-neutral-subtle);--_subtle-text: var(--mk-neutral-subtle-text)}.mk-banner__box{display:flex;align-items:flex-start;gap:var(--mk-space-3);padding:var(--mk-space-3) var(--mk-space-4);background-color:var(--_subtle);border:var(--mk-border-width) solid transparent;border-inline-start:3px solid var(--_main);border-radius:var(--mk-radius-md);color:var(--mk-text);font-family:var(--mk-font-sans);font-size:var(--mk-font-size-sm);line-height:var(--mk-line-height-normal)}.mk-banner__icon{flex:none;display:grid;place-items:center;color:var(--_main);line-height:0}.mk-banner__icon:empty{display:none}.mk-banner__body{flex:1 1 auto;min-width:0}.mk-banner__title{margin:0 0 var(--mk-space-1);font-weight:var(--mk-font-weight-semibold);color:var(--_subtle-text)}.mk-banner__message{color:var(--mk-text)}.mk-banner__actions{flex:none;display:flex;align-items:center;gap:var(--mk-space-2)}.mk-banner__actions:empty{display:none}.mk-banner__close{flex:none;display:grid;place-items:center;width:1.5rem;height:1.5rem;padding:0;font-size:var(--mk-font-size-lg);line-height:1;color:var(--mk-text-muted);background:transparent;border:none;border-radius:var(--mk-radius-circle);cursor:pointer;transition:background-color var(--mk-duration-fast) var(--mk-ease-standard)}.mk-banner__close:hover{background-color:var(--mk-hover-overlay);color:var(--mk-text)}.mk-banner__close:focus-visible{outline:var(--mk-focus-ring-width) solid var(--mk-focus-ring);outline-offset:var(--mk-focus-ring-offset)}@media(prefers-reduced-motion:reduce){.mk-banner__close{transition:none}}\n"] }]
|
|
1917
|
+
}], propDecorators: { tone: [{ type: i0.Input, args: [{ isSignal: true, alias: "tone", required: false }] }], title: [{ type: i0.Input, args: [{ isSignal: true, alias: "title", required: false }] }], dismissible: [{ type: i0.Input, args: [{ isSignal: true, alias: "dismissible", required: false }] }], open: [{ type: i0.Input, args: [{ isSignal: true, alias: "open", required: false }] }, { type: i0.Output, args: ["openChange"] }], dismissed: [{ type: i0.Output, args: ["dismissed"] }] } });
|
|
1918
|
+
|
|
1919
|
+
/**
|
|
1920
|
+
* Drives a global {@link MkLoadingBar} — the thin top-of-page progress bar for
|
|
1921
|
+
* route changes and background work. `start()` shows it and trickles toward 90%;
|
|
1922
|
+
* `complete()` finishes and hides it. Also supports a determinate `set(value)`.
|
|
1923
|
+
*
|
|
1924
|
+
* ```ts
|
|
1925
|
+
* const bar = inject(MkLoadingBarService);
|
|
1926
|
+
* router.events.subscribe(e => {
|
|
1927
|
+
* if (e instanceof NavigationStart) bar.start();
|
|
1928
|
+
* if (e instanceof NavigationEnd) bar.complete();
|
|
1929
|
+
* });
|
|
1930
|
+
* ```
|
|
1931
|
+
*/
|
|
1932
|
+
class MkLoadingBarService {
|
|
1933
|
+
isBrowser = isPlatformBrowser(inject(PLATFORM_ID));
|
|
1934
|
+
_progress = signal(0, /* @ts-ignore */
|
|
1935
|
+
...(ngDevMode ? [{ debugName: "_progress" }] : /* istanbul ignore next */ []));
|
|
1936
|
+
_active = signal(false, /* @ts-ignore */
|
|
1937
|
+
...(ngDevMode ? [{ debugName: "_active" }] : /* istanbul ignore next */ []));
|
|
1938
|
+
/** Current progress, 0–100. */
|
|
1939
|
+
progress = this._progress.asReadonly();
|
|
1940
|
+
/** Whether the bar is visible. */
|
|
1941
|
+
active = this._active.asReadonly();
|
|
1942
|
+
timer;
|
|
1943
|
+
doneTimer;
|
|
1944
|
+
/** Show the bar and start trickling toward 90%. */
|
|
1945
|
+
start() {
|
|
1946
|
+
if (!this.isBrowser)
|
|
1947
|
+
return;
|
|
1948
|
+
this.clearTimers();
|
|
1949
|
+
this._active.set(true);
|
|
1950
|
+
this._progress.set(8);
|
|
1951
|
+
this.armTrickle();
|
|
1952
|
+
}
|
|
1953
|
+
/** Set an explicit determinate value (0–100). */
|
|
1954
|
+
set(value) {
|
|
1955
|
+
this.clearTimers();
|
|
1956
|
+
this._active.set(true);
|
|
1957
|
+
this._progress.set(Math.max(0, Math.min(100, value)));
|
|
1958
|
+
}
|
|
1959
|
+
/** Nudge progress up by `amount` (default 5), capped at 90%. */
|
|
1960
|
+
inc(amount = 5) {
|
|
1961
|
+
if (!this._active())
|
|
1962
|
+
this.start();
|
|
1963
|
+
this._progress.set(Math.min(90, this._progress() + amount));
|
|
1964
|
+
// Re-arm the trickle if it stopped at the ceiling (or after a `set()`)
|
|
1965
|
+
// and there is still room to move.
|
|
1966
|
+
if (this._progress() < 90)
|
|
1967
|
+
this.armTrickle();
|
|
1968
|
+
}
|
|
1969
|
+
/** Finish (jump to 100%) then fade out. */
|
|
1970
|
+
complete() {
|
|
1971
|
+
if (!this.isBrowser) {
|
|
1972
|
+
this._active.set(false);
|
|
1973
|
+
return;
|
|
1974
|
+
}
|
|
1975
|
+
this.clearTimers();
|
|
1976
|
+
this._progress.set(100);
|
|
1977
|
+
this.doneTimer = setTimeout(() => {
|
|
1978
|
+
this._active.set(false);
|
|
1979
|
+
this._progress.set(0);
|
|
1980
|
+
}, 350);
|
|
1981
|
+
}
|
|
1982
|
+
/** Hide immediately without finishing. */
|
|
1983
|
+
stop() {
|
|
1984
|
+
this.clearTimers();
|
|
1985
|
+
this._active.set(false);
|
|
1986
|
+
this._progress.set(0);
|
|
1987
|
+
}
|
|
1988
|
+
/**
|
|
1989
|
+
* Auto-slowing trickle so the bar keeps moving without ever completing.
|
|
1990
|
+
* Stops its own interval at the 90% ceiling — otherwise a navigation that
|
|
1991
|
+
* never `complete()`s would leave a timer firing (and, in a zoneless app,
|
|
1992
|
+
* scheduling change detection) forever. `start()`/`inc()` re-arm it.
|
|
1993
|
+
*/
|
|
1994
|
+
trickle() {
|
|
1995
|
+
const p = this._progress();
|
|
1996
|
+
if (p >= 90) {
|
|
1997
|
+
this.stopTrickle();
|
|
1998
|
+
return;
|
|
1999
|
+
}
|
|
2000
|
+
const step = p < 20 ? 10 : p < 50 ? 4 : p < 80 ? 2 : 0.5;
|
|
2001
|
+
const next = Math.min(90, p + step);
|
|
2002
|
+
this._progress.set(next);
|
|
2003
|
+
if (next >= 90)
|
|
2004
|
+
this.stopTrickle();
|
|
2005
|
+
}
|
|
2006
|
+
/** Start the 350ms trickle interval unless it is already running. */
|
|
2007
|
+
armTrickle() {
|
|
2008
|
+
if (!this.isBrowser || this.timer)
|
|
2009
|
+
return;
|
|
2010
|
+
this.timer = setInterval(() => this.trickle(), 350);
|
|
2011
|
+
}
|
|
2012
|
+
stopTrickle() {
|
|
2013
|
+
if (this.timer) {
|
|
2014
|
+
clearInterval(this.timer);
|
|
2015
|
+
this.timer = undefined;
|
|
2016
|
+
}
|
|
2017
|
+
}
|
|
2018
|
+
clearTimers() {
|
|
2019
|
+
if (this.timer)
|
|
2020
|
+
clearInterval(this.timer);
|
|
2021
|
+
if (this.doneTimer)
|
|
2022
|
+
clearTimeout(this.doneTimer);
|
|
2023
|
+
this.timer = undefined;
|
|
2024
|
+
this.doneTimer = undefined;
|
|
2025
|
+
}
|
|
2026
|
+
/** True while finishing (progress full but not yet hidden). */
|
|
2027
|
+
finishing = computed(() => this._active() && this._progress() >= 100, /* @ts-ignore */
|
|
2028
|
+
...(ngDevMode ? [{ debugName: "finishing" }] : /* istanbul ignore next */ []));
|
|
2029
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.7", ngImport: i0, type: MkLoadingBarService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
|
|
2030
|
+
static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "22.0.7", ngImport: i0, type: MkLoadingBarService, providedIn: 'root' });
|
|
2031
|
+
}
|
|
2032
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.7", ngImport: i0, type: MkLoadingBarService, decorators: [{
|
|
2033
|
+
type: Injectable,
|
|
2034
|
+
args: [{ providedIn: 'root' }]
|
|
2035
|
+
}] });
|
|
2036
|
+
|
|
2037
|
+
/**
|
|
2038
|
+
* LoadingBar — a thin progress bar fixed to the top of the viewport, driven by
|
|
2039
|
+
* {@link MkLoadingBarService}. Place it once (e.g. in the app shell) and call
|
|
2040
|
+
* `start()` / `complete()` on navigation or async work.
|
|
2041
|
+
*
|
|
2042
|
+
* ```html
|
|
2043
|
+
* <mk-loading-bar />
|
|
2044
|
+
* ```
|
|
2045
|
+
*/
|
|
2046
|
+
class MkLoadingBar {
|
|
2047
|
+
service = inject(MkLoadingBarService);
|
|
2048
|
+
i18n = inject(MK_I18N);
|
|
2049
|
+
/** Bar colour. */
|
|
2050
|
+
color = input('var(--mk-primary)', /* @ts-ignore */
|
|
2051
|
+
...(ngDevMode ? [{ debugName: "color" }] : /* istanbul ignore next */ []));
|
|
2052
|
+
/** Bar thickness in px. */
|
|
2053
|
+
height = input(3, { ...(ngDevMode ? { debugName: "height" } : /* istanbul ignore next */ {}), transform: numberAttribute });
|
|
2054
|
+
progress = this.service.progress;
|
|
2055
|
+
active = computed(() => this.service.active(), /* @ts-ignore */
|
|
2056
|
+
...(ngDevMode ? [{ debugName: "active" }] : /* istanbul ignore next */ []));
|
|
2057
|
+
/**
|
|
2058
|
+
* The fill is a full-width bar scaled from the inline start — `transform`
|
|
2059
|
+
* animates on the compositor, so each trickle tick avoids the layout+paint
|
|
2060
|
+
* cost that animating `width` incurred. Origin flips for RTL in the styles.
|
|
2061
|
+
*/
|
|
2062
|
+
fillTransform = computed(() => `scaleX(${this.progress() / 100})`, /* @ts-ignore */
|
|
2063
|
+
...(ngDevMode ? [{ debugName: "fillTransform" }] : /* istanbul ignore next */ []));
|
|
2064
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.7", ngImport: i0, type: MkLoadingBar, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
2065
|
+
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.1.0", version: "22.0.7", type: MkLoadingBar, isStandalone: true, selector: "mk-loading-bar", inputs: { color: { classPropertyName: "color", publicName: "color", isSignal: true, isRequired: false, transformFunction: null }, height: { classPropertyName: "height", publicName: "height", isSignal: true, isRequired: false, transformFunction: null } }, host: { attributes: { "role": "progressbar", "aria-valuemin": "0", "aria-valuemax": "100" }, properties: { "attr.aria-valuenow": "active() ? progress() : null", "attr.aria-hidden": "active() ? null : 'true'", "attr.aria-label": "i18n.loading", "class.mk-loading-bar--active": "active()", "style.height.px": "height()" }, classAttribute: "mk-loading-bar" }, ngImport: i0, template: `<div
|
|
2066
|
+
class="mk-loading-bar__fill"
|
|
2067
|
+
[style.transform]="fillTransform()"
|
|
2068
|
+
[style.background]="color()"
|
|
2069
|
+
></div>`, isInline: true, styles: [":host{position:fixed;top:0;left:0;right:0;z-index:var(--mk-z-toast, 1400);pointer-events:none;opacity:0;transition:opacity var(--mk-duration-fast) var(--mk-ease-standard)}:host(.mk-loading-bar--active){opacity:1}.mk-loading-bar__fill{height:100%;width:100%;transform:scaleX(0);transform-origin:left center;will-change:transform;background-color:var(--mk-primary);box-shadow:0 0 8px color-mix(in srgb,var(--mk-primary) 60%,transparent);transition:transform var(--mk-duration-normal, .24s) var(--mk-ease-standard)}.mk-loading-bar__fill:dir(rtl){transform-origin:right center}@media(prefers-reduced-motion:reduce){:host,.mk-loading-bar__fill{transition:none}}\n"], changeDetection: i0.ChangeDetectionStrategy.OnPush });
|
|
2070
|
+
}
|
|
2071
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.7", ngImport: i0, type: MkLoadingBar, decorators: [{
|
|
2072
|
+
type: Component,
|
|
2073
|
+
args: [{ selector: 'mk-loading-bar', template: `<div
|
|
2074
|
+
class="mk-loading-bar__fill"
|
|
2075
|
+
[style.transform]="fillTransform()"
|
|
2076
|
+
[style.background]="color()"
|
|
2077
|
+
></div>`, changeDetection: ChangeDetectionStrategy.OnPush, host: {
|
|
2078
|
+
class: 'mk-loading-bar',
|
|
2079
|
+
role: 'progressbar',
|
|
2080
|
+
'aria-valuemin': '0',
|
|
2081
|
+
'aria-valuemax': '100',
|
|
2082
|
+
'[attr.aria-valuenow]': 'active() ? progress() : null',
|
|
2083
|
+
'[attr.aria-hidden]': "active() ? null : 'true'",
|
|
2084
|
+
'[attr.aria-label]': 'i18n.loading',
|
|
2085
|
+
'[class.mk-loading-bar--active]': 'active()',
|
|
2086
|
+
'[style.height.px]': 'height()',
|
|
2087
|
+
}, styles: [":host{position:fixed;top:0;left:0;right:0;z-index:var(--mk-z-toast, 1400);pointer-events:none;opacity:0;transition:opacity var(--mk-duration-fast) var(--mk-ease-standard)}:host(.mk-loading-bar--active){opacity:1}.mk-loading-bar__fill{height:100%;width:100%;transform:scaleX(0);transform-origin:left center;will-change:transform;background-color:var(--mk-primary);box-shadow:0 0 8px color-mix(in srgb,var(--mk-primary) 60%,transparent);transition:transform var(--mk-duration-normal, .24s) var(--mk-ease-standard)}.mk-loading-bar__fill:dir(rtl){transform-origin:right center}@media(prefers-reduced-motion:reduce){:host,.mk-loading-bar__fill{transition:none}}\n"] }]
|
|
2088
|
+
}], propDecorators: { color: [{ type: i0.Input, args: [{ isSignal: true, alias: "color", required: false }] }], height: [{ type: i0.Input, args: [{ isSignal: true, alias: "height", required: false }] }] } });
|
|
2089
|
+
|
|
2090
|
+
/** DI token carrying the current {@link MkTourData} to the popup. */
|
|
2091
|
+
const MK_TOUR_DATA = new InjectionToken('MK_TOUR_DATA');
|
|
2092
|
+
/**
|
|
2093
|
+
* The floating coach-mark popover for one tour step. Rendered dynamically by
|
|
2094
|
+
* {@link MkTourService} (one instance per step) and anchored to the step's
|
|
2095
|
+
* target through {@link MkAnchoredPanel}, so it lives in the browser top layer
|
|
2096
|
+
* and is never clipped by an ancestor's `overflow`/`transform`.
|
|
2097
|
+
*/
|
|
2098
|
+
class MkTourPopup {
|
|
2099
|
+
injector = inject(Injector);
|
|
2100
|
+
panelRef = viewChild('panel', /* @ts-ignore */
|
|
2101
|
+
...(ngDevMode ? [{ debugName: "panelRef" }] : /* istanbul ignore next */ []));
|
|
2102
|
+
i18n = inject(MK_I18N);
|
|
2103
|
+
/** The step + navigation data for this popup. */
|
|
2104
|
+
data = inject(MK_TOUR_DATA);
|
|
2105
|
+
/** Whether this is the final step (Next becomes "Done"). */
|
|
2106
|
+
isLast = computed(() => this.data.index >= this.data.total - 1, /* @ts-ignore */
|
|
2107
|
+
...(ngDevMode ? [{ debugName: "isLast" }] : /* istanbul ignore next */ []));
|
|
2108
|
+
constructor() {
|
|
2109
|
+
afterNextRender(() => this.focusInitial(), { injector: this.injector });
|
|
2110
|
+
}
|
|
2111
|
+
next() {
|
|
2112
|
+
this.data.controller.next();
|
|
2113
|
+
}
|
|
2114
|
+
prev() {
|
|
2115
|
+
this.data.controller.prev();
|
|
2116
|
+
}
|
|
2117
|
+
skip() {
|
|
2118
|
+
this.data.controller.end();
|
|
2119
|
+
}
|
|
2120
|
+
onEscape(event) {
|
|
2121
|
+
event.preventDefault();
|
|
2122
|
+
event.stopPropagation();
|
|
2123
|
+
this.data.controller.end();
|
|
2124
|
+
}
|
|
2125
|
+
/**
|
|
2126
|
+
* Ignore anchored-panel dismisses (outside pointerdown / window blur): a tour
|
|
2127
|
+
* should stay put while the user reads it — it ends only via the buttons or
|
|
2128
|
+
* Escape.
|
|
2129
|
+
*/
|
|
2130
|
+
onDismiss() {
|
|
2131
|
+
/* intentionally inert */
|
|
2132
|
+
}
|
|
2133
|
+
/**
|
|
2134
|
+
* Trap Tab inside the popup (the dialog is `aria-modal` over a scrim): wrap
|
|
2135
|
+
* from the last tabbable back to the first and vice versa, and pull focus in
|
|
2136
|
+
* if it somehow ended up outside.
|
|
2137
|
+
*/
|
|
2138
|
+
onTab(event) {
|
|
2139
|
+
const e = event;
|
|
2140
|
+
const panel = this.panelRef()?.nativeElement;
|
|
2141
|
+
if (!panel)
|
|
2142
|
+
return;
|
|
2143
|
+
const focusable = mkGetFocusable(panel);
|
|
2144
|
+
if (focusable.length === 0) {
|
|
2145
|
+
e.preventDefault();
|
|
2146
|
+
panel.focus();
|
|
2147
|
+
return;
|
|
2148
|
+
}
|
|
2149
|
+
const first = focusable[0];
|
|
2150
|
+
const last = focusable[focusable.length - 1];
|
|
2151
|
+
const active = panel.ownerDocument.activeElement;
|
|
2152
|
+
if (!panel.contains(active)) {
|
|
2153
|
+
e.preventDefault();
|
|
2154
|
+
first.focus();
|
|
2155
|
+
}
|
|
2156
|
+
else if (e.shiftKey && active === first) {
|
|
2157
|
+
e.preventDefault();
|
|
2158
|
+
last.focus();
|
|
2159
|
+
}
|
|
2160
|
+
else if (!e.shiftKey && active === last) {
|
|
2161
|
+
e.preventDefault();
|
|
2162
|
+
first.focus();
|
|
2163
|
+
}
|
|
2164
|
+
}
|
|
2165
|
+
focusInitial() {
|
|
2166
|
+
const panel = this.panelRef()?.nativeElement;
|
|
2167
|
+
if (!panel)
|
|
2168
|
+
return;
|
|
2169
|
+
(mkGetFocusable(panel)[0] ?? panel).focus();
|
|
2170
|
+
}
|
|
2171
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.7", ngImport: i0, type: MkTourPopup, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
2172
|
+
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.0.7", type: MkTourPopup, isStandalone: true, selector: "mk-tour-popup", host: { classAttribute: "mk-tour" }, viewQueries: [{ propertyName: "panelRef", first: true, predicate: ["panel"], descendants: true, isSignal: true }], ngImport: i0, template: "<div\n #panel\n class=\"mk-tour__popup\"\n role=\"dialog\"\n aria-modal=\"true\"\n tabindex=\"-1\"\n [attr.aria-label]=\"data.step.title ?? data.step.body\"\n mkAnchoredPanel\n [mkAnchoredPanelFor]=\"data.anchor\"\n [placement]=\"data.step.placement ?? 'bottom'\"\n [gap]=\"12\"\n (dismiss)=\"onDismiss()\"\n (keydown.escape)=\"onEscape($event)\"\n (keydown.tab)=\"onTab($event)\"\n (keydown.shift.tab)=\"onTab($event)\"\n>\n @if (data.step.title; as title) {\n <h3 class=\"mk-tour__title\">{{ title }}</h3>\n }\n <p class=\"mk-tour__body\">{{ data.step.body }}</p>\n\n <div class=\"mk-tour__footer\">\n <span class=\"mk-tour__indicator\">\n {{ i18n.tourStepOf(data.index + 1, data.total) }}\n </span>\n <div class=\"mk-tour__actions\">\n <button type=\"button\" class=\"mk-tour__btn mk-tour__btn--ghost\" (click)=\"skip()\">\n {{ i18n.tourSkip }}\n </button>\n @if (data.index > 0) {\n <button type=\"button\" class=\"mk-tour__btn\" (click)=\"prev()\">\n {{ i18n.tourPrevious }}\n </button>\n }\n <button\n type=\"button\"\n class=\"mk-tour__btn mk-tour__btn--primary\"\n (click)=\"next()\"\n >\n {{ isLast() ? i18n.tourDone : i18n.tourNext }}\n </button>\n </div>\n </div>\n</div>\n", styles: [":host{display:contents}.mk-tour__popup{box-sizing:border-box;width:min(20rem,100vw - var(--mk-space-4));padding:var(--mk-space-4);color:var(--mk-text);background:var(--mk-surface);border-radius:var(--mk-radius-lg);box-shadow:var(--mk-shadow-lg);font-family:var(--mk-font-sans);font-size:var(--mk-font-size-md);animation:mk-tour-in var(--mk-duration-fast) var(--mk-ease-out)}.mk-tour__popup:focus-visible{outline:none}.mk-tour__title{margin:0 0 var(--mk-space-2);font-size:var(--mk-font-size-lg);font-weight:600;line-height:1.3}.mk-tour__body{margin:0;color:var(--mk-text-muted);line-height:1.5}.mk-tour__footer{display:flex;align-items:center;justify-content:space-between;gap:var(--mk-space-3);margin-top:var(--mk-space-4)}.mk-tour__indicator{color:var(--mk-text-muted);font-size:var(--mk-font-size-sm);white-space:nowrap}.mk-tour__actions{display:flex;gap:var(--mk-space-2)}.mk-tour__btn{padding:var(--mk-space-1) var(--mk-space-3);color:var(--mk-text);background:transparent;border:var(--mk-border-width) solid var(--mk-border);border-radius:var(--mk-radius-md);font:inherit;font-size:var(--mk-font-size-sm);cursor:pointer}.mk-tour__btn:focus-visible{outline:var(--mk-focus-ring-width) solid var(--mk-focus-ring);outline-offset:var(--mk-focus-ring-offset)}.mk-tour__btn--ghost{color:var(--mk-text-muted);border-color:transparent}.mk-tour__btn--primary{color:var(--mk-primary-contrast);background:var(--mk-primary);border-color:var(--mk-primary)}@keyframes mk-tour-in{0%{opacity:0;transform:translateY(calc(-1 * var(--mk-space-1))) scale(.98)}to{opacity:1;transform:none}}@media(prefers-reduced-motion:reduce){.mk-tour__popup{animation:none}}\n"], dependencies: [{ kind: "directive", type: MkAnchoredPanel, selector: "[mkAnchoredPanel]", inputs: ["mkAnchoredPanelFor", "anchorRect", "placement", "gap", "matchWidth", "flip", "clamp"], outputs: ["dismiss"], exportAs: ["mkAnchoredPanel"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
|
|
2173
|
+
}
|
|
2174
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.7", ngImport: i0, type: MkTourPopup, decorators: [{
|
|
2175
|
+
type: Component,
|
|
2176
|
+
args: [{ selector: 'mk-tour-popup', changeDetection: ChangeDetectionStrategy.OnPush, imports: [MkAnchoredPanel], host: {
|
|
2177
|
+
class: 'mk-tour',
|
|
2178
|
+
}, template: "<div\n #panel\n class=\"mk-tour__popup\"\n role=\"dialog\"\n aria-modal=\"true\"\n tabindex=\"-1\"\n [attr.aria-label]=\"data.step.title ?? data.step.body\"\n mkAnchoredPanel\n [mkAnchoredPanelFor]=\"data.anchor\"\n [placement]=\"data.step.placement ?? 'bottom'\"\n [gap]=\"12\"\n (dismiss)=\"onDismiss()\"\n (keydown.escape)=\"onEscape($event)\"\n (keydown.tab)=\"onTab($event)\"\n (keydown.shift.tab)=\"onTab($event)\"\n>\n @if (data.step.title; as title) {\n <h3 class=\"mk-tour__title\">{{ title }}</h3>\n }\n <p class=\"mk-tour__body\">{{ data.step.body }}</p>\n\n <div class=\"mk-tour__footer\">\n <span class=\"mk-tour__indicator\">\n {{ i18n.tourStepOf(data.index + 1, data.total) }}\n </span>\n <div class=\"mk-tour__actions\">\n <button type=\"button\" class=\"mk-tour__btn mk-tour__btn--ghost\" (click)=\"skip()\">\n {{ i18n.tourSkip }}\n </button>\n @if (data.index > 0) {\n <button type=\"button\" class=\"mk-tour__btn\" (click)=\"prev()\">\n {{ i18n.tourPrevious }}\n </button>\n }\n <button\n type=\"button\"\n class=\"mk-tour__btn mk-tour__btn--primary\"\n (click)=\"next()\"\n >\n {{ isLast() ? i18n.tourDone : i18n.tourNext }}\n </button>\n </div>\n </div>\n</div>\n", styles: [":host{display:contents}.mk-tour__popup{box-sizing:border-box;width:min(20rem,100vw - var(--mk-space-4));padding:var(--mk-space-4);color:var(--mk-text);background:var(--mk-surface);border-radius:var(--mk-radius-lg);box-shadow:var(--mk-shadow-lg);font-family:var(--mk-font-sans);font-size:var(--mk-font-size-md);animation:mk-tour-in var(--mk-duration-fast) var(--mk-ease-out)}.mk-tour__popup:focus-visible{outline:none}.mk-tour__title{margin:0 0 var(--mk-space-2);font-size:var(--mk-font-size-lg);font-weight:600;line-height:1.3}.mk-tour__body{margin:0;color:var(--mk-text-muted);line-height:1.5}.mk-tour__footer{display:flex;align-items:center;justify-content:space-between;gap:var(--mk-space-3);margin-top:var(--mk-space-4)}.mk-tour__indicator{color:var(--mk-text-muted);font-size:var(--mk-font-size-sm);white-space:nowrap}.mk-tour__actions{display:flex;gap:var(--mk-space-2)}.mk-tour__btn{padding:var(--mk-space-1) var(--mk-space-3);color:var(--mk-text);background:transparent;border:var(--mk-border-width) solid var(--mk-border);border-radius:var(--mk-radius-md);font:inherit;font-size:var(--mk-font-size-sm);cursor:pointer}.mk-tour__btn:focus-visible{outline:var(--mk-focus-ring-width) solid var(--mk-focus-ring);outline-offset:var(--mk-focus-ring-offset)}.mk-tour__btn--ghost{color:var(--mk-text-muted);border-color:transparent}.mk-tour__btn--primary{color:var(--mk-primary-contrast);background:var(--mk-primary);border-color:var(--mk-primary)}@keyframes mk-tour-in{0%{opacity:0;transform:translateY(calc(-1 * var(--mk-space-1))) scale(.98)}to{opacity:1;transform:none}}@media(prefers-reduced-motion:reduce){.mk-tour__popup{animation:none}}\n"] }]
|
|
2179
|
+
}], ctorParameters: () => [], propDecorators: { panelRef: [{ type: i0.ViewChild, args: ['panel', { isSignal: true }] }] } });
|
|
2180
|
+
|
|
2181
|
+
/**
|
|
2182
|
+
* Product-onboarding tour service — walks the user through a sequence of
|
|
2183
|
+
* {@link MkTourStep}s. For each step it scrolls the target into view, dims the
|
|
2184
|
+
* page behind a scrim, highlights the target with a bright ring, and anchors a
|
|
2185
|
+
* coach-mark popover ({@link MkTourPopup}) to it in the browser top layer via
|
|
2186
|
+
* `MkAnchoredPanel`.
|
|
2187
|
+
*
|
|
2188
|
+
* ```ts
|
|
2189
|
+
* const tour = inject(MkTourService);
|
|
2190
|
+
* await tour.start([
|
|
2191
|
+
* { target: '#new-btn', title: 'Create', body: 'Start a project here.' },
|
|
2192
|
+
* { target: '#inbox', body: 'Your notifications land here.', placement: 'left' },
|
|
2193
|
+
* ]);
|
|
2194
|
+
* ```
|
|
2195
|
+
*/
|
|
2196
|
+
class MkTourService {
|
|
2197
|
+
appRef = inject(ApplicationRef);
|
|
2198
|
+
envInjector = inject(EnvironmentInjector);
|
|
2199
|
+
document = inject(DOCUMENT);
|
|
2200
|
+
isBrowser = isPlatformBrowser(inject(PLATFORM_ID));
|
|
2201
|
+
_steps = signal([], /* @ts-ignore */
|
|
2202
|
+
...(ngDevMode ? [{ debugName: "_steps" }] : /* istanbul ignore next */ []));
|
|
2203
|
+
_index = signal(-1, /* @ts-ignore */
|
|
2204
|
+
...(ngDevMode ? [{ debugName: "_index" }] : /* istanbul ignore next */ []));
|
|
2205
|
+
_running = signal(false, /* @ts-ignore */
|
|
2206
|
+
...(ngDevMode ? [{ debugName: "_running" }] : /* istanbul ignore next */ []));
|
|
2207
|
+
/** Zero-based index of the current step, or `-1` when no tour is running. */
|
|
2208
|
+
index = this._index.asReadonly();
|
|
2209
|
+
/** Whether a tour is currently active. */
|
|
2210
|
+
running = this._running.asReadonly();
|
|
2211
|
+
/** Total number of steps in the active tour. */
|
|
2212
|
+
total = computed(() => this._steps().length, /* @ts-ignore */
|
|
2213
|
+
...(ngDevMode ? [{ debugName: "total" }] : /* istanbul ignore next */ []));
|
|
2214
|
+
/** The current step, or `null`. */
|
|
2215
|
+
currentStep = computed(() => {
|
|
2216
|
+
const i = this._index();
|
|
2217
|
+
return i >= 0 ? (this._steps()[i] ?? null) : null;
|
|
2218
|
+
}, /* @ts-ignore */
|
|
2219
|
+
...(ngDevMode ? [{ debugName: "currentStep" }] : /* istanbul ignore next */ []));
|
|
2220
|
+
popupRef;
|
|
2221
|
+
scrim;
|
|
2222
|
+
snapshot;
|
|
2223
|
+
endResolve;
|
|
2224
|
+
endPromise = Promise.resolve();
|
|
2225
|
+
/** Element focused before start(); focus is restored to it on end(). */
|
|
2226
|
+
previouslyFocused = null;
|
|
2227
|
+
constructor() {
|
|
2228
|
+
// If a tour is still active when the app (root injector) is torn down,
|
|
2229
|
+
// end it — otherwise the capture-phase document keydown listener, scrim
|
|
2230
|
+
// and popup DOM would outlive the application.
|
|
2231
|
+
inject(DestroyRef).onDestroy(() => this.end());
|
|
2232
|
+
}
|
|
2233
|
+
/**
|
|
2234
|
+
* Begin a tour at step 0. Returns a promise that resolves when the tour ends
|
|
2235
|
+
* (finished, skipped, or Escaped).
|
|
2236
|
+
*/
|
|
2237
|
+
start(steps) {
|
|
2238
|
+
if (!this.isBrowser || steps.length === 0)
|
|
2239
|
+
return Promise.resolve();
|
|
2240
|
+
// Restart cleanly if one is already running.
|
|
2241
|
+
if (this._running())
|
|
2242
|
+
this.end();
|
|
2243
|
+
this.endPromise = new Promise((resolve) => {
|
|
2244
|
+
this.endResolve = resolve;
|
|
2245
|
+
});
|
|
2246
|
+
this.previouslyFocused = this.document
|
|
2247
|
+
.activeElement;
|
|
2248
|
+
this._steps.set([...steps]);
|
|
2249
|
+
this._running.set(true);
|
|
2250
|
+
this.document.addEventListener('keydown', this.onKeydown, true);
|
|
2251
|
+
this.ensureScrim();
|
|
2252
|
+
this.showStep(0, 1);
|
|
2253
|
+
return this.endPromise;
|
|
2254
|
+
}
|
|
2255
|
+
/** Resolves when the active (or next) tour ends. */
|
|
2256
|
+
afterEnd() {
|
|
2257
|
+
return this.endPromise;
|
|
2258
|
+
}
|
|
2259
|
+
/** Advance to the next step, or finish on the last step. */
|
|
2260
|
+
next() {
|
|
2261
|
+
if (!this._running())
|
|
2262
|
+
return;
|
|
2263
|
+
this.showStep(this._index() + 1, 1);
|
|
2264
|
+
}
|
|
2265
|
+
/** Return to the previous step (no-op at the first step). */
|
|
2266
|
+
prev() {
|
|
2267
|
+
if (!this._running() || this._index() <= 0)
|
|
2268
|
+
return;
|
|
2269
|
+
this.showStep(this._index() - 1, -1);
|
|
2270
|
+
}
|
|
2271
|
+
/** End the tour and clean up all DOM, highlight, and listeners. */
|
|
2272
|
+
end() {
|
|
2273
|
+
if (!this._running())
|
|
2274
|
+
return;
|
|
2275
|
+
this._running.set(false);
|
|
2276
|
+
this.teardownStep();
|
|
2277
|
+
this.removeScrim();
|
|
2278
|
+
this.document.removeEventListener('keydown', this.onKeydown, true);
|
|
2279
|
+
this._index.set(-1);
|
|
2280
|
+
this._steps.set([]);
|
|
2281
|
+
// Return focus to wherever the user was before the tour started
|
|
2282
|
+
// (Done/Skip/Escape all funnel through here).
|
|
2283
|
+
this.previouslyFocused?.focus?.();
|
|
2284
|
+
this.previouslyFocused = null;
|
|
2285
|
+
const resolve = this.endResolve;
|
|
2286
|
+
this.endResolve = undefined;
|
|
2287
|
+
resolve?.();
|
|
2288
|
+
}
|
|
2289
|
+
onKeydown = (e) => {
|
|
2290
|
+
if (e.key === 'Escape') {
|
|
2291
|
+
e.stopPropagation();
|
|
2292
|
+
this.end();
|
|
2293
|
+
}
|
|
2294
|
+
};
|
|
2295
|
+
/**
|
|
2296
|
+
* Render the step at `index`. Missing targets are skipped by continuing in
|
|
2297
|
+
* `dir` (+1 forward, -1 back); if none remain the tour ends.
|
|
2298
|
+
*/
|
|
2299
|
+
showStep(index, dir) {
|
|
2300
|
+
this.teardownStep();
|
|
2301
|
+
const steps = this._steps();
|
|
2302
|
+
let i = index;
|
|
2303
|
+
let target = null;
|
|
2304
|
+
while (i >= 0 && i < steps.length) {
|
|
2305
|
+
target = this.resolveTarget(steps[i].target);
|
|
2306
|
+
if (target)
|
|
2307
|
+
break;
|
|
2308
|
+
i += dir;
|
|
2309
|
+
}
|
|
2310
|
+
if (!target || i < 0 || i >= steps.length) {
|
|
2311
|
+
this.end();
|
|
2312
|
+
return;
|
|
2313
|
+
}
|
|
2314
|
+
this._index.set(i);
|
|
2315
|
+
this.highlight(target);
|
|
2316
|
+
target.scrollIntoView?.({ block: 'center', inline: 'center', behavior: 'smooth' });
|
|
2317
|
+
const data = {
|
|
2318
|
+
step: steps[i],
|
|
2319
|
+
index: i,
|
|
2320
|
+
total: steps.length,
|
|
2321
|
+
anchor: target,
|
|
2322
|
+
controller: {
|
|
2323
|
+
next: () => this.next(),
|
|
2324
|
+
prev: () => this.prev(),
|
|
2325
|
+
end: () => this.end(),
|
|
2326
|
+
},
|
|
2327
|
+
};
|
|
2328
|
+
const ref = createComponent(MkTourPopup, {
|
|
2329
|
+
environmentInjector: this.envInjector,
|
|
2330
|
+
elementInjector: Injector.create({
|
|
2331
|
+
parent: this.envInjector,
|
|
2332
|
+
providers: [{ provide: MK_TOUR_DATA, useValue: data }],
|
|
2333
|
+
}),
|
|
2334
|
+
});
|
|
2335
|
+
this.appRef.attachView(ref.hostView);
|
|
2336
|
+
this.document.body.appendChild(ref.location.nativeElement);
|
|
2337
|
+
this.popupRef = ref;
|
|
2338
|
+
}
|
|
2339
|
+
/** Destroy the current popup and restore the current target's styles. */
|
|
2340
|
+
teardownStep() {
|
|
2341
|
+
if (this.popupRef) {
|
|
2342
|
+
// At app teardown the ApplicationRef destroys (and detaches) its views
|
|
2343
|
+
// before injector destroy hooks run — don't touch it then.
|
|
2344
|
+
if (!this.appRef.destroyed)
|
|
2345
|
+
this.appRef.detachView(this.popupRef.hostView);
|
|
2346
|
+
this.popupRef.destroy();
|
|
2347
|
+
this.popupRef.location.nativeElement.remove();
|
|
2348
|
+
this.popupRef = undefined;
|
|
2349
|
+
}
|
|
2350
|
+
this.unhighlight();
|
|
2351
|
+
}
|
|
2352
|
+
resolveTarget(target) {
|
|
2353
|
+
if (typeof target !== 'string') {
|
|
2354
|
+
return target.isConnected ? target : null;
|
|
2355
|
+
}
|
|
2356
|
+
return this.document.querySelector(target);
|
|
2357
|
+
}
|
|
2358
|
+
/** Raise the target above the scrim and draw a bright ring around it. */
|
|
2359
|
+
highlight(el) {
|
|
2360
|
+
const style = el.style;
|
|
2361
|
+
this.snapshot = {
|
|
2362
|
+
el,
|
|
2363
|
+
position: style.position,
|
|
2364
|
+
zIndex: style.zIndex,
|
|
2365
|
+
boxShadow: style.boxShadow,
|
|
2366
|
+
borderRadius: style.borderRadius,
|
|
2367
|
+
transition: style.transition,
|
|
2368
|
+
};
|
|
2369
|
+
el.classList.add('mk-tour-target');
|
|
2370
|
+
if (getComputedStyle(el).position === 'static') {
|
|
2371
|
+
style.position = 'relative';
|
|
2372
|
+
}
|
|
2373
|
+
style.setProperty('z-index', 'calc(var(--mk-z-overlay, 1000) + 1)');
|
|
2374
|
+
style.boxShadow =
|
|
2375
|
+
'0 0 0 3px var(--mk-primary), 0 0 0 6px var(--mk-primary-contrast)';
|
|
2376
|
+
if (!style.borderRadius)
|
|
2377
|
+
style.borderRadius = 'var(--mk-radius-md)';
|
|
2378
|
+
style.transition = 'box-shadow var(--mk-duration-fast, 140ms) ease';
|
|
2379
|
+
}
|
|
2380
|
+
unhighlight() {
|
|
2381
|
+
const snap = this.snapshot;
|
|
2382
|
+
if (!snap)
|
|
2383
|
+
return;
|
|
2384
|
+
const { el } = snap;
|
|
2385
|
+
el.classList.remove('mk-tour-target');
|
|
2386
|
+
el.style.position = snap.position;
|
|
2387
|
+
el.style.zIndex = snap.zIndex;
|
|
2388
|
+
el.style.boxShadow = snap.boxShadow;
|
|
2389
|
+
el.style.borderRadius = snap.borderRadius;
|
|
2390
|
+
el.style.transition = snap.transition;
|
|
2391
|
+
this.snapshot = undefined;
|
|
2392
|
+
}
|
|
2393
|
+
ensureScrim() {
|
|
2394
|
+
if (this.scrim)
|
|
2395
|
+
return;
|
|
2396
|
+
const scrim = this.document.createElement('div');
|
|
2397
|
+
scrim.className = 'mk-tour-scrim';
|
|
2398
|
+
scrim.style.cssText =
|
|
2399
|
+
'position:fixed;inset:0;background:var(--mk-overlay-scrim,rgba(0,0,0,0.45));animation:mk-overlay-fade var(--mk-duration-fast,140ms) var(--mk-ease-out,ease);';
|
|
2400
|
+
scrim.style.setProperty('z-index', 'var(--mk-z-overlay, 1000)');
|
|
2401
|
+
this.document.body.appendChild(scrim);
|
|
2402
|
+
this.scrim = scrim;
|
|
2403
|
+
}
|
|
2404
|
+
removeScrim() {
|
|
2405
|
+
this.scrim?.remove();
|
|
2406
|
+
this.scrim = undefined;
|
|
2407
|
+
}
|
|
2408
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.7", ngImport: i0, type: MkTourService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
|
|
2409
|
+
static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "22.0.7", ngImport: i0, type: MkTourService, providedIn: 'root' });
|
|
2410
|
+
}
|
|
2411
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.7", ngImport: i0, type: MkTourService, decorators: [{
|
|
2412
|
+
type: Injectable,
|
|
2413
|
+
args: [{ providedIn: 'root' }]
|
|
2414
|
+
}], ctorParameters: () => [] });
|
|
2415
|
+
|
|
2416
|
+
/**
|
|
2417
|
+
* Feedback & Overlay component group barrel.
|
|
2418
|
+
* Re-exports every public symbol (components, directives, services, types).
|
|
2419
|
+
*/
|
|
2420
|
+
|
|
2421
|
+
/**
|
|
2422
|
+
* Generated bundle index. Do not edit.
|
|
2423
|
+
*/
|
|
2424
|
+
|
|
2425
|
+
export { MK_TOUR_DATA, MkAlert, MkBanner, MkBottomSheet, MkBottomSheetService, MkConfirmDialog, MkDialog, MkDialogService, MkDialogTitle, MkHovercard, MkHovercardTrigger, MkLoadingBar, MkLoadingBarService, MkNotificationCenter, MkPopconfirm, MkPopconfirmTrigger, MkPopover, MkPopoverTrigger, MkPromptDialog, MkResult, MkSnackbar, MkSnackbarContainer, MkSnackbarRef, MkSnackbarService, MkToast, MkToastContainer, MkToastService, MkTooltip, MkTooltipPanel, MkTourPopup, MkTourService };
|
|
2426
|
+
//# sourceMappingURL=mk-kit-ui-feedback.mjs.map
|