Skip to content

Accessibility (A11y) Decision Matrix: When to Use What

This guide establishes the architectural and accessibility standards for mutually exclusive switchers, tabs, action groups, and navigation controls across Waldur HomePort. It provides explicit decision trees and code recipes for developers and AI coding agents (LLMs).


1. Quick Decision Rules

Follow this decision order to determine the correct component:

  1. Does it change the URL or navigate to another page/state?
    ➔ Navigation Link (@/core/Link or <nav> + <a>).
  2. Does it trigger an immediate action, preset, or calculation?
    ➔ Action Button Group (BaseButton with role="group" and optional aria-pressed).
  3. Can multiple options be active simultaneously?
    ➔ Multi-select Buttons / Checkboxes (role="group" with aria-pressed or Checkbox).
  4. Does each option own a distinct, large content panel?
    ➔ Tabs Pattern (Radix Tabs styled via segmentedListClassName / segmentedItemClassName).
  5. Is it a question in a submission form with validation errors?
    ➔ Form RadioGroup (RadioGroup inside <fieldset> + <legend>).
  6. Does it switch a display lens, interval, or in-memory list filter?
    ➔ View Lens / Filter (SegmentedControl with role="radiogroup").

2. Comparative Matrix

UI Pattern Standard Component Underlying Semantics Keyboard Model Valid Contexts Prohibited Anti-Patterns
View Lens / Filter SegmentedControl (waldur-ui) role="radiogroup"
role="radio"
aria-checked
Single Tab stop;
← / → select option
Filtering a list in place (All/Unread), chart intervals (Day/Month), diff modes (Table/JSON) Never use for actions (+1h extend), routing tabs, or tab panels
Action Preset / Shortcut Group of BaseButton (waldur-ui) role="group"
<button>
aria-pressed
Normal Tab stop per button;
Enter / Space activates
Offset buttons (+30m, +1h), preset calculators, filter clearers Never use SegmentedControl (radios imply persistent state, not actions)
Tabbed Panels Radix Tabs (or Tab.Container) role="tablist"
role="tab"
role="tabpanel"
aria-controls
Single Tab stop;
← / → moves tabs;
Space / Enter activates
Swapping major DOM sections (e.g. Overview vs Settings vs Audit Log) Never use SegmentedControl without role="tabpanel" and aria-controls
Page Navigation @/core/Link or <nav> + <a> <nav aria-label="...">
<a href="...">
Normal Tab per link;
Enter navigates;
Right-click "Open in new tab"
UI-Router state tabs (TableTabs), page headers, sidebar links Never use SegmentedControl or <button> for URL transitions
Form Question RadioGroup (waldur-ui) <fieldset>
<legend>
native <input type="radio">
Single Tab stop;
Arrow keys select
Standard form questions with error messages and form serialization Don't use SegmentedControl when validation errors or descriptions are needed
Multi-Select Filter Button group with aria-pressed role="group"
<button aria-pressed="...">
Tab between buttons;
Space toggles
Filter tags, multi-select chips, facet bars SegmentedControl strictly supports one active value

3. Pattern Implementation Guides

Pattern 1: View Lens / Display Filter (SegmentedControl)

Use when changing how current data is viewed without navigating away or unmounting surrounding layout.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// ✅ CORRECT: View lens in a table or list header
import { useMemo, useState } from 'react';
import { SegmentedControl, SegmentedControlOption } from 'waldur-ui';
import { translate } from '@/i18n';

type ViewMode = 'table' | 'json';

export const MyDiffViewer = () => {
  const [viewMode, setViewMode] = useState<ViewMode>('table');

  const options: SegmentedControlOption<ViewMode>[] = useMemo(
    () => [
      { value: 'table', label: translate('Table') },
      { value: 'json', label: translate('JSON') },
    ],
    [],
  );

  return (
    <SegmentedControl<ViewMode>
      aria-label={translate('Diff view')}
      size="sm"
      options={options}
      value={viewMode}
      onValueChange={setViewMode}
    />
  );
};

A11y Rules for SegmentedControl:

  1. Mandatory accessible name: Must supply aria-label or aria-labelledby. Screen readers announce: "{aria-label}, radio group. Table, radio button, checked, 1 of 2".
  2. Value must never be empty: If state can be cleared, use toggle buttons, not a radio group.
  3. No action side-effects: Selecting an option should only update the view lens/filter parameter.

Pattern 2: Action Presets & Shortcuts (BaseButton Group)

