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,266 @@
1
+ import { describe, it, expect, vi, beforeEach } from "vitest";
2
+ import { nextTick } from "vue";
3
+ import { mountSuspended, mockNuxtImport } from "@nuxt/test-utils/runtime";
4
+
5
+ const { useAppConfigMock } = vi.hoisted(() => ({
6
+ // icon: {} is required — @nuxt/icon reads useAppConfig().icon.collections internally.
7
+ useAppConfigMock: vi.fn(() => ({ srcdev: undefined as Record<string, unknown> | undefined, icon: {} as object })),
8
+ }));
9
+
10
+ mockNuxtImport("useAppConfig", () => useAppConfigMock);
11
+ import DisplayToast from "../DisplayToast.vue";
12
+
13
+ // Helper: mount the toast and activate it so the teleported element is in the DOM.
14
+ async function mountAndShow(props: Record<string, unknown> = {}, slots: Record<string, string> = {}) {
15
+ const { config, styleClassPassthrough, ...rest } = props;
16
+ const wrapper = await mountSuspended(DisplayToast, {
17
+ props: { config, styleClassPassthrough } as Record<string, unknown>,
18
+ slots,
19
+ });
20
+ await wrapper.setProps({ ...rest, modelValue: true });
21
+ await nextTick();
22
+ return wrapper;
23
+ }
24
+
25
+ function toast() {
26
+ return document.querySelector(".display-toast");
27
+ }
28
+
29
+ describe("DisplayToast", () => {
30
+ afterEach(() => {
31
+ document.body.innerHTML = "";
32
+ });
33
+
34
+ // ─── Mount ────────────────────────────────────────────────────────────────
35
+
36
+ it("mounts without error", async () => {
37
+ const wrapper = await mountSuspended(DisplayToast);
38
+ expect(wrapper.vm).toBeTruthy();
39
+ });
40
+
41
+ // ─── Visibility ───────────────────────────────────────────────────────────
42
+
43
+ it("does not render the toast element when modelValue is false", async () => {
44
+ await mountSuspended(DisplayToast);
45
+ expect(toast()).toBeNull();
46
+ });
47
+
48
+ it("renders the toast element when modelValue becomes true", async () => {
49
+ await mountAndShow();
50
+ expect(toast()).not.toBeNull();
51
+ });
52
+
53
+ it("has the show class when activated", async () => {
54
+ await mountAndShow();
55
+ expect(toast()!.classList).toContain("show");
56
+ });
57
+
58
+ // ─── data-theme ───────────────────────────────────────────────────────────
59
+
60
+ it.each(["info", "success", "warning", "error"] as const)(
61
+ "sets data-theme='%s' from config",
62
+ async (theme) => {
63
+ await mountAndShow({ config: { appearance: { theme } } });
64
+ expect(toast()!.getAttribute("data-theme")).toBe(theme);
65
+ }
66
+ );
67
+
68
+ it("defaults to data-theme='info' when no theme is configured", async () => {
69
+ await mountAndShow();
70
+ expect(toast()!.getAttribute("data-theme")).toBe("info");
71
+ });
72
+
73
+ // ─── ARIA ─────────────────────────────────────────────────────────────────
74
+
75
+ it.each([
76
+ { theme: "info" as const, role: "status", live: "polite" },
77
+ { theme: "success" as const, role: "status", live: "polite" },
78
+ { theme: "warning" as const, role: "alert", live: "assertive" },
79
+ { theme: "error" as const, role: "alert", live: "assertive" },
80
+ ])("sets role=$role and aria-live=$live for theme='$theme'", async ({ theme, role, live }) => {
81
+ await mountAndShow({ config: { appearance: { theme } } });
82
+ expect(toast()!.getAttribute("role")).toBe(role);
83
+ expect(toast()!.getAttribute("aria-live")).toBe(live);
84
+ });
85
+
86
+ // ─── Position classes ─────────────────────────────────────────────────────
87
+
88
+ it.each(["top", "bottom"] as const)("applies %s position class", async (position) => {
89
+ await mountAndShow({ config: { appearance: { position } } });
90
+ expect(toast()!.classList).toContain(position);
91
+ });
92
+
93
+ it.each(["left", "center", "right"] as const)("applies %s alignment class", async (alignment) => {
94
+ await mountAndShow({ config: { appearance: { alignment } } });
95
+ expect(toast()!.classList).toContain(alignment);
96
+ });
97
+
98
+ it("applies full-width class when fullWidth is true", async () => {
99
+ await mountAndShow({ config: { appearance: { fullWidth: true } } });
100
+ expect(toast()!.classList).toContain("full-width");
101
+ });
102
+
103
+ it("does not apply alignment class when fullWidth is true", async () => {
104
+ await mountAndShow({ config: { appearance: { fullWidth: true, alignment: "left" } } });
105
+ expect(toast()!.classList).not.toContain("left");
106
+ });
107
+
108
+ // ─── tabindex / aria-describedby ──────────────────────────────────────────
109
+
110
+ it("sets tabindex='0' when no default slot is used", async () => {
111
+ await mountAndShow();
112
+ expect(toast()!.getAttribute("tabindex")).toBe("0");
113
+ });
114
+
115
+ it("omits tabindex when a default slot is used", async () => {
116
+ await mountAndShow({}, { default: "<div>Custom</div>" });
117
+ expect(toast()!.getAttribute("tabindex")).toBeNull();
118
+ });
119
+
120
+ it("sets aria-describedby when no default slot is used", async () => {
121
+ await mountAndShow();
122
+ expect(toast()!.getAttribute("aria-describedby")).not.toBeNull();
123
+ });
124
+
125
+ it("omits aria-describedby when a default slot is used", async () => {
126
+ await mountAndShow({}, { default: "<div>Custom</div>" });
127
+ expect(toast()!.getAttribute("aria-describedby")).toBeNull();
128
+ });
129
+
130
+ // ─── Content ──────────────────────────────────────────────────────────────
131
+
132
+ it("renders text content from config", async () => {
133
+ await mountAndShow({ config: { content: { text: "Hello toast" } } });
134
+ expect(document.querySelector(".alert-content-body")!.textContent).toContain("Hello toast");
135
+ });
136
+
137
+ it("renders title from config", async () => {
138
+ await mountAndShow({ config: { content: { title: "Toast Title" } } });
139
+ expect(document.querySelector("[data-test-id='alert-title']")!.textContent).toContain("Toast Title");
140
+ });
141
+
142
+ it("renders description from config", async () => {
143
+ await mountAndShow({ config: { content: { description: "Toast description" } } });
144
+ expect(document.querySelector("[data-test-id='alert-content']")!.textContent).toContain(
145
+ "Toast description"
146
+ );
147
+ });
148
+
149
+ it("renders title and description together", async () => {
150
+ await mountAndShow({
151
+ config: { content: { title: "Title", description: "Desc" } },
152
+ });
153
+ expect(document.querySelector("[data-test-id='alert-title']")).not.toBeNull();
154
+ expect(document.querySelector("[data-test-id='alert-content']")).not.toBeNull();
155
+ });
156
+
157
+ // ─── Default slot ─────────────────────────────────────────────────────────
158
+
159
+ it("renders default slot content instead of AlertContent", async () => {
160
+ await mountAndShow({}, { default: '<p class="custom-content">Custom</p>' });
161
+ expect(document.querySelector(".custom-content")).not.toBeNull();
162
+ expect(document.querySelector(".alert-content")).toBeNull();
163
+ });
164
+
165
+ // ─── Progress bar ─────────────────────────────────────────────────────────
166
+
167
+ it("renders the progress bar when autoDismiss is true", async () => {
168
+ await mountAndShow({ config: { behavior: { autoDismiss: true } } });
169
+ expect(document.querySelector(".display-toast-progress")).not.toBeNull();
170
+ });
171
+
172
+ it("does not render the progress bar when autoDismiss is false", async () => {
173
+ await mountAndShow({ config: { behavior: { autoDismiss: false } } });
174
+ expect(document.querySelector(".display-toast-progress")).toBeNull();
175
+ });
176
+
177
+ // ─── Close button ─────────────────────────────────────────────────────────
178
+
179
+ it("renders the close button in the toast content when autoDismiss is false", async () => {
180
+ await mountAndShow({ config: { behavior: { autoDismiss: false } } });
181
+ expect(document.querySelector("[data-test-id='alert-dismiss']")).not.toBeNull();
182
+ });
183
+
184
+ it("does not render the close button when autoDismiss is true", async () => {
185
+ await mountAndShow({ config: { behavior: { autoDismiss: true } } });
186
+ expect(document.querySelector("[data-test-id='alert-dismiss']")).toBeNull();
187
+ });
188
+
189
+ // ─── Dismiss via Escape key ────────────────────────────────────────────────
190
+
191
+ it("transitions to hide class when Escape is pressed", async () => {
192
+ await mountAndShow({ config: { behavior: { autoDismiss: false } } });
193
+ vi.advanceTimersByTime(100); // fire the focus setTimeout
194
+ await nextTick();
195
+ toast()!.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
196
+ await nextTick();
197
+ expect(toast()!.classList).toContain("hide");
198
+ });
199
+
200
+ // ─── Dismiss via close button ─────────────────────────────────────────────
201
+
202
+ it("transitions to hide class when the close button is clicked", async () => {
203
+ await mountAndShow({ config: { behavior: { autoDismiss: false } } });
204
+ vi.advanceTimersByTime(100);
205
+ await nextTick();
206
+ (document.querySelector("[data-test-id='alert-dismiss']") as HTMLElement).click();
207
+ await nextTick();
208
+ expect(toast()!.classList).toContain("hide");
209
+ });
210
+
211
+ // ─── styleClassPassthrough ────────────────────────────────────────────────
212
+
213
+ it("applies a styleClassPassthrough string class to the toast element", async () => {
214
+ await mountAndShow({ styleClassPassthrough: "my-custom" });
215
+ expect(toast()!.classList).toContain("my-custom");
216
+ });
217
+
218
+ it("applies multiple styleClassPassthrough classes from an array", async () => {
219
+ await mountAndShow({ styleClassPassthrough: ["class-a", "class-b"] });
220
+ expect(toast()!.classList).toContain("class-a");
221
+ expect(toast()!.classList).toContain("class-b");
222
+ });
223
+
224
+ // ─── app.config resolution ────────────────────────────────────────────────
225
+ // Combines mockNuxtImport (#imports path) + vi.stubGlobal (global path) to
226
+ // cover both routes Nuxt may use to resolve useAppConfig in this test env.
227
+
228
+ describe("app.config resolution", () => {
229
+ beforeEach(() => {
230
+ // Stub the global so the component's useAppConfig() call is intercepted
231
+ // regardless of whether Nuxt resolves it via #imports or the global scope.
232
+ vi.stubGlobal("useAppConfig", useAppConfigMock);
233
+ useAppConfigMock.mockImplementation(() => ({ srcdev: undefined, icon: {} }));
234
+ });
235
+
236
+ it("uses hardcoded fallbacks when app.config has no displayToast key", async () => {
237
+ await mountAndShow();
238
+ expect(toast()!.getAttribute("data-theme")).toBe("info");
239
+ expect(toast()!.classList).toContain("top");
240
+ expect(toast()!.classList).toContain("right");
241
+ expect(document.querySelector(".display-toast-progress")).not.toBeNull();
242
+ });
243
+
244
+ it("uses app.config autoDismiss:false when no config prop is supplied", async () => {
245
+ // autoDismiss defaults to true (both hardcoded and real app.config), so false
246
+ // is the only value that proves the mock is being read.
247
+ useAppConfigMock.mockImplementation(() => ({
248
+ icon: {},
249
+ srcdev: { displayToast: { behavior: { autoDismiss: false } } },
250
+ }));
251
+ await mountAndShow();
252
+ expect(document.querySelector(".display-toast-progress")).toBeNull();
253
+ expect(document.querySelector("[data-test-id='alert-dismiss']")).not.toBeNull();
254
+ });
255
+
256
+ it("config prop takes precedence over app.config", async () => {
257
+ useAppConfigMock.mockImplementation(() => ({
258
+ icon: {},
259
+ srcdev: { displayToast: { appearance: { theme: "success", position: "bottom" } } },
260
+ }));
261
+ await mountAndShow({ config: { appearance: { theme: "warning", position: "top" } } });
262
+ expect(toast()!.getAttribute("data-theme")).toBe("warning");
263
+ expect(toast()!.classList).toContain("top");
264
+ });
265
+ });
266
+ });
@@ -0,0 +1,256 @@
1
+ import { describe, it, expect, vi } from "vitest";
2
+ import { nextTick } from "vue";
3
+ import { mountSuspended } from "@nuxt/test-utils/runtime";
4
+ import DisplayToastProvider from "../DisplayToastProvider.vue";
5
+ import { useToastQueue } from "~/composables/useToastQueue";
6
+
7
+ function item() {
8
+ return document.querySelector(".display-toast-provider-item");
9
+ }
10
+
11
+ function items() {
12
+ return document.querySelectorAll(".display-toast-provider-item");
13
+ }
14
+
15
+ function provider() {
16
+ return document.querySelector(".display-toast-provider");
17
+ }
18
+
19
+ describe("DisplayToastProvider", () => {
20
+ const { show, clear } = useToastQueue();
21
+
22
+ beforeEach(() => {
23
+ clear();
24
+ });
25
+
26
+ afterEach(() => {
27
+ clear();
28
+ document.body.innerHTML = "";
29
+ });
30
+
31
+ // ─── Mount ────────────────────────────────────────────────────────────────
32
+
33
+ it("mounts without error", async () => {
34
+ const wrapper = await mountSuspended(DisplayToastProvider);
35
+ expect(wrapper.vm).toBeTruthy();
36
+ });
37
+
38
+ it("renders the provider container", async () => {
39
+ await mountSuspended(DisplayToastProvider);
40
+ expect(provider()).not.toBeNull();
41
+ });
42
+
43
+ // ─── Empty state ──────────────────────────────────────────────────────────
44
+
45
+ it("renders no items when queue is empty", async () => {
46
+ await mountSuspended(DisplayToastProvider);
47
+ expect(items().length).toBe(0);
48
+ });
49
+
50
+ // ─── Show / queue ─────────────────────────────────────────────────────────
51
+
52
+ it("promotes a pending entry to visible on show()", async () => {
53
+ await mountSuspended(DisplayToastProvider);
54
+ show({ content: { text: "Hello" } });
55
+ await nextTick();
56
+ expect(items().length).toBe(1);
57
+ });
58
+
59
+ it("renders the toast message text", async () => {
60
+ await mountSuspended(DisplayToastProvider);
61
+ show({ content: { text: "Toast message" } });
62
+ await nextTick();
63
+ expect(document.querySelector(".alert-content-body")!.textContent).toContain("Toast message");
64
+ });
65
+
66
+ it("renders the toast title", async () => {
67
+ await mountSuspended(DisplayToastProvider);
68
+ show({ content: { title: "My Title" } });
69
+ await nextTick();
70
+ expect(document.querySelector("[data-test-id='alert-title']")!.textContent).toContain("My Title");
71
+ });
72
+
73
+ it("renders the toast description", async () => {
74
+ await mountSuspended(DisplayToastProvider);
75
+ show({ content: { description: "My description" } });
76
+ await nextTick();
77
+ expect(document.querySelector("[data-test-id='alert-content']")!.textContent).toContain("My description");
78
+ });
79
+
80
+ // ─── data-theme ───────────────────────────────────────────────────────────
81
+
82
+ it.each(["info", "success", "warning", "error"] as const)(
83
+ "sets data-theme='%s' from config",
84
+ async (theme) => {
85
+ await mountSuspended(DisplayToastProvider);
86
+ show({ appearance: { theme } });
87
+ await nextTick();
88
+ expect(item()!.getAttribute("data-theme")).toBe(theme);
89
+ }
90
+ );
91
+
92
+ it("defaults to data-theme='info'", async () => {
93
+ await mountSuspended(DisplayToastProvider);
94
+ show({});
95
+ await nextTick();
96
+ expect(item()!.getAttribute("data-theme")).toBe("info");
97
+ });
98
+
99
+ // ─── ARIA ─────────────────────────────────────────────────────────────────
100
+
101
+ it.each([
102
+ { theme: "info" as const, role: "status", live: "polite" },
103
+ { theme: "success" as const, role: "status", live: "polite" },
104
+ { theme: "warning" as const, role: "alert", live: "assertive" },
105
+ { theme: "error" as const, role: "alert", live: "assertive" },
106
+ ])("sets role=$role and aria-live=$live for theme='$theme'", async ({ theme, role, live }) => {
107
+ await mountSuspended(DisplayToastProvider);
108
+ show({ appearance: { theme } });
109
+ await nextTick();
110
+ expect(item()!.getAttribute("role")).toBe(role);
111
+ expect(item()!.getAttribute("aria-live")).toBe(live);
112
+ });
113
+
114
+ it("sets aria-describedby on each item", async () => {
115
+ await mountSuspended(DisplayToastProvider);
116
+ show({});
117
+ await nextTick();
118
+ expect(item()!.getAttribute("aria-describedby")).not.toBeNull();
119
+ });
120
+
121
+ it("sets tabindex='0' on each item", async () => {
122
+ await mountSuspended(DisplayToastProvider);
123
+ show({});
124
+ await nextTick();
125
+ expect(item()!.getAttribute("tabindex")).toBe("0");
126
+ });
127
+
128
+ // ─── Position / alignment classes ────────────────────────────────────────
129
+
130
+ it.each(["top", "bottom"] as const)("applies %s class to provider", async (position) => {
131
+ await mountSuspended(DisplayToastProvider, { props: { position } });
132
+ expect(provider()!.classList).toContain(position);
133
+ });
134
+
135
+ it.each(["left", "center", "right"] as const)(
136
+ "applies %s alignment class to provider",
137
+ async (alignment) => {
138
+ await mountSuspended(DisplayToastProvider, { props: { alignment } });
139
+ expect(provider()!.classList).toContain(alignment);
140
+ }
141
+ );
142
+
143
+ it("applies full-width class when fullWidth is true", async () => {
144
+ await mountSuspended(DisplayToastProvider, { props: { fullWidth: true } });
145
+ expect(provider()!.classList).toContain("full-width");
146
+ });
147
+
148
+ it("does not apply alignment class when fullWidth is true", async () => {
149
+ await mountSuspended(DisplayToastProvider, { props: { fullWidth: true, alignment: "left" } });
150
+ expect(provider()!.classList).not.toContain("left");
151
+ });
152
+
153
+ // ─── maxVisible ───────────────────────────────────────────────────────────
154
+
155
+ it("shows only one item at a time when maxVisible=1", async () => {
156
+ await mountSuspended(DisplayToastProvider, { props: { maxVisible: 1 } });
157
+ show({ content: { text: "First" } });
158
+ show({ content: { text: "Second" } });
159
+ await nextTick();
160
+ expect(items().length).toBe(1);
161
+ expect(document.querySelector(".alert-content-body")!.textContent).toContain("First");
162
+ });
163
+
164
+ it("shows up to maxVisible items simultaneously", async () => {
165
+ await mountSuspended(DisplayToastProvider, { props: { maxVisible: 2 } });
166
+ show({ content: { text: "First" } });
167
+ show({ content: { text: "Second" } });
168
+ show({ content: { text: "Third" } });
169
+ await nextTick();
170
+ expect(items().length).toBe(2);
171
+ });
172
+
173
+ // ─── Dismiss ──────────────────────────────────────────────────────────────
174
+
175
+ it("removes item when close button is clicked", async () => {
176
+ await mountSuspended(DisplayToastProvider);
177
+ show({ behavior: { autoDismiss: false } });
178
+ await nextTick();
179
+ (document.querySelector("[data-test-id='alert-dismiss']") as HTMLElement).click();
180
+ await nextTick();
181
+ expect(items().length).toBe(0);
182
+ });
183
+
184
+ it("removes item when Escape is pressed", async () => {
185
+ await mountSuspended(DisplayToastProvider);
186
+ show({ behavior: { autoDismiss: false } });
187
+ await nextTick();
188
+ item()!.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
189
+ await nextTick();
190
+ expect(items().length).toBe(0);
191
+ });
192
+
193
+ // ─── Auto-dismiss timer ───────────────────────────────────────────────────
194
+
195
+ it("auto-dismisses after duration", async () => {
196
+ await mountSuspended(DisplayToastProvider);
197
+ show({ behavior: { autoDismiss: true, duration: 3000 } });
198
+ await nextTick();
199
+ expect(items().length).toBe(1);
200
+ vi.advanceTimersByTime(3000);
201
+ await nextTick();
202
+ expect(items().length).toBe(0);
203
+ });
204
+
205
+ it("does not auto-dismiss when autoDismiss is false", async () => {
206
+ await mountSuspended(DisplayToastProvider);
207
+ show({ behavior: { autoDismiss: false } });
208
+ await nextTick();
209
+ vi.advanceTimersByTime(10000);
210
+ await nextTick();
211
+ expect(items().length).toBe(1);
212
+ });
213
+
214
+ // ─── Queue progression ────────────────────────────────────────────────────
215
+
216
+ it("promotes next pending entry after current is dismissed", async () => {
217
+ await mountSuspended(DisplayToastProvider, { props: { maxVisible: 1 } });
218
+ show({ content: { text: "First" }, behavior: { autoDismiss: false } });
219
+ show({ content: { text: "Second" }, behavior: { autoDismiss: false } });
220
+ await nextTick();
221
+ expect(document.querySelector(".alert-content-body")!.textContent).toContain("First");
222
+
223
+ item()!.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
224
+ await nextTick();
225
+ expect(document.querySelector(".alert-content-body")!.textContent).toContain("Second");
226
+ });
227
+
228
+ // ─── Progress bar ─────────────────────────────────────────────────────────
229
+
230
+ it("renders progress bar when autoDismiss is true", async () => {
231
+ await mountSuspended(DisplayToastProvider);
232
+ show({ behavior: { autoDismiss: true } });
233
+ await nextTick();
234
+ expect(document.querySelector(".display-toast-provider-progress")).not.toBeNull();
235
+ });
236
+
237
+ it("does not render progress bar when autoDismiss is false", async () => {
238
+ await mountSuspended(DisplayToastProvider);
239
+ show({ behavior: { autoDismiss: false } });
240
+ await nextTick();
241
+ expect(document.querySelector(".display-toast-provider-progress")).toBeNull();
242
+ });
243
+
244
+ // ─── clear() ─────────────────────────────────────────────────────────────
245
+
246
+ it("removes all items on clear()", async () => {
247
+ const { clear: clearQueue } = useToastQueue();
248
+ await mountSuspended(DisplayToastProvider, { props: { maxVisible: 3 } });
249
+ show({});
250
+ show({});
251
+ await nextTick();
252
+ clearQueue();
253
+ await nextTick();
254
+ expect(items().length).toBe(0);
255
+ });
256
+ });
@@ -155,7 +155,7 @@ const { elementClasses } = useStyleClassPassthrough(props.styleClassPassthrough)
155
155
  }
