Skip to content

feat(ui): M4 core component kit (accessible, RTL-aware, axe-tested) + local dev navigation - #13

Merged
AdilAzhariOmsan merged 8 commits into
mainfrom
feat/component-kit
Nov 8, 2026
Merged

AdilAzhariOmsan merged 8 commits into
mainfrom
feat/component-kit

Conversation

@AdilAzhariOmsan

Copy link
Copy Markdown
Collaborator

M4 core component kit: 14 accessible, RTL-aware Vue components in resources/js/shared/ui/.

  • Each has Vitest tests, keyboard and focus checks where they apply, and an axe check.
  • A local-only component gallery shows them in en/ms/ar.
  • Dependency rule F1 is enforced.
  • A local-only dev navigation bar makes the app clickable when run locally.
  • One owner-approved dev dependency: axe-core.

Merge condition: this PR merges only when both PHP 8.5 / Laravel 13 and Node 24 / Vue 3 are green on the final head commit (follow-up F-1 from #10).

Summary

Eight commits on main (7b1fdea):

  1. test(ui): axe accessibility checks and shared Vitest setup
  2. feat(ui): form field and controls
  3. feat(ui): buttons, links and display components
  4. feat(ui): dialog, toasts and pagination
  5. test(rules): surface boundaries (dependency rule F1)
  6. feat(dev): local-only component gallery
  7. docs(engineering): component kit and F1 enforcement
  8. feat(dev): local-only development navigation bar

Each commit passes Vitest on its own.

Related Issues

N/A

Requirement

M4 (core component kit), per the Phase 3 milestone plan:

  • NFR-A11Y-01/02/04 (WCAG 2.2 AA, keyboard and focus, labelled controls with aria-describedby, automated axe);
  • NFR-I18N-02 (RTL);
  • dependency rules F1 and F3.

Type of Change

  • Feature
  • Tests
  • Documentation

What Changed

Components (resources/js/shared/ui/)

Component Notes
AppButton primary / secondary / ghost / danger; sm / md / lg (md and lg meet the 44px target); type="button" by default; loading = disabled + aria-busy + hidden spinner + announced "Loading"
TextLink Inertia link, or external (new tab, rel="noopener noreferrer", announced "(opens in a new tab)"); can look like a button; aria-current
FormField + TextInput / SelectInput the field provides the control's id, aria-describedby (error first, then hint), aria-invalid and required via provide/inject (NFR-A11Y-02); email / URL / phone inputs are always LTR; native <select> with a logical chevron
CheckboxInput native checkbox with its own label, hint and error
StatusBadge generic tone (neutral / success / warning / danger / info); callers map domain states onto it
SurfaceCard a region labelled by its heading when titled
LoadingSkeleton aria-hidden; pulses only without reduced motion
EmptyState heading, description, optional action and decorative icon
MoneyPrice shows server Money without arithmetic (F3) in an LTR <bdi>; sale and original prices labelled for screen readers
ModalDialog native <dialog> + showModal() (inert background, focus trap, Escape); v-model:open; labelled and described; scroll lock; focus returns to the trigger; optional non-dismissible mode
ToastRegion + toasts.ts polite live region, errors are role="alert"; success and info dismiss after 6s, paused on hover or focus; errors stay until dismissed (WCAG 2.2.1); server toasts through Inertia 3 flash data { toast: { tone, key, params } }, typed via flashDataType and translated in the browser
PageNav labelled <nav> of links; current page marked and not a link; arrows flip in RTL; numbers via formatNumber; optional label for pages with more than one; pageWindow() shows all pages when they fit, otherwise first / last / current±1 with gaps
  • The kit's own strings are in ui.json for en, ms and ar, covered by the M3 coverage test.
  • Component names are multi-word, to stay clear of HTML elements and Inertia's Link.

Accessibility testing (NFR-A11Y-04)

  • axe-core ^4.14.0 (dev only, owner-approved, released 2026-10-05) is called directly from resources/js/test/axe.ts, with no wrapper package.
  • color-contrast is off because jsdom has no layout; token contrast has its own tests (M2). region is off because components are tested outside page landmarks.
  • resources/js/test/setup.ts stubs HTMLDialogElement.showModal / close, which jsdom lacks.

Dependency rule F1

resources/js/rules/surface-boundaries.test.ts:

  • storefront code (storefront/**, pages/Storefront/**) and vendor code (vendor/**, pages/Vendor/**) may import shared code but not each other, and shared code imports neither;
  • it resolves @/ and relative imports, static and dynamic, with a self-check.

ESLint is not installed (owner decision), so F1 runs in Vitest like F4. Both migrate to ESLint if it is approved (documented in dependency-rules.md).

Local-only pages

  • /dev/components (routes/local.php): every component in its main states, with an en / ms / ar switch that runs activateLocale() so <html lang dir> flips as on the storefront.
  • Development navigation bar (resources/views/partials/dev-bar.blade.php):
    • included by app.blade.php only when APP_ENV=local;
    • links to Home in en / ms / ar and to both galleries;
    • plain Blade outside the Vue app, so it adds no JavaScript and no shared props.

Docs

  • code-quality.md gains a "Component kit" section.
  • dependency-rules.md records F1 enforcement and the surface path mapping.

Design / Architecture

  • Native elements first. <dialog>, <select>, checkbox and button give keyboard, screen-reader and mobile behaviour without custom focus management or a headless-UI dependency.
  • Field context through provide/inject. Pages never pass ids around, and error linkage cannot be forgotten.
  • Toasts carry translation keys, not text. Server messages are translated with the same catalogues and coverage rules as the rest of the UI.
  • The dev bar is server-rendered. It can't leak into a production bundle and needs no shared prop. It is checked against F4 (logical classes) like every Blade file.

Testing

  • Unit tests (Vitest + Vue Test Utils for every component, page-window, F1)
  • Feature/integration tests (Pest: galleries and dev bar local-only)
  • Architecture tests (existing suite)
  • Manual verification (Chrome)

Test Evidence

Local, Node 24.22.2 / npm 11.19.1, PHP 8.5, with APP_CONFIG_CACHE pointed at a path that doesn't exist:

Step Result
npm ci ok
npm run build ok
composer check composer.json valid · 0 advisories · Pint passed · PHPStan 0 errors · Pest 45 passed (206 assertions)
npm run typecheck ok
npm test 179 passed (18 files)
npm audit --omit=dev 0 vulnerabilities
AC Evidence
Vitest + Vue Test Utils tests per component, plus axe Every component's test file runs axeViolations(). Removing the dialog close button's aria-label fails with button-name (reverted)
Keyboard and focus Button focusable and clickable; dialog moves focus in, closes on Escape, returns focus to the trigger, unlocks scroll; non-dismissible dialog ignores Escape; toast timers pause on focusin and resume on focusout; checkbox is native and toggled by its label
Gallery renders every component in en / ar / ms Dev/Components.test.ts mounts it in each locale: all six sections present, translated labels (ترقيم الصفحات, Penomboran halaman), dir="rtl" for ar, RM 2,418.50; axe passes on the whole gallery
F1 surface-boundaries.test.ts self-check catches storefront↔vendor and shared→storefront imports (static, dynamic, @/, relative)
Local-only pages Pest: /dev/tokens and /dev/components 404 in production, staging, demo and testing, 200 in local; the dev bar renders on /, /ar and /dev/components in local and is absent on / in the other four
Real browser (Chrome) Dialog: focus inside after 5× Tab, :modal, Escape closes, focus back on the trigger, scroll restored. RTL: layout mirrors, PageNav arrows computed scale: -1 1, no horizontal overflow
Bundle budget Initial storefront JS ≈ 69 kB gzip (≤ 200 kB): dist 65.78 + app 2.62 + one locale's catalogues + Home. CSS 7.51 kB gzip

Bugs found and fixed while building:

  • axe flagged two PageNavs on one page with the same landmark name, so label was added.
  • Chrome showed aisyah@ as @aisyah in an Arabic form, so email, URL and phone inputs became LTR. Both fixes have tests.

Security / Data Isolation

  • No cross-vendor data leakage introduced (no vendor or auth code)
  • Sensitive data handling reviewed:
    • external links use rel="noopener noreferrer";
    • the dev bar and galleries exist only when APP_ENV=local;
    • flash toasts carry translation keys, not HTML, and render as text.
  • axe-core is dev-only (MPL-2.0, never bundled); npm audit --omit=dev is still 0. The full npm audit shows only the two dev-only advisories already recorded in security.md.

Database / Migration

  • No database changes

Screenshots / Recording

Run locally (APP_ENV=local) and use the dev bar at the top: Home (en / ms / ar), Design tokens, Components. The component gallery has its own en / ms / ar switch. Screenshots can be added on request.

Documentation

  • Documentation updated (docs/engineering/code-quality.md, docs/architecture/dependency-rules.md)

Reviewer Notes

  • Focus:
    • ModalDialog (open/close watchers, focus return);
    • toasts.ts timer pause and resume;
    • the FormField context;
    • pageWindow();
    • the F1 import resolver.
  • Known items:
    • jsdom has no <dialog>: the test stub models the behaviour the components rely on, and real-browser behaviour was checked manually; it belongs in the Phase 9 browser tests.
    • The server-flash toast path is typed and tested on the client, but no server route sends a toast yet, so the end-to-end round trip is untested until a feature uses it.
    • The new ms and ar strings in ui.json need native-speaker review, like M3's.
  • Still open from the M3 review (not M4 scope):
    • client-side locale race;
    • hreflang is rendered server-side only, so it goes stale after in-app navigation;
    • F4 regex gaps (gradients, origin-*, object-* / bg-* positions, arbitrary properties).
  • Follow-ups:
    • F-1: make Node 24 / Vue 3 a required check.
    • F-9: F1 and F4 move to ESLint if approved.

Definition of Done

  • Acceptance criteria are satisfied
  • composer check passes locally (validate, audit, Pint, PHPStan, Pest)
  • No unrelated changes
  • No secrets committed
  • Self-review completed
  • Reviewer feedback addressed

- axe-core ^4.14.0 as a dev dependency (owner-approved), called directly;
  no wrapper package. resources/js/test/axe.ts returns readable
  violations (NFR-A11Y-04). colour-contrast is off because jsdom does not
  compute styles; token contrast has its own tests. region is off because
  components are tested outside page landmarks.
- resources/js/test/setup.ts: activates English before each test, cleans
  the DOM after, and stubs HTMLDialogElement.showModal/close, which jsdom
  lacks (open state, focus into the dialog, Escape -> cancel -> close).
- FormField provides its control's id, aria-describedby (error first,
  then hint), aria-invalid and required through provide/inject, so pages
  write <FormField label hint error><TextInput v-model /></FormField>
  (NFR-A11Y-02). Required fields announce "(required)".
- TextInput (email, URL and phone values always LTR so "aisyah@" stays
  intact on Arabic pages), SelectInput (native select, disabled
  placeholder, logical chevron), CheckboxInput (native, own label, linked
  hint and error).
- ui.json catalogues for en/ms/ar with the kit's own strings.

Tests: labelling, describedby order, invalid/required states, v-model,
LTR types, and axe for each control.
- AppButton: primary/secondary/ghost/danger, sm/md/lg (md and lg meet the
  44px target), type="button" by default, loading state with aria-busy,
  a hidden spinner and an announced "Loading".
- TextLink: Inertia link, or an external link opening in a new tab with
  rel="noopener noreferrer" and an announced "(opens in a new tab)";
  can look like a button; aria-current for the current page.
- StatusBadge with a generic tone (neutral/success/warning/danger/info),
  so domain vocabularies map onto it rather than living in it.
- SurfaceCard (a labelled region when titled), LoadingSkeleton
  (aria-hidden, pulse only without reduced motion), EmptyState.
- MoneyPrice: formats server Money without arithmetic (F3) in an LTR
  <bdi>, with screen-reader labels for sale and original prices.

Tests cover behaviour, keyboard reachability and axe for each.
- ModalDialog on native <dialog> with showModal(): inert background,
  focus trap and Escape from the browser; v-model:open; labelled by its
  title and described by its description; scroll lock; focus returns to
  the trigger; optional non-dismissible mode.
- Toasts: a queue (push/dismiss) and ToastRegion, a polite live region
  where errors are role="alert". Success and info dismiss after 6s,
  paused on hover or focus; errors stay until dismissed (WCAG 2.2.1).
  Server responses use Inertia 3 flash data
  ({ toast: { tone, key, params } }), typed via flashDataType and
  translated in the browser.
- PageNav: a labelled nav of links, current page marked and not a link,
  previous/next arrows flipped in RTL, numbers via formatNumber, optional
  label for pages with more than one; pageWindow() decides which pages
  show (all when they fit, otherwise first/last/current±1 with gaps).

Tests cover keyboard (Escape, focus return), timers, flash translation,
RTL labels, the page window and axe.
Storefront (storefront/**, pages/Storefront/**) and vendor (vendor/**,
pages/Vendor/**) code may import shared code but not each other, and
shared code imports neither. ESLint is not installed (owner decision), so
like F4 this runs as a Vitest check: it resolves @/ and relative imports,
static and dynamic, and includes a self-check against known samples.
/dev/components shows every kit component in its main states, with an
en / ms / ar switch that runs activateLocale(), so <html lang dir> flips
as on the storefront. Sample copy is per locale; the components' own
labels come from the real catalogues. Registered from routes/local.php,
so it exists only when APP_ENV=local.

Vitest renders the gallery in all three locales (every section present,
translated labels, RTL for ar) and runs axe on it. Pest
(TokenGalleryTest renamed LocalGalleriesTest) proves both galleries 404
in production, staging, demo and testing and render in local.
- code-quality.md: kit location, components, form wiring, dialogs,
  toasts (client and Inertia flash), money display and test approach.
- dependency-rules.md: F1 is enforced by Vitest until ESLint is
  approved, with the surface path mapping.
The app had no links between its pages, so nothing was clickable when
running it locally. app.blade.php now includes partials/dev-bar only when
APP_ENV=local: a slim, labelled <nav> (English, LTR) linking to Home in
en/ms/ar and to the token and component galleries.

It is plain Blade outside the Vue app (full page loads), so it adds no
JavaScript and no shared props. Pest proves it renders on storefront and
gallery pages in local and is absent in production, staging, demo and
testing.

@AdilAzhariOmsan AdilAzhariOmsan left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

@AdilAzhariOmsan
AdilAzhariOmsan merged commit a2cde2e into main Nov 8, 2026
2 checks passed
@AdilAzhariOmsan
AdilAzhariOmsan deleted the feat/component-kit branch November 8, 2026 23:35
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant