Skip to content

Latest commit

 

History

History
2111 lines (1816 loc) · 83.5 KB

File metadata and controls

2111 lines (1816 loc) · 83.5 KB

Block contracts

Blocks are opt-in source recipes copied into the application. They compose native HTML and primitives without registering custom elements. Most are CSS-only; a block may include a small Mado helper when a repeated lifecycle cannot be expressed declaratively without duplicating application glue.

Most blocks depend only on theme. notification-region composes alert; command-palette composes the native dialog and editable combobox contracts; navigation-popover composes popover and navigation-list. The examples below also use optional primitive styles such as .mado-ui-button and .mado-ui-link. Install those items separately when their classes are wanted:

mado-ui add button form-controls

Unless an item appears in a block's dependency graph, native or application-owned control and action styles work equally well.

Field and validation

.mado-ui-field arranges one control with its explicit native label, supporting description and current validation message. The field block owns structure and message presentation; it does not create a control, validate input, move focus or announce updates.

The canonical root is a neutral container with a separate <label for>. That association continues to work when description and error nodes sit beside the label instead of inside its pointer target:

<div class="mado-ui-field" data-invalid>
  <label class="mado-ui-field-label" for="project-name">
    Project name (required)
  </label>
  <input
    id="project-name"
    class="mado-ui-control"
    name="project"
    required
    aria-invalid="true"
    aria-describedby="project-description project-error"
  />
  <p id="project-description" class="mado-ui-field-description">
    Use 3–24 lowercase letters, digits and hyphens.
  </p>
  <p id="project-error" class="mado-ui-field-error">
    Error: spaces are not allowed.
  </p>
</div>

The example composes the optional form-controls primitive. Install both items to reproduce it:

mado-ui add field form-controls

The attributes have deliberately separate jobs:

  • required, disabled and readonly remain native control attributes;
  • aria-invalid="true" exposes the control's current application-owned invalid state;
  • aria-describedby lists every currently relevant description and error ID in DOM order;
  • data-invalid changes only field presentation and never replaces aria-invalid.

Do not set invalid state merely because an untouched required control matches :invalid. Choose a lifecycle such as submit, blur or attempted step navigation. When the error becomes visible, update the text, aria-describedby, aria-invalid and data-invalid together. Remove that state when the value is accepted.

Validation announcements also belong to the application. If submission moves focus to the first invalid native control after its error is connected, the label and description provide the focus context without a competing live region. If focus deliberately remains elsewhere, a persistent role="status" can announce a non-urgent update. Reserve role="alert" for new urgent information; do not put it on every field error or combine a role with redundant aria-live.

Related choices use a native fieldset and legend:

<fieldset
  class="mado-ui-fieldset"
  aria-describedby="notification-description"
>
  <legend class="mado-ui-field-legend">Notifications</legend>
  <p
    id="notification-description"
    class="mado-ui-field-description"
  >
    Choose how release updates reach you.
  </p>

  <label class="mado-ui-check">
    <input
      class="mado-ui-checkbox"
      name="notification"
      type="radio"
      value="email"
    />
    Email
  </label>
</fieldset>

The fieldset and legend come from field; the optional native choice styles come from form-controls. Do not replace this group with a generic field and ARIA role.

Public properties:

Purpose Property
Layout --mado-ui-field-gap, --mado-ui-fieldset-gap
Label --mado-ui-field-label-color, --mado-ui-field-invalid-label-color
Description --mado-ui-field-description-color
Error --mado-ui-field-error-color, --mado-ui-field-error-accent

Form section

.mado-ui-form-section divides a native form into a thematic section with supporting copy, fields and actions. It provides a transparent wrapping layout; it does not create a form, submit data, validate controls or establish a new form owner.

The canonical root is a native section with a heading whose level follows the surrounding document:

<form action="/settings/workspace" method="post">
  <section class="mado-ui-form-section">
    <div class="mado-ui-form-section-heading">
      <h2 class="mado-ui-form-section-title">Workspace profile</h2>
      <p class="mado-ui-form-section-description">
        Choose the public name used across projects.
      </p>
    </div>

    <div class="mado-ui-form-section-content">
      <div class="mado-ui-field">
        <label class="mado-ui-field-label" for="workspace-name">
          Workspace name
        </label>
        <input
          id="workspace-name"
          class="mado-ui-control"
          name="workspace-name"
          required
        />
      </div>

      <div class="mado-ui-form-section-actions">
        <a class="mado-ui-link" href="/settings">Cancel</a>
        <button class="mado-ui-button" type="submit">
          Save changes
        </button>
      </div>
    </div>
  </section>
</form>

The HTML Standard defines section as a thematic grouping and recommends identifying it with a heading. Use a div instead when the wrapper is only visual and does not represent a distinct topic. Do not add role="region" to every form section: create a named region only when it is important enough to appear among the page's landmarks. See the native section contract.

One native form may contain several form sections. Never nest forms to give each visual section independent actions. Submit and reset buttons belong to their actual form owner and need explicit type values; a cancel destination remains a native anchor when it navigates elsewhere. Server requests, pending state, validation, focus after failure and success announcements all remain application behavior.

Use a native fieldset and legend instead of this generic heading pattern when a set of related controls needs a programmatic group label. The WAI form-group guidance describes that distinction.

Heading content, form content and actions remain in source order. The heading and content columns wrap according to their available inline size, so narrow containers and high zoom do not require a component breakpoint. The visual border does not represent a thematic break and should not be replaced with an hr.

The example composes the optional field, form-controls and button items. Install them separately when their classes are wanted:

mado-ui add button field form-controls form-section

Public properties:

Purpose Property
Layout --mado-ui-form-section-align, --mado-ui-form-section-gap, --mado-ui-form-section-padding-block
Frame --mado-ui-form-section-border, --mado-ui-form-section-color
Heading --mado-ui-form-section-heading-basis, --mado-ui-form-section-heading-gap, --mado-ui-form-section-title-size
Description --mado-ui-form-section-description-width, --mado-ui-form-section-description-color
Content --mado-ui-form-section-content-basis, --mado-ui-form-section-content-gap
Actions --mado-ui-form-section-actions-gap, --mado-ui-form-section-actions-justify

Settings row

.mado-ui-settings-row arranges one explicitly labelled native control beside its supporting description. The row is a layout container, not a control: click, keyboard, validation and form behavior continue to belong to the native input, select or textarea.

<div class="mado-ui-settings-row">
  <div class="mado-ui-settings-row-content">
    <label
      class="mado-ui-settings-row-label"
      for="release-summary"
    >
      Weekly release summary
    </label>
    <p
      id="release-summary-description"
      class="mado-ui-settings-row-description"
    >
      Receive one email after the final deployment each week.
    </p>
  </div>

  <div class="mado-ui-settings-row-control">
    <input
      id="release-summary"
      class="mado-ui-checkbox"
      name="release-summary"
      type="checkbox"
      aria-describedby="release-summary-description"
    />
  </div>
</div>

The for value must match the unique control ID. This native association provides the accessible name and lets activation of the visible label reach the control without a row-level click handler. aria-describedby connects the optional supporting copy; omit both the attribute and description when that copy is absent. See the HTML label element and the WAI form-label guidance.

Keep exactly one setting control in the control container. Related checkboxes or radio buttons require a native fieldset and legend; a larger group of fields belongs in a form section. An action that is not a setting control remains a native button or link in a more appropriate action layout.

Put required, disabled, readonly and current validation attributes on the native control. Do not mirror them onto the row, lower the opacity of the whole row or use aria-disabled to imitate native disabled behavior. Never make the entire row a clickable div, and do not replace the checkbox with a visual switch unless its implementation has complete native-equivalent semantics and keyboard behavior.

Content precedes the control in DOM order. The wrapping flex layout keeps that order when the row becomes narrow, while the control remains in the form's normal Tab sequence. The optional form-controls item styles the checkbox in the example but is not a registry dependency:

mado-ui add form-controls settings-row

Public properties:

Purpose Property
Layout --mado-ui-settings-row-align, --mado-ui-settings-row-justify, --mado-ui-settings-row-gap, --mado-ui-settings-row-padding-block
Frame --mado-ui-settings-row-border, --mado-ui-settings-row-color
Content --mado-ui-settings-row-content-basis, --mado-ui-settings-row-content-gap
Label --mado-ui-settings-row-label-color, --mado-ui-settings-row-label-size
Description --mado-ui-settings-row-description-color

Filter and action bar

.mado-ui-filter-bar keeps a filtering workflow and related collection actions on one responsive line when space permits. Its native structure separates the search landmark, GET form and page-level actions; the recipe only supplies a transparent wrapping layout.

