mibudge

Components

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 vs feature components

  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.


Props

Emits

defineModel

Use 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.

Modals and sheets

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.

Styling

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.


Naming and placement

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

Testing components

See testing.md.