From 75a01c0c4e3e709abf4585b902c4455772b24adc Mon Sep 17 00:00:00 2001 From: AdilAzhariOmsan Date: Sun, 8 Nov 2026 21:15:31 +0800 Subject: [PATCH 1/8] test(ui): add axe accessibility checks and shared Vitest setup - 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). --- package-lock.json | 13 ++++- package.json | 1 + resources/js/test/axe.ts | 20 +++++++ resources/js/test/setup.ts | 53 +++++++++++++++++++ ...GalleryTest.php => LocalGalleriesTest.php} | 0 vitest.config.ts | 1 + 6 files changed, 87 insertions(+), 1 deletion(-) create mode 100644 resources/js/test/axe.ts create mode 100644 resources/js/test/setup.ts rename tests/Feature/{TokenGalleryTest.php => LocalGalleriesTest.php} (100%) diff --git a/package-lock.json b/package-lock.json index e6a233d..b857ea0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,5 +1,5 @@ { - "name": "nexo-m1", + "name": "nexo-m4", "lockfileVersion": 3, "requires": true, "packages": { @@ -14,6 +14,7 @@ "@vitejs/plugin-vue": "^6.0.10", "@vue/test-utils": "^2.5.1", "@vue/tsconfig": "^0.9.1", + "axe-core": "^4.14.0", "concurrently": "^10.0.3", "jsdom": "^29.1.1", "laravel-vite-plugin": "^3.1", @@ -1487,6 +1488,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/axe-core": { + "version": "4.14.0", + "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.14.0.tgz", + "integrity": "sha512-9WTZxEjsZ7b13TH8JPmbV2z8CHbl80/2hm3XPEG4JgNdQLK81IBRXmSxHfMAOkSqQeRxT/0dwNDz2GOm3zzpcQ==", + "dev": true, + "license": "MPL-2.0", + "engines": { + "node": ">=4" + } + }, "node_modules/balanced-match": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", diff --git a/package.json b/package.json index 013d34b..f09b7d8 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "@vitejs/plugin-vue": "^6.0.10", "@vue/test-utils": "^2.5.1", "@vue/tsconfig": "^0.9.1", + "axe-core": "^4.14.0", "concurrently": "^10.0.3", "jsdom": "^29.1.1", "laravel-vite-plugin": "^3.1", diff --git a/resources/js/test/axe.ts b/resources/js/test/axe.ts new file mode 100644 index 0000000..5a245e2 --- /dev/null +++ b/resources/js/test/axe.ts @@ -0,0 +1,20 @@ +// Runs axe-core against a mounted component (NFR-A11Y-04) and returns its +// violations as readable strings, so a failing test shows what and where. +// +// colour-contrast is off: jsdom does not compute styles, and token contrast +// is enforced by resources/js/design/tokens-contrast.test.ts. `region` is off +// because components are tested outside page landmarks. +import axe from 'axe-core'; + +export async function axeViolations(element: Element): Promise { + const results = await axe.run(element, { + rules: { + 'color-contrast': { enabled: false }, + region: { enabled: false }, + }, + }); + + return results.violations.flatMap((violation) => + violation.nodes.map((node) => `${violation.id}: ${violation.help} → ${node.target.join(' ')}`), + ); +} diff --git a/resources/js/test/setup.ts b/resources/js/test/setup.ts new file mode 100644 index 0000000..9d87400 --- /dev/null +++ b/resources/js/test/setup.ts @@ -0,0 +1,53 @@ +// Vitest setup (vitest.config.ts → setupFiles). +import { afterEach, beforeEach } from 'vitest'; +import { activateLocale } from '@/i18n'; + +// jsdom has no HTMLDialogElement.showModal/close. This stand-in models what +// the components rely on: `open`, focus moving into the dialog, Escape +// firing a cancelable `cancel`, and `close`. Real browser behaviour (inert +// background, focus trap) is checked manually and in Phase 9 browser tests. +const dialogProto = globalThis.HTMLDialogElement?.prototype as HTMLDialogElement | undefined; + +if (dialogProto && typeof dialogProto.showModal !== 'function') { + const onKeydown = (event: KeyboardEvent) => { + const dialog = event.currentTarget as HTMLDialogElement; + + if (event.key === 'Escape' && dialog.open) { + const cancel = new Event('cancel', { cancelable: true }); + + if (dialog.dispatchEvent(cancel)) dialog.close(); + } + }; + + Object.assign(dialogProto, { + showModal(this: HTMLDialogElement) { + this.setAttribute('open', ''); + this.addEventListener('keydown', onKeydown); + const target = this.querySelector('[autofocus]') ?? this.querySelector( + 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])', + ); + target?.focus(); + }, + close(this: HTMLDialogElement) { + if (!this.hasAttribute('open')) return; + this.removeAttribute('open'); + this.removeEventListener('keydown', onKeydown); + this.dispatchEvent(new Event('close')); + }, + }); + Object.defineProperty(dialogProto, 'open', { + get(this: HTMLDialogElement) { + return this.hasAttribute('open'); + }, + configurable: true, + }); +} + +beforeEach(async () => { + await activateLocale('en'); +}); + +afterEach(() => { + document.body.innerHTML = ''; + document.documentElement.style.overflow = ''; +}); diff --git a/tests/Feature/TokenGalleryTest.php b/tests/Feature/LocalGalleriesTest.php similarity index 100% rename from tests/Feature/TokenGalleryTest.php rename to tests/Feature/LocalGalleriesTest.php diff --git a/vitest.config.ts b/vitest.config.ts index b29c413..8bf100e 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -12,6 +12,7 @@ export default defineConfig({ test: { environment: 'jsdom', include: ['resources/js/**/*.test.ts'], + setupFiles: ['resources/js/test/setup.ts'], // Let tests read stylesheets as text (`?raw`); by default Vitest // stubs CSS out and a raw import is an empty string. css: { include: [/resources\/css\/.+\.css/] }, From 27ee09f70ae1a29f7db4b3a3b3887382568db6a6 Mon Sep 17 00:00:00 2001 From: AdilAzhariOmsan Date: Sun, 8 Nov 2026 21:15:32 +0800 Subject: [PATCH 2/8] feat(ui): add form field and controls - FormField provides its control's id, aria-describedby (error first, then hint), aria-invalid and required through provide/inject, so pages write (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. --- resources/js/i18n/messages/ar/ui.json | 27 +++++ resources/js/i18n/messages/en/ui.json | 27 +++++ resources/js/i18n/messages/ms/ui.json | 27 +++++ resources/js/shared/ui/CheckboxInput.vue | 40 ++++++++ resources/js/shared/ui/FormField.test.ts | 119 ++++++++++++++++++++++ resources/js/shared/ui/FormField.vue | 56 ++++++++++ resources/js/shared/ui/SelectInput.vue | 57 +++++++++++ resources/js/shared/ui/TextInput.vue | 41 ++++++++ resources/js/shared/ui/control-classes.ts | 8 ++ resources/js/shared/ui/field-context.ts | 17 ++++ 10 files changed, 419 insertions(+) create mode 100644 resources/js/i18n/messages/ar/ui.json create mode 100644 resources/js/i18n/messages/en/ui.json create mode 100644 resources/js/i18n/messages/ms/ui.json create mode 100644 resources/js/shared/ui/CheckboxInput.vue create mode 100644 resources/js/shared/ui/FormField.test.ts create mode 100644 resources/js/shared/ui/FormField.vue create mode 100644 resources/js/shared/ui/SelectInput.vue create mode 100644 resources/js/shared/ui/TextInput.vue create mode 100644 resources/js/shared/ui/control-classes.ts create mode 100644 resources/js/shared/ui/field-context.ts diff --git a/resources/js/i18n/messages/ar/ui.json b/resources/js/i18n/messages/ar/ui.json new file mode 100644 index 0000000..f197c76 --- /dev/null +++ b/resources/js/i18n/messages/ar/ui.json @@ -0,0 +1,27 @@ +{ + "common": { + "loading": "جارٍ التحميل" + }, + "field": { + "required": "مطلوب" + }, + "link": { + "new_tab": "(يفتح في علامة تبويب جديدة)" + }, + "dialog": { + "close": "إغلاق" + }, + "toast": { + "dismiss": "إغلاق الإشعار" + }, + "price": { + "now": "السعر", + "was": "السعر الأصلي" + }, + "pagination": { + "label": "ترقيم الصفحات", + "previous": "السابق", + "next": "التالي", + "page": "الصفحة {page}" + } +} diff --git a/resources/js/i18n/messages/en/ui.json b/resources/js/i18n/messages/en/ui.json new file mode 100644 index 0000000..308dfe7 --- /dev/null +++ b/resources/js/i18n/messages/en/ui.json @@ -0,0 +1,27 @@ +{ + "common": { + "loading": "Loading" + }, + "field": { + "required": "required" + }, + "link": { + "new_tab": "(opens in a new tab)" + }, + "dialog": { + "close": "Close" + }, + "toast": { + "dismiss": "Dismiss notification" + }, + "price": { + "now": "Price", + "was": "Original price" + }, + "pagination": { + "label": "Pagination", + "previous": "Previous", + "next": "Next", + "page": "Page {page}" + } +} diff --git a/resources/js/i18n/messages/ms/ui.json b/resources/js/i18n/messages/ms/ui.json new file mode 100644 index 0000000..2eb1064 --- /dev/null +++ b/resources/js/i18n/messages/ms/ui.json @@ -0,0 +1,27 @@ +{ + "common": { + "loading": "Memuatkan" + }, + "field": { + "required": "wajib" + }, + "link": { + "new_tab": "(dibuka dalam tab baharu)" + }, + "dialog": { + "close": "Tutup" + }, + "toast": { + "dismiss": "Tutup pemberitahuan" + }, + "price": { + "now": "Harga", + "was": "Harga asal" + }, + "pagination": { + "label": "Penomboran halaman", + "previous": "Sebelumnya", + "next": "Seterusnya", + "page": "Halaman {page}" + } +} diff --git a/resources/js/shared/ui/CheckboxInput.vue b/resources/js/shared/ui/CheckboxInput.vue new file mode 100644 index 0000000..ae71ef7 --- /dev/null +++ b/resources/js/shared/ui/CheckboxInput.vue @@ -0,0 +1,40 @@ + + + diff --git a/resources/js/shared/ui/FormField.test.ts b/resources/js/shared/ui/FormField.test.ts new file mode 100644 index 0000000..d143cd8 --- /dev/null +++ b/resources/js/shared/ui/FormField.test.ts @@ -0,0 +1,119 @@ +import { mount } from '@vue/test-utils'; +import { describe, expect, it } from 'vitest'; +import { defineComponent, ref } from 'vue'; +import { axeViolations } from '@/test/axe'; +import CheckboxInput from './CheckboxInput.vue'; +import FormField from './FormField.vue'; +import SelectInput from './SelectInput.vue'; +import TextInput from './TextInput.vue'; + +function fieldWith(control: 'text' | 'select', props: Record = {}) { + return defineComponent({ + components: { FormField, TextInput, SelectInput }, + setup: () => ({ value: ref(''), props }), + template: + control === 'text' + ? `` + : ``, + }); +} + +describe('FormField with TextInput', () => { + it('labels the control', () => { + const wrapper = mount(fieldWith('text', { label: 'Email' })); + + expect(wrapper.get('label').attributes('for')).toBe(wrapper.get('input').attributes('id')); + }); + + it('links the error and hint with aria-describedby, error first, and marks the control invalid (NFR-A11Y-02)', () => { + const wrapper = mount(fieldWith('text', { label: 'Email', hint: 'We never share it.', error: 'Enter a valid email.' })); + const input = wrapper.get('input'); + const [first, second] = (input.attributes('aria-describedby') ?? '').split(' '); + + expect(input.attributes('aria-invalid')).toBe('true'); + expect(wrapper.get(`#${first}`).text()).toBe('Enter a valid email.'); + expect(wrapper.get(`#${second}`).text()).toBe('We never share it.'); + }); + + it('has no aria-describedby or aria-invalid when there is nothing to describe', () => { + const input = mount(fieldWith('text', { label: 'Name' })).get('input'); + + expect(input.attributes('aria-describedby')).toBeUndefined(); + expect(input.attributes('aria-invalid')).toBeUndefined(); + }); + + it('marks required fields natively and announces it', () => { + const wrapper = mount(fieldWith('text', { label: 'Name', required: true })); + + expect(wrapper.get('input').attributes('required')).toBeDefined(); + expect(wrapper.get('label .sr-only').text()).toBe('(required)'); + }); + + it('updates v-model as the user types', async () => { + const wrapper = mount(fieldWith('text', { label: 'Name' })); + + await wrapper.get('input').setValue('Aisyah'); + expect((wrapper.vm as unknown as { value: string }).value).toBe('Aisyah'); + }); + + it.each(['email', 'url', 'tel'] as const)('keeps %s values left to right, even on RTL pages', (type) => { + const input = mount(TextInput, { props: { type } }).get('input'); + + expect(input.attributes('dir')).toBe('ltr'); + }); + + it('lets ordinary text follow the page direction', () => { + expect(mount(TextInput).get('input').attributes('dir')).toBeUndefined(); + }); + + it('has no axe violations, valid or invalid', async () => { + const valid = mount(fieldWith('text', { label: 'Name', hint: 'As on your IC.' }), { attachTo: document.body }); + const invalid = mount(fieldWith('text', { label: 'Email', error: 'Required.' }), { attachTo: document.body }); + + expect(await axeViolations(valid.element)).toEqual([]); + expect(await axeViolations(invalid.element)).toEqual([]); + }); +}); + +describe('FormField with SelectInput', () => { + it('is a labelled native select with a disabled placeholder', () => { + const wrapper = mount(fieldWith('select', { label: 'Country', error: 'Pick one.' })); + const select = wrapper.get('select'); + + expect(wrapper.get('label').attributes('for')).toBe(select.attributes('id')); + expect(select.attributes('aria-invalid')).toBe('true'); + expect(select.get('option[value=""]').attributes('disabled')).toBeDefined(); + }); + + it('has no axe violations', async () => { + const wrapper = mount(fieldWith('select', { label: 'Country' }), { attachTo: document.body }); + + expect(await axeViolations(wrapper.element)).toEqual([]); + }); +}); + +describe('CheckboxInput', () => { + it('is a native checkbox toggled by its label', async () => { + const wrapper = mount(CheckboxInput, { props: { label: 'Gift wrap', modelValue: false } }); + const input = wrapper.get('input'); + + expect(input.attributes('type')).toBe('checkbox'); + expect(wrapper.get('label').attributes('for')).toBe(input.attributes('id')); + + await input.setValue(true); + expect(wrapper.emitted('update:modelValue')?.[0]).toEqual([true]); + }); + + it('links its error and hint', () => { + const wrapper = mount(CheckboxInput, { props: { label: 'Accept terms', error: 'Required.', hint: 'Read them first.' } }); + const ids = (wrapper.get('input').attributes('aria-describedby') ?? '').split(' '); + + expect(ids.map((id) => wrapper.get(`#${id}`).text())).toEqual(['Required.', 'Read them first.']); + }); + + it('has no axe violations', async () => { + const wrapper = mount(CheckboxInput, { props: { label: 'Gift wrap', hint: 'RM 5' }, attachTo: document.body }); + + expect(await axeViolations(wrapper.element)).toEqual([]); + }); +}); diff --git a/resources/js/shared/ui/FormField.vue b/resources/js/shared/ui/FormField.vue new file mode 100644 index 0000000..841fff1 --- /dev/null +++ b/resources/js/shared/ui/FormField.vue @@ -0,0 +1,56 @@ + + + diff --git a/resources/js/shared/ui/SelectInput.vue b/resources/js/shared/ui/SelectInput.vue new file mode 100644 index 0000000..0f643dd --- /dev/null +++ b/resources/js/shared/ui/SelectInput.vue @@ -0,0 +1,57 @@ + + + diff --git a/resources/js/shared/ui/TextInput.vue b/resources/js/shared/ui/TextInput.vue new file mode 100644 index 0000000..00b310b --- /dev/null +++ b/resources/js/shared/ui/TextInput.vue @@ -0,0 +1,41 @@ + + + diff --git a/resources/js/shared/ui/control-classes.ts b/resources/js/shared/ui/control-classes.ts new file mode 100644 index 0000000..6c1d0fc --- /dev/null +++ b/resources/js/shared/ui/control-classes.ts @@ -0,0 +1,8 @@ +// Shared look for text inputs and selects. +export const CONTROL_CLASSES = + 'block h-11 w-full min-w-0 rounded-md border bg-surface px-3 text-base text-fg ' + + 'placeholder:text-fg-muted transition-colors disabled:cursor-not-allowed disabled:bg-muted'; + +export function controlBorder(invalid: boolean): string { + return invalid ? 'border-danger' : 'border-border-control hover:border-fg-muted'; +} diff --git a/resources/js/shared/ui/field-context.ts b/resources/js/shared/ui/field-context.ts new file mode 100644 index 0000000..c082ea3 --- /dev/null +++ b/resources/js/shared/ui/field-context.ts @@ -0,0 +1,17 @@ +// Field provides ids and state to the control inside it, so a label, hint +// and error are wired with aria-describedby / aria-invalid without the page +// passing ids around (NFR-A11Y-02). +import { inject, type ComputedRef, type InjectionKey } from 'vue'; + +export interface FieldContext { + controlId: string; + describedBy: ComputedRef; + invalid: ComputedRef; + required: ComputedRef; +} + +export const FIELD_CONTEXT: InjectionKey = Symbol('nexo.field'); + +export function useFieldContext(): FieldContext | null { + return inject(FIELD_CONTEXT, null); +} From b177014a19881a2b0c5efc164d6d3d0cca3c4b61 Mon Sep 17 00:00:00 2001 From: AdilAzhariOmsan Date: Sun, 8 Nov 2026 21:15:34 +0800 Subject: [PATCH 3/8] feat(ui): add buttons, links and display components - 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 , with screen-reader labels for sale and original prices. Tests cover behaviour, keyboard reachability and axe for each. --- resources/js/shared/ui/AppButton.test.ts | 40 ++++++++++ resources/js/shared/ui/AppButton.vue | 39 ++++++++++ resources/js/shared/ui/EmptyState.vue | 14 ++++ resources/js/shared/ui/LoadingSkeleton.vue | 26 +++++++ resources/js/shared/ui/MoneyPrice.vue | 33 ++++++++ resources/js/shared/ui/StatusBadge.vue | 24 ++++++ resources/js/shared/ui/SurfaceCard.vue | 35 +++++++++ resources/js/shared/ui/TextLink.test.ts | 42 ++++++++++ resources/js/shared/ui/TextLink.vue | 46 +++++++++++ resources/js/shared/ui/button-classes.ts | 27 +++++++ resources/js/shared/ui/display.test.ts | 90 ++++++++++++++++++++++ 11 files changed, 416 insertions(+) create mode 100644 resources/js/shared/ui/AppButton.test.ts create mode 100644 resources/js/shared/ui/AppButton.vue create mode 100644 resources/js/shared/ui/EmptyState.vue create mode 100644 resources/js/shared/ui/LoadingSkeleton.vue create mode 100644 resources/js/shared/ui/MoneyPrice.vue create mode 100644 resources/js/shared/ui/StatusBadge.vue create mode 100644 resources/js/shared/ui/SurfaceCard.vue create mode 100644 resources/js/shared/ui/TextLink.test.ts create mode 100644 resources/js/shared/ui/TextLink.vue create mode 100644 resources/js/shared/ui/button-classes.ts create mode 100644 resources/js/shared/ui/display.test.ts diff --git a/resources/js/shared/ui/AppButton.test.ts b/resources/js/shared/ui/AppButton.test.ts new file mode 100644 index 0000000..224414c --- /dev/null +++ b/resources/js/shared/ui/AppButton.test.ts @@ -0,0 +1,40 @@ +import { mount } from '@vue/test-utils'; +import { describe, expect, it } from 'vitest'; +import { axeViolations } from '@/test/axe'; +import AppButton from './AppButton.vue'; + +describe('AppButton', () => { + it('is a native button of type "button" by default, so it never submits by accident', () => { + const button = mount(AppButton, { slots: { default: 'Save' } }).get('button'); + + expect(button.attributes('type')).toBe('button'); + expect(button.text()).toBe('Save'); + }); + + it('emits clicks, and is reachable by keyboard as a native button', async () => { + const wrapper = mount(AppButton, { slots: { default: 'Go' }, attachTo: document.body }); + const button = wrapper.get('button'); + + (button.element as HTMLButtonElement).focus(); + expect(document.activeElement).toBe(button.element); + + await button.trigger('click'); + expect(wrapper.emitted('click')).toHaveLength(1); + }); + + it('while loading: disabled, aria-busy, spinner hidden from AT, and announces "Loading"', () => { + const wrapper = mount(AppButton, { props: { loading: true }, slots: { default: 'Pay' } }); + const button = wrapper.get('button'); + + expect(button.attributes('disabled')).toBeDefined(); + expect(button.attributes('aria-busy')).toBe('true'); + expect(wrapper.get('[data-test="spinner"]').attributes('aria-hidden')).toBe('true'); + expect(wrapper.get('.sr-only').text()).toBe('Loading'); + }); + + it.each(['primary', 'secondary', 'ghost', 'danger'] as const)('%s variant has no axe violations', async (variant) => { + const wrapper = mount(AppButton, { props: { variant }, slots: { default: 'Action' }, attachTo: document.body }); + + expect(await axeViolations(wrapper.element)).toEqual([]); + }); +}); diff --git a/resources/js/shared/ui/AppButton.vue b/resources/js/shared/ui/AppButton.vue new file mode 100644 index 0000000..5a40b19 --- /dev/null +++ b/resources/js/shared/ui/AppButton.vue @@ -0,0 +1,39 @@ + + + diff --git a/resources/js/shared/ui/EmptyState.vue b/resources/js/shared/ui/EmptyState.vue new file mode 100644 index 0000000..e0c888c --- /dev/null +++ b/resources/js/shared/ui/EmptyState.vue @@ -0,0 +1,14 @@ + + + diff --git a/resources/js/shared/ui/LoadingSkeleton.vue b/resources/js/shared/ui/LoadingSkeleton.vue new file mode 100644 index 0000000..c2a1134 --- /dev/null +++ b/resources/js/shared/ui/LoadingSkeleton.vue @@ -0,0 +1,26 @@ + + + diff --git a/resources/js/shared/ui/MoneyPrice.vue b/resources/js/shared/ui/MoneyPrice.vue new file mode 100644 index 0000000..0a22844 --- /dev/null +++ b/resources/js/shared/ui/MoneyPrice.vue @@ -0,0 +1,33 @@ + + + diff --git a/resources/js/shared/ui/StatusBadge.vue b/resources/js/shared/ui/StatusBadge.vue new file mode 100644 index 0000000..104233f --- /dev/null +++ b/resources/js/shared/ui/StatusBadge.vue @@ -0,0 +1,24 @@ + + + diff --git a/resources/js/shared/ui/SurfaceCard.vue b/resources/js/shared/ui/SurfaceCard.vue new file mode 100644 index 0000000..55f2215 --- /dev/null +++ b/resources/js/shared/ui/SurfaceCard.vue @@ -0,0 +1,35 @@ + + + diff --git a/resources/js/shared/ui/TextLink.test.ts b/resources/js/shared/ui/TextLink.test.ts new file mode 100644 index 0000000..c83b2f4 --- /dev/null +++ b/resources/js/shared/ui/TextLink.test.ts @@ -0,0 +1,42 @@ +import { mount } from '@vue/test-utils'; +import { describe, expect, it } from 'vitest'; +import { axeViolations } from '@/test/axe'; +import TextLink from './TextLink.vue'; + +describe('TextLink', () => { + it('renders an internal link with its href', () => { + const link = mount(TextLink, { props: { href: '/ms' }, slots: { default: 'Malay' } }).get('a'); + + expect(link.attributes('href')).toBe('/ms'); + expect(link.attributes('target')).toBeUndefined(); + }); + + it('opens external links safely in a new tab and says so to screen readers', () => { + const link = mount(TextLink, { + props: { href: 'https://example.com', external: true }, + slots: { default: 'Docs' }, + }).get('a'); + + expect(link.attributes('target')).toBe('_blank'); + expect(link.attributes('rel')).toBe('noopener noreferrer'); + expect(link.get('.sr-only').text()).toBe('(opens in a new tab)'); + }); + + it('marks the current page', () => { + const link = mount(TextLink, { props: { href: '/', current: true }, slots: { default: 'Home' } }).get('a'); + + expect(link.attributes('aria-current')).toBe('page'); + }); + + it('has no axe violations as text or as a button', async () => { + const text = mount(TextLink, { props: { href: '/' }, slots: { default: 'Home' }, attachTo: document.body }); + const button = mount(TextLink, { + props: { href: '/', appearance: 'primary' }, + slots: { default: 'Shop' }, + attachTo: document.body, + }); + + expect(await axeViolations(text.element)).toEqual([]); + expect(await axeViolations(button.element)).toEqual([]); + }); +}); diff --git a/resources/js/shared/ui/TextLink.vue b/resources/js/shared/ui/TextLink.vue new file mode 100644 index 0000000..8dbe951 --- /dev/null +++ b/resources/js/shared/ui/TextLink.vue @@ -0,0 +1,46 @@ + + + diff --git a/resources/js/shared/ui/button-classes.ts b/resources/js/shared/ui/button-classes.ts new file mode 100644 index 0000000..abbdc90 --- /dev/null +++ b/resources/js/shared/ui/button-classes.ts @@ -0,0 +1,27 @@ +// Shared look for Button and button-styled Link, so both stay identical. + +export type ButtonVariant = 'primary' | 'secondary' | 'ghost' | 'danger'; +export type ButtonSize = 'sm' | 'md' | 'lg'; + +const BASE = + 'inline-flex items-center justify-center gap-2 rounded-md font-medium whitespace-nowrap select-none ' + + 'transition-colors disabled:cursor-not-allowed disabled:opacity-60 aria-disabled:cursor-not-allowed aria-disabled:opacity-60'; + +const VARIANTS: Record = { + primary: 'bg-accent text-on-accent hover:bg-accent-hover', + secondary: 'border border-border-control bg-surface text-fg hover:bg-subtle', + ghost: 'text-fg hover:bg-muted', + danger: 'border border-danger bg-surface text-danger hover:bg-danger-bg', +}; + +// Heights keep every size at or above the 24px WCAG 2.2 target minimum; +// md and lg meet the 44px comfortable target. +const SIZES: Record = { + sm: 'h-8 px-3 text-sm', + md: 'h-11 px-4 text-sm', + lg: 'h-12 px-6 text-base', +}; + +export function buttonClasses(variant: ButtonVariant = 'primary', size: ButtonSize = 'md', block = false): string { + return [BASE, VARIANTS[variant], SIZES[size], block ? 'w-full' : ''].filter(Boolean).join(' '); +} diff --git a/resources/js/shared/ui/display.test.ts b/resources/js/shared/ui/display.test.ts new file mode 100644 index 0000000..ab1bf5e --- /dev/null +++ b/resources/js/shared/ui/display.test.ts @@ -0,0 +1,90 @@ +// StatusBadge, SurfaceCard, LoadingSkeleton, EmptyState, MoneyPrice. +import { mount } from '@vue/test-utils'; +import { describe, expect, it } from 'vitest'; +import { activateLocale } from '@/i18n'; +import { axeViolations } from '@/test/axe'; +import EmptyState from './EmptyState.vue'; +import LoadingSkeleton from './LoadingSkeleton.vue'; +import MoneyPrice from './MoneyPrice.vue'; +import StatusBadge from './StatusBadge.vue'; +import SurfaceCard from './SurfaceCard.vue'; + +describe('StatusBadge', () => { + it.each([ + ['neutral', 'bg-muted'], + ['success', 'bg-success-bg'], + ['warning', 'bg-warning-bg'], + ['danger', 'bg-danger-bg'], + ['info', 'bg-info-bg'], + ] as const)('maps the %s tone to its token pair', async (tone, background) => { + const wrapper = mount(StatusBadge, { props: { tone }, slots: { default: 'Paid' }, attachTo: document.body }); + + expect(wrapper.classes()).toContain(background); + expect(await axeViolations(wrapper.element)).toEqual([]); + }); +}); + +describe('SurfaceCard', () => { + it('with a title: a region labelled by its heading at the requested level', async () => { + const wrapper = mount(SurfaceCard, { + props: { title: 'Order summary', level: 3 }, + slots: { default: '

Body

' }, + attachTo: document.body, + }); + const heading = wrapper.get('h3'); + + expect(wrapper.get('section').attributes('aria-labelledby')).toBe(heading.attributes('id')); + expect(heading.text()).toBe('Order summary'); + expect(await axeViolations(wrapper.element)).toEqual([]); + }); + + it('without a title: a plain container', () => { + expect(mount(SurfaceCard, { slots: { default: 'x' } }).find('section').exists()).toBe(false); + }); +}); + +describe('LoadingSkeleton', () => { + it('is hidden from assistive tech and animates only without reduced motion', () => { + const wrapper = mount(LoadingSkeleton, { props: { shape: 'block' } }); + + expect(wrapper.attributes('aria-hidden')).toBe('true'); + expect(wrapper.classes()).toContain('motion-safe:animate-pulse'); + expect(wrapper.classes()).not.toContain('animate-pulse'); + }); +}); + +describe('EmptyState', () => { + it('renders a heading, description and action, with a decorative icon', async () => { + const wrapper = mount(EmptyState, { + props: { title: 'Your cart is empty', description: 'Find something you love.', level: 2 }, + slots: { icon: '', action: 'Start shopping' }, + attachTo: document.body, + }); + + expect(wrapper.get('h2').text()).toBe('Your cart is empty'); + expect(wrapper.get('[aria-hidden="true"]').find('svg').exists()).toBe(true); + expect(await axeViolations(wrapper.element)).toEqual([]); + }); +}); + +describe('MoneyPrice', () => { + it.each(['en', 'ms', 'ar'] as const)('renders RM in %s inside an LTR isolate', async (locale) => { + await activateLocale(locale); + + const bdi = mount(MoneyPrice, { props: { amount: { amount: 241850, currency: 'MYR' } } }).get('bdi'); + + expect(bdi.attributes('dir')).toBe('ltr'); + expect(bdi.text()).toBe('RM 2,418.50'); + }); + + it('labels sale and original prices for screen readers', async () => { + const wrapper = mount(MoneyPrice, { + props: { amount: { amount: 9900, currency: 'MYR' }, compareAt: { amount: 12900, currency: 'MYR' } }, + attachTo: document.body, + }); + + expect(wrapper.findAll('.sr-only').map((node) => node.text())).toEqual(['Price', 'Original price']); + expect(wrapper.get('s').text()).toBe('RM 129.00'); + expect(await axeViolations(wrapper.element)).toEqual([]); + }); +}); From 45c05a9b46615d4cd0c8da3a1b8f4a436b9be95a Mon Sep 17 00:00:00 2001 From: AdilAzhariOmsan Date: Sun, 8 Nov 2026 21:15:36 +0800 Subject: [PATCH 4/8] feat(ui): add dialog, toasts and pagination MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ModalDialog on native 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. --- resources/js/shared/ui/ModalDialog.test.ts | 89 +++++++++++++++++++++ resources/js/shared/ui/ModalDialog.vue | 89 +++++++++++++++++++++ resources/js/shared/ui/PageNav.test.ts | 68 ++++++++++++++++ resources/js/shared/ui/PageNav.vue | 76 ++++++++++++++++++ resources/js/shared/ui/ToastRegion.test.ts | 90 ++++++++++++++++++++++ resources/js/shared/ui/ToastRegion.vue | 58 ++++++++++++++ resources/js/shared/ui/page-window.ts | 26 +++++++ resources/js/shared/ui/toasts.ts | 87 +++++++++++++++++++++ resources/js/test/setup.ts | 2 + resources/js/types/shared.ts | 16 ++++ 10 files changed, 601 insertions(+) create mode 100644 resources/js/shared/ui/ModalDialog.test.ts create mode 100644 resources/js/shared/ui/ModalDialog.vue create mode 100644 resources/js/shared/ui/PageNav.test.ts create mode 100644 resources/js/shared/ui/PageNav.vue create mode 100644 resources/js/shared/ui/ToastRegion.test.ts create mode 100644 resources/js/shared/ui/ToastRegion.vue create mode 100644 resources/js/shared/ui/page-window.ts create mode 100644 resources/js/shared/ui/toasts.ts diff --git a/resources/js/shared/ui/ModalDialog.test.ts b/resources/js/shared/ui/ModalDialog.test.ts new file mode 100644 index 0000000..9e110a4 --- /dev/null +++ b/resources/js/shared/ui/ModalDialog.test.ts @@ -0,0 +1,89 @@ +import { mount } from '@vue/test-utils'; +import { describe, expect, it } from 'vitest'; +import { defineComponent, nextTick, ref } from 'vue'; +import { axeViolations } from '@/test/axe'; +import ModalDialog from './ModalDialog.vue'; + +const Harness = defineComponent({ + components: { ModalDialog }, + props: { dismissible: { type: Boolean, default: true } }, + setup: () => ({ open: ref(false) }), + template: ` + + +

Body

+ +
`, +}); + +async function openDialog(dismissible = true) { + const wrapper = mount(Harness, { props: { dismissible }, attachTo: document.body }); + const trigger = wrapper.get('[data-test="trigger"]'); + + (trigger.element as HTMLElement).focus(); + await trigger.trigger('click'); + await nextTick(); + + return { wrapper, trigger, dialog: wrapper.get('dialog').element as HTMLDialogElement }; +} + +describe('ModalDialog', () => { + it('opens as a modal labelled by its title and described by its description', async () => { + const { wrapper, dialog } = await openDialog(); + + expect(dialog.open).toBe(true); + expect(wrapper.get(`#${dialog.getAttribute('aria-labelledby')}`).text()).toBe('Remove item?'); + expect(wrapper.get(`#${dialog.getAttribute('aria-describedby')}`).text()).toBe('It leaves your cart.'); + }); + + it('moves focus into the dialog and locks page scroll while open', async () => { + const { dialog } = await openDialog(); + + expect(dialog.contains(document.activeElement)).toBe(true); + expect(document.documentElement.style.overflow).toBe('hidden'); + }); + + it('closes on Escape and returns focus to the trigger', async () => { + const { trigger, dialog } = await openDialog(); + + dialog.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); + await nextTick(); + await nextTick(); + + expect(dialog.open).toBe(false); + expect(document.activeElement).toBe(trigger.element); + expect(document.documentElement.style.overflow).toBe(''); + }); + + it('closes from the labelled close button and from an action', async () => { + const { wrapper, dialog } = await openDialog(); + const close = wrapper.get('[data-test="dialog-close"]'); + + expect(close.attributes('aria-label')).toBe('Close'); + await close.trigger('click'); + await nextTick(); + expect(dialog.open).toBe(false); + + await wrapper.get('[data-test="trigger"]').trigger('click'); + await nextTick(); + await wrapper.get('[data-test="confirm"]').trigger('click'); + await nextTick(); + expect(dialog.open).toBe(false); + }); + + it('ignores Escape and hides the close button when not dismissible', async () => { + const { wrapper, dialog } = await openDialog(false); + + dialog.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); + await nextTick(); + + expect(dialog.open).toBe(true); + expect(wrapper.find('[data-test="dialog-close"]').exists()).toBe(false); + }); + + it('has no axe violations while open', async () => { + const { dialog } = await openDialog(); + + expect(await axeViolations(dialog)).toEqual([]); + }); +}); diff --git a/resources/js/shared/ui/ModalDialog.vue b/resources/js/shared/ui/ModalDialog.vue new file mode 100644 index 0000000..4e33c47 --- /dev/null +++ b/resources/js/shared/ui/ModalDialog.vue @@ -0,0 +1,89 @@ + + + diff --git a/resources/js/shared/ui/PageNav.test.ts b/resources/js/shared/ui/PageNav.test.ts new file mode 100644 index 0000000..f7908ff --- /dev/null +++ b/resources/js/shared/ui/PageNav.test.ts @@ -0,0 +1,68 @@ +import { mount } from '@vue/test-utils'; +import { describe, expect, it } from 'vitest'; +import { activateLocale } from '@/i18n'; +import { axeViolations } from '@/test/axe'; +import { pageWindow } from './page-window'; +import PageNav from './PageNav.vue'; + +const href = (page: number) => `/products?page=${page}`; + +describe('pageWindow', () => { + it.each([ + [1, 1, [1]], + [1, 5, [1, 2, 3, 4, 5]], + [4, 7, [1, 2, 3, 4, 5, 6, 7]], + [1, 10, [1, 2, null, 10]], + [5, 10, [1, null, 4, 5, 6, null, 10]], + [10, 10, [1, null, 9, 10]], + [3, 10, [1, 2, 3, 4, null, 10]], + [4, 10, [1, 2, 3, 4, 5, null, 10]], + [99, 10, [1, null, 9, 10]], + ])('page %i of %i → %j', (page, last, expected) => { + expect(pageWindow(page, last)).toEqual(expected); + }); +}); + +describe('PageNav', () => { + it('renders nothing for a single page', () => { + expect(mount(PageNav, { props: { page: 1, lastPage: 1, href } }).find('nav').exists()).toBe(false); + }); + + it('is a labelled nav; the current page is marked and not a link', () => { + const wrapper = mount(PageNav, { props: { page: 5, lastPage: 10, href } }); + const current = wrapper.get('[aria-current="page"]'); + + expect(wrapper.get('nav').attributes('aria-label')).toBe('Pagination'); + expect(current.element.tagName).toBe('SPAN'); + expect(current.attributes('aria-label')).toBe('Page 5'); + expect(wrapper.get('[data-test="previous"]').attributes('href')).toBe('/products?page=4'); + expect(wrapper.get('[data-test="next"]').attributes('href')).toBe('/products?page=6'); + }); + + it('accepts a custom landmark label for pages with more than one', () => { + const wrapper = mount(PageNav, { props: { page: 1, lastPage: 3, href, label: 'Reviews pages' } }); + + expect(wrapper.get('nav').attributes('aria-label')).toBe('Reviews pages'); + }); + + it('omits previous on the first page and next on the last', () => { + expect(mount(PageNav, { props: { page: 1, lastPage: 3, href } }).find('[data-test="previous"]').exists()).toBe(false); + expect(mount(PageNav, { props: { page: 3, lastPage: 3, href } }).find('[data-test="next"]').exists()).toBe(false); + }); + + it('flips the arrows in RTL and translates its labels', async () => { + await activateLocale('ar'); + + const wrapper = mount(PageNav, { props: { page: 2, lastPage: 3, href } }); + + expect(wrapper.get('nav').attributes('aria-label')).toBe('ترقيم الصفحات'); + expect(wrapper.get('[data-test="next"]').text()).toBe('التالي'); + expect(wrapper.get('[data-test="next"] svg').classes()).toContain('rtl:-scale-x-100'); + }); + + it('has no axe violations', async () => { + const wrapper = mount(PageNav, { props: { page: 5, lastPage: 10, href }, attachTo: document.body }); + + expect(await axeViolations(wrapper.element)).toEqual([]); + }); +}); diff --git a/resources/js/shared/ui/PageNav.vue b/resources/js/shared/ui/PageNav.vue new file mode 100644 index 0000000..633101c --- /dev/null +++ b/resources/js/shared/ui/PageNav.vue @@ -0,0 +1,76 @@ + + + diff --git a/resources/js/shared/ui/ToastRegion.test.ts b/resources/js/shared/ui/ToastRegion.test.ts new file mode 100644 index 0000000..5b02308 --- /dev/null +++ b/resources/js/shared/ui/ToastRegion.test.ts @@ -0,0 +1,90 @@ +import { mount } from '@vue/test-utils'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { nextTick, reactive } from 'vue'; +import { axeViolations } from '@/test/axe'; +import type { Flash } from '@/types/shared'; +import ToastRegion from './ToastRegion.vue'; +import { DURATION_MS, push } from './toasts'; + +const page = reactive<{ flash: Flash }>({ flash: {} }); + +vi.mock('@inertiajs/vue3', async (importOriginal) => ({ + ...(await importOriginal()), + usePage: () => page, +})); + +beforeEach(() => { + vi.useFakeTimers(); + page.flash = {}; +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +describe('ToastRegion', () => { + it('is a polite live region; success is a status, errors are alerts', async () => { + const wrapper = mount(ToastRegion, { attachTo: document.body }); + + push('success', 'Added to cart'); + push('error', 'Payment failed'); + await nextTick(); + + expect(wrapper.get('[data-test="toast-region"]').attributes('aria-live')).toBe('polite'); + expect(wrapper.findAll('[data-test="toast"]').map((t) => t.attributes('role'))).toEqual(['status', 'alert']); + }); + + it('dismisses success toasts after the duration but keeps errors until dismissed (WCAG 2.2.1)', async () => { + const wrapper = mount(ToastRegion, { attachTo: document.body }); + + push('success', 'Saved'); + push('error', 'Could not save'); + await nextTick(); + vi.advanceTimersByTime(DURATION_MS + 1); + await nextTick(); + + expect(wrapper.findAll('[data-test="toast"]').map((t) => t.text())).toEqual(['Could not save']); + + await wrapper.get('[data-test="toast"] button').trigger('click'); + expect(wrapper.findAll('[data-test="toast"]')).toHaveLength(0); + }); + + it('pauses auto-dismiss while the pointer or keyboard focus is on the region', async () => { + const wrapper = mount(ToastRegion, { attachTo: document.body }); + const region = wrapper.get('[data-test="toast-region"]'); + + push('info', 'Shipping updated'); + await nextTick(); + vi.advanceTimersByTime(DURATION_MS - 1000); + + await region.trigger('focusin'); + vi.advanceTimersByTime(DURATION_MS * 2); + await nextTick(); + expect(wrapper.findAll('[data-test="toast"]')).toHaveLength(1); + + await region.trigger('focusout'); + vi.advanceTimersByTime(1001); + await nextTick(); + expect(wrapper.findAll('[data-test="toast"]')).toHaveLength(0); + }); + + it('translates server flash data into a toast', async () => { + const wrapper = mount(ToastRegion, { attachTo: document.body }); + + page.flash = { toast: { tone: 'success', key: 'ui.pagination.page', params: { page: 2 } } }; + await nextTick(); + + expect(wrapper.get('[data-test="toast"]').text()).toContain('Page 2'); + }); + + it('labels the dismiss button and has no axe violations', async () => { + vi.useRealTimers(); + const wrapper = mount(ToastRegion, { attachTo: document.body }); + + push('success', 'Saved'); + await nextTick(); + + expect(wrapper.get('[data-test="toast"] button').attributes('aria-label')).toBe('Dismiss notification'); + expect(await axeViolations(wrapper.element)).toEqual([]); + }); +}); diff --git a/resources/js/shared/ui/ToastRegion.vue b/resources/js/shared/ui/ToastRegion.vue new file mode 100644 index 0000000..1d7549e --- /dev/null +++ b/resources/js/shared/ui/ToastRegion.vue @@ -0,0 +1,58 @@ + + + diff --git a/resources/js/shared/ui/page-window.ts b/resources/js/shared/ui/page-window.ts new file mode 100644 index 0000000..7fa4a04 --- /dev/null +++ b/resources/js/shared/ui/page-window.ts @@ -0,0 +1,26 @@ +// Which page numbers a pagination control shows: always the first and +// last, the current page with `siblings` on each side, and gaps (null) +// where pages are skipped. A gap never hides a single page, and when every +// page fits in the control's usual width they are all shown. +export function pageWindow(current: number, last: number, siblings = 1): (number | null)[] { + if (last <= 1) return [1]; + + if (last <= siblings * 2 + 5) return Array.from({ length: last }, (_, i) => i + 1); + + const page = Math.min(Math.max(current, 1), last); + const from = Math.max(2, page - siblings); + const to = Math.min(last - 1, page + siblings); + const pages: (number | null)[] = [1]; + + if (from > 3) pages.push(null); + else for (let p = 2; p < from; p++) pages.push(p); + + for (let p = from; p <= to; p++) pages.push(p); + + if (to < last - 2) pages.push(null); + else for (let p = to + 1; p < last; p++) pages.push(p); + + pages.push(last); + + return pages; +} diff --git a/resources/js/shared/ui/toasts.ts b/resources/js/shared/ui/toasts.ts new file mode 100644 index 0000000..df1050a --- /dev/null +++ b/resources/js/shared/ui/toasts.ts @@ -0,0 +1,87 @@ +// Toast queue. Pages call push(); server responses send Inertia flash data +// ({ toast: { tone, key, params } }), which turns into a toast. +// +// Success and info toasts dismiss themselves after DURATION_MS, paused while +// the pointer or focus is on the region; errors stay until dismissed, so +// nobody has to read a failure against a clock (WCAG 2.2.1). +import { readonly, shallowRef } from 'vue'; + +export type ToastTone = 'success' | 'info' | 'error'; + +export interface Toast { + id: number; + tone: ToastTone; + message: string; +} + +export const DURATION_MS = 6000; + +interface Timer { + remaining: number; + startedAt: number; + handle?: ReturnType; +} + +const queue = shallowRef([]); +const timers = new Map(); +let nextId = 1; +let paused = false; + +function startTimer(id: number): void { + const timer = timers.get(id); + + if (!timer || paused) return; + + timer.startedAt = Date.now(); + timer.handle = setTimeout(() => dismiss(id), timer.remaining); +} + +export function push(tone: ToastTone, message: string): number { + const id = nextId++; + + queue.value = [...queue.value, { id, tone, message }]; + + if (tone !== 'error') { + timers.set(id, { remaining: DURATION_MS, startedAt: Date.now() }); + startTimer(id); + } + + return id; +} + +export function dismiss(id: number): void { + clearTimeout(timers.get(id)?.handle); + timers.delete(id); + queue.value = queue.value.filter((toast) => toast.id !== id); +} + +export function pause(): void { + if (paused) return; + + paused = true; + + for (const timer of timers.values()) { + clearTimeout(timer.handle); + timer.remaining = Math.max(0, timer.remaining - (Date.now() - timer.startedAt)); + } +} + +export function resume(): void { + if (!paused) return; + + paused = false; + + for (const id of timers.keys()) startTimer(id); +} + +/** Test helper: clears every toast and timer. */ +export function reset(): void { + for (const id of [...timers.keys()]) dismiss(id); + + queue.value = []; + paused = false; +} + +export function useToasts() { + return { toasts: readonly(queue), push, dismiss }; +} diff --git a/resources/js/test/setup.ts b/resources/js/test/setup.ts index 9d87400..4897fc1 100644 --- a/resources/js/test/setup.ts +++ b/resources/js/test/setup.ts @@ -1,6 +1,7 @@ // Vitest setup (vitest.config.ts → setupFiles). import { afterEach, beforeEach } from 'vitest'; import { activateLocale } from '@/i18n'; +import { reset as resetToasts } from '@/shared/ui/toasts'; // jsdom has no HTMLDialogElement.showModal/close. This stand-in models what // the components rely on: `open`, focus moving into the dialog, Escape @@ -48,6 +49,7 @@ beforeEach(async () => { }); afterEach(() => { + resetToasts(); document.body.innerHTML = ''; document.documentElement.style.overflow = ''; }); diff --git a/resources/js/types/shared.ts b/resources/js/types/shared.ts index 8978e7a..9206366 100644 --- a/resources/js/types/shared.ts +++ b/resources/js/types/shared.ts @@ -10,8 +10,24 @@ export interface SharedProps { direction: Direction; } +/** + * Inertia flash data, sent with Inertia::flash('toast', [...]). Messages are + * translation keys so every toast is translated in the browser like the + * rest of the UI. + */ +export interface FlashToast { + tone: 'success' | 'info' | 'error'; + key: string; + params?: Record; +} + +export interface Flash { + toast?: FlashToast; +} + declare module '@inertiajs/core' { export interface InertiaConfig { sharedPageProps: SharedProps; + flashDataType: Flash; } } From 5d2d1d07b4907e1b509f641f067e8ec62b3822e7 Mon Sep 17 00:00:00 2001 From: AdilAzhariOmsan Date: Sun, 8 Nov 2026 21:15:37 +0800 Subject: [PATCH 5/8] test(rules): enforce surface boundaries (dependency rule F1) 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. --- resources/js/rules/surface-boundaries.test.ts | 103 ++++++++++++++++++ 1 file changed, 103 insertions(+) create mode 100644 resources/js/rules/surface-boundaries.test.ts diff --git a/resources/js/rules/surface-boundaries.test.ts b/resources/js/rules/surface-boundaries.test.ts new file mode 100644 index 0000000..b1fbb6e --- /dev/null +++ b/resources/js/rules/surface-boundaries.test.ts @@ -0,0 +1,103 @@ +// Dependency rule F1: storefront and vendor code may import shared code but +// not each other, and shared code imports neither. +// +// docs/architecture/dependency-rules.md plans this as ESLint +// `import/no-restricted-paths`. ESLint is not installed (owner decision), so +// the rule runs here until it is, like F4 (logical-direction.test.ts). +// +// Surfaces (paths relative to resources/js): +// storefront: storefront/**, pages/Storefront/** +// vendor: vendor/**, pages/Vendor/** +// shared: everything else (shared/**, i18n/**, design/**, types/**, …) + +import { describe, expect, it } from 'vitest'; + +type Surface = 'storefront' | 'vendor' | 'shared'; + +const sources = import.meta.glob(['../**/*.vue', '../**/*.ts', '!../**/*.test.ts', '!../test/**'], { + eager: true, + query: '?raw', + import: 'default', +}); + +/** Path relative to resources/js, from a glob key such as "../pages/Storefront/Home.vue". */ +const fromRoot = (globKey: string) => globKey.replace(/^\.\.\//, ''); + +export function surfaceOf(path: string): Surface { + if (/^(storefront|pages\/Storefront)\//.test(path)) return 'storefront'; + if (/^(vendor|pages\/Vendor)\//.test(path)) return 'vendor'; + + return 'shared'; +} + +/** Resolves an import specifier to a path relative to resources/js, or null for packages. */ +export function resolveImport(fromPath: string, specifier: string): string | null { + if (specifier.startsWith('@/')) return specifier.slice(2); + if (!specifier.startsWith('.')) return null; + + const parts = fromPath.split('/').slice(0, -1); + + for (const segment of specifier.split('/')) { + if (segment === '..') parts.pop(); + else if (segment !== '.') parts.push(segment); + } + + return parts.join('/'); +} + +const IMPORT = /(?:\bfrom\s*|\bimport\s*\(?\s*)['"]([^'"]+)['"]/g; + +const ALLOWED: Record = { + storefront: ['storefront', 'shared'], + vendor: ['vendor', 'shared'], + shared: ['shared'], +}; + +function violations(files: Record): string[] { + return Object.entries(files).flatMap(([key, source]) => { + const path = fromRoot(key); + const from = surfaceOf(path); + + return [...source.matchAll(IMPORT)].flatMap((match) => { + const target = resolveImport(path, match[1] as string); + + if (target === null) return []; + + const to = surfaceOf(target); + + return ALLOWED[from].includes(to) ? [] : [`${path} (${from}) imports ${match[1]} (${to})`]; + }); + }); +} + +describe('dependency rule F1: surface boundaries', () => { + it('classifies paths into surfaces', () => { + expect(surfaceOf('pages/Storefront/Home.vue')).toBe('storefront'); + expect(surfaceOf('storefront/cart/useCart.ts')).toBe('storefront'); + expect(surfaceOf('pages/Vendor/Orders/Index.vue')).toBe('vendor'); + expect(surfaceOf('shared/ui/AppButton.vue')).toBe('shared'); + expect(surfaceOf('i18n/index.ts')).toBe('shared'); + }); + + it('detects cross-surface imports (self-check)', () => { + expect( + violations({ + '../pages/Storefront/Cart.vue': `import X from '@/pages/Vendor/Orders.vue';\nimport Y from '@/shared/ui/AppButton.vue';`, + '../vendor/useOrders.ts': `const m = await import('../storefront/cart');`, + '../shared/ui/Thing.vue': `import { a } from '../../pages/Storefront/Home.vue';\nimport { ref } from 'vue';`, + }), + ).toEqual([ + 'pages/Storefront/Cart.vue (storefront) imports @/pages/Vendor/Orders.vue (vendor)', + 'vendor/useOrders.ts (vendor) imports ../storefront/cart (storefront)', + 'shared/ui/Thing.vue (shared) imports ../../pages/Storefront/Home.vue (storefront)', + ]); + }); + + it('finds the application source', () => { + expect(Object.keys(sources).some((key) => key.includes('shared/ui/'))).toBe(true); + }); + + it('has no cross-surface imports', () => { + expect(violations(sources)).toEqual([]); + }); +}); From 8eeddc721829648a19cf6303ba3cab9931a45ae0 Mon Sep 17 00:00:00 2001 From: AdilAzhariOmsan Date: Sun, 8 Nov 2026 21:15:39 +0800 Subject: [PATCH 6/8] feat(dev): add a local-only component gallery /dev/components shows every kit component in its main states, with an en / ms / ar switch that runs activateLocale(), so 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. --- resources/js/pages/Dev/Components.test.ts | 51 ++++++ resources/js/pages/Dev/Components.vue | 195 ++++++++++++++++++++++ routes/local.php | 1 + tests/Feature/LocalGalleriesTest.php | 17 +- 4 files changed, 257 insertions(+), 7 deletions(-) create mode 100644 resources/js/pages/Dev/Components.test.ts create mode 100644 resources/js/pages/Dev/Components.vue diff --git a/resources/js/pages/Dev/Components.test.ts b/resources/js/pages/Dev/Components.test.ts new file mode 100644 index 0000000..33ceb11 --- /dev/null +++ b/resources/js/pages/Dev/Components.test.ts @@ -0,0 +1,51 @@ +import { mount } from '@vue/test-utils'; +import { describe, expect, it, vi } from 'vitest'; +import { defineComponent, nextTick } from 'vue'; +import { activateLocale } from '@/i18n'; +import { axeViolations } from '@/test/axe'; +import Components from './Components.vue'; + +vi.mock('@inertiajs/vue3', async (importOriginal) => ({ + ...(await importOriginal()), + Head: defineComponent({ name: 'Head', render: () => null }), + usePage: () => ({ flash: {} }), +})); + +const SECTIONS = ['button', 'form', 'badge', 'card', 'overlay', 'pagination']; + +describe('Dev/Components gallery', () => { + it.each([ + ['en', 'ltr', 'Save changes', 'Pagination'], + ['ms', 'ltr', 'Simpan perubahan', 'Penomboran halaman'], + ['ar', 'rtl', 'حفظ التغييرات', 'ترقيم الصفحات'], + ] as const)('renders every component in %s', async (locale, dir, sample, paginationLabel) => { + await activateLocale(locale); + + const wrapper = mount(Components, { attachTo: document.body }); + + for (const section of SECTIONS) expect(wrapper.find(`[data-test="section-${section}"]`).exists()).toBe(true); + + expect(document.documentElement.dir).toBe(dir); + expect(wrapper.text()).toContain(sample); + expect(wrapper.get('nav').attributes('aria-label')).toBe(paginationLabel); + expect(wrapper.text()).toContain('RM 2,418.50'); + }); + + it('switches locale from its own controls', async () => { + const wrapper = mount(Components, { attachTo: document.body }); + + await wrapper.get('[data-test="locale-ar"]').trigger('click'); + await new Promise((resolve) => setTimeout(resolve, 0)); + await nextTick(); + + expect(document.documentElement.lang).toBe('ar'); + expect(wrapper.get('[data-test="locale-ar"]').attributes('aria-pressed')).toBe('true'); + expect(wrapper.text()).toContain('حفظ التغييرات'); + }); + + it('has no axe violations', async () => { + const wrapper = mount(Components, { attachTo: document.body }); + + expect(await axeViolations(wrapper.element)).toEqual([]); + }); +}); diff --git a/resources/js/pages/Dev/Components.vue b/resources/js/pages/Dev/Components.vue new file mode 100644 index 0000000..fbb34c1 --- /dev/null +++ b/resources/js/pages/Dev/Components.vue @@ -0,0 +1,195 @@ + + + diff --git a/routes/local.php b/routes/local.php index 52e0417..4237b65 100644 --- a/routes/local.php +++ b/routes/local.php @@ -10,3 +10,4 @@ use Inertia\Inertia; Route::get('/dev/tokens', fn () => Inertia::render('Dev/Tokens'))->name('dev.tokens'); +Route::get('/dev/components', fn () => Inertia::render('Dev/Components'))->name('dev.components'); diff --git a/tests/Feature/LocalGalleriesTest.php b/tests/Feature/LocalGalleriesTest.php index a4b2e80..058594e 100644 --- a/tests/Feature/LocalGalleriesTest.php +++ b/tests/Feature/LocalGalleriesTest.php @@ -19,18 +19,21 @@ function bootAs(string $environment): void putenv('APP_ENV=testing'); }); -it('is not routed outside the local environment', function (string $environment) { +it('is not routed outside the local environment', function (string $environment, string $url) { bootAs($environment); expect(app()->environment())->toBe($environment); - $this->get('/dev/tokens')->assertNotFound(); -})->with(['production', 'staging', 'demo', 'testing']); + $this->get($url)->assertNotFound(); +})->with(['production', 'staging', 'demo', 'testing'])->with(['/dev/tokens', '/dev/components']); -it('renders the token gallery in the local environment', function () { +it('renders the galleries in the local environment', function (string $url, string $component) { bootAs('local'); - $this->get('/dev/tokens') + $this->get($url) ->assertOk() - ->assertInertia(fn (Assert $page) => $page->component('Dev/Tokens')); -}); + ->assertInertia(fn (Assert $page) => $page->component($component)); +})->with([ + 'tokens' => ['/dev/tokens', 'Dev/Tokens'], + 'components' => ['/dev/components', 'Dev/Components'], +]); From 2b1bd1f78e594a03f9a236c47e50295830012462 Mon Sep 17 00:00:00 2001 From: AdilAzhariOmsan Date: Sun, 8 Nov 2026 21:15:40 +0800 Subject: [PATCH 7/8] docs(engineering): document the component kit and F1 enforcement - 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. --- docs/architecture/dependency-rules.md | 5 ++++- docs/engineering/code-quality.md | 29 +++++++++++++++++++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/docs/architecture/dependency-rules.md b/docs/architecture/dependency-rules.md index 4ac4e31..b4ee031 100644 --- a/docs/architecture/dependency-rules.md +++ b/docs/architecture/dependency-rules.md @@ -101,4 +101,7 @@ Adding to this list needs a PR that updates this table. Rules F1–F4 are enforced with ESLint (`import/no-restricted-paths`, plus a custom Tailwind class lint) in Phase 3. -ESLint is not installed yet (owner decision), so F4 is enforced now by a Vitest check, `resources/js/rules/logical-direction.test.ts`, which scans Vue, TypeScript, Blade and CSS for physical utilities and properties. It moves to ESLint if and when ESLint is approved. +ESLint is not installed yet (owner decision), so F1 and F4 are enforced now by Vitest checks that run in CI, and move to ESLint if and when ESLint is approved: + +- **F1:** `resources/js/rules/surface-boundaries.test.ts`. Storefront code is `resources/js/storefront/**` and `resources/js/pages/Storefront/**`; vendor code is `resources/js/vendor/**` and `resources/js/pages/Vendor/**`; everything else (`shared/`, `i18n/`, `design/`, `types/`, …) is shared and may import neither. It resolves `@/` and relative imports, static and dynamic. +- **F4:** `resources/js/rules/logical-direction.test.ts`, which scans Vue, TypeScript, Blade and CSS for physical utilities and properties. diff --git a/docs/engineering/code-quality.md b/docs/engineering/code-quality.md index 0a918b0..a5a380d 100644 --- a/docs/engineering/code-quality.md +++ b/docs/engineering/code-quality.md @@ -93,6 +93,35 @@ composer analyse `resources/js/rules/logical-direction.test.ts`; a line may opt out with `f4-allow` and a reason. +## Component kit + +- **Location:** `resources/js/shared/ui/`: shared by storefront and vendor + pages (rule F1). The gallery at `/dev/components` (local only) shows every + component in en, ms and ar. +- **Components:** `AppButton`, `TextLink`, `FormField` + `TextInput` / + `SelectInput`, `CheckboxInput`, `StatusBadge` (generic `tone`), + `SurfaceCard`, `ModalDialog`, `ToastRegion` (+ `toasts.ts`), + `LoadingSkeleton`, `EmptyState`, `MoneyPrice`, `PageNav`. Names are + multi-word to stay clear of HTML elements and Inertia's `Link`. +- **Forms:** put the control inside ``; it gets + its id, `aria-describedby` (error, then hint), `aria-invalid` and + `required` automatically (NFR-A11Y-02). Email, URL and phone inputs are + always LTR. +- **Dialogs** use the native `` with `showModal()` (inert + background, focus trap, Escape) and return focus to the trigger. +- **Toasts:** call `push(tone, message)` in the browser, or send + `Inertia::flash('toast', ['tone' => 'success', 'key' => '…', 'params' => [...]])` + from the server; the key is translated in the browser. Success and info + toasts auto-dismiss (paused on hover or focus); errors stay until dismissed. +- **Money** is shown only through `MoneyPrice` (LTR-isolated, no + arithmetic, F3). +- **Tests:** every component has Vitest + Vue Test Utils tests including + keyboard/focus behaviour where it applies and an axe check + (`resources/js/test/axe.ts`, NFR-A11Y-04; colour contrast is covered by + the token tests instead). jsdom has no ``, so + `resources/js/test/setup.ts` stubs `showModal`/`close`; real browser + behaviour is checked manually and in Phase 9 browser tests. + ## Before every pull request ```sh From e4b44a3c4c21bd92a86968193c3e3912fcccbdb4 Mon Sep 17 00:00:00 2001 From: AdilAzhariOmsan Date: Sun, 8 Nov 2026 21:40:26 +0800 Subject: [PATCH 8/8] feat(dev): add a local-only development navigation bar 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