<div class="mado-ui-filter-bar">
  <search
    class="mado-ui-filter-bar-search"
    aria-label="Filter projects"
  >
    <form
      class="mado-ui-filter-bar-form"
      action="/projects"
      method="get"
    >
      <div class="mado-ui-filter-bar-fields">
        <div class="mado-ui-field">
          <label
            class="mado-ui-field-label"
            for="project-query"
          >
            Search
          </label>
          <input
            id="project-query"
            class="mado-ui-control"
            name="query"
            type="search"
          />
        </div>

        <div class="mado-ui-field">
          <label
            class="mado-ui-field-label"
            for="project-status"
          >
            Status
          </label>
          <select
            id="project-status"
            class="mado-ui-control"
            name="status"
          >
            <option value="">All statuses</option>
            <option value="active">Active</option>
            <option value="paused">Paused</option>
          </select>
        </div>
      </div>

      <div class="mado-ui-filter-bar-submit">
        <button class="mado-ui-button" type="submit">
          Apply filters
        </button>
        <a class="mado-ui-link" href="/projects">
          Clear filters
        </a>
      </div>
    </form>
  </search>

  <div
    class="mado-ui-filter-bar-actions"
    role="group"
    aria-label="Project actions"
  >
    <a class="mado-ui-link" href="/projects/new">
      Create project
    </a>
    <button class="mado-ui-button" type="button">
      Export results
    </button>
  </div>
</div>

The native search element represents controls or content used for searching or filtering. Give multiple search landmarks on one page distinct accessible names. The nested form does not need its own landmark name: adding one would expose a redundant form landmark inside the named search landmark.

The default progressive-enhancement contract is a real method="get" form. Every successful control has a stable name, the text query uses type="search", and visible label elements use for values matching unique control IDs. Enter in a text field and the explicit submit button therefore retain native submission behavior, while the result URL remains bookmarkable and shareable.

The browser's clear affordance inside an input[type="search"] only edits that control; it does not submit the form. Likewise, type="reset" restores initial control values but neither removes current query parameters nor applies the new values. A link to the collection's base URL is the honest progressive action for “Clear filters”. A client-side application may instead implement clear-and-submit behavior, but it owns synchronization between controls, URL state, results and history.

Collection actions stay outside the filter form. Navigation remains a native link, while an in-place command uses button type="button" so it cannot submit filters accidentally. The optional role="group" is useful only when the collection actions benefit from a group name; omit both the role and name otherwise.

Neither the root nor its action group is an ARIA toolbar. All links, buttons and controls remain in ordinary Tab order, and native inputs and selects keep their own arrow-key behavior. Use the separate toolbar block only when the content is actually a general action layout; implementing role="toolbar" also requires application-owned roving focus and arrow-key navigation.

Applications may apply filters immediately from input and change events, as the Mado site does with URL query signals. That behavior is not part of this CSS item. Avoid unexpected changes of context, keep focus stable, and make any dynamic result count or loading announcement part of the results' lifecycle. See WCAG guidance for changes on input.

The DOM order is fields, submit/clear actions, then collection actions. Every group wraps without order, reverse directions or a horizontal overflow shell, preserving that reading and focus order at narrow widths and enlarged text. This supports the WCAG reflow objective.

The example composes the optional field, form-controls and button items. The filter bar itself depends only on theme:

mado-ui add button field filter-bar form-controls

Public properties:

Purpose Property
Root --mado-ui-filter-bar-align, --mado-ui-filter-bar-color, --mado-ui-filter-bar-gap
Form --mado-ui-filter-bar-form-gap
Fields --mado-ui-filter-bar-fields-gap, --mado-ui-filter-bar-field-basis
Submit actions --mado-ui-filter-bar-submit-gap
Collection actions --mado-ui-filter-bar-actions-gap

Command palette

.mado-ui-command-palette presents a searchable command accelerator inside a native modal dialog. The block adds only layout and appearance: native dialog owns modality, focus containment, Escape and return values, while the editable combobox primitive owns suggestion filtering and selection. Commands, shortcuts, navigation, local actions and announcements remain application behavior.

Install the block and its complete dependency graph with one command:

mado-ui add command-palette

That copies theme, form-controls, combobox, dialog and command-palette in dependency order. Import the copied styles and initialize the combobox helper from application code; there is deliberately no separate command-palette helper.

The trigger may use the native declarative command contract. Keep a feature-detected showModal() fallback while browser support is uneven:

<button
  type="button"
  commandfor="project-command-palette"
  command="show-modal"
  aria-keyshortcuts="Control+K Meta+K"
>
  Open command palette
  <kbd class="mado-ui-command-palette-shortcut">Ctrl/⌘K</kbd>
</button>

<dialog
  id="project-command-palette"
  class="mado-ui-command-palette"
  aria-labelledby="project-command-palette-title"
  aria-describedby="project-command-palette-description"
>
  <header class="mado-ui-command-palette-header">
    <div>
      <h2
        id="project-command-palette-title"
        class="mado-ui-command-palette-title"
      >
        Run a command
      </h2>
      <p id="project-command-palette-description">
        Search the commands available in this project.
      </p>
    </div>

    <form method="dialog">
      <button
        class="mado-ui-command-palette-close"
        type="submit"
        value="close"
        aria-label="Close command palette"
      >
        <span aria-hidden="true">×</span>
      </button>
    </form>
  </header>

  <form
    class="mado-ui-command-palette-content"
    action="/commands"
    method="get"
  >
    <div class="mado-ui-command-palette-search">
      <label
        class="mado-ui-command-palette-label"
        for="project-command-query"
      >
        Command
      </label>

      <div class="mado-ui-combobox">
        <input
          id="project-command-query"
          class="mado-ui-control mado-ui-combobox-input"
          name="command"
          type="search"
          list="project-command-options"
          aria-describedby="project-command-hint"
          autocomplete="off"
          autofocus
        />
        <button
          class="mado-ui-combobox-toggle"
          type="button"
          tabindex="-1"
          popovertarget="project-command-listbox"
          aria-label="Show available commands"
          hidden
        >
          <span aria-hidden="true">⌄</span>
        </button>
        <datalist id="project-command-options">
          <option
            value="Create project"
            label="Create a local project"
            data-command-id="create-project"
          ></option>
          <option
            value="View tabs"
            label="Open the tabs page"
            data-command-id="view-tabs"
          ></option>
          <option
            value="Delete project"
            label="Unavailable"
            data-command-id="delete-project"
            disabled
          ></option>
        </datalist>
        <ul
          id="project-command-listbox"
          class="mado-ui-combobox-listbox"
          popover="auto"
          aria-label="Available commands"
          hidden
        ></ul>
      </div>

      <span
        id="project-command-hint"
        class="mado-ui-command-palette-hint"
      >
        Type an exact command or use the arrow keys to choose one.
      </span>
    </div>

    <p
      class="mado-ui-command-palette-status"
      role="status"
      aria-atomic="true"
    >
      2 commands available.
    </p>

    <footer class="mado-ui-command-palette-footer">
      <p class="mado-ui-command-palette-hint">
        Escape closes suggestions first, then the dialog.
      </p>
      <button
        class="mado-ui-command-palette-submit"
        type="submit"
      >
        Run command
      </button>
    </footer>
  </form>
</dialog>

The close form and search form are siblings; never nest them. method="dialog" lets the native close button set the dialog return value without submitting a request. The search form may keep a real GET action as a useful unenhanced fallback. Client-side submission should run a command only when the normalized input value exactly matches one enabled command. Suggestions are not an authorization boundary: re-check the command ID and disabled state immediately before execution.

Keep one application-owned command collection as the source for datalist options, disabled state and execution. Store a stable command ID in data-command-id; do not infer behavior from translated labels or listbox text. Pass onSelect and onResultsChange to madoUiCombobox() to execute the original option and update the in-dialog status. Close the dialog before running a local action or Mado navigation so native focus restoration and announcement order stay predictable.

The global Ctrl/⌘K listener is also application-owned. Ignore already handled, repeated, composing and modified keystrokes, and never steal the shortcut from an input, textarea, select or editable region. Call preventDefault() only when a closed dialog can actually open, and remove the document listener when its owning Mado view disconnects. Advertise aria-keyshortcuts only when that listener is active.

Open with showModal(), not by setting open; do not add redundant role="dialog", aria-modal or tabindex to the native dialog. autofocus places initial focus on the query. While the listbox is open, the combobox consumes the first Escape and keeps focus in its input; a subsequent Escape closes the modal. Native Tab containment and focus restoration must not be reimplemented. Clear the form, active suggestion and result message before each opening and after close so stale commands cannot run.

