Skip to content

Add Pathogen::Toolbar component for accessible control groups above grids #49

Description

@joshsadam

Implements the W3C ARIA Authoring Practices Guide — Toolbar Pattern (Toolbar Example) for the subset of widgets needed by v1 (toolbar container, button, separator). Other APG demo widgets (menu button, spin button, radio group, checkbox, link, popup labels) are explicitly out of scope.

Problem Statement

Developers building pages that present a Pathogen::DataGridComponent — or any analogous content surface — need a consistent, accessible way to group controls into a single horizontal strip above the grid: bulk-action buttons, toggles, separators, occasional search inputs, future menu and radio components. Today every host page reinvents this strip with raw HTML, which leads to inconsistent focus management, no roving tabindex, native disabled buttons that screen-reader users cannot perceive, and ad-hoc styling that drifts from the shared design contract.

End users of those pages, especially keyboard and screen-reader users, are forced to Tab through every individual control instead of treating the strip as one accessible toolbar with the APG-prescribed Left/Right/Home/End keyboard contract.

Solution

Introduce a generic Pathogen::Toolbar ViewComponent and pathogen--toolbar Stimulus controller that implement the APG Toolbar pattern: one tab stop into the strip, Left/Right/Home/End navigation with wrap-around, the most-recently-focused control as the tab-sequence entry point, and aria-disabled (focusable) instead of native disabled for disabled items.

Two thin item helpers ship in v1:

  • Pathogen::Toolbar::Button — toggle-capable, aria-disabled-aware toolbar item that composes Pathogen::Button underneath.
  • Pathogen::Toolbar::Separator — visual divider with role=\"separator\", aria-orientation=\"vertical\", not focusable, not in the roving rotation.

The toolbar accepts an optional controls: referencing the controlled surface (typically a grid id) and exactly-one-of label: / labelled_by: for the accessible name. The API is a freeform content block, so heterogeneous children (buttons, separators, search inputs, future menu/radio components) compose without API churn.

User Stories

  1. As a developer rendering controls above Pathogen::DataGridComponent, I want to wrap them in Pathogen::Toolbar so that the controls share a single tab stop and follow the APG keyboard contract.
  2. As a developer, I want to render a bulk-action button that is currently disabled (e.g. no rows selected) without removing it from the toolbar's keyboard rotation, so that screen-reader users still discover that the action exists.
  3. As a developer, I want to render a toggle button ("Show archived") that exposes aria-pressed, so that screen-reader users hear its current pressed / unpressed state.
  4. As a developer, I want to drop a search field inside the toolbar without arrow keys yanking focus away from the input, so that typing and caret navigation behave naturally.
  5. As a developer, I want to add visual separators between groups of toolbar buttons so that the strip reads as logically grouped without writing custom CSS.
  6. As a developer, I want the toolbar to optionally reference the grid it controls (aria-controls) so that the relationship is exposed to assistive technologies.
  7. As a developer, I want to provide the toolbar's accessible name via either label: (→ aria-label) or labelled_by: (→ aria-labelledby pointing at a visible heading), so that I can pick whichever fits the surrounding page.
  8. As a keyboard user, I want Tab to land on the most-recently-focused control inside the toolbar — or the first focusable control on first entry — so that I am not forced to Tab through every item.
  9. As a keyboard user, I want Left / Right to navigate between toolbar items with wrap-around, and Home / End to jump to the first / last item.
  10. As a screen-reader user, I want the toolbar to announce as a toolbar with its accessible name, and disabled items to announce as disabled but still be reachable.
  11. As a screen-reader user on NVDA + Firefox or VoiceOver + Safari, I want toggle items to announce their pressed state when they receive focus.
  12. As a developer, I want the toolbar to work after a Turbo morph without losing accessibility wiring, so that filtering and pagination interactions do not break the keyboard experience.
  13. As a developer, I want a Lookbook preview that demonstrates each variant (basic, toggle, disabled, search input, separator, aria-controls over a grid) so that I can copy a working pattern instead of re-deriving it.
  14. As a designer / PM reviewing accessibility, I want the toolbar to pass axe checks and match the documented APG keyboard and ARIA contract verbatim.

