srcdev-nuxt-components 9.1.55 → 9.1.56

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (100) hide show
  1. package/.claude/settings.json +29 -2
  2. package/.claude/skills/components/alert-content-inner.md +60 -0
  3. package/.claude/skills/components/display-dialog.md +80 -0
  4. package/.claude/skills/components/display-prompt.md +163 -0
  5. package/.claude/skills/components/display-toast.md +235 -0
  6. package/.claude/skills/index.md +7 -3
  7. package/.claude/skills/theming-colour-ramps.md +328 -0
  8. package/.claude/skills/theming-override-default.md +115 -186
  9. package/.claude/skills/theming-partial-override.md +88 -75
  10. package/README.md +166 -0
  11. package/app/app.config.ts +44 -0
  12. package/app/assets/styles/setup/01.config/_head.css +3 -3
  13. package/app/assets/styles/setup/02.colours/_amber.css +12 -10
  14. package/app/assets/styles/setup/02.colours/_blue.css +10 -9
  15. package/app/assets/styles/setup/02.colours/_green.css +12 -11
  16. package/app/assets/styles/setup/02.colours/_orange.css +12 -10
  17. package/app/assets/styles/setup/02.colours/_red.css +10 -9
  18. package/app/assets/styles/setup/02.colours/_slate.css +12 -11
  19. package/app/assets/styles/setup/02.colours/_sunset.css +12 -10
  20. package/app/assets/styles/setup/02.colours/_theme-params.css +18 -0
  21. package/app/assets/styles/setup/02.colours/index.css +1 -0
  22. package/app/assets/styles/setup/03.theming/_default.css +87 -0
  23. package/app/assets/styles/setup/03.theming/_error.css +14 -0
  24. package/app/assets/styles/setup/03.theming/_success.css +4 -0
  25. package/app/assets/styles/setup/03.theming/_theme-slots.css +16 -0
  26. package/app/assets/styles/setup/03.theming/_warning.css +13 -0
  27. package/app/assets/styles/setup/03.theming/index.css +6 -4
  28. package/app/assets/styles/setup/03.theming/theme-ramp.css +19 -0
  29. package/app/assets/styles/setup/04.elements/forms/00.element-defaults.css +0 -7
  30. package/app/assets/styles/setup/04.elements/forms/06.button-geometry.css +1 -1
  31. package/app/assets/styles/setup/index.css +1 -1
  32. package/app/components/01.atoms/display-dialog/DisplayDialog.vue +74 -24
  33. package/app/components/01.atoms/display-dialog/tests/DisplayDialog.spec.ts +148 -14
  34. package/app/components/01.atoms/display-dialog/tests/__snapshots__/DisplayDialog.spec.ts.snap +3 -3
  35. package/app/components/01.atoms/prompt/DisplayPrompt.vue +113 -0
  36. package/app/components/01.atoms/prompt/stories/DisplayPrompt.stories.ts +112 -0
  37. package/app/components/01.atoms/prompt/tests/DisplayPrompt.spec.ts +205 -0
  38. package/app/components/01.atoms/toast/DisplayToast.vue +327 -0
  39. package/app/components/01.atoms/toast/DisplayToastProvider.vue +362 -0
  40. package/app/components/01.atoms/toast/stories/DisplayToast.stories.ts +195 -0
  41. package/app/components/01.atoms/toast/stories/DisplayToastProvider.stories.ts +142 -0
  42. package/app/components/01.atoms/toast/tests/DisplayToast.spec.ts +266 -0
  43. package/app/components/01.atoms/toast/tests/DisplayToastProvider.spec.ts +256 -0
  44. package/app/components/02.molecules/action-menu/ActionMenu.vue +8 -1
  45. package/app/components/02.molecules/action-menu/ActionMenuItemCore.vue +11 -3
  46. package/app/components/02.molecules/alert-content/AlertContent.vue +67 -0
  47. package/app/components/02.molecules/alert-content/AlertContentInner.vue +153 -0
  48. package/app/components/02.molecules/alert-content/stories/AlertContent.stories.ts +131 -0
  49. package/app/components/02.molecules/alert-content/tests/AlertContent.spec.ts +135 -0
  50. package/app/components/02.molecules/alert-content/tests/AlertContentInner.spec.ts +135 -0
  51. package/app/components/02.molecules/alert-masked-content/AlertMaskedContent.vue +78 -0
  52. package/app/components/02.molecules/alert-masked-content/stories/AlertMaskedContent.stories.ts +132 -0
  53. package/app/components/02.molecules/alert-masked-content/tests/AlertMaskedContent.spec.ts +149 -0
  54. package/app/components/05.forms/input-button/InputButtonCore.vue +35 -39
  55. package/app/components/05.forms/input-checkbox-radio/InputCheckboxRadioButton.vue +8 -8
  56. package/app/components/05.forms/input-checkbox-radio/InputCheckboxRadioCore.vue +5 -5
  57. package/app/components/05.forms/input-copy/InputCopyCore.vue +7 -7
  58. package/app/components/05.forms/input-number/InputNumberCore.vue +5 -5
  59. package/app/components/05.forms/input-select/InputSelectCore.vue +6 -6
  60. package/app/components/05.forms/input-text/InputTextCore.vue +6 -6
  61. package/app/components/05.forms/input-text/variants/InputTextAsNumberWithLabel.vue +2 -2
  62. package/app/components/05.forms/input-textarea/InputTextareaCore.vue +5 -5
  63. package/app/components/05.forms/pending-effect/PendingEffect.vue +60 -60
  64. package/app/components/05.forms/toggle-switch/ToggleSwitchCore.vue +10 -10
  65. package/app/components/05.forms/triple-toggle-switch/TripleToggleSwitchCore.vue +4 -4
  66. package/app/components/display-theme-switch/DisplayThemeSwitch.vue +4 -4
  67. package/app/components/display-tooltip/DisplayTooltip.vue +4 -4
  68. package/app/composables/useBodyLock.ts +21 -0
  69. package/app/composables/useDialogControls.ts +11 -11
  70. package/app/composables/useToastQueue.ts +39 -0
  71. package/app/layouts/default.vue +1 -1
  72. package/app/pages/ui/display-dialog.vue +275 -127
  73. package/app/pages/ui/display-prompt.vue +255 -52
  74. package/app/pages/ui/display-toast.vue +278 -299
  75. package/app/pages/ui/mask-element.vue +25 -3
  76. package/app/pages/ui/settings.vue +9 -35
  77. package/app/types/app-config.d.ts +41 -0
  78. package/app/types/components/display-prompt.d.ts +11 -0
  79. package/app/types/components/display-toast.d.ts +11 -9
  80. package/app/types/components/index.ts +2 -0
  81. package/app/types/components/semantic-theme.d.ts +1 -0
  82. package/nuxt.config.ts +16 -0
  83. package/package.json +6 -4
  84. package/app/assets/styles/setup/03.theming/default/_dark.css +0 -156
  85. package/app/assets/styles/setup/03.theming/default/_light.css +0 -166
  86. package/app/assets/styles/setup/03.theming/default/index.css +0 -2
  87. package/app/assets/styles/setup/03.theming/error/_dark.css +0 -56
  88. package/app/assets/styles/setup/03.theming/error/_light.css +0 -74
  89. package/app/assets/styles/setup/03.theming/error/index.css +0 -2
  90. package/app/assets/styles/setup/03.theming/success/_dark.css +0 -50
  91. package/app/assets/styles/setup/03.theming/success/_light.css +0 -65
  92. package/app/assets/styles/setup/03.theming/success/index.css +0 -2
  93. package/app/assets/styles/setup/03.theming/warning/_dark.css +0 -50
  94. package/app/assets/styles/setup/03.theming/warning/_light.css +0 -64
  95. package/app/assets/styles/setup/03.theming/warning/index.css +0 -2
  96. package/app/components/display-prompt/DisplayPromptCore.vue +0 -228
  97. package/app/components/display-prompt/variants/DisplayPromptError.vue +0 -53
  98. package/app/components/display-toast/DisplayToast.vue +0 -447
  99. package/app/components/display-toast/molecules/DefaultToastContent.vue +0 -163
  100. package/app/components/display-toast/stories/DisplayToast.stories.ts +0 -380
