# yaml-language-server: $schema=https://opencomponents.dev/schemas/contract.json
# The Popover contract, with every rule from its checklist.
# The page explains why each one exists: https://opencomponents.dev/raw/docs/components/popover.md

component: Popover
summary: Shows more next to the button that opened it, like filters or a short form, without blocking the rest of the page. Its title names it, and a Button with popovertarget opens it.
standard: https://opencomponents.dev/docs/components/popover
element: div # popover="auto", right after its trigger, and in the top layer while it's open
role: dialog # without aria-modal, since the page around it keeps working
props:
  open: { type: boolean, default: false } # it follows every open and close, so bind it both ways
  id: { type: string } # required. Its trigger's popovertarget points to it, and its title's ID starts with it
  title: { type: string } # required
  placement: { type: [top, bottom, start, end], default: bottom }
  arrow: { type: boolean, default: false }
  closeLabel: { type: string, default: Close }
slots:
  default: The content, like a few options, a short form or a hint with a link, which scrolls when it doesn't fit.
events:
  close: "escape, outside, close-button, trigger or closed. Fires once it has closed, unless open closed it."
  toggle: "The browser's own ToggleEvent, with newState open or closed, for every open and close."
states:
  open: "[data-state=open], and :popover-open"
  closed: "[data-state=closed], hidden by the browser's own styles"
keyboard:
  Enter: On its trigger, opens it, or closes it when it's open. Focus moves to data-autofocus, or else the first control in its body, then its close button. The trigger's aria-expanded follows.
  Space: On its trigger, the same as Enter.
  Tab: Moves between its controls, then on to what comes after its trigger, leaving it open.
  Escape: Closes it, wherever focus is, and stops there, giving focus back to its trigger when it was inside. A popover open inside it, or a control that handles Escape itself, goes first.
parts: [header, title, close, body, arrow] # data-slot, in this order. The arrow is only there with arrow
tokens:
  theme: ["--color--neutral-text"]
  variables: [--popover--max-width, --popover--padding, --popover--gap, --popover--radius, --popover--border-color, --popover--background, --popover--box-shadow, --popover--offset, --popover--arrow--size]
  layer: components # in Tailwind's order: theme, base, components, utilities
