diff --git a/.specs/message.md b/.specs/message.md index bafa80e9e..aa592a2f0 100644 --- a/.specs/message.md +++ b/.specs/message.md @@ -4,57 +4,100 @@ category: feedback structure: monolithic status: implemented spec_version: 1 -checksum: ba56d1090963d422e4457107fa9d9de706c060de1767dd4b4efc8d9b4213dcd0 +figma: + url: https://www.figma.com/design/t97pXRs7xME3SJDs5iZ5RF/Webkit?node-id=478-892 + node_id: 478:892 +checksum: 369176139640a950359df9192158948fc7e62777ee0c88b2f8fba00a1a0b3ced created: 2026-05-22 -last_updated: 2026-05-22 +last_updated: 2026-05-29 --- # Message — Component Spec ## Purpose -Communicates status, alerts, or progress to the user. Migrated from the existing implementation at `packages/webkit/src/components/webkit/feedback/message/`. +Inline feedback banner that communicates status, alerts, or progress. Presents a severity-colored surface with icon, title, optional description, and an optional text action aligned to Figma Message (478:892). + +## Usage + +```vue + + + +``` ## Props | Prop | Type | Default | Required | JSDoc | |---|---|---|---|---| -| `severity` | `'info' | 'success' | 'warning' | 'danger' | 'error'` | `'info'` | no | severity. | -| `title` | `string` | `—` | yes | title. | -| `description` | `string` | `''` | no | description. | -| `icon` | `string` | `'undefined'` | no | PrimeIcons class for the leading/trailing icon. | -| `actionLabel` | `string` | `''` | no | action Label. | +| `severity` | `'info' | 'success' | 'warning' | 'danger' | 'error'` | `'info'` | no | Visual severity variant (maps Error to danger). | +| `title` | `string` | `—` | yes | Primary message heading. | +| `description` | `string` | `''` | no | Supporting body copy below the title. | +| `icon` | `string` | `''` | no | PrimeIcons class override for the leading icon. | +| `actionLabel` | `string` | `''` | no | Label for the built-in text action button; hidden when empty. | +| `closable` | `boolean` | `false` | no | When true, shows a close control that dismisses the message. | +| `life` | `number` | `0` | no | Duration in milliseconds before auto-dismiss; `0` disables auto-dismiss. | ## Events | Event | Payload | Notes | |---|---|---| -| `action` | `unknown` | — | +| `action` | `MouseEvent` | Emitted when the built-in action button is clicked. | +| `close` | `void` | Emitted when the message is dismissed manually or after `life` expires. | ## Slots | Slot | Scope | Notes | |---|---|---| -| `action` | — | Named slot. | -| `default` | — | Main content. | +| `action` | — | Custom action control; replaces the built-in Button when provided. | +| `default` | — | Replaces the default icon + title + description layout. | ## States -- Visual states: `default`, `hover`, `focus-visible`, `active`, `disabled` +- Visual states: default (per severity) ## Motion & Animations -_none_ +| Trigger | Animation / Transition | Token | Reduced-motion fallback | +|---|---|---|---| +| dismiss | inline `opacity` transition | `duration['fast-02']` · `curve['productive-exit']` (animate.js) | `motion-reduce:transition-none` | ## Tokens | Region | Token (DESIGN.md) | |---|---| -| typography | .text-body-sm | -| surface | `var(--bg-surface)` | +| title typography | `.text-label-sm` | +| description typography | `.text-body-xs` | +| action typography | `.text-button-md` | +| surface (info) | `var(--info)` | +| surface (success) | `var(--success)` | +| surface (warning) | `var(--warning)` | +| surface (danger) | `var(--danger)` | +| border (info) | `var(--info-border)` | +| border (success) | `var(--success-border)` | +| border (warning) | `var(--warning-border)` | +| border (danger) | `var(--danger-border)` | +| icon (info) | `var(--info-contrast)` | +| icon (success) | `var(--success-contrast)` | +| icon (warning) | `var(--warning-contrast)` | +| icon (danger) | `var(--danger-contrast)` | | text | `var(--text-default)` | -| spacing | `var(--spacing-3)` | -| shape | `var(--shape-elements)` | +| muted text | `var(--text-muted)` | +| spacing (padding) | `var(--spacing-sm)` | +| spacing (gap) | `var(--spacing-xs)` | +| spacing (title stack) | `var(--spacing-xxs)` | +| shape | `var(--shape-button)` | +| shadow | `var(--shadow-xs)` | | ring | `var(--ring-color)` | +| min height | `min-h-14` | ## Theme gaps @@ -65,8 +108,8 @@ _none_ ## Accessibility (WCAG 2.1 AA) - Visible focus: `focus-visible:ring-2 focus-visible:ring-[var(--ring-color)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--bg-canvas)]` -- Keyboard map: `Tab` focuses; `Enter`/`Space` activates; `Escape` closes overlays where applicable. -- ARIA: root uses appropriate roles (`button`, `dialog`, `status`, etc.) per sub-component. +- Keyboard map: `Tab` focuses the action and close controls when present; `Enter`/`Space` activates them; `Escape` dismisses when `closable` is true. +- ARIA: root uses `role="alert"` for danger/warning severities and `role="status"` for info/success. - Contrast ≥4.5:1 (text) / ≥3:1 (large + icons), including disabled state. - `motion-reduce:transition-none motion-reduce:transform-none` on animated states. - Touch target ≥40×40 px where the control is interactive. @@ -74,6 +117,9 @@ _none_ ## Stories (Storybook) - Default +- Types +- Closable +- AutoDismiss ## Constraints — DO NOT diff --git a/apps/storybook/src/stories/webkit/feedback/message/Message.stories.js b/apps/storybook/src/stories/webkit/feedback/message/Message.stories.js index f9fa572c4..8409e8718 100644 --- a/apps/storybook/src/stories/webkit/feedback/message/Message.stories.js +++ b/apps/storybook/src/stories/webkit/feedback/message/Message.stories.js @@ -1,8 +1,7 @@ import Message from '@aziontech/webkit/feedback/message' -const severities = ['info', 'success', 'warning', 'danger'] - -export default { +/** @type {import('@storybook/vue3').Meta} */ +const meta = { title: 'Webkit/Feedback/Message', component: Message, tags: ['autodocs'], @@ -10,111 +9,210 @@ export default { layout: 'padded', backgrounds: { default: 'dark' + }, + a11y: { + config: { + rules: [ + { id: 'color-contrast', enabled: true }, + { id: 'focus-order-semantics', enabled: true } + ] + } + }, + docs: { + description: { + component: [ + 'Inline feedback banner that communicates status, alerts, or progress. Presents a severity-colored surface with icon, title, optional description, and an optional text action aligned to Figma Message (478:892).', + '', + '## Usage', + '', + '```vue', + '', + '', + '', + '```' + ].join('\n') + }, + source: { + type: 'dynamic', + excludeDecorators: true + }, + canvas: { + sourceState: 'shown' + } } }, argTypes: { severity: { control: 'select', - options: severities, - description: 'Visual severity variant (Figma: Info, Success, Warning, Error)' + options: ['info', 'success', 'warning', 'danger', 'error'], + description: 'Visual severity variant (maps Error to danger).', + table: { + category: 'props', + type: { summary: "'info' | 'success' | 'warning' | 'danger' | 'error'" }, + defaultValue: { summary: "'info'" } + } }, title: { control: 'text', - description: 'Message title' + description: 'Primary message heading.', + table: { category: 'props', type: { summary: 'string', required: true } } }, description: { control: 'text', - description: 'Supporting description text' + description: 'Supporting body copy below the title.', + table: { category: 'props', type: { summary: 'string' }, defaultValue: { summary: "''" } } }, icon: { control: 'text', - description: 'PrimeIcons class override' + description: 'PrimeIcons class override for the leading icon.', + table: { category: 'props', type: { summary: 'string' }, defaultValue: { summary: "''" } } }, actionLabel: { control: 'text', - description: 'Action button label (hidden when empty)' + description: 'Label for the built-in text action button; hidden when empty.', + table: { category: 'props', type: { summary: 'string' }, defaultValue: { summary: "''" } } + }, + closable: { + control: 'boolean', + description: 'When true, shows a close control that dismisses the message.', + table: { category: 'props', type: { summary: 'boolean' }, defaultValue: { summary: 'false' } } + }, + life: { + control: 'number', + description: 'Duration in milliseconds before auto-dismiss; `0` disables auto-dismiss.', + table: { category: 'props', type: { summary: 'number' }, defaultValue: { summary: '0' } } + }, + onAction: { + action: 'action', + description: 'Emitted when the built-in action button is clicked.', + table: { category: 'events', type: { summary: 'MouseEvent' } } + }, + onClose: { + action: 'close', + description: 'Emitted when the message is dismissed manually or after `life` expires.', + table: { category: 'events', type: { summary: 'void' } } } - } -} - -export const Default = { + }, args: { severity: 'info', title: 'Info message', description: 'A brief description of the message.', - actionLabel: 'Label' - }, - render: (args) => ({ - components: { Message }, - setup() { - return { args } - }, - template: ` - - ` + actionLabel: 'Label', + icon: '', + closable: false, + life: 0 + } +} + +export default meta + +const messageRemountKey = (args) => + JSON.stringify({ + severity: args.severity, + title: args.title, + description: args.description, + icon: args.icon, + actionLabel: args.actionLabel, + closable: args.closable, + life: args.life }) + +const Template = (args) => ({ + components: { Message }, + setup() { + const { onAction, onClose, ...props } = args + + return { props, onAction, onClose, remountKey: messageRemountKey(args) } + }, + template: '' +}) + +/** @type {import('@storybook/vue3').StoryObj} */ +export const Default = { + render: Template, + parameters: { + docs: { description: { story: 'Default info message with title, description, and action.' } } + } } -export const Severities = { +/** @type {import('@storybook/vue3').StoryObj} */ +export const Types = { render: () => ({ components: { Message }, template: ` -
+
+ + +
` - }) + }), + parameters: { + docs: { + controls: { disable: true }, + description: { story: 'All severity variants stacked.' } + } + } } -export const WithoutAction = { +/** @type {import('@storybook/vue3').StoryObj} */ +export const Closable = { args: { - severity: 'info', - title: 'Info message', - description: 'A brief description of the message.', - actionLabel: '' + closable: true }, - render: (args) => ({ - components: { Message }, - setup() { - return { args } - }, - template: ` - - ` - }) + render: Template, + parameters: { + docs: { + description: { story: 'Closable message with a dismiss control on the trailing edge.' } + } + } } -export const CustomAction = { - render: () => ({ - components: { Message }, - template: ` - - - - ` - }) +/** @type {import('@storybook/vue3').StoryObj} */ +export const AutoDismiss = { + args: { + life: 5000, + actionLabel: '' + }, + render: Template, + parameters: { + docs: { + description: { + story: 'Auto-dismisses after 5 seconds when `life` is greater than zero.' + } + } + } } diff --git a/packages/webkit/src/components/feedback/message/message.vue b/packages/webkit/src/components/feedback/message/message.vue index 603cdfa4b..673583c3d 100644 --- a/packages/webkit/src/components/feedback/message/message.vue +++ b/packages/webkit/src/components/feedback/message/message.vue @@ -1,154 +1,211 @@ - diff --git a/packages/webkit/src/components/feedback/message/presets/transitions.ts b/packages/webkit/src/components/feedback/message/presets/transitions.ts new file mode 100644 index 000000000..8175ee4ff --- /dev/null +++ b/packages/webkit/src/components/feedback/message/presets/transitions.ts @@ -0,0 +1,23 @@ +import { + curve, + duration +} from '../../../../../../theme/src/tokens/primitives/animations/animate.js' + +/** + * Message dismiss motion — values read only from `animate.js` (`duration`, `curve`). + * Applied via inline `transition` (Tailwind does not emit dynamic `duration-[…]` classes). + */ +export const messageDismissMotion = { + duration: duration['fast-02'], + curve: curve['productive-exit'] +} as const + +/** Defers unmount until the opacity exit finishes. */ +export const MESSAGE_DISMISS_MS = Number.parseInt(messageDismissMotion.duration, 10) + +/** Inline transition for dismiss (opacity fade-out). */ +export const getMessageDismissTransitionStyle = (): { transition: string } => ({ + transition: `opacity ${messageDismissMotion.duration} ${messageDismissMotion.curve}` +}) + +export const messageDismissTransitionClasses = ['motion-reduce:transition-none'] as const