forty-cdk 0.2.0 → 0.3.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/accordion/README.md +122 -0
- package/aspect-ratio/README.md +76 -0
- package/avatar/README.md +100 -0
- package/breadcrumbs/README.md +49 -0
- package/breakpoints/README.md +81 -0
- package/button/README.md +49 -0
- package/calendar/README.md +458 -0
- package/carousel/README.md +358 -0
- package/checkbox/README.md +146 -0
- package/combobox/README.md +535 -0
- package/context-menu/README.md +139 -0
- package/date-field/README.md +184 -0
- package/date-picker/README.md +338 -0
- package/dialog/README.md +388 -0
- package/disclosure/README.md +114 -0
- package/drag-drop/README.md +359 -0
- package/drawer/README.md +560 -0
- package/dropdown-menu/README.md +176 -0
- package/fesm2022/forty-cdk-accordion.mjs +348 -0
- package/fesm2022/forty-cdk-accordion.mjs.map +1 -0
- package/fesm2022/forty-cdk-aspect-ratio.mjs +74 -0
- package/fesm2022/forty-cdk-aspect-ratio.mjs.map +1 -0
- package/fesm2022/forty-cdk-avatar.mjs +308 -0
- package/fesm2022/forty-cdk-avatar.mjs.map +1 -0
- package/fesm2022/forty-cdk-breadcrumbs.mjs +125 -0
- package/fesm2022/forty-cdk-breadcrumbs.mjs.map +1 -0
- package/fesm2022/forty-cdk-breakpoints.mjs +117 -0
- package/fesm2022/forty-cdk-breakpoints.mjs.map +1 -0
- package/fesm2022/forty-cdk-button.mjs +134 -0
- package/fesm2022/forty-cdk-button.mjs.map +1 -0
- package/fesm2022/forty-cdk-calendar.mjs +2034 -0
- package/fesm2022/forty-cdk-calendar.mjs.map +1 -0
- package/fesm2022/forty-cdk-carousel.mjs +968 -0
- package/fesm2022/forty-cdk-carousel.mjs.map +1 -0
- package/fesm2022/forty-cdk-checkbox.mjs +226 -0
- package/fesm2022/forty-cdk-checkbox.mjs.map +1 -0
- package/fesm2022/forty-cdk-combobox.mjs +2596 -0
- package/fesm2022/forty-cdk-combobox.mjs.map +1 -0
- package/fesm2022/forty-cdk-context-menu.mjs +413 -0
- package/fesm2022/forty-cdk-context-menu.mjs.map +1 -0
- package/fesm2022/forty-cdk-core.mjs +9022 -0
- package/fesm2022/forty-cdk-core.mjs.map +1 -0
- package/fesm2022/forty-cdk-date-field.mjs +744 -0
- package/fesm2022/forty-cdk-date-field.mjs.map +1 -0
- package/fesm2022/forty-cdk-date-picker.mjs +1011 -0
- package/fesm2022/forty-cdk-date-picker.mjs.map +1 -0
- package/fesm2022/forty-cdk-dialog.mjs +707 -0
- package/fesm2022/forty-cdk-dialog.mjs.map +1 -0
- package/fesm2022/forty-cdk-disclosure.mjs +190 -0
- package/fesm2022/forty-cdk-disclosure.mjs.map +1 -0
- package/fesm2022/forty-cdk-drag-drop.mjs +1180 -0
- package/fesm2022/forty-cdk-drag-drop.mjs.map +1 -0
- package/fesm2022/forty-cdk-drawer.mjs +1641 -0
- package/fesm2022/forty-cdk-drawer.mjs.map +1 -0
- package/fesm2022/forty-cdk-dropdown-menu.mjs +350 -0
- package/fesm2022/forty-cdk-dropdown-menu.mjs.map +1 -0
- package/fesm2022/forty-cdk-field.mjs +425 -0
- package/fesm2022/forty-cdk-field.mjs.map +1 -0
- package/fesm2022/forty-cdk-fieldset.mjs +164 -0
- package/fesm2022/forty-cdk-fieldset.mjs.map +1 -0
- package/fesm2022/forty-cdk-file-upload.mjs +221 -0
- package/fesm2022/forty-cdk-file-upload.mjs.map +1 -0
- package/fesm2022/forty-cdk-hover-card.mjs +496 -0
- package/fesm2022/forty-cdk-hover-card.mjs.map +1 -0
- package/fesm2022/forty-cdk-input.mjs +274 -0
- package/fesm2022/forty-cdk-input.mjs.map +1 -0
- package/fesm2022/forty-cdk-internationalized-date.mjs +1 -1
- package/fesm2022/forty-cdk-internationalized-date.mjs.map +1 -1
- package/fesm2022/forty-cdk-listbox.mjs +1279 -0
- package/fesm2022/forty-cdk-listbox.mjs.map +1 -0
- package/fesm2022/forty-cdk-menu.mjs +1439 -0
- package/fesm2022/forty-cdk-menu.mjs.map +1 -0
- package/fesm2022/forty-cdk-menubar.mjs +787 -0
- package/fesm2022/forty-cdk-menubar.mjs.map +1 -0
- package/fesm2022/forty-cdk-meter.mjs +211 -0
- package/fesm2022/forty-cdk-meter.mjs.map +1 -0
- package/fesm2022/forty-cdk-navigation-menu.mjs +1145 -0
- package/fesm2022/forty-cdk-navigation-menu.mjs.map +1 -0
- package/fesm2022/forty-cdk-number-input.mjs +559 -0
- package/fesm2022/forty-cdk-number-input.mjs.map +1 -0
- package/fesm2022/forty-cdk-otp-input.mjs +527 -0
- package/fesm2022/forty-cdk-otp-input.mjs.map +1 -0
- package/fesm2022/forty-cdk-pagination.mjs +323 -0
- package/fesm2022/forty-cdk-pagination.mjs.map +1 -0
- package/fesm2022/forty-cdk-pane-resizer.mjs +297 -0
- package/fesm2022/forty-cdk-pane-resizer.mjs.map +1 -0
- package/fesm2022/forty-cdk-popover.mjs +698 -0
- package/fesm2022/forty-cdk-popover.mjs.map +1 -0
- package/fesm2022/forty-cdk-progress.mjs +226 -0
- package/fesm2022/forty-cdk-progress.mjs.map +1 -0
- package/fesm2022/forty-cdk-radio-group.mjs +378 -0
- package/fesm2022/forty-cdk-radio-group.mjs.map +1 -0
- package/fesm2022/forty-cdk-scroll-area.mjs +640 -0
- package/fesm2022/forty-cdk-scroll-area.mjs.map +1 -0
- package/fesm2022/forty-cdk-search.mjs +205 -0
- package/fesm2022/forty-cdk-search.mjs.map +1 -0
- package/fesm2022/forty-cdk-select.mjs +1661 -0
- package/fesm2022/forty-cdk-select.mjs.map +1 -0
- package/fesm2022/forty-cdk-separator.mjs +82 -0
- package/fesm2022/forty-cdk-separator.mjs.map +1 -0
- package/fesm2022/forty-cdk-signal-forms.mjs +97 -0
- package/fesm2022/forty-cdk-signal-forms.mjs.map +1 -0
- package/fesm2022/forty-cdk-slider.mjs +803 -0
- package/fesm2022/forty-cdk-slider.mjs.map +1 -0
- package/fesm2022/forty-cdk-stepper.mjs +886 -0
- package/fesm2022/forty-cdk-stepper.mjs.map +1 -0
- package/fesm2022/forty-cdk-switch.mjs +137 -0
- package/fesm2022/forty-cdk-switch.mjs.map +1 -0
- package/fesm2022/forty-cdk-table.mjs +1518 -0
- package/fesm2022/forty-cdk-table.mjs.map +1 -0
- package/fesm2022/forty-cdk-tabs.mjs +400 -0
- package/fesm2022/forty-cdk-tabs.mjs.map +1 -0
- package/fesm2022/forty-cdk-time-field.mjs +593 -0
- package/fesm2022/forty-cdk-time-field.mjs.map +1 -0
- package/fesm2022/forty-cdk-time-picker.mjs +1013 -0
- package/fesm2022/forty-cdk-time-picker.mjs.map +1 -0
- package/fesm2022/forty-cdk-toast.mjs +1153 -0
- package/fesm2022/forty-cdk-toast.mjs.map +1 -0
- package/fesm2022/forty-cdk-toggle.mjs +516 -0
- package/fesm2022/forty-cdk-toggle.mjs.map +1 -0
- package/fesm2022/forty-cdk-toolbar.mjs +374 -0
- package/fesm2022/forty-cdk-toolbar.mjs.map +1 -0
- package/fesm2022/forty-cdk-tooltip.mjs +672 -0
- package/fesm2022/forty-cdk-tooltip.mjs.map +1 -0
- package/fesm2022/forty-cdk-tree.mjs +2007 -0
- package/fesm2022/forty-cdk-tree.mjs.map +1 -0
- package/fesm2022/forty-cdk-virtualization.mjs +1 -1
- package/fesm2022/forty-cdk-virtualization.mjs.map +1 -1
- package/fesm2022/forty-cdk.mjs +0 -43310
- package/fesm2022/forty-cdk.mjs.map +1 -1
- package/field/README.md +97 -0
- package/fieldset/README.md +86 -0
- package/file-upload/README.md +73 -0
- package/hover-card/README.md +171 -0
- package/input/README.md +156 -0
- package/listbox/README.md +424 -0
- package/menu/README.md +181 -0
- package/menubar/README.md +140 -0
- package/meter/README.md +128 -0
- package/navigation-menu/README.md +253 -0
- package/number-input/README.md +171 -0
- package/otp-input/README.md +198 -0
- package/package.json +213 -1
- package/pagination/README.md +61 -0
- package/pane-resizer/README.md +136 -0
- package/popover/README.md +262 -0
- package/progress/README.md +115 -0
- package/radio-group/README.md +129 -0
- package/scroll-area/README.md +184 -0
- package/search/README.md +42 -0
- package/select/README.md +488 -0
- package/separator/README.md +84 -0
- package/signal-forms/README.md +72 -0
- package/slider/README.md +152 -0
- package/stepper/README.md +292 -0
- package/switch/README.md +116 -0
- package/table/README.md +769 -0
- package/tabs/README.md +130 -0
- package/time-field/README.md +157 -0
- package/time-picker/README.md +172 -0
- package/toast/README.md +398 -0
- package/toggle/README.md +224 -0
- package/toolbar/README.md +109 -0
- package/tooltip/README.md +274 -0
- package/tree/README.md +708 -0
- package/types/forty-cdk-accordion.d.ts +242 -0
- package/types/forty-cdk-aspect-ratio.d.ts +59 -0
- package/types/forty-cdk-avatar.d.ts +133 -0
- package/types/forty-cdk-breadcrumbs.d.ts +92 -0
- package/types/forty-cdk-breakpoints.d.ts +141 -0
- package/types/forty-cdk-button.d.ts +80 -0
- package/types/forty-cdk-calendar.d.ts +914 -0
- package/types/forty-cdk-carousel.d.ts +530 -0
- package/types/forty-cdk-checkbox.d.ts +141 -0
- package/types/forty-cdk-combobox.d.ts +1259 -0
- package/types/forty-cdk-context-menu.d.ts +313 -0
- package/types/forty-cdk-core.d.ts +5774 -0
- package/types/forty-cdk-date-field.d.ts +307 -0
- package/types/forty-cdk-date-picker.d.ts +622 -0
- package/types/forty-cdk-dialog.d.ts +546 -0
- package/types/forty-cdk-disclosure.d.ts +127 -0
- package/types/forty-cdk-drag-drop.d.ts +456 -0
- package/types/forty-cdk-drawer.d.ts +871 -0
- package/types/forty-cdk-dropdown-menu.d.ts +242 -0
- package/types/forty-cdk-field.d.ts +236 -0
- package/types/forty-cdk-fieldset.d.ts +119 -0
- package/types/forty-cdk-file-upload.d.ts +124 -0
- package/types/forty-cdk-hover-card.d.ts +320 -0
- package/types/forty-cdk-input.d.ts +169 -0
- package/types/forty-cdk-internationalized-date.d.ts +1 -1
- package/types/forty-cdk-listbox.d.ts +513 -0
- package/types/forty-cdk-menu.d.ts +629 -0
- package/types/forty-cdk-menubar.d.ts +451 -0
- package/types/forty-cdk-meter.d.ts +122 -0
- package/types/forty-cdk-navigation-menu.d.ts +514 -0
- package/types/forty-cdk-number-input.d.ts +319 -0
- package/types/forty-cdk-otp-input.d.ts +248 -0
- package/types/forty-cdk-pagination.d.ts +214 -0
- package/types/forty-cdk-pane-resizer.d.ts +145 -0
- package/types/forty-cdk-popover.d.ts +509 -0
- package/types/forty-cdk-progress.d.ts +143 -0
- package/types/forty-cdk-radio-group.d.ts +222 -0
- package/types/forty-cdk-scroll-area.d.ts +258 -0
- package/types/forty-cdk-search.d.ts +142 -0
- package/types/forty-cdk-select.d.ts +899 -0
- package/types/forty-cdk-separator.d.ts +59 -0
- package/types/forty-cdk-signal-forms.d.ts +58 -0
- package/types/forty-cdk-slider.d.ts +379 -0
- package/types/forty-cdk-stepper.d.ts +650 -0
- package/types/forty-cdk-switch.d.ts +87 -0
- package/types/forty-cdk-table.d.ts +723 -0
- package/types/forty-cdk-tabs.d.ts +235 -0
- package/types/forty-cdk-time-field.d.ts +307 -0
- package/types/forty-cdk-time-picker.d.ts +578 -0
- package/types/forty-cdk-toast.d.ts +598 -0
- package/types/forty-cdk-toggle.d.ts +310 -0
- package/types/forty-cdk-toolbar.d.ts +217 -0
- package/types/forty-cdk-tooltip.d.ts +436 -0
- package/types/forty-cdk-tree.d.ts +688 -0
- package/types/forty-cdk.d.ts +1 -19743
|
@@ -0,0 +1,1153 @@
|
|
|
1
|
+
import * as i0 from '@angular/core';
|
|
2
|
+
import { InjectionToken, inject, ElementRef, DestroyRef, input, numberAttribute, booleanAttribute, output, signal, computed, effect, untracked, Directive, DOCUMENT, Injectable, afterEveryRender, Injector, isDevMode, ChangeDetectionStrategy, Component } from '@angular/core';
|
|
3
|
+
import { LiveAnnouncer, injectPauseController, attachSwipeDismiss, createDefaults, IdGenerator, registerHandle, resolveHostId, resolveConfigClass } from 'forty-cdk/core';
|
|
4
|
+
import { NgTemplateOutlet } from '@angular/common';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Region a toast belongs to. Toasts route to the `<for-toast-viewport>` whose
|
|
8
|
+
* `[region]` matches; an unspecified region falls through to this default, so
|
|
9
|
+
* the common single-viewport setup needs no `region` anywhere.
|
|
10
|
+
*/
|
|
11
|
+
const DEFAULT_TOAST_REGION = 'default';
|
|
12
|
+
const FOR_TOAST_CONTEXT = new InjectionToken('FOR_TOAST_CONTEXT');
|
|
13
|
+
function injectToastContext(piece) {
|
|
14
|
+
const ctx = inject(FOR_TOAST_CONTEXT, { optional: true });
|
|
15
|
+
if (!ctx) {
|
|
16
|
+
throw new Error(`[forty-cdk/toast] ${piece} must be used inside a [forToast] element.`);
|
|
17
|
+
}
|
|
18
|
+
return ctx;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* One toast notification. Apply on a `<div>` (or `<output>`, `<section>`)
|
|
23
|
+
* — the directive sets `role="status"` (`'info'` / `'success'` / `'warning'`)
|
|
24
|
+
* or `role="alert"` (`'error'`), `aria-live`, and the timer / pause
|
|
25
|
+
* machinery.
|
|
26
|
+
*
|
|
27
|
+
* The directive does **not** control its own visibility. The consumer
|
|
28
|
+
* mounts it (typically through `<for-toast-viewport>` for programmatic
|
|
29
|
+
* toasts, or directly with `@if` for declarative ones) and unmounts it
|
|
30
|
+
* when `(dismiss)` fires. Pair the unmount with `animate.leave="…"` for
|
|
31
|
+
* exit animations.
|
|
32
|
+
*
|
|
33
|
+
* Behavior:
|
|
34
|
+
* - Auto-dismisses after `duration` ms (default 5000). `0` is sticky.
|
|
35
|
+
* - Hovering or focusing the toast pauses the timer; leaving / blurring
|
|
36
|
+
* resumes with the remaining time.
|
|
37
|
+
* - Escape (while focus is inside) closes the toast.
|
|
38
|
+
* - When `[swipeDirection]` is set, the user can drag past
|
|
39
|
+
* `[swipeThreshold]` (default 50 px) to dismiss. While the gesture is
|
|
40
|
+
* live the host reflects `data-swipe="start" | "move" | "cancel" | "end"`,
|
|
41
|
+
* `data-swipe-direction`, and the CSS variables
|
|
42
|
+
* `--for-toast-swipe-movement-x` / `--for-toast-swipe-movement-y`.
|
|
43
|
+
* - The host carries `data-state="open"` while alive (no `closed` state —
|
|
44
|
+
* the consumer unmounts on close).
|
|
45
|
+
*/
|
|
46
|
+
class ForToast {
|
|
47
|
+
#host = inject(ElementRef);
|
|
48
|
+
#destroyRef = inject(DestroyRef);
|
|
49
|
+
#announcer = inject(LiveAnnouncer);
|
|
50
|
+
variant = input('info', /* @ts-ignore */
|
|
51
|
+
...(ngDevMode ? [{ debugName: "variant" }] : /* istanbul ignore next */ []));
|
|
52
|
+
/** Auto-dismiss timer in ms. `0` keeps the toast sticky. */
|
|
53
|
+
duration = input(5000, { ...(ngDevMode ? { debugName: "duration" } : /* istanbul ignore next */ {}), transform: numberAttribute });
|
|
54
|
+
/**
|
|
55
|
+
* Whether the user can dismiss the toast through an ambient gesture —
|
|
56
|
+
* Escape, the close button, the auto-dismiss timer, and swipe. Defaults to
|
|
57
|
+
* `true`.
|
|
58
|
+
*
|
|
59
|
+
* Set `false` for a sticky / forced-action toast: every ambient gesture is
|
|
60
|
+
* suppressed, no auto-dismiss timer is scheduled, and a `[forToastClose]`
|
|
61
|
+
* button (if present) becomes inert. The **action button stays live and is
|
|
62
|
+
* the sanctioned dismissal path** — `[forToastAction]` still emits `(dismiss)`
|
|
63
|
+
* with reason `'action'` so a forced-action toast (e.g. "Update available —
|
|
64
|
+
* Reload") can be dismissed by the only control the user is meant to use.
|
|
65
|
+
* Programmatic close via `ForToastRef.dismiss()` is also always honored.
|
|
66
|
+
*/
|
|
67
|
+
closable = input(true, { ...(ngDevMode ? { debugName: "closable" } : /* istanbul ignore next */ {}), transform: booleanAttribute });
|
|
68
|
+
/**
|
|
69
|
+
* Direction(s) the user can swipe to dismiss the toast. Pass a
|
|
70
|
+
* single direction (`'right'`) or an array of directions
|
|
71
|
+
* (`['right', 'down']`). `null` (default) disables swipe.
|
|
72
|
+
*
|
|
73
|
+
* The swipe gesture uses pointer events, so it works for both touch
|
|
74
|
+
* and mouse drags. While the user is swiping, the host reflects
|
|
75
|
+
* `data-swipe="start" | "move" | "cancel" | "end"` and
|
|
76
|
+
* `data-swipe-direction` plus the CSS custom properties
|
|
77
|
+
* `--for-toast-swipe-movement-x` / `--for-toast-swipe-movement-y` so the
|
|
78
|
+
* consumer can drive a transform-based animation entirely from CSS.
|
|
79
|
+
*/
|
|
80
|
+
swipeDirection = input(null, /* @ts-ignore */
|
|
81
|
+
...(ngDevMode ? [{ debugName: "swipeDirection" }] : /* istanbul ignore next */ []));
|
|
82
|
+
/**
|
|
83
|
+
* Pixels of pointer travel along the active swipe direction needed
|
|
84
|
+
* to commit a dismissal. Defaults to `50`.
|
|
85
|
+
*/
|
|
86
|
+
swipeThreshold = input(50, { ...(ngDevMode ? { debugName: "swipeThreshold" } : /* istanbul ignore next */ {}), transform: numberAttribute });
|
|
87
|
+
/** Emitted when the toast wants to be unmounted. The consumer reacts by removing it from the rendered list. */
|
|
88
|
+
dismiss = output();
|
|
89
|
+
/** Fired once on the move that arms the swipe. */
|
|
90
|
+
swipeStart = output();
|
|
91
|
+
/** Fired on every pointer move while the swipe is active. */
|
|
92
|
+
swipeMove = output();
|
|
93
|
+
/** Fired on pointer-up after the threshold is crossed (immediately followed by `(dismiss)`). */
|
|
94
|
+
swipeEnd = output();
|
|
95
|
+
/** Fired on pointer-up before the threshold, or on `pointercancel`. */
|
|
96
|
+
swipeCancel = output();
|
|
97
|
+
#pause;
|
|
98
|
+
paused;
|
|
99
|
+
#swipeState = signal(null, /* @ts-ignore */
|
|
100
|
+
...(ngDevMode ? [{ debugName: "#swipeState" }] : /* istanbul ignore next */ []));
|
|
101
|
+
#swipeActiveDirection = signal(null, /* @ts-ignore */
|
|
102
|
+
...(ngDevMode ? [{ debugName: "#swipeActiveDirection" }] : /* istanbul ignore next */ []));
|
|
103
|
+
#swipeMovementX = signal(0, /* @ts-ignore */
|
|
104
|
+
...(ngDevMode ? [{ debugName: "#swipeMovementX" }] : /* istanbul ignore next */ []));
|
|
105
|
+
#swipeMovementY = signal(0, /* @ts-ignore */
|
|
106
|
+
...(ngDevMode ? [{ debugName: "#swipeMovementY" }] : /* istanbul ignore next */ []));
|
|
107
|
+
swipeState = this.#swipeState.asReadonly();
|
|
108
|
+
swipeActiveDirection = this.#swipeActiveDirection.asReadonly();
|
|
109
|
+
swipeMovementX = this.#swipeMovementX.asReadonly();
|
|
110
|
+
swipeMovementY = this.#swipeMovementY.asReadonly();
|
|
111
|
+
#normalizedSwipeDirections = computed(() => {
|
|
112
|
+
// A non-closable toast forbids user-initiated dismissal of any kind —
|
|
113
|
+
// swipe is exactly that, so disable it regardless of the input.
|
|
114
|
+
if (!this.closable()) {
|
|
115
|
+
return [];
|
|
116
|
+
}
|
|
117
|
+
const raw = this.swipeDirection();
|
|
118
|
+
if (raw === null || raw === undefined) {
|
|
119
|
+
return [];
|
|
120
|
+
}
|
|
121
|
+
if (typeof raw === 'string') {
|
|
122
|
+
return [raw];
|
|
123
|
+
}
|
|
124
|
+
return raw;
|
|
125
|
+
}, /* @ts-ignore */
|
|
126
|
+
...(ngDevMode ? [{ debugName: "#normalizedSwipeDirections" }] : /* istanbul ignore next */ []));
|
|
127
|
+
#labels = signal([], /* @ts-ignore */
|
|
128
|
+
...(ngDevMode ? [{ debugName: "#labels" }] : /* istanbul ignore next */ []));
|
|
129
|
+
#descriptions = signal([], /* @ts-ignore */
|
|
130
|
+
...(ngDevMode ? [{ debugName: "#descriptions" }] : /* istanbul ignore next */ []));
|
|
131
|
+
#actions = signal([], /* @ts-ignore */
|
|
132
|
+
...(ngDevMode ? [{ debugName: "#actions" }] : /* istanbul ignore next */ []));
|
|
133
|
+
labelledBy = computed(() => this.#labels()
|
|
134
|
+
.map((h) => h.id)
|
|
135
|
+
.join(' ') || null, /* @ts-ignore */
|
|
136
|
+
...(ngDevMode ? [{ debugName: "labelledBy" }] : /* istanbul ignore next */ []));
|
|
137
|
+
describedBy = computed(() => this.#descriptions()
|
|
138
|
+
.map((h) => h.id)
|
|
139
|
+
.join(' ') || null, /* @ts-ignore */
|
|
140
|
+
...(ngDevMode ? [{ debugName: "describedBy" }] : /* istanbul ignore next */ []));
|
|
141
|
+
/**
|
|
142
|
+
* `true` when at least one registered `[forToastAction]` carries a
|
|
143
|
+
* non-empty `altText`. In that mode the host's `aria-live` is silenced
|
|
144
|
+
* and the announcement is composed and routed through `LiveAnnouncer`.
|
|
145
|
+
*/
|
|
146
|
+
#hasActionAltText = computed(() => this.#actions().some((a) => a.altText().trim() !== ''), /* @ts-ignore */
|
|
147
|
+
...(ngDevMode ? [{ debugName: "#hasActionAltText" }] : /* istanbul ignore next */ []));
|
|
148
|
+
/**
|
|
149
|
+
* The synthesized announcement, composed reactively from the registered
|
|
150
|
+
* title / description text and action `altText` signals (never re-read from
|
|
151
|
+
* the DOM). Recomputes when any of those change — late-bound `altText` or a
|
|
152
|
+
* `ref.update()` text change flows through here, so the announcement effect
|
|
153
|
+
* re-fires on the edge instead of only once on first render.
|
|
154
|
+
*/
|
|
155
|
+
#announcement = computed(() => {
|
|
156
|
+
const titles = this.#labels()
|
|
157
|
+
.map((h) => h.text().trim())
|
|
158
|
+
.filter(Boolean);
|
|
159
|
+
const descriptions = this.#descriptions()
|
|
160
|
+
.map((h) => h.text().trim())
|
|
161
|
+
.filter(Boolean);
|
|
162
|
+
const altTexts = this.#actions()
|
|
163
|
+
.map((a) => a.altText().trim())
|
|
164
|
+
.filter(Boolean);
|
|
165
|
+
return [...titles, ...descriptions, ...altTexts].join('. ');
|
|
166
|
+
}, /* @ts-ignore */
|
|
167
|
+
...(ngDevMode ? [{ debugName: "#announcement" }] : /* istanbul ignore next */ []));
|
|
168
|
+
computedRole = computed(() => this.variant() === 'error' ? 'alert' : 'status', /* @ts-ignore */
|
|
169
|
+
...(ngDevMode ? [{ debugName: "computedRole" }] : /* istanbul ignore next */ []));
|
|
170
|
+
ariaLive = computed(() => {
|
|
171
|
+
if (this.#hasActionAltText()) {
|
|
172
|
+
return 'off';
|
|
173
|
+
}
|
|
174
|
+
return this.variant() === 'error' ? 'assertive' : 'polite';
|
|
175
|
+
}, /* @ts-ignore */
|
|
176
|
+
...(ngDevMode ? [{ debugName: "ariaLive" }] : /* istanbul ignore next */ []));
|
|
177
|
+
// Timer state — kept off the reactive graph because pause/resume are
|
|
178
|
+
// imperative and we don't want to trip change detection on every tick.
|
|
179
|
+
#timerHandle = null;
|
|
180
|
+
#timerEndsAt = 0;
|
|
181
|
+
#remainingMs = 0;
|
|
182
|
+
// True once the auto-dismiss countdown has begun, so `#remainingMs` holds a
|
|
183
|
+
// meaningful value. While paused this guards the duration effect from
|
|
184
|
+
// clobbering a captured remaining time on an unrelated re-run (e.g. an
|
|
185
|
+
// `update()` that re-renders the toast); see `#onPausedChange`.
|
|
186
|
+
#timerStarted = false;
|
|
187
|
+
constructor() {
|
|
188
|
+
// Multi-reason pause: pointer hover, focus, and page visibility each
|
|
189
|
+
// pause/resume independently. The remaining-ms capture/reschedule is
|
|
190
|
+
// layered on the `paused` transition via `onChange`, keeping the timer off
|
|
191
|
+
// the reactive graph (pause/resume are imperative, so a tick must not trip
|
|
192
|
+
// change detection). The shared controller wires the page-visibility source
|
|
193
|
+
// and tears its subscription down with this injector.
|
|
194
|
+
this.#pause = injectPauseController({
|
|
195
|
+
onChange: (paused) => this.#onPausedChange(paused),
|
|
196
|
+
});
|
|
197
|
+
this.paused = this.#pause.paused;
|
|
198
|
+
// Start (or reset) the auto-dismiss timer whenever `duration` /
|
|
199
|
+
// `closable` changes. Pause / resume are handled imperatively in
|
|
200
|
+
// `#onPausedChange`, so we read `paused()` via `untracked()` here —
|
|
201
|
+
// otherwise the effect would re-run on hover and clobber the in-flight
|
|
202
|
+
// remaining-ms capture. While paused with a countdown already in flight we
|
|
203
|
+
// bail out entirely so the captured remaining time survives a re-render;
|
|
204
|
+
// resume reschedules with that captured value rather than the full
|
|
205
|
+
// duration.
|
|
206
|
+
effect(() => {
|
|
207
|
+
const ms = this.duration();
|
|
208
|
+
const closable = this.closable();
|
|
209
|
+
const paused = untracked(this.paused);
|
|
210
|
+
if (paused && this.#timerStarted) {
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
this.#cancelTimer();
|
|
214
|
+
this.#remainingMs = ms;
|
|
215
|
+
this.#timerStarted = ms > 0 && closable;
|
|
216
|
+
if (this.#timerStarted && !paused) {
|
|
217
|
+
this.#scheduleTimer();
|
|
218
|
+
}
|
|
219
|
+
});
|
|
220
|
+
this.#destroyRef.onDestroy(() => this.#cancelTimer());
|
|
221
|
+
// Route the synthesized announcement through LiveAnnouncer reactively, so
|
|
222
|
+
// a late-bound `altText` and `ref.update()` text changes are announced —
|
|
223
|
+
// not only the value present on first render. Only active when at least
|
|
224
|
+
// one action carries `altText` (the host's silenced `aria-live` path);
|
|
225
|
+
// otherwise the host's own `aria-live` reads the toast and a manual
|
|
226
|
+
// announce would duplicate it. A guard against re-announcing identical
|
|
227
|
+
// text keeps an unrelated re-render from re-reading the same message.
|
|
228
|
+
// Pure side effect (an imperative `LiveAnnouncer` write), so `effect()` is
|
|
229
|
+
// the correct primitive here.
|
|
230
|
+
let lastAnnounced = null;
|
|
231
|
+
effect(() => {
|
|
232
|
+
if (!this.#hasActionAltText()) {
|
|
233
|
+
lastAnnounced = null;
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
const message = this.#announcement();
|
|
237
|
+
if (!message || message === lastAnnounced) {
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
lastAnnounced = message;
|
|
241
|
+
this.#announcer.announce(message, this.variant() === 'error' ? 'assertive' : 'polite');
|
|
242
|
+
});
|
|
243
|
+
const detachSwipe = attachSwipeDismiss({
|
|
244
|
+
element: this.#host.nativeElement,
|
|
245
|
+
getDirections: () => this.#normalizedSwipeDirections(),
|
|
246
|
+
getThreshold: () => this.swipeThreshold(),
|
|
247
|
+
onSwipeStart: (detail) => {
|
|
248
|
+
this.#swipeActiveDirection.set(detail.direction);
|
|
249
|
+
this.#swipeState.set('start');
|
|
250
|
+
this.#swipeMovementX.set(detail.delta.x);
|
|
251
|
+
this.#swipeMovementY.set(detail.delta.y);
|
|
252
|
+
this.swipeStart.emit(detail);
|
|
253
|
+
},
|
|
254
|
+
onSwipeMove: (detail) => {
|
|
255
|
+
this.#swipeState.set('move');
|
|
256
|
+
this.#swipeMovementX.set(detail.delta.x);
|
|
257
|
+
this.#swipeMovementY.set(detail.delta.y);
|
|
258
|
+
this.swipeMove.emit(detail);
|
|
259
|
+
},
|
|
260
|
+
onSwipeCancel: (detail) => {
|
|
261
|
+
this.#swipeState.set('cancel');
|
|
262
|
+
// Movement vars stay at the released delta so the consumer's CSS
|
|
263
|
+
// transition can spring them back to zero on its own timeline. The
|
|
264
|
+
// parked `data-swipe="cancel"` is cleared on the next `pointerdown`
|
|
265
|
+
// (see `onPointerDown`) so it never lingers into an unrelated gesture.
|
|
266
|
+
this.swipeCancel.emit(detail);
|
|
267
|
+
},
|
|
268
|
+
onSwipeEnd: (detail) => {
|
|
269
|
+
this.#swipeState.set('end');
|
|
270
|
+
this.#swipeMovementX.set(detail.delta.x);
|
|
271
|
+
this.#swipeMovementY.set(detail.delta.y);
|
|
272
|
+
this.swipeEnd.emit(detail);
|
|
273
|
+
this.requestClose('swipe');
|
|
274
|
+
},
|
|
275
|
+
});
|
|
276
|
+
this.#destroyRef.onDestroy(detachSwipe);
|
|
277
|
+
}
|
|
278
|
+
registerLabel(handle) {
|
|
279
|
+
this.#labels.update((arr) => (arr.includes(handle) ? arr : [...arr, handle]));
|
|
280
|
+
}
|
|
281
|
+
unregisterLabel(handle) {
|
|
282
|
+
this.#labels.update((arr) => arr.filter((h) => h !== handle));
|
|
283
|
+
}
|
|
284
|
+
registerDescription(handle) {
|
|
285
|
+
this.#descriptions.update((arr) => (arr.includes(handle) ? arr : [...arr, handle]));
|
|
286
|
+
}
|
|
287
|
+
unregisterDescription(handle) {
|
|
288
|
+
this.#descriptions.update((arr) => arr.filter((h) => h !== handle));
|
|
289
|
+
}
|
|
290
|
+
registerAction(handle) {
|
|
291
|
+
this.#actions.update((arr) => (arr.includes(handle) ? arr : [...arr, handle]));
|
|
292
|
+
}
|
|
293
|
+
unregisterAction(handle) {
|
|
294
|
+
this.#actions.update((arr) => arr.filter((h) => h !== handle));
|
|
295
|
+
}
|
|
296
|
+
requestClose(reason) {
|
|
297
|
+
if (!this.closable() && reason !== 'action' && reason !== 'programmatic') {
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
this.#cancelTimer();
|
|
301
|
+
this.dismiss.emit(reason);
|
|
302
|
+
}
|
|
303
|
+
/**
|
|
304
|
+
* Clear a parked swipe-cancel state at the start of the next pointer
|
|
305
|
+
* interaction. After a cancel the host keeps `data-swipe="cancel"` and the
|
|
306
|
+
* released movement vars so the consumer's CSS can spring the toast back; a
|
|
307
|
+
* fresh `pointerdown` is the terminal reset that returns the host to a clean
|
|
308
|
+
* slate (no stale `data-swipe`, zeroed movement vars) before a new gesture
|
|
309
|
+
* arms. A re-armed swipe overwrites the state anyway, so this only matters
|
|
310
|
+
* for the parked-after-cancel window.
|
|
311
|
+
*/
|
|
312
|
+
onPointerDown() {
|
|
313
|
+
if (this.#swipeState() === 'cancel') {
|
|
314
|
+
this.#resetSwipeState();
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
#resetSwipeState() {
|
|
318
|
+
this.#swipeState.set(null);
|
|
319
|
+
this.#swipeActiveDirection.set(null);
|
|
320
|
+
this.#swipeMovementX.set(0);
|
|
321
|
+
this.#swipeMovementY.set(0);
|
|
322
|
+
}
|
|
323
|
+
onPause(reason) {
|
|
324
|
+
this.#pause.apply(reason);
|
|
325
|
+
}
|
|
326
|
+
onResume(reason) {
|
|
327
|
+
this.#pause.release(reason);
|
|
328
|
+
}
|
|
329
|
+
onMaybeResumeFocus(event) {
|
|
330
|
+
const next = event.relatedTarget;
|
|
331
|
+
if (next && this.#host.nativeElement.contains(next)) {
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
this.onResume('focus');
|
|
335
|
+
}
|
|
336
|
+
onKeyDown(event) {
|
|
337
|
+
if (event.key !== 'Escape' || !this.closable()) {
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
event.preventDefault();
|
|
341
|
+
event.stopPropagation();
|
|
342
|
+
this.requestClose('escape');
|
|
343
|
+
}
|
|
344
|
+
#onPausedChange(paused) {
|
|
345
|
+
if (paused) {
|
|
346
|
+
// Pause: capture remaining time and cancel the running timer.
|
|
347
|
+
if (this.#timerHandle !== null) {
|
|
348
|
+
this.#remainingMs = Math.max(0, this.#timerEndsAt - Date.now());
|
|
349
|
+
this.#cancelTimer();
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
else if (this.duration() > 0 && this.closable() && this.#remainingMs > 0) {
|
|
353
|
+
this.#scheduleTimer();
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
#scheduleTimer() {
|
|
357
|
+
this.#cancelTimer();
|
|
358
|
+
this.#timerEndsAt = Date.now() + this.#remainingMs;
|
|
359
|
+
this.#timerHandle = setTimeout(() => {
|
|
360
|
+
this.#timerHandle = null;
|
|
361
|
+
this.requestClose('auto');
|
|
362
|
+
}, this.#remainingMs);
|
|
363
|
+
}
|
|
364
|
+
#cancelTimer() {
|
|
365
|
+
if (this.#timerHandle !== null) {
|
|
366
|
+
clearTimeout(this.#timerHandle);
|
|
367
|
+
this.#timerHandle = null;
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.2", ngImport: i0, type: ForToast, deps: [], target: i0.ɵɵFactoryTarget.Directive });
|
|
371
|
+
static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "22.0.2", type: ForToast, isStandalone: true, selector: "[forToast]", inputs: { variant: { classPropertyName: "variant", publicName: "variant", isSignal: true, isRequired: false, transformFunction: null }, duration: { classPropertyName: "duration", publicName: "duration", isSignal: true, isRequired: false, transformFunction: null }, closable: { classPropertyName: "closable", publicName: "closable", isSignal: true, isRequired: false, transformFunction: null }, swipeDirection: { classPropertyName: "swipeDirection", publicName: "swipeDirection", isSignal: true, isRequired: false, transformFunction: null }, swipeThreshold: { classPropertyName: "swipeThreshold", publicName: "swipeThreshold", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { dismiss: "dismiss", swipeStart: "swipeStart", swipeMove: "swipeMove", swipeEnd: "swipeEnd", swipeCancel: "swipeCancel" }, host: { attributes: { "aria-atomic": "true", "tabindex": "0" }, listeners: { "pointerdown": "onPointerDown()", "pointerenter": "onPause(\"hover\")", "pointerleave": "onResume(\"hover\")", "focusin": "onPause(\"focus\")", "focusout": "onMaybeResumeFocus($event)", "keydown": "onKeyDown($event)" }, properties: { "attr.role": "computedRole()", "attr.aria-live": "ariaLive()", "attr.aria-labelledby": "labelledBy()", "attr.aria-describedby": "describedBy()", "attr.data-state": "\"open\"", "attr.data-variant": "variant()", "attr.data-paused": "paused() ? \"\" : null", "attr.data-swipe": "swipeState()", "attr.data-swipe-direction": "swipeActiveDirection()", "style.--for-toast-swipe-movement-x.px": "swipeMovementX()", "style.--for-toast-swipe-movement-y.px": "swipeMovementY()" } }, providers: [{ provide: FOR_TOAST_CONTEXT, useExisting: ForToast }], exportAs: ["forToast"], ngImport: i0 });
|
|
372
|
+
}
|
|
373
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.2", ngImport: i0, type: ForToast, decorators: [{
|
|
374
|
+
type: Directive,
|
|
375
|
+
args: [{
|
|
376
|
+
selector: '[forToast]',
|
|
377
|
+
exportAs: 'forToast',
|
|
378
|
+
host: {
|
|
379
|
+
'[attr.role]': 'computedRole()',
|
|
380
|
+
'[attr.aria-live]': 'ariaLive()',
|
|
381
|
+
'aria-atomic': 'true',
|
|
382
|
+
'[attr.aria-labelledby]': 'labelledBy()',
|
|
383
|
+
'[attr.aria-describedby]': 'describedBy()',
|
|
384
|
+
tabindex: '0',
|
|
385
|
+
'[attr.data-state]': '"open"',
|
|
386
|
+
'[attr.data-variant]': 'variant()',
|
|
387
|
+
'[attr.data-paused]': 'paused() ? "" : null',
|
|
388
|
+
'[attr.data-swipe]': 'swipeState()',
|
|
389
|
+
'[attr.data-swipe-direction]': 'swipeActiveDirection()',
|
|
390
|
+
'[style.--for-toast-swipe-movement-x.px]': 'swipeMovementX()',
|
|
391
|
+
'[style.--for-toast-swipe-movement-y.px]': 'swipeMovementY()',
|
|
392
|
+
'(pointerdown)': 'onPointerDown()',
|
|
393
|
+
'(pointerenter)': 'onPause("hover")',
|
|
394
|
+
'(pointerleave)': 'onResume("hover")',
|
|
395
|
+
'(focusin)': 'onPause("focus")',
|
|
396
|
+
'(focusout)': 'onMaybeResumeFocus($event)',
|
|
397
|
+
'(keydown)': 'onKeyDown($event)',
|
|
398
|
+
},
|
|
399
|
+
providers: [{ provide: FOR_TOAST_CONTEXT, useExisting: ForToast }],
|
|
400
|
+
}]
|
|
401
|
+
}], ctorParameters: () => [], propDecorators: { variant: [{ type: i0.Input, args: [{ isSignal: true, alias: "variant", required: false }] }], duration: [{ type: i0.Input, args: [{ isSignal: true, alias: "duration", required: false }] }], closable: [{ type: i0.Input, args: [{ isSignal: true, alias: "closable", required: false }] }], swipeDirection: [{ type: i0.Input, args: [{ isSignal: true, alias: "swipeDirection", required: false }] }], swipeThreshold: [{ type: i0.Input, args: [{ isSignal: true, alias: "swipeThreshold", required: false }] }], dismiss: [{ type: i0.Output, args: ["dismiss"] }], swipeStart: [{ type: i0.Output, args: ["swipeStart"] }], swipeMove: [{ type: i0.Output, args: ["swipeMove"] }], swipeEnd: [{ type: i0.Output, args: ["swipeEnd"] }], swipeCancel: [{ type: i0.Output, args: ["swipeCancel"] }] } });
|
|
402
|
+
|
|
403
|
+
const FALLBACK = {
|
|
404
|
+
duration: 5000,
|
|
405
|
+
hotkey: 'F6',
|
|
406
|
+
maxVisible: Infinity,
|
|
407
|
+
};
|
|
408
|
+
const { token, provideDefaults } = createDefaults('FOR_TOAST_DEFAULTS', FALLBACK);
|
|
409
|
+
/**
|
|
410
|
+
* Token holding the resolved toast defaults for the current injector scope.
|
|
411
|
+
* The library always provides a fully-populated value (the fallback at the
|
|
412
|
+
* root, or the merged result of the nearest `provideForToastDefaults`).
|
|
413
|
+
*/
|
|
414
|
+
const FOR_TOAST_DEFAULTS = token;
|
|
415
|
+
/**
|
|
416
|
+
* Configures forty-cdk toast defaults for this injector scope. Partial
|
|
417
|
+
* overrides inherit unspecified keys from the parent scope (or library
|
|
418
|
+
* defaults at the root).
|
|
419
|
+
*/
|
|
420
|
+
function provideForToastDefaults(defaults) {
|
|
421
|
+
return provideDefaults(defaults);
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/**
|
|
425
|
+
* Handle returned by `ForToastManager.show()`. Drives `dismiss()` /
|
|
426
|
+
* `update()` imperatively from anywhere. Both reactive (`isClosed`) and
|
|
427
|
+
* awaitable (`closed`) APIs are exposed — pick what fits the call site.
|
|
428
|
+
*
|
|
429
|
+
* `R` is the close-result type, settable via `dismiss(reason, value)`.
|
|
430
|
+
* Default `unknown`; specify on `manager.show<R, D>(...)` for type safety.
|
|
431
|
+
*/
|
|
432
|
+
class ForToastRef {
|
|
433
|
+
#closed = signal(false, /* @ts-ignore */
|
|
434
|
+
...(ngDevMode ? [{ debugName: "#closed" }] : /* istanbul ignore next */ []));
|
|
435
|
+
#result = signal(undefined, /* @ts-ignore */
|
|
436
|
+
...(ngDevMode ? [{ debugName: "#result" }] : /* istanbul ignore next */ []));
|
|
437
|
+
#config = signal({}, /* @ts-ignore */
|
|
438
|
+
...(ngDevMode ? [{ debugName: "#config" }] : /* istanbul ignore next */ []));
|
|
439
|
+
/** Reactive: `true` once `dismiss()` has been called. */
|
|
440
|
+
isClosed = this.#closed.asReadonly();
|
|
441
|
+
/** Reactive: the close result, populated on `dismiss(reason, value)`. */
|
|
442
|
+
result = this.#result.asReadonly();
|
|
443
|
+
/** Reactive: the toast's current config (mutable via `update()`). */
|
|
444
|
+
config = this.#config.asReadonly();
|
|
445
|
+
/** Awaitable: resolves with `{ reason, result }` the first time `dismiss()` runs. */
|
|
446
|
+
closed;
|
|
447
|
+
#resolveClosed;
|
|
448
|
+
#teardown;
|
|
449
|
+
/** @internal */
|
|
450
|
+
constructor(initialConfig, teardown) {
|
|
451
|
+
this.#config.set(initialConfig);
|
|
452
|
+
this.#teardown = teardown;
|
|
453
|
+
this.closed = new Promise((resolve) => {
|
|
454
|
+
this.#resolveClosed = resolve;
|
|
455
|
+
});
|
|
456
|
+
}
|
|
457
|
+
/**
|
|
458
|
+
* Dismiss the toast, optionally returning a result. Idempotent — calling
|
|
459
|
+
* twice is a no-op. Reason defaults to `'programmatic'`.
|
|
460
|
+
*/
|
|
461
|
+
dismiss(reason = 'programmatic', result) {
|
|
462
|
+
if (this.#closed()) {
|
|
463
|
+
return;
|
|
464
|
+
}
|
|
465
|
+
this.#closed.set(true);
|
|
466
|
+
this.#result.set(result);
|
|
467
|
+
this.#resolveClosed({ reason, result });
|
|
468
|
+
this.#teardown(reason);
|
|
469
|
+
}
|
|
470
|
+
/**
|
|
471
|
+
* Mutate the toast in-place (e.g. to update text on long-running
|
|
472
|
+
* operations: "Saving…" → "Saved"). No-op once dismissed.
|
|
473
|
+
*
|
|
474
|
+
* `id` and `region` are normalized at `show()` and are **immutable** — they
|
|
475
|
+
* are ignored here. `id` is the toast's identity (used for dedupe and
|
|
476
|
+
* external dismissal); `region` decides which `<for-toast-viewport>` renders
|
|
477
|
+
* the toast, and changing it would silently unmount and remount the toast in
|
|
478
|
+
* another viewport (resetting its timer and announcement). To move a toast
|
|
479
|
+
* to a different region, dismiss it and `show()` a new one.
|
|
480
|
+
*/
|
|
481
|
+
update(patch) {
|
|
482
|
+
if (this.#closed()) {
|
|
483
|
+
return;
|
|
484
|
+
}
|
|
485
|
+
this.#config.update((current) => {
|
|
486
|
+
const { id: _id, region: _region, ...mutable } = patch;
|
|
487
|
+
return { ...current, ...mutable };
|
|
488
|
+
});
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
/**
|
|
493
|
+
* Programmatic toast opener — show a transient notification from TypeScript.
|
|
494
|
+
* Inject anywhere, call `show({ title, ... })` and get a `ForToastRef`
|
|
495
|
+
* back. Toasts render through `<for-toast-viewport>`, which the consumer
|
|
496
|
+
* mounts once near the root of their app.
|
|
497
|
+
*
|
|
498
|
+
* The manager owns the live array of toasts as a signal; the viewport
|
|
499
|
+
* iterates with `@for` so add / remove transitions are pure DOM control
|
|
500
|
+
* flow — consumers attach `animate.enter` / `animate.leave` directly.
|
|
501
|
+
*/
|
|
502
|
+
class ForToastManager {
|
|
503
|
+
#idGen = inject(IdGenerator);
|
|
504
|
+
#defaults = inject(FOR_TOAST_DEFAULTS);
|
|
505
|
+
#doc = inject(DOCUMENT);
|
|
506
|
+
#entries = signal([], /* @ts-ignore */
|
|
507
|
+
...(ngDevMode ? [{ debugName: "#entries" }] : /* istanbul ignore next */ []));
|
|
508
|
+
// O(1) id → entry index kept in sync with `#entries`. Prevents same-tick
|
|
509
|
+
// id-collision races where two `show({ id: 'X' })` calls would both miss
|
|
510
|
+
// an array `find` and end up pushing duplicate entries.
|
|
511
|
+
#byId = new Map();
|
|
512
|
+
// Live viewports in registration order. The first registration for a given
|
|
513
|
+
// region is its active renderer; the rest stay dormant (see
|
|
514
|
+
// `isActiveViewport`). A signal so each viewport's activeness recomputes
|
|
515
|
+
// when viewports mount / unmount or change region.
|
|
516
|
+
#viewports = signal([], /* @ts-ignore */
|
|
517
|
+
...(ngDevMode ? [{ debugName: "#viewports" }] : /* istanbul ignore next */ []));
|
|
518
|
+
#hotkeyListener = null;
|
|
519
|
+
/** Reactive view of all live toasts, in insertion order. */
|
|
520
|
+
toasts = computed(() => this.#entries().map((e) => this.#instance(e)), /* @ts-ignore */
|
|
521
|
+
...(ngDevMode ? [{ debugName: "toasts" }] : /* istanbul ignore next */ []));
|
|
522
|
+
/** Reactive count of currently open toasts. */
|
|
523
|
+
count = computed(() => this.#entries().length, /* @ts-ignore */
|
|
524
|
+
...(ngDevMode ? [{ debugName: "count" }] : /* istanbul ignore next */ []));
|
|
525
|
+
/**
|
|
526
|
+
* Open a toast. Returns a `ForToastRef` you can use to dismiss / update
|
|
527
|
+
* the toast imperatively.
|
|
528
|
+
*
|
|
529
|
+
* If `config.id` matches an existing toast, that toast is updated in place
|
|
530
|
+
* instead of duplicating — useful for "saving / saved" sequences.
|
|
531
|
+
*/
|
|
532
|
+
show(config = {}) {
|
|
533
|
+
const id = config.id ?? this.#idGen.next('for-toast');
|
|
534
|
+
const existing = this.#byId.get(id);
|
|
535
|
+
if (existing) {
|
|
536
|
+
existing.ref.update(config);
|
|
537
|
+
return existing.ref;
|
|
538
|
+
}
|
|
539
|
+
// Normalize the region up front so `instance.config.region` is always a
|
|
540
|
+
// concrete string the viewport can filter on (an explicit `undefined`
|
|
541
|
+
// collapses to the default too).
|
|
542
|
+
const region = config.region ?? DEFAULT_TOAST_REGION;
|
|
543
|
+
const ref = new ForToastRef({ ...config, id, region }, (reason) => this.#removeById(id, reason));
|
|
544
|
+
const entry = { id, ref: ref };
|
|
545
|
+
this.#byId.set(id, entry);
|
|
546
|
+
this.#entries.update((arr) => [...arr, entry]);
|
|
547
|
+
return ref;
|
|
548
|
+
}
|
|
549
|
+
/** Dismiss a toast by id. No-op when unknown. */
|
|
550
|
+
dismiss(id, reason = 'programmatic') {
|
|
551
|
+
this.#byId.get(id)?.ref.dismiss(reason);
|
|
552
|
+
}
|
|
553
|
+
/** Dismiss every live toast. */
|
|
554
|
+
dismissAll(reason = 'programmatic') {
|
|
555
|
+
for (const entry of [...this.#entries()]) {
|
|
556
|
+
entry.ref.dismiss(reason);
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
/** @internal Used by the viewport to read the configured hotkey. */
|
|
560
|
+
hotkey() {
|
|
561
|
+
return this.#defaults.hotkey;
|
|
562
|
+
}
|
|
563
|
+
/** @internal Default duration applied when a toast omits it. */
|
|
564
|
+
defaultDuration() {
|
|
565
|
+
return this.#defaults.duration;
|
|
566
|
+
}
|
|
567
|
+
/** @internal Default `maxVisible` applied to the viewport. */
|
|
568
|
+
defaultMaxVisible() {
|
|
569
|
+
return this.#defaults.maxVisible;
|
|
570
|
+
}
|
|
571
|
+
/**
|
|
572
|
+
* @internal Register a viewport for hotkey + region coordination. Returns a
|
|
573
|
+
* teardown the viewport runs on destroy. Installs the single document-level
|
|
574
|
+
* hotkey listener on the first registration and tears it down with the last.
|
|
575
|
+
*/
|
|
576
|
+
registerViewport(registration) {
|
|
577
|
+
this.#viewports.update((arr) => [...arr, registration]);
|
|
578
|
+
this.#ensureHotkeyListener();
|
|
579
|
+
return () => {
|
|
580
|
+
this.#viewports.update((arr) => arr.filter((r) => r !== registration));
|
|
581
|
+
if (this.#viewports().length === 0) {
|
|
582
|
+
this.#teardownHotkeyListener();
|
|
583
|
+
}
|
|
584
|
+
};
|
|
585
|
+
}
|
|
586
|
+
/**
|
|
587
|
+
* @internal Whether `registration` is the active renderer for its region —
|
|
588
|
+
* the first registered viewport for that region. Later viewports for the
|
|
589
|
+
* same region stay dormant so a single `show()` produces exactly one toast
|
|
590
|
+
* node regardless of how many viewports are mounted.
|
|
591
|
+
*/
|
|
592
|
+
isActiveViewport(registration) {
|
|
593
|
+
const region = registration.region();
|
|
594
|
+
const first = this.#viewports().find((r) => r.region() === region);
|
|
595
|
+
return first === registration;
|
|
596
|
+
}
|
|
597
|
+
#ensureHotkeyListener() {
|
|
598
|
+
if (this.#hotkeyListener) {
|
|
599
|
+
return;
|
|
600
|
+
}
|
|
601
|
+
const listener = (event) => {
|
|
602
|
+
// First viewport (in registration order) whose hotkey matches and that
|
|
603
|
+
// actually has a toast to focus wins — a single handler for every
|
|
604
|
+
// viewport, so the hotkey can't double-fire across mounted viewports.
|
|
605
|
+
for (const viewport of this.#viewports()) {
|
|
606
|
+
if (event.key !== viewport.hotkey()) {
|
|
607
|
+
continue;
|
|
608
|
+
}
|
|
609
|
+
if (viewport.focusFirst()) {
|
|
610
|
+
event.preventDefault();
|
|
611
|
+
return;
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
};
|
|
615
|
+
this.#hotkeyListener = listener;
|
|
616
|
+
this.#doc.addEventListener('keydown', listener);
|
|
617
|
+
}
|
|
618
|
+
#teardownHotkeyListener() {
|
|
619
|
+
if (!this.#hotkeyListener) {
|
|
620
|
+
return;
|
|
621
|
+
}
|
|
622
|
+
this.#doc.removeEventListener('keydown', this.#hotkeyListener);
|
|
623
|
+
this.#hotkeyListener = null;
|
|
624
|
+
}
|
|
625
|
+
#removeById(id, _reason) {
|
|
626
|
+
this.#byId.delete(id);
|
|
627
|
+
this.#entries.update((arr) => arr.filter((e) => e.id !== id));
|
|
628
|
+
}
|
|
629
|
+
#instance(entry) {
|
|
630
|
+
const ref = entry.ref;
|
|
631
|
+
return {
|
|
632
|
+
id: entry.id,
|
|
633
|
+
get config() {
|
|
634
|
+
return ref.config();
|
|
635
|
+
},
|
|
636
|
+
dismiss(reason = 'manual') {
|
|
637
|
+
ref.dismiss(reason);
|
|
638
|
+
},
|
|
639
|
+
};
|
|
640
|
+
}
|
|
641
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.2", ngImport: i0, type: ForToastManager, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
|
|
642
|
+
static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "22.0.2", ngImport: i0, type: ForToastManager, providedIn: 'root' });
|
|
643
|
+
}
|
|
644
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.2", ngImport: i0, type: ForToastManager, decorators: [{
|
|
645
|
+
type: Injectable,
|
|
646
|
+
args: [{ providedIn: 'root' }]
|
|
647
|
+
}] });
|
|
648
|
+
|
|
649
|
+
/**
|
|
650
|
+
* Action button inside a toast (e.g. "Undo"). Apply on a
|
|
651
|
+
* `<button type="button">` so Space / Enter dispatch a native click.
|
|
652
|
+
*
|
|
653
|
+
* Clicking emits `(dismiss)` from the parent `[forToast]` with reason
|
|
654
|
+
* `'action'`. Wire your action handler with `(click)` on this element
|
|
655
|
+
* itself — the close fires after your handler runs (via event order).
|
|
656
|
+
*
|
|
657
|
+
* The action is the **sanctioned dismissal path even when the toast is
|
|
658
|
+
* `closable=false`**: a forced-action toast suppresses Escape / swipe / the
|
|
659
|
+
* close button but keeps the action button live so the user always has a way
|
|
660
|
+
* out through the control they are meant to use.
|
|
661
|
+
*
|
|
662
|
+
* Set `[altText]` for WCAG 2.2.1 compliance: the visible label (e.g.
|
|
663
|
+
* "Undo") rarely carries enough context to recover the action once the
|
|
664
|
+
* toast disappears. The alt text replaces the toast's automatic live
|
|
665
|
+
* announcement with a self-contained string, e.g. `altText="Undo (Cmd+Z)"`.
|
|
666
|
+
* When at least one action carries an alt text, the parent toast routes
|
|
667
|
+
* its announcement through `LiveAnnouncer` and silences the host
|
|
668
|
+
* `aria-live` to avoid a duplicate readout.
|
|
669
|
+
*/
|
|
670
|
+
class ForToastAction {
|
|
671
|
+
ctx = injectToastContext('ForToastAction');
|
|
672
|
+
/**
|
|
673
|
+
* Self-contained text read aloud in place of the visible label so the
|
|
674
|
+
* user knows how to recover the action after the toast is gone. Required
|
|
675
|
+
* for time-limited toasts per WCAG 2.2.1; defaults to `''` (no alt text).
|
|
676
|
+
*/
|
|
677
|
+
altText = input('', /* @ts-ignore */
|
|
678
|
+
...(ngDevMode ? [{ debugName: "altText" }] : /* istanbul ignore next */ []));
|
|
679
|
+
constructor() {
|
|
680
|
+
const handle = { altText: this.altText };
|
|
681
|
+
registerHandle(handle, (h) => this.ctx.registerAction(h), (h) => this.ctx.unregisterAction(h));
|
|
682
|
+
}
|
|
683
|
+
onClick() {
|
|
684
|
+
this.ctx.requestClose('action');
|
|
685
|
+
}
|
|
686
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.2", ngImport: i0, type: ForToastAction, deps: [], target: i0.ɵɵFactoryTarget.Directive });
|
|
687
|
+
static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "22.0.2", type: ForToastAction, isStandalone: true, selector: "[forToastAction]", inputs: { altText: { classPropertyName: "altText", publicName: "altText", isSignal: true, isRequired: false, transformFunction: null } }, host: { attributes: { "type": "button" }, listeners: { "click": "onClick()" } }, exportAs: ["forToastAction"], ngImport: i0 });
|
|
688
|
+
}
|
|
689
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.2", ngImport: i0, type: ForToastAction, decorators: [{
|
|
690
|
+
type: Directive,
|
|
691
|
+
args: [{
|
|
692
|
+
selector: '[forToastAction]',
|
|
693
|
+
exportAs: 'forToastAction',
|
|
694
|
+
host: {
|
|
695
|
+
type: 'button',
|
|
696
|
+
'(click)': 'onClick()',
|
|
697
|
+
},
|
|
698
|
+
}]
|
|
699
|
+
}], ctorParameters: () => [], propDecorators: { altText: [{ type: i0.Input, args: [{ isSignal: true, alias: "altText", required: false }] }] } });
|
|
700
|
+
|
|
701
|
+
/**
|
|
702
|
+
* Close button inside a toast. Apply on a `<button type="button">` so
|
|
703
|
+
* Space / Enter dispatch a native click. Clicking emits `(dismiss)` from
|
|
704
|
+
* the parent `[forToast]` with reason `'manual'`.
|
|
705
|
+
*
|
|
706
|
+
* The host carries `aria-label="Close"` by default; override with your
|
|
707
|
+
* own `[attr.aria-label]` when the toast's language requires localization.
|
|
708
|
+
*/
|
|
709
|
+
class ForToastClose {
|
|
710
|
+
ctx = injectToastContext('ForToastClose');
|
|
711
|
+
onClick() {
|
|
712
|
+
this.ctx.requestClose('manual');
|
|
713
|
+
}
|
|
714
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.2", ngImport: i0, type: ForToastClose, deps: [], target: i0.ɵɵFactoryTarget.Directive });
|
|
715
|
+
static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "22.0.2", type: ForToastClose, isStandalone: true, selector: "[forToastClose]", host: { attributes: { "type": "button", "aria-label": "Close" }, listeners: { "click": "onClick()" } }, exportAs: ["forToastClose"], ngImport: i0 });
|
|
716
|
+
}
|
|
717
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.2", ngImport: i0, type: ForToastClose, decorators: [{
|
|
718
|
+
type: Directive,
|
|
719
|
+
args: [{
|
|
720
|
+
selector: '[forToastClose]',
|
|
721
|
+
exportAs: 'forToastClose',
|
|
722
|
+
host: {
|
|
723
|
+
type: 'button',
|
|
724
|
+
'aria-label': 'Close',
|
|
725
|
+
'(click)': 'onClick()',
|
|
726
|
+
},
|
|
727
|
+
}]
|
|
728
|
+
}] });
|
|
729
|
+
|
|
730
|
+
/**
|
|
731
|
+
* Supplementary description for the toast. Registers its generated id
|
|
732
|
+
* with the parent `[forToast]` so `aria-describedby` is wired automatically.
|
|
733
|
+
*/
|
|
734
|
+
class ForToastDescription {
|
|
735
|
+
#ctx = injectToastContext('ForToastDescription');
|
|
736
|
+
#host = inject(ElementRef);
|
|
737
|
+
id = resolveHostId(this.#host.nativeElement, 'for-toast-description');
|
|
738
|
+
#text = signal('', /* @ts-ignore */
|
|
739
|
+
...(ngDevMode ? [{ debugName: "#text" }] : /* istanbul ignore next */ []));
|
|
740
|
+
constructor() {
|
|
741
|
+
const handle = { id: this.id, text: this.#text.asReadonly() };
|
|
742
|
+
registerHandle(handle, (h) => this.#ctx.registerDescription(h), (h) => this.#ctx.unregisterDescription(h));
|
|
743
|
+
afterEveryRender(() => {
|
|
744
|
+
const next = (this.#host.nativeElement.textContent ?? '').trim();
|
|
745
|
+
if (next !== this.#text()) {
|
|
746
|
+
this.#text.set(next);
|
|
747
|
+
}
|
|
748
|
+
});
|
|
749
|
+
}
|
|
750
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.2", ngImport: i0, type: ForToastDescription, deps: [], target: i0.ɵɵFactoryTarget.Directive });
|
|
751
|
+
static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "22.0.2", type: ForToastDescription, isStandalone: true, selector: "[forToastDescription]", host: { properties: { "id": "id" } }, exportAs: ["forToastDescription"], ngImport: i0 });
|
|
752
|
+
}
|
|
753
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.2", ngImport: i0, type: ForToastDescription, decorators: [{
|
|
754
|
+
type: Directive,
|
|
755
|
+
args: [{
|
|
756
|
+
selector: '[forToastDescription]',
|
|
757
|
+
exportAs: 'forToastDescription',
|
|
758
|
+
host: {
|
|
759
|
+
'[id]': 'id',
|
|
760
|
+
},
|
|
761
|
+
}]
|
|
762
|
+
}], ctorParameters: () => [] });
|
|
763
|
+
|
|
764
|
+
/**
|
|
765
|
+
* @internal Exposes the element injector at a child of the row's `[forToast]`
|
|
766
|
+
* element so a consumer-supplied toast `template` can be rendered with the
|
|
767
|
+
* `[forToast]` injection context in scope.
|
|
768
|
+
*
|
|
769
|
+
* Angular's `ngTemplateOutlet` renders a template against its *declaration*
|
|
770
|
+
* injector (the consumer's component, where `<ng-template>` was written), not
|
|
771
|
+
* the insertion point — so `[forToastTitle]` / `[forToastDescription]` /
|
|
772
|
+
* `[forToastAction]` / `[forToastClose]`, which `inject(FOR_TOAST_CONTEXT)`,
|
|
773
|
+
* would fail to resolve the row's `ForToast`.
|
|
774
|
+
*
|
|
775
|
+
* This directive sits on the same `<ng-container>` as the `ngTemplateOutlet`,
|
|
776
|
+
* inside the row's `[forToast]` element, so its own element injector already
|
|
777
|
+
* resolves `FOR_TOAST_CONTEXT`. The viewport feeds that injector to
|
|
778
|
+
* `[ngTemplateOutletInjector]`, making the rendered template inherit it and
|
|
779
|
+
* restoring the automatic `aria-labelledby` / `aria-describedby` / close-reason
|
|
780
|
+
* wiring for the helper directives inside the custom template — without adding
|
|
781
|
+
* a wrapper element to the consumer's markup.
|
|
782
|
+
*/
|
|
783
|
+
class ForToastOutlet {
|
|
784
|
+
/**
|
|
785
|
+
* Element injector resolving `FOR_TOAST_CONTEXT` for the enclosing toast
|
|
786
|
+
* row. Passed to `[ngTemplateOutletInjector]` so helper directives inside a
|
|
787
|
+
* custom template wire a11y exactly as in the default shape.
|
|
788
|
+
*/
|
|
789
|
+
injector = inject(Injector);
|
|
790
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.2", ngImport: i0, type: ForToastOutlet, deps: [], target: i0.ɵɵFactoryTarget.Directive });
|
|
791
|
+
static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "22.0.2", type: ForToastOutlet, isStandalone: true, selector: "[forToastOutlet]", exportAs: ["forToastOutlet"], ngImport: i0 });
|
|
792
|
+
}
|
|
793
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.2", ngImport: i0, type: ForToastOutlet, decorators: [{
|
|
794
|
+
type: Directive,
|
|
795
|
+
args: [{
|
|
796
|
+
selector: '[forToastOutlet]',
|
|
797
|
+
exportAs: 'forToastOutlet',
|
|
798
|
+
}]
|
|
799
|
+
}] });
|
|
800
|
+
|
|
801
|
+
/**
|
|
802
|
+
* Element holding the toast's accessible name. Registers its generated id
|
|
803
|
+
* with the parent `[forToast]` so `aria-labelledby` is wired automatically.
|
|
804
|
+
* Apply on whatever heading element fits (`<strong>`, `<h2>`, `<div>`).
|
|
805
|
+
*/
|
|
806
|
+
class ForToastTitle {
|
|
807
|
+
#ctx = injectToastContext('ForToastTitle');
|
|
808
|
+
#host = inject(ElementRef);
|
|
809
|
+
id = resolveHostId(this.#host.nativeElement, 'for-toast-title');
|
|
810
|
+
#text = signal('', /* @ts-ignore */
|
|
811
|
+
...(ngDevMode ? [{ debugName: "#text" }] : /* istanbul ignore next */ []));
|
|
812
|
+
constructor() {
|
|
813
|
+
const handle = { id: this.id, text: this.#text.asReadonly() };
|
|
814
|
+
registerHandle(handle, (h) => this.#ctx.registerLabel(h), (h) => this.#ctx.unregisterLabel(h));
|
|
815
|
+
afterEveryRender(() => {
|
|
816
|
+
const next = (this.#host.nativeElement.textContent ?? '').trim();
|
|
817
|
+
if (next !== this.#text()) {
|
|
818
|
+
this.#text.set(next);
|
|
819
|
+
}
|
|
820
|
+
});
|
|
821
|
+
}
|
|
822
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.2", ngImport: i0, type: ForToastTitle, deps: [], target: i0.ɵɵFactoryTarget.Directive });
|
|
823
|
+
static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "22.0.2", type: ForToastTitle, isStandalone: true, selector: "[forToastTitle]", host: { properties: { "id": "id" } }, exportAs: ["forToastTitle"], ngImport: i0 });
|
|
824
|
+
}
|
|
825
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.2", ngImport: i0, type: ForToastTitle, decorators: [{
|
|
826
|
+
type: Directive,
|
|
827
|
+
args: [{
|
|
828
|
+
selector: '[forToastTitle]',
|
|
829
|
+
exportAs: 'forToastTitle',
|
|
830
|
+
host: {
|
|
831
|
+
'[id]': 'id',
|
|
832
|
+
},
|
|
833
|
+
}]
|
|
834
|
+
}], ctorParameters: () => [] });
|
|
835
|
+
|
|
836
|
+
/**
|
|
837
|
+
* Mounted once near the root of the app — typically inside `app.html`.
|
|
838
|
+
* Listens to `ForToastManager.toasts()` and renders each programmatic
|
|
839
|
+
* toast inline. Position the viewport from CSS (`position: fixed`,
|
|
840
|
+
* `top|bottom`, `left|right`) — the directive doesn't impose any.
|
|
841
|
+
*
|
|
842
|
+
* Custom rendering: if a toast's config has `template`, that
|
|
843
|
+
* `TemplateRef<ForToastTemplateContext>` is used instead of the default
|
|
844
|
+
* title / description / action / close shape. The template receives
|
|
845
|
+
* `$implicit: instance` and `data: instance.config.data`, and is rendered
|
|
846
|
+
* with the `[forToast]` injection context in scope (via `ForToastOutlet`),
|
|
847
|
+
* so `[forToastTitle]` / `[forToastDescription]` / `[forToastAction]` /
|
|
848
|
+
* `[forToastClose]` keep their automatic a11y / close wiring inside it.
|
|
849
|
+
*
|
|
850
|
+
* Per-toast classes: a config's `class` / `classList` is applied to the
|
|
851
|
+
* rendered toast root, merged with the directive's own host attributes.
|
|
852
|
+
*
|
|
853
|
+
* Enter / exit animations: a toast renders inside this viewport's `@for`, so a
|
|
854
|
+
* `[animate.leave]` on the row defers the unmount natively — the toast stays
|
|
855
|
+
* mounted until its exit animation settles. Supply the class per-toast with
|
|
856
|
+
* `show({ animateLeave })` or set a viewport-wide default with `[animateLeave]`
|
|
857
|
+
* (per-toast wins); `[animateEnter]` mirrors it for class-applied entrances.
|
|
858
|
+
*
|
|
859
|
+
* Hotkey: pressing the configured `hotkey` (default `F6`) anywhere in the
|
|
860
|
+
* document focuses the first toast. Override per-viewport with `[hotkey]`
|
|
861
|
+
* or globally with `provideForToastDefaults({ hotkey: '…' })`. The listener
|
|
862
|
+
* is owned once by `ForToastManager`, so the hotkey never double-fires when
|
|
863
|
+
* several viewports are mounted.
|
|
864
|
+
*
|
|
865
|
+
* Regions: a viewport renders only toasts whose `region` matches its
|
|
866
|
+
* `[region]` input (default {@link DEFAULT_TOAST_REGION}), so independent
|
|
867
|
+
* viewports — e.g. top-right system notifications and bottom-center action
|
|
868
|
+
* confirmations — are a first-class feature. If two viewports share a region,
|
|
869
|
+
* only the first one mounted renders it (the rest stay dormant and warn in dev
|
|
870
|
+
* mode), so a single `show()` always produces exactly one toast node.
|
|
871
|
+
*
|
|
872
|
+
* Accessibility: the host carries `role="region"` and an `aria-label`
|
|
873
|
+
* (default `Notifications`). Toast nodes themselves carry their own
|
|
874
|
+
* `role` (`status` / `alert`) and `aria-live`, so screen readers
|
|
875
|
+
* announce updates without forcing focus.
|
|
876
|
+
*/
|
|
877
|
+
class ForToastViewport {
|
|
878
|
+
#manager = inject(ForToastManager);
|
|
879
|
+
#host = inject(ElementRef);
|
|
880
|
+
/** Accessible name for the viewport region. Default `'Notifications'`. */
|
|
881
|
+
label = input('Notifications', /* @ts-ignore */
|
|
882
|
+
...(ngDevMode ? [{ debugName: "label" }] : /* istanbul ignore next */ []));
|
|
883
|
+
/**
|
|
884
|
+
* The toast region this viewport renders. Only toasts whose `region` matches
|
|
885
|
+
* appear here; toasts opened without a `region` use
|
|
886
|
+
* {@link DEFAULT_TOAST_REGION}, so the common single-viewport setup needs no
|
|
887
|
+
* `[region]` at all. Mount viewports with distinct `[region]` values to drive
|
|
888
|
+
* independent toast regions from one global `ForToastManager`.
|
|
889
|
+
*/
|
|
890
|
+
region = input(DEFAULT_TOAST_REGION, /* @ts-ignore */
|
|
891
|
+
...(ngDevMode ? [{ debugName: "region" }] : /* istanbul ignore next */ []));
|
|
892
|
+
/** Hotkey to focus the viewport. Default reads from `provideForToastDefaults`, falling back to `F6`. */
|
|
893
|
+
hotkey = input('', /* @ts-ignore */
|
|
894
|
+
...(ngDevMode ? [{ debugName: "hotkey" }] : /* istanbul ignore next */ []));
|
|
895
|
+
/**
|
|
896
|
+
* Maximum number of toasts rendered at once. Older toasts collapse out of
|
|
897
|
+
* the visible stack but stay in `manager.toasts()` until dismissed. When
|
|
898
|
+
* unset (`null`, the default), the viewport falls back to the global
|
|
899
|
+
* `provideForToastDefaults({ maxVisible })` value (itself `Infinity` unless
|
|
900
|
+
* configured, which renders all live toasts). Setting `[maxVisible]`
|
|
901
|
+
* per-viewport overrides the global default.
|
|
902
|
+
*
|
|
903
|
+
* **Overflow is parked, not auto-expired.** A toast pushed out of the
|
|
904
|
+
* visible window is unmounted, so its auto-dismiss timer is not running
|
|
905
|
+
* while it waits. It re-enters the window when a newer toast is dismissed,
|
|
906
|
+
* and its `duration` countdown then restarts from full (a fresh `[forToast]`
|
|
907
|
+
* mounts). Cap with `maxVisible` for layout, but dismiss explicitly — via
|
|
908
|
+
* `ForToastRef.dismiss()`, `dismissAll()`, or the action / close button —
|
|
909
|
+
* if you need overflow toasts to clear on a deadline.
|
|
910
|
+
*/
|
|
911
|
+
maxVisible = input(null, { ...(ngDevMode ? { debugName: "maxVisible" } : /* istanbul ignore next */ {}), transform: (v) => (v == null ? null : numberAttribute(v)) });
|
|
912
|
+
/**
|
|
913
|
+
* Default swipe direction(s) applied to every programmatic toast that
|
|
914
|
+
* doesn't override `swipeDirection` in its own config. `null` (default)
|
|
915
|
+
* keeps swipe disabled unless explicitly opted into per-toast.
|
|
916
|
+
*/
|
|
917
|
+
swipeDirection = input(null, /* @ts-ignore */
|
|
918
|
+
...(ngDevMode ? [{ debugName: "swipeDirection" }] : /* istanbul ignore next */ []));
|
|
919
|
+
/**
|
|
920
|
+
* Default dismiss-distance in pixels applied to every programmatic toast
|
|
921
|
+
* that doesn't override `swipeThreshold` in its own config.
|
|
922
|
+
*/
|
|
923
|
+
swipeThreshold = input(50, { ...(ngDevMode ? { debugName: "swipeThreshold" } : /* istanbul ignore next */ {}), transform: numberAttribute });
|
|
924
|
+
/**
|
|
925
|
+
* Default `animate.enter` class applied to every programmatic toast that
|
|
926
|
+
* doesn't set `animateEnter` in its own config. Empty (default) plays no
|
|
927
|
+
* class-applied enter animation — a plain CSS `@keyframes` on `[forToast]`
|
|
928
|
+
* already runs on mount without it.
|
|
929
|
+
*/
|
|
930
|
+
animateEnter = input('', /* @ts-ignore */
|
|
931
|
+
...(ngDevMode ? [{ debugName: "animateEnter" }] : /* istanbul ignore next */ []));
|
|
932
|
+
/**
|
|
933
|
+
* Default `animate.leave` class applied to every programmatic toast that
|
|
934
|
+
* doesn't set `animateLeave` in its own config. Empty (default) unmounts the
|
|
935
|
+
* toast synchronously on dismiss; set it (or per-toast `animateLeave`) to
|
|
936
|
+
* keep the toast mounted until its exit animation settles before it leaves
|
|
937
|
+
* the DOM.
|
|
938
|
+
*/
|
|
939
|
+
animateLeave = input('', /* @ts-ignore */
|
|
940
|
+
...(ngDevMode ? [{ debugName: "animateLeave" }] : /* istanbul ignore next */ []));
|
|
941
|
+
defaultDuration = computed(() => this.#manager.defaultDuration(), /* @ts-ignore */
|
|
942
|
+
...(ngDevMode ? [{ debugName: "defaultDuration" }] : /* istanbul ignore next */ []));
|
|
943
|
+
/**
|
|
944
|
+
* Resolved `maxVisible` limit: the per-viewport `[maxVisible]` when set,
|
|
945
|
+
* otherwise the global `provideForToastDefaults({ maxVisible })` value.
|
|
946
|
+
*/
|
|
947
|
+
resolvedMaxVisible = computed(() => {
|
|
948
|
+
const own = this.maxVisible();
|
|
949
|
+
return own ?? this.#manager.defaultMaxVisible();
|
|
950
|
+
}, /* @ts-ignore */
|
|
951
|
+
...(ngDevMode ? [{ debugName: "resolvedMaxVisible" }] : /* istanbul ignore next */ []));
|
|
952
|
+
/**
|
|
953
|
+
* Whether this viewport is the active renderer for its region. The manager
|
|
954
|
+
* grants this to the first viewport mounted per region; dormant viewports
|
|
955
|
+
* render nothing so a single `show()` yields exactly one toast node.
|
|
956
|
+
*/
|
|
957
|
+
#active;
|
|
958
|
+
visible = computed(() => {
|
|
959
|
+
if (!this.#active()) {
|
|
960
|
+
return [];
|
|
961
|
+
}
|
|
962
|
+
const region = this.region();
|
|
963
|
+
const all = this.#manager.toasts().filter((toast) => toast.config.region === region);
|
|
964
|
+
const resolved = this.resolvedMaxVisible();
|
|
965
|
+
const limit = Number.isFinite(resolved) ? resolved : all.length;
|
|
966
|
+
if (all.length <= limit) {
|
|
967
|
+
return all;
|
|
968
|
+
}
|
|
969
|
+
// Newest toasts win; older ones drop out of the visible window.
|
|
970
|
+
return all.slice(all.length - limit);
|
|
971
|
+
}, /* @ts-ignore */
|
|
972
|
+
...(ngDevMode ? [{ debugName: "visible" }] : /* istanbul ignore next */ []));
|
|
973
|
+
constructor() {
|
|
974
|
+
const registration = {
|
|
975
|
+
region: this.region,
|
|
976
|
+
hotkey: () => this.hotkey() || this.#manager.hotkey(),
|
|
977
|
+
focusFirst: () => this.#focusFirst(),
|
|
978
|
+
};
|
|
979
|
+
this.#active = computed(() => this.#manager.isActiveViewport(registration), /* @ts-ignore */
|
|
980
|
+
...(ngDevMode ? [{ debugName: "#active" }] : /* istanbul ignore next */ []));
|
|
981
|
+
const unregister = this.#manager.registerViewport(registration);
|
|
982
|
+
inject(DestroyRef).onDestroy(unregister);
|
|
983
|
+
if (isDevMode()) {
|
|
984
|
+
// Warn once each time this viewport becomes dormant because another
|
|
985
|
+
// viewport already owns its region — the silent-duplication footgun the
|
|
986
|
+
// region API exists to remove.
|
|
987
|
+
let warned = false;
|
|
988
|
+
effect(() => {
|
|
989
|
+
if (this.#active()) {
|
|
990
|
+
warned = false;
|
|
991
|
+
}
|
|
992
|
+
else if (!warned) {
|
|
993
|
+
warned = true;
|
|
994
|
+
console.warn(`[forty-cdk/toast] A <for-toast-viewport> for region "${this.region()}" is already mounted; ` +
|
|
995
|
+
`this one stays inactive to avoid duplicate toasts. Give it a distinct [region] for an independent toast region.`);
|
|
996
|
+
}
|
|
997
|
+
});
|
|
998
|
+
}
|
|
999
|
+
}
|
|
1000
|
+
/** Focus the first rendered toast host. Returns `true` when focus moved. */
|
|
1001
|
+
#focusFirst() {
|
|
1002
|
+
const first = this.#host.nativeElement.querySelector('[forToast]');
|
|
1003
|
+
if (first) {
|
|
1004
|
+
first.focus();
|
|
1005
|
+
return true;
|
|
1006
|
+
}
|
|
1007
|
+
return false;
|
|
1008
|
+
}
|
|
1009
|
+
onClose(toast, reason) {
|
|
1010
|
+
toast.dismiss(reason);
|
|
1011
|
+
}
|
|
1012
|
+
/**
|
|
1013
|
+
* Consumer class(es) for a toast row, resolved from its config's `class` /
|
|
1014
|
+
* `classList`. Returns `''` when neither is set so the `[class]` host
|
|
1015
|
+
* binding emits no extra tokens and leaves the directive's own host
|
|
1016
|
+
* attributes untouched.
|
|
1017
|
+
*/
|
|
1018
|
+
toastClass(toast) {
|
|
1019
|
+
return resolveConfigClass(toast.config) ?? '';
|
|
1020
|
+
}
|
|
1021
|
+
/**
|
|
1022
|
+
* `animate.enter` class for a toast row: its per-toast `animateEnter` config
|
|
1023
|
+
* when set, otherwise the viewport-level `[animateEnter]`. Empty when neither
|
|
1024
|
+
* is set.
|
|
1025
|
+
*/
|
|
1026
|
+
toastAnimateEnter(toast) {
|
|
1027
|
+
return toast.config.animateEnter ?? this.animateEnter();
|
|
1028
|
+
}
|
|
1029
|
+
/**
|
|
1030
|
+
* `animate.leave` class for a toast row: its per-toast `animateLeave` config
|
|
1031
|
+
* when set, otherwise the viewport-level `[animateLeave]`. Empty when neither
|
|
1032
|
+
* is set, which keeps the toast's unmount synchronous on dismiss.
|
|
1033
|
+
*/
|
|
1034
|
+
toastAnimateLeave(toast) {
|
|
1035
|
+
return toast.config.animateLeave ?? this.animateLeave();
|
|
1036
|
+
}
|
|
1037
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.2", ngImport: i0, type: ForToastViewport, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
1038
|
+
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.0.2", type: ForToastViewport, isStandalone: true, selector: "for-toast-viewport, [forToastViewport]", inputs: { label: { classPropertyName: "label", publicName: "label", isSignal: true, isRequired: false, transformFunction: null }, region: { classPropertyName: "region", publicName: "region", isSignal: true, isRequired: false, transformFunction: null }, hotkey: { classPropertyName: "hotkey", publicName: "hotkey", isSignal: true, isRequired: false, transformFunction: null }, maxVisible: { classPropertyName: "maxVisible", publicName: "maxVisible", isSignal: true, isRequired: false, transformFunction: null }, swipeDirection: { classPropertyName: "swipeDirection", publicName: "swipeDirection", isSignal: true, isRequired: false, transformFunction: null }, swipeThreshold: { classPropertyName: "swipeThreshold", publicName: "swipeThreshold", isSignal: true, isRequired: false, transformFunction: null }, animateEnter: { classPropertyName: "animateEnter", publicName: "animateEnter", isSignal: true, isRequired: false, transformFunction: null }, animateLeave: { classPropertyName: "animateLeave", publicName: "animateLeave", isSignal: true, isRequired: false, transformFunction: null } }, host: { attributes: { "role": "region", "tabindex": "-1" }, properties: { "attr.aria-label": "label()", "attr.data-region": "region()", "attr.data-toast-count": "visible().length" } }, exportAs: ["forToastViewport"], ngImport: i0, template: `
|
|
1039
|
+
@for (toast of visible(); track toast.id; let i = $index) {
|
|
1040
|
+
<div
|
|
1041
|
+
forToast
|
|
1042
|
+
[class]="toastClass(toast)"
|
|
1043
|
+
[animate.enter]="toastAnimateEnter(toast)"
|
|
1044
|
+
[animate.leave]="toastAnimateLeave(toast)"
|
|
1045
|
+
[variant]="toast.config.variant ?? 'info'"
|
|
1046
|
+
[duration]="toast.config.duration ?? defaultDuration()"
|
|
1047
|
+
[closable]="toast.config.closable !== false"
|
|
1048
|
+
[swipeDirection]="toast.config.swipeDirection ?? swipeDirection()"
|
|
1049
|
+
[swipeThreshold]="toast.config.swipeThreshold ?? swipeThreshold()"
|
|
1050
|
+
[attr.data-front-stack-index]="i"
|
|
1051
|
+
(dismiss)="onClose(toast, $event)"
|
|
1052
|
+
>
|
|
1053
|
+
@if (toast.config.template; as template) {
|
|
1054
|
+
<ng-container
|
|
1055
|
+
forToastOutlet
|
|
1056
|
+
#outlet="forToastOutlet"
|
|
1057
|
+
[ngTemplateOutlet]="template"
|
|
1058
|
+
[ngTemplateOutletContext]="{ $implicit: toast, data: toast.config.data }"
|
|
1059
|
+
[ngTemplateOutletInjector]="outlet.injector"
|
|
1060
|
+
/>
|
|
1061
|
+
} @else {
|
|
1062
|
+
@if (toast.config.title) {
|
|
1063
|
+
<div forToastTitle>{{ toast.config.title }}</div>
|
|
1064
|
+
}
|
|
1065
|
+
@if (toast.config.description) {
|
|
1066
|
+
<div forToastDescription>{{ toast.config.description }}</div>
|
|
1067
|
+
}
|
|
1068
|
+
@if (toast.config.action; as action) {
|
|
1069
|
+
<button forToastAction (click)="action.onClick()">
|
|
1070
|
+
{{ action.label }}
|
|
1071
|
+
</button>
|
|
1072
|
+
}
|
|
1073
|
+
@if (toast.config.closable !== false) {
|
|
1074
|
+
<button forToastClose>×</button>
|
|
1075
|
+
}
|
|
1076
|
+
}
|
|
1077
|
+
</div>
|
|
1078
|
+
}
|
|
1079
|
+
`, isInline: true, dependencies: [{ kind: "directive", type: NgTemplateOutlet, selector: "[ngTemplateOutlet]", inputs: ["ngTemplateOutletContext", "ngTemplateOutlet", "ngTemplateOutletInjector"] }, { kind: "directive", type: ForToast, selector: "[forToast]", inputs: ["variant", "duration", "closable", "swipeDirection", "swipeThreshold"], outputs: ["dismiss", "swipeStart", "swipeMove", "swipeEnd", "swipeCancel"], exportAs: ["forToast"] }, { kind: "directive", type: ForToastOutlet, selector: "[forToastOutlet]", exportAs: ["forToastOutlet"] }, { kind: "directive", type: ForToastTitle, selector: "[forToastTitle]", exportAs: ["forToastTitle"] }, { kind: "directive", type: ForToastDescription, selector: "[forToastDescription]", exportAs: ["forToastDescription"] }, { kind: "directive", type: ForToastAction, selector: "[forToastAction]", inputs: ["altText"], exportAs: ["forToastAction"] }, { kind: "directive", type: ForToastClose, selector: "[forToastClose]", exportAs: ["forToastClose"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
|
|
1080
|
+
}
|
|
1081
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.2", ngImport: i0, type: ForToastViewport, decorators: [{
|
|
1082
|
+
type: Component,
|
|
1083
|
+
args: [{
|
|
1084
|
+
selector: 'for-toast-viewport, [forToastViewport]',
|
|
1085
|
+
exportAs: 'forToastViewport',
|
|
1086
|
+
imports: [
|
|
1087
|
+
NgTemplateOutlet,
|
|
1088
|
+
ForToast,
|
|
1089
|
+
ForToastOutlet,
|
|
1090
|
+
ForToastTitle,
|
|
1091
|
+
ForToastDescription,
|
|
1092
|
+
ForToastAction,
|
|
1093
|
+
ForToastClose,
|
|
1094
|
+
],
|
|
1095
|
+
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
1096
|
+
host: {
|
|
1097
|
+
role: 'region',
|
|
1098
|
+
'[attr.aria-label]': 'label()',
|
|
1099
|
+
'[attr.data-region]': 'region()',
|
|
1100
|
+
tabindex: '-1',
|
|
1101
|
+
'[attr.data-toast-count]': 'visible().length',
|
|
1102
|
+
},
|
|
1103
|
+
template: `
|
|
1104
|
+
@for (toast of visible(); track toast.id; let i = $index) {
|
|
1105
|
+
<div
|
|
1106
|
+
forToast
|
|
1107
|
+
[class]="toastClass(toast)"
|
|
1108
|
+
[animate.enter]="toastAnimateEnter(toast)"
|
|
1109
|
+
[animate.leave]="toastAnimateLeave(toast)"
|
|
1110
|
+
[variant]="toast.config.variant ?? 'info'"
|
|
1111
|
+
[duration]="toast.config.duration ?? defaultDuration()"
|
|
1112
|
+
[closable]="toast.config.closable !== false"
|
|
1113
|
+
[swipeDirection]="toast.config.swipeDirection ?? swipeDirection()"
|
|
1114
|
+
[swipeThreshold]="toast.config.swipeThreshold ?? swipeThreshold()"
|
|
1115
|
+
[attr.data-front-stack-index]="i"
|
|
1116
|
+
(dismiss)="onClose(toast, $event)"
|
|
1117
|
+
>
|
|
1118
|
+
@if (toast.config.template; as template) {
|
|
1119
|
+
<ng-container
|
|
1120
|
+
forToastOutlet
|
|
1121
|
+
#outlet="forToastOutlet"
|
|
1122
|
+
[ngTemplateOutlet]="template"
|
|
1123
|
+
[ngTemplateOutletContext]="{ $implicit: toast, data: toast.config.data }"
|
|
1124
|
+
[ngTemplateOutletInjector]="outlet.injector"
|
|
1125
|
+
/>
|
|
1126
|
+
} @else {
|
|
1127
|
+
@if (toast.config.title) {
|
|
1128
|
+
<div forToastTitle>{{ toast.config.title }}</div>
|
|
1129
|
+
}
|
|
1130
|
+
@if (toast.config.description) {
|
|
1131
|
+
<div forToastDescription>{{ toast.config.description }}</div>
|
|
1132
|
+
}
|
|
1133
|
+
@if (toast.config.action; as action) {
|
|
1134
|
+
<button forToastAction (click)="action.onClick()">
|
|
1135
|
+
{{ action.label }}
|
|
1136
|
+
</button>
|
|
1137
|
+
}
|
|
1138
|
+
@if (toast.config.closable !== false) {
|
|
1139
|
+
<button forToastClose>×</button>
|
|
1140
|
+
}
|
|
1141
|
+
}
|
|
1142
|
+
</div>
|
|
1143
|
+
}
|
|
1144
|
+
`,
|
|
1145
|
+
}]
|
|
1146
|
+
}], ctorParameters: () => [], propDecorators: { label: [{ type: i0.Input, args: [{ isSignal: true, alias: "label", required: false }] }], region: [{ type: i0.Input, args: [{ isSignal: true, alias: "region", required: false }] }], hotkey: [{ type: i0.Input, args: [{ isSignal: true, alias: "hotkey", required: false }] }], maxVisible: [{ type: i0.Input, args: [{ isSignal: true, alias: "maxVisible", required: false }] }], swipeDirection: [{ type: i0.Input, args: [{ isSignal: true, alias: "swipeDirection", required: false }] }], swipeThreshold: [{ type: i0.Input, args: [{ isSignal: true, alias: "swipeThreshold", required: false }] }], animateEnter: [{ type: i0.Input, args: [{ isSignal: true, alias: "animateEnter", required: false }] }], animateLeave: [{ type: i0.Input, args: [{ isSignal: true, alias: "animateLeave", required: false }] }] } });
|
|
1147
|
+
|
|
1148
|
+
/**
|
|
1149
|
+
* Generated bundle index. Do not edit.
|
|
1150
|
+
*/
|
|
1151
|
+
|
|
1152
|
+
export { DEFAULT_TOAST_REGION, FOR_TOAST_CONTEXT, FOR_TOAST_DEFAULTS, ForToast, ForToastAction, ForToastClose, ForToastDescription, ForToastManager, ForToastRef, ForToastTitle, ForToastViewport, provideForToastDefaults };
|
|
1153
|
+
//# sourceMappingURL=forty-cdk-toast.mjs.map
|