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

component: TagGroup
summary: A named list of Tags that wraps onto rows, moves focus when a tag is removed, announces it and shows an empty state. It fires remove, and never removes a tag itself.
standard: https://opencomponents.dev/docs/components/tag-group
element: div # holds the list, or the empty state. It takes every attribute that isn't a prop, apart from the list's name
role: list # on the <ul>, with a listitem per tag. Each tag is a Tag, with its own link and remove button
props:
  tags: { type: "{ value: string, label: string, href?: string, disabled?: boolean, readonly?: boolean }[]" } # a Tag for each, in this order
  label: { type: string } # the list's name. Or aria-labelledby, which lands on the list
  size: { type: [xs, sm, md, lg, xl], default: md } # passed to every Tag
  removable: { type: boolean, default: false }
  disabled: { type: boolean, default: false }
  readonly: { type: boolean, default: false }
  removeLabel: { type: function } # (label) => "Remove Bug"
  removedLabel: { type: function } # (label) => "Bug removed"
  emptyLabel: { type: string, default: No tags }
  fallbackFocus: { type: "HTMLElement | null | () => HTMLElement | null" } # takes focus when no tag with a remove button follows the one removed
slots:
  tag: A tag's label, for a router's link. It goes to the Tag's default slot.
  leading: An icon before a tag's label. It goes to the Tag's leading slot.
  empty: What it shows with no tags, in place of emptyLabel.
events:
  remove: The tag whose remove button was pressed. The group keeps it until tags drops it. Once it has gone, focus that was on it moves to the next tag's remove button, then fallbackFocus, then the previous tag's remove button, then the empty state, or the list when tags are left that can't be removed, and removedLabel is announced through a polite live region.
states:
  removable: "[data-slot=remove] in each tag"
  disabled: "[data-disabled] on every tag"
  read-only: "[data-readonly] on every tag, with no [data-slot=remove]"
  empty: "> [data-slot=empty], in place of > [data-slot=list]"
keyboard:
  Tab: Moves focus to each tag's link, then its remove button, in order, then out of the group. There's no roving focus.
  Enter: Follows a link, or fires remove on a remove button.
  Space: Fires remove on a remove button.
parts: [list, item, empty] # data-slot. An item per tag inside the list, and the empty state in its place with no tags
tokens:
  theme: ["--color--neutral-text", "--color--focus"] # and the Tag's, through its tags
  variables: [--tag-group--gap, --tag-group--item--height, --tag-group--row-gap, --tag-group--empty--color, --tag-group--empty--font-size]
  layer: components # in Tailwind's order: theme, base, components, utilities
