# Notification

> Something people should know about: a toast when it happens, and a list of past ones behind the bell in the Header.

- Import: `import { Notification } from '@thekestongroup/ui'`
- Group: Overlay
- Live example: https://ui.keston.co/storybook/?path=/story/components-overlay-notification--default
- Source: https://github.com/thekestongroup/ui/blob/main/src/components/Notification/Notification.vue

## When to use

| Use a notification for | Use instead |
|---|---|
| Something that happened, especially in the background (an upload finished, a password is expiring) | The result of what someone just did on the page (show it there: a Button's status, a field's error) |
| Something people may want to come back to (it stays in the pane) | A problem that blocks the page (an alert on the page) |
| | A question that needs an answer before continuing (a dialog) |

## Anatomy

| Part | Required | Notes |
|---|---|---|
| icon | Yes | From the type, 16px, in its colour. |
| title | Yes | Short, bold. |
| description | No | A string, or a render function for **bold** text and links. |
| action | No | One small basic Button, e.g. "Change password". Closes the toast or pane. |
| time | Pane only | "4 minutes ago" (RelativeTime: the exact date and time on hover or focus), kept up to date. |
| unread dot | Pane only | 8px primary dot, and a light blue background on the row. |
| × | Yes | Toast: closes it (it stays in the pane). Pane: removes it. |

## Types

| Type | Icon | Colour | Toast stays | Announced |
|---|---|---|---|---|
| info | Info (i) | blue `#035483` | 6 seconds | When the screen reader is idle |
| success | Circle check | green `#007A16` | 6 seconds | When the screen reader is idle |
| warning | Triangle | yellow `#F1C70B`, black outline | 6 seconds | Straight away |
| error | Circle × | red `#D2042D` | Until closed | Straight away |

With an action, every type stays until closed, so people have time to use it, except compact ones (e.g. Undo), which stay 10 seconds. Errors always stay.

## Compact

One line, for quick confirmations: icon, title, the action on the right, then ×.

```ts
notify({ type: 'success', title: 'Student disabled', compact: true, action: { label: 'Undo', onSelect: undo } })
```

| Rule | Value |
|---|---|
| Width | The same as other toasts (384px), only shorter |
| Padding | 20px left (icons line up with full toasts), 6px top and bottom, 6px right beside the × (16px without one) |
| Content | Title only: the description isn't shown in the toast. In the pane it's a normal row. |
| Gaps | 12px icon to title (as in the full toast), 12px title to action |

## Properties

### notify(input, options)

| Field | Type | Default | Description |
|---|---|---|---|
| title | string | — | Required. |
| type | info \| success \| warning \| error | info | |
| description | string \| () => VNode | — | No HTML strings, so nothing in a notification can run as code. |
| action | { label, onSelect?, href? } | — | |
| compact | boolean | false | One-line toast (see Compact). |
| id | string | generated | Notifying with the same id replaces it, e.g. "Uploading…" then "Uploaded". |
| createdAt | Date | now | |
| options.toast | boolean | true | False: only add it to the pane. |
| options.duration | number (ms) | see Types | |

### Components

| Component | Property | Type | Default |
|---|---|---|---|
| Toaster | notifications | the list | the shared `useNotifications()` |
| Toaster | position | bottom-right \| top-right \| bottom-center | bottom-right |
| Toaster | maxHeight | number (px) | 320: older toasts past this collapse into a stack |
| Toaster | limit | number | 10: the most kept at once |
| Toaster | @more | event | "+3 more" was pressed (it also opens the pane) |
| NotificationPane | notifications | the list | the shared `useNotifications()` |
| NotificationPane | label | string | "Notifications" |
| NotificationPane | emptyText | string | "You're all caught up." |
| Notification | appearance | card \| row | card |
| Notification | compact | boolean | false |
| Notification | dismissible, dismissLabel | boolean, string | true, "Dismiss: {title}" |

## Sizes

| Part | Value |
|---|---|
| Toast | 384px wide (full width minus 16px each side on phones), 16px padding (20px on the left), shadow, 4px corners, 8px between toasts, 16px from the edges |
| Pane | 384px wide, as tall as fits under the Header then scrolls; heading row 44px; rows clipped to its 4px corners |
| Pane row | 16px sides, 12px top and bottom, a line between |
| Icon to text | 12px |
| Title / description | 14px / 20px; title bold black, description black 65% (bold parts black) |
| Time | 12px, black 65% |
| Action | Small basic Button, 8px above |
| Unread count | 16px red circle, white bold 12px, "99+" above 99, white ring |

## Behaviour

