The SPA has two kinds of component: presentational components in
src/components/, and feature (container) components in
src/features/<section>/. Views in src/views/ are route shells that
compose both. See architecture.md for the import
rules, which tests/architecture.test.ts enforces.
Presentational (components/) |
Feature / container (features/<section>/) |
|
|---|---|---|
| Gets data from | props only | a feature composable, stores, the router |
| Reports changes | emits events; defineModel for two-way-bound inputs |
calls actions, navigates, emits to its view |
| May import | domain/, models/ (types and pure helpers), composables/, other components |
anything except views/ |
| Must not import | @/api, @/stores, vue-router, features/, views/ |
views/ |
| Tested with | mount(Component, { props }), asserting on output and emitted() |
mountWithApp with the mock API, or through its view’s test |
| Examples | BudgetCard, MoneyAmount, TransactionRow, ConfirmSheet, TopBar |
AppShell, MoveMoneySheet, BudgetTransactionsSection, PasswordSection |
A presentational component renders the same way wherever it is used.
If it needs to load, save or navigate, it emits an event and lets its
parent do it. For example, BudgetCard emits select with the budget
id, and BudgetsView pushes { name: "budget-detail", params: { id } }.
A feature component is a section of a page with its own behaviour. Its
logic lives in a sibling composable (MoveMoneySheet.vue +
useMoveMoney.ts), so the component file is mostly template, and the
logic can be tested without mounting.
features/shell/AppShell.vue is the container for the presentational
TopBar, SideNav, BottomNav and AccountSwitcher. It reads the
account context and the budget cache and passes models down.
defineProps<{ budget: Budget; fillupBudget?: Budget }>().
Use withDefaults for optional props that have a default.Money, a
calendar date is a LocalDate, and a budget is a Budget. Components
never parse decimal or date strings.TopBar takes
account: BankAccount | null and unallocated: Money | null, not the
whole account-context store.defineModel (below).Declare events with a type:
const emit = defineEmits<{
(e: "select", budgetId: string): void;
(e: "close"): void;
}>();
select, close, confirm, cancel, back;save / submit with the form value;switch-account.defineModelUse defineModel when the parent owns a value and the component edits
it directly: a text input inside a presentational card, bound to a
feature composable’s ref.
// components/bankAccounts/BankAccountHeader.vue
const name = defineModel<string>("name", { required: true });
<!-- views/BankAccountDetailView.vue -->
<BankAccountHeader
v-model:name="detail.editName.value"
v-model:account-number="detail.editAccountNumber.value"
:account="account"
@edit="detail.startEdit"
...
/>
Name each model after the value (v-model:name, v-model:email), and
don’t use a bare v-model for a component that edits more than one
value. Actions on the value (save, cancel) are still events.
Every sheet and dialog is a BaseSheet (components/base/). It
teleports to the body, draws the scrim and transition, and calls
useModal, which provides:
It also traps focus inside the sheet while it is the topmost modal.
A sheet component supplies the title and content and listens for
close. Don’t add @keydown.esc handlers to sheet markup.
Components are built from the Base* primitives in components/base/
and style everything else with the semantic token utilities
(text-fg-muted, bg-surface, rounded-card). A component never uses
a raw colour, an arbitrary value or a <style> block; pnpm
lint:styles enforces this. styling.md has the tokens,
the primitives and how to change a style. When you move markup between
components, move it verbatim, classes included. A refactor never
restyles.
| What | Where | Name |
|---|---|---|
| Presentational, one section | components/<section>/ |
<Thing>.vue, e.g. BudgetCard.vue |
| Design-system primitive | components/base/ |
Base<Thing>.vue, e.g. BaseButton.vue; also MoneyAmount.vue, ProgressBar.vue |
| Presentational, used across sections | components/shared/ |
ConfirmSheet.vue, AccountSwitcher.vue |
| App chrome | components/layout/ |
TopBar.vue, SideNav.vue |
| Feature component | features/<section>/ |
<Thing>Section.vue, <Thing>Sheet.vue, <Thing>Form.vue |
| Its logic | features/<section>/, next to it |
use<Thing>.ts |
| Route shell | views/ |
<Page>View.vue |
<section> is the product area: budgets, transactions,
bankAccounts, settings, overview, auth or shell.<MoneyAmount :amount="..." />) and to props and events in
kebab-case (:fillup-budget, @switch-account).<script setup> with a header comment. It names
the component, says what it shows, and states its kind (“Feature
component (budgets)”, or which events it emits).wrapper.emitted(). No mock API is needed.mountWithApp(Component, { route, props }) from
tests/helpers. It uses the real router, the active Pinia, and the
MSW mock API.createTestingPinia({ createSpy: vi.fn, stubActions: false }).See testing.md.