The palette must remain an accelerator, not the only way to reach critical actions. Preserve ordinary links, buttons and forms elsewhere in the application. The dialog contract and declarative command contract define the native behavior composed here.

Public properties:

Purpose Property
Dialog frame --mado-ui-command-palette-inline-size, --mado-ui-command-palette-max-block-size, --mado-ui-command-palette-background, --mado-ui-command-palette-color, --mado-ui-command-palette-border, --mado-ui-command-palette-radius, --mado-ui-command-palette-shadow, --mado-ui-command-palette-backdrop
Header --mado-ui-command-palette-header-gap, --mado-ui-command-palette-header-padding, --mado-ui-command-palette-title-size
Content and search --mado-ui-command-palette-content-gap, --mado-ui-command-palette-content-padding, --mado-ui-command-palette-search-gap, --mado-ui-command-palette-label-color
Results --mado-ui-command-palette-results-inline-size, --mado-ui-command-palette-results-max-block-size, --mado-ui-command-palette-status-color
Footer --mado-ui-command-palette-footer-gap, --mado-ui-command-palette-footer-padding-block-start, --mado-ui-command-palette-hint-color
Controls --mado-ui-command-palette-control-height, --mado-ui-command-palette-control-padding, --mado-ui-command-palette-control-border, --mado-ui-command-palette-control-radius, --mado-ui-command-palette-focus, --mado-ui-command-palette-focus-ring, --mado-ui-command-palette-disabled-opacity
Close action --mado-ui-command-palette-close-background, --mado-ui-command-palette-close-color, --mado-ui-command-palette-close-hover-background, --mado-ui-command-palette-close-hover-color
Submit action --mado-ui-command-palette-submit-background, --mado-ui-command-palette-submit-color, --mado-ui-command-palette-submit-hover-background, --mado-ui-command-palette-submit-hover-color
Shortcut --mado-ui-command-palette-shortcut-background, --mado-ui-command-palette-shortcut-border, --mado-ui-command-palette-shortcut-color, --mado-ui-command-palette-shortcut-shadow
Compact layout --mado-ui-command-palette-compact-padding

Breadcrumbs

.mado-ui-breadcrumbs presents the path to the current page as a labelled native navigation landmark and ordered list. The application owns every URL, the localized landmark name and the current-page state:

<nav class="mado-ui-breadcrumbs" aria-label="Breadcrumb">
  <ol class="mado-ui-breadcrumbs-list">
    <li class="mado-ui-breadcrumbs-item">
      <a class="mado-ui-breadcrumbs-link" href="/">Home</a>
    </li>
    <li class="mado-ui-breadcrumbs-item">
      <a class="mado-ui-breadcrumbs-link" href="/projects">Projects</a>
    </li>
    <li class="mado-ui-breadcrumbs-item">
      <span aria-current="page">Mado site</span>
    </li>
  </ol>
</nav>

Use either a localized aria-label or aria-labelledby; a page with several navigation landmarks needs a unique name for each one. Keep the hierarchical source order in an <ol>. Previous levels are ordinary anchors with real href values. Exactly one item identifies the current page with aria-current="page", normally the final item. A non-link current label keeps it out of the Tab order; use an anchor instead only when navigating to the same page is genuinely useful.

The separator is a presentation-only logical border on each item after the first. Do not add / or > text to the DOM, and do not add menu, menuitem, roving tabindex or arrow-key behavior. Breadcrumb links retain native Tab and Enter behavior as described by the WAI-ARIA breadcrumb pattern. Long labels and the list wrap in source order instead of clipping or requiring horizontal scrolling.

Public properties:

Purpose Property
Text --mado-ui-breadcrumbs-color, --mado-ui-breadcrumbs-font-size
Spacing --mado-ui-breadcrumbs-gap, --mado-ui-breadcrumbs-row-gap
Links --mado-ui-breadcrumbs-link-color, --mado-ui-breadcrumbs-link-hover-color
Current page --mado-ui-breadcrumbs-current-color
Separator --mado-ui-breadcrumbs-separator-color

Navigation list

.mado-ui-navigation-list styles a meaningful group of page or section links. It remains a native navigation landmark containing a native list:

<nav
  class="mado-ui-navigation-list"
  aria-labelledby="project-navigation-title"
>
  <h2
    id="project-navigation-title"
    class="mado-ui-navigation-list-label"
  >
    Project
  </h2>

  <ul class="mado-ui-navigation-list-items">
    <li class="mado-ui-navigation-list-item">
      <a
        class="mado-ui-navigation-list-link"
        href="/overview"
        aria-current="page"
      >
        Overview
      </a>
    </li>
    <li class="mado-ui-navigation-list-item">
      <a class="mado-ui-navigation-list-link" href="/activity">
        Activity
      </a>
    </li>
  </ul>
</nav>

Use <nav> only when the links form a significant navigation region. Its visible label can be any heading level required by the surrounding document; connect that heading with aria-labelledby. An aria-label is suitable when no visible heading belongs in the design. Use <ul> when order is irrelevant and <ol> when the sequence itself carries meaning.

Set aria-current="page" for the current page or aria-current="location" for the current location within a page. Keep one current item in each related link set. The state is visibly reinforced with background, border and font weight rather than color alone.

The default layout is vertical. Set data-layout="horizontal" on the root for a wrapping row:

<nav
  class="mado-ui-navigation-list"
  data-layout="horizontal"
  aria-label="Documentation"
>
  <ul class="mado-ui-navigation-list-items">
    <!-- Native list items and anchors. -->
  </ul>
</nav>

Horizontal changes presentation only. Links stay in source and Tab order, Enter follows the focused link, Space retains browser scrolling behavior and arrow keys do not move focus. Do not replace this contract with menu, menubar, menuitem, tree, aria-selected or roving tabindex; ordinary site navigation uses the native structure in the WAI navigation menu guidance. Both layouts wrap long content without visually reordering it.

Public properties:

Purpose Property
Layout --mado-ui-navigation-list-gap, --mado-ui-navigation-list-items-gap
Label --mado-ui-navigation-list-label-color
Link size --mado-ui-navigation-list-link-min-height, --mado-ui-navigation-list-link-gap, --mado-ui-navigation-list-link-padding, --mado-ui-navigation-list-link-radius
Link color --mado-ui-navigation-list-link-color, --mado-ui-navigation-list-link-hover-background, --mado-ui-navigation-list-link-hover-color
Current location --mado-ui-navigation-list-current-background, --mado-ui-navigation-list-current-border, --mado-ui-navigation-list-current-color
Focus --mado-ui-navigation-list-focus-ring

Navigation popover

navigation-popover composes the native Popover API with one or more ordinary navigation lists. It is intended for responsive application navigation that opens from a compact trigger but may contain many or dynamically rendered links. Its header and footer stay pinned while only the navigation body scrolls, so account and session actions do not disappear at the end of a long mobile menu.

Install it once; the dependency graph also installs popover and navigation-list:

mado-ui add navigation-popover

Create one helper instance for one trigger/panel relationship. closeAt is an optional media query, normally the breakpoint where the compact trigger is replaced by visible desktop navigation:

import { html } from "@madojs/mado";
import { madoUiNavigationPopover } from "./shared/ui/mado-ui-navigation-popover.js";

const navigation = madoUiNavigationPopover({
  closeAt: "(min-width: 56rem)",
  focusAfterCloseAt: () =>
    document.querySelector<HTMLElement>("#desktop-navigation-current"),
});

html`
  <button
    id="application-navigation-trigger"
    class="mado-ui-popover-invoker mado-ui-navigation-popover-trigger"
    type="button"
    popovertarget="application-navigation"
    ref=${navigation.trigger}
  >
    Menu
  </button>

  <aside
    id="application-navigation"
    class="mado-ui-popover mado-ui-navigation-popover"
    popover="auto"
    aria-labelledby="application-navigation-title"
    ref=${navigation.panel}
  >
    <header
      class="mado-ui-popover-header mado-ui-navigation-popover-header"
    >
      <h2
        id="application-navigation-title"
        class="mado-ui-popover-title mado-ui-navigation-popover-title"
      >
        Navigation
      </h2>
    </header>

    <div
      class="mado-ui-popover-content mado-ui-navigation-popover-content"
    >
      <nav class="mado-ui-navigation-list" aria-label="Application">
        <ul class="mado-ui-navigation-list-items">
          <li class="mado-ui-navigation-list-item">
            <a
              class="mado-ui-navigation-list-link"
              href="/journeys"
              aria-current="page"
            >
              Journeys
            </a>
          </li>
          <li class="mado-ui-navigation-list-item">
            <a
              class="mado-ui-navigation-list-link"
              href="/publish"
            >
              Publish
            </a>
          </li>
        </ul>
      </nav>
    </div>

    <footer
      class="mado-ui-popover-actions mado-ui-navigation-popover-footer"
    >
      <span>Signed in as Ada</span>
      <button type="button">Sign out</button>
      <button
        class="mado-ui-popover-dismiss"
        type="button"
        popovertarget="application-navigation"
        popovertargetaction="hide"
      >
        Close
      </button>
    </footer>
  </aside>
`;

