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
@@ -2,9 +2,11 @@
2
2
  <dialog
3
3
  class="display-dialog"
4
4
  :class="[elementClasses]"
5
- :role="isAlert ? 'alertdialog' : 'dialog'"
6
- :align-dialog
7
- :justify-dialog
5
+ :role="isAlert ? 'alertdialog' : undefined"
6
+ :aria-modal="true"
7
+ :aria-labelledby="hasTitle ? dialogTitleId : undefined"
8
+ :align-dialog="resolved.alignDialog"
9
+ :justify-dialog="resolved.justifyDialog"
8
10
  :open
9
11
  :data-dialog-id="dataDialogId"
10
12
  >
@@ -14,19 +16,19 @@
14
16
  :click-outside-deactivates="!isAlert"
15
17
  @deactivate="closeDialog()"
16
18
  >
17
- <div class="inner" :class="[variant]">
18
- <div class="header">
19
- <div v-if="hasTitle" class="col-left">
19
+ <div class="inner" :class="[resolved.variant]">
20
+ <div class="header" :data-theme="resolved.theme">
21
+ <div v-if="hasTitle" :id="dialogTitleId" class="col-left">
20
22
  <slot name="dialogTitle"></slot>
21
23
  </div>
22
24
 
23
25
  <div class="col-right">
24
26
  <button
25
27
  data-test-id="display-dialog-header-close"
26
- class="display-prompt-action"
28
+ class="display-dialog-close"
27
29
  @click.prevent="closeDialog()"
28
30
  >
29
- <Icon name="bitcoin-icons:cross-filled" class="icon" />
31
+ <Icon :name="resolved.closeIcon" class="icon" />
30
32
  <span class="sr-only">Close</span>
31
33
  </button>
32
34
  </div>
@@ -34,7 +36,8 @@
34
36
  <div
35
37
  v-if="hasContent"
36
38
  class="dialog-content"
37
- :class="[{ 'allow-content-scroll': allowContentScroll }]"
39
+ :class="[{ 'allow-content-scroll': resolved.allowContentScroll }]"
40
+ :tabindex="resolved.allowContentScroll ? 0 : undefined"
38
41
  >
39
42
  <slot name="dialogContent"></slot>
40
43
  </div>
@@ -49,6 +52,8 @@
49
52
 
50
53
  <script setup lang="ts">
51
54
  import { FocusTrap } from "focus-trap-vue";
