Skip to content

Shared UX layer ​

A set of shared components, composables and stores that every screen is expected to use rather than re-implement. They exist because the same four defects kept being written independently into new views: a table that vanishes while it reloads, a zero-row state that cannot say why it is empty, a change no screen reader ever hears, and a thrown error that leaves a blank white document.

The canonical reference for the list/CRUD half is sis_frontend/src/components/common/README.md, which sits beside the code and is kept current with it. This page covers the whole layer and the reasoning; read the README for the exact prop names.

The list layer ​

PieceOwns
composables/useListPage.tsloading flag, page/size/search/ordering, results || data normalization, error toast, stale-response guard, URL sync
composables/useCrudDialog.tsdialog visibility and mode, form state, save flow, toasts
composables/useDeleteConfirm.tsthe confirm prompt, the delete, the toasts — never window.confirm
components/common/ListPageToolbar.vuethe search row, with #filters and #actions slots

New list views must use this layer

Existing views migrate opportunistically — only when you are already modifying them. Reference conversions: views/admin/UserListView.vue (lazy), views/core/CurrencyListView.vue (client-side), and views/billing/BillingMethodListView.vue (apply-button filters via extraParams).

hasLoaded, not loading ​

hasLoaded separates a first load from a refetch. Show the skeleton only the first time and leave existing rows in place while a refetch runs. Binding a skeleton to loading instead makes the whole table disappear on every page flip.

Each list needs its own urlKey ​

useListPage mirrors page, size, search and ordering into the query string, so the browser's back button returns the result set the user left and a list URL can be bookmarked or sent to someone.

A screen running more than one list must give each a urlKey. Without it both write page and q, so paging one table repaginates the other and whichever load finishes last wins the URL. On a view that already writes its own filters into the query string, pass syncUrl: false — two writers racing over the same keys is worse than either alone.

Empty states ​

components/common/EmptyState.vue fills the #empty slot. A DataTable with no #empty slot falls through to PrimeVue's default string, which names neither what is missing nor what to do about it.

The filtered prop is the point of the component. "No majors exist yet" and "your search matches none of the forty that do" need opposite remedies and look identical from a zero-length array — isFiltered tells them apart.

clearFilters empties the search box. A view holding its own filter refs must also pass onClearFilters to useListPage, or pass :clearable="false" so the button does not lie about what it will do.

Skeletons ​

SkeletonTable.vue, SkeletonForm.vue and SkeletonDetail.vue hold the real content's geometry during a first load.

A spinner in place of a table reserves none of the space the table needs, so the page jumps when rows land — the layout shift the UX contract caps at CLS 0.1. Small inline spinners (inside a button, beside a value being revealed) are still right; a spinner standing in for a whole region is not.

Recovery ​

components/common/RecoveryState.vue is the shared surface for 404, 403, a render crash, offline, and a panel that failed to load. Pick a variant; every variant offers a way out.

components/common/RouteErrorBoundary.vue wraps the router view and catches an error thrown anywhere below it. Without it, one thrown error unmounts the entire component tree and leaves the user on an empty document with no navigation and no explanation. The boundary clears its error on navigation — without that reset it latches, and every subsequent route renders the error too. It still logs to the console, so real bugs stay visible to window.onerror.

Announcements ​

composables/useAnnouncer.ts plus components/common/LiveRegion.vue, mounted once in App.vue.

A sighted user sees "24 results" appear under a filter. A screen-reader user gets nothing, because updating a table's rows in place fires no event a reader announces. announce(text, urgent?) is how a screen speaks.

Two implementation details are load-bearing and easy to undo:

  • It is module-level, not per-component. Several live regions competing is worse than none, because readers serialize them unpredictably. This is the one region the whole app speaks through.
  • The region is visually hidden, not display: none. A hidden element is removed from the accessibility tree entirely and announces nothing — the classic way this feature ends up doing nothing at all while looking correct.

The text is cleared shortly after being set, so announcing the same string twice still speaks twice. A live region fires only on change, so re-filtering to an identical count would otherwise be silent.

PiecePurpose
stores/recentPages.tsthe last 10 pages visited, scoped per active role
stores/scrollPositions.tsscroll offsets for a router.push back from a detail view
utils/scroll-restore.tsthe contentReady() handshake both wait on
composables/useGlobalSearch.tsone search box over menu entries, people, students and courses

Recent pages store the full query string, not the bare path. Recording the path alone reopened a list unfiltered and on page one — handing the user back the raw result set they had already narrowed. Deduplication still happens on path, so revisiting a list under different filters updates the one entry instead of filling the list with near-identical rows.

Scroll position is restored by the router, not by views. savedPosition covers the browser's back and forward; the store covers a router.push back from a detail view, which produces no savedPosition. Both wait on contentReady(), which useListPage calls once rows have rendered — scrolling to y=900 on a page still one viewport tall just clamps to the top. The wait always times out rather than hanging, so a view that never signals is safe.

Global search is permission-aware. Each group (menu, people, students, courses) declares the permission node that makes it worth searching, so a role never gets hits it cannot open.

Focus ​

The primary search box takes focus when its window opens. ListPageToolbar does this for you; a hand-rolled search needs v-autofocus (src/directives/autofocus.ts) on the input, plus an :aria-label, since a placeholder alone disappears as soon as the user types.

One per screen and one per dialog. A second is a race decided by document order, not intent. In a dialog, put it on the first field only, and skip it where a required Select comes first, or where search is a secondary filter on a report screen.

Student Information System Documentation