Keep the trigger a native button type="button" and connect it to the unique panel ID with popovertarget. The panel uses popover="auto" and needs its own accessible name through aria-labelledby or aria-label. Each nested nav also needs a useful name when the panel contains more than one navigation landmark. The paired elements must share one document or shadow tree; the panel ID and any referenced label IDs must resolve uniquely in that tree. This block is not a menu or dialog: do not add menuitem, roving tabindex, arrow-key navigation, modality or a focus trap. Links retain their native Tab and Enter behavior.

The helper validates and reconciles the paired Mado refs without assigning ARIA. One delegated listener on the panel closes it after an unmodified, primary-button activation of any same-context a[href] in the panel. It respects events already prevented before they reach the panel listener and does not close for downloads, disabled links, new browsing contexts, modifier-key activation or unrelated buttons and form controls. Because activation is delegated, keyed each() updates and other application-owned additions need no new refs, listeners or mutation observer.

Native auto-popover behavior owns opening, light dismissal, Escape and focus return to the invoker. Link activation deliberately does not force focus back to the trigger; the destination route owns its page-focus policy. Call the returned idempotent close() only when another application lifecycle needs it. For example, close after an asynchronous sign-out succeeds, but keep the panel open when it fails so the error and retry action remain available.

When closeAt changes from non-matching to matching, the helper closes the panel. If that desktop breakpoint also hides the focused compact invoker, provide focusAfterCloseAt to resolve a visible native link or control after the close. Omit the callback when the invoker remains visible. The helper owns one matchMedia listener and removes it with its refs; it does not infer application breakpoints or inspect rendered items.

While both refs are connected, an open panel also closes on popstate and hashchange. This covers browser back/forward, query or hash changes and routers that emit those platform events. The helper removes both global listeners when either ref disconnects.

The root's maximum size accounts for the viewport and safe-area insets. Header and footer remain fixed within that surface, while .mado-ui-navigation-popover-content owns local block-axis scrolling and overscroll containment. Navigation links span the available inline size and align their labels to the logical start, including current-page state. Keep account actions in the footer rather than appending them to the scrollable link list. A nested popover or menu starts a new surface boundary, so it keeps its own size and overflow instead of inheriting the navigation frame. At 30rem and below, the composed popover uses its viewport-centred compact placement instead of an anchor side that may be too short for a growing menu. Its block limit uses the stable small viewport, preventing mobile browser chrome from stretching or collapsing an already open navigation surface while the page scrolls.

In browsers without native popover support, the dependency stylesheet hides the inert invoker and leaves the labelled panel in normal document flow. The native links and controls therefore remain available without enhancement.

Public properties:

Purpose Property
Surface size --mado-ui-navigation-popover-inline-size, --mado-ui-navigation-popover-max-block-size
Surface spacing --mado-ui-navigation-popover-gap, --mado-ui-navigation-popover-padding, --mado-ui-navigation-popover-offset
Anchor placement --mado-ui-navigation-popover-position-area
Link row --mado-ui-navigation-popover-link-min-height
Current rail --mado-ui-navigation-popover-current-rail-width, --mado-ui-navigation-popover-current-rail
Footer --mado-ui-navigation-popover-footer-padding-block-start, --mado-ui-navigation-popover-footer-border, --mado-ui-navigation-popover-footer-justify

The composed popover and navigation-list properties remain available for their own surface, typography, state and focus styling.

Description list

.mado-ui-description-list presents related names and values with the native dl, dt and dd relationship intact. Each direct div wraps one complete name-value group for layout and styling without changing that relationship:

<dl class="mado-ui-description-list">
  <div class="mado-ui-description-list-group">
    <dt class="mado-ui-description-list-term">Repository</dt>
    <dd class="mado-ui-description-list-value">madojs/ui</dd>
  </div>

  <div class="mado-ui-description-list-group">
    <dt class="mado-ui-description-list-term">License</dt>
    <dd class="mado-ui-description-list-value">MIT</dd>
  </div>
</dl>

The HTML Standard defines a description list as an association list. A group contains one or more dt names followed by one or more dd values, and explicitly permits a div around the whole group. A dt is not necessarily a dictionary term; use dfn inside it only when the content really is being defined. See the description-list model in the HTML Standard and the WAI description-list guidance.

The one-name, one-value pair is the common compact layout, but the recipe does not narrow native HTML to that case. Several names or values remain inside one group when they share one association:

<dl class="mado-ui-description-list">
  <div class="mado-ui-description-list-group">
    <dt class="mado-ui-description-list-term">Owner</dt>
    <dt class="mado-ui-description-list-term">Maintainer</dt>
    <dd class="mado-ui-description-list-value">Mado core team</dd>
    <dd class="mado-ui-description-list-value">UI working group</dd>
  </div>
</dl>

Multiple dd elements represent separate values or descriptions in the same group. Put several paragraphs that form one value inside a single dd instead. Complex groups stack every term and value in source order so their presentation does not imply false one-to-one pairs.

By default a simple pair shares a row when its container has enough space and wraps term before value when it does not. Set data-layout="stacked" on the dl to keep every simple pair vertical at all widths:

<dl class="mado-ui-description-list" data-layout="stacked">
  <div class="mado-ui-description-list-group">
    <dt class="mado-ui-description-list-term">Support window</dt>
    <dd class="mado-ui-description-list-value">
      The current minor Mado release.
    </dd>
  </div>
</dl>

The attribute changes presentation only. Do not add list, listitem, term or definition roles, focus the list, or add arrow-key behavior; native elements already expose the relationship. A heading outside the list can introduce the surrounding section when one is needed. Dynamic value formatting and announcement policy remain application behavior.

Use this block for one-dimensional metadata, glossary entries, questions and answers, and other name-value associations. Use a native table when readers must compare values across rows and columns. Use metric-card when a compact surface and prominent numerical value are part of the design, and use settings-row for an editable control. Row dividers are presentational borders, not thematic hr elements.

Values may contain ordinary flow content such as paragraphs, links, lists, time and data. Long content wraps instead of creating an overflow shell, and visual order always follows source order.

Public properties:

Purpose Property
Layout --mado-ui-description-list-gap, --mado-ui-description-list-group-padding, --mado-ui-description-list-term-width, --mado-ui-description-list-value-min
Text --mado-ui-description-list-color, --mado-ui-description-list-font-size, --mado-ui-description-list-term-color
Divider --mado-ui-description-list-divider

Metric card and grid

.mado-ui-metric-grid lays out compact .mado-ui-metric-card surfaces without depending on the general layout or panel items. A native description list is the canonical structure for related metric labels and values:

<dl class="mado-ui-metric-grid">
  <div class="mado-ui-metric-card">
    <dt class="mado-ui-metric-card-label">
      Production deployments
    </dt>
    <dd class="mado-ui-metric-card-value">
      <data value="128">128</data>
      <span class="mado-ui-metric-card-detail">
        12 more than during the previous 30 days.
      </span>
    </dd>
  </div>

  <div class="mado-ui-metric-card">
    <dt class="mado-ui-metric-card-label">
      Median build duration
    </dt>
    <dd class="mado-ui-metric-card-value">
      <time datetime="PT42S">42 s</time>
      <span class="mado-ui-metric-card-detail">
        6 seconds faster than during the previous 30 days.
      </span>
    </dd>
  </div>
</dl>

The HTML Standard defines <dl> as name-value groups and explicitly permits each dt/dd group to be wrapped in a div for styling. Keep the visible value and its supporting detail inside one dd; multiple dd elements in a group represent alternative values, not separate visual rows. See the description-list contract in the HTML Standard.

data[value] is optional when a machine-readable equivalent is useful. Use the more specific time[datetime] for dates, times and durations. Plain text remains correct when neither element adds meaning. The recipe styles the supplied markup but never calculates, formats or announces a value.

Comparisons must be complete visible text such as “12 more than the previous period”. Do not communicate direction with an arrow or color alone. The shared block deliberately has no positive, negative, increase or decrease state because the product meaning of a rising value depends on the metric. Applications may compose a separate badge when a semantic tone is truly useful.

