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 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/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/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/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([]); + }); +}); 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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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([]); + }); +}); 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); +} 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/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..4897fc1 --- /dev/null +++ b/resources/js/test/setup.ts @@ -0,0 +1,55 @@ +// 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 +// 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(() => { + 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; } } diff --git a/resources/views/app.blade.php b/resources/views/app.blade.php index 0e02f1b..b703575 100644 --- a/resources/views/app.blade.php +++ b/resources/views/app.blade.php @@ -14,6 +14,7 @@ @inertiaHead + @includeWhen(app()->isLocal(), 'partials.dev-bar') @inertia diff --git a/resources/views/partials/dev-bar.blade.php b/resources/views/partials/dev-bar.blade.php new file mode 100644 index 0000000..0306e5e --- /dev/null +++ b/resources/views/partials/dev-bar.blade.php @@ -0,0 +1,17 @@ +{{-- + Development navigation, included by app.blade.php only when + APP_ENV=local. Plain links (full page loads), outside the Vue app, so it + adds no JavaScript and no shared props, and never renders in testing, + staging, demo or production. +--}} + 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 new file mode 100644 index 0000000..103d1d8 --- /dev/null +++ b/tests/Feature/LocalGalleriesTest.php @@ -0,0 +1,57 @@ +refreshApplication(); +} + +afterEach(function () { + $_ENV['APP_ENV'] = $_SERVER['APP_ENV'] = 'testing'; + putenv('APP_ENV=testing'); +}); + +it('is not routed outside the local environment', function (string $environment, string $url) { + bootAs($environment); + + expect(app()->environment())->toBe($environment); + + $this->get($url)->assertNotFound(); +})->with(['production', 'staging', 'demo', 'testing'])->with(['/dev/tokens', '/dev/components']); + +it('shows a development navigation bar on every page in local', function (string $url) { + bootAs('local'); + + $html = $this->get($url)->assertOk()->getContent(); + + expect($html)->toContain('