srcdev-nuxt-components 9.1.55 → 9.1.57

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 (104) hide show
  1. package/.claude/settings.json +30 -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 +8 -4
  84. package/ramps.config.mjs +28 -0
  85. package/scripts/check-ramps.mjs +50 -0
  86. package/scripts/generate-consumer-ramps.mjs +100 -0
  87. package/scripts/generate-ramps.mjs +92 -0
  88. package/app/assets/styles/setup/03.theming/default/_dark.css +0 -156
  89. package/app/assets/styles/setup/03.theming/default/_light.css +0 -166
  90. package/app/assets/styles/setup/03.theming/default/index.css +0 -2
  91. package/app/assets/styles/setup/03.theming/error/_dark.css +0 -56
  92. package/app/assets/styles/setup/03.theming/error/_light.css +0 -74
  93. package/app/assets/styles/setup/03.theming/error/index.css +0 -2
  94. package/app/assets/styles/setup/03.theming/success/_dark.css +0 -50
  95. package/app/assets/styles/setup/03.theming/success/_light.css +0 -65
  96. package/app/assets/styles/setup/03.theming/success/index.css +0 -2
  97. package/app/assets/styles/setup/03.theming/warning/_dark.css +0 -50
  98. package/app/assets/styles/setup/03.theming/warning/_light.css +0 -64
  99. package/app/assets/styles/setup/03.theming/warning/index.css +0 -2
  100. package/app/components/display-prompt/DisplayPromptCore.vue +0 -228
  101. package/app/components/display-prompt/variants/DisplayPromptError.vue +0 -53
  102. package/app/components/display-toast/DisplayToast.vue +0 -447
  103. package/app/components/display-toast/molecules/DefaultToastContent.vue +0 -163
  104. package/app/components/display-toast/stories/DisplayToast.stories.ts +0 -380