CSS grid is presentation only. Do not add role="grid", roving tabindex or arrow-key handling to a collection of read-only values. A card is not a clickable div; add a native anchor inside its detail when navigation is needed. For live metrics, the application chooses an update and announcement lifecycle instead of placing aria-live on the entire grid.

The grid uses auto-fit and collapses to one column when its configured minimum cannot fit. Cards remain in source order and wrap long labels, values and details instead of clipping or introducing document-level scrolling.

Public properties:

Purpose Property
Grid --mado-ui-metric-grid-min, --mado-ui-metric-grid-gap, --mado-ui-metric-grid-align
Surface --mado-ui-metric-card-background, --mado-ui-metric-card-color, --mado-ui-metric-card-border, --mado-ui-metric-card-radius, --mado-ui-metric-card-shadow
Spacing --mado-ui-metric-card-padding, --mado-ui-metric-card-gap
Type --mado-ui-metric-card-label-color, --mado-ui-metric-card-value-size, --mado-ui-metric-card-detail-color

Native table shell

.mado-ui-table-shell keeps a native data table intact while containing the horizontal overflow that can be necessary at narrow widths and high zoom. The shell is an explicitly focusable, named scroll region; its accessible name reuses the table's short native caption:

<div
  class="mado-ui-table-shell"
  role="region"
  tabindex="0"
  aria-labelledby="projects-table-caption"
>
  <table class="mado-ui-table">
    <caption
      id="projects-table-caption"
      class="mado-ui-table-caption"
    >
      Projects
    </caption>

    <thead class="mado-ui-table-header">
      <tr class="mado-ui-table-row">
        <th class="mado-ui-table-head" scope="col">Project</th>
        <th
          class="mado-ui-table-head"
          scope="col"
          data-align="center"
        >
          Status
        </th>
        <th class="mado-ui-table-head" scope="col" data-align="end">
          Updated
        </th>
      </tr>
    </thead>

    <tbody class="mado-ui-table-body">
      <tr class="mado-ui-table-row">
        <th class="mado-ui-table-head" scope="row">Mado UI</th>
        <td class="mado-ui-table-cell" data-align="center">Ready</td>
        <td class="mado-ui-table-cell" data-align="end">
          <time datetime="2026-07-28">28 July 2026</time>
        </td>
      </tr>
      <tr class="mado-ui-table-row">
        <th class="mado-ui-table-head" scope="row">Mado site</th>
        <td class="mado-ui-table-cell" data-align="center">Building</td>
        <td class="mado-ui-table-cell" data-align="end">
          <time datetime="2026-07-27">27 July 2026</time>
        </td>
      </tr>
    </tbody>

    <tfoot class="mado-ui-table-footer">
      <tr class="mado-ui-table-row">
        <th class="mado-ui-table-head" scope="row">Visible projects</th>
        <td class="mado-ui-table-cell" colspan="2" data-align="end">
          2
        </td>
      </tr>
    </tfoot>
  </table>
</div>

The HTML Standard defines table for multidimensional data, and its caption as the table title. Header and data relationships stay native: use th[scope="col"] for column headers, th[scope="row"] for row headers and td for data. See the HTML table model and the WAI tables tutorial. data-align="center" and data-align="end" change only visual alignment. The header, body and footer classes style their corresponding native thead, tbody and optional tfoot elements; they do not replace them.

For grouped headers, use the native colgroup and rowgroup scope values only when they match the table model. Irregular or multi-level tables can associate cells explicitly with unique header IDs and headers. Keep the caption short. When a complex table needs orientation instructions, place the visible summary nearby and connect it to the table with aria-describedby.

The shell owns overflow, not table semantics. tabindex="0" lets keyboard users reach and scroll it, while role="region" and aria-labelledby provide context when it receives focus. Each shell needs a unique caption ID and accessible name. CSS cannot add these attributes; keep them in copied markup. The MDN overflow accessibility guidance documents this focus contract.

At 320 CSS pixels and under zoom, only the table may scroll horizontally. The surrounding page must continue to reflow, as described by WCAG's data-table Reflow exception. Cells wrap long content instead of clipping it. Do not hide “optional” columns at a breakpoint, turn rows into cards with CSS, change native table elements to block layout or visually reorder cells. Those approaches can remove data or break the relationship between a cell and its headers. Likewise, do not add role="grid", cell tabindex, roving focus or arrow-key behavior to a read-only data table. Sticky rows and columns are outside this base contract because they can obscure content and focus at high zoom.

Sorting is application behavior. When sorting is implemented, put a native button inside the sortable header and apply aria-sort to the currently sorted th:

<th
  class="mado-ui-table-head"
  scope="col"
  aria-sort="ascending"
>
  <button type="button">
    Updated
    <span aria-hidden="true">↑</span>
  </button>
</th>

The application must make the button work, reorder the rows and synchronize the visible indicator and aria-sort. Remove aria-sort from the previous header when the sort key changes, and expose it on at most one header. Unsorted headers do not need aria-sort="none". Do not use aria-pressed, a click handler on th or a non-functional sort button. The WAI sortable-table example uses the same native button and single-header state boundary.

The public class surface is exactly .mado-ui-table-shell, .mado-ui-table, .mado-ui-table-caption, .mado-ui-table-header, .mado-ui-table-body, .mado-ui-table-footer, .mado-ui-table-row, .mado-ui-table-head and .mado-ui-table-cell.

Public properties:

Purpose Property
Layout --mado-ui-table-min-inline-size, --mado-ui-table-font-size
Surface --mado-ui-table-background, --mado-ui-table-color, --mado-ui-table-border, --mado-ui-table-radius, --mado-ui-table-shadow
Scroll focus --mado-ui-table-focus-ring
Caption --mado-ui-table-caption-color, --mado-ui-table-caption-padding
Header --mado-ui-table-header-background, --mado-ui-table-header-color
Cells and rows --mado-ui-table-cell-padding, --mado-ui-table-row-border, --mado-ui-table-row-hover-background
Footer --mado-ui-table-footer-background

Pagination

.mado-ui-pagination presents a bounded set of page links in a labelled native navigation landmark. The application owns every URL, localized label, current page and page-window calculation:

<nav class="mado-ui-pagination" aria-label="Project pages">
  <ul class="mado-ui-pagination-list">
    <li class="mado-ui-pagination-item">
      <a
        class="mado-ui-pagination-link"
        href="/projects?page=1"
        rel="prev"
      >
        Previous
      </a>
    </li>
    <li class="mado-ui-pagination-item">
      <a
        class="mado-ui-pagination-link"
        href="/projects?page=1"
        aria-label="Page 1"
      >
        1
      </a>
    </li>
    <li class="mado-ui-pagination-item">
      <a
        class="mado-ui-pagination-link"
        href="/projects?page=2"
        aria-label="Page 2"
        aria-current="page"
      >
        2
      </a>
    </li>
    <li class="mado-ui-pagination-item">
      <span class="mado-ui-pagination-ellipsis" aria-hidden="true">
        …
      </span>
    </li>
    <li class="mado-ui-pagination-item">
      <a
        class="mado-ui-pagination-link"
        href="/projects?page=20"
        aria-label="Page 20"
      >
        20
      </a>
    </li>
    <li class="mado-ui-pagination-item">
      <a
        class="mado-ui-pagination-link"
        href="/projects?page=3"
        rel="next"
      >
        Next
      </a>
    </li>
  </ul>
</nav>

Use a localized aria-label or aria-labelledby that distinguishes this navigation landmark from the site's other navigation. Real page destinations remain anchors with real href values, so Tab, Enter, browser history, copy-link and open-in-new-tab behavior all remain native. rel="prev" and rel="next" describe adjacent documents in a sequence in the HTML link-type standard. Numeric aria-label values such as “Page 2” are application-owned and must be localized.

Exactly one link in the set uses aria-current="page", including when that current page remains a useful self-link. This follows the W3C pagination pattern and the WAI aria-current technique. The ellipsis only indicates an omitted range. It is never a link or button and is hidden from the accessibility tree in the canonical markup.

At the first or last page, omit an unavailable boundary link when its presence adds no value. If keeping the placeholder helps users understand the sequence, remove href and use this exact disabled contract:

<a
  class="mado-ui-pagination-link"
  role="link"
  aria-disabled="true"
>
  Previous
</a>

HTML has no disabled attribute for anchors, and aria-disabled does not cancel a working hyperlink. Never combine href with aria-disabled="true", and do not add the placeholder to the Tab order. The ARIA in HTML guidance for disabled links specifies removing href before applying the explicit disabled link role.

