Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion docs/architecture/dependency-rules.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
29 changes: 29 additions & 0 deletions docs/engineering/code-quality.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<FormField label hint error>`; 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 `<dialog>` 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 `<dialog>`, 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
Expand Down
13 changes: 12 additions & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
27 changes: 27 additions & 0 deletions resources/js/i18n/messages/ar/ui.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
{
"common": {
"loading": "جارٍ التحميل"
},
"field": {
"required": "مطلوب"
},
"link": {
"new_tab": "(يفتح في علامة تبويب جديدة)"
},
"dialog": {
"close": "إغلاق"
},
"toast": {
"dismiss": "إغلاق الإشعار"
},
"price": {
"now": "السعر",
"was": "السعر الأصلي"
},
"pagination": {
"label": "ترقيم الصفحات",
"previous": "السابق",
"next": "التالي",
"page": "الصفحة {page}"
}
}
27 changes: 27 additions & 0 deletions resources/js/i18n/messages/en/ui.json
Original file line number Diff line number Diff line change
@@ -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}"
}
}
27 changes: 27 additions & 0 deletions resources/js/i18n/messages/ms/ui.json
Original file line number Diff line number Diff line change
@@ -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}"
}
}
51 changes: 51 additions & 0 deletions resources/js/pages/Dev/Components.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof import('@inertiajs/vue3')>()),
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([]);
});
});
Loading
Loading