@@ -0,0 +1,205 @@
1
+ import { describe, it, expect, vi, beforeEach } from "vitest";
2
+ import { nextTick } from "vue";
3
+ import { mountSuspended, mockNuxtImport } from "@nuxt/test-utils/runtime";
4
+ import DisplayPrompt from "../DisplayPrompt.vue";
5
+
6
+ const { useAppConfigMock } = vi.hoisted(() => ({
7
+ // icon: {} is required — @nuxt/icon reads useAppConfig().icon.collections internally.
8
+ useAppConfigMock: vi.fn(() => ({ srcdev: undefined as Record<string, unknown> | undefined, icon: {} as object })),
9
+ }));
10
+
11
+ mockNuxtImport("useAppConfig", () => useAppConfigMock);
12
+
13
+ function root(wrapper: Awaited<ReturnType<typeof mountSuspended<typeof DisplayPrompt>>>) {
14
+ return wrapper.find(".display-prompt-core");
15
+ }
16
+
17
+ function wrapper(w: Awaited<ReturnType<typeof mountSuspended<typeof DisplayPrompt>>>) {
18
+ return w.find("[data-test-id='display-prompt']");
19
+ }
20
+
21
+ describe("DisplayPrompt", () => {
22
+ // ─── Mount ────────────────────────────────────────────────────────────────
23
+
24
+ it("mounts without error", async () => {
25
+ const w = await mountSuspended(DisplayPrompt);
26
+ expect(w.vm).toBeTruthy();
27
+ });
28
+
29
+ // ─── Root element ─────────────────────────────────────────────────────────
30
+
31
+ it("renders the root element with class display-prompt-core", async () => {
32
+ const w = await mountSuspended(DisplayPrompt);
33
+ expect(root(w).exists()).toBe(true);
34
+ });
35
+
36
+ it("is visible by default (no closed class)", async () => {
37
+ const w = await mountSuspended(DisplayPrompt);
38
+ expect(root(w).classes()).not.toContain("closed");
39
+ });
40
+
41
+ it("has tabindex='0' on the root element", async () => {
42
+ const w = await mountSuspended(DisplayPrompt);
43
+ expect(root(w).attributes("tabindex")).toBe("0");
44
+ });
45
+
46
+ // ─── data-theme ───────────────────────────────────────────────────────────
47
+
48
+ it.each(["info", "success", "warning", "error"] as const)(
49
+ "sets data-theme='%s' on the wrapper",
50
+ async (theme) => {
51
+ const w = await mountSuspended(DisplayPrompt, { props: { theme } });
52
+ expect(wrapper(w).attributes("data-theme")).toBe(theme);
53
+ }
54
+ );
55
+
56
+ it("defaults to data-theme='info' when no theme prop is provided", async () => {
57
+ const w = await mountSuspended(DisplayPrompt);
58
+ expect(wrapper(w).attributes("data-theme")).toBe("info");
59
+ });
60
+
61
+ // ─── data-test-id ─────────────────────────────────────────────────────────
62
+
63
+ it.each(["info", "success", "warning", "error"] as const)(
64
+ "sets data-test-id reflecting the theme on the root element",
65
+ async (theme) => {
66
+ const w = await mountSuspended(DisplayPrompt, { props: { theme } });
67
+ expect(root(w).attributes("data-test-id")).toBe(`display-prompt-core-${theme}`);
68
+ }
69
+ );
70
+
71
+ // ─── Icon ─────────────────────────────────────────────────────────────────
72
+
73
+ it("renders the icon region", async () => {
74
+ const w = await mountSuspended(DisplayPrompt);
75
+ expect(w.find("[data-test-id='alert-icon']").exists()).toBe(true);
76
+ });
77
+
78
+ it("renders a custom icon via the customDecoratorIcon slot", async () => {
79
+ const w = await mountSuspended(DisplayPrompt, {
80
+ slots: { customDecoratorIcon: '<span class="custom-icon">★</span>' },
81
+ });
82
+ expect(w.find(".custom-icon").exists()).toBe(true);
83
+ });
84
+
85
+ // ─── Slots ────────────────────────────────────────────────────────────────
86
+
87
+ it("renders title slot content", async () => {
88
+ const w = await mountSuspended(DisplayPrompt, {
89
+ slots: { title: "Important notice" },
90
+ });
91
+ expect(w.find("[data-test-id='alert-title']").text()).toContain("Important notice");
92
+ });
93
+
94
+ it("renders content slot when provided", async () => {
95
+ const w = await mountSuspended(DisplayPrompt, {
96
+ slots: { content: "Detailed explanation here" },
97
+ });
98
+ expect(w.find("[data-test-id='alert-content']").exists()).toBe(true);
99
+ expect(w.find("[data-test-id='alert-content']").text()).toContain("Detailed explanation here");
100
+ });
101
+
102
+ it("does not render content element when content slot is empty", async () => {
103
+ const w = await mountSuspended(DisplayPrompt);
104
+ expect(w.find("[data-test-id='alert-content']").exists()).toBe(false);
105
+ });
106
+
107
+ // ─── Dismiss button ───────────────────────────────────────────────────────
108
+
109
+ it("does not render the dismiss button when dismissible is false", async () => {
110
+ const w = await mountSuspended(DisplayPrompt, { props: { dismissible: false } });
111
+ expect(w.find("[data-test-id='alert-dismiss']").exists()).toBe(false);
112
+ });
113
+
114
+ it("renders the dismiss button when dismissible is true", async () => {
115
+ const w = await mountSuspended(DisplayPrompt, { props: { dismissible: true } });
116
+ expect(w.find("[data-test-id='alert-dismiss']").exists()).toBe(true);
117
+ });
118
+
119
+ // ─── Dismiss behaviour (no parent model) ─────────────────────────────────
120
+
121
+ it("adds the closed class after the dismiss button is clicked", async () => {
122
+ const w = await mountSuspended(DisplayPrompt, { props: { dismissible: true } });
123
+ await w.find("[data-test-id='alert-dismiss']").trigger("click");
124
+ await nextTick();
125
+ expect(root(w).classes()).toContain("closed");
126
+ });
127
+
128
+ // ─── Dismiss behaviour (with parent model) ────────────────────────────────
129
+
130
+ it("emits update:modelValue=false when clicked with modelValue=true", async () => {
131
+ const w = await mountSuspended(DisplayPrompt, {
132
+ props: { dismissible: true, modelValue: true },
133
+ });
134
+ await w.find("[data-test-id='alert-dismiss']").trigger("click");
135
+ await nextTick();
136
+ const emitted = w.emitted("update:modelValue");
137
+ expect(emitted).toBeTruthy();
138
+ expect(emitted![0]).toEqual([false]);
139
+ });
140
+
141
+ it("does not add the closed class when dismissed via parent model", async () => {
142
+ const w = await mountSuspended(DisplayPrompt, {
143
+ props: { dismissible: true, modelValue: true },
144
+ });
145
+ await w.find("[data-test-id='alert-dismiss']").trigger("click");
146
+ await nextTick();
147
+ // componentOpen is unchanged — parent controls visibility via modelValue
148
+ expect(root(w).classes()).not.toContain("closed");
149
+ });
150
+
151
+ // ─── styleClassPassthrough ────────────────────────────────────────────────
152
+
153
+ it("applies a styleClassPassthrough string class to the wrapper", async () => {
154
+ const w = await mountSuspended(DisplayPrompt, {
155
+ props: { styleClassPassthrough: "outlined" },
156
+ });
157
+ expect(wrapper(w).classes()).toContain("outlined");
158
+ });
159
+
160
+ it("applies multiple styleClassPassthrough classes from an array", async () => {
161
+ const w = await mountSuspended(DisplayPrompt, {
162
+ props: { styleClassPassthrough: ["dark", "outlined"] },
163
+ });
164
+ expect(wrapper(w).classes()).toContain("dark");
165
+ expect(wrapper(w).classes()).toContain("outlined");
166
+ });
167
+
168
+ // ─── app.config resolution ────────────────────────────────────────────────
169
+
170
+ describe("app.config resolution", () => {
171
+ beforeEach(() => {
172
+ useAppConfigMock.mockReturnValue({ srcdev: undefined, icon: {} });
173
+ });
174
+
175
+ it("uses hardcoded fallbacks when app.config has no displayPrompt key", async () => {
176
+ const w = await mountSuspended(DisplayPrompt);
177
+ expect(wrapper(w).attributes("data-theme")).toBe("info");
178
+ expect(root(w).attributes("data-test-id")).toBe("display-prompt-core-info");
179
+ expect(w.find("[data-test-id='alert-dismiss']").exists()).toBe(false);
180
+ });
181
+
182
+ it("uses app.config values when no props are supplied", async () => {
183
+ useAppConfigMock.mockReturnValue({
184
+ icon: {},
185
+ srcdev: { displayPrompt: { theme: "success", dismissible: true } },
186
+ });
187
+ const w = await mountSuspended(DisplayPrompt);
188
+ expect(wrapper(w).attributes("data-theme")).toBe("success");
189
+ expect(root(w).attributes("data-test-id")).toBe("display-prompt-core-success");
190
+ expect(w.find("[data-test-id='alert-dismiss']").exists()).toBe(true);
191
+ });
192
+
193
+ it("explicit props take precedence over app.config", async () => {
194
+ useAppConfigMock.mockReturnValue({
195
+ icon: {},
196
+ srcdev: { displayPrompt: { theme: "warning", dismissible: true } },
197
+ });
198
+ const w = await mountSuspended(DisplayPrompt, {
199
+ props: { theme: "error", dismissible: false },
200
+ });
201
+ expect(wrapper(w).attributes("data-theme")).toBe("error");
202
+ expect(w.find("[data-test-id='alert-dismiss']").exists()).toBe(false);
203
+ });
204
+ });
205
+ });
@@ -0,0 +1,327 @@
1
+ <template>
2
+ <Teleport to="body">
3
+ <div
4
+ v-if="privateDisplayToast"
5
+ ref="toastElementRef"
6
+ class="display-toast"
7
+ :class="[elementClasses, cssStateClass, positionClasses]"
8
+ :data-theme="theme"
9
+ :role="toastRole"
10
+ :aria-live="ariaLive"
11
+ :tabindex="slots.default ? undefined : '0'"
12
+ :aria-describedby="slots.default ? undefined : 'toast-message-' + toastId"
13
+ @keydown.escape="setDismissToast"
14
+ >
15
+ <slot v-if="slots.default"></slot>
16
+
17
+ <component
18
+ :is="contentComponent"
19
+ v-else
20
+ :theme="theme"
21
+ :custom-icon="customIcon"
22
+ :content-id="'toast-message-' + toastId"
23
+ :dismissible="!autoDismiss"
24
+ @dismiss="setDismissToast"
25
+ >
26
+ <template v-if="slots.customToastIcon" #icon>
27
+ <slot name="customToastIcon"></slot>
28
+ </template>
29
+ <template v-if="slots.title || toastTitle || toastDisplayText" #title>
30
+ <slot name="title">{{ toastTitle || toastDisplayText }}</slot>
31
+ </template>
32
+ <template v-if="slots.description || toastDescription" #content>
33
+ <slot name="description">{{ toastDescription }}</slot>
34
+ </template>
35
+ </component>
36
+ <div v-if="autoDismiss" class="display-toast-progress"></div>
37
+ </div>
38
+ </Teleport>
39
+ </template>
40
+
41
+ <script setup lang="ts">
42
+ import AlertContent from "~/components/02.molecules/alert-content/AlertContent.vue";
43
+ import AlertMaskedContent from "~/components/02.molecules/alert-masked-content/AlertMaskedContent.vue";
44
+ import type { DisplayToastConfig, ToastSlots } from "~/types/components";
45
+
46
+ interface Props {
47
+ config?: DisplayToastConfig;
48
+ styleClassPassthrough?: string | string[];
49
+ }
50
+
51
+ const props = withDefaults(defineProps<Props>(), {
52
+ config: undefined,
53
+ styleClassPassthrough: () => [],
54
+ });
55
+
56
+ const slots = defineSlots<ToastSlots>();
57
+
58
+ const { elementClasses, resetElementClasses } = useStyleClassPassthrough(props.styleClassPassthrough);
59
+
60
+ const appConfig = useAppConfig();
61
+
62
+ // Computed properties for accessing config values with defaults
63
+ // Resolution chain: prop config → app.config → hardcoded fallback
64
+ const theme = computed(() => props.config?.appearance?.theme ?? appConfig.srcdev?.displayToast?.appearance?.theme ?? "info");
65
+ const position = computed(() => props.config?.appearance?.position ?? appConfig.srcdev?.displayToast?.appearance?.position ?? "top");
66
+ const alignment = computed(() => props.config?.appearance?.alignment ?? appConfig.srcdev?.displayToast?.appearance?.alignment ?? "right");
67
+ const fullWidth = computed(() => props.config?.appearance?.fullWidth ?? appConfig.srcdev?.displayToast?.appearance?.fullWidth ?? false);
68
+ const masked = computed(() => props.config?.appearance?.masked ?? appConfig.srcdev?.displayToast?.appearance?.masked ?? false);
69
+ const contentComponent = computed(() => (masked.value ? AlertMaskedContent : AlertContent));
70
+ const autoDismiss = computed(() => props.config?.behavior?.autoDismiss ?? appConfig.srcdev?.displayToast?.behavior?.autoDismiss ?? true);
71
+ const duration = computed(() => props.config?.behavior?.duration ?? appConfig.srcdev?.displayToast?.behavior?.duration ?? 5000);
72
+ const revealDuration = computed(() => props.config?.behavior?.revealDuration ?? appConfig.srcdev?.displayToast?.behavior?.revealDuration ?? 550);
73
+ const returnFocusTo = computed(() => props.config?.behavior?.returnFocusTo ?? null);
74
+ const toastDisplayText = computed(() => props.config?.content?.text ?? "");
75
+ const toastTitle = computed(() => props.config?.content?.title ?? "");
76
+ const toastDescription = computed(() => props.config?.content?.description ?? "");
77
+ const customIcon = computed(() => props.config?.content?.customIcon);
78
+
79
+ // Computed classes for positioning
80
+ const positionClasses = computed(() => {
81
+ const classes = [];
82
+ classes.push(position.value);
83
+ if (fullWidth.value) {
84
+ classes.push("full-width");
85
+ } else {
86
+ classes.push(alignment.value);
87
+ }
88
+ return classes;
89
+ });
90
+
91
+ /*
92
+ * Accessibility setup
93
+ */
94
+ const toastId = useId();
95
+ const toastElementRef = useTemplateRef<HTMLElement>("toastElementRef");
96
+
97
+ // Determine appropriate ARIA attributes based on theme
98
+ const toastRole = computed(() => {
99
+ return ["error", "warning"].includes(theme.value) ? "alert" : "status";
100
+ });
101
+
102
+ const ariaLive = computed(() => {
103
+ return ["error", "warning"].includes(theme.value) ? "assertive" : "polite";
104
+ });
105
+
106
+ /*
107
+ * Setup component state
108
+ */
109
+ const externalTriggerModel = defineModel<boolean>({ default: false });
110
+ const privateDisplayToast = ref(false);
111
+ const transitionalState = ref(false);
112
+ const cssStateClass = computed(() => {
113
+ return transitionalState.value ? "show" : "hide";
114
+ });
115
+
116
+ /*
117
+ * Computed properties for durations (in ms for CSS)
118
+ */
119
+ const revealDurationMs = computed(() => revealDuration.value + "ms");
120
+ const displayDurationMs = computed(() => duration.value + "ms");
121
+
122
+ /*
123
+ * Lifecycle hooks
124
+ */
125
+ const setDismissToast = async () => {
126
+ transitionalState.value = false;
127
+ await useSleep(revealDuration.value);
128
+
129
+ // Return focus to specified element if provided
130
+ if (returnFocusTo.value) {
131
+ // Handle both HTMLElement and ComponentPublicInstance
132
+ let focusTarget: HTMLElement | null = null;
133
+
134
+ if (returnFocusTo.value instanceof HTMLElement) {
135
+ focusTarget = returnFocusTo.value;
136
+ } else if (returnFocusTo.value && "$el" in returnFocusTo.value) {
137
+ focusTarget = returnFocusTo.value.$el as HTMLElement;
138
+ }
139
+
140
+ if (focusTarget && typeof focusTarget.focus === "function") {
141
+ focusTarget.focus();
142
+ }
143
+ }
144
+
145
+ externalTriggerModel.value = false;
146
+ privateDisplayToast.value = false;
147
+ };
148
+
149
+ watch(
150
+ () => props.styleClassPassthrough,
151
+ () => {
152
+ resetElementClasses(props.styleClassPassthrough);
153
+ }
154
+ );
155
+
156
+ watch(
157
+ () => externalTriggerModel.value,
158
+ async (newValue, previousValue) => {
159
+ if (newValue) {
160
+ privateDisplayToast.value = true;
161
+ transitionalState.value = true;
162
+
163
+ // Focus management for accessibility when not using custom slots
164
+ if (!slots.default) {
165
+ await nextTick();
166
+ // Wait for animation to start before focusing
167
+ setTimeout(() => {
168
+ toastElementRef.value?.focus();
169
+ }, 100);
170
+ }
171
+
172
+ if (autoDismiss.value) {
173
+ await useSleep(duration.value);
174
+ setDismissToast();
175
+ }
176
+ } else if (!newValue && previousValue) {
177
+ // If external model is set to false, dismiss the toast
178
+ setDismissToast();
179
+ }
180
+ }
181
+ );
182
+
183
+ onBeforeRouteLeave(() => {
184
+ setDismissToast();
185
+ });
186
+ </script>
187
+
188
+ <style lang="css">
189
+ @layer components {
190
+ @keyframes show {
191
+ to {
192
+ opacity: 1;
193
+ transform: translateY(0);
194
+ }
195
+ }
196
+
197
+ @keyframes hideTop {
198
+ 0% {
199
+ opacity: 1;
200
+ transform: translateY(0);
201
+ }
202
+ 100% {
203
+ opacity: 0;
204
+ transform: translateY(-30px);
205
+ }
206
+ }
207
+
208
+ @keyframes hideBottom {
209
+ 0% {
210
+ opacity: 1;
211
+ transform: translateY(0);
212
+ }
213
+ 100% {
214
+ opacity: 0;
215
+ transform: translateY(30px);
216
+ }
217
+ }
218
+
219
+ @keyframes progress {
220
+ to {
221
+ transform: scaleX(1);
222
+ }
223
+ }
224
+
225
+ .display-toast {
226
+ --_toast-gutter: 12px;
227
+ @media (width >= 600px) {
228
+ --_toast-gutter: 24px;
229
+ }
230
+
231
+ display: block;
232
+ overflow: hidden;
233
+ position: fixed;
234
+ margin: 0;
235
+ opacity: 0;
236
+
237
+ z-index: 100;
238
+
239
+ /* Focus styles for accessibility */
240
+ &:focus {
241
+ outline: 2px solid var(--theme-ring);
242
+ outline-offset: 2px;
243
+ }
244
+
245
+ &:focus:not(:focus-visible) {
246
+ outline: none;
247
+ }
248
+
249
+ &.show {
250
+ @supports (animation-timing-function: linear(0, 1)) {
251
+ animation: show v-bind(revealDurationMs) var(--spring-easing) forwards;
252
+ }
253
+
254
+ @supports not (animation-timing-function: linear(0, 1)) {
255
+ animation: show calc(v-bind(revealDurationMs) / 2) linear forwards;
256
+ }
257
+ }
258
+
259
+ &.hide {
260
+ @supports (animation-timing-function: linear(0, 1)) {
261
+ animation: hideTop v-bind(revealDurationMs) var(--spring-easing) forwards;
262
+ }
263
+
264
+ @supports not (animation-timing-function: linear(0, 1)) {
265
+ animation: hideTop calc(v-bind(revealDurationMs) / 2) linear forwards;
266
+ }
267
+
268
+ &.bottom {
269
+ @supports (animation-timing-function: linear(0, 1)) {
270
+ animation: hideBottom v-bind(revealDurationMs) var(--spring-easing) forwards;
271
+ }
272
+
273
+ @supports not (animation-timing-function: linear(0, 1)) {
274
+ animation: hideBottom calc(v-bind(revealDurationMs) / 2) linear forwards;
275
+ }
276
+ }
277
+ }
278
+
279
+ /*
280
+ * Default is centre for smaller screens
281
+ */
282
+ inset-inline: var(--_toast-gutter);
283
+ margin-inline: auto;
284
+
285
+ @media (width >= 600px) {
286
+ &.left {
287
+ inset-inline-start: var(--_toast-gutter);
288
+ inset-inline-end: unset;
289
+ }
290
+
291
+ &.right {
292
+ inset-inline-end: var(--_toast-gutter);
293
+ inset-inline-start: unset;
294
+ }
295
+
296
+ &.center {
297
+ &:not(.full-width) {
298
+ inset-inline: 0;
299
+ margin-inline: auto;
300
+ width: max-content;
301
+ }
302
+ }
303
+ }
304
+
305
+ &.top {
306
+ inset-block-start: var(--_toast-gutter);
307
+ transform: translateY(-30px);
308
+ }
309
+ &.bottom {
310
+ inset-block-end: var(--_toast-gutter);
311
+ transform: translateY(30px);
312
+ }
313
+
314
+ .display-toast-progress {
315
+ position: absolute;
316
+ inset-block-end: 4px;
317
+ inset-inline: 15px 8px;
318
+ height: 3px;
319
+ transform: scaleX(0);
320
+ transform-origin: right;
321
+ background: var(--theme-accent);
322
+ border-radius: inherit;
323
+ animation: progress v-bind(displayDurationMs) linear forwards;
324
+ }
325
+ }
326
+ }
327
+ </style>