55
+ import type { SemanticTheme } from "~/types/components";
56
+
52
57
  interface Props {
53
58
  styleClassPassthrough?: string | string[];
54
59
  variant?: "dialog" | "modal" | "confirm" | "alert" | "fullscreen";
@@ -57,28 +62,45 @@ interface Props {
57
62
  lockViewport?: boolean;
58
63
  allowContentScroll?: boolean;
59
64
  dataDialogId: string;
65
+ theme?: SemanticTheme;
66
+ closeIcon?: string;
60
67
  }
61
68
 
62
69
  const props = withDefaults(defineProps<Props>(), {
63
70
  styleClassPassthrough: () => [],
64
- variant: "dialog",
65
- justifyDialog: "center",
66
- alignDialog: "center",
67
- lockViewport: true,
68
- allowContentScroll: false,
71
+ variant: undefined,
72
+ justifyDialog: undefined,
73
+ alignDialog: undefined,
74
+ lockViewport: undefined,
75
+ allowContentScroll: undefined,
76
+ theme: undefined,
77
+ closeIcon: undefined,
78
+ });
79
+
80
+ const appConfig = useAppConfig();
81
+
82
+ const resolved = computed(() => {
83
+ const config = appConfig.srcdev?.displayDialog;
84
+ return {
85
+ variant: props.variant ?? config?.variant ?? "dialog",
86
+ justifyDialog: props.justifyDialog ?? config?.justifyDialog ?? "center",
87
+ alignDialog: props.alignDialog ?? config?.alignDialog ?? "center",
88
+ lockViewport: props.lockViewport ?? config?.lockViewport ?? true,
89
+ allowContentScroll: props.allowContentScroll ?? config?.allowContentScroll ?? false,
90
+ theme: props.theme ?? config?.theme,
91
+ closeIcon: props.closeIcon ?? config?.closeIcon ?? "bitcoin-icons:cross-filled",
92
+ } as const;
69
93
  });
70
94
 
71
95
  const { elementClasses } = useStyleClassPassthrough(props.styleClassPassthrough);
72
96
 
73
- const isAlert = computed(() => props.variant === "alert");
97
+ const isAlert = computed(() => resolved.value.variant === "alert");
98
+ const dialogTitleId = useId();
74
99
 
75
100
  const open = defineModel<boolean>();
76
101
 
77
102
  const closeDialog = () => {
78
103
  open.value = false;
79
- if (props.lockViewport) {
80
- document.body.classList.remove("lock");
81
- }
82
104
  };
83
105
 
84
106
  const slots = useSlots();
@@ -86,9 +108,20 @@ const hasTitle = computed(() => !!slots.dialogTitle);
86
108
  const hasContent = computed(() => !!slots.dialogContent);
87
109
  const hasFooter = computed(() => !!(slots.actionButtonLeft || slots.actionButtonRight));
88
110
 
111
+ const { lock, unlock } = useBodyLock();
112
+ let hasLocked = false;
113
+
89
114
  onMounted(() => {
90
- if (props.lockViewport) {
91
- document.body.classList.add("lock");
115
+ if (resolved.value.lockViewport) {
116
+ lock();
117
+ hasLocked = true;
118
+ }
119
+ });
120
+
121
+ onUnmounted(() => {
122
+ if (hasLocked) {
123
+ unlock();
124
+ hasLocked = false;
92
125
  }
93
126
  });
94
127
  </script>
@@ -112,9 +145,18 @@ onMounted(() => {
112
145
  --_header-button-border: var(--display-dialog-header-button-border, 0.1rem solid transparent);
113
146
  --_header-button-border-radius: var(--display-dialog-header-button-border-radius, 0.4rem);
114
147
  --_header-button-outline: var(--display-dialog-header-button-outline, 0.1rem solid transparent);
115
- --_header-button-border-hover: var(--display-dialog-header-button-border-hover, 0.1rem solid light-dark(var(--slate-08), var(--slate-04)));
116
- --_header-button-outline-hover: var(--display-dialog-header-button-outline-hover, 0.1rem solid light-dark(var(--slate-08), var(--slate-04)));
117
- --_header-button-icon-color: var(--display-dialog-header-button-icon-color, light-dark(var(--slate-09), var(--slate-02)));
148
+ --_header-button-border-hover: var(
149
+ --display-dialog-header-button-border-hover,
150
+ 0.1rem solid light-dark(var(--slate-08), var(--slate-04))
151
+ );
152
+ --_header-button-outline-hover: var(
153
+ --display-dialog-header-button-outline-hover,
154
+ 0.1rem solid light-dark(var(--slate-08), var(--slate-04))
155
+ );
156
+ --_header-button-icon-color: var(
157
+ --display-dialog-header-button-icon-color,
158
+ light-dark(var(--slate-09), var(--slate-02))
159
+ );
118
160
  --_header-button-icon-size: var(--display-dialog-header-button-icon-size, 2.4rem);
119
161
 
120
162
  --_content-padding: var(--display-dialog-content-padding, 1.2rem);
@@ -215,6 +257,14 @@ onMounted(() => {
215
257
  align-items: center;
216
258
  padding: var(--_header-padding);
217
259
 
260
+ &[data-theme] {
261
+ border-block-end: 0.2rem solid var(--theme-accent);
262
+
263
+ .display-dialog-close .icon {
264
+ color: var(--theme-accent);
265
+ }
266
+ }
267
+
218
268
  .col-left {
219
269
  flex: 1;
220
270
  }
@@ -222,7 +272,7 @@ onMounted(() => {
222
272
  .col-right {
223
273
  margin-inline-start: auto;
224
274
 
225
- .display-prompt-action {
275
+ .display-dialog-close {
226
276
  background-color: transparent;
227
277
  display: block flex;
228
278
  align-items: center;
@@ -1,7 +1,14 @@
1
- import { describe, it, expect, vi } from "vitest";
2
- import { nextTick } from "vue";
3
- import { mountSuspended } from "@nuxt/test-utils/runtime";
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
+ import { mountSuspended, mockNuxtImport } from "@nuxt/test-utils/runtime";
4
3
  import DisplayDialog from "../DisplayDialog.vue";
4
+ import type { SemanticTheme } from "~/types/components";
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);
5
12
 
6
13
  vi.mock("focus-trap-vue", () => ({
7
14
  FocusTrap: {
@@ -13,6 +20,11 @@ vi.mock("focus-trap-vue", () => ({
13
20
  }));
14
21
 
15
22
  describe("DisplayDialog", () => {
23
+ afterEach(() => {
24
+ document.body.classList.remove("lock");
25
+ delete document.body.dataset.lockCount;
26
+ });
27
+
16
28
  // ─── Mount ────────────────────────────────────────────────────────────────
17
29
 
18
30
  it("mounts without error", async () => {
@@ -80,20 +92,20 @@ describe("DisplayDialog", () => {
80
92
 
81
93
  // ─── role ─────────────────────────────────────────────────────────────────
82
94
 
83
- it("has role='dialog' by default", async () => {
95
+ it("has no explicit role by default — implicit from <dialog> element", async () => {
84
96
  const wrapper = await mountSuspended(DisplayDialog, {
85
97
  props: { dataDialogId: "test" },
86
98
  });
87
- expect(wrapper.attributes("role")).toBe("dialog");
99
+ expect(wrapper.attributes("role")).toBeUndefined();
88
100
  });
89
101
 
90
102
  it.each(["dialog", "modal", "confirm", "fullscreen"] as const)(
91
- "has role='dialog' for variant='%s'",
103
+ "has no explicit role for variant='%s' — implicit from <dialog> element",
92
104
  async (variant) => {
93
105
  const wrapper = await mountSuspended(DisplayDialog, {
94
106
  props: { dataDialogId: "test", variant },
95
107
  });
96
- expect(wrapper.attributes("role")).toBe("dialog");
108
+ expect(wrapper.attributes("role")).toBeUndefined();
97
109
  }
98
110
  );
99
111
 
@@ -238,11 +250,11 @@ describe("DisplayDialog", () => {
238
250
  // ─── Body viewport lock ───────────────────────────────────────────────────
239
251
 
240
252
  it("adds lock class to body on mount when lockViewport=true", async () => {
241
- await mountSuspended(DisplayDialog, {
253
+ const wrapper = await mountSuspended(DisplayDialog, {
242
254
  props: { dataDialogId: "test", lockViewport: true },
243
255
  });
244
256
  expect(document.body.classList.contains("lock")).toBe(true);
245
- document.body.classList.remove("lock");
257
+ wrapper.unmount();
246
258
  });
247
259
 
248
260
  it("does not add lock class to body when lockViewport=false", async () => {
@@ -252,13 +264,135 @@ describe("DisplayDialog", () => {
252
264
  expect(document.body.classList.contains("lock")).toBe(false);
253
265
  });
254
266
 
255
- it("removes lock class from body when close button is clicked", async () => {
256
- document.body.classList.add("lock");
267
+ it("removes lock class from body on unmount when lockViewport=true", async () => {
257
268
  const wrapper = await mountSuspended(DisplayDialog, {
258
- props: { dataDialogId: "test", modelValue: true, lockViewport: true },
269
+ props: { dataDialogId: "test", lockViewport: true },
259
270
  });
260
- await wrapper.find('[data-test-id="display-dialog-header-close"]').trigger("click");
261
- await nextTick();
271
+ expect(document.body.classList.contains("lock")).toBe(true);
272
+ wrapper.unmount();
262
273
  expect(document.body.classList.contains("lock")).toBe(false);
263
274
  });
275
+
276
+ it("does not remove lock when a second locking dialog is still open", async () => {
277
+ const wrapper1 = await mountSuspended(DisplayDialog, {
278
+ props: { dataDialogId: "a", lockViewport: true },
279
+ });
280
+ const wrapper2 = await mountSuspended(DisplayDialog, {
281
+ props: { dataDialogId: "b", lockViewport: true },
282
+ });
283
+ expect(document.body.classList.contains("lock")).toBe(true);
284
+ wrapper2.unmount();
285
+ expect(document.body.classList.contains("lock")).toBe(true);
286
+ wrapper1.unmount();
287
+ expect(document.body.classList.contains("lock")).toBe(false);
288
+ });
289
+
290
+ // ─── app.config resolution ────────────────────────────────────────────────
291
+ // Icon renders as <span class="iconify undefined{name} icon"> in jsdom.
292
+ // All assertions are DOM-based — setupState is private API in <script setup>.
293
+
294
+ describe("app.config resolution", () => {
295
+ beforeEach(() => {
296
+ useAppConfigMock.mockReturnValue({ srcdev: undefined, icon: {} });
297
+ });
298
+
299
+ afterEach(() => {
300
+ document.body.classList.remove("lock");
301
+ delete document.body.dataset.lockCount;
302
+ });
303
+
304
+ const iconName = (wrapper: Awaited<ReturnType<typeof mountSuspended>>) =>
305
+ wrapper
306
+ .find('[data-test-id="display-dialog-header-close"] .iconify')
307
+ .classes()
308
+ .find((c: string) => c !== "iconify" && c !== "icon") ?? "";
309
+
310
+ it("uses hardcoded fallbacks when app.config has no displayDialog key", async () => {
311
+ const wrapper = await mountSuspended(DisplayDialog, {
312
+ props: { dataDialogId: "test" },
313
+ slots: { dialogContent: "<p>Content</p>" },
314
+ });
315
+ expect(wrapper.find(".inner").classes()).toContain("dialog");
316
+ expect(wrapper.attributes("align-dialog")).toBe("center");
317
+ expect(wrapper.attributes("justify-dialog")).toBe("center");
318
+ expect(document.body.classList.contains("lock")).toBe(true);
319
+ expect(wrapper.find(".dialog-content").classes()).not.toContain("allow-content-scroll");
320
+ expect(wrapper.find(".header").attributes("data-theme")).toBeUndefined();
321
+ expect(iconName(wrapper)).toContain("bitcoin-icons:cross-filled");
322
+ });
323
+
324
+ it("uses app.config values when no props are supplied", async () => {
325
+ useAppConfigMock.mockReturnValue({
326
+ icon: {},
327
+ srcdev: {
328
+ displayDialog: {
329
+ variant: "modal",
330
+ justifyDialog: "start",
331
+ alignDialog: "end",
332
+ lockViewport: false,
333
+ allowContentScroll: true,
334
+ theme: "info" as SemanticTheme,
335
+ closeIcon: "heroicons:x-mark",
336
+ },
337
+ },
338
+ });
339
+ const wrapper = await mountSuspended(DisplayDialog, {
340
+ props: { dataDialogId: "test" },
341
+ slots: { dialogContent: "<p>Content</p>" },
342
+ });
343
+ expect(wrapper.find(".inner").classes()).toContain("modal");
344
+ expect(wrapper.attributes("align-dialog")).toBe("end");
345
+ expect(wrapper.attributes("justify-dialog")).toBe("start");
346
+ expect(document.body.classList.contains("lock")).toBe(false);
347
+ expect(wrapper.find(".dialog-content").classes()).toContain("allow-content-scroll");
348
+ expect(wrapper.find(".header").attributes("data-theme")).toBe("info");
349
+ expect(iconName(wrapper)).toContain("heroicons:x-mark");
350
+ });
351
+
352
+ it("explicit props take precedence over app.config", async () => {
353
+ useAppConfigMock.mockReturnValue({
354
+ icon: {},
355
+ srcdev: {
356
+ displayDialog: {
357
+ variant: "modal",
358
+ justifyDialog: "start",
359
+ alignDialog: "end",
360
+ lockViewport: false,
361
+ allowContentScroll: true,
362
+ theme: "info" as SemanticTheme,
363
+ closeIcon: "heroicons:x-mark",
364
+ },
365
+ },
366
+ });
367
+ const wrapper = await mountSuspended(DisplayDialog, {
368
+ props: {
369
+ dataDialogId: "test",
370
+ variant: "fullscreen",
371
+ justifyDialog: "end",
372
+ alignDialog: "start",
373
+ lockViewport: true,
374
+ allowContentScroll: false,
375
+ theme: "error" as SemanticTheme,
376
+ closeIcon: "material-symbols:close",
377
+ },
378
+ slots: { dialogContent: "<p>Content</p>" },
379
+ });
380
+ expect(wrapper.find(".inner").classes()).toContain("fullscreen");
381
+ expect(wrapper.attributes("align-dialog")).toBe("start");
382
+ expect(wrapper.attributes("justify-dialog")).toBe("end");
383
+ expect(document.body.classList.contains("lock")).toBe(true);
384
+ expect(wrapper.find(".dialog-content").classes()).not.toContain("allow-content-scroll");
385
+ expect(wrapper.find(".header").attributes("data-theme")).toBe("error");
386
+ expect(iconName(wrapper)).toContain("material-symbols:close");
387
+ });
388
+
389
+ it("lockViewport:false in app.config skips body lock on mount", async () => {
390
+ useAppConfigMock.mockReturnValue({
391
+ icon: {},
392
+ srcdev: { displayDialog: { lockViewport: false } },
393
+ });
394
+ await mountSuspended(DisplayDialog, { props: { dataDialogId: "test" } });
395
+ expect(document.body.classList.contains("lock")).toBe(false);
396
+ });
397
+ });
264
398
  });
@@ -1,13 +1,13 @@
1
1
  // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
2
2
 
3
3
  exports[`DisplayDialog > renders correct HTML structure with all slots 1`] = `
4
- "<dialog class="display-dialog" role="dialog" align-dialog="center" justify-dialog="center" open="" data-dialog-id="test">
4
+ "<dialog class="display-dialog" aria-modal="true" aria-labelledby="v-0-0" align-dialog="center" justify-dialog="center" open="" data-dialog-id="test">
5
5
  <div class="inner dialog">
6
6
  <div class="header">
7
- <div class="col-left">
7
+ <div id="v-0-0" class="col-left">
8
8
  <p>Title</p>
9
9
  </div>
10
- <div class="col-right"><button data-test-id="display-dialog-header-close" class="display-prompt-action"><span class="iconify i-bitcoin-icons:cross-filled icon" aria-hidden="true"></span><span class="sr-only">Close</span></button></div>
10
+ <div class="col-right"><button data-test-id="display-dialog-header-close" class="display-dialog-close"><span class="iconify undefinedbitcoin-icons:cross-filled icon"></span><span class="sr-only">Close</span></button></div>
11
11
  </div>
12
12
  <div class="dialog-content">
13
13
  <p>Content</p>
@@ -0,0 +1,113 @@
1
+ <template>
2
+ <div
3
+ ref="promptElementRef"
4
+ class="display-prompt-core"
5
+ :class="[{ closed: !componentOpen }]"
6
+ :data-test-id="`display-prompt-core-${resolved.theme}`"
7
+ tabindex="0"
8
+ >
9
+ <div class="display-prompt-wrapper" :data-theme="resolved.theme" :class="[elementClasses]" data-test-id="display-prompt">
10
+ <component
11
+ :is="contentComponent"
12
+ :theme="resolved.theme"
13
+ :dismissible="resolved.dismissible"
14
+ :aria-live="resolved.useAutoFocus ? 'polite' : undefined"
15
+ @dismiss="updateComponentState()"
16
+ >
17
+ <template v-if="slots.customDecoratorIcon" #icon>
18
+ <slot name="customDecoratorIcon"></slot>
19
+ </template>
20
+ <template v-if="slots.title" #title>
21
+ <slot name="title"></slot>
22
+ </template>
23
+ <template v-if="slots.content" #content>
24
+ <slot name="content"></slot>
25
+ </template>
26
+ <template v-if="slots.customCloseIcon" #dismissIcon>
27
+ <slot name="customCloseIcon"></slot>
28
+ </template>
29
+ <template #dismissLabel>
30
+ <slot name="customTitle">Close this prompt</slot>
31
+ </template>
32
+ </component>
33
+ </div>
34
+ </div>
35
+ </template>
36
+
37
+ <script setup lang="ts">
38
+ import AlertContent from "~/components/02.molecules/alert-content/AlertContent.vue";
39
+ import AlertMaskedContent from "~/components/02.molecules/alert-masked-content/AlertMaskedContent.vue";
40
+ import type { DisplayPromptTheme } from "~/types/components";
41
+
42
+ interface Props {
43
+ theme?: DisplayPromptTheme;
44
+ dismissible?: boolean;
45
+ useAutoFocus?: boolean;
46
+ masked?: boolean;
47
+ styleClassPassthrough?: string | string[];
48
+ }
49
+
50
+ const props = withDefaults(defineProps<Props>(), {
51
+ styleClassPassthrough: () => [],
52
+ theme: undefined,
53
+ dismissible: undefined,
54
+ useAutoFocus: undefined,
55
+ masked: undefined,
56
+ });
57
+
58
+ const appConfig = useAppConfig();
59
+
60
+ const resolved = computed(() => {
61
+ const config = appConfig.srcdev?.displayPrompt;
62
+ return {
63
+ theme: props.theme ?? config?.theme ?? "info",
64
+ dismissible: props.dismissible ?? config?.dismissible ?? false,
65
+ useAutoFocus: props.useAutoFocus ?? config?.useAutoFocus ?? false,
66
+ masked: props.masked ?? config?.masked ?? false,
67
+ } as const;
68
+ });
69
+
70
+ const contentComponent = computed(() => (resolved.value.masked ? AlertMaskedContent : AlertContent));
71
+
72
+ const slots = useSlots();
73
+ const promptElementRef = useTemplateRef<HTMLElement>("promptElementRef");
74
+ const parentComponentState = defineModel<boolean>({ default: false });
75
+ const componentOpen = ref(true);
76
+ const { elementClasses } = useStyleClassPassthrough(props.styleClassPassthrough);
77
+
78
+ const updateComponentState = () => {
79
+ if (parentComponentState.value) {
80
+ parentComponentState.value = false;
81
+ return;
82
+ }
83
+
84
+ componentOpen.value = false;
85
+ };
86
+
87
+ onMounted(async () => {
88
+ if (resolved.value.useAutoFocus && promptElementRef.value) {
89
+ promptElementRef.value.focus();
90
+ }
91
+ });
92
+ </script>
93
+
94
+ <style lang="css">
95
+ @layer components {
96
+ .display-prompt-core {
97
+ display: grid;
98
+ grid-template-rows: 1fr;
99
+ opacity: 1;
100
+ transition: all 200ms ease-in-out;
101
+
102
+ &.closed {
103
+ grid-template-rows: 0fr;
104
+ opacity: 0;
105
+ pointer-events: none;
106
+ }
107
+
108
+ .display-prompt-wrapper {
109
+ overflow: hidden;
110
+ }
111
+ }
112
+ }
113
+ </style>
@@ -0,0 +1,112 @@
1
+ import type { Meta, StoryFn } from "@nuxtjs/storybook";
2
+ import { ref } from "vue";
3
+ import StorybookComponent from "../DisplayPrompt.vue";
4
+
5
+ export default {
6
+ title: "Atoms/DisplayPrompt",
7
+ component: StorybookComponent,
8
+ argTypes: {
9
+ theme: {
10
+ control: { type: "inline-radio" },
11
+ options: ["info", "success", "warning", "error"],
12
+ description: "Semantic theme for the prompt",
13
+ table: { category: "Appearance" },
14
+ },
15
+ dismissible: {
16
+ control: { type: "boolean" },
17
+ description: "Show a close button to dismiss the prompt",
18
+ table: { category: "Behaviour" },
19
+ },
20
+ useAutoFocus: {
21
+ control: { type: "boolean" },
22
+ description: "Focuses the prompt element on mount",
23
+ table: { category: "Behaviour" },
24
+ },
25
+ styleClassPassthrough: {
26
+ control: { type: "object" },
27
+ description: "Extra classes applied to the prompt wrapper",
28
+ table: { category: "Styling" },
29
+ },
30
+ modelValue: { table: { disable: true } },
31
+ },
32
+ args: {
33
+ theme: "info",
34
+ dismissible: false,
35
+ useAutoFocus: false,
36
+ styleClassPassthrough: [],
37
+ },
38
+ } as Meta<typeof StorybookComponent>;
39
+
40
+ const Template: StoryFn<typeof StorybookComponent> = (args) => ({
41
+ components: { StorybookComponent },
42
+ setup() {
43
+ const model = ref(false);
44
+ return { args, model };
45
+ },
46
+ template: `
47
+ <div style="padding: 2rem; max-width: 640px;">
48
+ <StorybookComponent
49
+ v-model="model"
50
+ :theme="args.theme"
51
+ :dismissible="args.dismissible"
52
+ :use-auto-focus="args.useAutoFocus"
53
+ :style-class-passthrough="args.styleClassPassthrough"
54
+ >
55
+ <template #title>Prompt title</template>
56
+ <template #content>This is the prompt content. It can contain any information you want to communicate to the user.</template>
57
+ </StorybookComponent>
58
+ </div>
59
+ `,
60
+ });
61
+
62
+ export const Default = Template.bind({});
63
+
64
+ export const InfoTheme = Template.bind({});
65
+ InfoTheme.args = {
66
+ theme: "info",
67
+ dismissible: true,
68
+ };
69
+
70
+ export const SuccessTheme = Template.bind({});
71
+ SuccessTheme.args = {
72
+ theme: "success",
73
+ dismissible: true,
74
+ };
75
+
76
+ export const WarningTheme = Template.bind({});
77
+ WarningTheme.args = {
78
+ theme: "warning",
79
+ dismissible: true,
80
+ };
81
+
82
+ export const ErrorTheme = Template.bind({});
83
+ ErrorTheme.args = {
84
+ theme: "error",
85
+ dismissible: true,
86
+ };
87
+
88
+ export const Dismissible = Template.bind({});
89
+ Dismissible.args = {
90
+ theme: "info",
91
+ dismissible: true,
92
+ };
93
+
94
+ export const AllThemesDismissible: StoryFn<typeof StorybookComponent> = () => ({
95
+ components: { StorybookComponent },
96
+ setup() {
97
+ return { themes: ["info", "success", "warning", "error"] as const };
98
+ },
99
+ template: `
100
+ <div style="padding: 2rem; display: flex; flex-direction: column; gap: 1.6rem; max-width: 640px;">
101
+ <StorybookComponent
102
+ v-for="theme in themes"
103
+ :key="theme"
104
+ :theme="theme"
105
+ :dismissible="true"
106
+ >
107
+ <template #title>{{ theme.charAt(0).toUpperCase() + theme.slice(1) }} prompt</template>
108
+ <template #content>This is the {{ theme }} variant of the prompt component.</template>
109
+ </StorybookComponent>
110
+ </div>
111
+ `,
112
+ });