Implementation Decisions

  • New components.

    • Pathogen::Toolbar — unsuffixed (mirrors Pathogen::Tabs). Constructor: label:, labelled_by:, controls: (optional), plus **system_arguments. Exactly-one-of label / labelled_by enforced via ArgumentError at construction.
    • Pathogen::Toolbar::Button — composes Pathogen::Button and adds toolbar-item plumbing. Constructor: label:, pressed: nil, disabled: false, tag: :button, with scheme: / size: flowing through to Pathogen::Button. pressed: nil emits no aria-pressed; pressed: true | false emits the attribute. disabled: true emits aria-disabled=\"true\" (never the native disabled attribute), per the APG focusability rule.
    • Pathogen::Toolbar::Separator — renders <div role=\"separator\" aria-orientation=\"vertical\"> with subdued neutral styling. Not a Stimulus target.
  • New Stimulus controller. pathogen--toolbar, one item target, log namespace [pathogen--toolbar]. Public actions wired via data-action on the toolbar root: keydown->pathogen--toolbar#handleKeyDown, focusin->pathogen--toolbar#handleFocusIn, click->pathogen--toolbar#handleClick. Sets dataset.controllerConnected = \"true\" on connect for test introspection.

  • Item discovery. Explicit Stimulus target — items carry data-pathogen--toolbar-target=\"item\". Pathogen::Toolbar::Button adds it automatically; raw elements (e.g. a search input) opt in via the data attribute. Mirrors the explicit-target convention used by pathogen--tabs and pathogen--data-grid.

  • Roving tabindex. On connect the controller sets tabindex=\"-1\" on every item and tabindex=\"0\" on the first non-aria-disabled item (or the first item if all are aria-disabled — APG keeps them focusable). On focusin, the focused item becomes the new tabindex=\"0\".

  • Keyboard contract. Left / Right move focus with wrap-around; Home / End jump to first / last item. The handler bails out unconditionally when event.target is a text-entry surface — input types other than button / submit / checkbox / radio / hidden, textarea, or [contenteditable=\"true\"] — so search inputs and future inline editors behave naturally.

  • Disabled-item click guard. A delegated click listener calls preventDefault() and stopImmediatePropagation() when the activated item is aria-disabled=\"true\", so consumer handlers do not fire.

  • Toggle-state ownership. The controller does NOT flip aria-pressed on click. Toggle state is application state; the consumer toggles it via their own Stimulus action or a Turbo round-trip. Matches how Pathogen::Tabs does not own panel content.

  • Tooltip wiring. The toolbar does not auto-wrap items with Pathogen::Tooltip. Consumers compose tooltips on toolbar buttons using the existing Pathogen::Tooltip pattern. Esc-dismisses-popup remains the tooltip's responsibility.

  • Orientation. Horizontal only in v1. No aria-orientation attribute emitted; no Up / Down key handling. Vertical orientation is an explicit follow-up.

  • API shape. Freeform content block — heterogeneous children compose inside the toolbar. Pathogen::Toolbar::Button and Pathogen::Toolbar::Separator are first-class items but live inside the block rather than being slots.

  • Accessible name. Exactly one of label: or labelled_by: is required. Validation occurs at construction with ArgumentError, matching the Pathogen::Tabs precedent.

  • aria-controls. Optional controls: keyword. When provided, renders aria-controls=\"<id>\" on the toolbar root referencing the controlled surface (typically a Pathogen::DataGridComponent id).

  • CSS & engine integration. All toolbar styling expressed with Tailwind utility classes in the ERB templates so it folds into the pre-compiled pathogen_view_components.css build. No host-app Tailwind required. Per the shared design contract, focus-visible outlines live on each item (not the container).

  • Importmap & barrel. Pin pathogen_view_components/toolbar_controller in the engine's importmap; import, register, and re-export ToolbarController from the public JS entrypoint at app/assets/javascripts/pathogen_view_components.js. The Stimulus identifier is pathogen--toolbar.

  • No new user-visible strings. The toolbar ships no localized text; consumers supply label: and any visible button copy. No new config/locales/en.yml or config/locales/fr.yml entries are required. i18n-tasks health check should pass without changes.

  • Constitution alignment.

    • Principle I (Component-First): three new ViewComponents with stable slot / argument surfaces.
    • Principle II (Accessibility NON-NEGOTIABLE): conformance to the APG Toolbar pattern is the entire point. rubocop-rails-accessibility must pass; manual NVDA + Firefox and VoiceOver + Safari verification before merge, per the README keyboard-nav checklist.
    • Principle III (Internationalization): no new strings shipped by the library.
    • Principle IV (Test-First): tests written alongside implementation; see Testing Decisions.
    • Principle V (Engine Integrity & Simplicity): one Stimulus controller under the pathogen-- prefix; styling folds into the pre-compiled stylesheet; YAGNI — no menu button, no radio group, no spin button, no vertical orientation, no overflow menu, no auto-tooltip.
  • Shared design contract alignment. Relies on:

    • Focus-visible outlines on controls (not the container).
    • primary-* / neutral-* token usage via Pathogen::Button.
    • text-sm toolbar-item typography.
    • 4px-increment gap-* spacing.
    • aria-disabled for visually-muted, still-focusable disabled controls.

    No exceptions to the contract are anticipated.

Testing Decisions

  • What makes a good test here. Assert externally observable behaviour: rendered DOM (role=\"toolbar\", aria-label, aria-controls, item tabindex, item aria-disabled / aria-pressed, separator role), keyboard event outcomes (focus moves, wrap-around, Home / End, no movement inside text inputs), and click suppression for aria-disabled items. Do not assert internal helper methods, private Stimulus fields, or class names beyond the documented visual contract.
  • Modules to be tested (all four).
    • Pathogen::Toolbar — Ruby unit test (ViewComponentTestCase): renders role=\"toolbar\"; renders aria-label from label:; renders aria-labelledby from labelled_by:; raises ArgumentError when neither or both are provided; renders aria-controls when controls: is provided; sets data-controller=\"pathogen--toolbar\" on the root; passes through system_arguments.
    • Pathogen::Toolbar::Button — Ruby unit test: renders with data-pathogen--toolbar-target=\"item\" and tabindex=\"-1\"; emits aria-disabled=\"true\" (and no native disabled) when disabled: true; emits aria-pressed=\"true\" | \"false\" only when pressed: is non-nil; renders Pathogen::Button underneath (visuals / size / scheme honored).
    • Pathogen::Toolbar::Separator — Ruby unit test: renders role=\"separator\" with aria-orientation=\"vertical\", has no data-pathogen--toolbar-target attribute, is not in the tab order.
    • pathogen--toolbar Stimulus controller — JS unit test (Vitest + jsdom + real Stimulus Application): on connect, the first non-aria-disabled item has tabindex=\"0\" and the rest tabindex=\"-1\"; ArrowRight moves focus and updates the roving tabindex; ArrowLeft wraps from first to last; ArrowRight wraps from last to first; Home / End jump to first / last; aria-disabled items remain in the rotation but a click is suppressed (no event reaches a downstream handler); ArrowLeft / ArrowRight inside <input type=\"search\"> does NOT change focus.
  • Prior art. Pattern-match these existing suites:
    • test/components/pathogen/tabs/lazy_panel_test.rb and the typography tests for component test style and ArgumentError shape.
    • test/components/pathogen/data_grid_component_test.rb for ARIA-attribute assertions.
    • test/javascript/controllers/data_grid_controller.test.js for the Stimulus controller test pattern — real Application, manual DOM fixture, KeyboardEvent dispatch, afterEach cleanup. This is the canonical example to follow since the tabs controller has no JS test yet.
  • Lookbook previews (required by the constitution). Add a Pathogen::ToolbarPreview with at least these scenarios: basic_usage, keyboard_and_accessibility, toggle_buttons, with_aria_controls_over_grid, with_search_input. Each scenario gets its own .html.erb template under test/components/previews/pathogen/toolbar_preview/.
  • Accessibility tests called out explicitly. Explicit DOM-level assertions for role, aria-label / aria-labelledby, aria-controls, aria-disabled, aria-pressed, tabindex; axe checks on the previews where the harness supports them; manual NVDA + Firefox and VoiceOver + Safari verification per the README before merging.
  • Runners. Ruby tests via bin/test. JS tests via pnpm test. bundle exec rubocop, Prettier, and i18n-tasks health must pass in CI.

Out of Scope

  • Pathogen::Toolbar::RadioGroup. The APG nested-radio-group pattern (Up / Down inside the group, Left / Right consumed by the parent toolbar) is its own follow-up.
  • Pathogen::Toolbar::MenuButton. Full APG menu button + menu pattern; defer until a real consumer needs it.
  • Pathogen::Toolbar::SpinButton, ::Checkbox, ::Link. Each is its own ARIA pattern.
  • Pathogen::DataGrid::Toolbar — a table-aware composition with selection-count awareness, bulk-action vs. default-action slots, and selection-mode visual states. Revisit once a real host page wants it.
  • Vertical orientation.
  • Overflow / "More" menu at narrow widths.
  • Auto-wrapping toolbar items in Pathogen::Tooltip.
  • Auto-toggling of aria-pressed on click.
  • aria-orientation attribute (horizontal-only v1).
  • New localized strings.
  • An ADR file. None of the v1 decisions are simultaneously hard-to-reverse, surprising-without-context, AND the result of a real trade-off — they are standard APG conformance.

Further Notes

  • Implements the W3C APG Toolbar Pattern — Toolbar Example for the subset of widgets in v1.
  • Open question — none. All design branches were resolved during a grilling session before this PRD was written: scope, item discovery, orientation, separator support, API shape, toggle-state ownership, tooltip wiring, text-input bail-out, accessible name strategy, aria-controls, naming, and disabled-item behaviour.

Metadata

Metadata

Assignees

Labels

No labels
No labels

Fields

No fields configured for Feature.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions