From d46b74fea89cc0dd65a7f24315d010d8ed062a4d Mon Sep 17 00:00:00 2001 From: J-Sek Date: Tue, 5 May 2026 18:53:35 +0200 Subject: [PATCH] feat(Alert): add new component --- apps/docs/src/components/docs/DocsApi.vue | 2 +- .../src/examples/components/alert/basic.vue | 50 ++++ .../examples/components/alert/dismissible.vue | 79 ++++++ apps/docs/src/pages/components/index.md | 1 + .../src/pages/components/semantic/alert.md | 153 +++++++++++ apps/docs/src/typed-router.d.ts | 13 + dev/src/components.d.ts | 8 + dev/src/plugins/index.ts | 8 + packages/0/README.md | 1 + .../0/src/components/Alert/AlertAction.vue | 80 ++++++ .../src/components/Alert/AlertDescription.vue | 65 +++++ packages/0/src/components/Alert/AlertIcon.vue | 68 +++++ packages/0/src/components/Alert/AlertRoot.vue | 119 ++++++++ .../0/src/components/Alert/AlertTitle.vue | 65 +++++ packages/0/src/components/Alert/index.test.ts | 256 ++++++++++++++++++ packages/0/src/components/Alert/index.ts | 110 ++++++++ packages/0/src/components/index.ts | 1 + packages/0/src/maturity.json | 3 +- 18 files changed, 1080 insertions(+), 2 deletions(-) create mode 100644 apps/docs/src/examples/components/alert/basic.vue create mode 100644 apps/docs/src/examples/components/alert/dismissible.vue create mode 100644 apps/docs/src/pages/components/semantic/alert.md create mode 100644 packages/0/src/components/Alert/AlertAction.vue create mode 100644 packages/0/src/components/Alert/AlertDescription.vue create mode 100644 packages/0/src/components/Alert/AlertIcon.vue create mode 100644 packages/0/src/components/Alert/AlertRoot.vue create mode 100644 packages/0/src/components/Alert/AlertTitle.vue create mode 100644 packages/0/src/components/Alert/index.test.ts create mode 100644 packages/0/src/components/Alert/index.ts diff --git a/apps/docs/src/components/docs/DocsApi.vue b/apps/docs/src/components/docs/DocsApi.vue index 6ff35dbfa..d59fffe0b 100644 --- a/apps/docs/src/components/docs/DocsApi.vue +++ b/apps/docs/src/components/docs/DocsApi.vue @@ -40,7 +40,7 @@ if (!prefix) return [] return Object.entries(data.components) - .filter(([name]) => name.startsWith(prefix)) + .filter(([name]) => name === prefix || name.startsWith(`${prefix}.`)) .map(([, api]) => api) .toSorted((a, b) => { if (a.name.endsWith('Root')) return -1 diff --git a/apps/docs/src/examples/components/alert/basic.vue b/apps/docs/src/examples/components/alert/basic.vue new file mode 100644 index 000000000..aacc6a162 --- /dev/null +++ b/apps/docs/src/examples/components/alert/basic.vue @@ -0,0 +1,50 @@ + + + diff --git a/apps/docs/src/examples/components/alert/dismissible.vue b/apps/docs/src/examples/components/alert/dismissible.vue new file mode 100644 index 000000000..fd9fab596 --- /dev/null +++ b/apps/docs/src/examples/components/alert/dismissible.vue @@ -0,0 +1,79 @@ + + + diff --git a/apps/docs/src/pages/components/index.md b/apps/docs/src/pages/components/index.md index 4f7239007..b824281d5 100644 --- a/apps/docs/src/pages/components/index.md +++ b/apps/docs/src/pages/components/index.md @@ -75,6 +75,7 @@ Components with meaningful HTML defaults. Render semantic elements by default bu | Name | Description | | - | - | +| [Alert](/components/semantic/alert) | Inline status banner with icon, title, description, and dismiss action | | [Avatar](/components/semantic/avatar) | Image/fallback avatar with priority loading | | [Breadcrumbs](/components/semantic/breadcrumbs) | Navigation breadcrumbs with overflow detection and truncation | | [Carousel](/components/semantic/carousel) | Scroll-snap slide navigation with multi-slide display and drag/swipe | diff --git a/apps/docs/src/pages/components/semantic/alert.md b/apps/docs/src/pages/components/semantic/alert.md new file mode 100644 index 000000000..95fbba543 --- /dev/null +++ b/apps/docs/src/pages/components/semantic/alert.md @@ -0,0 +1,153 @@ +--- +title: Alert - Inline Status Banner Component +meta: +- name: description + content: Headless inline alert component with role="alert" for status messages, warnings, and dismissible banners. Composable sub-components for icon, title, description, and dismiss action. +- name: keywords + content: alert, banner, notification, status, warning, error, success, info, dismissible, Vue 3, headless, accessible, WAI-ARIA +features: + category: Component + label: 'C: Alert' + github: /components/Alert/ + renderless: false + level: 2 +related: + - /components/semantic/snackbar + - /components/disclosure/alert-dialog + - /components/semantic/progress +--- + +# Alert + +Inline status banner for persistent, non-interrupting feedback — errors, warnings, and contextual notices. + + + +## Usage + +Alert renders with `role="alert"` so screen readers announce the content automatically when the component enters the DOM. Use it for feedback that belongs inline with page content and does not require user acknowledgement. + +::: example +/components/alert/basic + +### Informational and warning banners + +Static alerts with icon, title, and description — the most common pattern for system notices, in-form error summaries, and section-level warnings. + +| Sub-component | Role | +|---|---| +| `Alert.Root` | Container; carries `role="alert"` and ARIA ID links | +| `Alert.Icon` | Decorative icon wrapper; `aria-hidden="true"` by default | +| `Alert.Title` | Heading with auto-generated ID for `aria-labelledby` | +| `Alert.Description` | Body text with auto-generated ID for `aria-describedby` | + +::: + +## Anatomy + +```vue Anatomy playground collapse + + + +``` + +## Examples + +::: example +/components/alert/dismissible + +### Dismissible alerts + +Alerts can be made dismissible by adding `Alert.Action` and binding `v-model` on `Alert.Root`. When the action is clicked, `dismiss()` is called internally and `v-model` is set to `false`. + +Use `v-if` on `Alert.Root` to remove the element from the DOM after dismissal — `role="alert"` elements should not stay in the DOM silently, because some screen readers re-announce live regions when page state changes. + +```vue + +``` + +You can also react to the model externally — for example, to persist the dismissed state across sessions: + +```ts +const dismissed = useStorage('alert-dismissed', false) +``` + +```vue + +``` + +| File | Role | +|---|---| +| `dismissible.vue` | Two dismissible alerts with v-model binding and a reset button | + +::: + +## Alert vs Snackbar vs AlertDialog + +| | Alert | Snackbar | AlertDialog | +|---|---|---|---| +| **Position** | Inline, in document flow | Floating overlay | Modal overlay | +| **Auto-dismiss** | No | Yes (configurable) | No | +| **Interrupts focus** | No | No | Yes — focus moves to dialog | +| **ARIA** | `role="alert"` | `role="status"` / `role="alert"` | `role="alertdialog"` | +| **Use case** | Persistent page-level notices | Transient confirmations | Requires explicit acknowledgement | + +> [!TIP] +> Alerts present in the DOM **before** page load are not announced by most screen readers — the live region only fires on mutation. Dynamically insert the alert (via `v-if`) after user action or data load to guarantee announcement. + +> [!WARNING] +> Do not auto-dismiss alerts. The WAI-ARIA spec notes that alerts that disappear automatically violate WCAG 2.0 criterion 2.2.3 (No Timing). If you need ephemeral, auto-dismissing feedback, use [Snackbar](/components/semantic/snackbar) instead. + +## Accessibility + +### ARIA roles and attributes + +| Attribute | Element | Value | Purpose | +|---|---|---|---| +| `role` | `Alert.Root` | `"alert"` | Declares a live region with `aria-live="assertive"` implicit semantics | +| `aria-labelledby` | `Alert.Root` | `{id}-title` | Links root to `Alert.Title` for accessible name | +| `aria-describedby` | `Alert.Root` | `{id}-description` | Links root to `Alert.Description` for supplementary text | +| `id` | `Alert.Title` | `{id}-title` | Target for `aria-labelledby` | +| `id` | `Alert.Description` | `{id}-description` | Target for `aria-describedby` | +| `aria-hidden` | `Alert.Icon` | `"true"` | Hides decorative icon from screen reader tree | +| `type` | `Alert.Action` | `"button"` | Prevents accidental form submission | +| `aria-label` | `Alert.Action` | locale `Alert.dismiss` | Accessible name for icon-only dismiss buttons | + +### Screen reader behavior + +`role="alert"` has implicit `aria-live="assertive"` and `aria-atomic="true"` in all major browsers. When an alert enters or changes in the DOM, the entire alert content is announced immediately, interrupting any in-progress announcements. + +For non-urgent status messages (e.g. "saved successfully"), prefer [Snackbar](/components/semantic/snackbar) which uses `aria-live="polite"` and does not interrupt. + +### Icon accessibility + +`Alert.Icon` renders `aria-hidden="true"` by default. If your icon communicates meaning beyond what the text already conveys (e.g. an icon that is the *only* indicator of severity), remove `aria-hidden` and provide a visible or screen-reader-only label instead: + +```vue + +``` + + diff --git a/apps/docs/src/typed-router.d.ts b/apps/docs/src/typed-router.d.ts index b25560952..99ba978aa 100644 --- a/apps/docs/src/typed-router.d.ts +++ b/apps/docs/src/typed-router.d.ts @@ -272,6 +272,13 @@ declare module 'vue-router/auto-routes' { Record, | never >, + '/components/semantic/alert': RouteRecordInfo< + '/components/semantic/alert', + '/components/semantic/alert', + Record, + Record, + | never + >, '/components/semantic/avatar': RouteRecordInfo< '/components/semantic/avatar', '/components/semantic/avatar', @@ -1265,6 +1272,12 @@ declare module 'vue-router/auto-routes' { views: | never } + 'src/pages/components/semantic/alert.md': { + routes: + | '/components/semantic/alert' + views: + | never + } 'src/pages/components/semantic/avatar.md': { routes: | '/components/semantic/avatar' diff --git a/dev/src/components.d.ts b/dev/src/components.d.ts index 5b3969f93..9720b79bb 100644 --- a/dev/src/components.d.ts +++ b/dev/src/components.d.ts @@ -11,6 +11,8 @@ export {} /* prettier-ignore */ declare module 'vue' { export interface GlobalComponents { + AlertAction: typeof import('./../../packages/0/src/components/Alert/AlertAction.vue')['default'] + AlertDescription: typeof import('./../../packages/0/src/components/Alert/AlertDescription.vue')['default'] AlertDialogAction: typeof import('./../../packages/0/src/components/AlertDialog/AlertDialogAction.vue')['default'] AlertDialogActivator: typeof import('./../../packages/0/src/components/AlertDialog/AlertDialogActivator.vue')['default'] AlertDialogCancel: typeof import('./../../packages/0/src/components/AlertDialog/AlertDialogCancel.vue')['default'] @@ -19,6 +21,9 @@ declare module 'vue' { AlertDialogDescription: typeof import('./../../packages/0/src/components/AlertDialog/AlertDialogDescription.vue')['default'] AlertDialogRoot: typeof import('./../../packages/0/src/components/AlertDialog/AlertDialogRoot.vue')['default'] AlertDialogTitle: typeof import('./../../packages/0/src/components/AlertDialog/AlertDialogTitle.vue')['default'] + AlertIcon: typeof import('./../../packages/0/src/components/Alert/AlertIcon.vue')['default'] + AlertRoot: typeof import('./../../packages/0/src/components/Alert/AlertRoot.vue')['default'] + AlertTitle: typeof import('./../../packages/0/src/components/Alert/AlertTitle.vue')['default'] AspectRatio: typeof import('./../../packages/0/src/components/AspectRatio/AspectRatio.vue')['default'] Atom: typeof import('./../../packages/0/src/components/Atom/Atom.vue')['default'] AvatarFallback: typeof import('./../../packages/0/src/components/Avatar/AvatarFallback.vue')['default'] @@ -95,6 +100,9 @@ declare module 'vue' { NumberFieldIncrement: typeof import('./../../packages/0/src/components/NumberField/NumberFieldIncrement.vue')['default'] NumberFieldRoot: typeof import('./../../packages/0/src/components/NumberField/NumberFieldRoot.vue')['default'] NumberFieldScrub: typeof import('./../../packages/0/src/components/NumberField/NumberFieldScrub.vue')['default'] + OverflowIndicator: typeof import('./../../packages/0/src/components/Overflow/OverflowIndicator.vue')['default'] + OverflowItem: typeof import('./../../packages/0/src/components/Overflow/OverflowItem.vue')['default'] + OverflowRoot: typeof import('./../../packages/0/src/components/Overflow/OverflowRoot.vue')['default'] PaginationEllipsis: typeof import('./../../packages/0/src/components/Pagination/PaginationEllipsis.vue')['default'] PaginationFirst: typeof import('./../../packages/0/src/components/Pagination/PaginationFirst.vue')['default'] PaginationItem: typeof import('./../../packages/0/src/components/Pagination/PaginationItem.vue')['default'] diff --git a/dev/src/plugins/index.ts b/dev/src/plugins/index.ts index bdc2bd67d..6b2da61f9 100644 --- a/dev/src/plugins/index.ts +++ b/dev/src/plugins/index.ts @@ -1,3 +1,6 @@ +// Framework +import { Vuetify0DateAdapter } from '@vuetify/v0/date' + // Types import type { App } from 'vue' @@ -16,6 +19,11 @@ export function registerPlugins (app: App) { app.use(createLoggerPlugin()) + app.use(createDatePlugin({ + adapter: new Vuetify0DateAdapter(), + locales: { en: 'en-US' }, + })) + app.use( createBreakpointsPlugin({ // diff --git a/packages/0/README.md b/packages/0/README.md index b6c3f4fd8..940eebccb 100644 --- a/packages/0/README.md +++ b/packages/0/README.md @@ -136,6 +136,7 @@ import { ... } from '@vuetify/v0/date' // Date adapter and utilities | Component | Description | |-----------|-------------| +| [Alert](https://0.vuetifyjs.com/components/semantic/alert) | Inline status banner with icon, title, description, and dismiss action | | [Avatar](https://0.vuetifyjs.com/components/semantic/avatar) | Image/fallback avatar with priority loading | | [Breadcrumbs](https://0.vuetifyjs.com/components/semantic/breadcrumbs) | Navigation breadcrumbs with overflow detection and truncation | | [Carousel](https://0.vuetifyjs.com/components/semantic/carousel) | Scroll-snap slide navigation with multi-slide display and drag/swipe | diff --git a/packages/0/src/components/Alert/AlertAction.vue b/packages/0/src/components/Alert/AlertAction.vue new file mode 100644 index 000000000..c4698f42e --- /dev/null +++ b/packages/0/src/components/Alert/AlertAction.vue @@ -0,0 +1,80 @@ +/** + * @module AlertAction + * + * @see https://0.vuetifyjs.com/components/semantic/alert + * + * @remarks + * Dismiss button for alert banners. Calls dismiss() from the alert context when + * clicked, which sets v-model to false. Receives an accessible label from the + * locale system so it announces correctly to screen readers even when rendered + * as an icon-only button. + */ + + + + + + diff --git a/packages/0/src/components/Alert/AlertDescription.vue b/packages/0/src/components/Alert/AlertDescription.vue new file mode 100644 index 000000000..ec54d0d5e --- /dev/null +++ b/packages/0/src/components/Alert/AlertDescription.vue @@ -0,0 +1,65 @@ +/** + * @module AlertDescription + * + * @see https://0.vuetifyjs.com/components/semantic/alert + * + * @remarks + * Description component for alert banners. Renders with the ID that Alert.Root + * references in its aria-describedby attribute, associating the body text with + * the alert container for screen readers. + */ + + + + + + diff --git a/packages/0/src/components/Alert/AlertIcon.vue b/packages/0/src/components/Alert/AlertIcon.vue new file mode 100644 index 000000000..692eb5c47 --- /dev/null +++ b/packages/0/src/components/Alert/AlertIcon.vue @@ -0,0 +1,68 @@ +/** + * @module AlertIcon + * + * @see https://0.vuetifyjs.com/components/semantic/alert + * + * @remarks + * Icon slot for alert banners. Renders with aria-hidden="true" by default because + * alert icons are decorative — the semantic meaning is carried by the text content. + * Pass aria-label to make the icon meaningful to screen readers when it communicates + * information beyond what the text already conveys. + */ + + + + + + diff --git a/packages/0/src/components/Alert/AlertRoot.vue b/packages/0/src/components/Alert/AlertRoot.vue new file mode 100644 index 000000000..3a6bbd891 --- /dev/null +++ b/packages/0/src/components/Alert/AlertRoot.vue @@ -0,0 +1,119 @@ +/** + * @module AlertRoot + * + * @see https://0.vuetifyjs.com/components/semantic/alert + * + * @remarks + * Root component for inline alert banners. Renders with `role="alert"` so screen + * readers announce the content automatically when it enters the DOM. Manages optional + * dismiss state via v-model — set to false to hide, or call dismiss() from slot props. + */ + + + + + + diff --git a/packages/0/src/components/Alert/AlertTitle.vue b/packages/0/src/components/Alert/AlertTitle.vue new file mode 100644 index 000000000..bb7895f8c --- /dev/null +++ b/packages/0/src/components/Alert/AlertTitle.vue @@ -0,0 +1,65 @@ +/** + * @module AlertTitle + * + * @see https://0.vuetifyjs.com/components/semantic/alert + * + * @remarks + * Title component for alert banners. Renders with the ID that Alert.Root + * references in its aria-labelledby attribute, providing an accessible name + * for screen readers. + */ + + + + + + diff --git a/packages/0/src/components/Alert/index.test.ts b/packages/0/src/components/Alert/index.test.ts new file mode 100644 index 000000000..614c3eec4 --- /dev/null +++ b/packages/0/src/components/Alert/index.test.ts @@ -0,0 +1,256 @@ +import { describe, expect, it } from 'vitest' +import { renderToString } from 'vue/server-renderer' + +// Utilities +import { mount } from '@vue/test-utils' +import { createSSRApp, defineComponent, h, ref } from 'vue' + +import { Alert } from './index' + +describe('alert', () => { + describe('root', () => { + it('should render as div by default', () => { + const wrapper = mount(Alert.Root) + expect(wrapper.element.tagName).toBe('DIV') + }) + + it('should have role=alert', () => { + const wrapper = mount(Alert.Root) + expect(wrapper.attributes('role')).toBe('alert') + }) + + it('should have aria-labelledby pointing to title', () => { + const wrapper = mount(Alert.Root, { props: { id: 'test-alert' } }) + expect(wrapper.attributes('aria-labelledby')).toBe('test-alert-title') + }) + + it('should have aria-describedby pointing to description', () => { + const wrapper = mount(Alert.Root, { props: { id: 'test-alert' } }) + expect(wrapper.attributes('aria-describedby')).toBe('test-alert-description') + }) + + it('should expose isDismissed=false and dismiss in slot props', () => { + let slotProps: any + + mount(Alert.Root, { + slots: { + default: (props: any) => { + slotProps = props + return h('span', 'content') + }, + }, + }) + + expect(slotProps.isDismissed).toBe(false) + expect(typeof slotProps.dismiss).toBe('function') + expect(typeof slotProps.id).toBe('string') + }) + + it('should expose attrs with role, id, aria-labelledby, aria-describedby, data-state', () => { + let slotProps: any + + mount(Alert.Root, { + props: { id: 'my-alert' }, + slots: { + default: (props: any) => { + slotProps = props + return h('span', 'content') + }, + }, + }) + + expect(slotProps.attrs.role).toBe('alert') + expect(slotProps.attrs.id).toBe('my-alert') + expect(slotProps.attrs['aria-labelledby']).toBe('my-alert-title') + expect(slotProps.attrs['aria-describedby']).toBe('my-alert-description') + expect(slotProps.attrs['data-state']).toBe('visible') + }) + + it('should have data-state=visible by default', () => { + const wrapper = mount(Alert.Root) + expect(wrapper.attributes('data-state')).toBe('visible') + }) + + it('should have data-state=dismissed after dismiss', async () => { + const wrapper = mount(Alert.Root, { + slots: { + default: (props: any) => h('button', { onClick: props.dismiss }, 'Dismiss'), + }, + }) + await wrapper.find('button').trigger('click') + expect(wrapper.attributes('data-state')).toBe('dismissed') + }) + + it('should support v-model for visibility', async () => { + const visible = ref(true) + + mount(Alert.Root, { + props: { + 'modelValue': visible.value, + 'onUpdate:modelValue': (v: unknown) => { + visible.value = v as boolean + }, + }, + slots: { + default: (props: any) => h('button', { onClick: props.dismiss }, 'Dismiss'), + }, + }) + + expect(visible.value).toBe(true) + }) + + it('should set isDismissed=true and emit update:model-value=false when dismiss is called', async () => { + const visible = ref(true) + + const wrapper = mount(Alert.Root, { + props: { + 'modelValue': visible.value, + 'onUpdate:modelValue': (v: unknown) => { + visible.value = v as boolean + }, + }, + slots: { + default: (props: any) => h('button', { onClick: props.dismiss }, 'Dismiss'), + }, + }) + + await wrapper.find('button').trigger('click') + expect(visible.value).toBe(false) + }) + }) + + describe('title', () => { + it('should render as p by default', () => { + const wrapper = mount(Alert.Root, { + slots: { default: () => h(Alert.Title, {}, () => 'Title') }, + }) + expect(wrapper.findComponent(Alert.Title as any).element.tagName).toBe('P') + }) + + it('should have correct id for aria-labelledby', () => { + const wrapper = mount(Alert.Root, { + props: { id: 'test-alert' }, + slots: { default: () => h(Alert.Title, {}, () => 'Title') }, + }) + expect(wrapper.findComponent(Alert.Title as any).attributes('id')).toBe('test-alert-title') + }) + }) + + describe('description', () => { + it('should render as p by default', () => { + const wrapper = mount(Alert.Root, { + slots: { default: () => h(Alert.Description, {}, () => 'Desc') }, + }) + expect(wrapper.findComponent(Alert.Description as any).element.tagName).toBe('P') + }) + + it('should have correct id for aria-describedby', () => { + const wrapper = mount(Alert.Root, { + props: { id: 'test-alert' }, + slots: { default: () => h(Alert.Description, {}, () => 'Desc') }, + }) + expect(wrapper.findComponent(Alert.Description as any).attributes('id')).toBe('test-alert-description') + }) + }) + + describe('icon', () => { + it('should render as span by default', () => { + const wrapper = mount(Alert.Root, { + slots: { default: () => h(Alert.Icon, {}, () => '⚠') }, + }) + expect(wrapper.findComponent(Alert.Icon as any).element.tagName).toBe('SPAN') + }) + + it('should have aria-hidden=true', () => { + const wrapper = mount(Alert.Root, { + slots: { default: () => h(Alert.Icon, {}, () => '⚠') }, + }) + expect(wrapper.findComponent(Alert.Icon as any).attributes('aria-hidden')).toBe('true') + }) + }) + + describe('action', () => { + it('should render as button by default', () => { + const wrapper = mount(Alert.Root, { + slots: { default: () => h(Alert.Action, {}, () => '✕') }, + }) + expect(wrapper.findComponent(Alert.Action as any).element.tagName).toBe('BUTTON') + }) + + it('should have type=button', () => { + const wrapper = mount(Alert.Root, { + slots: { default: () => h(Alert.Action, {}, () => '✕') }, + }) + expect(wrapper.findComponent(Alert.Action as any).attributes('type')).toBe('button') + }) + + it('should have aria-label', () => { + const wrapper = mount(Alert.Root, { + slots: { default: () => h(Alert.Action, {}, () => '✕') }, + }) + expect(wrapper.findComponent(Alert.Action as any).attributes('aria-label')).toBeDefined() + }) + + it('should call dismiss on click', async () => { + const visible = ref(true) + + const wrapper = mount(Alert.Root, { + props: { + 'modelValue': visible.value, + 'onUpdate:modelValue': (v: unknown) => { + visible.value = v as boolean + }, + }, + slots: { default: () => h(Alert.Action, {}, () => '✕') }, + }) + + await wrapper.findComponent(Alert.Action as any).trigger('click') + expect(visible.value).toBe(false) + }) + }) + + describe('integration', () => { + it('should wire title, description, icon, and action together', () => { + const wrapper = mount(Alert.Root, { + props: { id: 'full-alert' }, + slots: { + default: () => [ + h(Alert.Icon, {}, () => '⚠'), + h(Alert.Title, {}, () => 'Warning'), + h(Alert.Description, {}, () => 'Something needs attention.'), + h(Alert.Action, {}, () => '✕'), + ], + }, + }) + + expect(wrapper.attributes('role')).toBe('alert') + expect(wrapper.attributes('aria-labelledby')).toBe('full-alert-title') + expect(wrapper.attributes('aria-describedby')).toBe('full-alert-description') + expect(wrapper.findComponent(Alert.Title as any).attributes('id')).toBe('full-alert-title') + expect(wrapper.findComponent(Alert.Description as any).attributes('id')).toBe('full-alert-description') + }) + }) +}) + +describe('alert SSR', () => { + it('should render to string on server without errors', async () => { + const app = createSSRApp(defineComponent({ + render: () => + h(Alert.Root as never, { id: 'ssr-alert' }, { + default: () => [ + h(Alert.Icon as never, {}, () => '⚠'), + h(Alert.Title as never, {}, () => 'Server-side alert'), + h(Alert.Description as never, {}, () => 'This was rendered on the server.'), + h(Alert.Action as never, {}, () => '✕'), + ], + }), + })) + + const html = await renderToString(app) + + expect(html).toBeTruthy() + expect(html).toContain('role="alert"') + expect(html).toContain('Server-side alert') + expect(html).toContain('This was rendered on the server.') + }) +}) diff --git a/packages/0/src/components/Alert/index.ts b/packages/0/src/components/Alert/index.ts new file mode 100644 index 000000000..6f67d0729 --- /dev/null +++ b/packages/0/src/components/Alert/index.ts @@ -0,0 +1,110 @@ +export { default as AlertAction } from './AlertAction.vue' +export { default as AlertDescription } from './AlertDescription.vue' +export { default as AlertIcon } from './AlertIcon.vue' +export { default as AlertRoot } from './AlertRoot.vue' +export { default as AlertTitle } from './AlertTitle.vue' +export { provideAlertContext, useAlertContext } from './AlertRoot.vue' +export type { AlertActionProps, AlertActionSlotProps } from './AlertAction.vue' +export type { AlertDescriptionProps, AlertDescriptionSlotProps } from './AlertDescription.vue' +export type { AlertIconProps, AlertIconSlotProps } from './AlertIcon.vue' +export type { AlertContext, AlertRootProps, AlertRootSlotProps } from './AlertRoot.vue' +export type { AlertTitleProps, AlertTitleSlotProps } from './AlertTitle.vue' + +// Components +import Action from './AlertAction.vue' +import Description from './AlertDescription.vue' +import Icon from './AlertIcon.vue' +import Root from './AlertRoot.vue' +import Title from './AlertTitle.vue' + +/** + * Alert component for inline status banners with title, description, icon, and dismiss action. + * + * @see https://0.vuetifyjs.com/components/semantic/alert + * + * @example + * ```vue + * + * + * + * ``` + */ +export const Alert = { + /** + * Root container for the alert banner. Renders with role="alert". + * + * @see https://0.vuetifyjs.com/components/semantic/alert + * + * @example + * ```vue + * + * + * + * ``` + */ + Root, + /** + * Title element providing an accessible name for the alert. + * + * @see https://0.vuetifyjs.com/components/semantic/alert + * + * @example + * ```vue + * Something went wrong + * ``` + */ + Title, + /** + * Body text of the alert, associated via aria-describedby. + * + * @see https://0.vuetifyjs.com/components/semantic/alert + * + * @example + * ```vue + * Check your connection and try again. + * ``` + */ + Description, + /** + * Decorative icon or avatar area. Renders aria-hidden="true" by default. + * + * @see https://0.vuetifyjs.com/components/semantic/alert + * + * @example + * ```vue + * + * + * + * ``` + */ + Icon, + /** + * Dismiss button that calls dismiss() from the alert context. + * + * @see https://0.vuetifyjs.com/components/semantic/alert + * + * @example + * ```vue + * + * ``` + */ + Action, +} diff --git a/packages/0/src/components/index.ts b/packages/0/src/components/index.ts index ad5f4a9ec..73bd57f83 100644 --- a/packages/0/src/components/index.ts +++ b/packages/0/src/components/index.ts @@ -1,3 +1,4 @@ +export * from './Alert' export * from './AlertDialog' export * from './AspectRatio' export * from './Atom' diff --git a/packages/0/src/maturity.json b/packages/0/src/maturity.json index 6dd5e761e..c84aebb7e 100644 --- a/packages/0/src/maturity.json +++ b/packages/0/src/maturity.json @@ -483,7 +483,8 @@ "category": "disclosure" }, "Alert": { - "level": "draft", + "level": "preview", + "since": null, "category": "semantic" }, "Avatar": {