rules:
  - id: tag-group/uses-tag
    layer: ui
    level: must
    scope: component
    requirement: "Each item holds the Tag, and the group sets none of its variables and draws no tag, button or icon of its own"
    basis: "Open Components"
    check: "Unit test, Review"
  - id: tag-group/sizes
    layer: ui
    level: should
    scope: component
    requirement: "It takes the Tag's five size names and passes its size to every tag"
    basis: "Open Components"
    check: "Unit test"
  - id: tag-group/wrap
    layer: ui
    level: must
    scope: component
    requirement: "Tags wrap onto as many rows as they need, with no horizontal scroll at 320px wide"
    basis: "WCAG 1.4.10 (AA)"
    check: "Emulation, 200% zoom"
  - id: tag-group/row-gap
    layer: ui
    level: must
    scope: component
    requirement: "Rows sit at least 24px minus the tag's height apart, and 44px minus it on a touch screen, so no tag's hit area covers a tag on the next row"
    basis: "WCAG 2.5.8 (AA), 2.5.5 (AAA)"
    check: "Emulation"
  - id: tag-group/stable-height
    layer: ui
    level: should
    scope: component
    requirement: "The empty state is as tall as a tag, so removing the last tag doesn't move what's below the group"
    basis: "Open Components"
    check: "Visual regression test"
  - id: tag-group/contrast
    layer: ui
    level: must
    scope: both
    requirement: "The empty state's text has at least 4.5:1 against what's behind it, and its focus ring at least 3:1"
    basis: "WCAG 1.4.3, 1.4.11 (AA)"
    check: "Contrast checker"
  - id: tag-group/tokens
    layer: ui
    level: must
    scope: component
    requirement: "The group sets its look through `--tag-group--…` token paths in the `components` layer"
    basis: "Open Components"
    check: "Stylelint"
  - id: tag-group/forced-colors
    layer: ui
    level: must
    scope: component
    requirement: "The empty state's text and the focus ring on it and on the list stay visible in forced colours mode"
    basis: "Open Components"
    check: "Emulation"
  - id: tag-group/list
    layer: ux
    level: must
    scope: component
    requirement: "The tags are a `<ul>` with one `<li>` per tag, which keeps its list role where its bullets are removed"
    basis: "WCAG 1.3.1 (A)"
    check: "Unit test, ARIA snapshot"
  - id: tag-group/name
    layer: ux
    level: must
    scope: both
    requirement: "The list has a name, from `label` or from a visible heading through `aria-labelledby`"
    basis: "Open Components, beyond WCAG 1.3.1 (A)"
    check: "Unit test, Screen reader"
  - id: tag-group/tab-stops
    layer: ux
    level: must
    scope: component
    requirement: "Each link and remove button is a stop in the tab order, in order, with no roving focus and no arrow keys"
    basis: "WCAG 2.1.1 (A)"
    check: "Unit test, Keyboard"
  - id: tag-group/focus-after-remove
    layer: ux
    level: must
    scope: component
    requirement: "Once a removed tag has gone, focus that was on it moves to the next tag's remove button, then `fallbackFocus`, then the previous tag's remove button, then the empty state, or the list when tags are left that can't be removed"
    basis: "WCAG 2.4.3 (A)"
    check: "Unit test"
  - id: tag-group/announce-removal
    layer: ux
    level: should
    scope: component
    requirement: "Once a tag whose remove button was pressed has gone, a polite live region announces it, like \"Bug removed\""
    basis: "WCAG 4.1.3 (AA)"
    check: "Unit test, Screen reader"
  - id: tag-group/empty-state
    layer: ux
    level: must
    scope: component
    requirement: "With no tags, the empty state shows in place of the list, and can take focus from a script but isn't a stop in the tab order"
    basis: "Open Components"
    check: "Unit test"
  - id: tag-group/no-selection
    layer: ux
    level: should
    scope: component
    requirement: "No tag in the group can be selected: there's no `aria-selected`, `aria-pressed`, `grid` or `row`"
    basis: "Open Components"
    check: "Unit test"
  - id: tag-group/short-groups
    layer: ux
    level: should
    scope: usage
    requirement: "A group holds a handful of tags, or offers a way to clear them all"
    basis: "Open Components"
    check: "Review"
  - id: tag-group/alike-tags
    layer: ux
    level: should
    scope: usage
    requirement: "The tags in a group are all removable or none, and all links or none"
    basis: "Open Components"
    check: "Review"
  - id: tag-group/mirrors-in-rtl
    layer: ux
    level: should
    scope: both
    requirement: "Tags wrap from the start edge, so the first tag sits on the right in right-to-left layouts"
    basis: "Open Components"
    check: "Review"
  - id: tag-group/shared-vocabulary
    layer: dx
    level: must
    scope: component
    requirement: "It takes `label`, `size`, `disabled`, `readonly`, `removable`, `removeLabel` and `leading` like the Tag, `removedLabel` like the Select, fires `remove` like the Tag, and names its own props after what they do: `tags`, `emptyLabel` and `fallbackFocus`"
    basis: "Open Components"
    check: "Review"
  - id: tag-group/typed
    layer: dx
    level: must
    scope: component
    requirement: "Props, slots, the event and `TagGroupItem` are typed, with unions rather than `string`, so `size` only takes the five sizes"
    basis: "Open Components"
    check: "Type check"
  - id: tag-group/controlled-removal
    layer: dx
    level: should
    scope: component
    requirement: "`remove` fires with the tag, and the group keeps it until `tags` drops it, moving no focus and announcing nothing while it stays"
    basis: "Open Components"
    check: "Unit test"
  - id: tag-group/root-element
    layer: dx
    level: must
    scope: component
    requirement: "The root element is the group itself, so attributes, listeners and refs land on it, while `aria-label`, `aria-labelledby` and `aria-describedby` land on the list"
    basis: "Open Components"
    check: "Unit test"
  - id: tag-group/slots
    layer: dx
    level: should
    scope: component
    requirement: "`tag` and `leading` go to each Tag's default and leading slots, and `empty` replaces the empty state's text, while every remove button keeps its name"
    basis: "Open Components"
    check: "Unit test"
  - id: tag-group/dev-warnings
    layer: dx
    level: should
    scope: component
    requirement: "It warns in development when the list has no name"
    basis: "Open Components"
    check: "Unit test"
  - id: tag-group/role-and-name
    layer: ax
    level: must
    scope: component
    requirement: "It can be found by its role and name, with `getByRole(\"list\", { name: \"Labels\" })`, and each tag by its parts', like `getByRole(\"button\", { name: \"Remove Bug\" })`"
    basis: "WCAG 4.1.2 (A)"
    check: "Unit test"
  - id: tag-group/deterministic-dom
    layer: ax
    level: must
    scope: component
    requirement: "The same props render the same markup, with no generated IDs. The list renders only with tags and the empty state only without, `disabled` and `readonly` take each tag's remove button away, and every other state changes attributes on the tags, never elements"
    basis: "Open Components"
    check: "Unit test"
  - id: tag-group/data-attributes
    layer: ax
    level: should
    scope: component
    requirement: "`size` is mirrored as `data-size`, and parts carry `data-slot`: `list`, `item` and `empty`"
    basis: "Open Components"
    check: "Unit test"
