Fore's strongest target market — government/public-sector and GLAM (galleries, libraries, archives, museums) forms — carries some of the strictest accessibility compliance requirements (Section 508, EN 301 549), both of which defer to WCAG 2.1 AA. This document tracks the current state, the fixes shipped as the "foundation" layer, and what's still open.
| Criterion | What it requires | Status |
|---|---|---|
| 1.1.1 Non-text Content | Icons (fx-trigger, fx-control-menu) need accessible names |
Backlog |
| 1.3.1 Info and Relationships | Label/hint/alert programmatically associated with their control | Fixed (foundation) |
| 2.1.1/2.1.2 Keyboard / No Trap | All interactive widgets reachable and escapable via keyboard | Fixed (fx-trigger OK; fx-dialog deprecated in favor of native <dialog>, which provides this natively — see below) |
| 2.4.3 Focus Order | Nonrelevant controls must not be reachable by Tab | Fixed (foundation, via inert) |
| 2.4.6 Labels or Instructions | Every control has a name | Fixed (foundation) |
| 2.4.7 Focus Visible | Custom widgets show a visible focus indicator | Not audited (CSS-level, out of scope) |
| 3.3.1/3.3.2 Error ID / Labels | Validation errors identified and associated with the field; required marker has a text alternative | Fixed (foundation + F3) |
| 3.3.3 Error Suggestion | Error text is descriptive | Author-controlled (alert text is markup, not enforced) |
| 4.1.2 Name, Role, Value | Required/readonly/invalid state exposed to AT | Fixed (foundation) |
| 4.1.3 Status Messages | Dynamic error text, messages and toasts are announced without a focus move | Fixed (fx-alert polite live region, toasts — F1/F2) |
- 1. ModelItem state → ARIA mirroring (
src/ui/abstract-control.js,src/ui/UIElement.js,src/ui/fx-container.js) - 2. Label/hint/alert association (
src/ui/fx-control.js,src/ui/fx-output.js,src/ui/fx-alert.js) - 3. Automated regression gate (
cypress-axe/axe-core,cypress/e2e/accessibility.cy.ts)
1. ModelItem state → ARIA mirroring (src/ui/abstract-control.js, src/ui/UIElement.js, src/ui/fx-container.js)
Previously only aria-invalid was mirrored from ModelItem state (via _syncAriaInvalid()).
required/readonly/relevant had no ARIA equivalent at all.
- Native-first: native form elements (
<input>/<select>/<textarea>/<button>) already exposerequired/readonlyto assistive tech via their native attributes — addingaria-required/aria-readonlyon top would be redundant ARIA. These are now only added whengetWidget()returns a non-native widget (a custom element used viaclass="widget").aria-invalidremains the unconditional exception (no native equivalent exists). - Relevant →
inert: CSS already hides[nonrelevant]elements (display:noneinresources/fore.css), which already removes them from the accessibility tree and tab order.inertis added as a semantic backstop (matching the pattern already used correctly byfx-switch.js's case-toggling) rather thanaria-hidden, which would be redundant given the existing CSS. Implemented once inUIElement._reflectRelevantInert(), called from bothAbstractControl.handleRelevant()(controls) andFxContainer.handleRelevant()(containers) — two call sites because containers don't inherit fromAbstractControl. - Removed the duplicate inline
aria-invalidsets insidehandleValid()'s branches — the unconditional_syncAriaInvalid()call at the end of the method already did this; this also resolved a long-standing// TODO: duplicate codecomment.
Only fx-items.js (checkbox/radio lists) had working for/id label association; the mainline
fx-control/fx-output controls had none.
fx-control's light-DOM<label>child form (<fx-control><label>Name</label>...) now gets a generated/reused id wired viafor/id— both label and widget are light-DOM children slotted into the same tree, so this works.fx-control'slabel="..."attribute form is rendered as plain text into the shadow root (a different tree than the light-DOM widget), so an id reference can't cross that boundary — this form now setsaria-labelon the widget instead (a string, which does cross the boundary).fx-output's named<slot name="label">feeds a shadow-DOM<span>widget — same cross-boundary problem, same fix:aria-labelderived from the slotted label's text via aslotchangelistener.- Statically-authored
<fx-hint>/<fx-alert>children (and dynamically-created alerts from a<fx-bind>'salertattribute) are now wired into the widget'saria-describedby, appended without clobbering any existing value. The reference is assigned once, when the id is first established, rather than added/removed on every valid/invalid transition — the alert element persists in the DOM and is purely CSS-hidden when inactive, anddisplay:nonecontent isn't exposed via the accessible description anyway, so toggling the reference would add complexity with no behavioral benefit. fx-alertsets a live-region role on its host element, so validation errors are announced by assistive tech without requiring a focus move (initially assertive; now polite by default — see F1 below).
cypress-axe + axe-core added as devDependencies, wired into cypress/support/e2e.ts. New
spec cypress/e2e/accessibility.cy.ts runs an automated WCAG 2.1 A/AA check against
demo/controls/email.html (fx-control + label + fx-items + fx-dialog),
demo/controls/fx-output.html (output-label path), and — new —
demo/controls/fx-repeat.html (a populated repeat using slot="header" for a non-repeated column
header, plus a zero-item repeat), added as standing coverage for the fx-repeat list-semantics
work below.
demo/controls/ui.html was the original intended primary target but turned out to be broken
independent of accessibility: it has no <script> tag loading Fore at all (so none of its custom
elements ever upgrade) and references model paths (to/email, subject, message,
attachments) with no <fx-model>/<fx-instance> defining them. It also had a stray <<fx-fore>
typo (fixed in passing — a trivial one-character correction). Making the page actually functional
is a separate, unrelated fix and out of scope here; demo-with-issues.md should be updated to
list it.
| Component | Current state | Fix layer |
|---|---|---|
fx-control |
Label association, aria-label, hint/alert aria-describedby |
Fixed |
fx-output |
aria-label from slotted label |
Fixed |
fx-items |
Already had working for/id association (the one correct example pre-existing) |
n/a |
fx-trigger |
Has role="button", tabindex, keyboard activation (Space/Enter) — best-covered control already; no aria-pressed/aria-busy for toggle/async semantics |
Backlog |
fx-upload |
No ARIA beyond inherited Layer 1 mirroring | Covered by Layer 1 only |
fx-alert |
Polite role="status" by default, opt-in politeness="assertive", aria-atomic, wired aria-describedby |
Fixed |
fx-hint |
Wired into aria-describedby; itself @deprecated in source, not restructured |
Fixed (association only) |
fx-group |
role="group" already set; no aria-label/aria-labelledby tying it to a visible group label |
Backlog |
fx-switch/fx-case |
Correct inert toggling; opt-in appearance="tabs" adds role="tablist"/"tab"/"tabpanel", aria-selected, roving tabindex, arrow-key navigation |
Fixed (opt-in) |
fx-dialog |
Deprecated in favor of native <dialog> (fx-show/fx-hide work against it unchanged), which provides aria-modal, focus trap, and Escape-to-close natively |
Fixed (via deprecation) |
fx-repeat/fx-repeatitem |
role="list"/"listitem", aria-posinset/aria-setsize, row-unique label ids; tabindex no-op fixed |
Fixed |
fx-control-menu |
role="menu"/"menuitem", aria-haspopup/aria-expanded on the trigger, roving-tabindex arrow-key navigation; on-demand hide buttons uniquely named, focus + announcement on hide |
Fixed |
Each of these was genuine per-component widget-pattern work — it didn't fit the "implement once
in a shared choke point" model the foundation layer used, because these controls either bypass
AbstractControl's handlers or need bespoke DOM structure a generic mixin can't infer. All four
are now done:
- 1.
fx-switch/fx-casetab semantics (src/ui/fx-switch.js,src/ui/fx-trigger.js) — see write-up below. - 2.
fx-repeatlist semantics (src/ui/fx-repeat.js,src/ui/fx-repeatitem.js) — see write-up below. - 3.
fx-dialogfocus trap — resolved by deprecation rather than a hand-rolled trap; see write-up below. - 4.
fx-control-menumenu semantics —role="menu"/"menuitem",aria-haspopup,aria-expandedon the trigger, roving-tabindex arrow-key navigation. See write-up below.
fx-switch is polymorphic — it's used as a bound panel switcher (driven by a <select>, no
trigger children at all), as a wizard, as an accordion (fx-trigger/fx-case pairs interleaved
as children, each trigger immediately followed by its own panel), and as tabs. A real ARIA
tablist may only own tab children, and fx-trigger↔fx-case linkage isn't structural — it's
a by-ID reference buried in fx-toggle's case attribute, resolved via
ownerForm.querySelector('#id'). Applying role="tablist"/"tab"/"tabpanel" unconditionally
would put invalid ARIA on wizards, accordions, and bound switches (axe's aria-required-children
would flag the accordion pattern specifically, since its panels sit inside the switch as
siblings of the triggers). So this ships as an opt-in appearance="tabs" attribute rather
than default behavior on every fx-switch — see demo/switch-tabs.html.
- With
appearance="tabs",fx-switchrenders<div role="tablist"><slot name="tab"></slot></div>followed by the regular default slot, in its shadow root. Direct-childfx-triggers that carry anfx-toggletargeting a siblingfx-caseare assignedslot="tab"— this regroups them under the tablist in the flattened (accessibility) tree without moving them in the light DOM, so document order and existing markup stay untouched. - Each such trigger's widget gets
role="tab",aria-controls(pointing at its case), and a rovingtabindex(0for the selected tab,-1for the rest); its targetfx-casegetsrole="tabpanel",aria-labelledby(pointing back at the tab), andtabindex="0". IDs are generated viaFore.createUUID()when missing, matching the pattern already used for label/hint association in the foundation layer. fx-trigger.js's defaultrole="button"assignment (in itsslotchangehandler) is now guarded to skip elements that already carry an explicitrole— a one-line change needed sofx-switch'srole="tab"isn't raced/overwritten back to"button".- Arrow-key navigation (Left/Right/Up/Down/Home/End) is wired via a single delegated
keydownlistener onfx-switch, following the WAI-ARIA "automatic activation" tabs pattern: moving focus to a tab also activates its case (calls the trigger's ownperformActions(), so it goes through the normalfx-togglepath rather than duplicating switch logic). - Plain
fx-switchusage (noappearance="tabs") is completely unaffected — the attribute is read once inconnectedCallbackand everything described above is skipped when absent.
Unlike fx-switch, fx-repeat isn't polymorphic — it only ever means "repeat this template once
per bound node" — so role="listitem" ships unconditionally on every fx-repeatitem, the same way
fx-group unconditionally sets role="group".
- Each
fx-repeatitemsetsrole="listitem"on itself inconnectedCallback. role="list"does not go on thefx-repeathost itself.fx-repeatsupports a namedslot="header"for non-repeated content rendered alongside the repeated items (e.g.demo/api.html's<table slot="header">column-header row). Ifrole="list"were on the host, that header content would land in the list's accessibility subtree in the flattened tree (Shadow DOM slotting doesn't change the light-DOM parent, but it does change the accessibility tree), and axe'saria-required-childrenflags it: a<table>(or anything with a role other thanlistitem/group) isn't an allowed child ofrole="list"— confirmed empirically with a throwawaycy.checkA11yrun before landing this. So, matching the same wrapper techniquefx-switch's tabs mode uses forrole="tablist", the shadow root now wraps only the default slot (wherefx-repeatitems land) in<div part="list" role="list">, leaving<slot name="header">outside it:An empty repeat (nodeset resolves to zero items, e.g. an "add your first item" pattern) was also checked with axe — a<slot name="header"></slot> <div part="list" role="list"><slot></slot></div>
role="list"wrapper with zerolistitemchildren does not triggeraria-required-children, so no extra guard was needed for that case.- The wrapper
<div>also neededdisplay: contents(added infx-repeat.js's shadow<style>). A bare<slot>renders as layout-transparent (display: contents-like) by default, so demos that putdisplay: flex/griddirectly on thefx-repeathost — e.g.demo/kanban.html's#column { display: flex }, used to lay out the board's columns side by side — relied on the repeatedfx-repeatitems being direct flex items of the host. Withoutdisplay: contentson the wrapper, the wrapper itself becomes the sole flex item and the columns collapse into a vertical stack instead of a row — a regression that every automated test in this repo (unit, axe, and the functionalkanban.cy.tsdrag/drop specs) sailed straight through, because none of them assert computed layout. Caught by renderingdemo/kanban.htmlin a real browser and diffinggetBoundingClientRect()of the columnfx-repeatitems before/after (sametop, increasingleftconfirms the row layout held).display: contentshas a history of dropping the element's role from the accessibility tree in some browsers, so this was re-verified with a scopedcy.checkA11y('#column', { runOnly: ['aria-required-children', 'aria-required-parent'] })run against the live kanban page — both rules passed, confirmingrole="list"still parents thelistitems correctly through thedisplay: contentswrapper. - The unrelated table-repeat widget (
fx-repeat-attributes.js, driven bytable[data-ref], producing real<tr class="fx-repeatitem">rows) is untouched — real table rows already carry an implicitrole="row"and forcingrole="listitem"onto them would be invalid ARIA. That widget doesn't use the<fx-repeatitem>custom element at all, so it's unaffected by this change. - Fixed
fx-repeatitem.js'sthis.tabindex = 0—tabindex(lowercase) is not a reflected IDL property onHTMLElement(only the camelCasetabIndexis), so the assignment was a silent no-op that created a throwaway JS property instead of thetabindexattribute. Repeat items were therefore never keyboard-focusable even though afocusinlistener already existed to react to them being focused (_dispatchIndexChange). Replaced withsetAttribute('tabindex', '0'), which makes every repeat item Tab-reachable and activates that existing focus-handling path. Note this is a behavior change beyond "fixing a bug that did nothing": a large plain-data repeat now adds one tab stop per row (previously onlydraggableitems were focusable, via a separatesetAttribute('tabindex', 0)in_createNewRepeatItem) — sensible for master-detail row selection (the patternfocusin→_dispatchIndexChangealready existed for), but worth knowing if a very long static list ever feels noisy under keyboard/AT navigation.
fx-dialog is a hand-rolled modal (role="dialog" + a .show class toggled by fx-show/
fx-hide) with real gaps: aria-modal is hardcoded "false" and never updated, there's no focus
trap, focus is set once on connectedCallback rather than on open, and the Escape-key handler in
showModal() calls a this.hide() method that doesn't exist on the class (a silent no-op, so
Escape never actually closed it).
Building a correct focus trap (tracking tab order, wrapping at the first/last focusable element,
capturing/restoring the triggering element, real aria-modal toggling) reimplements behavior the
native HTML <dialog> element already provides for free: showModal() traps focus, sets the
accessibility tree to modal, honors autofocus, and Escape-to-close is built in — and fx-show/
fx-hide already work against <dialog> unchanged, since they only call the shared
.showModal()/.close() method names via resolveId() (demo/dialog.html already demonstrated
this pattern). Patching fx-dialog to reimplement what the platform does natively (and arguably
worse, since hand-rolled traps are a common source of new a11y bugs) isn't worth it next to
switching to <dialog>.
src/ui/fx-dialog.jsis now marked@deprecatedin its JSDoc, recommending native<dialog>. No functional change was made to the element itself — it keeps working as-is for any external pages still using it.- Every in-repo demo using
<fx-dialog>was migrated to native<dialog>:demo/controls/ fx-dialog.html,demo/controls/email.html,demo/generator/view-editor.html, anddemo/tp/layout-templates/simple-index.html. The migration is markup-only — swap the tag name, and (where a decorative.close-dialoglink existed) wrap it in anfx-trigger/fx-hidepair like the other close buttons, sincefx-dialog.jsused to special-case that class internally via a rawclicklistener and native<dialog>has no equivalent. resources/fore.css(and its byte-identical vendored copydemo/edep/css/fore.css) gained a paralleldialog/.dialog-content/.close-dialog/.action/::backdroprule set alongside the pre-existing (now legacy)fx-dialogrules, so migrated demos keep the same visual language. Centering/sizing rules thatfx-dialogneeded (position:fixed, manualtranslatecentering) were dropped — native<dialog>centers itself via the UA stylesheet.test/dialog.test.jswas updated to author<dialog>markup and assertdialog.openinstead ofdialog.classList.contains('show'); thedialog-shown/dialog-hiddenevent assertions are unchanged since those events are dispatched by thefx-show/fx-hideaction classes, not byfx-dialog.js.- The automated axe gate (
cypress/e2e/accessibility.cy.ts) was not extended to cover a dialog page — the demos above aren't in its page list, and a real coverage addition would need to open the dialog before scanning (adisplay:none/closed dialog is skipped by axe) plus giving the dialog an accessible name (aria-labelledbypointing at its heading), which native<dialog>doesn't provide by default either. Left as a future addition, not bundled into this deprecation.
fx-control-menu renders a slotted trigger <button> plus a shadow-DOM popup listing
currently-[on-demand] targets as plain <a> entries for activation (e.g. "add a field back
after it was hidden"). It's a single, non-polymorphic widget — every instance is the same ARIA
"menu button" pattern, so this ships unconditionally (unlike fx-switch's opt-in tabs mode,
which had to accommodate several unrelated usages of the same element).
- The trigger
<button>getsaria-haspopup="true"andaria-expanded(toggled open/close).aria-controlswas deliberately not added: the button is light-DOM (slotted) while the popup lives in the shadow root, and an IDREF attribute can't cross that boundary — anaria-controlspointing at a shadow-DOM id would just resolve to nothing for assistive tech, the same cross-boundary problem the foundation layer's label association hit (see above, "label" attribute form).aria-controlsis optional in the APG menu-button pattern, so it's omitted rather than shipped as a dangling reference. Previouslyslotchangere-attached the click handler on every re-slot with no de-dupe guard; abutton !== this.triggerButtoncheck was added so the ARIA attributes and reference are only (re-)wired once per button. - The popup (
div.menu) getsrole="menu"; each generated entry getsrole="menuitem"and a rovingtabindex(0on the first item,-1on the rest, mirroring the pattern already used forfx-switch's tabs). - Opening the menu moves focus to the first item; Up/Down/Home/End move a roving tabindex between
items while open (
_handleMenuKeydown), following the WAI-ARIA menu pattern. Closing via Escape or an outside click restores focus to the trigger button; closing via item selection deliberately does not steal focus back to the trigger, sincefx-control.activate()already moves focus to the newly-revealed widget one frame later — restoring focus to the button first would just have the widget steal it back immediately after, which reads as double focus movement to AT users. - Opening/closing/disabling is now centralized in
_openMenu()/_closeMenu()rather than togglingclassList/aria-expandedad hoc at each of the four call sites (button click, outside click, Escape, zero-targets inupdateMenu()), so the ARIA state can't drift out of sync with the visible state.
Findings from an external accessibility audit of a Fore-generated page (BITV 2.0 / EN 301 549 / WCAG 2.1 AA). All five are addressed.
- F1 —
fx-alertpoliteness (src/ui/fx-alert.js) — alerts now default torole="status"+aria-live="polite", so tabbing through several invalid fields no longer interrupts the screen reader.<fx-alert politeness="assertive">restoresrole="alert"/aria-live="assertive"for urgent messages. This supersedes the earlier "always assertive" behaviour described in foundation item 2. - F2 — toast live regions (
jinn-toast,src/fx-fore.js,src/actions/fx-message.js) — toasts render into a persistent live region (role="status"/polite, orrole="alert"forpoliteness="assertive"); the error toast infx-foreis assertive. The close button has a localisablearia-label. - F3 — required marker text alternative (
resources/fore.css) — the*onfx-control[required] labeluses CSS alt text (content: "*" / " required"), overridable per project/language via--fore-required-markerand--fore-required-text. Guarded by@supports, so browsers without alt-text support keep the plain*. - F4 —
on-demandcontrols (src/ui/UIElement.js,fx-control.js,fx-control-menu.js) — each hide (trash) button is a keyboard-operablerole="button"with a unique, label-based name ("Hide Telephone"), iconsvgisaria-hidden. Hiding with Enter moves focus to thefx-control-menutrigger and announces " hidden" via a polite announcer ([data-fore-announcer]); menu items are named from the control'slabel/aria-label. - F5 — repeats (
fx-control.js,fx-repeatitem.js) — labelfor/widgetidpairs inside a repeat get a row-unique id, so each row's label targets its own widget (authored ids of Fore elements are left intact because Fore resolves them per row).fx-repeatitemexposesaria-posinset/aria-setsize(full set size even for size-capped/virtual repeats, renumbered after deletes), and repeated buttons get row-specific names.
What's left after the foundation layer, the four P1 widget-pattern fixes and the F1–F5 audit follow-ups above. None of these block the current release; they're the next candidates when accessibility work resumes.
- 1. Icon accessible names (1.1.1) — icon-only controls (
fx-trigger,fx-control-menu) have no mechanism for naming a bare icon glyph. Needs a convention decision (e.g. anicon-labelattribute, or requiring authors to supply visible/SR-only text) before it's an implementation task — confirmed no icon-related ARIA code currently exists in either file. - 2.
fx-grouplabelling —role="group"is set unconditionally (src/ui/fx-group.js:39) but there's noaria-label/aria-labelledbytying the group to a visible heading, so AT users hit an unnamed group. Same shape as the label-association work already done forfx-control/fx-output: preferaria-labelledbywhen a heading child exists, fall back toaria-label. - 3.
fx-triggertoggle/async state — noaria-pressedoraria-busy; confirmed absent fromsrc/ui/fx-trigger.js. Only relevant iffx-triggerhas actual toggle semantics somewhere in the codebase — scope that before building it. - 4.
fx-upload— confirmed zero ARIA beyond inherited Layer 1 mirroring (required/readonly/invalid); no exposure of selected-file, progress, or upload-error state. Real widget-pattern work, not a shared choke point — largest item on this list. - 5. Manual keyboard-only and screen-reader pass — carried over from the foundation layer's
verification checklist (still unperformed): VoiceOver/Safari and NVDA/Firefox-or-Chrome
pass on
demo/controls/email.html, confirming labels are announced, validation errors are announced without a focus move, nonrelevant-then-focused controls are unreachable by Tab, and hint/alert text is included in the field's accessible description. The axe gate only catches missing ARIA, not whether the result actually reads correctly — do this before adding more surface area (items 1–4 above).
Out of scope, unchanged from the foundation layer:
- 2.4.7 Focus Visible — CSS-level, not audited.
- 3.3.3 Error Suggestion — author-controlled (alert text is markup, not enforced).
-
npm test— karma/mocha unit suite: 1232 passing (incl. new assertions foraria-required,aria-readonly,aria-invalid, label association,aria-describedby,inert, thefx-alertlive-region attributes,fx-switch'sappearance="tabs"role/aria-selected/roving-tabindex wiring and arrow-key navigation intest/switch.test.js, and — new —fx-repeat/fx-repeatitem'srole="list"/"listitem"and the fixedtabindexintest/repeat.test.js). -
npx cypress run— full e2e suite: 54 specs / 154 tests (153 passing, 1 skipped), including the pre-existingnative-validation.cy.jsandbinding.valid-relevant.cy.js(both assertaria-invalid). - Audit follow-ups F1–F5 are covered by unit tests:
test/on-demand-a11y.test.js,test/repeat-a11y.test.js,test/required-marker.test.js. - Manual screen-reader pass (VoiceOver/Safari, NVDA/Firefox) — done.