156
156
 
157
157
  &:focus-visible {
158
- outline: var(--button-outline-width, 0.2rem) solid var(--theme-button-primary-outline, currentcolor);
158
+ outline: var(--button-outline-width, 0.2rem) solid var(--theme-ring, currentcolor);
159
159
  outline-offset: 0.2rem;
160
160
  }
161
161
 
@@ -202,11 +202,18 @@ const { elementClasses } = useStyleClassPassthrough(props.styleClassPassthrough)
202
202
  }
203
203
 
204
204
  .action-menu-list {
205
+ display: grid;
206
+ grid-template-columns: auto 1fr auto;
207
+
205
208
  list-style: none;
206
209
  padding: 0;
207
210
  margin: 0;
208
211
 
209
212
  .action-menu-list-item {
213
+ grid-column: 1 / -1;
214
+ display: grid;
215
+ grid-template-columns: subgrid;
216
+
210
217
  &:not(:last-child) {
211
218
  border-bottom: var(--_item-divider);
212
219
  }
@@ -57,6 +57,7 @@ watch(
57
57
  <style lang="css">
58
58
  @layer components {
59
59
  .action-menu-item-core {
60
+ --_gap: var(--action-menu-item-gap, 1.2rem);
60
61
  --_surface-hover: var(--action-menu-item-surface-hover, light-dark(var(--slate-01), var(--slate-09)));
61
62
  --_text-color: var(--action-menu-item-text-color, light-dark(var(--slate-09), var(--slate-01)));
62
63
  --_icon-size: var(--action-menu-item-icon-size, 2rem);
@@ -67,9 +68,9 @@ watch(
67
68
  box-sizing: border-box;
68
69
  cursor: pointer;
69
70
  display: grid;
70
- grid-template-columns: auto 1fr auto;
71
+ grid-template-columns: subgrid;
71
72
  align-items: center;
72
- gap: 1.2rem;
73
+
73
74
  width: 100%;
74
75
  padding-inline: var(--_padding-inline);
75
76
  padding-block: var(--_padding-block);
@@ -87,11 +88,14 @@ watch(
87
88
  }
88
89
 
89
90
  &:focus-visible {
90
- outline: var(--button-outline-width, 0.2rem) solid var(--theme-button-primary-outline, currentcolor);
91
+ outline: var(--button-outline-width, 0.2rem) solid var(--theme-ring, currentcolor);
91
92
  outline-offset: -0.2rem;
92
93
  }
93
94
 
94
95
  .action-menu-item-icon {
96
+ grid-column: 1;
97
+ margin-inline-end: var(--_gap);
98
+
95
99
  display: flex;
96
100
  align-items: center;
97
101
  justify-content: center;
@@ -101,12 +105,16 @@ watch(
101
105
  }
102
106
 
103
107
  .action-menu-item-label {
108
+ grid-column: 2;
104
109
  white-space: nowrap;
105
110
  overflow: hidden;
106
111
  text-overflow: ellipsis;
107
112
  }
108
113
 
109
114
  .action-menu-item-arrow {
115
+ grid-column: 3;
116
+ margin-inline-start: var(--_gap);
117
+
110
118
  display: flex;
111
119
  align-items: center;
112
120
  flex-shrink: 0;
@@ -0,0 +1,67 @@
1
+ <template>
2
+ <div class="alert-content" :data-theme="theme">
3
+ <AlertContentInner
4
+ :theme="theme"
5
+ :custom-icon="customIcon"
6
+ :dismissible="dismissible"
7
+ :content-id="contentId"
8
+ :aria-live="ariaLive"
9
+ @dismiss="emit('dismiss')"
10
+ >
11
+ <template v-if="slots.icon" #icon>
12
+ <slot name="icon"></slot>
13
+ </template>
14
+ <template v-if="slots.title" #title>
15
+ <slot name="title"></slot>
16
+ </template>
17
+ <template v-if="slots.content" #content>
18
+ <slot name="content"></slot>
19
+ </template>
20
+ <template v-if="slots.dismissIcon" #dismissIcon>
21
+ <slot name="dismissIcon"></slot>
22
+ </template>
23
+ <template v-if="slots.dismissLabel" #dismissLabel>
24
+ <slot name="dismissLabel"></slot>
25
+ </template>
26
+ </AlertContentInner>
27
+ </div>
28
+ </template>
29
+
30
+ <script setup lang="ts">
31
+ import type { SemanticTheme } from "~/types/components";
32
+
33
+ interface Props {
34
+ theme: SemanticTheme;
35
+ customIcon?: string;
36
+ dismissible?: boolean;
37
+ contentId?: string;
38
+ ariaLive?: "polite" | "assertive" | "off";
39
+ }
40
+
41
+ withDefaults(defineProps<Props>(), {
42
+ customIcon: undefined,
43
+ dismissible: false,
44
+ contentId: undefined,
45
+ ariaLive: undefined,
46
+ });
47
+
48
+ const emit = defineEmits<{ dismiss: [] }>();
49
+
50
+ const slots = useSlots();
51
+ </script>
52
+
53
+ <style lang="css">
54
+ @layer components {
55
+ .alert-content {
56
+ background-color: var(--theme-accent);
57
+ border: 0.1rem solid var(--theme-border);
58
+ border-start-start-radius: 8px;
59
+ border-end-start-radius: 8px;
60
+ border-start-end-radius: 4px;
61
+ border-end-end-radius: 4px;
62
+ padding-inline-start: 6px;
63
+ overflow: hidden;
64
+
65
+ }
66
+ }
67
+ </style>