| Action | Result |
|---|---|
| `notify()` | Added to the top of the pane; a toast slides up from below the screen and the others move up with it (300ms, ease-out; top-right drops in 16px with a fade) |
| More than `maxHeight` of toasts | The older ones shrink into the "+5 more" pill (to 50%, fading out) as newer ones arrive, and grow back out of it when spread out or when there's room again |
| Any toasts not in view, collapsed | A "+5 more" pill (96px wide, 28px tall, 16px corners, 12px bold, white) sits 8px above the stack, counting every toast not in view: in the pill or waiting |
| Spreading out or collapsing | The pill and the "+3 more" row are one button that morphs: its position, width (96px ↔ full), height (28 ↔ 36px), corners (16 ↔ 4px) and text size (12 ↔ 14px) animate together (300ms, ease-out). Spread out, it counts only those waiting, and fades away if none are |
| Tap or click the pill | The toasts spread out (focus moves to the newest, without a focus ring), for touch screens with no hover |
| Hover the toasts, or focus in them (Tab, F8) | They spread out, 8px apart; leaving collapses them again |
| A toast closes | It fades where it is while the others slide to close the gap (300ms, ease-out); one from the pill grows out to fill the space |
| A toast has to wait (newer ones took its place) | It shrinks into "+N more" (the row when spread out, the pill when collapsed) before it goes |
| A waiting toast's turn comes | It grows out of "+N more", rather than sliding in from the edge like a new one |
| More than fit on the screen spread out (its height, less 80px for the Header and edges) | The oldest wait (not shown, timers not started); the ones in view are always the newest, in the order they came in. When spread out, a "+3 more" row (36px, bold, white) after the last toast counts just those waiting |
| "+3 more" pressed | The pane opens with everything in it; focus moves to its heading |
| Pane open (however it was opened) | Toasts showing fade away and waiting ones are dropped (they're all in the pane); new notifications go straight to the pane, with no toast |
| Hover a toast, or leave the window | Its timer pauses |
| Swipe a toast right | It closes |
| F8 | Focus jumps to the toasts |
| Open the pane | Unread ones show a dot |
| Close the pane | All marked read; the count goes |
| × in the pane | Removed; focus moves to the next one's × (or the heading) |
| Clear all | All removed; focus moves to the heading |

## Accessibility

| Part | Announced as |
|---|---|
| Bell | Button "Notifications, 3 unread" |
| Toast | Read out when it appears ("Notification, Warning: Password Expiry, Your password expires in 7 days"), politely or straight away by type |
| Toasts area | `region` "Notifications (F8)" |
| Pane | `region` named by its heading, a list of notifications |
| Each notification | `group` named by its title; the type is read before the title |
| Unread | "Unread," before the time |
| × | "Dismiss: {title}" in the pane, "Close: {title}" on a toast |

Colour is never the only signal: each type has its own icon and is said in words.

## Platform adaptation

| Platform | Implementation | Differences |
|---|---|---|
| Web / desktop (Vue) | `Notification.vue`, `Toaster.vue`, `NotificationPane.vue` (Reka UI Toast and Popover) | — |
| iOS (SwiftUI) | — | System notifications when the app is closed; a banner overlay in the app; the list in a sheet. |
| Android (Compose) | — | `Snackbar` for toasts (one at a time); the list in a bottom sheet or screen. |

## Tokens used

| Token | Used for |
|---|---|
| color blue / green / yellow / red | type icons (yellow fills the warning triangle; black outlines it) |
| color red | unread count |
| color primary | unread dot |
| color blue (5%) | unread row background |
| color black (65%) | description, time |
| color black (15%) | borders, lines |

## Do / Don't

| Do | Don't |
|---|---|
| Write a title that makes sense on its own | Put the important part only in the description |
| Give errors a way forward (an action) | Notify for every small save: show those where they happened |
| Use `id` to update one notification as something progresses | Send several notifications for the same thing |

## API (from the source)

The component's exact props, model, events, slots and types, with their documentation comments.

```ts
// Notification.vue
/**
 * One notification: an icon for its type, a title, a description, an
 * optional action button, and a close ×.
 *
 * - appearance "card" (default): a white panel with a border. On a page, or
 *   as a toast (Toaster adds the shadow).
 * - appearance "row": no border, for lists, e.g. NotificationPane. Shows the
 *   time, and a dot when unread.
 *
 * The description can be formatted with a render function, e.g.
 * `() => ['Your password expires in ', h('b', '7 days'), '.']`, or the
 * default slot.
 */
export interface NotificationProps {
  type?: NotificationType
  title: string
  description?: NotificationInput['description']
  action?: NotificationAction
  /** When it happened. Shown in the row appearance, e.g. "2 hours ago", with the exact time on hover. */
  createdAt?: Date
  /** Row appearance: show the unread dot. */
  unread?: boolean
  appearance?: 'card' | 'row'
  /**
   * Card appearance: one line, for quick confirmations, e.g. "✓ Student
   * disabled [Undo] ×". The title only (no description); the action sits on
   * the right, before the ×.
   */
  compact?: boolean
  /** Show the close ×. */
  dismissible?: boolean
  /** The ×'s accessible name. Default "Dismiss: {title}". */
  dismissLabel?: string
  /** What "now" is for the relative time. Default the live time. */
  now?: Date
  class?: HTMLAttributes['class']
}

const emit = defineEmits<{ dismiss: []; action: [] }>()

defineSlots<{
  default?(): unknown
  actions?(): unknown
}>()

// NotificationPane.vue
/**
 * The bell in the Header, with a count of unread notifications, that opens
 * a panel of past notifications: newest first, each with a × to remove it,
 * and Clear all. Put it in the Header's `trailing` slot (AppShell's
 * `header-trailing`).
 *
 * Opening it shows what's unread (a dot on each); closing it marks them all
 * read.
 */
export interface NotificationPaneProps {
  /** The list to show. Default the shared one from `useNotifications()`. */
  notifications?: Notifications
  /** Panel heading, and the bell's name. */
  label?: string
  /** Shown when there are none. */
  emptyText?: string
  ui?: { content?: HTMLAttributes['class']; trigger?: HTMLAttributes['class'] }
}

const open = defineModel<boolean>('open', { default: false })

// Toaster.vue
/**
 * Shows new notifications as toasts: cards that slide in at the edge of the
 * screen and close by themselves. Put one in the app (e.g. beside AppShell),
 * then call `notify()` from anywhere:
 *
 *   const { notify } = useNotifications()
 *   notify({ type: 'success', title: 'Saved' })
 *
 * New toasts slide in from the bottom (top for top-right) and push the
 * others along smoothly. Toasts that don't fit in `maxHeight` collapse into
 * a "+5 more" pill on top of the stack saying how many aren't in view (they
 * shrink into it, and grow out of it again); hovering the toasts, tapping the pill, or
 * keyboard focus in them (F8), spreads them all out.
 *
 * If there are more than fit on the screen spread out, the oldest wait, and
 * a "+3 more"
 * row at the end opens NotificationPane, which has them all.
 *
 * Built on Reka UI's Toast: screen readers hear each one (errors and
 * warnings straight away, others when they're idle), timers pause while the
 * pointer is over them or the window isn't focused, and they swipe away.
 *
 * Closing a toast leaves the notification in the list (NotificationPane).
 */
export interface ToasterProps {
  /** The list to show. Default the shared one from `useNotifications()`. */
  notifications?: Notifications
  /** Where they appear. top-right sits under the 48px Header. */
  position?: 'bottom-right' | 'top-right' | 'bottom-center'
  /**
   * How tall the toasts can be, spread out, before older ones collapse into
   * a stack (in px). The newest always shows in full.
   */
  maxHeight?: number
  /** The most toasts kept at once; older ones beyond this wait until one closes. */
  limit?: number
  class?: HTMLAttributes['class']
}

const emit = defineEmits<{ more: [] }>()

// notifications.ts
export type NotificationType = 'info' | 'success' | 'warning' | 'error'

export interface NotificationAction {
  label: string
  /** Run when pressed. The toast closes afterwards (the notification stays in the pane). */
  onSelect?: () => void
  /** Make the action a link instead. */
  href?: string
}

export interface NotificationInput {
  /** Generated if left out. Notifying with an id that's already there replaces it. */
  id?: string
  /** Colour and icon. Default info. */
  type?: NotificationType
  title: string
  /**
   * Text under the title. A string, or a render function for formatting,
   * e.g. `() => ['Your password expires in ', h('b', '7 days'), '.']`.
   * (No HTML strings: they'd let anything that reaches a notification run
   * as code.)
   */
  description?: string | (() => VNodeChild)
  action?: NotificationAction
  /**
   * A one-line toast: icon, title, action, × (e.g. "✓ Student disabled
   * [Undo]"). For quick confirmations; the description isn't shown in the
   * toast. In the pane it's a normal row.
   */
  compact?: boolean
  /** When it happened. Default now. */
  createdAt?: Date
  read?: boolean
}

export interface NotificationItem extends NotificationInput {
  id: string
  type: NotificationType
  createdAt: Date
  read: boolean
}

export interface ToastOptions {
  /** Pop up a toast as well as adding it to the history. Default true. */
  toast?: boolean
  /**
   * How long the toast stays, in ms. Default 6000; until closed for errors,
   * and for toasts with an action (people need time to use it); compact ones
   * with an action (Undo) stay 10 seconds.
   */
  duration?: number
}

export interface ShownToast {
  id: string
  duration: number
  item: NotificationItem
}

/** Default toast length: long enough to read; forever when there's something to do. */
export function toastDuration(item: Pick<NotificationItem, 'type' | 'action' | 'compact'>, duration?: number) { … }

/**
 * A list of notifications (newest first), and the ones currently showing as
 * toasts. Pass `items` to NotificationPane and `toasts` to Toaster, or call
 * `useNotifications()` for one shared list for the whole app.
 */
export function createNotifications(initial: NotificationInput[] = []) { … }

export type Notifications = ReturnType<typeof createNotifications>

/** One shared notification list for the whole app. */
export function useNotifications(): Notifications { … }
```
