diff --git a/.claude/skills/components/display-prompt.md b/.claude/skills/components/display-prompt.md index fd452c51..383cf71b 100644 --- a/.claude/skills/components/display-prompt.md +++ b/.claude/skills/components/display-prompt.md @@ -16,6 +16,7 @@ the DOM. Dismiss can be controlled locally (closes itself) or by a parent via `v | `theme` | `SemanticTheme` | `"info"` | `"info" \| "success" \| "warning" \| "error"` | | `dismissible` | `boolean` | `false` | Shows a close button. | | `useAutoFocus` | `boolean` | `false` | Focuses the prompt root element on mount. | +| `masked` | `boolean` | `false` | SVG glass border — swaps `AlertContent` for `AlertMaskedContent`. | | `styleClassPassthrough` | `string \| string[]` | `[]` | Extra classes on the inner wrapper. Supported modifier: `"outlined"`. | | `v-model` | `boolean` | `false` | Optional parent control — see dismiss behaviour below. | @@ -38,8 +39,9 @@ the DOM. Dismiss can be controlled locally (closes itself) or by a parent via `v | `"warning"` | `akar-icons:circle-alert` | | `"error"` | `akar-icons:circle-alert` | -`data-theme` is set on the inner wrapper, activating the CSS palette (`--theme-surface`, -`--theme-text`, `--theme-border`, `--theme-ring`, etc.). +`data-theme` is set on `.display-prompt-wrapper`, activating the CSS palette (`--theme-accent`, +`--theme-text`, `--theme-border`, `--theme-ring`, etc.). The wrapper's left-edge accent uses +`--theme-accent` (step 5/4) — not `--theme-surface` — so it stays visually distinct from buttons. ## Dismiss behaviour @@ -111,11 +113,25 @@ Scope overrides using your page or section wrapper class — no `:deep()` needed ```css .my-section .display-prompt-wrapper { - --theme-surface: oklch(60% 0.18 140); + --theme-accent: oklch(60% 0.18 140); /* left-edge accent strip */ border-radius: 0.8rem; } ``` +## Masked variant + +Setting `:masked="true"` swaps `AlertContent` for `AlertMaskedContent`, giving the prompt an SVG-based +border and a semi-transparent background so content behind it is faintly visible. The inner layout is +identical — all the same slots apply. Place the prompt over a coloured or image background for the +glass effect to be visible. + +```vue + + Glass prompt + Semi-transparent, backed by AlertMaskedContent. + +``` + ## Notes - `DisplayPromptTheme` is an alias for `SemanticTheme` (`"info" | "success" | "warning" | "error"`). diff --git a/.claude/skills/components/display-toast.md b/.claude/skills/components/display-toast.md index 4daf9645..cf025f87 100644 --- a/.claude/skills/components/display-toast.md +++ b/.claude/skills/components/display-toast.md @@ -41,6 +41,7 @@ interface DisplayToastConfig { position?: "top" | "bottom" // default: "top" alignment?: "left" | "center" | "right" // default: "right" fullWidth?: boolean // default: false — overrides alignment + masked?: boolean // SVG glass border — semi-transparent background — default: false } behavior?: { autoDismiss?: boolean // default: true @@ -198,6 +199,14 @@ Override tokens via an unscoped style block scoped to a page or layout class: } ``` +### Masked variant + +Setting `appearance.masked: true` swaps `AlertContent` for `AlertMaskedContent`, which uses an SVG-based border and a semi-transparent background (`rgba(0,0,0,0.3)`) so page content is faintly visible beneath the toast. + +The SVG border shape matches the standard toast exactly (8px left radius, 4px right, 6px accent-colour left bar via `--theme-accent`). Override any dimension or colour via a `maskConfig` prop if using `AlertMaskedContent` standalone. + +The inner content layout is handled by the shared `AlertContentInner` molecule — both variants reuse it. `AlertMaskedContent` overrides `--_alert-content-inner-bg: transparent` so the glass effect shows through. + ### Provider notes - `useToastQueue` state persists across route changes — no `onBeforeRouteLeave` cleanup needed. diff --git a/.claude/skills/index.md b/.claude/skills/index.md index 154922d4..3f5da982 100644 --- a/.claude/skills/index.md +++ b/.claude/skills/index.md @@ -92,7 +92,7 @@ Each skill is a single markdown file named `-.md`. ├── display-pill.md — DisplayPill: pill/badge label with icon slot, 6 variants, 3 sizes, reversible order, full CSS token API for border/outline/colour ├── carousel-flip.md — CarouselFlip: FLIP-animated carousel, carouselDataIds slot API, buttonLayout variants (sides/controls-flanking/controls-grouped-right/overlay), CSS tokens ├── samaritan-prompt-mixed.md — SamaritanPromptMixed: animated text prompt, typewriter/word-pulse effects, MessageConfig API, aria-live accessibility, CSS tokens - ├── display-toast.md — DisplayToast (standalone v-model) + DisplayToastProvider + useToastQueue (app-wide queue): stacking, FLIP dismiss, maxVisible, SemanticTheme × 4 + ├── display-toast.md — DisplayToast (standalone v-model) + DisplayToastProvider + useToastQueue (app-wide queue): stacking, FLIP dismiss, maxVisible, SemanticTheme × 4, masked SVG glass variant └── display-prompt.md — DisplayPrompt: inline notification banner, SemanticTheme × 4, local vs parent-controlled dismiss, outlined modifier, CSS token override ``` diff --git a/.claude/skills/theming-colour-ramps.md b/.claude/skills/theming-colour-ramps.md index d3fddfcb..c0577709 100644 --- a/.claude/skills/theming-colour-ramps.md +++ b/.claude/skills/theming-colour-ramps.md @@ -70,17 +70,18 @@ These are used by components that need a specific step by name (e.g. error state ## Semantic slots -Eight shared colour roles are declared in `_theme-slots.css` on the same selector as the ramp. +Nine shared colour roles are declared in `_theme-slots.css` on the same selector as the ramp. All themed components (buttons, inputs, prompts, toasts) read only these tokens: | Token | Light (step) | Dark (step) | Role | |-------------------------|--------------|-------------|-----------------------------------| -| `--theme-surface` | 9 | 7 | Filled button/chip surface | -| `--theme-surface-hover` | 8 | 6 | Hover state of filled surface | -| `--theme-surface-subtle`| 1 | 9 | Hover bg for outline elements | +| `--theme-surface` | 7 | 9 | Filled button/chip surface | +| `--theme-surface-hover` | 9 | 7 | Hover state of filled surface | +| `--theme-accent` | 5 | 4 | Decorative accent strip (prompt/toast left edge) | +| `--theme-surface-subtle`| 1 | 9 | Subtle body bg for prompt/toast, outline element hover | | `--theme-border` | 6 | 5 | Input/card border | | `--theme-border-focus` | 4 | 3 | Focused border | -| `--theme-ring` | 2 | 2 | Focus ring (outline) | +| `--theme-ring` | 1 | 9 | Focus ring (outline) | | `--theme-on-surface` | 0 | 0 | Text/icon on filled surface | | `--theme-text` | 9 | 2 | Text on page, outline element text| diff --git a/.claude/skills/theming-override-default.md b/.claude/skills/theming-override-default.md index cbaa218d..92c3d7cc 100644 --- a/.claude/skills/theming-override-default.md +++ b/.claude/skills/theming-override-default.md @@ -181,4 +181,5 @@ that hue/chroma pair. - `--slate-*` neutrals ship with the layer — no need to redefine for page backgrounds/text - To add a custom `[data-theme]` variant using your generated palette, see `theming-colour-ramps.md` -- The `warning` theme overrides `--theme-surface` steps directly for a different surface intensity — this pattern is available for any custom theme that needs a non-default surface step +- The `warning` theme overrides `--theme-surface` and `--theme-surface-hover` to lighter steps (5/4 and 7/5) so warning buttons read as warm/alert rather than the default dark surface — this pattern is available for any custom theme that needs a non-default surface step +- `--theme-accent` (step 5/4) is separate from `--theme-surface` (step 7/9) — accent is for decorative strips in `DisplayPrompt` and `DisplayToast`; surface is for interactive elements like buttons diff --git a/.claude/skills/theming-partial-override.md b/.claude/skills/theming-partial-override.md index f5555d93..5cc28b30 100644 --- a/.claude/skills/theming-partial-override.md +++ b/.claude/skills/theming-partial-override.md @@ -14,17 +14,18 @@ after the layer styles. ## Semantic slots — the tokens to override -All themed components share the same 8-slot vocabulary. Overriding these affects buttons, inputs, +All themed components share a 9-slot vocabulary. Overriding these affects buttons, inputs, prompts, and toasts simultaneously: | Token | Default (light → dark) | Role | |-------------------------|----------------------------|-----------------------------------| -| `--theme-surface` | `--colour-theme-9` / `7` | Filled button/chip surface | -| `--theme-surface-hover` | `--colour-theme-8` / `6` | Hover state of filled surface | -| `--theme-surface-subtle`| `--colour-theme-1` / `9` | Hover bg for outline elements | +| `--theme-surface` | `--colour-theme-7` / `9` | Filled button/chip surface | +| `--theme-surface-hover` | `--colour-theme-9` / `7` | Hover state of filled surface | +| `--theme-accent` | `--colour-theme-5` / `4` | Decorative accent strip (prompt/toast left edge) | +| `--theme-surface-subtle`| `--colour-theme-1` / `9` | Subtle body bg for prompt/toast, outline element hover | | `--theme-border` | `--colour-theme-6` / `5` | Input/card border | | `--theme-border-focus` | `--colour-theme-4` / `3` | Focused border | -| `--theme-ring` | `--colour-theme-2` | Focus ring (outline) | +| `--theme-ring` | `--colour-theme-1` / `9` | Focus ring (outline) | | `--theme-on-surface` | `--colour-theme-0` | Text/icon on filled surface | | `--theme-text` | `--colour-theme-9` / `2` | Text on page, outline element text| @@ -64,10 +65,10 @@ Page-level tokens: #### Override button surface only ```css -/* Make primary buttons darker/more saturated */ +/* Make primary buttons darker/more saturated (step-7/9 defaults, step-9/7 hover) */ :where(html) { - --theme-surface: light-dark(oklch(28% 0.18 85), oklch(45% 0.20 85)); - --theme-surface-hover: light-dark(oklch(22% 0.16 85), oklch(38% 0.18 85)); + --theme-surface: light-dark(oklch(48% 0.18 85), oklch(32% 0.20 85)); + --theme-surface-hover: light-dark(oklch(32% 0.18 85), oklch(48% 0.20 85)); } ``` @@ -118,8 +119,8 @@ To restrict an override to a specific section, scope to a wrapper class instead .hero-cta { /* Just these buttons use a different surface colour */ - --theme-surface: light-dark(oklch(32% 0.16 85), oklch(55% 0.18 85)); - --theme-surface-hover: light-dark(oklch(25% 0.14 85), oklch(48% 0.16 85)); + --theme-surface: light-dark(oklch(48% 0.16 85), oklch(32% 0.18 85)); + --theme-surface-hover: light-dark(oklch(32% 0.14 85), oklch(48% 0.16 85)); } ``` diff --git a/README.md b/README.md index 92b2063a..fc7a7c68 100644 --- a/README.md +++ b/README.md @@ -124,7 +124,7 @@ computes the scale from the two params: Scale direction: **00 = lightest, 10 = darkest**. Chroma tapers at both ends and peaks at step 06. -Eight semantic slots (`--theme-surface`, `--theme-text`, `--theme-ring`, etc.) are derived from the +Nine semantic slots (`--theme-surface`, `--theme-accent`, `--theme-text`, `--theme-ring`, etc.) are derived from the scale using `light-dark()` and are shared by all components. Theme variants (`data-theme="success"`, `"warning"`, `"error"`) swap `--theme-hue` and `--theme-chroma` on their element; the formula re-evaluates locally so each themed element gets its own full palette without affecting the page. diff --git a/app/assets/styles/setup/03.theming/_theme-slots.css b/app/assets/styles/setup/03.theming/_theme-slots.css index 90548e04..7dc306f2 100644 --- a/app/assets/styles/setup/03.theming/_theme-slots.css +++ b/app/assets/styles/setup/03.theming/_theme-slots.css @@ -2,12 +2,15 @@ Declared on the same selector as theme-ramp.css so the formula re-evaluates per [data-theme] element rather than being inherited from html. */ :where(html, [data-theme], [data-invalid]) { - --theme-surface: light-dark(var(--colour-theme-9), var(--colour-theme-7)); - --theme-surface-hover: light-dark(var(--colour-theme-8), var(--colour-theme-6)); - --theme-surface-subtle: light-dark(var(--colour-theme-1), var(--colour-theme-9)); - --theme-border: light-dark(var(--colour-theme-6), var(--colour-theme-5)); - --theme-border-focus: light-dark(var(--colour-theme-4), var(--colour-theme-3)); - --theme-ring: var(--colour-theme-2); - --theme-on-surface: var(--colour-theme-0); - --theme-text: light-dark(var(--colour-theme-9), var(--colour-theme-2)); + --theme-surface: light-dark(var(--colour-theme-7), var(--colour-theme-9)); + --theme-surface-inverted: light-dark(var(--colour-theme-9), var(--colour-theme-2)); + --theme-surface-hover: light-dark(var(--colour-theme-9), var(--colour-theme-7)); + --theme-accent: light-dark(var(--colour-theme-5), var(--colour-theme-4)); + --theme-surface-subtle: light-dark(var(--colour-theme-1), var(--colour-theme-9)); + --theme-border: light-dark(var(--colour-theme-6), var(--colour-theme-5)); + --theme-border-focus: light-dark(var(--colour-theme-4), var(--colour-theme-3)); + --theme-ring: light-dark(var(--colour-theme-1), var(--colour-theme-9)); + --theme-on-surface: var(--colour-theme-0); + --theme-text: light-dark(var(--colour-theme-9), var(--colour-theme-2)); + --theme-text-inverted: light-dark(var(--colour-theme-9), var(--colour-theme-7)); } diff --git a/app/assets/styles/setup/04.elements/forms/06.button-geometry.css b/app/assets/styles/setup/04.elements/forms/06.button-geometry.css index 6d1d010d..9dd36a7d 100644 --- a/app/assets/styles/setup/04.elements/forms/06.button-geometry.css +++ b/app/assets/styles/setup/04.elements/forms/06.button-geometry.css @@ -2,7 +2,7 @@ --button-padding-block: 1rem; --button-padding-inline: 1.8rem; - --button-border-radius: 0.8rem; + --button-border-radius: 0.2rem; --button-border-radius-icon-only: 100vw; --button-border-width: 0.1rem; diff --git a/app/components/01.atoms/prompt/DisplayPrompt.vue b/app/components/01.atoms/prompt/DisplayPrompt.vue index 4cc9efc5..0b29e201 100644 --- a/app/components/01.atoms/prompt/DisplayPrompt.vue +++ b/app/components/01.atoms/prompt/DisplayPrompt.vue @@ -7,45 +7,43 @@ tabindex="0" > - - - - - - - - - - - - - - - - - - - - Close this prompt - - - + + + + + + + + + + + + + + + Close this prompt + + - - diff --git a/app/components/01.atoms/toast/stories/DisplayToast.stories.ts b/app/components/01.atoms/toast/stories/DisplayToast.stories.ts index 4aba1abb..c0e69bc9 100644 --- a/app/components/01.atoms/toast/stories/DisplayToast.stories.ts +++ b/app/components/01.atoms/toast/stories/DisplayToast.stories.ts @@ -2,7 +2,6 @@ import type { Meta, StoryFn } from "@nuxtjs/storybook"; import { ref, onMounted } from "vue"; import StorybookComponent from "../DisplayToast.vue"; -// Custom interface for story args interface ToastStoryArgs { theme: "info" | "success" | "warning" | "error"; position: "top" | "bottom"; @@ -11,153 +10,70 @@ interface ToastStoryArgs { autoDismiss: boolean; duration: number; revealDuration: number; - text: string; + title: string; + description: string; customIcon: string; - // DisplayPrompt specific props - usePrompt: boolean; - promptTheme: "info" | "success" | "warning" | "error"; - dismissible: boolean; - promptTitle: string; - promptContent: string; - useAutoFocus: boolean; - styleClassPassthrough: string[]; } export default { title: "Atoms/DisplayToast", component: StorybookComponent, argTypes: { - // Appearance Config theme: { control: { type: "select" }, options: ["info", "success", "warning", "error"], description: "Toast theme/variant", - table: { - category: "Appearance", - }, + table: { category: "Appearance" }, }, position: { control: { type: "select" }, options: ["top", "bottom"], description: "Vertical position of toast", - table: { - category: "Appearance", - }, + table: { category: "Appearance" }, }, alignment: { control: { type: "select" }, options: ["left", "center", "right"], description: "Horizontal alignment of toast", - table: { - category: "Appearance", - }, + table: { category: "Appearance" }, }, fullWidth: { control: { type: "boolean" }, description: "Whether toast spans full width", - table: { - category: "Appearance", - }, + table: { category: "Appearance" }, }, - // Behavior Config autoDismiss: { control: { type: "boolean" }, description: "Auto dismiss toast after duration", - table: { - category: "Behavior", - }, + table: { category: "Behaviour" }, }, duration: { control: { type: "range", min: 1000, max: 10000, step: 500 }, description: "Auto dismiss duration in milliseconds", - table: { - category: "Behavior", - }, + table: { category: "Behaviour" }, }, revealDuration: { control: { type: "range", min: 100, max: 1000, step: 50 }, description: "Animation duration for reveal/hide", - table: { - category: "Behavior", - }, + table: { category: "Behaviour" }, }, - // Content Config - text: { + title: { control: { type: "text" }, - description: "Toast message text", - table: { - category: "Content", - }, + description: "Toast title text", + table: { category: "Content" }, }, - customIcon: { - control: { type: "text" }, - description: "Custom icon name (e.g., 'akar-icons:check-box')", - table: { - category: "Content", - }, - }, - // DisplayPrompt Config - usePrompt: { - control: { type: "boolean" }, - description: "Use DisplayPrompt instead of simple text", - table: { - category: "Prompt", - }, - }, - promptTheme: { - control: { type: "select" }, - options: ["info", "success", "warning", "error"], - description: "Theme for DisplayPrompt", - table: { - category: "Prompt", - }, - }, - dismissible: { - control: { type: "boolean" }, - description: "Whether DisplayPrompt is dismissible", - table: { - category: "Prompt", - }, - }, - promptTitle: { + description: { control: { type: "text" }, - description: "Title for DisplayPrompt", - table: { - category: "Prompt", - }, + description: "Toast description text shown below the title", + table: { category: "Content" }, }, - promptContent: { + customIcon: { control: { type: "text" }, - description: "Content for DisplayPrompt", - table: { - category: "Prompt", - }, - }, - useAutoFocus: { - control: { type: "boolean" }, - description: "Auto focus DisplayPrompt", - table: { - category: "Prompt", - }, - }, - styleClassPassthrough: { - control: { type: "object" }, - description: "Style classes for DisplayPrompt (e.g., ['dark', 'outlined'])", - table: { - category: "Prompt", - }, - }, - // Hide complex internal props - config: { - table: { - disable: true, - }, - }, - modelValue: { - table: { - disable: true, - }, + description: "Custom icon name (Iconify, e.g. 'akar-icons:check-box')", + table: { category: "Content" }, }, + config: { table: { disable: true } }, + modelValue: { table: { disable: true } }, }, args: { theme: "info", @@ -167,26 +83,17 @@ export default { autoDismiss: false, duration: 3000, revealDuration: 300, - text: "This is a toast notification message", + title: "Notification", + description: "This is a toast notification message.", customIcon: "", - usePrompt: false, - promptTheme: "info", - dismissible: true, - promptTitle: "Prompt Title", - promptContent: "This is prompt content, it can contain html or plain text.", - useAutoFocus: false, - styleClassPassthrough: ["dark", "outlined"], }, } as Meta; const Template: StoryFn = (args) => ({ - components: { - StorybookComponent, - }, + components: { StorybookComponent }, setup() { const isActive = ref(false); - // Auto-trigger toast for demo onMounted(() => { setTimeout(() => { isActive.value = true; @@ -197,10 +104,6 @@ const Template: StoryFn = (args) => ({ }, template: ` - Toast will appear and demonstrate the configured behavior: - - Note: DisplayPrompt examples require component to be properly loaded in the environment. - = (args) => ({ theme: args.theme, position: args.position, alignment: args.alignment, - fullWidth: args.fullWidth + fullWidth: args.fullWidth, }, behavior: { autoDismiss: args.autoDismiss, duration: args.duration, - revealDuration: args.revealDuration + revealDuration: args.revealDuration, }, - content: args.usePrompt ? { - text: args.promptTitle + ': ' + args.promptContent - } : { - text: args.text, - ...(args.customIcon && { customIcon: args.customIcon }) + content: { + title: args.title, + description: args.description, + ...(args.customIcon && { customIcon: args.customIcon }), }, }" /> @@ -239,142 +141,55 @@ export const Default = Template.bind({}); export const SuccessToast = Template.bind({}); SuccessToast.args = { theme: "success", - text: "Success! Your action was completed.", + title: "Changes saved", + description: "Your changes have been saved successfully.", customIcon: "akar-icons:check-box", autoDismiss: false, - duration: 4000, - position: "top", - alignment: "right", - fullWidth: false, - revealDuration: 300, }; export const ErrorToast = Template.bind({}); ErrorToast.args = { theme: "error", - text: "Error! Something went wrong.", + title: "Something went wrong", + description: "Please try again or contact support if the problem persists.", autoDismiss: false, position: "top", alignment: "center", - fullWidth: false, - duration: 3000, - revealDuration: 300, - customIcon: "", }; -export const BottomCenterToast = Template.bind({}); -BottomCenterToast.args = { - theme: "info", - text: "Info message at bottom center", - position: "bottom", - alignment: "center", - autoDismiss: false, - duration: 5000, - fullWidth: false, - revealDuration: 300, - customIcon: "", -}; - -export const FullWidthToast = Template.bind({}); -FullWidthToast.args = { +export const WarningToast = Template.bind({}); +WarningToast.args = { theme: "warning", - text: "Full width warning message", - fullWidth: true, - position: "top", - alignment: "left", - autoDismiss: false, - duration: 6000, - revealDuration: 300, - customIcon: "", -}; - -// DisplayPrompt Examples - Based on Playground -export const ErrorPromptToast = Template.bind({}); -ErrorPromptToast.args = { - theme: "error", - position: "top", - alignment: "right", - fullWidth: false, + title: "Action required", + description: "Please review the highlighted fields before continuing.", autoDismiss: false, - duration: 5000, - revealDuration: 300, - usePrompt: true, - promptTheme: "error", - dismissible: false, - promptTitle: "Error Prompt Title with content", - promptContent: "This is an error prompt content, it can contain html or plain text.", - useAutoFocus: false, - styleClassPassthrough: ["dark", "outlined"], }; -export const SuccessPromptToast = Template.bind({}); -SuccessPromptToast.args = { - theme: "success", - position: "top", - alignment: "right", - fullWidth: false, - autoDismiss: false, +export const AutoDismiss = Template.bind({}); +AutoDismiss.args = { + theme: "info", + title: "Auto-dismissing", + description: "This toast will dismiss itself after 3 seconds.", + autoDismiss: true, duration: 3000, - revealDuration: 300, - usePrompt: true, - promptTheme: "success", - dismissible: true, - promptTitle: "Success Prompt Title with content (Dismissible)", - promptContent: "This is success prompt content, it can contain html or plain text.", - useAutoFocus: true, - styleClassPassthrough: ["dark", "outlined"], }; -export const InfoPromptFullWidth = Template.bind({}); -InfoPromptFullWidth.args = { +export const BottomCenter = Template.bind({}); +BottomCenter.args = { theme: "info", - position: "top", - alignment: "center", - fullWidth: true, - autoDismiss: false, - duration: 4000, - revealDuration: 300, - usePrompt: true, - promptTheme: "info", - dismissible: false, - promptTitle: "Info Prompt Title with content (Auto Dismiss)", - promptContent: "This is info prompt content displayed full width.", - useAutoFocus: false, - styleClassPassthrough: ["dark", "outlined"], -}; - -export const WarningPromptBottomCenter = Template.bind({}); -WarningPromptBottomCenter.args = { - theme: "warning", + title: "Bottom centre", + description: "Positioned at the bottom centre of the viewport.", position: "bottom", alignment: "center", - fullWidth: false, autoDismiss: false, - duration: 6000, - revealDuration: 300, - usePrompt: true, - promptTheme: "warning", - dismissible: true, - promptTitle: "Warning Prompt at Bottom Center", - promptContent: "This warning prompt appears at the bottom center with auto dismiss.", - useAutoFocus: false, - styleClassPassthrough: ["outlined"], }; -export const InfoPromptCustomStyles = Template.bind({}); -InfoPromptCustomStyles.args = { - theme: "info", +export const FullWidth = Template.bind({}); +FullWidth.args = { + theme: "warning", + title: "Full width warning", + description: "This toast spans the full width of the viewport.", + fullWidth: true, position: "top", - alignment: "left", - fullWidth: false, autoDismiss: false, - duration: 3000, - revealDuration: 500, - usePrompt: true, - promptTheme: "info", - dismissible: true, - promptTitle: "Info Prompt with Custom Styles", - promptContent: "This prompt uses custom style configurations and slower reveal animation.", - useAutoFocus: false, - styleClassPassthrough: ["dark"], }; diff --git a/app/components/01.atoms/toast/tests/DisplayToast.spec.ts b/app/components/01.atoms/toast/tests/DisplayToast.spec.ts index abf262b5..181ff6ff 100644 --- a/app/components/01.atoms/toast/tests/DisplayToast.spec.ts +++ b/app/components/01.atoms/toast/tests/DisplayToast.spec.ts @@ -98,18 +98,6 @@ describe("DisplayToast", () => { expect(toast()!.classList).not.toContain("left"); }); - // ─── has-theme class ────────────────────────────────────────────────────── - - it("has the has-theme class when no default slot is used", async () => { - await mountAndShow(); - expect(toast()!.classList).toContain("has-theme"); - }); - - it("does not have the has-theme class when a default slot is used", async () => { - await mountAndShow({}, { default: "Custom" }); - expect(toast()!.classList).not.toContain("has-theme"); - }); - // ─── tabindex / aria-describedby ────────────────────────────────────────── it("sets tabindex='0' when no default slot is used", async () => { @@ -136,17 +124,17 @@ describe("DisplayToast", () => { it("renders text content from config", async () => { await mountAndShow({ config: { content: { text: "Hello toast" } } }); - expect(document.querySelector(".toast-message")!.textContent).toContain("Hello toast"); + expect(document.querySelector(".alert-content-body")!.textContent).toContain("Hello toast"); }); it("renders title from config", async () => { await mountAndShow({ config: { content: { title: "Toast Title" } } }); - expect(document.querySelector("[data-test-id='toast-title']")!.textContent).toContain("Toast Title"); + expect(document.querySelector("[data-test-id='alert-title']")!.textContent).toContain("Toast Title"); }); it("renders description from config", async () => { await mountAndShow({ config: { content: { description: "Toast description" } } }); - expect(document.querySelector("[data-test-id='toast-description']")!.textContent).toContain( + expect(document.querySelector("[data-test-id='alert-content']")!.textContent).toContain( "Toast description" ); }); @@ -155,16 +143,16 @@ describe("DisplayToast", () => { await mountAndShow({ config: { content: { title: "Title", description: "Desc" } }, }); - expect(document.querySelector("[data-test-id='toast-title']")).not.toBeNull(); - expect(document.querySelector("[data-test-id='toast-description']")).not.toBeNull(); + expect(document.querySelector("[data-test-id='alert-title']")).not.toBeNull(); + expect(document.querySelector("[data-test-id='alert-content']")).not.toBeNull(); }); // ─── Default slot ───────────────────────────────────────────────────────── - it("renders default slot content instead of DefaultToastContent", async () => { + it("renders default slot content instead of AlertContent", async () => { await mountAndShow({}, { default: 'Custom' }); expect(document.querySelector(".custom-content")).not.toBeNull(); - expect(document.querySelector(".display-toast-inner")).toBeNull(); + expect(document.querySelector(".alert-content")).toBeNull(); }); // ─── Progress bar ───────────────────────────────────────────────────────── @@ -183,12 +171,12 @@ describe("DisplayToast", () => { it("renders the close button in the toast content when autoDismiss is false", async () => { await mountAndShow({ config: { behavior: { autoDismiss: false } } }); - expect(document.querySelector(".toast-action button")).not.toBeNull(); + expect(document.querySelector("[data-test-id='alert-dismiss']")).not.toBeNull(); }); it("does not render the close button when autoDismiss is true", async () => { await mountAndShow({ config: { behavior: { autoDismiss: true } } }); - expect(document.querySelector(".toast-action")).toBeNull(); + expect(document.querySelector("[data-test-id='alert-dismiss']")).toBeNull(); }); // ─── Dismiss via Escape key ──────────────────────────────────────────────── @@ -208,7 +196,7 @@ describe("DisplayToast", () => { await mountAndShow({ config: { behavior: { autoDismiss: false } } }); vi.advanceTimersByTime(100); await nextTick(); - (document.querySelector(".toast-action button") as HTMLElement).click(); + (document.querySelector("[data-test-id='alert-dismiss']") as HTMLElement).click(); await nextTick(); expect(toast()!.classList).toContain("hide"); }); diff --git a/app/components/01.atoms/toast/tests/DisplayToastProvider.spec.ts b/app/components/01.atoms/toast/tests/DisplayToastProvider.spec.ts index dad613fe..cafd6fc7 100644 --- a/app/components/01.atoms/toast/tests/DisplayToastProvider.spec.ts +++ b/app/components/01.atoms/toast/tests/DisplayToastProvider.spec.ts @@ -60,21 +60,21 @@ describe("DisplayToastProvider", () => { await mountSuspended(DisplayToastProvider); show({ content: { text: "Toast message" } }); await nextTick(); - expect(document.querySelector(".toast-message")!.textContent).toContain("Toast message"); + expect(document.querySelector(".alert-content-body")!.textContent).toContain("Toast message"); }); it("renders the toast title", async () => { await mountSuspended(DisplayToastProvider); show({ content: { title: "My Title" } }); await nextTick(); - expect(document.querySelector("[data-test-id='toast-title']")!.textContent).toContain("My Title"); + expect(document.querySelector("[data-test-id='alert-title']")!.textContent).toContain("My Title"); }); it("renders the toast description", async () => { await mountSuspended(DisplayToastProvider); show({ content: { description: "My description" } }); await nextTick(); - expect(document.querySelector("[data-test-id='toast-description']")!.textContent).toContain("My description"); + expect(document.querySelector("[data-test-id='alert-content']")!.textContent).toContain("My description"); }); // ─── data-theme ─────────────────────────────────────────────────────────── @@ -158,7 +158,7 @@ describe("DisplayToastProvider", () => { show({ content: { text: "Second" } }); await nextTick(); expect(items().length).toBe(1); - expect(document.querySelector(".toast-message")!.textContent).toContain("First"); + expect(document.querySelector(".alert-content-body")!.textContent).toContain("First"); }); it("shows up to maxVisible items simultaneously", async () => { @@ -176,7 +176,7 @@ describe("DisplayToastProvider", () => { await mountSuspended(DisplayToastProvider); show({ behavior: { autoDismiss: false } }); await nextTick(); - (document.querySelector(".toast-action button") as HTMLElement).click(); + (document.querySelector("[data-test-id='alert-dismiss']") as HTMLElement).click(); await nextTick(); expect(items().length).toBe(0); }); @@ -218,11 +218,11 @@ describe("DisplayToastProvider", () => { show({ content: { text: "First" }, behavior: { autoDismiss: false } }); show({ content: { text: "Second" }, behavior: { autoDismiss: false } }); await nextTick(); - expect(document.querySelector(".toast-message")!.textContent).toContain("First"); + expect(document.querySelector(".alert-content-body")!.textContent).toContain("First"); item()!.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true })); await nextTick(); - expect(document.querySelector(".toast-message")!.textContent).toContain("Second"); + expect(document.querySelector(".alert-content-body")!.textContent).toContain("Second"); }); // ─── Progress bar ───────────────────────────────────────────────────────── diff --git a/app/components/02.molecules/alert-content/AlertContent.vue b/app/components/02.molecules/alert-content/AlertContent.vue new file mode 100644 index 00000000..0dd95c1f --- /dev/null +++ b/app/components/02.molecules/alert-content/AlertContent.vue @@ -0,0 +1,67 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/components/02.molecules/alert-content/AlertContentInner.vue b/app/components/02.molecules/alert-content/AlertContentInner.vue new file mode 100644 index 00000000..8f2aac6f --- /dev/null +++ b/app/components/02.molecules/alert-content/AlertContentInner.vue @@ -0,0 +1,145 @@ + + + + + + + + + + + + + + + + + + + + + + + Close + + + + + + + + diff --git a/app/components/02.molecules/alert-content/stories/AlertContent.stories.ts b/app/components/02.molecules/alert-content/stories/AlertContent.stories.ts new file mode 100644 index 00000000..47bb0212 --- /dev/null +++ b/app/components/02.molecules/alert-content/stories/AlertContent.stories.ts @@ -0,0 +1,131 @@ +import type { Meta, StoryObj } from "@nuxtjs/storybook"; +import AlertContent from "../AlertContent.vue"; + +const meta: Meta = { + title: "Molecules/AlertContent", + component: AlertContent, + argTypes: { + theme: { + control: { type: "inline-radio" }, + options: ["info", "success", "warning", "error"], + description: "Semantic theme — controls the accent colour and default icon", + table: { category: "Appearance" }, + }, + customIcon: { + control: "text", + description: "Iconify icon name to override the default theme icon", + table: { category: "Appearance" }, + }, + dismissible: { + control: "boolean", + description: "Show a close button (emits 'dismiss' on click)", + table: { category: "Behaviour" }, + }, + contentId: { table: { disable: true } }, + ariaLive: { table: { disable: true } }, + }, + args: { + theme: "info", + dismissible: false, + }, +}; + +export default meta; +type Story = StoryObj; + +/** Default — info theme with title and body content. */ +export const Default: Story = { + render: (args) => ({ + components: { AlertContent }, + setup() { + return { args }; + }, + template: ` + + + Alert title + This is the alert body. It describes what happened and what the user should do next. + + + `, + }), +}; + +/** Dismissible — renders the close button and demonstrates the dismiss emit. */ +export const Dismissible: Story = { + args: { theme: "info", dismissible: true }, + render: (args) => ({ + components: { AlertContent }, + setup() { + return { args }; + }, + template: ` + + + Dismissible alert + Click the close button to emit the dismiss event. + + + `, + }), +}; + +/** Title only — no body content slot. */ +export const TitleOnly: Story = { + name: "Title Only", + args: { theme: "success" }, + render: (args) => ({ + components: { AlertContent }, + setup() { + return { args }; + }, + template: ` + + + Your changes have been saved. + + + `, + }), +}; + +/** Custom icon — overrides the default theme icon via the customIcon prop. */ +export const CustomIcon: Story = { + name: "Custom Icon", + args: { theme: "info", customIcon: "akar-icons:star" }, + render: (args) => ({ + components: { AlertContent }, + setup() { + return { args }; + }, + template: ` + + + Custom icon via prop + The icon is overridden using the customIcon prop. + + + `, + }), +}; + +/** All themes — all four semantic variants stacked. */ +export const AllThemes: Story = { + name: "All Themes", + render: () => ({ + components: { AlertContent }, + setup() { + return { themes: ["info", "success", "warning", "error"] as const }; + }, + template: ` + + + + {{ theme.charAt(0).toUpperCase() + theme.slice(1) }} + This is the {{ theme }} variant of the AlertContent component. + + + + `, + }), +}; diff --git a/app/components/02.molecules/alert-content/tests/AlertContent.spec.ts b/app/components/02.molecules/alert-content/tests/AlertContent.spec.ts new file mode 100644 index 00000000..ddf93d6e --- /dev/null +++ b/app/components/02.molecules/alert-content/tests/AlertContent.spec.ts @@ -0,0 +1,135 @@ +import { describe, it, expect } from "vitest"; +import { mountSuspended } from "@nuxt/test-utils/runtime"; +import AlertContent from "../AlertContent.vue"; + +describe("AlertContent", () => { + // ─── Mount ──────────────────────────────────────────────────────────────── + + it("mounts without error", async () => { + const wrapper = await mountSuspended(AlertContent, { props: { theme: "info" } }); + expect(wrapper.vm).toBeTruthy(); + }); + + // ─── Root element ───────────────────────────────────────────────────────── + + it("renders the root element with class alert-content", async () => { + const wrapper = await mountSuspended(AlertContent, { props: { theme: "info" } }); + expect(wrapper.find(".alert-content").exists()).toBe(true); + }); + + // ─── Icon ───────────────────────────────────────────────────────────────── + + it("renders the icon region", async () => { + const wrapper = await mountSuspended(AlertContent, { props: { theme: "info" } }); + expect(wrapper.find("[data-test-id='alert-icon']").exists()).toBe(true); + }); + + it("replaces the icon via the #icon slot", async () => { + const wrapper = await mountSuspended(AlertContent, { + props: { theme: "info" }, + slots: { icon: '★' }, + }); + expect(wrapper.find(".custom-icon").exists()).toBe(true); + }); + + // ─── Title slot ─────────────────────────────────────────────────────────── + + it("renders the title element when the #title slot is provided", async () => { + const wrapper = await mountSuspended(AlertContent, { + props: { theme: "info" }, + slots: { title: "Important notice" }, + }); + expect(wrapper.find("[data-test-id='alert-title']").exists()).toBe(true); + expect(wrapper.find("[data-test-id='alert-title']").text()).toContain("Important notice"); + }); + + it("does not render the title element when the #title slot is not provided", async () => { + const wrapper = await mountSuspended(AlertContent, { props: { theme: "info" } }); + expect(wrapper.find("[data-test-id='alert-title']").exists()).toBe(false); + }); + + // ─── Content slot ───────────────────────────────────────────────────────── + + it("renders the content element when the #content slot is provided", async () => { + const wrapper = await mountSuspended(AlertContent, { + props: { theme: "info" }, + slots: { content: "More detail here." }, + }); + expect(wrapper.find("[data-test-id='alert-content']").exists()).toBe(true); + expect(wrapper.find("[data-test-id='alert-content']").text()).toContain("More detail here."); + }); + + it("does not render the content element when the #content slot is not provided", async () => { + const wrapper = await mountSuspended(AlertContent, { props: { theme: "info" } }); + expect(wrapper.find("[data-test-id='alert-content']").exists()).toBe(false); + }); + + // ─── contentId ──────────────────────────────────────────────────────────── + + it("sets id on the body element when contentId is provided", async () => { + const wrapper = await mountSuspended(AlertContent, { + props: { theme: "info", contentId: "toast-message-abc" }, + }); + expect(wrapper.find(".alert-content-body").attributes("id")).toBe("toast-message-abc"); + }); + + it("omits id on the body element when contentId is not provided", async () => { + const wrapper = await mountSuspended(AlertContent, { props: { theme: "info" } }); + expect(wrapper.find(".alert-content-body").attributes("id")).toBeUndefined(); + }); + + // ─── ariaLive ───────────────────────────────────────────────────────────── + + it("sets aria-live on the body element when ariaLive is provided", async () => { + const wrapper = await mountSuspended(AlertContent, { + props: { theme: "info", ariaLive: "polite" }, + }); + expect(wrapper.find(".alert-content-body").attributes("aria-live")).toBe("polite"); + }); + + it("omits aria-live when ariaLive is not provided", async () => { + const wrapper = await mountSuspended(AlertContent, { props: { theme: "info" } }); + expect(wrapper.find(".alert-content-body").attributes("aria-live")).toBeUndefined(); + }); + + // ─── Dismiss button ─────────────────────────────────────────────────────── + + it("does not render the dismiss button when dismissible is false", async () => { + const wrapper = await mountSuspended(AlertContent, { props: { theme: "info", dismissible: false } }); + expect(wrapper.find("[data-test-id='alert-dismiss']").exists()).toBe(false); + }); + + it("renders the dismiss button when dismissible is true", async () => { + const wrapper = await mountSuspended(AlertContent, { props: { theme: "info", dismissible: true } }); + expect(wrapper.find("[data-test-id='alert-dismiss']").exists()).toBe(true); + }); + + it("emits the dismiss event when the dismiss button is clicked", async () => { + const wrapper = await mountSuspended(AlertContent, { props: { theme: "info", dismissible: true } }); + await wrapper.find("[data-test-id='alert-dismiss']").trigger("click"); + expect(wrapper.emitted("dismiss")).toBeTruthy(); + }); + + it("replaces the dismiss icon via the #dismissIcon slot", async () => { + const wrapper = await mountSuspended(AlertContent, { + props: { theme: "info", dismissible: true }, + slots: { dismissIcon: '×' }, + }); + expect(wrapper.find(".custom-close").exists()).toBe(true); + }); + + it("replaces the dismiss sr-only label via the #dismissLabel slot", async () => { + const wrapper = await mountSuspended(AlertContent, { + props: { theme: "info", dismissible: true }, + slots: { dismissLabel: "Close this prompt" }, + }); + expect(wrapper.find(".sr-only").text()).toBe("Close this prompt"); + }); + + it("uses 'Close' as the default dismiss label", async () => { + const wrapper = await mountSuspended(AlertContent, { + props: { theme: "info", dismissible: true }, + }); + expect(wrapper.find(".sr-only").text()).toBe("Close"); + }); +}); diff --git a/app/components/02.molecules/alert-content/tests/AlertContentInner.spec.ts b/app/components/02.molecules/alert-content/tests/AlertContentInner.spec.ts new file mode 100644 index 00000000..a40f5d64 --- /dev/null +++ b/app/components/02.molecules/alert-content/tests/AlertContentInner.spec.ts @@ -0,0 +1,135 @@ +import { describe, it, expect } from "vitest"; +import { mountSuspended } from "@nuxt/test-utils/runtime"; +import AlertContentInner from "../AlertContentInner.vue"; + +describe("AlertContentInner", () => { + // ─── Mount ──────────────────────────────────────────────────────────────── + + it("mounts without error", async () => { + const wrapper = await mountSuspended(AlertContentInner, { props: { theme: "info" } }); + expect(wrapper.vm).toBeTruthy(); + }); + + // ─── Root element ───────────────────────────────────────────────────────── + + it("renders the root element with class alert-content-inner", async () => { + const wrapper = await mountSuspended(AlertContentInner, { props: { theme: "info" } }); + expect(wrapper.find(".alert-content-inner").exists()).toBe(true); + }); + + // ─── Icon ───────────────────────────────────────────────────────────────── + + it("renders the icon region", async () => { + const wrapper = await mountSuspended(AlertContentInner, { props: { theme: "info" } }); + expect(wrapper.find("[data-test-id='alert-icon']").exists()).toBe(true); + }); + + it("replaces the icon via the #icon slot", async () => { + const wrapper = await mountSuspended(AlertContentInner, { + props: { theme: "info" }, + slots: { icon: '★' }, + }); + expect(wrapper.find(".custom-icon").exists()).toBe(true); + }); + + // ─── Title slot ─────────────────────────────────────────────────────────── + + it("renders the title element when the #title slot is provided", async () => { + const wrapper = await mountSuspended(AlertContentInner, { + props: { theme: "info" }, + slots: { title: "Important notice" }, + }); + expect(wrapper.find("[data-test-id='alert-title']").exists()).toBe(true); + expect(wrapper.find("[data-test-id='alert-title']").text()).toContain("Important notice"); + }); + + it("does not render the title element when the #title slot is not provided", async () => { + const wrapper = await mountSuspended(AlertContentInner, { props: { theme: "info" } }); + expect(wrapper.find("[data-test-id='alert-title']").exists()).toBe(false); + }); + + // ─── Content slot ───────────────────────────────────────────────────────── + + it("renders the content element when the #content slot is provided", async () => { + const wrapper = await mountSuspended(AlertContentInner, { + props: { theme: "info" }, + slots: { content: "More detail here." }, + }); + expect(wrapper.find("[data-test-id='alert-content']").exists()).toBe(true); + expect(wrapper.find("[data-test-id='alert-content']").text()).toContain("More detail here."); + }); + + it("does not render the content element when the #content slot is not provided", async () => { + const wrapper = await mountSuspended(AlertContentInner, { props: { theme: "info" } }); + expect(wrapper.find("[data-test-id='alert-content']").exists()).toBe(false); + }); + + // ─── contentId ──────────────────────────────────────────────────────────── + + it("sets id on the body element when contentId is provided", async () => { + const wrapper = await mountSuspended(AlertContentInner, { + props: { theme: "info", contentId: "toast-message-abc" }, + }); + expect(wrapper.find(".alert-content-body").attributes("id")).toBe("toast-message-abc"); + }); + + it("omits id on the body element when contentId is not provided", async () => { + const wrapper = await mountSuspended(AlertContentInner, { props: { theme: "info" } }); + expect(wrapper.find(".alert-content-body").attributes("id")).toBeUndefined(); + }); + + // ─── ariaLive ───────────────────────────────────────────────────────────── + + it("sets aria-live on the body element when ariaLive is provided", async () => { + const wrapper = await mountSuspended(AlertContentInner, { + props: { theme: "info", ariaLive: "polite" }, + }); + expect(wrapper.find(".alert-content-body").attributes("aria-live")).toBe("polite"); + }); + + it("omits aria-live when ariaLive is not provided", async () => { + const wrapper = await mountSuspended(AlertContentInner, { props: { theme: "info" } }); + expect(wrapper.find(".alert-content-body").attributes("aria-live")).toBeUndefined(); + }); + + // ─── Dismiss button ─────────────────────────────────────────────────────── + + it("does not render the dismiss button when dismissible is false", async () => { + const wrapper = await mountSuspended(AlertContentInner, { props: { theme: "info", dismissible: false } }); + expect(wrapper.find("[data-test-id='alert-dismiss']").exists()).toBe(false); + }); + + it("renders the dismiss button when dismissible is true", async () => { + const wrapper = await mountSuspended(AlertContentInner, { props: { theme: "info", dismissible: true } }); + expect(wrapper.find("[data-test-id='alert-dismiss']").exists()).toBe(true); + }); + + it("emits the dismiss event when the dismiss button is clicked", async () => { + const wrapper = await mountSuspended(AlertContentInner, { props: { theme: "info", dismissible: true } }); + await wrapper.find("[data-test-id='alert-dismiss']").trigger("click"); + expect(wrapper.emitted("dismiss")).toBeTruthy(); + }); + + it("replaces the dismiss icon via the #dismissIcon slot", async () => { + const wrapper = await mountSuspended(AlertContentInner, { + props: { theme: "info", dismissible: true }, + slots: { dismissIcon: '×' }, + }); + expect(wrapper.find(".custom-close").exists()).toBe(true); + }); + + it("replaces the dismiss sr-only label via the #dismissLabel slot", async () => { + const wrapper = await mountSuspended(AlertContentInner, { + props: { theme: "info", dismissible: true }, + slots: { dismissLabel: "Close this prompt" }, + }); + expect(wrapper.find(".sr-only").text()).toBe("Close this prompt"); + }); + + it("uses 'Close' as the default dismiss label", async () => { + const wrapper = await mountSuspended(AlertContentInner, { + props: { theme: "info", dismissible: true }, + }); + expect(wrapper.find(".sr-only").text()).toBe("Close"); + }); +}); diff --git a/app/components/02.molecules/alert-masked-content/AlertMaskedContent.vue b/app/components/02.molecules/alert-masked-content/AlertMaskedContent.vue new file mode 100644 index 00000000..237b99c7 --- /dev/null +++ b/app/components/02.molecules/alert-masked-content/AlertMaskedContent.vue @@ -0,0 +1,78 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/components/02.molecules/alert-masked-content/stories/AlertMaskedContent.stories.ts b/app/components/02.molecules/alert-masked-content/stories/AlertMaskedContent.stories.ts new file mode 100644 index 00000000..00701c8e --- /dev/null +++ b/app/components/02.molecules/alert-masked-content/stories/AlertMaskedContent.stories.ts @@ -0,0 +1,132 @@ +import type { Meta, StoryObj } from "@nuxtjs/storybook"; +import AlertMaskedContent from "../AlertMaskedContent.vue"; + +const meta: Meta = { + title: "Molecules/AlertMaskedContent", + component: AlertMaskedContent, + argTypes: { + theme: { + control: { type: "inline-radio" }, + options: ["info", "success", "warning", "error"], + description: "Semantic theme — controls the accent colour and default icon", + table: { category: "Appearance" }, + }, + customIcon: { + control: "text", + description: "Iconify icon name to override the default theme icon", + table: { category: "Appearance" }, + }, + dismissible: { + control: "boolean", + description: "Show a close button (emits 'dismiss' on click)", + table: { category: "Behaviour" }, + }, + maskConfig: { table: { disable: true } }, + contentId: { table: { disable: true } }, + ariaLive: { table: { disable: true } }, + }, + args: { + theme: "info", + dismissible: false, + }, +}; + +export default meta; +type Story = StoryObj; + +const wrapperStyle = ` + max-width: 600px; + padding: 2rem; + background: linear-gradient(135deg, oklch(40% 0.15 250), oklch(25% 0.1 300)); + border-radius: 8px; +`; + +/** Default — info theme over a coloured background to show the glass effect. */ +export const Default: Story = { + render: (args) => ({ + components: { AlertMaskedContent }, + setup() { + return { args }; + }, + template: ` + + + Alert title + This is the alert body. It describes what happened and what the user should do next. + + + `, + }), +}; + +/** Dismissible — renders the close button. */ +export const Dismissible: Story = { + args: { theme: "info", dismissible: true }, + render: (args) => ({ + components: { AlertMaskedContent }, + setup() { + return { args }; + }, + template: ` + + + Dismissible alert + Click the close button to emit the dismiss event. + + + `, + }), +}; + +/** All themes — all four semantic variants stacked over a shared background. */ +export const AllThemes: Story = { + name: "All Themes", + render: () => ({ + components: { AlertMaskedContent }, + setup() { + return { themes: ["info", "success", "warning", "error"] as const }; + }, + template: ` + + + + {{ theme.charAt(0).toUpperCase() + theme.slice(1) }} + This is the {{ theme }} masked variant. + + + + `, + }), +}; + +/** Compared side-by-side with different background colours to show how the glass adapts. */ +export const BackgroundVariants: Story = { + name: "Background Variants", + render: () => ({ + components: { AlertMaskedContent }, + setup() { + return { + backgrounds: [ + "linear-gradient(135deg, oklch(40% 0.15 250), oklch(25% 0.1 300))", + "linear-gradient(135deg, oklch(35% 0.12 30), oklch(20% 0.08 60))", + "linear-gradient(135deg, oklch(20% 0 0), oklch(10% 0 0))", + ], + }; + }, + template: ` + + + + Glass effect + The masked border adapts to whatever sits behind it. + + + + `, + }), +}; diff --git a/app/components/02.molecules/alert-masked-content/tests/AlertMaskedContent.spec.ts b/app/components/02.molecules/alert-masked-content/tests/AlertMaskedContent.spec.ts new file mode 100644 index 00000000..f67c67a8 --- /dev/null +++ b/app/components/02.molecules/alert-masked-content/tests/AlertMaskedContent.spec.ts @@ -0,0 +1,149 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { mountSuspended } from "@nuxt/test-utils/runtime"; +import AlertMaskedContent from "../AlertMaskedContent.vue"; + +const mockResizeObserver = vi.fn(() => ({ + observe: vi.fn(), + unobserve: vi.fn(), + disconnect: vi.fn(), +})); + +vi.stubGlobal("ResizeObserver", mockResizeObserver); + +describe("AlertMaskedContent", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + // ─── Mount ──────────────────────────────────────────────────────────────── + + it("mounts without error", async () => { + const wrapper = await mountSuspended(AlertMaskedContent, { props: { theme: "info" } }); + expect(wrapper.vm).toBeTruthy(); + }); + + // ─── Root element ───────────────────────────────────────────────────────── + + it("renders the root element with class alert-masked-content", async () => { + const wrapper = await mountSuspended(AlertMaskedContent, { props: { theme: "info" } }); + expect(wrapper.find(".alert-masked-content").exists()).toBe(true); + }); + + it("sets data-theme on the root element", async () => { + const wrapper = await mountSuspended(AlertMaskedContent, { props: { theme: "success" } }); + expect(wrapper.find(".alert-masked-content").attributes("data-theme")).toBe("success"); + }); + + // ─── AlertMaskCore ──────────────────────────────────────────────────────── + + it("renders AlertMaskCore", async () => { + const wrapper = await mountSuspended(AlertMaskedContent, { props: { theme: "info" } }); + expect(wrapper.find(".alert-mask-core").exists()).toBe(true); + }); + + it("renders AlertContentInner inside AlertMaskCore", async () => { + const wrapper = await mountSuspended(AlertMaskedContent, { props: { theme: "info" } }); + expect(wrapper.find(".alert-mask-core .alert-content-inner").exists()).toBe(true); + }); + + // ─── Icon ───────────────────────────────────────────────────────────────── + + it("renders the icon region", async () => { + const wrapper = await mountSuspended(AlertMaskedContent, { props: { theme: "info" } }); + expect(wrapper.find("[data-test-id='alert-icon']").exists()).toBe(true); + }); + + it("replaces the icon via the #icon slot", async () => { + const wrapper = await mountSuspended(AlertMaskedContent, { + props: { theme: "info" }, + slots: { icon: '★' }, + }); + expect(wrapper.find(".custom-icon").exists()).toBe(true); + }); + + // ─── Title slot ─────────────────────────────────────────────────────────── + + it("renders the title element when the #title slot is provided", async () => { + const wrapper = await mountSuspended(AlertMaskedContent, { + props: { theme: "info" }, + slots: { title: "Important notice" }, + }); + expect(wrapper.find("[data-test-id='alert-title']").text()).toContain("Important notice"); + }); + + it("does not render the title element when the #title slot is not provided", async () => { + const wrapper = await mountSuspended(AlertMaskedContent, { props: { theme: "info" } }); + expect(wrapper.find("[data-test-id='alert-title']").exists()).toBe(false); + }); + + // ─── Content slot ───────────────────────────────────────────────────────── + + it("renders the content element when the #content slot is provided", async () => { + const wrapper = await mountSuspended(AlertMaskedContent, { + props: { theme: "info" }, + slots: { content: "More detail here." }, + }); + expect(wrapper.find("[data-test-id='alert-content']").text()).toContain("More detail here."); + }); + + it("does not render the content element when the #content slot is not provided", async () => { + const wrapper = await mountSuspended(AlertMaskedContent, { props: { theme: "info" } }); + expect(wrapper.find("[data-test-id='alert-content']").exists()).toBe(false); + }); + + // ─── contentId / ariaLive ───────────────────────────────────────────────── + + it("sets id on the body element when contentId is provided", async () => { + const wrapper = await mountSuspended(AlertMaskedContent, { + props: { theme: "info", contentId: "toast-message-xyz" }, + }); + expect(wrapper.find(".alert-content-body").attributes("id")).toBe("toast-message-xyz"); + }); + + it("sets aria-live on the body element when ariaLive is provided", async () => { + const wrapper = await mountSuspended(AlertMaskedContent, { + props: { theme: "info", ariaLive: "assertive" }, + }); + expect(wrapper.find(".alert-content-body").attributes("aria-live")).toBe("assertive"); + }); + + // ─── Dismiss button ─────────────────────────────────────────────────────── + + it("does not render the dismiss button when dismissible is false", async () => { + const wrapper = await mountSuspended(AlertMaskedContent, { props: { theme: "info", dismissible: false } }); + expect(wrapper.find("[data-test-id='alert-dismiss']").exists()).toBe(false); + }); + + it("renders the dismiss button when dismissible is true", async () => { + const wrapper = await mountSuspended(AlertMaskedContent, { props: { theme: "info", dismissible: true } }); + expect(wrapper.find("[data-test-id='alert-dismiss']").exists()).toBe(true); + }); + + it("emits the dismiss event when the dismiss button is clicked", async () => { + const wrapper = await mountSuspended(AlertMaskedContent, { props: { theme: "info", dismissible: true } }); + await wrapper.find("[data-test-id='alert-dismiss']").trigger("click"); + expect(wrapper.emitted("dismiss")).toBeTruthy(); + }); + + // ─── Default maskConfig ─────────────────────────────────────────────────── + + it("passes the resolved maskConfig to AlertMaskCore", async () => { + const wrapper = await mountSuspended(AlertMaskedContent, { props: { theme: "info" } }); + const contentEl = wrapper.find(".alert-mask-content"); + const style = (contentEl.element as HTMLElement).style; + // Default borderLeft: 6 → --insetInlineStart: 6px + expect(style.getPropertyValue("--insetInlineStart")).toBe("6px"); + expect(style.getPropertyValue("--insetBlockStart")).toBe("1px"); + expect(style.getPropertyValue("--insetInlineEnd")).toBe("1px"); + expect(style.getPropertyValue("--insetBlockEnd")).toBe("1px"); + }); + + it("merges custom maskConfig over defaults", async () => { + const wrapper = await mountSuspended(AlertMaskedContent, { + props: { theme: "info", maskConfig: { borderLeft: 12 } }, + }); + const contentEl = wrapper.find(".alert-mask-content"); + const style = (contentEl.element as HTMLElement).style; + expect(style.getPropertyValue("--insetInlineStart")).toBe("12px"); + }); +}); diff --git a/app/components/05.forms/input-button/InputButtonCore.vue b/app/components/05.forms/input-button/InputButtonCore.vue index 7bb1e34c..0762ac24 100644 --- a/app/components/05.forms/input-button/InputButtonCore.vue +++ b/app/components/05.forms/input-button/InputButtonCore.vue @@ -10,7 +10,7 @@ class="input-button-core" :class="buttonClasses" > - + @@ -87,7 +87,7 @@ const { elementClasses } = useStyleClassPassthrough(props.styleClassPassthrough) diff --git a/app/layouts/default.vue b/app/layouts/default.vue index 8dd10893..7f853fd6 100644 --- a/app/layouts/default.vue +++ b/app/layouts/default.vue @@ -79,12 +79,12 @@ const responsiveNavLinks = { { name: "Card Core", path: "/ui/card-core" }, { name: "Glowing Border", path: "/ui/glowing-border" }, { name: "Display Toast", path: "/ui/display-toast" }, + { name: "Prompts", path: "/ui/display-prompt" }, { name: "Masked Alert", path: "/ui/mask-element" }, { name: "Expanding Panel", path: "/ui/expanding-panel" }, { name: "Dialogs", path: "/ui/display-dialog" }, { name: "Tabs X", path: "/ui/tabs" }, { name: "Tabs Y", path: "/ui/tabs-y" }, - { name: "Prompts", path: "/ui/display-prompt" }, { name: "Rotating Carousel", path: "/ui/rotating-carousel" }, { name: "Clipped Panels", path: "/ui/clipped-panels" }, { name: "Display Chip", path: "/ui/display-chip" }, diff --git a/app/pages/ui/display-prompt.vue b/app/pages/ui/display-prompt.vue index 0e5a9f92..86071b05 100644 --- a/app/pages/ui/display-prompt.vue +++ b/app/pages/ui/display-prompt.vue @@ -2,49 +2,130 @@ - + Display Prompts + + + + QA — DisplayPrompt + + {{ qaTheme }} · {{ qaDismissible ? "dismissible" : "static" }} · + {{ qaOutlined ? "outlined" : "no-outline" }} · {{ qaHighContrast ? "high-contrast" : "standard" }} · + {{ qaMasked ? "masked" : "unmasked" }} + + + + + Theme + + + {{ opt }} + + + + + + Dismissible + + + {{ opt ? "yes" : "no" }} + + + + + + Outlined + + + {{ opt ? "yes" : "no" }} + + + + + + High Contrast + + + {{ opt ? "yes" : "no" }} + + + + + + Auto Focus + + + {{ opt ? "yes" : "no" }} + + + + + + Masked + + + {{ opt ? "yes" : "no" }} + + + + + + + - - - - - Success Prompt Title with content (Dismissable) - This is prompt content, it can contain html or plain text. - - - - Dismiss - - - - - - - Info Prompt Title with content (Dismissable) - This is prompt content, it can contain html or plain text. - - - - - - - Error Prompt Title with content (Dismissable) - This is prompt content, it can contain html or plain text. - - - - - - - Warning Prompt Title with content (Dismissable) - This is prompt content, it can contain html or plain text. - + + + + + + {{ themeLabels[qaTheme] }} Prompt Title + This is prompt content, it can contain html or plain text. + + @@ -53,14 +134,49 @@ diff --git a/app/pages/ui/display-toast.vue b/app/pages/ui/display-toast.vue index cc3f5857..b8223e2b 100644 --- a/app/pages/ui/display-toast.vue +++ b/app/pages/ui/display-toast.vue @@ -3,7 +3,7 @@ - DisplayToast / DisplayToastProvider + DisplayToast Masked / DisplayToastProvider @@ -11,7 +11,8 @@ QA — DisplayToastProvider {{ qaTheme }} · {{ qaPosition }}-{{ qaAlignment }} · - {{ qaAutoDismiss ? `auto ${qaDuration}ms` : "manual" }} · max:{{ qaMaxVisible }} + {{ qaAutoDismiss ? `auto ${qaDuration}ms` : "manual" }} · max:{{ qaMaxVisible }} · + {{ qaIsMasked ? "masked" : "solid" }} @@ -60,6 +61,21 @@ + + Masked + + + {{ opt ? "yes" : "no" }} + + + + Full Width @@ -109,7 +125,7 @@ Max Visible ("info"); const qaPosition = ref("top"); const qaAlignment = ref("right"); +const qaIsMasked = ref(true); const qaFullWidth = ref(false); const qaAutoDismiss = ref(true); const qaDuration = ref(5000); @@ -178,7 +195,7 @@ const messages: Record { show({ - appearance: { theme: qaTheme.value }, + appearance: { theme: qaTheme.value, masked: qaIsMasked.value }, behavior: { autoDismiss: qaAutoDismiss.value, duration: qaDuration.value }, content: messages[qaTheme.value], }); @@ -187,7 +204,7 @@ const triggerToast = () => { const queueMultiple = () => { themes.forEach((theme) => { show({ - appearance: { theme }, + appearance: { theme, masked: qaIsMasked.value }, behavior: { autoDismiss: qaAutoDismiss.value, duration: qaDuration.value }, content: messages[theme], }); @@ -274,19 +291,19 @@ const queueMultiple = () => { font-family: monospace; font-size: 1.2rem; color: white; - background: oklch(0% 0 0 / 0.25); + background-color: oklch(0% 0 0 / 0.25); border: 1px solid oklch(100% 0 0 / 0.18); padding: 0.3rem 1rem; border-radius: 0.4rem; cursor: pointer; - transition: background 0.15s; + transition: background-color 0.15s; &:hover { - background: oklch(0% 0 0 / 0.4); + background-color: oklch(0% 0 0 / 0.4); } &.is-active { - background: oklch(55% 0.18 240); + background-color: oklch(55% 0.18 240); border-color: oklch(55% 0.18 240); } } diff --git a/app/types/components/display-prompt.d.ts b/app/types/components/display-prompt.d.ts index 86cadf88..3a229c5a 100644 --- a/app/types/components/display-prompt.d.ts +++ b/app/types/components/display-prompt.d.ts @@ -6,5 +6,6 @@ export interface DisplayPromptProps { theme?: DisplayPromptTheme dismissible?: boolean useAutoFocus?: boolean + masked?: boolean styleClassPassthrough?: string | string[] } diff --git a/app/types/components/display-toast.d.ts b/app/types/components/display-toast.d.ts index f79dec24..c23640b7 100644 --- a/app/types/components/display-toast.d.ts +++ b/app/types/components/display-toast.d.ts @@ -11,6 +11,7 @@ export interface DisplayToastAppearanceConfig { position?: DisplayToastPosition alignment?: DisplayToastAlignment fullWidth?: boolean + masked?: boolean } export interface DisplayToastBehaviorConfig {
- -
Toast will appear and demonstrate the configured behavior:
- Note: DisplayPrompt examples require component to be properly loaded in the environment. -
Custom
+ +
+ {{ qaTheme }} · {{ qaDismissible ? "dismissible" : "static" }} · + {{ qaOutlined ? "outlined" : "no-outline" }} · {{ qaHighContrast ? "high-contrast" : "standard" }} · + {{ qaMasked ? "masked" : "unmasked" }} +
{{ qaTheme }} · {{ qaPosition }}-{{ qaAlignment }} · - {{ qaAutoDismiss ? `auto ${qaDuration}ms` : "manual" }} · max:{{ qaMaxVisible }} + {{ qaAutoDismiss ? `auto ${qaDuration}ms` : "manual" }} · max:{{ qaMaxVisible }} · + {{ qaIsMasked ? "masked" : "solid" }}