rules:
  - id: popover/contrast
    layer: ui
    level: must
    scope: component
    requirement: "The title and the content have a contrast of 4.5:1 against the panel"
    basis: "WCAG 1.4.3 (AA)"
    check: "axe `color-contrast`, contrast checker"
  - id: popover/edge
    layer: ui
    level: should
    scope: component
    requirement: "A border and a shadow set the panel apart from the page behind it"
    basis: "Open Components"
    check: "Review"
  - id: popover/top-layer
    layer: ui
    level: must
    scope: component
    requirement: "It opens in the top layer, so no `overflow` or `z-index` around its trigger clips or covers it, above an open Dialog too"
    basis: "HTML"
    check: "Visual regression test"
  - id: popover/flips
    layer: ui
    level: must
    scope: component
    requirement: "It opens beside its trigger, on the side `placement` names, flips to the other side when there's no room and stays inside the viewport, never covering its trigger where the browser has CSS anchor positioning"
    basis: "Open Components"
    check: "Visual regression test"
  - id: popover/fallback-position
    layer: ui
    level: should
    scope: component
    requirement: "Browsers without CSS anchor positioning get the same side from a script and stay inside the viewport, although a tall popover can cover its trigger there"
    basis: "Open Components"
    check: "Unit test, visual regression test"
  - id: popover/points-at-trigger
    layer: ui
    level: must
    scope: component
    requirement: "An arrow, when there is one, sits on the edge facing the trigger and points at its centre, after a flip too"
    basis: "Open Components"
    check: "Unit test, visual regression test"
  - id: popover/fits-screen
    layer: ui
    level: must
    scope: component
    requirement: "It's never wider or taller than the screen less 16px on each side, down to 320px wide, and its body scrolls when its content doesn't fit"
    basis: "WCAG 1.4.10 (AA)"
    check: "Emulation"
  - id: popover/resizable
    layer: ui
    level: must
    scope: component
    requirement: "It's sized in `rem`, so it grows with its text, and its title wraps rather than getting cut off"
    basis: "Open Components, beyond WCAG 1.4.4, 1.4.12 (AA)"
    check: "200% zoom"
  - id: popover/reduced-motion
    layer: ui
    level: should
    scope: component
    requirement: "It only animates as it opens when `prefers-reduced-motion` is `no-preference`"
    basis: "WCAG 2.3.3 (AAA)"
    check: "Emulation"
  - id: popover/forced-colors
    layer: ui
    level: must
    scope: component
    requirement: "The panel's border stays visible in forced colours mode, which drops its shadow"
    basis: "Open Components"
    check: "Emulation"
  - id: popover/more-contrast
    layer: ui
    level: should
    scope: component
    requirement: "The panel's border is drawn at full strength when `prefers-contrast: more` is set"
    basis: "Open Components"
    check: "Emulation"
  - id: popover/follows-direction
    layer: ui
    level: should
    scope: component
    requirement: "`start` and `end` follow the reading direction, and its close button mirrors in right-to-left languages"
    basis: "Open Components"
    check: "Unit test, review"
  - id: popover/native-popover
    layer: ux
    level: must
    scope: component
    requirement: "It's an element with `popover=\"auto\"`, which a `popovertarget` button opens before any script loads, and never a scripted overlay"
    basis: "Open Components, beyond WCAG 2.1.1 (A)"
    check: "Unit test"
  - id: popover/non-modal
    layer: ux
    level: must
    scope: component
    requirement: "The page around it keeps working: nothing is inert, nothing is `aria-modal`, and `Tab` can leave it"
    basis: "WAI-ARIA, WCAG 2.1.2 (A)"
    check: "Unit test, keyboard"
  - id: popover/named
    layer: ux
    level: must
    scope: component
    requirement: "It's `role=\"dialog\"`, named by its title with `aria-labelledby`"
    basis: "WCAG 4.1.2 (A)"
    check: "Unit test"
  - id: popover/visible-title
    layer: ux
    level: must
    scope: usage
    requirement: "Every popover has a title that says what it holds, like \"Filters\""
    basis: "WCAG 1.3.1 (A), 2.4.6 (AA)"
    check: "Review"
  - id: popover/trigger
    layer: ux
    level: must
    scope: usage
    requirement: "Its trigger is a button that points at it with `popovertarget`, and sets `aria-haspopup=\"dialog\"`"
    basis: "WCAG 4.1.2 (A), HTML"
    check: "ARIA snapshot"
  - id: popover/trigger-expanded
    layer: ux
    level: must
    scope: component
    requirement: "It keeps `aria-expanded` on its trigger in step with whether it's open, `true` only on the trigger that opened it"
    basis: "WCAG 4.1.2 (A)"
    check: "Unit test, ARIA snapshot"
  - id: popover/after-trigger
    layer: ux
    level: should
    scope: usage
    requirement: "It's rendered right after its trigger, so `Tab` and screen readers reach it next"
    basis: "WCAG 1.3.2, 2.4.3 (A)"
    check: "Keyboard"
  - id: popover/opens-on-press
    layer: ux
    level: must
    scope: usage
    requirement: "It opens from a press on its trigger, or from your code, and never on hover or focus alone"
    basis: "Open Components"
    check: "Review"
  - id: popover/initial-focus
    layer: ux
    level: must
    scope: both
    requirement: "Opening it moves focus inside, to the control marked with `data-autofocus`, or else the first in its body, then its close button"
    basis: "WCAG 2.4.3 (A)"
    check: "Unit test"
  - id: popover/return-focus
    layer: ux
    level: must
    scope: both
    requirement: "Closing it while focus is inside moves focus back to its trigger, and a press outside leaves focus where people pressed"
    basis: "WCAG 2.4.3 (A), HTML"
    check: "Unit test, keyboard"
  - id: popover/escape
    layer: ux
    level: must
    scope: component
    requirement: "`Escape` closes it, with focus in it, on its trigger or anywhere else on the page"
    basis: "HTML, WCAG 2.4.11 (AA)"
    check: "Unit test"
  - id: popover/contain-escape
    layer: ux
    level: must
    scope: component
    requirement: "An `Escape` that closes it stops there, so a Dialog around it stays open, while one a Tooltip inside it took closes only the Tooltip"
    basis: "Open Components"
    check: "Unit test"
  - id: popover/light-dismiss
    layer: ux
    level: must
    scope: component
    requirement: "A press that starts and ends outside it and its trigger closes it, and one inside it doesn't"
    basis: "HTML"
    check: "Unit test"
  - id: popover/close-button
    layer: ux
    level: must
    scope: component
    requirement: "It has a visible close button, a Button named \"Close\""
    basis: "Open Components"
    check: "Unit test"
  - id: popover/one-at-a-time
    layer: ux
    level: should
    scope: component
    requirement: "Opening a popover closes any other one, unless it opened from inside that one"
    basis: "HTML"
    check: "Keyboard"
  - id: popover/inside-dialog
    layer: ux
    level: must
    scope: usage
    requirement: "A popover in a Dialog is rendered inside it, so the Dialog doesn't make it inert"
    basis: "HTML"
    check: "Keyboard"
  - id: popover/right-component
    layer: ux
    level: should
    scope: usage
    requirement: "It holds a few controls or a short hint, while a hint on hover is a Tooltip and anything that needs an answer is a Dialog"
    basis: "Open Components"
    check: "Review"
  - id: popover/shared-vocabulary
    layer: dx
    level: must
    scope: component
    requirement: "It uses the shared names: `open`, `title` (`popoverTitle` in Angular), `placement`, `arrow`, `closeLabel` and `close` (`closeRequest` in Angular)"
    basis: "Open Components"
    check: "Review"
  - id: popover/typed
    layer: dx
    level: must
    scope: component
    requirement: "Props, events and slots are typed, with unions rather than `string`, and `id` and `title` are required"
    basis: "Open Components"
    check: "Type check"
  - id: popover/controllable
    layer: dx
    level: must
    scope: component
    requirement: "`open` opens and closes it, and follows every other open and close, each of which fires `close` with why"
    basis: "Open Components"
    check: "Unit test"
  - id: popover/root-attributes
    layer: dx
    level: must
    scope: component
    requirement: "Attributes, classes and listeners land on the popover element"
    basis: "Open Components"
    check: "Unit test"
  - id: popover/role-and-name
    layer: ax
    level: must
    scope: component
    requirement: "It can be found by its role and title alone, with `getByRole(\"dialog\", { name })`"
    basis: "WCAG 4.1.2 (A)"
    check: "Unit test, ARIA snapshot"
  - id: popover/hidden-when-closed
    layer: ax
    level: must
    scope: component
    requirement: "A closed popover isn't in the accessibility tree, so neither agents nor screen readers find it"
    basis: "HTML"
    check: "Unit test"
  - id: popover/deterministic-dom
    layer: ax
    level: must
    scope: component
    requirement: "The same props render the same markup, with its title's ID made from its own, and opening it changes attributes, never elements"
    basis: "Open Components"
    check: "Unit test"
  - id: popover/data-attributes
    layer: ax
    level: should
    scope: component
    requirement: "Its state and placement are mirrored as `data-state` and `data-placement`, and parts are marked with `data-slot`"
    basis: "Open Components"
    check: "Unit test"
  - id: popover/dev-warnings
    layer: ax
    level: should
    scope: component
    requirement: "It warns during development when it has no title"
    basis: "Open Components"
    check: "Unit test"