The list wraps in source order and remains outside the data-table Reflow exception. At narrow widths and high zoom, the application supplies a bounded window such as first, nearby and last pages while preserving useful Previous and Next links. CSS must not hide an arbitrary subset of a large page list. Every link retains a comfortable pointer target and visible focus state. Do not use menu, menubar, aria-selected, roving tabindex, arrow-key handling or a live region for ordinary page navigation. Client-side data replacement, focus policy and any update announcement are separate application behavior.

The public class surface is exactly .mado-ui-pagination, .mado-ui-pagination-list, .mado-ui-pagination-item, .mado-ui-pagination-link and .mado-ui-pagination-ellipsis.

Public properties:

Purpose Property
Layout --mado-ui-pagination-gap, --mado-ui-pagination-justify
Link size --mado-ui-pagination-link-min-size, --mado-ui-pagination-link-padding, --mado-ui-pagination-link-radius
Link surface --mado-ui-pagination-link-border, --mado-ui-pagination-link-background, --mado-ui-pagination-link-color
Link hover --mado-ui-pagination-link-hover-background, --mado-ui-pagination-link-hover-color
Current page --mado-ui-pagination-current-background, --mado-ui-pagination-current-border, --mado-ui-pagination-current-color
Disabled link --mado-ui-pagination-disabled-background, --mado-ui-pagination-disabled-border, --mado-ui-pagination-disabled-color
Focus --mado-ui-pagination-focus-ring
Ellipsis --mado-ui-pagination-ellipsis-color

Panel and card

.mado-ui-panel provides one visual surface for two related patterns:

  • a panel groups persistent controls or content within a page;
  • a card represents one self-contained item, often in a list or grid.

Choose the root element from the content, not its appearance. A labelled section suits a distinct page region, article suits independently useful content, and li suits an item in a semantic list. Use div when none of those meanings apply. The recipe does not assign a role.

<section class="mado-ui-panel" aria-labelledby="deployments-title">
  <header class="mado-ui-panel-header">
    <div class="mado-ui-panel-heading">
      <h2 id="deployments-title" class="mado-ui-panel-title">
        Deployments
      </h2>
      <p class="mado-ui-panel-description">
        Production activity from the last seven days.
      </p>
    </div>

    <div class="mado-ui-panel-actions">
      <a class="mado-ui-button" data-variant="secondary" href="/deployments">
        View all
      </a>
    </div>
  </header>

  <div class="mado-ui-panel-content">
    <!-- Native lists, forms, tables or application content. -->
  </div>

  <footer class="mado-ui-panel-footer">
    Updated five minutes ago
  </footer>
</section>

The same structure can be an independently meaningful card:

<article class="mado-ui-panel" aria-labelledby="project-title">
  <header class="mado-ui-panel-header">
    <div class="mado-ui-panel-heading">
      <h3 id="project-title" class="mado-ui-panel-title">Docs site</h3>
      <p class="mado-ui-panel-description">Ready to deploy.</p>
    </div>
  </header>

  <div class="mado-ui-panel-content">
    Last build passed in 42 seconds.
  </div>

  <footer class="mado-ui-panel-footer">
    <a class="mado-ui-link" href="/projects/docs-site">Open project</a>
  </footer>
</article>

Header, heading, description, actions, content and footer are optional. Keep heading levels consistent with the surrounding page. Do not add a click handler or link role to the whole container; use a native anchor or button for each action. If a compact card truly has one navigation target, its root may be a native anchor only when it contains no nested interactive content.

Public properties:

Purpose Property
Surface --mado-ui-panel-background, --mado-ui-panel-color
Frame --mado-ui-panel-border, --mado-ui-panel-radius, --mado-ui-panel-shadow
Spacing --mado-ui-panel-padding, --mado-ui-panel-gap, --mado-ui-panel-header-gap
Actions --mado-ui-panel-actions-gap
Footer --mado-ui-panel-footer-gap, --mado-ui-panel-footer-justify

Document content

prose provides a readable width and vertical rhythm for guides, policies, help pages and other long-form content. It is an opt-in document block, not a global typography reset. The application still chooses whether the root is article, main, section or a neutral div, and it owns the document outline and landmark names.

<article class="mado-ui-prose" aria-labelledby="handbook-title">
  <header class="mado-ui-prose-section">
    <h1 id="handbook-title">Community project handbook</h1>
    <p>
      This guide explains how maintainers review and publish changes.
    </p>
  </header>

  <section
    class="mado-ui-prose-section"
    aria-labelledby="handbook-principles"
  >
    <h2 id="handbook-principles">Principles</h2>
    <p>Prefer durable browser semantics and explicit ownership.</p>
    <ul>
      <li>Keep the source close to its maintainers.</li>
      <li>Document recovery and review expectations.</li>
    </ul>
    <blockquote>
      Documentation is part of the interface people depend on.
    </blockquote>
  </section>

  <footer class="mado-ui-prose-note">
    The application owns dates, authorship and revision metadata.
  </footer>
</article>

Only direct headings, paragraphs, lists and blockquotes inside an explicit .mado-ui-prose-section receive document typography. This boundary lets an alert, panel, navigation list or another Mado block remain a direct child of .mado-ui-prose without inheriting accidental element styles. Wrap ordinary authored flow in a prose section; keep independently styled blocks outside that wrapper. Native anchors retain their own behavior and can compose the independent button or link recipe when a different visual treatment is wanted.

Use native ul, ol and blockquote elements for their real meanings. .mado-ui-prose-note is only a separated visual note: it does not assign a contentinfo role, live-region behavior or metadata. A footer inside an article may be appropriate for document metadata, while an ordinary p suits supporting copy that is not a footer. Without the stylesheet, every element remains readable in source order.

Public properties:

Purpose Property
Width and rhythm --mado-ui-prose-max-inline-size, --mado-ui-prose-gap, --mado-ui-prose-line-height, --mado-ui-prose-section-gap
Text --mado-ui-prose-color, --mado-ui-prose-heading-color, --mado-ui-prose-muted-color
Lists --mado-ui-prose-list-gap
Quotation --mado-ui-prose-quote-border, --mado-ui-prose-quote-padding-inline
Note --mado-ui-prose-note-border, --mado-ui-prose-note-font-size

Alert

An alert is contextual feedback, not an accessibility role by default. data-tone accepts neutral, info, success, warning and danger; omitting it uses neutral.

<div class="mado-ui-alert" data-tone="warning">
  <div class="mado-ui-alert-content">
    <p class="mado-ui-alert-title">Deployment paused</p>
    <p class="mado-ui-alert-description">
      Add a production domain before deploying.
    </p>
  </div>

  <div class="mado-ui-alert-actions">
    <a class="mado-ui-button" data-variant="secondary" href="/domains">
      Configure domain
    </a>
  </div>
</div>

The visible title and description must communicate the state by themselves. Color, an accent border or an icon can reinforce meaning, but cannot be its only signal. data-tone changes presentation only: it never assigns an ARIA role, live-region behavior or urgency.

Choose announcement behavior from when and why the message appears:

  • For feedback already present when the page loads, use ordinary semantic HTML without a live-region role.
  • For a non-urgent update, keep a role="status" container in the DOM before the update and replace its text when the state changes. This announces politely without moving focus.
  • Use role="alert" only for a new, urgent message that warrants an assertive interruption. Do not use it for routine success, neutral guidance or every validation message.

Do not add redundant aria-live to status or alert, and do not choose role="alert" merely because data-tone="danger" is used. A dismiss action is application behavior: render a labelled native button, remove the message only after activation, and place focus somewhere logical if the focused control is removed with it.

Public properties:

Purpose Property
Surface --mado-ui-alert-background, --mado-ui-alert-color
Frame --mado-ui-alert-border, --mado-ui-alert-accent, --mado-ui-alert-radius
Spacing --mado-ui-alert-padding, --mado-ui-alert-gap, --mado-ui-alert-actions-gap

Notification region

notification-region is an ordinary in-flow stack for application-owned updates. It composes the existing alert block instead of defining a second message surface, and it deliberately separates announcement text from the visual message list:

<div class="mado-ui-notification-region">
  <!-- Mount this empty node before the first dynamic update. -->
  <p
    class="mado-ui-notification-announcer"
    role="status"
    aria-atomic="true"
  ></p>

  <ol class="mado-ui-notification-list" aria-label="Notifications">
    <li class="mado-ui-notification-item">
      <div class="mado-ui-alert" data-tone="success">
        <div class="mado-ui-alert-content">
          <p class="mado-ui-alert-title">Project saved</p>
          <p class="mado-ui-alert-description">
            The latest settings are available to the team.
          </p>
        </div>

        <div class="mado-ui-alert-actions">
          <button
            type="button"
            aria-label="Dismiss project saved notification"
          >
            Dismiss
          </button>
        </div>
      </div>
    </li>
  </ol>
