@ryegaspar/wirevue
v0.1.2
Published
Vue 3 helpers for Laravel back ends.
Maintainers
Readme
@ryegaspar/wirevue
Vue 3 helpers for Laravel back ends.
Every runtime helper is available from the package root as a named export:
import { useForm, useDatatable, useFilters, iconOptions } from '@ryegaspar/wirevue'Each helper is also published as its own subpath, and those are the narrower surface: naming a module directly resolves only that module's graph, so importing one never pulls in the dependencies of another. Use them when you want one helper and not the rest.
import useForm from '@ryegaspar/wirevue/form'
import useDatatable from '@ryegaspar/wirevue/datatable'
import useFilters from '@ryegaspar/wirevue/filters'
import iconOptions from '@ryegaspar/wirevue/icons'Both spellings resolve to the same bindings — useForm from the root is the same function as the
default export of @ryegaspar/wirevue/form, and a RequestError caught from either is the same class, so an
instanceof check holds whichever way the helper was imported.
The root entry has no default export: four modules each have one and a barrel can carry a
single default, so every name it exports is a named export. The Vite plugin is not on it either
— @ryegaspar/wirevue/icons/vite runs in Node, and reaching it through an entry the browser bundle imports
would pull a build-time module into that bundle. It stays on its own subpath.
Requirements
axios and vue are peer dependencies — install them in the consuming application:
npm install axios vueThey are peers rather than dependencies on purpose. A nested copy of axios would be a different object from the one the application configured, and the guarantee below would quietly stop holding.
The Font Awesome packages are peers too, and optional — only @ryegaspar/wirevue/icons uses them, and an
application that does not is not asked to install them. See @ryegaspar/wirevue/icons.
Requests go through your default axios instance
Every request this package makes — a form submit, a table's page of rows — is sent on axios itself — the default instance, the same one you get from
import axios from 'axios'. Your interceptors already cover them. A 401/419 handler
registered in the application's bootstrap applies to form submits without being told to, as do
axios.defaults, the XSRF cookie handling, and any request or response interceptor.
This package never writes to axios.defaults. Anything a request needs goes in that request's
own config, so nothing it does can change requests it did not make.
Every submit carries two headers, in its own config rather than on the instance:
Accept: application/json
X-Requested-With: XMLHttpRequestThey are what a Laravel back end reads to answer with a 422 body instead of a redirect, so a brand-new application needs no bootstrap of its own for validation errors to arrive. A request interceptor still has the last word if you want to change them.
To send on a different instance — another baseURL, an isolated interceptor chain, or a double
under test — pass one:
const form = useForm({ email: '' }, { client: myAxiosInstance })
const dt = useDatatable(route, {}, 'id', { client: myAxiosInstance })@ryegaspar/wirevue/form
import useForm from '@ryegaspar/wirevue/form'
const form = useForm({
name: '',
email: ''
})Every field is spliced onto the form as its own property, so form.email and form.post(...)
sit on the same object and v-model="form.email" works directly.
<input v-model="form.email">
<small v-if="form.errors.email">{{ form.errors.email }}</small>
<button :disabled="form.processing || !form.isDirty">Save</button>Submitting
get, post, put, patch and delete all take a route and return a handle to attach
handlers to. get and delete send the fields as query parameters; the rest send a body.
form.post('/users')
.onSuccess(data => close(data.id))
.onValidation(() => focusFirstError())
.onFailure(error => toast(error.message))
.onFinally(() => refresh())Each handler is named for the outcome it answers, so none of them has to test what it was given:
| Handler | Runs when |
| --- | --- |
| onSuccess(data) | The request succeeded. Receives the response body. |
| onValidation(error) | A 422 carrying field errors. They are already recorded on form.errors before this runs, so register it only to do something further. |
| onFailure(error) | Anything the form cannot fix by retyping: a conflict, a refused business rule, a 5xx, a request that never reached the server. A 422 that carries only a message lands here too. |
| onFinally() | Every outcome, after the handler for that outcome. |
All four are optional and chainable. A failure with no onFailure attached is reported rather
than lost, and a handler that throws does not stop the ones behind it.
error is a FormError: status and data are always present, so a request that never
reached the server carries status 0 and an empty body rather than nothing at all.
It extends RequestError, the normalised failure every module in this package reports — the
datatable's dtError is one too — so error instanceof RequestError holds whichever module the
failure came from, and a shared handler can take either. @ryegaspar/wirevue/form exports both names.
To drive a request by hand, form.post('/users').promise settles for every outcome, validation
included.
form.wasSuccessful is the same answer as a value rather than a callback, for rendering a
success rather than acting on one:
<p v-if="form.wasSuccessful">Saved.</p>It clears as the next submit starts, so the message comes down when the user submits again rather
than a round trip later, and form.reset() clears it too.
Typing the response
Name the response body on the verb and both onSuccess and promise carry it:
interface CreatedUser {
id: number
name: string
}
form.post<CreatedUser>('/users')
.onSuccess(user => close(user.id)) // user is CreatedUser
const user = await form.put<CreatedUser>('/users/1').promiseThe type argument is optional and defaults to any, so a call site that reaches into the
payload without naming a type keeps working.
Double submits
A submit made while one is already in flight is dropped, not queued and not cancelled: no
second request goes out. The handle you get back runs onFinally and no outcome handler — the
submit still in flight will report its own result — and its promise rejects, so anything
awaiting it resumes rather than hanging.
Cancelling instead would not help. Aborting only stops the browser listening; the handler on the server runs to completion either way, so the two would overlap and both take effect while you saw only the second one's answer.
Options
form.post('/users', {
resetAfterSuccess: true,
dataOverrides: { role: 'admin' }
})resetAfterSuccess restores the values the form was built with; by default it keeps them and
takes what was submitted as the new baseline. dataOverrides is merged over the fields for that
one request. An option set to undefined means "not specified", so forwarding an optional
variable leaves the default in place.
only sends a subset — one input saved on blur, or one section of a longer form — and leaves
every other field where it is:
form.patch(`/users/${id}`, { only: [ 'email' ] })The names are typed against the form's own fields, so a misspelt one is a compile error, and a name the form has no field for is refused at the call rather than sent as a payload quietly missing it. It applies to that request alone: there is no partial mode to leave switched on and narrow the next full save.
Two further options, forceFormData and onUploadProgress, belong to file uploads and are
covered below.
Reshaping the payload
transform rewrites the body on its way out without touching the fields your inputs are bound
to, for the endpoint whose shape is not the form's:
form.transform(data => ({
name: `${data.firstName} ${data.lastName}`,
role: data.role ?? 'member'
})).post('/users')The callback receives a detached copy of the values, so editing it in place cannot reach the
fields. Those go on rendering, and isDirty goes on answering, for what the user actually sees.
It stays installed until another call replaces it — it describes the endpoint, not one request —
and reset() leaves it alone. Assembling the body at the call site instead costs you the file
detection, the null handling and the _method spoofing that only happen on the way through a
submit.
File uploads
Put a File on a field and submit as usual. A file anywhere in the payload — on a field, in an
array, nested inside an object — sends the body as multipart/form-data instead of JSON, and
axios does the encoding: nested fields arrive as a[b], arrays as a[], dates as ISO strings.
<input type="file" @change="form.photo = $event.target.files[0]">form.put(`/people/${id}`) // sent as POST with _method=put, see below
.onSuccess(() => toast('Saved'))Four things happen that are worth knowing about:
- A
putorpatchcarrying a file goes out as aPOSTwith_methodin the body. PHP fills$_POSTand$_FILESfrom the body of a POST and of nothing else, so a multipartPUTreaches Laravel with every field empty. Laravel reads_methodand routes it as the verb you asked for. Aputwith no file is left alone — a JSON body parses on any verb. - A null field is sent as an empty string. axios drops nulls from a multipart body entirely,
which would make "clear my bio" silently keep it. Laravel's
ConvertEmptyStringsToNullmiddleware turns the empty string back into a null on arrival, so the submit means the same thing either way. getanddeleterefuse a file rather than serialising one into a query string, which has nowhere to put it. The submit throws, naming the fields that hold one.- A file field's dirtiness is decided by what the file is — its name, size, type and modification date — because the baseline holds a clone rather than the same object. Choosing a different file marks the form dirty; choosing the same one again does not.
forceFormData: true sends a multipart body with no file in the payload, for an endpoint that
expects one either way:
form.post('/people', { forceFormData: true })Progress
form.progress is null unless a multipart submit is in flight, and 0 to 100 while one is:
<progress v-if="form.progress !== null" :value="form.progress" max="100" />Only a multipart submit moves it. A JSON body is gone in a single chunk, and a bar that flashed
0 to 100 on every ordinary submit would be reporting nothing. For the bytes themselves, pass
onUploadProgress and receive axios's own event:
form.post('/people', { onUploadProgress: e => console.log(e.loaded, e.total) })Uploads are a browser feature here. In Node — an SSR pass, a test outside a browser environment —
axios builds its multipart body with the form-data package, which takes streams and buffers
rather than web Files, and rejects one.
Errors
form.errors.email // first message for the field, or undefined
form.errors.get('email') // the same, for a key a property cannot reach
form.errors.first('photos')// the message for the field or for anything beneath it
form.errors.any() // is anything recorded
form.errors.all() // every recorded message, keyed by field
form.errors.clear() // discard themLaravel's 422 field errors are recorded against the fields that produced them. Each submit clears what the last one recorded.
The messages are held as properties of form.errors and nowhere else, so one you assign
yourself — form.errors.email = 'Already taken.' — renders and counts in any() and all()
exactly like a recorded one, and is cleared by the same clear().
A 422 only reaches onValidation if it left a message on at least one field. One that names no
field, or names fields with no message, is a failure carrying the response's own message.
Messages about a value inside an array
Laravel names a message after the value that failed, so a rule on an array arrives under a dotted
key — photos.2, or items.0.name — and that is the key it is stored under. Read one directly
when the index is in hand, which is what a v-for has:
<small>{{ form.errors[`photos.${i}`] }}</small>When it is not — the server decides which entry failed — ask the field for it. first() answers
with the message for the field itself or for anything beneath it, and takes the dot into account,
so photos never answers for photos_url:
form.errors.first('photos') // 'photos', 'photos.0', 'photos.2.size', …Dotted keys are typed by their shape rather than listed, since the index only exists at runtime.
A field you declared is still spell-checked: form.errors.emial is a compile error, photos.0 is
not.
Dirtiness
form.isDirty // does anything differ from the baseline
form.reset() // back to the values the form was built with
form.setInitialData({ name: 'Ada' })The baseline is what the form was constructed with, re-taken after every successful submit. The
comparison is structural and handles Dates, nested objects and arrays, and values that refer
back to themselves.
isDirty is cached and recomputed whenever a field changes — as long as something writes to the
field. A component that only writes its model on blur will leave the form clean while the user is
still typing, which is a property of that component rather than of the form. A write that steps
around reactivity on purpose, through toRaw(form), is the same case: nothing marks the cached
answer stale, so it stands until the next ordinary change.
A field cannot be named after the API
Fields are set on the form itself — that is what lets form.email and form.post(...) be reads
of one object — so a field named after a member would be that member. useForm throws on the
collision rather than letting it through:
[Form] A field cannot be named after a member of the form or its errors: data. …The names it refuses, which are every member of the form and of its error bag:
data, defaultData, initialData, rawData, errors, processing, progress, isDirty,
wasSuccessful, reset, setInitialData, setCurrentDataAsInitial, transform, submit,
bodyConfig, get, post, put, patch, delete, record, clear, first, any, all,
constructor, and the internals _client, _processing, _dirtyMemo, _progress,
_transform and _wasSuccessful.
The error bag's names are in there because Laravel keys its messages by field name and the bag
stores each message as a property under that key, so a field named clear would leave
form.errors unable to clear itself.
Only keys present at construction are ever submitted, so add every field up front, even the empty ones.
@ryegaspar/wirevue/datatable
A server-driven list: it holds the query parameters, asks for a page, and keeps the rows it got back so the call site can edit them in place after a save.
import useDatatable from '@ryegaspar/wirevue/datatable'
import { index } from '@/routes/bookings'
const dt = useDatatable(index, { sort: '-created_at' }, 'id')| Argument | Type | What it is |
|------------|-------------------------------|--------------------------------------------------------|
| route | { url(options?) => string } | Anything that builds a URL from { mergeQuery } |
| args | Record<string, any> | Query parameters the first request starts from |
| identity | keyof T | The field that tells one row from another, e.g. 'id' |
| options | { client? } | An axios instance, if not the default one |
The route argument is a shape, not a type imported from anywhere: a Wayfinder route object
satisfies it, and so does { url: params => '/bookings?' + new URLSearchParams(params.mergeQuery) }.
No component library is required. The event type the table passes to change is declared by
this package, wide enough that a PrimeVue DataTable's @page, @sort and @filter can all be
bound straight to it, and plain enough that anything else can hand over an object.
<DataTable
lazy
:value="dt.dtData.value"
:loading="dt.dtLoading.value"
:totalRecords="dt.dtTotalRecords.value"
:rows="dt.dtNumRows.value"
@page="dt.change"
@sort="dt.change"
@filter="dt.change"
/>The request
The first load happens on mount, and every later one is triggered by change or by calling
loadData() yourself. What goes out is dtParams: per_page, page, whatever args held, and
a filter[name] key per filter.
The response is read as Laravel's paginated resource envelope — data for the rows,
meta.total for the count. Anything else in the body lands in dtExtra untouched, which is
where an endpoint's totals or summary counts arrive. An endpoint that answers without meta
leaves the total null rather than undefined.
Only the newest request is allowed to write. Two loads started close together settle in whatever order the server answers, so an older answer arriving late is discarded instead of replacing rows the user has already moved on from.
A request that fails leaves the rows as they were, lowers dtLoading, and records why in
dtError. It is not rethrown: nothing awaits a load, so a rejection could only surface as an
unhandled one, and by then the application's own interceptors have already seen the status.
<DataTable v-if="!dt.dtError.value || dt.dtData.value.length" :value="dt.dtData.value" />
<Message v-else severity="error">
{{ dt.dtError.value.message }}
<Button label="Try again" @click="dt.loadData()" />
</Message>dtError is null until something goes wrong, and null again as soon as the next attempt
starts — so it describes the newest attempt rather than the last one to have gone wrong, and a
retry never shows a spinner and a failure at the same time. It matters most on the opening load:
without it a table that never heard back looks exactly like a table with no rows, and says so.
The value is a RequestError, the same normalised failure the form module's FormError extends:
status is 0 when the request never reached the server, data is the response body or {}, and
cause is the rejection it was built from. One class serves both modules, so a call site holding
failures from a submit and a load can test them against a single type.
State
Every one of these is readonly — the composable writes them, the template reads them.
| Name | Type | What it holds |
|------------------|-------------------------|-----------------------------------------|
| dtData | Ref<T[]> | The rows currently on screen |
| dtLoading | Ref<boolean> | Whether a request is in flight |
| dtTotalRecords | Ref<number \| null> | The count the last response reported |
| dtNumRows | Ref<number> | The page size the next request will use |
| dtParams | Record<string, any> | The live query parameters |
| dtExtra | Ref<object> | Whatever the envelope carried beside the page |
| dtAppending | Ref<boolean> | Whether the load in flight is adding rather than replacing |
| dtHasMore | Ref<boolean> | Whether there are rows left to fetch |
| dtError | Ref<RequestError \| null> | Why the last load failed, or null |
loadMoreTrigger comes back too, and is the one thing here that is not readonly: it is a
template ref for the element at the end of the list.
Methods
| Name | Signature | What it does |
|------------------------|----------------------------------------|--------------------------------------------------|
| loadData | (append?) => Promise<boolean> | Fetch again with the current parameters; answers whether it landed |
| loadMore | () => Promise<void> | Fetch the next page and add it to the rows |
| change | (event) => void | Apply a page, sort or filter event, then fetch |
| setFilters | (filters) => void | Write filters into the parameters without fetching |
| changeRowAt | (index, data, animate?) => void | Merge fields into one row, marking it by default |
| insertRowAt | (index, row, animate?) => void | Add a row, unmarked by default |
| removeRowAt | (index) => void | Take a row out immediately |
| updateExtra | (data) => void | Merge into dtExtra |
| rowEffectClass | (row) => string[] | The effect classes active on a row |
| flashRowAt | (index, severity, duration?) => void | Mark a row by severity for a moment |
| removeRowAtAnimated | (index) => void | Mark a row on its way out, then remove it |
| flashThenRemoveRowAt | (index, severity) => void | Flash, then remove |
change starts again at page one for anything that is not a page event, because the row that was
at offset 200 is not the same row once the ordering or the filtering changed.
Editing rows after a save
The point of holding the rows is that a save does not need a reload. Find the row, write the answer into it, and the table updates with the change marked:
form.put(`/bookings/${booking.id}`)
.onSuccess(data => {
const index = dt.dtData.value.findIndex(row => row.id === booking.id)
if (index !== -1) dt.changeRowAt(index, data)
})If the save takes the row out of the current view — approving something in a "pending" filter — flash it and let it go instead:
dt.flashThenRemoveRowAt(index, 'success')An effect is scheduled against the row rather than the index it was at, so rows arriving or leaving while an animation runs cannot make it hit the wrong one.
Row effects need your CSS
rowEffectClass(row) returns class names and nothing else — this package ships no
stylesheet. Bind it and write the animations yourself:
<tr v-for="row in dt.dtData.value" :key="row.id" :class="dt.rowEffectClass(row)">| Class | Applied by | Removed after |
|---------------------|----------------------------------|--------------------------|
| row-glow | changeRowAt / insertRowAt | 2000 ms |
| row-flash-<severity> | flashRowAt(i, severity) | 700 ms, or the duration you pass |
| row-removing | removeRowAtAnimated | 380 ms, then the row goes |
Severities are success, danger, warn and info. The class comes off the row when the timer
ends, cutting off anything still animating — so a stylesheet whose animation runs longer than the
figure above will snap rather than finish.
A server-rendered first page
Pass the page the server already produced as initial, and the table starts populated and skips
its opening request:
const dt = useDatatable(index, { initial: props.bookings }, 'id')It wants { data, total, per_page }. The page size comes from the server rather than the default
so that the page it rendered and the page the next request asks for cannot disagree and skip or
repeat rows. initial is held out of the query parameters, so it never reaches the URL.
Filters
change accepts either a table's filter model or bare values, and writes each one as
filter[name]:
dt.change({ filters: { status: { value: 'pending' } } })
dt.change({ filters: { status: 'pending' } })A filter set to null stays in the parameters as null rather than being dropped from them, which is what clears it: a key simply left out is indistinguishable from one that was never set, and the previous value would go on being sent.
setFilters does the same without fetching, for seeding defaults during setup — going through
change there would fire a second request before the first has landed.
Both are what @ryegaspar/wirevue/filters drives; see below if you want the filter state held for you.
Infinite scroll
Turn it on with infiniteScroll, and bind loadMoreTrigger to an element at the end of the
list. When that element comes on screen the next page is fetched and added to the rows already
there.
<script setup>
const {
dtData, dtLoading, dtAppending, dtHasMore, loadMoreTrigger
} = useDatatable(index, { infiniteScroll: true }, 'id')
</script>
<template>
<article v-for="row in dtData" :key="row.id">{{ row.title }}</article>
<div v-if="dtHasMore" ref="loadMoreTrigger" class="h-20">
<Spinner v-if="dtLoading && dtAppending" />
</div>
</template>Destructured like that, the template unwraps each ref for you and ref="loadMoreTrigger" binds
the element to the one that is writable.
infiniteScroll is a setting rather than a query parameter, so — like initial — it is held out
of the URL.
dtAppending is what tells the two kinds of load apart. Both raise dtLoading: one is
fetching the next page, where the rows on screen are still right and a spinner belongs under
them; the other is a filter or a sort, where the rows are about to be replaced and a skeleton is
the honest thing to show.
The list keeps filling while the sentinel is on screen, so a first page too short to reach the bottom of the window fetches the next one immediately rather than stalling until the reader scrolls. It stops when the count is reached, and also when a page comes back empty — the total was taken by an earlier query, and a row deleted since would otherwise leave a target that can never be reached and a request that repeats forever.
Watch the sentinel through the ref rather than fetching it once at mount, which the example above
does by keeping the v-if: the element is destroyed and rebuilt as the list completes and grows
again, and each new one is picked up.
A page that fails puts the page number back — a page silently skipped leaves a gap in the middle of a list with no way to notice one — and stops the automatic fetching rather than retrying as fast as the loading flag clears. Scrolling away and back asks again.
Infinite scroll needs meta.total in the response. Without it there is no count to compare
against, and the list ends after its first page.
@ryegaspar/wirevue/filters
Filter state for a datatable: it holds what the controls are showing, decides which filters are active, and reloads the table when one settles.
import useDatatable from '@ryegaspar/wirevue/datatable'
import useFilters from '@ryegaspar/wirevue/filters'
import { index } from '@/routes/bookings'
const dt = useDatatable(index, {}, 'id')
const filters = useFilters(dt, {
search: { default: '', debounce: 500 },
status: {
default: 'all',
transform: value => (value === 'all' ? null : value)
},
assignees: { default: [] }
})Bind the values straight to your controls — everything else follows from a write to one:
<InputText v-model="filters.values.search" />
<Select v-model="filters.values.status" :options="statuses" />
<Button v-if="filters.activeCount" label="Clear" @click="filters.reset()" />The first argument is anything with change and setFilters — a useDatatable satisfies it, and
so does an object of your own with those two methods. Neither module imports the other.
Defining a filter
| Key | Type | What it does |
|-------------|----------------------|-----------------------------------------------------------|
| default | any | The opening value, and what reset() restores |
| debounce | number | Milliseconds to wait after the last change; omit to reload on the change itself |
| transform | (value) => unknown | What to send for what the control holds |
default types the field, so transform receives the value rather than unknown, and
filters.values reads back the types you defined.
A filter is inactive when its value is null, undefined, an empty string or an empty array —
after the transform, if there is one. An inactive filter is sent as null, which is how a cleared
filter is dropped from the query rather than left behind filtering. That is what transform is
usually for: an "any status" option is the absence of a parameter, not a parameter meaning
anything.
status: {
default: 'all',
transform: value => (value === 'all' ? null : value)
}A value that is falsy but not empty — 0, false — is an active filter, and is sent.
What you get back
| Name | Type | What it is |
|---------------|----------------------------|---------------------------------------------------|
| values | the shape you defined | The live values, for v-model |
| activeCount | number | How many filters are carrying a value |
| isActive | (key) => boolean | Whether that one is |
| reset | () => Promise<void> | Every filter back to its default, one reload |
| set | (values) => Promise<void>| Write several values, one reload |
reset() and set() reload once rather than once per filter, and they reload immediately
whatever the debounce on any field they touch — a value your code set is not a value being typed.
Both cancel a debounce still counting down, so a keystroke in flight cannot land afterwards and
ask for the same page again.
The opening request
The defaults are written into the table's parameters as the composable is created, before the
mount-time load, so the first page already reflects what the controls are showing. Create the
filters in setup, alongside the table:
const dt = useDatatable(index, {}, 'id')
const filters = useFilters(dt, { status: { default: 'pending' } })Created later — after the table has already loaded — the defaults still reach the parameters, but not the request that has already gone.
Debouncing
Each field has its own timer, so a slow one does not hold up a fast one, and each fires on the
trailing edge: a search box sends one request from where the typing stopped, not one per
keystroke. A pending timer is cancelled when the component unmounts, so a reload cannot go out
for a table nobody is looking at. Outside a component there is no unmount to hook — call
reset() or set(), both of which cancel, or let the timer run.
Values are watched deeply, so a control that mutates its array or object in place — pushing an id onto a selection rather than replacing it — reloads like any other change.
@ryegaspar/wirevue/icons
A build step for Font Awesome: name the icons an application uses, and the plugin generates the
module that imports them and registers them with the library. There is no faicons.js to
regenerate by hand and no list to keep in sync with it.
The Font Awesome packages are optional peer dependencies — install the ones the application draws from:
npm install @fortawesome/fontawesome-svg-core @fortawesome/free-solid-svg-iconsAdd the plugin, and name the icons:
// vite.config.js
import icons from '@ryegaspar/wirevue/icons/vite'
export default defineConfig({
plugins: [
icons({
include: [
'faShieldHalved', // solid
'far:faCircleCheck', // regular
'fab:faGithub' // brands
]
})
]
})A bare name is solid; a prefix takes the icon from another package. A name cannot identify a
package on its own — faBell is in both the solid and the regular set, and a filled bell and an
outlined one are two different icons — so a bare name is never resolved by looking it up.
Import the result once per entry, for the side effect of registering the icons:
// app.js
import 'virtual:wirevue/icons'Nothing is written to your tree. The module is generated in memory on every dev start and
every build, so there is no generated file to gitignore, diff, or find stale. An include array
written inline in vite.config.js is picked up as you edit it: Vite restarts the dev server when
its own config changes.
Nothing is included that you did not name. The application's icon weight is a function of that list and nothing else.
A starting set
starterIcons is a general-purpose set of 263 icons — 246 solid, 15 regular, 2 brand — already in
the spelling include takes. Spread it in and add what the application needs on top:
import icons, { starterIcons } from '@ryegaspar/wirevue/icons/vite'
icons({
include: [
...starterIcons,
'faShieldHalved',
'far:faCircleCheck'
]
})Nothing is applied on its own — leave it out and you get only the names you wrote. It ships from
@ryegaspar/wirevue/icons/vite rather than @ryegaspar/wirevue/icons because it is configuration: it is read where the
plugin is configured, and a browser bundle should never carry a few hundred strings it has no use
for.
Types
Reference the ambient declaration once, from a file your tsconfig already includes:
// env.d.ts
/// <reference types="@ryegaspar/wirevue/icons/virtual" />Unknown names fail the build
A name no package exports fails at the start of the build, or at dev-server start, naming the entry and the package it was looked for in — and reporting every bad entry at once rather than one per run:
Cannot build the icon library:
- 'faShieldHalfed' is not exported by @fortawesome/free-solid-svg-icons.Options for a picker
@ryegaspar/wirevue/icons turns the arrays the generated module exports into a list to show a person:
import { solidIcons, regularIcons, brandIcons } from 'virtual:wirevue/icons'
import iconOptions from '@ryegaspar/wirevue/icons'
const options = iconOptions(solidIcons, regularIcons, brandIcons)[
{ fontFull: 'fa-solid fa-bell', fontShort: 'fas fa-bell', name: 'Bell' },
{ fontFull: 'fa-regular fa-circle-check', fontShort: 'far fa-circle-check', name: 'Circle Check' }
]| Name | What it is |
|-------------|-------------------------------------------------------------------|
| fontFull | The full style name and the icon: fa-solid fa-bell |
| fontShort | The same icon in Font Awesome's shorthand: fas fa-bell |
| name | The icon's name, spelled for display: Circle Check |
Groups are taken in the order you pass them, and each group keeps its own order, so the list reads the way you asked for it. The result is frozen — nothing about it changes after it is built, and Vue leaves a frozen object alone rather than walking several hundred options to make them reactive.
Server-side rendering
The plugin sets ssr.noExternal for @fortawesome/*. Left external, an SSR build gives Font
Awesome two copies of its library — the generated module fills one and the component reads the
other, and every icon renders as "Could not find".
Development
npm test # vitest run
npm run typecheck # tsc --noEmit
npm run lint # eslint .
npm run build # ESM bundle + .d.ts into dist/