@waynestate/wayne-ui-vue
v2.0.1
Published
Wayne State University Vue 3 component library.
Keywords
Readme
@waynestate/wayne-ui-vue
Wayne State University's design system, as Vue 3 components.
These components carry no styles of their own. Every class they emit is
defined in @waynestate/wayne-ui-css, and the markup is held to
contract/markup-contract.json. Extracted from the two canonical templates in
apps/examples/static-html. That is the only reason the static-HTML, raw-PHP,
Blade and Vue paths render identically rather than merely similarly.
Install
pnpm add @waynestate/wayne-ui-vue @waynestate/wayne-ui-cssImport the stylesheet once, in your application entry. This one line is required for both templates:
import '@waynestate/wayne-ui-css'If the public can reach the page, also import the official chrome overlay,
after the line above and never instead of it. /official layers the
wayne.edu masthead and footer on top of the base stylesheet; on its own it
renders almost no styling at all:
import '@waynestate/wayne-ui-css'
import '@waynestate/wayne-ui-css/official'Copy icons.svg and the marks/ directory from
node_modules/@waynestate/wayne-ui-css/dist into your app's public/
directory, at public/assets/wayne-ui, so a Vite build serves them at
/assets/wayne-ui/icons.svg and /assets/wayne-ui/marks/*.svg unchanged.
That is also the components' default sprite path, so no further
configuration is needed if you copy them there. Somewhere else instead, pass
that URL as the sprite option shown below, or as the mark prop on
AppBar for a mark. The <use> elements the icon components render resolve
against whatever URL you pass, not against where the npm package itself
lives, so the path has to match where you actually copied the files.
Use
Per-file imports tree-shake and are what a Vite application should prefer:
<script setup>
import { AppShell, AppBar, Sidebar, SidebarNav, PageHeader, Footer } from '@waynestate/wayne-ui-vue'
</script>
<template>
<AppShell>
<template #bar>
<AppBar variant="compact" title="Course Registration" mark="/assets/wayne-ui/marks/wsu-shield.svg" :nav="nav" search user="ee6515" />
</template>
<template #sidebar>
<Sidebar><SidebarNav heading="Registration" :items="items" /></Sidebar>
</template>
<template #footer><Footer :links="links" /></template>
<PageHeader title="Select courses" subtitle="Winter 2027" />
</AppShell>
</template>Or register everything globally. Useful in Blade-adjacent setups where no bundler analyzes the templates:
import WayneUI from '@waynestate/wayne-ui-vue'
app.use(WayneUI, { sprite: '/assets/wayne-ui/icons.svg' })
// <WsuButton>, <WsuAppShell>, …. Prefixed because Footer, Dialog, Input and
// Select all collide with something. Pass `prefix: ''` to opt out.Two templates, not two design systems
Both are application templates. The difference is who can reach them, and therefore how much university identity they carry.
| | internal application | publicly accessible |
|---|---|---|
| chrome | <AppBar variant="compact">. Shield only, full-bleed | <OfficialHeader> + <OfficialFooter> |
| search | inline field, collapsing to a row on narrow viewports | search-variant="expandable" |
| footer | <Footer> | <OfficialFooter> |
A public page's masthead already has a wayne.edu search box; two search fields side by side, doing different jobs and looking identical, is a confusing thing to put in front of someone. Hence the expanding variant.
OfficialHeader and OfficialFooter render @waynestate/wsuheader and
@waynestate/wsufooter themselves. They are not ours to fork, so they are
consumed as dependencies; their scoped styles are stripped at build time so the
vendored header.css/footer.css in wayne-ui-css/official remains the single
source of their appearance, for every consumer path.
Behavior is shared, not reimplemented
Theme switching, the mobile drawer, the expanding search and the Cmd/Ctrl+K
shortcut all come from @waynestate/wayne-ui-css/theme. The same module
the no-build paths load. Wired through data-wsu-* attributes. A drawer
therefore behaves identically in a Blade page and an Inertia page, and a bug
fixed in one is fixed in all four.
AppShell initializes that runtime for markup Vue has just mounted, and only
once the document has finished loading; before that the runtime's own bootstrap
handles it. Wiring both would give every toggle two click listeners, and a
drawer that opens and immediately closes.
import { useTheme, initChrome } from '@waynestate/wayne-ui-vue'
const { theme, isDark, toggle } = useTheme()Forms and Laravel
Field components read validation errors from any of the three shapes Laravel
hands a front end. An Inertia page prop, a MessageBag, or a JSON envelope, so no adapter is needed at the call site:
<script setup>
import { provideFormErrors } from '@waynestate/wayne-ui-vue'
const form = useForm({ course: '', reason: '' })
provideFormErrors(() => form.errors)
</script>
<template>
<ErrorSummary :errors="form.errors" />
<Input label="Course number" name="course" v-model="form.course" required />
<Textarea label="Reason" name="reason" v-model="form.reason" />
</template>Each field finds its own message by name. An error is always three things at
once. A message in text, an icon, and aria-invalid. Never the red border
alone (1.4.1, 3.3.1). ErrorSummary takes focus when it appears, so a failed
submit is announced rather than hunted for.
Give each field an explicit id matching its name when you use
ErrorSummary, so its links land on the right control.
Accessibility
WCAG 2.2 AA is the floor, AAA where the palette allows, and it is measured
rather than asserted. Every component here is audited with axe-core in the test
suite, and the whole system is audited again in a real browser at three
viewports in both themes. pnpm verify at the repository root runs both.
What that means in practice for these components:
- Styling is driven off the accessible state,
aria-current,aria-invalid,aria-sort,[open]. Never a parallel.activeclass, so the two cannot drift apart. - Nothing is hidden by width, opacity or off-screen positioning. A collapsed
drawer and a collapsed search row use
hidden, so their controls are genuinely out of the tab order (2.4.3). Dialogis a native<dialog>: focus trapping, Escape and an inert backdrop come from the browser, which is the implementation screen readers already agree with.Tabsimplements the APG roving tabindex;DataTableputs sort state inaria-sort, not only in an arrow.- Icon-only controls take their name from the
labelprop, rendered for screen readers only. Note thatlabelis not enforced here:<Button icon>with no label renders an unnamed control. The Blade component throws in that case and the Vue one does not, which is a gap rather than a decision. - Toasts never disappear while they are being read: hovering or focusing the
region pauses the countdown, and
duration: 0pins one open (2.2.1).
Motion
Subtle motion is part of the design and lives in wayne-ui-css, not in these
components. The Blade and static paths have to move the same way. Durations
come from --wsu-duration-fast and --wsu-duration-base; the global
prefers-reduced-motion rule in base.css collapses them all.
Where Vue is needed at all it drives CSS classes rather than animating
properties itself: Toasts uses <TransitionGroup name="wsu-toast">, whose
wsu-toast-* classes are defined alongside the toast in components.css.
Development
pnpm --filter @waynestate/wayne-ui-vue test # vitest + axe + the markup contract
pnpm --filter @waynestate/wayne-ui-vue build # es + umd, vue externaltest/contract.test.js reads contract/markup-contract.json and asserts the
rendered output matches it, class for class, attribute for attribute. If it
fails, the component is wrong. The templates are the contract, and changes go
in that direction. Regenerate the contract with
node scripts/extract-contract.mjs only when a template changed on purpose, and
expect every other consumer path to need the same change.