</div>

When the application adds that list item, it separately replaces the stable announcer's text with one concise update:

<p
  class="mado-ui-notification-announcer"
  role="status"
  aria-atomic="true"
>
  Project saved. The latest settings are available to the team.
</p>

The visual ol and its li children remain ordinary list semantics. Do not put role="status", role="alert" or aria-live on the list, an item or its .mado-ui-alert: the dedicated announcer owns the one polite announcement. Its status role already implies aria-live="polite"; do not add the redundant attribute. aria-atomic="true" makes each replacement one coherent message. Give the ordered list a concise accessible name when its surrounding content does not already label it.

Install the block with:

mado-ui add notification-region

The dependency graph installs theme, alert and notification-region. The optional dismiss button may use the independent button recipe, but it is not required by the region.

The public class surface is exactly .mado-ui-notification-region, .mado-ui-notification-announcer, .mado-ui-notification-list and .mado-ui-notification-item; each visible item composes one .mado-ui-alert. Put the native hidden attribute on the ordered list while the application has no visual messages, and remove the attribute when the first item is rendered. The stable announcer remains mounted either way.

The region and list classes own only normal-flow sizing, spacing and local overflow. They do not create a queue, insert or remove alerts, assign ARIA, announce list mutations, move focus or intercept keyboard input. The announcer class supplies presentation for the authored live node, while its role, text and update lifecycle remain application-owned.

Notifications are persistent by default. Application state decides when a message is complete and removes its li; this first contract has no automatic expiry or timer policy. Adding a notification never moves focus. Native links and buttons inside an alert remain in normal Tab order. If a focused dismiss button removes its own item, move focus to the action that opened it or another stable, logical target.

The announcer communicates a concise change, while the visual alert may keep longer explanation and actions available. Do not duplicate the same update through another live region. An urgent interruption belongs in a separate, pre-existing role="alert" node or a modal response; data-tone="danger" changes appearance only.

Without the stylesheet, the announcer and ordered list retain their authored semantics and the alerts remain in normal document flow. Keep one stable region at the relevant application-shell or page location instead of mounting an independent region for every feature.

This lifecycle follows the W3C role="status" technique: the live node exists before its text changes and receives no focus. Persistent messages also avoid silently imposing the reading time discussed by WCAG Timing Adjustable.

Public properties:

Purpose Property
Region width --mado-ui-notification-inline-size
List size and spacing --mado-ui-notification-max-block-size, --mado-ui-notification-gap

Content state

content-state is one visual block for the current state of a content area. Empty, loading, completed, constrained and error states are not separate registry items: they share layout but have different application-owned data, actions and announcement lifecycles. The block adds no JavaScript, request state machine, focus behavior or live-region role.

The public class surface is exactly .mado-ui-content-state, .mado-ui-content-state-media, .mado-ui-content-state-content, .mado-ui-content-state-title, .mado-ui-content-state-description and .mado-ui-content-state-actions. All child elements are optional. data-tone accepts neutral, info, success, warning and danger; omitting it uses neutral. Tone changes presentation only. In particular, data-tone="success" does not imply role="status", and data-tone="warning" or data-tone="danger" does not imply role="alert". Media that only reinforces the title is decorative and uses aria-hidden="true". If an image adds information that the text does not, keep it exposed and supply an appropriate native text alternative under WCAG Non-text Content.

The root is normally a generic div inside the stable content region that owns the resource heading and aria-busy state. Use a native section only when the state itself is a thematic document section that belongs in the outline; section is not a generic styling container in the HTML Standard. Heading level and landmark choice come from the surrounding page.

Empty data and empty results

An initial empty collection is ordinary document content. It needs a clear heading, explanation and useful native action, but no live-region role:

<section aria-labelledby="projects-title">
  <h2 id="projects-title">Projects</h2>

  <div class="mado-ui-content-state">
    <span class="mado-ui-content-state-media" aria-hidden="true">
      ∅
    </span>
    <div class="mado-ui-content-state-content">
      <h3 class="mado-ui-content-state-title">No projects yet</h3>
      <p class="mado-ui-content-state-description">
        Create a project to publish your first Mado application.
      </p>
    </div>
    <div class="mado-ui-content-state-actions">
      <a class="mado-ui-button" href="/projects/new">Create project</a>
    </div>
  </div>
</section>

The optional button item styles the anchor but is not a registry dependency. Use an anchor with a real href when the action navigates and a native button type="button" when it changes the current interface.

“No data yet” and “no results match these filters” are different product states even though they use the same block. The application owns the query, count, copy and recovery action: a first-use state might create or import data, while an empty search should normally clear or adjust filters. When zero results appear dynamically without navigation or focus movement, update a persistent role="status" with a localized message such as “No projects match the current filters”. WCAG explicitly identifies dynamic “No results” text as a status message. Keep focus on the search field, filter or action that initiated the update; do not make the empty block focusable.

Initial loading

Put aria-busy="true" on the stable region being updated, not on the spinner. The spinner remains decorative because the visible loading text supplies the meaning:

<section
  id="project-results"
  aria-labelledby="project-results-title"
  aria-busy="true"
>
  <h2 id="project-results-title">Projects</h2>

  <div class="mado-ui-content-state" data-tone="info">
    <span class="mado-ui-content-state-media" aria-hidden="true">
      <span class="mado-ui-spinner" aria-hidden="true"></span>
    </span>
    <div class="mado-ui-content-state-content">
      <h3 class="mado-ui-content-state-title">Loading projects…</h3>
      <p class="mado-ui-content-state-description">
        Recent deployments will appear when the request completes.
      </p>
    </div>
  </div>
</section>

<!-- Present before any client-side request and outside the busy region. -->
<p id="project-results-status" role="status" aria-atomic="true"></p>

The optional spinner item supplies only the ring. Its wrapper is aria-hidden="true" so the visual is not exposed as an unlabeled control. Static loading content delivered with the initial document is not a content change and does not need a live announcement. The empty status node is useful for later client-side loads and refreshes; it must already exist before its text is changed. The official role="status" technique uses the same persistent-container lifecycle. status is polite and atomic by default and must not receive focus because its text changed; do not add redundant aria-live="polite".

Compose the independent skeleton primitive when preserving the expected content geometry is more useful than a centered loading block. Its placeholder group stays decorative and aria-hidden; the same stable region, visible loading copy, external status and aria-busy lifecycle still apply. Keep usable stale content in place during a background refresh instead of replacing it with either loading presentation.

For a request that begins after the page is interactive, the application performs these updates as one lifecycle:

  1. Set aria-busy="true" on the target region and place concise waiting text in its already-present status node.
  2. Keep the live node outside that region. Assistive technologies may defer descendant changes while aria-busy is true.
  3. Apply the final data, empty or error DOM, then set aria-busy="false" after the last related mutation.
  4. Replace the waiting text with a useful completion, result-count or failure message. Do not merely remove “Loading…”: disappearance alone does not communicate the end of a waiting state to non-visual users.

When progress is measurable, compose the native progress primitive instead of turning the spinner into a progressbar:

<label for="project-load-progress">Loading projects</label>
<progress
  id="project-load-progress"
  class="mado-ui-progress"
  value="6"
  max="10"
>
  6 of 10
</progress>

Omit value completely when progress is indeterminate. The native progress element already exposes progressbar semantics, but it is not a live region. If intermediate progress must be announced, update the separate status at useful intervals rather than speaking every value change, following the W3C progress-status technique.

Completed and constrained states

Use success when the visible state represents a completed operation or available result, and warning when content is constrained by an important precondition such as authentication, permissions or incomplete setup:

<div class="mado-ui-content-state" data-tone="success">
  <span class="mado-ui-content-state-media" aria-hidden="true">✓</span>
  <div class="mado-ui-content-state-content">
    <h3 class="mado-ui-content-state-title">Projects are ready</h3>
    <p class="mado-ui-content-state-description">
      The latest project data is available.
    </p>
  </div>
</div>

<div class="mado-ui-content-state" data-tone="warning">
  <span class="mado-ui-content-state-media" aria-hidden="true">!</span>
  <div class="mado-ui-content-state-content">
    <h3 class="mado-ui-content-state-title">Sign in to continue</h3>
    <p class="mado-ui-content-state-description">
      This area is private. Continue with your account.
    </p>
  </div>
