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
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
- As a screen-reader user on NVDA + Firefox or VoiceOver + Safari, I want toggle items to announce their pressed state when they receive focus.
- 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.
- 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.
- 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.
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, nativedisabledbuttons 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::ToolbarViewComponent andpathogen--toolbarStimulus 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, andaria-disabled(focusable) instead of nativedisabledfor disabled items.Two thin item helpers ship in v1:
Pathogen::Toolbar::Button— toggle-capable,aria-disabled-aware toolbar item that composesPathogen::Buttonunderneath.Pathogen::Toolbar::Separator— visual divider withrole=\"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-oflabel:/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
Pathogen::DataGridComponent, I want to wrap them inPathogen::Toolbarso that the controls share a single tab stop and follow the APG keyboard contract.aria-pressed, so that screen-reader users hear its current pressed / unpressed state.aria-controls) so that the relationship is exposed to assistive technologies.label:(→aria-label) orlabelled_by:(→aria-labelledbypointing at a visible heading), so that I can pick whichever fits the surrounding page.aria-controlsover a grid) so that I can copy a working pattern instead of re-deriving it.Implementation Decisions
New components.
Pathogen::Toolbar— unsuffixed (mirrorsPathogen::Tabs). Constructor:label:,labelled_by:,controls:(optional), plus**system_arguments. Exactly-one-oflabel/labelled_byenforced viaArgumentErrorat construction.Pathogen::Toolbar::Button— composesPathogen::Buttonand adds toolbar-item plumbing. Constructor:label:,pressed: nil,disabled: false,tag: :button, withscheme:/size:flowing through toPathogen::Button.pressed: nilemits noaria-pressed;pressed: true | falseemits the attribute.disabled: trueemitsaria-disabled=\"true\"(never the nativedisabledattribute), 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, oneitemtarget, log namespace[pathogen--toolbar]. Public actions wired viadata-actionon the toolbar root:keydown->pathogen--toolbar#handleKeyDown,focusin->pathogen--toolbar#handleFocusIn,click->pathogen--toolbar#handleClick. Setsdataset.controllerConnected = \"true\"on connect for test introspection.Item discovery. Explicit Stimulus target — items carry
data-pathogen--toolbar-target=\"item\".Pathogen::Toolbar::Buttonadds it automatically; raw elements (e.g. a search input) opt in via the data attribute. Mirrors the explicit-target convention used bypathogen--tabsandpathogen--data-grid.Roving tabindex. On connect the controller sets
tabindex=\"-1\"on every item andtabindex=\"0\"on the first non-aria-disableditem (or the first item if all arearia-disabled— APG keeps them focusable). Onfocusin, the focused item becomes the newtabindex=\"0\".Keyboard contract. Left / Right move focus with wrap-around; Home / End jump to first / last item. The handler bails out unconditionally when
event.targetis a text-entry surface —inputtypes other thanbutton/submit/checkbox/radio/hidden,textarea, or[contenteditable=\"true\"]— so search inputs and future inline editors behave naturally.Disabled-item click guard. A delegated
clicklistener callspreventDefault()andstopImmediatePropagation()when the activated item isaria-disabled=\"true\", so consumer handlers do not fire.Toggle-state ownership. The controller does NOT flip
aria-pressedon click. Toggle state is application state; the consumer toggles it via their own Stimulus action or a Turbo round-trip. Matches howPathogen::Tabsdoes not own panel content.Tooltip wiring. The toolbar does not auto-wrap items with
Pathogen::Tooltip. Consumers compose tooltips on toolbar buttons using the existingPathogen::Tooltippattern. Esc-dismisses-popup remains the tooltip's responsibility.Orientation. Horizontal only in v1. No
aria-orientationattribute 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::ButtonandPathogen::Toolbar::Separatorare first-class items but live inside the block rather than being slots.Accessible name. Exactly one of
label:orlabelled_by:is required. Validation occurs at construction withArgumentError, matching thePathogen::Tabsprecedent.aria-controls. Optionalcontrols:keyword. When provided, rendersaria-controls=\"<id>\"on the toolbar root referencing the controlled surface (typically aPathogen::DataGridComponentid).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.cssbuild. 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_controllerin the engine's importmap; import, register, and re-exportToolbarControllerfrom the public JS entrypoint atapp/assets/javascripts/pathogen_view_components.js. The Stimulus identifier ispathogen--toolbar.No new user-visible strings. The toolbar ships no localized text; consumers supply
label:and any visible button copy. No newconfig/locales/en.ymlorconfig/locales/fr.ymlentries are required.i18n-taskshealth check should pass without changes.Constitution alignment.
rubocop-rails-accessibilitymust pass; manual NVDA + Firefox and VoiceOver + Safari verification before merge, per the README keyboard-nav checklist.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:
primary-*/neutral-*token usage viaPathogen::Button.text-smtoolbar-item typography.gap-*spacing.aria-disabledfor visually-muted, still-focusable disabled controls.No exceptions to the contract are anticipated.
Testing Decisions
role=\"toolbar\",aria-label,aria-controls, itemtabindex, itemaria-disabled/aria-pressed, separator role), keyboard event outcomes (focus moves, wrap-around, Home / End, no movement inside text inputs), and click suppression foraria-disableditems. Do not assert internal helper methods, private Stimulus fields, or class names beyond the documented visual contract.Pathogen::Toolbar— Ruby unit test (ViewComponentTestCase): rendersrole=\"toolbar\"; rendersaria-labelfromlabel:; rendersaria-labelledbyfromlabelled_by:; raisesArgumentErrorwhen neither or both are provided; rendersaria-controlswhencontrols:is provided; setsdata-controller=\"pathogen--toolbar\"on the root; passes throughsystem_arguments.Pathogen::Toolbar::Button— Ruby unit test: renders withdata-pathogen--toolbar-target=\"item\"andtabindex=\"-1\"; emitsaria-disabled=\"true\"(and no nativedisabled) whendisabled: true; emitsaria-pressed=\"true\" | \"false\"only whenpressed:is non-nil; rendersPathogen::Buttonunderneath (visuals / size / scheme honored).Pathogen::Toolbar::Separator— Ruby unit test: rendersrole=\"separator\"witharia-orientation=\"vertical\", has nodata-pathogen--toolbar-targetattribute, is not in the tab order.pathogen--toolbarStimulus controller — JS unit test (Vitest + jsdom + real StimulusApplication): on connect, the first non-aria-disableditem hastabindex=\"0\"and the resttabindex=\"-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-disableditems remain in the rotation but aclickis suppressed (no event reaches a downstream handler); ArrowLeft / ArrowRight inside<input type=\"search\">does NOT change focus.test/components/pathogen/tabs/lazy_panel_test.rband the typography tests for component test style andArgumentErrorshape.test/components/pathogen/data_grid_component_test.rbfor ARIA-attribute assertions.test/javascript/controllers/data_grid_controller.test.jsfor the Stimulus controller test pattern — realApplication, manual DOM fixture,KeyboardEventdispatch,afterEachcleanup. This is the canonical example to follow since the tabs controller has no JS test yet.Pathogen::ToolbarPreviewwith 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.erbtemplate undertest/components/previews/pathogen/toolbar_preview/.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.bin/test. JS tests viapnpm test.bundle exec rubocop, Prettier, andi18n-tasks healthmust 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.Pathogen::Tooltip.aria-pressedon click.aria-orientationattribute (horizontal-only v1).Further Notes
aria-controls, naming, and disabled-item behaviour.