@@ -0,0 +1,153 @@
1
+ <template>
2
+ <div class="alert-content-inner">
3
+ <div class="alert-content-icon" data-test-id="alert-icon" aria-hidden="true">
4
+ <slot name="icon">
5
+ <Icon :name="customIcon || themeIcon" class="icon" />
6
+ </slot>
7
+ </div>
8
+
9
+ <div :id="contentId" class="alert-content-body" :aria-live="ariaLive">
10
+ <p v-if="slots.title" class="title" data-test-id="alert-title">
11
+ <slot name="title"></slot>
12
+ </p>
13
+ <p v-if="slots.content" class="content" data-test-id="alert-content">
14
+ <slot name="content"></slot>
15
+ </p>
16
+ </div>
17
+
18
+ <button
19
+ v-if="dismissible"
20
+ type="button"
21
+ class="alert-content-dismiss"
22
+ data-test-id="alert-dismiss"
23
+ @click.prevent="emit('dismiss')"
24
+ >
25
+ <slot name="dismissIcon">
26
+ <Icon :name="dismissIcon" class="icon" />
27
+ </slot>
28
+ <span class="sr-only">
29
+ <slot name="dismissLabel">Close</slot>
30
+ </span>
31
+ </button>
32
+ </div>
33
+ </template>
34
+
35
+ <script setup lang="ts">
36
+ import type { SemanticTheme } from "~/types/components";
37
+
38
+ interface Props {
39
+ theme: SemanticTheme;
40
+ customIcon?: string;
41
+ dismissible?: boolean;
42
+ contentId?: string;
43
+ ariaLive?: "polite" | "assertive" | "off";
44
+ }
45
+
46
+ const props = withDefaults(defineProps<Props>(), {
47
+ customIcon: undefined,
48
+ dismissible: false,
49
+ contentId: undefined,
50
+ ariaLive: undefined,
51
+ });
52
+
53
+ const emit = defineEmits<{ dismiss: [] }>();
54
+
55
+ const slots = useSlots();
56
+
57
+ const appConfig = useAppConfig();
58
+
59
+ const fallbackIcons: Record<SemanticTheme, string> = {
60
+ info: "akar-icons:info",
61
+ success: "akar-icons:check",
62
+ warning: "akar-icons:circle-alert",
63
+ error: "akar-icons:circle-alert",
64
+ };
65
+
66
+ const themeIcon = computed(() => {
67
+ const icons = appConfig.srcdev?.alertContent?.icons as Partial<Record<SemanticTheme, string>> | undefined;
68
+ return icons?.[props.theme] ?? fallbackIcons[props.theme];
69
+ });
70
+ const dismissIcon = computed(() => appConfig.srcdev?.alertContent?.dismissIcon ?? "material-symbols:close");
71
+ </script>
72
+
73
+ <style lang="css">
74
+ @layer components {
75
+ .alert-content-inner {
76
+ display: grid;
77
+ grid-template-columns: auto 1fr auto;
78
+ gap: 1.2rem;
79
+ align-items: center;
80
+ background-color: var(--_alert-content-inner-bg, var(--theme-surface-subtle));
81
+ border-start-start-radius: 8px;
82
+ border-end-start-radius: 8px;
83
+ padding: 1.2rem 1.5rem;
84
+ overflow: hidden;
85
+
86
+ .alert-content-icon {
87
+ display: inline-flex;
88
+ align-items: center;
89
+ justify-content: center;
90
+
91
+ .icon {
92
+ color: var(--theme-text);
93
+ display: inline-block;
94
+ font-size: 2.5rem;
95
+ font-style: normal;
96
+ font-weight: normal;
97
+ overflow: hidden;
98
+ }
99
+ }
100
+
101
+ .alert-content-body {
102
+ display: flex;
103
+ flex-direction: column;
104
+ gap: 0.4rem;
105
+ color: var(--theme-text);
106
+
107
+ .title {
108
+ font-size: var(--step-4);
109
+ font-weight: 600;
110
+ line-height: 1.2;
111
+ margin: 0;
112
+ }
113
+
114
+ .content {
115
+ font-size: var(--step-3);
116
+ font-weight: normal;
117
+ line-height: 1.4;
118
+ margin: 0;
119
+ opacity: 0.9;
120
+ }
121
+ }
122
+
123
+ .alert-content-dismiss {
124
+ display: flex;
125
+ align-items: center;
126
+ justify-content: center;
127
+ background: transparent;
128
+ border: 0.1rem solid var(--theme-border);
129
+ outline: 0.1rem solid transparent;
130
+ border-radius: 50%;
131
+ color: var(--theme-text);
132
+ cursor: pointer;
133
+ flex-shrink: 0;
134
+ padding: 0.5rem;
135
+ transition: all 200ms ease;
136
+
137
+ .icon {
138
+ color: inherit;
139
+ display: block;
140
+ font-size: 1.5rem;
141
+ }
142
+
143
+ &:hover,
144
+ &:focus-visible {
145
+ background-color: var(--theme-surface-hover);
146
+ color: var(--theme-on-surface);
147
+ outline: 0.1rem solid var(--theme-ring);
148
+ outline-offset: 0.2rem;
149
+ }
150
+ }
151
+ }
152
+ }
153
+ </style>
@@ -0,0 +1,131 @@
1
+ import type { Meta, StoryObj } from "@nuxtjs/storybook";
2
+ import AlertContent from "../AlertContent.vue";
3
+
4
+ const meta: Meta<typeof AlertContent> = {
5
+ title: "Molecules/AlertContent",
6
+ component: AlertContent,
7
+ argTypes: {
8
+ theme: {
9
+ control: { type: "inline-radio" },
10
+ options: ["info", "success", "warning", "error"],
11
+ description: "Semantic theme — controls the accent colour and default icon",
12
+ table: { category: "Appearance" },
13
+ },
14
+ customIcon: {
15
+ control: "text",
16
+ description: "Iconify icon name to override the default theme icon",
17
+ table: { category: "Appearance" },
18
+ },
19
+ dismissible: {
20
+ control: "boolean",
21
+ description: "Show a close button (emits 'dismiss' on click)",
22
+ table: { category: "Behaviour" },
23
+ },
24
+ contentId: { table: { disable: true } },
25
+ ariaLive: { table: { disable: true } },
26
+ },
27
+ args: {
28
+ theme: "info",
29
+ dismissible: false,
30
+ },
31
+ };
32
+
33
+ export default meta;
34
+ type Story = StoryObj<typeof AlertContent>;
35
+
36
+ /** Default — info theme with title and body content. */
37
+ export const Default: Story = {
38
+ render: (args) => ({
39
+ components: { AlertContent },
40
+ setup() {
41
+ return { args };
42
+ },
43
+ template: `
44
+ <div :data-theme="args.theme" style="max-width: 600px; padding: 2rem;">
45
+ <AlertContent v-bind="args">
46
+ <template #title>Alert title</template>
47
+ <template #content>This is the alert body. It describes what happened and what the user should do next.</template>
48
+ </AlertContent>
49
+ </div>
50
+ `,
51
+ }),
52
+ };
53
+
54
+ /** Dismissible — renders the close button and demonstrates the dismiss emit. */
55
+ export const Dismissible: Story = {
56
+ args: { theme: "info", dismissible: true },
57
+ render: (args) => ({
58
+ components: { AlertContent },
59
+ setup() {
60
+ return { args };
61
+ },
62
+ template: `
63
+ <div :data-theme="args.theme" style="max-width: 600px; padding: 2rem;">
64
+ <AlertContent v-bind="args">
65
+ <template #title>Dismissible alert</template>
66
+ <template #content>Click the close button to emit the dismiss event.</template>
67
+ </AlertContent>
68
+ </div>
69
+ `,
70
+ }),
71
+ };
72
+
73
+ /** Title only — no body content slot. */
74
+ export const TitleOnly: Story = {
75
+ name: "Title Only",
76
+ args: { theme: "success" },
77
+ render: (args) => ({
78
+ components: { AlertContent },
79
+ setup() {
80
+ return { args };
81
+ },
82
+ template: `
83
+ <div :data-theme="args.theme" style="max-width: 600px; padding: 2rem;">
84
+ <AlertContent v-bind="args">
85
+ <template #title>Your changes have been saved.</template>
86
+ </AlertContent>
87
+ </div>
88
+ `,
89
+ }),
90
+ };
91
+
92
+ /** Custom icon — overrides the default theme icon via the customIcon prop. */
93
+ export const CustomIcon: Story = {
94
+ name: "Custom Icon",
95
+ args: { theme: "info", customIcon: "akar-icons:star" },
96
+ render: (args) => ({
97
+ components: { AlertContent },
98
+ setup() {
99
+ return { args };
100
+ },
101
+ template: `
102
+ <div :data-theme="args.theme" style="max-width: 600px; padding: 2rem;">
103
+ <AlertContent v-bind="args">
104
+ <template #title>Custom icon via prop</template>
105
+ <template #content>The icon is overridden using the customIcon prop.</template>
106
+ </AlertContent>
107
+ </div>
108
+ `,
109
+ }),
110
+ };
111
+
112
+ /** All themes — all four semantic variants stacked. */
113
+ export const AllThemes: Story = {
114
+ name: "All Themes",
115
+ render: () => ({
116
+ components: { AlertContent },
117
+ setup() {
118
+ return { themes: ["info", "success", "warning", "error"] as const };
119
+ },
120
+ template: `
121
+ <div style="display: flex; flex-direction: column; gap: 1.6rem; max-width: 600px; padding: 2rem;">
122
+ <div v-for="theme in themes" :key="theme" :data-theme="theme">
123
+ <AlertContent :theme="theme" :dismissible="true">
124
+ <template #title>{{ theme.charAt(0).toUpperCase() + theme.slice(1) }}</template>
125
+ <template #content>This is the {{ theme }} variant of the AlertContent component.</template>
126
+ </AlertContent>
127
+ </div>
128
+ </div>
129
+ `,
130
+ }),
131
+ };
@@ -0,0 +1,135 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { mountSuspended } from "@nuxt/test-utils/runtime";
3
+ import AlertContent from "../AlertContent.vue";
4
+
5
+ describe("AlertContent", () => {
6
+ // ─── Mount ────────────────────────────────────────────────────────────────
7
+
8
+ it("mounts without error", async () => {
9
+ const wrapper = await mountSuspended(AlertContent, { props: { theme: "info" } });
10
+ expect(wrapper.vm).toBeTruthy();
11
+ });
12
+
13
+ // ─── Root element ─────────────────────────────────────────────────────────
14
+
15
+ it("renders the root element with class alert-content", async () => {
16
+ const wrapper = await mountSuspended(AlertContent, { props: { theme: "info" } });
17
+ expect(wrapper.find(".alert-content").exists()).toBe(true);
18
+ });
19
+
20
+ // ─── Icon ─────────────────────────────────────────────────────────────────
21
+
22
+ it("renders the icon region", async () => {
23
+ const wrapper = await mountSuspended(AlertContent, { props: { theme: "info" } });
24
+ expect(wrapper.find("[data-test-id='alert-icon']").exists()).toBe(true);
25
+ });
26
+
27
+ it("replaces the icon via the #icon slot", async () => {
28
+ const wrapper = await mountSuspended(AlertContent, {
29
+ props: { theme: "info" },
30
+ slots: { icon: '<span class="custom-icon">★</span>' },
31
+ });
32
+ expect(wrapper.find(".custom-icon").exists()).toBe(true);
33
+ });
34
+
35
+ // ─── Title slot ───────────────────────────────────────────────────────────
36
+
37
+ it("renders the title element when the #title slot is provided", async () => {
38
+ const wrapper = await mountSuspended(AlertContent, {
39
+ props: { theme: "info" },
40
+ slots: { title: "Important notice" },
41
+ });
42
+ expect(wrapper.find("[data-test-id='alert-title']").exists()).toBe(true);
43
+ expect(wrapper.find("[data-test-id='alert-title']").text()).toContain("Important notice");
44
+ });
45
+
46
+ it("does not render the title element when the #title slot is not provided", async () => {
47
+ const wrapper = await mountSuspended(AlertContent, { props: { theme: "info" } });
48
+ expect(wrapper.find("[data-test-id='alert-title']").exists()).toBe(false);
49
+ });
50
+
51
+ // ─── Content slot ─────────────────────────────────────────────────────────
52
+
53
+ it("renders the content element when the #content slot is provided", async () => {
54
+ const wrapper = await mountSuspended(AlertContent, {
55
+ props: { theme: "info" },
56
+ slots: { content: "More detail here." },
57
+ });
58
+ expect(wrapper.find("[data-test-id='alert-content']").exists()).toBe(true);
59
+ expect(wrapper.find("[data-test-id='alert-content']").text()).toContain("More detail here.");
60
+ });
61
+
62
+ it("does not render the content element when the #content slot is not provided", async () => {
63
+ const wrapper = await mountSuspended(AlertContent, { props: { theme: "info" } });
64
+ expect(wrapper.find("[data-test-id='alert-content']").exists()).toBe(false);
65
+ });
66
+
67
+ // ─── contentId ────────────────────────────────────────────────────────────
68
+
69
+ it("sets id on the body element when contentId is provided", async () => {
70
+ const wrapper = await mountSuspended(AlertContent, {
71
+ props: { theme: "info", contentId: "toast-message-abc" },
72
+ });
73
+ expect(wrapper.find(".alert-content-body").attributes("id")).toBe("toast-message-abc");
74
+ });
75
+
76
+ it("omits id on the body element when contentId is not provided", async () => {
77
+ const wrapper = await mountSuspended(AlertContent, { props: { theme: "info" } });
78
+ expect(wrapper.find(".alert-content-body").attributes("id")).toBeUndefined();
79
+ });
80
+
81
+ // ─── ariaLive ─────────────────────────────────────────────────────────────
82
+
83
+ it("sets aria-live on the body element when ariaLive is provided", async () => {
84
+ const wrapper = await mountSuspended(AlertContent, {
85
+ props: { theme: "info", ariaLive: "polite" },
86
+ });
87
+ expect(wrapper.find(".alert-content-body").attributes("aria-live")).toBe("polite");
88
+ });
89
+
90
+ it("omits aria-live when ariaLive is not provided", async () => {
91
+ const wrapper = await mountSuspended(AlertContent, { props: { theme: "info" } });
92
+ expect(wrapper.find(".alert-content-body").attributes("aria-live")).toBeUndefined();
93
+ });
94
+
95
+ // ─── Dismiss button ───────────────────────────────────────────────────────
96
+
97
+ it("does not render the dismiss button when dismissible is false", async () => {
98
+ const wrapper = await mountSuspended(AlertContent, { props: { theme: "info", dismissible: false } });
99
+ expect(wrapper.find("[data-test-id='alert-dismiss']").exists()).toBe(false);
100
+ });
101
+
102
+ it("renders the dismiss button when dismissible is true", async () => {
103
+ const wrapper = await mountSuspended(AlertContent, { props: { theme: "info", dismissible: true } });
104
+ expect(wrapper.find("[data-test-id='alert-dismiss']").exists()).toBe(true);
105
+ });
106
+
107
+ it("emits the dismiss event when the dismiss button is clicked", async () => {
108
+ const wrapper = await mountSuspended(AlertContent, { props: { theme: "info", dismissible: true } });
109
+ await wrapper.find("[data-test-id='alert-dismiss']").trigger("click");
110
+ expect(wrapper.emitted("dismiss")).toBeTruthy();
111
+ });
112
+
113
+ it("replaces the dismiss icon via the #dismissIcon slot", async () => {
114
+ const wrapper = await mountSuspended(AlertContent, {
115
+ props: { theme: "info", dismissible: true },
116
+ slots: { dismissIcon: '<span class="custom-close">×</span>' },
117
+ });
118
+ expect(wrapper.find(".custom-close").exists()).toBe(true);
119
+ });
120
+
121
+ it("replaces the dismiss sr-only label via the #dismissLabel slot", async () => {
122
+ const wrapper = await mountSuspended(AlertContent, {
123
+ props: { theme: "info", dismissible: true },
124
+ slots: { dismissLabel: "Close this prompt" },
125
+ });
126
+ expect(wrapper.find(".sr-only").text()).toBe("Close this prompt");
127
+ });
128
+
129
+ it("uses 'Close' as the default dismiss label", async () => {
130
+ const wrapper = await mountSuspended(AlertContent, {
131
+ props: { theme: "info", dismissible: true },
132
+ });
133
+ expect(wrapper.find(".sr-only").text()).toBe("Close");
134
+ });
135
+ });
@@ -0,0 +1,135 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { mountSuspended } from "@nuxt/test-utils/runtime";
3
+ import AlertContentInner from "../AlertContentInner.vue";
4
+
5
+ describe("AlertContentInner", () => {
6
+ // ─── Mount ────────────────────────────────────────────────────────────────
7
+
8
+ it("mounts without error", async () => {
9
+ const wrapper = await mountSuspended(AlertContentInner, { props: { theme: "info" } });
10
+ expect(wrapper.vm).toBeTruthy();
11
+ });
12
+
13
+ // ─── Root element ─────────────────────────────────────────────────────────
14
+
15
+ it("renders the root element with class alert-content-inner", async () => {
16
+ const wrapper = await mountSuspended(AlertContentInner, { props: { theme: "info" } });
17
+ expect(wrapper.find(".alert-content-inner").exists()).toBe(true);
18
+ });
19
+
20
+ // ─── Icon ─────────────────────────────────────────────────────────────────
21
+
22
+ it("renders the icon region", async () => {
23
+ const wrapper = await mountSuspended(AlertContentInner, { props: { theme: "info" } });
24
+ expect(wrapper.find("[data-test-id='alert-icon']").exists()).toBe(true);
25
+ });
26
+
27
+ it("replaces the icon via the #icon slot", async () => {
28
+ const wrapper = await mountSuspended(AlertContentInner, {
29
+ props: { theme: "info" },
30
+ slots: { icon: '<span class="custom-icon">★</span>' },
31
+ });
32
+ expect(wrapper.find(".custom-icon").exists()).toBe(true);
33
+ });
34
+
35
+ // ─── Title slot ───────────────────────────────────────────────────────────
36
+
37
+ it("renders the title element when the #title slot is provided", async () => {
38
+ const wrapper = await mountSuspended(AlertContentInner, {
39
+ props: { theme: "info" },
40
+ slots: { title: "Important notice" },
41
+ });
42
+ expect(wrapper.find("[data-test-id='alert-title']").exists()).toBe(true);
43
+ expect(wrapper.find("[data-test-id='alert-title']").text()).toContain("Important notice");
44
+ });
45
+
46
+ it("does not render the title element when the #title slot is not provided", async () => {
47
+ const wrapper = await mountSuspended(AlertContentInner, { props: { theme: "info" } });
48
+ expect(wrapper.find("[data-test-id='alert-title']").exists()).toBe(false);
49
+ });
50
+
51
+ // ─── Content slot ─────────────────────────────────────────────────────────
52
+
53
+ it("renders the content element when the #content slot is provided", async () => {
54
+ const wrapper = await mountSuspended(AlertContentInner, {
55
+ props: { theme: "info" },
56
+ slots: { content: "More detail here." },
57
+ });
58
+ expect(wrapper.find("[data-test-id='alert-content']").exists()).toBe(true);
59
+ expect(wrapper.find("[data-test-id='alert-content']").text()).toContain("More detail here.");
60
+ });
61
+
62
+ it("does not render the content element when the #content slot is not provided", async () => {
63
+ const wrapper = await mountSuspended(AlertContentInner, { props: { theme: "info" } });
64
+ expect(wrapper.find("[data-test-id='alert-content']").exists()).toBe(false);
65
+ });
66
+
67
+ // ─── contentId ────────────────────────────────────────────────────────────
68
+
69
+ it("sets id on the body element when contentId is provided", async () => {
70
+ const wrapper = await mountSuspended(AlertContentInner, {
71
+ props: { theme: "info", contentId: "toast-message-abc" },
72
+ });
73
+ expect(wrapper.find(".alert-content-body").attributes("id")).toBe("toast-message-abc");
74
+ });
75
+
76
+ it("omits id on the body element when contentId is not provided", async () => {
77
+ const wrapper = await mountSuspended(AlertContentInner, { props: { theme: "info" } });
78
+ expect(wrapper.find(".alert-content-body").attributes("id")).toBeUndefined();
79
+ });
80
+
81
+ // ─── ariaLive ─────────────────────────────────────────────────────────────
82
+
83
+ it("sets aria-live on the body element when ariaLive is provided", async () => {
84
+ const wrapper = await mountSuspended(AlertContentInner, {
85
+ props: { theme: "info", ariaLive: "polite" },
86
+ });
87
+ expect(wrapper.find(".alert-content-body").attributes("aria-live")).toBe("polite");
88
+ });
89
+
90
+ it("omits aria-live when ariaLive is not provided", async () => {
91
+ const wrapper = await mountSuspended(AlertContentInner, { props: { theme: "info" } });
92
+ expect(wrapper.find(".alert-content-body").attributes("aria-live")).toBeUndefined();
93
+ });
94
+
95
+ // ─── Dismiss button ───────────────────────────────────────────────────────
96
+
97
+ it("does not render the dismiss button when dismissible is false", async () => {
98
+ const wrapper = await mountSuspended(AlertContentInner, { props: { theme: "info", dismissible: false } });
99
+ expect(wrapper.find("[data-test-id='alert-dismiss']").exists()).toBe(false);
100
+ });
101
+
102
+ it("renders the dismiss button when dismissible is true", async () => {
103
+ const wrapper = await mountSuspended(AlertContentInner, { props: { theme: "info", dismissible: true } });
104
+ expect(wrapper.find("[data-test-id='alert-dismiss']").exists()).toBe(true);
105
+ });
106
+
107
+ it("emits the dismiss event when the dismiss button is clicked", async () => {
108
+ const wrapper = await mountSuspended(AlertContentInner, { props: { theme: "info", dismissible: true } });
109
+ await wrapper.find("[data-test-id='alert-dismiss']").trigger("click");
110
+ expect(wrapper.emitted("dismiss")).toBeTruthy();
111
+ });
112
+
113
+ it("replaces the dismiss icon via the #dismissIcon slot", async () => {
114
+ const wrapper = await mountSuspended(AlertContentInner, {
115
+ props: { theme: "info", dismissible: true },
116
+ slots: { dismissIcon: '<span class="custom-close">×</span>' },
117
+ });
118
+ expect(wrapper.find(".custom-close").exists()).toBe(true);
119
+ });
120
+
121
+ it("replaces the dismiss sr-only label via the #dismissLabel slot", async () => {
122
+ const wrapper = await mountSuspended(AlertContentInner, {
123
+ props: { theme: "info", dismissible: true },
124
+ slots: { dismissLabel: "Close this prompt" },
125
+ });
126
+ expect(wrapper.find(".sr-only").text()).toBe("Close this prompt");
127
+ });
128
+
129
+ it("uses 'Close' as the default dismiss label", async () => {
130
+ const wrapper = await mountSuspended(AlertContentInner, {
131
+ props: { theme: "info", dismissible: true },
132
+ });
133
+ expect(wrapper.find(".sr-only").text()).toBe("Close");
134
+ });
135
+ });
@@ -0,0 +1,78 @@
1
+ <template>
2
+ <div class="alert-masked-content" :data-theme="theme">
3
+ <AlertMaskCore :config="resolvedMaskConfig">
4
+ <AlertContentInner
5
+ :theme="theme"
6
+ :custom-icon="customIcon"
7
+ :dismissible="dismissible"
8
+ :content-id="contentId"
9
+ :aria-live="ariaLive"
10
+ @dismiss="emit('dismiss')"
11
+ >
12
+ <template v-if="slots.icon" #icon>
13
+ <slot name="icon"></slot>
14
+ </template>
15
+ <template v-if="slots.title" #title>
16
+ <slot name="title"></slot>
17
+ </template>
18
+ <template v-if="slots.content" #content>
19
+ <slot name="content"></slot>
20
+ </template>
21
+ <template v-if="slots.dismissIcon" #dismissIcon>
22
+ <slot name="dismissIcon"></slot>
23
+ </template>
24
+ <template v-if="slots.dismissLabel" #dismissLabel>
25
+ <slot name="dismissLabel"></slot>
26
+ </template>
27
+ </AlertContentInner>
28
+ </AlertMaskCore>
29
+ </div>
30
+ </template>
31
+
32
+ <script setup lang="ts">
33
+ import type { SemanticTheme, AlertMaskConfig } from "~/types/components";
34
+
35
+ interface Props {
36
+ theme: SemanticTheme;
37
+ customIcon?: string;
38
+ dismissible?: boolean;
39
+ contentId?: string;
40
+ ariaLive?: "polite" | "assertive" | "off";
41
+ maskConfig?: AlertMaskConfig;
42
+ }
43
+
44
+ const props = withDefaults(defineProps<Props>(), {
45
+ customIcon: undefined,
46
+ dismissible: false,
47
+ contentId: undefined,
48
+ ariaLive: undefined,
49
+ maskConfig: undefined,
50
+ });
51
+
52
+ const emit = defineEmits<{ dismiss: [] }>();
53
+
54
+ const slots = useSlots();
55
+
56
+ // Shape matches AlertContent: 8px left radius, 4px right, 6px accent bar on left, 1px border elsewhere
57
+ const resolvedMaskConfig = computed<AlertMaskConfig>(() => ({
58
+ borderColour: "var(--theme-accent)",
59
+ backgroundColour: "rgba(0, 0, 0, 0.3)",
60
+ radiusLeft: 8,
61
+ radiusRight: 4,
62
+ borderLeft: 6,
63
+ borderTop: 1,
64
+ borderRight: 1,
65
+ borderBottom: 1,
66
+ ...props.maskConfig,
67
+ }));
68
+ </script>
69
+
70
+ <style lang="css">
71
+ @layer components {
72
+ .alert-masked-content {
73
+ .alert-content-inner {
74
+ --_alert-content-inner-bg: transparent;
75
+ }
76
+ }
77
+ }
78
+ </style>