Use when buttons trigger an immediate operation or calculation (e.g. calculating dates, triggering presets).

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
// ✅ CORRECT: Quick extend action buttons
import { BaseButton } from 'waldur-ui';
import { translate } from '@/i18n';

const PRESETS = [
  { key: '30m', label: '+30 min', minutes: 30 },
  { key: '1h', label: '+1 h', minutes: 60 },
  { key: '2h', label: '+2 h', minutes: 120 },
];

export const QuickExtendSection = ({ onApply, activeKey, submitting }) => (
  <div
    role="group"
    aria-label={translate('Quick extend options')}
    className="d-flex flex-wrap gap-2"
  >
    {PRESETS.map((preset) => {
      const isSelected = activeKey === preset.key;
      return (
        <BaseButton
          key={preset.key}
          size="sm"
          variant={isSelected ? 'secondary' : 'tertiary'}
          disabled={submitting}
          disabledReason={
            submitting ? translate('Submission in progress') : undefined
          }
          onClick={() => onApply(preset.key, preset.minutes)}
          label={preset.label}
          aria-pressed={isSelected}
        />
      );
    })}
  </div>
);

Why SegmentedControl is an Anti-Pattern here:

  • In Radix RadioGroup, onValueChange only triggers when changing to a different value. If a user clicks +30 min, manually adjusts the input, and clicks +30 min again, RadioGroup drops the interaction.
  • Screen readers announce "Radio button, checked", misleading users to believe they are selecting a form field setting rather than executing an action.

Pattern 3: Segmented Tab Panels (Radix Tabs with Segmented Styling)

When you want the sleek segmented look (inset shadows, connected borders) for full tab panels:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// ✅ CORRECT: Real tabs with segmented styling
import * as Tabs from '@radix-ui/react-tabs';
import { segmentedListClassName, segmentedItemClassName } from 'waldur-ui';
import { translate } from '@/i18n';

export const SettingsTabs = () => (
  <Tabs.Root defaultValue="account">
    <Tabs.List className={segmentedListClassName({ fullWidth: true })}>
      <Tabs.Trigger
        value="account"
        className={segmentedItemClassName({ size: 'md', fullWidth: true })}
      >
        {translate('Account')}
      </Tabs.Trigger>
      <Tabs.Trigger
        value="security"
        className={segmentedItemClassName({ size: 'md', fullWidth: true })}
      >
        {translate('Security')}
      </Tabs.Trigger>
    </Tabs.List>

    <Tabs.Content value="account">{/* Account panel */}</Tabs.Content>
    <Tabs.Content value="security">{/* Security panel */}</Tabs.Content>
  </Tabs.Root>
);

Why this is superior to SegmentedControl:

  • Emits WAI-ARIA role="tablist", role="tab", and role="tabpanel".
  • Establishes aria-controls links from triggers to content containers.
  • Assistive technologies announce "Tab 1 of 2" and support shortcuts to navigate into the active panel.

When selecting a tab changes the URL or router state (router.stateService.go):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
// ✅ CORRECT: URL navigation bar
import { Nav } from 'react-bootstrap';
import { Link } from '@/core/Link';

export const NavigationBar = ({ tabs, activeState }) => (
  <nav aria-label={translate('Secondary navigation')}>
    <Nav variant="tabs" className="nav-line-tabs">
      {tabs.map((tab) => (
        <Nav.Item key={tab.key}>
          <Link
            state={tab.state}
            className={`nav-link ${tab.state === activeState ? 'active' : ''}`}
          >
            {tab.title}
          </Link>
        </Nav.Item>
      ))}
    </Nav>
  </nav>
);

Prohibited Anti-Pattern:

  • Never replace routing links with SegmentedControl or plain <button> elements. Doing so breaks middle-click, "Copy link address", search-engine indexing, and screen reader link announcements.

4. Checklist for Developers & LLMs

Before choosing or migrating a switcher component, run this checklist:

  • What happens on click?
  • Updates URL/route? ➔ Use Link / <nav>.
  • Triggers calculation or modal action? ➔ Use BaseButton (role="group" + aria-pressed).
  • Swaps a large tab panel? ➔ Use Radix Tabs with segmentedItemClassName.
  • Filters or changes view parameters in place? ➔ Use SegmentedControl.
  • Does SegmentedControl have an accessible label?
  • Every <SegmentedControl> must have aria-label={translate('...')} or aria-labelledby.
  • Are disabled buttons compliant?
  • Any disabled BaseButton must supply disabledReason or tooltip per the waldur-custom/enforce-disabled-button-tooltip lint rule.
  • Is single-selection strictly guaranteed?
  • If the user can deselect the option, do not use SegmentedControl.