</div>

These tones do not determine whether the state appeared dynamically. When a completion or access change is a useful status message, update one stable, application-owned role="status" outside the block. Persistent success and warning content normally remains ordinary document content. Authentication, authorization and recovery actions remain application policy rather than a CSS state machine.

Recoverable error

A recoverable request failure keeps the explanation and Retry action visible. The error block itself remains ordinary content; a separate, pre-existing status node announces a non-urgent dynamic failure:

<section
  id="project-results"
  aria-labelledby="project-results-title"
>
  <h2 id="project-results-title">Projects</h2>

  <div class="mado-ui-content-state" data-tone="danger">
    <span class="mado-ui-content-state-media" aria-hidden="true">!</span>
    <div class="mado-ui-content-state-content">
      <h3 class="mado-ui-content-state-title">
        Could not load projects
      </h3>
      <p class="mado-ui-content-state-description">
        Check the connection and try the request again.
      </p>
    </div>
    <div class="mado-ui-content-state-actions">
      <button
        class="mado-ui-button"
        type="button"
        aria-controls="project-results"
      >
        Try again
      </button>
    </div>
  </div>
</section>

<!-- This node existed empty before the request began. -->
<p role="status" aria-atomic="true">
  Could not load projects. Try the request again.
</p>

Retry behavior belongs to the application. It guards repeated requests, sets the controlled region busy, keeps the button's accessible label and chooses whether native disabled is appropriate. Do not remove the focused Retry button merely to show another loading block. After another failure, leave focus on Retry and update the visible explanation and status text. On success, preserve an enduring focus target; if the focused Retry control must be removed, move focus to a stable logical result heading or region according to the application's navigation policy. The content-state root does not gain tabindex by default and an error does not receive automatic focus.

Reserve a separate role="alert" node for a rare, newly inserted message that is important and time-sensitive enough to interrupt current speech. It must also exist before its text update, stay outside the busy region and contain only the concise announcement, not the content-state actions:

<p id="project-urgent-error" role="alert" aria-atomic="true"></p>

alert is assertive and atomic. It does not move focus, and a message that requires a modal response belongs to an alert-dialog implementation instead, as specified by the WAI-ARIA alert role and WAI-ARIA alert pattern. Do not add redundant aria-live="assertive", put role="alert" on every danger tone or duplicate the same message through both status and alert. The W3C error live-region technique documents why the announcement container is established before the error.

Refresh, stale data and fatal routes

A background refresh is not a content-replacement state while usable data is still present. Keep the data and its focused descendants in place, set their stable region to aria-busy="true" and use the external status for a concise “Refreshing…” message. After a successful coherent update, clear busy and announce the useful result or count. After failure, clear busy, preserve the stale data and focus, and show or announce that refresh failed while previous data remains available. An inline alert block and native Retry button can be composed for that case; replacing usable content with a full error state would discard context.

A fatal route failure is a page, not an assertive notification. Use the normal main landmark, a visible h1, explanatory text and native Retry, Back or Home actions. On full navigation, no live-region role is needed. In a client-side router, the application updates the document title and applies its normal new-view focus policy. It must not also announce the same content as an alert. Authentication, offline detection, error details, request cancellation, retry backoff and fatal-versus-recoverable classification all remain outside this CSS block.

Public properties:

Purpose Property
Layout --mado-ui-content-state-align, --mado-ui-content-state-min-block-size, --mado-ui-content-state-gap, --mado-ui-content-state-padding
Surface --mado-ui-content-state-background, --mado-ui-content-state-color, --mado-ui-content-state-border, --mado-ui-content-state-radius
Content --mado-ui-content-state-content-max-inline-size, --mado-ui-content-state-title-size, --mado-ui-content-state-description-color
Media --mado-ui-content-state-media-size, --mado-ui-content-state-media-background, --mado-ui-content-state-media-border, --mado-ui-content-state-media-color, --mado-ui-content-state-media-radius
Actions --mado-ui-content-state-actions-gap

Page header

.mado-ui-page-header arranges a page title, supporting copy and actions without deciding their document semantics. Its class name describes the visual recipe: it does not create a heading, a banner landmark or a particular outline level.

<header class="mado-ui-page-header">
  <div class="mado-ui-page-header-heading">
    <p class="mado-ui-page-header-eyebrow">Workspace</p>
    <h1 class="mado-ui-page-header-title">Deployments</h1>
    <p class="mado-ui-page-header-description">
      Review production activity and create a new deployment.
    </p>
  </div>

  <div class="mado-ui-page-header-actions">
    <a class="mado-ui-link" href="/docs/deployments">Documentation</a>
    <button class="mado-ui-button" type="button">New deployment</button>
  </div>
</header>

Use the native heading level required by the surrounding document. h1 normally suits the main title of a page, while an embedded region may require h2 through h6. Do not replace a heading with a styled div.

The root can be a native header when it introduces the page's main content. It can instead be a div when the same layout appears in a context where header would convey the wrong structure. The recipe never assigns role="banner": applications own landmark choice, and a page should not gain duplicate banner landmarks merely for visual consistency.

Heading content and actions are optional. The flex layout wraps in source order on narrow viewports, so put the title before its actions in the DOM. The button item remains optional; install it separately when using the example action classes.

Public properties:

Purpose Property
Layout --mado-ui-page-header-gap, --mado-ui-page-header-align
Color --mado-ui-page-header-color, --mado-ui-page-header-eyebrow-color
Heading --mado-ui-page-header-heading-gap, --mado-ui-page-header-title-size
Description --mado-ui-page-header-description-width, --mado-ui-page-header-description-color
Actions --mado-ui-page-header-actions-gap, --mado-ui-page-header-actions-align

Toolbar layout

.mado-ui-toolbar is a responsive visual layout for related native controls. The copied item contains no focus-management JavaScript and therefore does not implement the ARIA toolbar composite-widget pattern.

For an ordinary set of related actions, keep every native control in the normal Tab sequence. Add a labelled group only when announcing the set as a group is useful:

<div
  class="mado-ui-toolbar"
  role="group"
  aria-labelledby="release-actions-title"
>
  <p id="release-actions-title" class="mado-ui-toolbar-label">
    Release actions
  </p>

  <div class="mado-ui-toolbar-group">
    <button class="mado-ui-button" type="button">Build</button>
    <button class="mado-ui-button" type="button">Preview</button>
  </div>

  <div class="mado-ui-toolbar-group" data-align="end">
    <button class="mado-ui-button" type="button" data-variant="secondary">
      Settings
    </button>
  </div>
</div>

The visible label supplies the group's accessible name. When no grouping semantics are useful, omit both role="group" and its accessible-name attribute. Nested .mado-ui-toolbar-group elements are layout wrappers, not ARIA groups by default. Separators and spacers are decorative in this contract. All actions remain native buttons, links or form controls and keep their standard Enter, Space and Tab behavior.

role="toolbar" is a different, application-owned contract. Use it only when application code also provides the complete composite behavior:

  • an accessible name for the toolbar;
  • one item in the page Tab sequence and the other items at tabindex="-1";
  • roving focus with Left/Right Arrow for a horizontal toolbar or Up/Down Arrow for a vertical toolbar;
  • an aria-orientation value that matches a non-horizontal presentation;
  • synchronized focus state after both keyboard and pointer interaction.

Tab and Shift+Tab then enter and leave the toolbar as one stop; they do not replace arrow-key navigation between its controls. Home/End and focus wrapping are optional policy decisions, but must be consistent. Do not add role="toolbar" merely because the controls appear on one row. This CSS-only registry item deliberately promises layout, not that keyboard pattern.

The toolbar and each group wrap in source order at narrow widths. data-align="end" gives a group an automatic inline-start margin when room exists; it changes alignment, never DOM or focus order. The button item is optional and is not a registry dependency.

Public properties:

Purpose Property
Surface --mado-ui-toolbar-background, --mado-ui-toolbar-color
Frame --mado-ui-toolbar-border, --mado-ui-toolbar-radius
Spacing --mado-ui-toolbar-padding, --mado-ui-toolbar-gap, --mado-ui-toolbar-group-gap
Alignment --mado-ui-toolbar-align, --mado-ui-toolbar-justify, --mado-ui-toolbar-group-align
Label --mado-ui-toolbar-label-color

Customization

All block recipes use :where() inside @layer mado-ui, so normal unlayered application CSS overrides them without specificity escalation. Public custom properties start with --mado-ui-; edit the copied source when a product needs a different structural contract.

See the primitive contracts for buttons, links and layout recipes commonly composed inside blocks.