---
url: https://rsmple.github.io/eco-vue-js/guide/getting-started.md
description: >-
Install eco-vue-js, wire Tailwind v4, vue-query and the global containers
(modals, notifications, tooltips), and import components.
---
# Getting started
## Install
```sh
npm i eco-vue-js @tanstack/vue-query
```
Peer dependencies: `vue` ≥ 3.5, `@tanstack/vue-query` ≥ 5. `vue-router` is optional — only needed for components that take a `to` prop.
## Styles
The kit ships Tailwind v4 classes, not compiled CSS. Import its Tailwind base right after Tailwind in your app's stylesheet — it adds the theme tokens, variants and plugins, and registers the kit's components as a Tailwind source:
```css
@import "tailwindcss";
@import "eco-vue-js/tailwind-base/base.css";
```
Dark mode follows a `dark` class on an ancestor (usually ``). Colors, fonts and field sizes are CSS variables — see [Theming](./theming) to change them.
### Markdown
Rendered markdown is styled inside a `.w-markdown` container: headings, lists, tables, inline code, and code blocks with numbered `.line`s. Sizes are in `em`, so the content follows the container's font size, and colors come from the color roles. Import it after the base:
```css
@import "eco-vue-js/tailwind-base/markdown.css";
/* Optional: GitBook blocks — hints, colored text, expandable sections, embedded videos */
@import "eco-vue-js/tailwind-base/css/markdown/blocks.css";
```
Add `enable-mobile` to the container to keep text on the page's inner margin below `sm` while code blocks and tables reach the screen edges, or `w-report` for print: code wraps and lines don't highlight on hover.
## Query client
List, select and async components fetch through `@tanstack/vue-query`. Install the plugin and hand the same client to the kit, so model actions running outside `setup` can reach it:
```ts
import {QueryClient, VueQueryPlugin} from '@tanstack/vue-query'
import {defaultQueryRetry, setQueryClient} from 'eco-vue-js/dist/utils/queryClient'
export const queryClient = new QueryClient({
defaultOptions: {
queries: {retry: defaultQueryRetry},
},
})
setQueryClient(queryClient)
app.use(VueQueryPlugin, {queryClient})
```
`defaultQueryRetry` retries a query twice when asking again may help: the server was unreachable, answered 5xx, 408 or 429. Other 4xx, cancelled requests and errors thrown by the query function fail at once. A query that must skip more composes it with `isRetryableError` or the same function:
```ts
options: {
retry: (failureCount, error) => error.response?.status !== 504 && defaultQueryRetry(failureCount, error),
}
```
The queries themselves are declared per model — see [Data layer](/guide/data-layer).
## App layout
Sticky list headers and columns, full-width rows, dropdowns and modals position themselves from a few CSS variables that describe the app's frame. Set them on `body`, with the values your header and side bars actually take at each breakpoint — all default to `0px`:
```css
body {
--header-height: 3.5rem;
--nav-bar-width: 0px;
--actions-bar-width: 0px;
--inner-margin: 1rem;
@apply sm:[--actions-bar-width:3.5rem] xl:[--nav-bar-width:15rem];
}
```
[App shell](./app-shell) explains each variable and sets up the header, nav and actions bars around the content.
## Global containers
Modals, menus, notifications and tooltips render into containers mounted once at the root of the app — `WModal` renders the modals and every `WButtonMore` menu. `WShineEffect` is optional: it drives the moving shine on action buttons. `WNotify` shows the toasts; their history opens from `WNotifyCenterButton` in the actions bar (see [Notify](/components/notify#notify-center)). `WQueryStatus` shows one message with Retry when queries on screen fail to load (see [Query status](/components/query-status)).
```vue
```
## Texts
A few texts the components show on their own are in English: the Back, Next, Close, Cancel and submit buttons of a stepper, a confirm's Cancel and Accept, the question before a modal with unsaved changes closes, the warning about invalid fields, the From and To of a date picker, No data on an empty chart, the error and Retry of a list page, a [query boundary](/components/query-boundary) or the [query status](/components/query-status), and the heading, messages and buttons of a [page error](/components/page-error). Replace them once, before the app mounts. A getter is read as the text renders, so a translation follows the locale:
```ts
import {setTexts} from 'eco-vue-js/dist/utils/texts'
setTexts({
back: () => i18n.global.t('modal.back'),
next: () => i18n.global.t('modal.next'),
close: () => i18n.global.t('modal.close'),
cancel: () => i18n.global.t('modal.cancel'),
})
```
A prop such as `submitText` or a confirm's `acceptText` still names its own button.
Dates, times, durations and numbers are formatted in `en-GB` until `setLocale` sets another locale — see [Formatting](/utilities/formatting#dates):
```ts
import {setLocale} from 'eco-vue-js/dist/utils/locale'
setLocale(() => i18n.global.locale.value)
```
## Importing
Import each component, utility and icon by its own path — there is no barrel import:
```ts
import WButton from 'eco-vue-js/dist/components/Button/WButton.vue'
import {Notify} from 'eco-vue-js/dist/utils/Notify'
import IconAdd from 'eco-vue-js/dist/assets/icons/IconAdd'
```
See [Conventions](./conventions) for the patterns the rest of the docs assume.
## AI assistants
The package carries these docs as Markdown, for the version it is: point an assistant at `node_modules/eco-vue-js/docs/llms.txt`, for example with a line in the app's `CLAUDE.md` or `AGENTS.md`:
```md
UI kit docs: node_modules/eco-vue-js/docs/llms.txt — read the page for a component before using it.
```
This site also serves them for the latest version: [llms.txt](https://rsmple.github.io/eco-vue-js/llms.txt) indexes every page, and [llms-full.txt](https://rsmple.github.io/eco-vue-js/llms-full.txt) has all of them in one file.
## Eslint config
The package also ships the shared eslint config used by its consumers:
```js
import plugin from 'eco-vue-js/eslint/plugin'
export default [
...plugin.configs.recommended({
tsConfig: ['tsconfig.node.json', 'tsconfig.vue.json'],
}),
]
```
---
---
url: https://rsmple.github.io/eco-vue-js/guide/app-shell.md
description: >-
Set up the frame of an eco-vue-js app with WNavBar, WHeaderBar and WActionsBar
— the CSS variables that describe the fixed bars, the content padding, filters
and search in the bars, dark mode and a centred layout.
---
# App shell
Kit apps share one frame: a fixed header on top (`WHeaderBar`), a nav on the left (`WNavBar`) and an actions bar on the right (`WActionsBar`), with the page content between them. The bars are `position: fixed` and don't push anything aside — the app describes the frame in a few CSS variables, pads its content by them, and every component that needs to know where the content box is (sticky list headers and columns, full-width rows, dropdowns, modals) reads the same variables.
This site is built that way: its layout is [`DocsLayout.vue`](https://github.com/rsmple/eco-vue-js/blob/main/docs/.vitepress/theme/DocsLayout.vue) and its shell styles are at the top of [`style.css`](https://github.com/rsmple/eco-vue-js/blob/main/docs/.vitepress/theme/style.css).
## Frame variables
Set them on `body`. Each one is the space a part of the frame takes *in layout* at the current breakpoint, so all default to `0px`:
| Variable | What it is |
| --- | --- |
| `--header-height` | Height of the header. |
| `--nav-bar-width` | Space taken by the nav — `0px` below `xl`, where the nav is an overlay opened from the header. |
| `--actions-bar-width` | Space taken by the actions bar — `0px` on phones, where it is hidden. |
| `--inner-margin` | Horizontal padding of the content. |
| `--left-margin`, `--right-margin` | Space outside the frame when it is centred on a wide screen — see [Centred frame](#centred-frame). |
| `--w-actions-bar-width` | Width of the actions bar itself, at every breakpoint. |
| `--actions-bar-filter-width` | Width of the filter panel that slides out of the actions bar on `sm` and up — see [Filters](#filters-and-search). |
The bars' own widths come from two theme tokens, so they are also Tailwind sizes: the nav's content uses `w-nav-bar-width` to keep its width while the nav collapses.
```css
@import "tailwindcss";
@import "eco-vue-js/tailwind-base/base.css";
@theme {
--spacing-nav-bar-width: 15rem;
--spacing-actions-bar-width: 3.5rem;
}
body {
--header-height: 3.5rem;
--nav-bar-width: 0px;
--actions-bar-width: 0px;
--inner-margin: 1rem;
--w-actions-bar-width: var(--spacing-actions-bar-width);
--actions-bar-filter-width: 24rem;
@apply
bg-surface text-accent
sm:[--inner-margin:1.5rem]
sm:[--actions-bar-width:var(--spacing-actions-bar-width)]
xl:[--nav-bar-width:var(--spacing-nav-bar-width)];
}
/* The bars have no background of their own: the class is a hook for the app's surface. */
.w-nav-bar {
@apply bg-surface shadow-md xl:shadow-none;
}
.w-actions-bar {
@apply bg-surface shadow-md sm:shadow-none;
}
```
The breakpoints aren't a free choice: `WNavBar` docks from `xl` (1280px) and turns into an overlay below it, and `useIsMobile()` reports `isMobile` below `sm` (640px) and `isTablet` below `xl` — the same widths the variables switch at.
### The content box
From the frame variables the kit derives the content box on every element, and exposes each as a Tailwind size:
| Variable | Utility size | Value |
| --- | --- | --- |
| `--w-left-inner` | `---left-inner` | Left edge of the content: left margin + nav + inner margin. |
| `--w-right-inner` | `---right-inner` | The same on the right, with the actions bar. |
| `--w-width-inner` | `---width-inner` | Width of the content: `100vw` minus both. |
| `--w-top-inner` | — | Header height plus the height of a sticky list header above. |
| `--w-height-inner` | `---height-inner` | Height left under them. |
The frame variables are sizes too: `pt---header-height`, `pl---nav-bar-width`, `px---inner-margin`. Inside a modal, the modal replaces the content box with its own, so the same classes work there.
An element that should stay in view while a wide list scrolls sideways sticks to the content's left edge and takes its width — the search input in the [list recipe](../recipes/list-with-fields) does this:
```vue
```
## Layout component
The root layout mounts the three bars and pads the content by the frame:
```vue
```
What each part does:
* **[`WNavBar`](/components/nav-bar)** docks at `xl`. Below it, it collapses and adds a menu button in the header's top-left corner — the header leaves that corner free on its own. It emits `update:is-open` when the overlay opens or closes.
* **[`WHeaderBar`](/components/header-bar)** spans the whole width; the `pl`/`pr` classes keep its title and buttons between the bars. The title is the `title` prop or the `#title` slot, and the `#right` slot goes before the search button.
* **[`WActionsBar`](/components/actions-bar)** holds icon buttons: `WButtonAction` in the `#top` and `#bottom` slots, with `to` for routes or `tag="a"` with `href` for links, and settings in `#footer`. On phones it is off-screen, so it slides in together with the open nav.
* **Dark mode** is a `dark` class on an ancestor, usually ``. [`WToggleTheme`](/components/toggle#theme-toggle) only switches a `Theme` value — applying and persisting it is up to the app.
## Filters and search
Pages put their own content into the bars without touching the layout:
* **[`WActionsBarFilter`](/components/actions-bar#filters)** — its default slot becomes a filter panel that slides out of the actions bar, and the bar gets a filter button with the `count` prop as its badge. The panel is `--actions-bar-filter-width` wide from `sm` up, and covers the screen next to the bar on phones.
* **[`WHeaderBarSearch`](/components/header-bar#search)** — its default slot is shown in place of the header's title row, and the header gets a search button that opens it. The slot receives `visible`, `show` and `hide`; the `shown` prop opens it right away.
Both render nothing where they are placed, and remove their content from the bar when the page unmounts.
```vue
```
## Pages without a bar
Everything measures from inherited variables, so a route without a bar only needs the bar left out and its variable reset on an element that wraps the header and the content — for a page with no actions bar, `[--actions-bar-width:0px]`. The header and every component inside then use the full width.
## Centred frame
On very wide screens the frame can keep a maximum width and centre itself. Set the space outside it as `--left-margin` and `--right-margin`: `WNavBar` and `WActionsBar` offset themselves by them, and `---left-inner` and `---right-inner` already include them. The header needs them added to its padding:
```css
@theme {
--spacing-frame-max-width: 92rem;
}
body {
--left-margin: max(0px, (100vw - var(--spacing-frame-max-width)) / 2);
--right-margin: var(--left-margin);
}
```
```vue
```
---
---
url: https://rsmple.github.io/eco-vue-js/guide/conventions.md
description: >-
Rules every eco-vue-js consumer follows — import paths, markRaw for component
props, inherited disabled/readonly/skeleton state, semantic types, and typing
list extension components.
---
# Conventions
These patterns hold across the whole kit. Most mistakes when using it come from breaking one of them.
## Import by path
Components live at `eco-vue-js/dist/components//.vue`, their types at `eco-vue-js/dist/components//types`, utilities at `eco-vue-js/dist/utils/` and icons at `eco-vue-js/dist/assets/icons/` (no extension).
```ts
import type {FieldProps} from 'eco-vue-js/dist/components/List/types'
import WListCardField from 'eco-vue-js/dist/components/List/WListCardField.vue'
import {SemanticType} from 'eco-vue-js/dist/utils/SemanticType'
import IconEdit from 'eco-vue-js/dist/assets/icons/IconEdit'
```
## Wrap components passed as props in `markRaw`
Props that take a component — icons, list fields, menus, bulk actions, option components — expect a raw component, so Vue does not make it reactive:
```vue
```
## Disabled, readonly and skeleton are inherited
Inputs and buttons read `disabled`, `readonly` and `skeleton` from the nearest parent that provides them with `useProvideDisabled`, `useProvideReadonly` or `useProvideSkeleton` from `eco-vue-js/dist/utils/provide` when the prop is left unset. A [form](/components/form) passes its states to its fields through the scope instead. Only pass the prop to override the inherited state — passing `false` forces the control enabled even inside a disabled form.
## Semantic types, not colors
Components take a `semanticType` (`primary`, `secondary`, `positive`, `negative`, `warning`, `info`) from `eco-vue-js/dist/utils/SemanticType` instead of color classes. The classes behind each type are set once per app with the `setSemanticType*Map` functions, which is how an app rebrands the kit.
## Type list extensions with the kit's types
Components plugged into `WList` — fields, menus, bulk actions, filters — must declare their props with the kit's types instead of restating the shape. When the kit adds a prop, a restated shape silently drifts and breaks on the next upgrade:
```ts
import type {MenuEmits, MenuProps} from 'eco-vue-js/dist/components/List/types'
defineProps>()
defineEmits>()
```
See the [List with fields](../recipes/list-with-fields) recipe for the full pattern.
---
---
url: https://rsmple.github.io/eco-vue-js/guide/data-layer.md
description: >-
Fetch data for the kit's lists and selects — ApiClientInstance for requests
and auth, createRestModelApi for a model's queries and actions that keep every
cached copy in sync, list filters kept in the URL, and handleApiError for
failed requests.
---
# Data layer
Lists, infinite lists and async selects don't fetch anything themselves. They take a query hook — `useQueryFn` — and call it for each page or lookup. The kit's data utilities build those hooks on top of `@tanstack/vue-query`:
* `ApiClientInstance` sends the requests and handles auth.
* `createRestModelApi` declares a model's queries and the actions that change it, keeping every cached copy of an item in sync.
* `createUseQueryParams` keeps the filters a user sets on a list in the URL.
* `handleApiError` reports a failed request in a notification and in the form that sent it.
The [query client](/guide/getting-started#query-client) has to be installed first.
## API client
One client per backend, in its own module:
```ts
import {ApiClientInstance} from 'eco-vue-js/dist/utils/ApiClient'
export const apiClient = new ApiClientInstance({
baseUrl: '/api/v1',
refreshUrl: '/jwt/update/',
credentials: import.meta.env.DEV ? 'include' : 'same-origin',
})
```
`get`, `post`, `patch` and `delete` resolve with `{data, status}` and take a config with:
* `params` — the query string. Arrays of ids or strings are joined with commas, objects are sent as JSON, and empty values are left out.
* `signal` — pass the one vue-query gives the query function, so a query that is no longer needed cancels its request.
* `noAuth` — sends the request without an auth check, e.g. for the login.
URLs start and end with a slash — `/products/` or `` `/products/${ id }/` `` — and are typed that way. `addInstance(baseUrl)` gives a client for another prefix that shares the auth.
A failed request rejects with an `ApiError`, whose `response` has the `status` and the error body in `data`. A cancelled one rejects with `ApiErrorCancel`, which error handlers skip. When the server can't be reached, or a gateway answers 502, 503 or 504 in its place, the request rejects with `ApiErrorUnavailable`, a subclass of `ApiError` with no `status` for a network failure.
`useAvailable()` returns a ref that turns false on such a failure and true again on the server's next answer. Hand it to `watchServerRecovery`, with a cheap request as the probe, to bring back what failed during an outage. vue-query's `refetchOnReconnect` only follows the browser's own connection, so it never notices a server coming back:
```ts
import {watchServerRecovery} from 'eco-vue-js/dist/utils/queryClient'
watchServerRecovery(queryClient, apiClient.useAvailable(), () => apiClient.get('/profile/'))
```
While the server is unreachable, the probe is sent every few seconds, and at once when the browser comes back online or the tab is shown. Once the server answers again, the failed queries on screen are fetched again. Their error states, such as a list's Retry, a [page error](/components/page-error) or the [query status](/components/query-status) message, stay in place until then. A probe answered right away means the server is up and only some endpoints fail, so their queries wait for Retry instead of being fetched again and again.
### Auth
The client supports two kinds of auth:
* **Cookie session** (the default). The server sets an `exp` cookie, readable from JS, with the session's expiry in seconds. Before each request the client checks it. Once it has passed, the client calls `refreshUrl` — or your own `tokenRefresh` — and then sends the request. A 401 also refreshes and retries once, except for requests sent with `noAuth`. A refresh that fails with `ApiErrorUnavailable` keeps the session and rejects the request; any other failed refresh logs out. Open tabs share the refresh, so only one of them calls the server. Without an `exp` cookie the client counts as logged out and rejects every request not sent with `noAuth`.
* **Bearer token.** With `tokenGetter`, the client adds `Authorization: Bearer ` to each request and counts as logged out when it returns nothing.
`useAuth()` returns a ref of the logged-in state, for a guard that shows the login page. `logout()` clears the session. `onFailure`, or `setOnFailure` later, is called with every failed response, e.g. to redirect on 403.
## Models
`createRestModelApi` declares every query of one model — an item, a paginated list, a list of options, a settings object — together with the actions that change it:
```ts
import {createRestModelApi} from 'eco-vue-js/dist/utils/restModelApi'
import {isId} from 'eco-vue-js/dist/utils/utils'
import {apiClient} from '@/api/ApiClient'
const PRODUCT_PATH = '/products/'
export const productModelApi = createRestModelApi({
modelKey: 'Product',
model: {} as Product,
queries: {
item: {
scope: 'item',
dataType: {} as Product,
isQueryParams: isId,
queryFn: ({queryKey, signal}) => apiClient
.get(`${ PRODUCT_PATH }${ queryKey[2] }/`, {signal})
.then(response => response.data),
},
paginated: {
scope: 'paginated',
dataType: {} as PaginatedResponse,
isQueryParams: (value: unknown): value is QueryParamsProducts | undefined => value === undefined || value instanceof Object,
queryFn: ({queryKey, signal}) => apiClient
.get>(PRODUCT_PATH, {signal, params: queryKey[2]})
.then(response => response.data),
},
},
})
```
* `modelKey` names the model in the cache. Every query of the model is keyed under it, so all of them can be updated or invalidated together.
* `model` is the shape that every query of the model shares, and must have an `id`. It is used only for its type, so pass `{} as Product`.
Each query has:
* `scope` — what the query holds: `item` (one item), `list` (an array of items), `paginated` (a page, in `PaginatedResponse`), or `single` (anything else, such as settings). The first three hold items with an `id`, and actions keep them in sync. A `single` query is left alone.
* `isQueryParams` — a type guard for the query's params. Without it the query takes no params. The query only runs when its params pass the guard, so `isId` holds back an item query until there is an id. A guard that accepts `undefined` also takes `{}`, and both share one cache entry.
* `queryFn` — fetches the data. Its params are at `queryKey[2]`.
* `dataType` — the type of the query's data, again as `{} as Type`. Declare it whenever `queryFn` reads `queryKey`, and whenever a query returns a richer shape than `model`, such as the full item where the list returns a short one.
* `options` — default vue-query options for the query, e.g. `placeholderData`.
The query's name is the last part of its key. Several queries of one scope, such as two paginated endpoints with different filters, don't collide.
### Using the queries
Every query gives a hook at `.use`:
```ts
const queryProduct = productModelApi.item.use(computed(() => props.productId ?? 0))
```
* `use(params, options?)` — runs the query and returns the vue-query result, with `setItem` and `removeItem` added. `params` can be a ref, and a change runs the query for the new params.
* `use.config(params)` — the query's options, for `queryClient.ensureQueryData` or `prefetchQuery` outside of a component.
* `use.setItem(item)` and `use.removeItem(id)` — write to the cache without a component.
The `use` of a `paginated` query is what [WList](/components/list), [WInfiniteList](/components/infinite-list) and the async [selects](/components/select) take as `useQueryFn`. When a row changes, the list's `setter` calls `setItem`, which updates the item in every cached query of the model.
### Actions
Actions are the requests that change the model, declared on the query they belong to:
```ts
item: {
scope: 'item',
// …
actions: {
update: ({set, invalidate}, id, payload: Partial) => apiClient
.patch(`${ PRODUCT_PATH }${ id }/`, payload)
.then(response => {
set(response.data)
invalidate('paginated')
return response.data
}),
delete: (context, id) => apiClient
.delete(`${ PRODUCT_PATH }${ id }/`)
.then(() => () => null),
},
},
```
They are called as `.actions.(params, payload)`, where `params` is left out for a query without them:
```ts
await productModelApi.item.actions.update(product.id, {name})
```
An action gets its query's params, its payload, and a context to change the cache with:
* `set(item)` — puts the item, or an array of items, in every cached query of the model that holds it — items, lists and pages alike. On an `item` query it also sets that query's data.
* `update(item => newItem)` — changes the cached items the action is about. On an `item` query that is the item with the id in the params. On a `list` or `paginated` query it is the items its bulk params select: `id__in`, or `id__not_in` and `slice_indexes` against the loaded pages with the same filters. Return `null` to remove an item. Call it before the request for an optimistic update: when the action rejects, the model's cache is restored.
* `invalidate(scope?)` — refetches the model's queries of that scope, or all of them, once the action succeeds.
An action can also return an updater instead of calling `update`, and it then resolves with nothing. `() => null` above drops the deleted product from every list. For another model, use its `invalidate(scope?)`, e.g. to refresh a dashboard after a product is added.
In the demo, the toggle's action changes the task at once, in both the list and the detail. Turn on failing requests to see the change rolled back.
```vue
```
The model behind it, with a stand-in for the server:
```ts [Task.ts]
import {reactive} from 'vue'
import {createRestModelApi} from 'eco-vue-js/dist/utils/restModelApi'
import {paginateList} from 'eco-vue-js/dist/utils/useDefaultQuery'
import {isId} from 'eco-vue-js/dist/utils/utils'
export type Task = {
id: number
title: string
done: boolean
}
export type QueryParamsTasks = {
page?: number
}
let tasks: Task[] = [
{id: 1, title: 'Repot the monstera', done: false},
{id: 2, title: 'Prune the roses', done: true},
{id: 3, title: 'Sow the tomato seeds', done: false},
{id: 4, title: 'Clean the greenhouse gutters', done: false},
]
/** Lets the demo make every request fail, to show the rollback. */
export const server = reactive({fail: false})
/** Stands in for a request to the API: answers after half a second, or fails. */
const respond = (handler: () => Data) => new Promise((resolve, reject) => {
setTimeout(() => {
if (server.fail) reject(new Error('Server error'))
else resolve(structuredClone(handler()))
}, 500)
})
export const taskModelApi = createRestModelApi({
modelKey: 'Task',
model: {} as Task,
queries: {
item: {
scope: 'item',
dataType: {} as Task,
isQueryParams: isId,
queryFn: ({queryKey}) => respond(() => tasks.find(task => task.id === queryKey[2])!),
actions: {
update: ({update, set}, id, payload: Partial) => {
// Shows the change at once, in every cached query holding the task. Rolled back if the request fails.
update(task => ({...task, ...payload}))
return respond(() => {
tasks = tasks.map(task => task.id === id ? {...task, ...payload} : task)
return tasks.find(task => task.id === id)!
})
.then(task => set(task))
},
},
},
paginated: {
scope: 'paginated',
dataType: {} as PaginatedResponse,
isQueryParams: (value: unknown): value is QueryParamsTasks | undefined => value === undefined || value instanceof Object,
queryFn: ({queryKey}) => respond(() => paginateList(tasks, queryKey[2]?.page)),
},
},
})
```
## Filters in the URL
The filters a user sets on a list — a search, a type, a sort order — can be kept in the URL, so a reload or a shared link opens the list as it was. `createUseQueryParams` declares them with a parser for each, which turns the value from the URL back into its type:
```ts
import {createUseQueryParams} from 'eco-vue-js/dist/utils/api'
import {parseBoolean, parseIdList, parseString} from 'eco-vue-js/dist/utils/utils'
export const useQueryParamsProducts = createUseQueryParams({
search: parseString,
product_type__in: parseIdList,
is_default: parseBoolean,
ordering: parseString,
})
export type QueryParamsProducts = typeof useQueryParamsProducts['QueryParams']
```
Leave `page` and `size` out: the list adds them to each page's query itself.
On a page, the declaration reads the filters from the route and writes changes back to it:
```ts
const route = useRoute()
const {queryParams, updateQueryParams} = useQueryParamsProducts(route)
```
* `queryParams` is a reactive object parsed from the route's query. It goes to the list as `query-params`. The object is created once per declaration, so the pages that use one declaration share it.
* `updateQueryParams(value)` merges `value` into the URL with `router.replace`. An empty value removes its param. Bind it to the filters and to the list's `update:query-params`, which sets `ordering` when a column is sorted.
* `useQueryParamsProducts.useQueryParamsLocal(initial?)` gives the same pair without the URL, for filters that don't need to survive a reload, e.g. in a modal.
The parsers in `eco-vue-js/dist/utils/utils` are `parseString`, `parseBoolean`, `parseInteger`, `parseId`, `parseIdList`, `parseIntegerList`, `parseStringList` and `parseJson(guard)`. A declaration's `config` can be spread into another, to share filters between lists.
## Errors
`handleApiError` shows an error notification with the server's `detail` or `non_field_errors`. With a [form](/components/form), it also shows the error body's field errors under the fields they name. It skips cancelled requests and rejects again, so a caller can still react:
```ts
const submit = () => productModelApi.item.actions.update(id, payload)
.then(() => emit('close:modal'))
.catch(error => handleApiError(error, formRef.value))
```
The field errors are expected in the Django REST framework shape: `{name: ['This field is required.']}`.
## Lower-level helpers
* `createDefaultQuery(modelKey, scope, name, queryFn, isQueryParams?, options?)` from `eco-vue-js/dist/utils/useDefaultQuery` builds one query hook, the way `createRestModelApi` builds each of its queries.
* `makeQueryPaginated(modelKey, getter, setter?, pageLength?)` serves a paginated query from an array in memory, and `wrapUseQueryPaginated` pages the result of a query that returns the whole list. `paginateList` pages an array into a `PaginatedResponse`.
* `setQueryItem`, `setQueryItems`, `removeQueryItem`, `removeQueryItems` and `updateQueryItems` from `eco-vue-js/dist/utils/queryCache` change a model's cached items outside an action, e.g. on a message from a websocket. `snapshotQueries` saves the model's cache and returns a function that restores it.
---
---
url: https://rsmple.github.io/eco-vue-js/guide/theming.md
description: >-
Theme eco-vue-js with a palette, color roles and w-* CSS variables — a live
playground, presets, CSS to copy into an app, the tone and surface classes for
app code, and theme links that open any docs page with a theme applied.
---
# Theming
The kit's look comes from three kinds of CSS variables:
* **Palette** — a primary color, four status colors, a neutral `gray-*` scale, the page background and text, and fonts. They are Tailwind theme tokens: an app overrides them in its own `@theme`.
* **Color roles** — what components actually paint with, named by purpose: main and muted text, surfaces, lines, focus. Each reads the palette by default, and an app can set any of them in `@theme` too.
* **`w-*` variables** — sizes and radii of fields, buttons and list headers. An app sets them on `body`, directly or with the matching utilities (`w-input-h-9`, `w-button-rounded-xl`).
All are plain CSS variables at runtime, so this site can swap them live: the whole site follows the playground below, including the header and every component page.
* **The theme in use** is at the top, with what to do with it. **Save as…** keeps it under a name in this browser; changes to a saved theme are saved into it as you make them, and the palette button in the header switches to it from any page. **Copy link** shares it: the link opens this page with the theme applied, and adds it to the other person's themes when it is a saved one. **Copy CSS** gives the stylesheet for an app.
* **Presets** are starting points, and **My themes** lists the saved ones. **Random** picks a primary color, status colors (negative, positive, warning, info) in the same style and weight, the neutral scale, size and radius that suit them, and a dark mode background that fits the style — lifted for Soft, almost black for Neon, whose shadows also glow in its primary — each at a lightness that passes the kit's contrast checks in both modes. A fill with black text can be bright, like warning, and goes brightest in Neon. A yellow or lime primary always is one, and the status colors turn bright with it. Bright fills stand out from a white page less than darker fills do, so their text and the focus ring are darkened to stay readable.
* **Ask an AI assistant** describes a look in words: it opens Claude or ChatGPT with a prompt that explains the tokens, or copies it for another assistant. The reply gives the theme as JSON and as a link; paste either one, or the whole reply, into the field below the buttons.
* **The fields below** adjust each token of the theme in use.
## Preview
## In an app
**Copy CSS** gives what goes in the app's stylesheet, after the kit's base:
```css
@import "tailwindcss";
@import "eco-vue-js/tailwind-base/base.css";
@theme {
--color-primary: oklch(54% 0.15 250);
--color-gray-50: oklch(98.4% 0.003 247.858);
/* … the rest of the neutral scale */
}
body {
--w-input-height: 2.25rem;
--w-input-rounded: 0.5rem;
/* … */
}
```
Only changed colors are listed, but every `w-*` variable is: this site sets its own sizes, and an app without them gets the kit's defaults instead (in the [shape table](#shape)).
### Light and dark mode
A color named `X` is its value in light mode and `X-dark` its value in dark mode — not a darker shade. The status colors and primary default their `-dark` to the light value, which works when the color is a mid-tone: dark enough for white text, light enough to stand out on a dark page. Set `-dark` when one value can't do both, as the `compact` preset does with a near-black primary that turns light gray in dark mode.
Swapping the neutral scale in the playground also sets the dark page background, `default-dark`, to the scale's `gray-900`, so both modes take its tint. In an app, set `default-dark` alongside the scale: the kit keeps it fixed, since some apps use it and `gray-900` as two different surfaces.
### Tones
The palette gives one color per tone, the fill. Everything else a tone needs is derived from it in the current mode:
* **Text and borders** on the page — the fill's lightness clamped to stay readable: at most 53% in light mode, at least 72% in dark.
* **Text on the fill** — black or white, whichever reads better by APCA, so a saturated purple or blue keeps white text where WCAG 2 would pick black.
* **Soft background** — 12% of the fill over the surface, as in info cards.
So a light brand color still gives readable links on white, and a yellow fill gets black text, without extra tokens. To pin one of them, set `--tone-text-{name}`, `--tone-line-{name}`, `--tone-on-{name}` or `--tone-soft-{name}` in an `@theme inline` block — for example `--tone-on-warning: var(--color-black-default)`.
### Roles
| Role | Classes | Default, light / dark | Paints |
| --- | --- | --- | --- |
| `text-accent` | `text-accent` | `black-default` / `default` | Main text |
| `text-description` | `text-description` | `gray-500` / `gray-400` | Muted text |
| `text-subtle` | `text-subtle` | `gray-400` / `gray-600` | Placeholders, disabled text, chart axes |
| `surface` | `bg-surface` | `default` / `default-dark` | Page and cards |
| `surface-subtle` | `bg-surface-subtle` | `gray-50` / `gray-850` | Barely raised areas: striped rows, empty chart cells, panels inside a card |
| `surface-muted` | `bg-surface-muted` | `gray-100` / `gray-800` | Secondary fills, hovered rows, tooltips |
| `surface-inset` | `bg-surface-inset` | `gray-200` / `gray-700` | Fills inside controls, chips |
| `surface-raised` | `bg-surface-raised`, `surface-raised` | `default` / `default-dark` lightened by 4% | Dropdowns and popovers — in dark mode a shadow hardly shows, so they are lighter than the page instead |
| `overlay` | `bg-overlay` | `default` 40% / `default-dark` 60% | Translucent bars over scrolling content |
| `backdrop` | `bg-backdrop` | `primary-light` 40% / `primary-darkest` 40% | Behind modals, bottom sheets and the mobile nav |
| `track` | `bg-track` | `gray-300` / `gray-700` | Unfilled tracks of sliders and progress bars, toggles and checkboxes when off |
| `track-strong` | `bg-track-strong` | `gray-400` / `gray-500` | The stronger track: neutral progress bars, slider and tab indicator parts |
| `line` | `border-line` | `gray-300` / `gray-700` | Field borders and dividers |
| `line-subtle` | `border-line-subtle` | `gray-200` / `gray-700` | Card and section borders |
| `line-raised` | `border-line-raised`, `outline-line-raised` | `gray-300` / `gray-700` | Edge of dropdowns, popovers and tooltips |
| `shadow` | `shadow-md`, `drop-shadow-md` | black 15% / black 50% | Shadow under dropdowns, modals, tooltips and toasts — a primary mix makes it a glow |
| `focus` | `border-focus`, `outline-focus` | `primary` / `primary-dark` | Focused field border and ring |
| `gradient-start`, `gradient-middle`, `gradient-end` | `from-gradient-start via-gradient-middle to-gradient-end` | `primary`, turned 30° and 60° towards pink / the same from `primary-dark` | The brand gradient |
A theme sets a role with `--role-{name}` and `--role-{name}-dark` in `@theme`:
```css
@theme {
--role-line: var(--color-gray-400);
--role-surface-muted-dark: oklch(25% 0.02 250);
}
```
The shadow's color is a role too, and `--w-shadow-lift` sets how far it falls: `0` centers it, so a theme can turn every `shadow-md` into a glow:
```css
@theme {
--role-shadow-dark: color-mix(in oklab, var(--color-primary-dark) 45%, transparent);
}
body {
--w-shadow-lift: 0rem;
}
```
On a dark page a glow shows about as bright as its alpha times its color's lightness, so a yellow or cyan glows much brighter than a purple at the same share. Dividing the alpha by the lightness evens that out — this matches 45% at lightness 0.6 for every hue:
```css
@theme {
--role-shadow-dark: oklch(from var(--color-primary-dark) l c h / min(0.675, calc(0.27 / l)));
}
```
### Data palette
Categories — chart series, plant kinds, syntax colors — take the kit's data hues: `data-red`, `data-orange`, `data-amber`, `data-green`, `data-teal`, `data-cyan`, `data-blue`, `data-violet`, `data-fuchsia`, `data-pink` and `data-gray`. They are mid-tones like the status colors, so each works as a tone in both modes without `-dark` values (the Preview above shows them as fills and as soft chips).
An app names its categories after them, so a theme that retunes the palette retunes the categories too:
```css
@theme {
--color-kind-fern: var(--color-data-green);
--color-kind-succulent: var(--color-data-teal);
}
```
```html
FernSucculent
```
### In app code
Components use the same classes, and app code should too: a color written as a role or a tone follows the theme and the mode by itself, with no `dark:` pair.
```html
3 items
Required
Plan renews monthly
Title
```
| Class | Effect |
| --- | --- |
| `tone-{name}` | Sets the tone for the element and its children: `primary`, `negative`, `positive`, `warning`, `info`, any theme color (`tone-kind-fern`) or an arbitrary one (`tone-[#0f766e]`) |
| `text-tone` | The tone's readable shade, for text and icons on the page |
| `border-tone-line` | The tone's shade for borders and outlines on the page — nearer the fill than `text-tone`, as lines need less contrast than text |
| `bg-tone-fill`, `border-tone-fill`, `text-tone-on` | The fill, and the text that goes on it |
| `bg-tone-soft` | The tone's soft background |
| `surface-fill` | Fill background; `text-accent`, `text-description`, `text-subtle` and lines inside switch to the text-on-fill color |
| `surface-soft` | Soft background; roles inside stay as they are |
| `surface-muted` | Muted background; a soft tone set inside it mixes over it |
| `surface-raised` | Dropdown or popover background; a soft tone and `bg-surface` inside it follow it |
| `surface` | Page background and text, and resets roles a scope outside changed |
| `light`, `dark` | Forces a mode for the element and its children, as tooltips do with `dark` |
Roles take opacity like any color: `bg-surface-muted/50`, `outline-focus/20`.
### Migrating `dark:` pairs
The kit ships a codemod that rewrites light/dark palette pairs in an app's class strings to roles and tones:
```sh
npx eco-vue-color-roles # report what it would change in src/
npx eco-vue-color-roles --write src # apply
npx eco-vue-color-roles --exact --write src # only replacements that render the same colors
```
Without `--exact` it also moves near-duplicate grays to the role the kit uses and tone text to the readable shade derived from the fill. It ends with the pairs it found no role for, most frequent first. A frequent one usually means the app's surface or line differs from the kit's default. Set it as a role in the app's theme, then run again with `--theme`, so those pairs map too:
```css
@theme {
--role-surface-dark: var(--color-gray-900);
}
```
```sh
npx eco-vue-color-roles --theme src/assets/styles/index.css --write src
```
## Theme links
A link to any page of this site can carry a theme in a `theme` query parameter. Opening it applies the theme, keeps it in the browser like one picked here, and drops the parameter from the address. **Copy link** above makes one for this page.
The parameter is a preset id, or a URL-encoded JSON object of tokens, with an optional `preset` the tokens override:
```
https://rsmple.github.io/eco-vue-js/guide/theming?theme=ocean
https://rsmple.github.io/eco-vue-js/components/select?theme={"preset":"compact","color-primary":"#e11d48","neutral":"stone"}
```
Encode the JSON with `encodeURIComponent` when building a link in code; browsers accept it unencoded when typed. Keys are the variable names without `--`, plus `neutral`, which takes a scale name: `zinc`, `slate`, `gray`, `neutral` or `stone`. An optional `name` saves the theme among the opener's themes under that name, numbered when it is taken; opening a theme that is saved already switches to it instead of saving a copy. Unknown keys are ignored, and so are values with `;`, `{`, `}`, `<`, `>`, `\`, `url(` or `@import`. Colors take any CSS color syntax, including `var(--color-gray-400)`; sizes any CSS length. Radii double as side padding, so a pill shape is half the height (`1.125rem` for `2.25rem`), not `9999px`.
To make a theme from a description — "a warm, rounded theme for a finance dashboard" — pick a neutral scale and a primary, adjust status colors only where they clash with it, and link to the page the theme should be seen on, or to this one to keep editing it. Mid-tone fills (OKLCH lightness 50–58%) work in both modes without `-dark` values.
### Presets
| Id | Look |
| --- | --- |
| `default` | The kit's violet on zinc, as this site ships |
| `ocean` | Blue primary, cyan info, slate neutrals |
| `forest` | Green primary, lime positive, stone neutrals, pill-shaped fields and buttons |
| `sunset` | Orange primary, rose negative, stone neutrals |
| `compact` | Monochrome: near-black primary that turns light in dark mode, no tint, 2rem fields and buttons, small radii |
### Palette
| Key | Default | Controls |
| --- | --- | --- |
| `color-primary` | `oklch(62% 0.17 286.8)` | Fills, links, focus and selection |
| `color-primary-dark` | `primary` | The same in dark mode |
| `color-primary-light` | `primary` 10% over `default` | List headers and the backdrop in light mode |
| `color-primary-darkest` | `primary-dark` 20% over `default-dark` | The same in dark mode |
| `neutral` | `zinc` | The `gray-*` scale: lines, fills, muted text |
| `color-default` | `#ffffff` | Page and card background in light mode |
| `color-default-dark` | `oklch(21% 0.006 285.885)`, zinc's 900 | Page and card background in dark mode |
| `color-black-default` | `#333333` | Main text in light mode |
| `color-negative` | `oklch(64% 0.2 24.1)` | Errors and destructive actions |
| `color-positive` | `oklch(69% 0.148 150)` | Success |
| `color-warning` | `oklch(85% 0.16 85)` | Warnings; black text on the fill |
| `color-info` | `oklch(63% 0.16 262.6)` | Information |
| `color-*-dark` | the light value | Each status color in dark mode |
| `color-data-*` | Tailwind's hues at mid lightness: white text on all but orange and amber; gray is the scale's 500 | [Data palette](#data-palette) |
| `role-*`, `role-*-dark` | see [Roles](#roles) | One role, per mode |
| `font-sans` | `MontSerrat, system-ui, sans-serif` | Text font. Only MontSerrat is loaded on this site; other families must be installed on the viewer's machine |
### Shape
| Key | Site value | Kit default | Controls |
| --- | --- | --- | --- |
| `w-input-height` | `2.25rem` | `2.75rem` | Inputs and selects; the options inside them follow |
| `w-input-rounded` | `0.5rem` | `0.75rem` | Radius of inputs and selects, and the side padding of their options |
| `w-input-gap` | `0.125rem` | `0.25rem` | Space between a field's border and the options in it |
| `w-button-height` | `2.25rem` | `2.75rem` | Buttons |
| `w-button-rounded` | `0.75rem` | `1rem` | Radius of buttons, and their side padding |
| `w-list-header-height` | `2.25rem` | `2rem` | List headers |
| `w-list-header-rounded` | `0.75rem` | `1rem` | Radius of list headers |
| `w-checkbox-size` | `0.75rem` | `1rem` | Checkboxes and radios |
| `w-shadow-lift` | `0.5rem` | `0.5rem` | How far `shadow-md` falls under dropdowns, modals and tooltips; `0rem` centers it into a glow |
The kit has more `w-*` variables for single components, such as `w-list-padding` and `w-modal-wrapper-rounded`; the playground covers the ones that shape the whole app.
---
---
url: https://rsmple.github.io/eco-vue-js/releases.md
description: >-
Release notes for every eco-vue-js version, newest first — what changed in
each component and what breaks on upgrade.
---
# Releases
Every version published to npm, newest first. The same notes are on [GitHub Releases](https://github.com/rsmple/eco-vue-js/releases).
## 0.26
### v0.26.20
2026-10-27 · [GitHub](https://github.com/rsmple/eco-vue-js/releases/tag/v0.26.20)
* Add `deferred` and `previewData` props to [WSelectSingle](/components/select), [WSelect](/components/select), [WSelectAsync](/components/select) and [WSelectAsyncSingle](/components/select) — a deferred `seamless` select renders as a lightweight field until it is clicked or focused, so lists with many select cells render much faster; `previewData` supplies the selected options for its chips without loading the options
* Fix WSelectAsync and WSelectAsyncSingle requiring `valueGetter` — it defaults to the option's `id`, and leaving it out no longer logs a warning per field
* Speed up rendering of [WInputAsync](/components/input)
* Fix [WNavItem](/components/nav-bar) title for a route whose child record shares its path — the title registered on the parent record is now used
### v0.26.19
2026-10-26 · [GitHub](https://github.com/rsmple/eco-vue-js/releases/tag/v0.26.19)
* Fix [WDropdownAdaptive](/components/dropdown-menu) not updating its open content when the parent changes its slots — content added or removed behind a `v-if` while the dropdown, dialog or bottom sheet is open now shows up
### v0.26.18
2026-10-25 · [GitHub](https://github.com/rsmple/eco-vue-js/releases/tag/v0.26.18)
* Fix [WList](/components/list) sort menu listing fields whose `field` getter returns `undefined` for the current query params — only fields that resolve to a sortable key are offered now
### v0.26.17
2026-10-25 · [GitHub](https://github.com/rsmple/eco-vue-js/releases/tag/v0.26.17)
* Fix [WInfiniteList](/components/infinite-list) adding pages one after another on very long lists (about a million rows and up) — the spacers now shrink to fit under the browser's element height limit, so the scrollbar moves through the list faster than the rows scroll
### v0.26.16
2026-10-24 · [GitHub](https://github.com/rsmple/eco-vue-js/releases/tag/v0.26.16)
* Fix [WList](/components/list) table header and rows not stretching to the full width of the list when no column is flexible — an empty filler column now takes the leftover space
* Fix WList header cells picking up the field's `cssClass` — header cells now only get `cssClassHeader`
* Fix WList header settings content being clipped
### v0.26.15
2026-10-24 · [GitHub](https://github.com/rsmple/eco-vue-js/releases/tag/v0.26.15)
* Lay out [WList](/components/list) table columns on one shared grid sized by the field meta `width` (`10rem` by default, `minmax(…fr)` and `var(--…)` allowed), so header and cells always line up and rows stop re-rendering while the list opens — nested fields span their own columns, and `componentItem` lines of nested arrays now fill the row instead of one cell
* Add `w-list-align-top-*` to set how far the checkbox and row menu sit from the top with `align-top`, so they line up with the first line of the cells
* Fix grouped WList: the group heading spans the whole row and sticks under the toolbar, grid rows run on across pages, and cards no longer jump while pages above load or are dropped — [WInfiniteList](/components/infinite-list) `pageClass` now styles one element holding all pages, with a new `pageStyle` beside it
* Fix long dropdown menus without a tip overflowing the screen — they now scroll within the height left
### v0.26.14
2026-10-23 · [GitHub](https://github.com/rsmple/eco-vue-js/releases/tag/v0.26.14)
* Add [WQueryStatus](/components/query-status) — mounted once next to [WNotify](/components/notify), it watches the queries on screen and shows one message with Retry when some fail to load because the server is unreachable or answers 5xx, 408 or 429; while the server is down every skeleton stops and greys out
* Add [WQueryBoundary](/components/query-boundary) — wraps a card with the queries it shows; when one fails without data its skeletons stop and grey out, with its own message only for failures WQueryStatus doesn't show, such as a 403
* Keep failed list pages and [WPageError](/components/page-error) out of the WQueryStatus message, so a failure is told once
* Add the `partialLoadError` text ("Some data failed to load") and `getLoadErrorMessage`, `findFailedQueries` and `useHandledQueryError` in `utils/queryClient`
### v0.26.13
2026-10-23 · [GitHub](https://github.com/rsmple/eco-vue-js/releases/tag/v0.26.13)
* Add [WPageError](/components/page-error) — wraps a page that shows one item and replaces it while its query has an error: the status, the server's `detail` and Back, plus Retry when the server was unreachable or answered 5xx; its texts (`errorTitle`, `pageNotFound`, `serverError`, `serverUnavailable`, `back`, `retry`) translate with `setTexts`
* Add `watchServerRecovery` to `utils/queryClient` — while the server is unreachable it sends a cheap probe request with a growing delay (and at once when the tab is shown or the browser comes back online), then refetches the failed queries once the server answers; vue-query's `refetchOnReconnect` only follows the browser's own connection
* Add `useAvailable()` to ApiClientInstance — false after a network failure or a 502/503/504, true again on the server's next answer
### v0.26.12
2026-10-23 · [GitHub](https://github.com/rsmple/eco-vue-js/releases/tag/v0.26.12)
* Add an error state to [WInfiniteList](/components/infinite-list) and [WList](/components/list) — a page that failed to load with nothing cached shows the server's `detail` (or "Server is unavailable") and a Retry button instead of endless skeletons; replace it with the new `error` slot, and translate it with the `loadError`, `serverUnavailable` and `retry` texts
* Add `ApiErrorUnavailable` — requests reject with it when the server can't be reached or a gateway answers 502/503/504, instead of a bare `TypeError` that error handlers ignored
* Fix ApiClientInstance logging users out when the session refresh fails because the server is down, and requests hanging forever when the refresh failed or the refresh endpoint answered 401 — every request now settles, and a request retried after a 401 rejects when the retry fails
* Add `defaultQueryRetry` and `isRetryableError` to `utils/queryClient` — retries network errors, 5xx, 408 and 429 only, never 404 or other 4xx; use it as the `QueryClient` default
### v0.26.11
2026-10-23 · [GitHub](https://github.com/rsmple/eco-vue-js/releases/tag/v0.26.11)
* Add `trail` to `useRouteTitle` — extra breadcrumb levels after a page that are not routes, such as the active filter of a list (`Pipelines / In progress`); the last one is the current title and shows its own `suffix`
### v0.26.10
2026-10-23 · [GitHub](https://github.com/rsmple/eco-vue-js/releases/tag/v0.26.10)
* Fix [WSelectAsync](/components/select) showing stale options when `useQueryFnOptions` or `useQueryFnPrefix` changes — the option list and selected prefix now query again with the new function, including while the menu is open
* Fix queries from `createDefaultQuery` failing when called outside component setup — the resolved query client is now used
* Hide the [WInputSuggest](/components/input) placeholder while its unclickable menu is open, and drop the extra bottom margin on mobile
* Show the placeholder in [WInput](/components/input) when `unclickable` — the input also keeps its width and the mobile `text-base` size
### v0.26.9
2026-10-20 · [GitHub](https://github.com/rsmple/eco-vue-js/releases/tag/v0.26.9)
* Add `maxSizeKb` to `WInput` to set the largest file `allowDropFile` accepts by drop or picker
* Raise the default `WInput` dropped-file limit from 10 KB to 100 KB
### v0.26.8
2026-10-19 · [GitHub](https://github.com/rsmple/eco-vue-js/releases/tag/v0.26.8)
* Add `replace`, `target` and `rel` props to [WButtonMoreItem](/components/dropdown-menu) and [WMenuItem](/components/dropdown-menu) — open a menu link in a new tab or replace the current history entry, same as [WButton](/components/button)
### v0.26.7
2026-10-19 · [GitHub](https://github.com/rsmple/eco-vue-js/releases/tag/v0.26.7)
* Fix [WUniform](/components/form) `confimGetter` cancel: the field drops its unsaved draft (an async [WInput](/components/input) shows the saved value again) and the update promise resolves instead of rejecting, so cancelling no longer reaches the app `errorHandler`
* Expose `cancel` on WInput — discards the unsaved async value
* Hide the [WFieldWrapper](/components/field-wrapper) filter button until the field is hovered, unless the filter is active
* Fix [WTabs](/components/tabs) switching from a horizontally scrolled tab — the scroll offset is reset so it no longer shifts the next tab during the transition
### v0.26.6
2026-10-19 · [GitHub](https://github.com/rsmple/eco-vue-js/releases/tag/v0.26.6)
* Add `expirationCookie` option to ApiClientInstance — apps sharing an origin can each point at their own session-expiry cookie (defaults to `exp`), so one app logging out or refreshing no longer clears or stalls the other's session
### v0.26.5
2026-10-18 · [GitHub](https://github.com/rsmple/eco-vue-js/releases/tag/v0.26.5)
* Add `toggle` slot to [WButtonMore](/components/dropdown-menu) to open the menu from another button, such as [WButtonInput](/components/button) — the slot gets `toggle`, `active` and `attrs` to wire up the click, highlight and `aria-expanded`
* Add `active` prop to WButtonInput to highlight it, such as while its menu is open
* Fix the hover and press ripple on [WButton](/components/button), WButtonInput and [WCheckbox](/components/checkbox) stopping inside the border — it now covers the border and its corners match the element's rounding
* Add `w-ripple-border` utility for custom `w-ripple` elements with a border — `w-ripple-border` for 1px, `w-ripple-border-2` or `w-ripple-border-(--var)` for others
### v0.26.4
2026-10-18 · [GitHub](https://github.com/rsmple/eco-vue-js/releases/tag/v0.26.4)
* Fix [WInfiniteList](/components/infinite-list) not loading the next pages after a fast scroll, and size its spacers and skeletons by the real item count — a short last page no longer leaves blank space or shrinks the scrollbar estimate
* Fix the [WTabs](/components/tabs) bar on phones adding the page inset inside a box that sets `--w-frame-padding` — it now takes that padding instead
* Fix menus and confirms opened from above the modals, such as a full-screen login, rendering underneath their opener
* Fix the [WNavItem](/components/nav-bar) counter skeleton overlapping the title
### v0.26.3
2026-10-15 · [GitHub](https://github.com/rsmple/eco-vue-js/releases/tag/v0.26.3)
* Fix [WTabs](/components/tabs) scrolling the page down when switching tabs while it isn't at the very top — the page now stays where it was
### v0.26.2
2026-10-14 · [GitHub](https://github.com/rsmple/eco-vue-js/releases/tag/v0.26.2)
* Fix [WDragContainer](/components/drag-container) reordering when `list` holds equal values (the same string twice, or the same object) — dragging one of them no longer moves the wrong item or loses track of it
### v0.26.1
2026-10-14 · [GitHub](https://github.com/rsmple/eco-vue-js/releases/tag/v0.26.1)
* Change IconDanger, IconSuccess and IconWarn to an outline style — existing usages will render as outlines
* Add IconDangerSolid, IconSuccessSolid and IconWarnSolid filled variants — use these to keep the previous filled look
* Notifications keep their filled icons by using the new solid variants
### v0.26.0
2026-10-14 · [GitHub](https://github.com/rsmple/eco-vue-js/releases/tag/v0.26.0)
* Add paginated mode to [WInputOptions](/components/input) — pass `useQueryFnOptions` + `queryParamsOptions` (and `searchField`, default `search`) instead of `options` to get server-side suggestions with infinite scroll
* Add `draft` mode to WInputOptions — typing only drives suggestions; the value changes on Enter, a picked suggestion or the "Apply:" row (`applyPrefix`), and the typed text is dropped when the menu closes
* **Breaking**: WInputOptions `#option` slot and `optionComponent` now use the select option types — `option` may be `undefined` on skeleton rows
* Fix Enter not selecting the cursor option in [WSelectAsync](/components/select), and "Duplicate keys" warnings in [WInfiniteList](/components/infinite-list) when rows share a `valueGetter` value
## 0.25
### v0.25.2
2026-10-14 · [GitHub](https://github.com/rsmple/eco-vue-js/releases/tag/v0.25.2)
* Lighten the default `primary` and status colors (`negative`, `positive`, `warning`, `info`) in the Tailwind base theme — status fills now sit lighter than the primary, so badges and alerts no longer outweigh the content beside them, while white text on them stays readable
* Use `text-accent` instead of `text-description` for idle and disabled items in [WMenuItem](/components/dropdown-menu)
### v0.25.1
2026-10-13 · [GitHub](https://github.com/rsmple/eco-vue-js/releases/tag/v0.25.1)
* Add `soft` and `icon` props to [WChip](/components/chip) — `soft` renders a rounded pill with a tinted background and colored text, with classes set via `setSemanticTypeChipSoftMap`; `icon` shows an icon before the label
* Fix [WButtonCopy](/components/button) changing the line height when placed inside text — it now takes up one line of the surrounding text and has a larger click area
* Add `noInline` prop to WButtonCopy to lay it out as a regular block instead
### v0.25.0
2026-10-10 · [GitHub](https://github.com/rsmple/eco-vue-js/releases/tag/v0.25.0)
* Add `to` prop to [WTabsItem](/components/tabs) and [WButtonTab](/components/tabs) — tabs become route links that replace the history entry, and the current route decides which tab is open
* Add route titles: [WHeaderBar](/components/header-bar) without a `title` shows a breadcrumb of the current route, pages report data-driven titles with `useRouteTitle`, and `useDocumentTitle` keeps the browser tab title in sync — [WNavItem](/components/nav-bar) also shows the title the open page registers
* Fix [WNotify](/components/notify) toast position to respect safe-area insets, and keep pending toasts on screen until they finish — the result shows and closes after 5 seconds like any other toast
* Fix [WLink](/components/links) clipping long text — it now wraps instead of being cut off
## 0.24
### v0.24.0
2026-10-10 · [GitHub](https://github.com/rsmple/eco-vue-js/releases/tag/v0.24.0)
* Add filters to the [WList](/components/list) selection bar — put [WListFilter](/components/list) in the new `filter` slot. Chips that do not fit fold into a "more" chip, `pinned` keeps chosen filters always shown, and a filter's `meta.summary` names the picked values on its chip
* **Breaking**: [WInput](/components/input) emits `''` when cleared or emptied, also when the model was `undefined` — set `clearValue` to `undefined` or `null` to keep emitting that
* Add the `isPlaceholderItem` query option to show an item already cached by a list while its own query loads, a `divider` prop and count badges to [WTabs](/components/tabs), and `iconClass` to [WInfoCard](/components/info-card)
* Fix WTabs not updating when tabs change without changing their number, and list every sortable field in the WList sort menu, with a badge counting the sorted ones
## 0.23
### v0.23.1
2026-10-07 · [GitHub](https://github.com/rsmple/eco-vue-js/releases/tag/v0.23.1)
* Fix [WUniform](/components/form) resetting its model when `queryParams` change before the new item has loaded — a create whose result carries the new id (stepper forms that save on a step) no longer erases the form; the loaded item replaces the model once it arrives
* Update dependencies with audit fixes
### v0.23.0
2026-10-07 · [GitHub](https://github.com/rsmple/eco-vue-js/releases/tag/v0.23.0)
* **Breaking**: `dateFormat` shows the month name (`07 Oct 2026`) and `datetimeFormat` puts the date first with the locale's separator (`07 Oct 2026, 14:30:05`) — dates no longer read differently across locales
* **Breaking**: date, time and duration formatters take options instead of flags — `dateFormatShort` → `dateFormat(d, {year: 'auto'})`, `timeFormatShort` → `timeFormat(d, {seconds: false})`, `durationHumanize(s, full, round)` → `durationFormat(s, {style, round})`, `parseDate` → `parseDateQuery`
* Add `setLocale` — dates, times, durations and numbers format in the app's locale, and durations show `10 mths` / `10 mins` instead of the ambiguous `10 M` / `10 m`
* Add `noData`, `dateFrom`, `dateTo` and `noDate` texts and localize the "Today" label in charts; [WInputDate](/components/input) reads dates typed in the locale's order
## 0.22
### v0.22.1
2026-10-06 · [GitHub](https://github.com/rsmple/eco-vue-js/releases/tag/v0.22.1)
* Add `replace` option to `useOverlay().open` — the new dropdown takes the place of the one it was opened from, at its anchor, and keeps its `onClose` until it closes too
* Fix lazily loaded overlay content flashing an empty frame — the overlay now shows once the component has loaded, keeping the one it replaces until then
* Fix dropdowns closing when a press inside, such as dragging a slider, is released outside; fix bottom sheets not closing on swipe when shown without a backdrop; fix [WUniform](/components/form) detecting changes in nested object values
* Open the [WButtonDropdown](/components/dropdown-menu) menu with the overlay manager — a bottom sheet on phones; widen filter dropdowns to fit their fields, line up embedded select options with the field, and pin the [WNotifyCenter](/components/notify) footer in its dropdown
### v0.22.0
2026-10-06 · [GitHub](https://github.com/rsmple/eco-vue-js/releases/tag/v0.22.0)
* **Breaking**: Overlays now lay out their own content — a [WModalWrapper](/components/modal), filter or menu opened as a modal, dropdown or bottom sheet gets its title, buttons and padding from the frame. Remove `sm-not:px---inner-margin` from content and give edge-to-edge parts such as lists `w-frame-bleed`
* **Breaking**: Drop `embedded` from filter `meta` — filters show their `header` as a title and size to their content. Drop `moreToggleClass` from [WButtonSelection](/components/list) — list bulk actions that do not fit the bar move into More
* Ask before closing a modal whose [WUniform](/components/form) with `api-method` has unsaved changes, and keep it open while it saves — `useLayerChanges` and `useLayerBusy` do the same for other content. Add `setTexts` to translate the built-in texts
* Add `stepperControls`, `submitting` and `disabledNext` to [WTabs](/components/tabs) to put the step title and buttons in the frame — [WModalStepper](/components/modal) is deprecated in their favour
* Show toasts at the top edge on phones for every [WNotify](/components/notify) `position`, clear of a modal's buttons
* Fix WUniform date fields not reporting changes or losing their value after a save, keep unsaved changes when the loaded model updates, and check the current step's fields before the WTabs stepper moves on
## 0.21
### v0.21.3
2026-10-05 · [GitHub](https://github.com/rsmple/eco-vue-js/releases/tag/v0.21.3)
* Add `horizontalAlign` to [WNotifyCenterButton](/components/notify) and `useNotifyCenter` — `HorizontalAlign.LEFT_INNER` grows the center left from a bell at the right end of the header, `HorizontalAlign.LEFT_CENTER` opens it beside a bell in a side rail
* Open overlay dropdowns with `align` set to `LEFT_CENTER` or `RIGHT_CENTER` beside the anchor, with a tip pointing at it — near the top or bottom of the screen the box shifts while the tip stays on the anchor
* Animate notifications into and out of [WNotifyCenter](/components/notify), and center its empty state
* Hide the [WButtonAction](/components/actions-bar) tooltip while the button is `active`
### v0.21.2
2026-10-05 · [GitHub](https://github.com/rsmple/eco-vue-js/releases/tag/v0.21.2)
* Fix [WNavItemExpand](/components/nav-bar) not opening its dropdown on hover on server-rendered pages, such as a VitePress site
* Export `NotifyType`, `NotifyChannel` and the notify types (`NotifyConfig`, `NotifyContentEmits`, `NotifyGroup`, `NotifyItem`, `NotifyPatch`) from `eco-vue-js/dist/utils/Notify`
* Add `eco-vue-js/dist/utils/NotifyCenter` with `useNotifyCenter`, `closeNotifyCenter`, `isNotifyCenterOpen` and the pending and action counts — no more imports from component internals
### v0.21.1
2026-10-05 · [GitHub](https://github.com/rsmple/eco-vue-js/releases/tag/v0.21.1)
* Keep [WNotify](/components/notify) toasts on screen while the pointer is over them — their timers pause and resume with the time they had left
* Fix WNotify groups merging repeated members into one — every member now adds to the list, newest first; a group's toast stays up for 5 seconds after its latest member and leaves all at once, and a long member list scrolls
* Fix a notify added by the same click that closes the notify center never showing its toast
* Fix `set` writing to the wrong level when an intermediate object already exists, `numberFormatter`/`percentFormatter` leaving every comma after the first in large numbers, and `w-border-svg-stroke-*` ignoring bare numbers — `w-border-svg-stroke-2` is now 2px
### v0.21.0
2026-10-05 · [GitHub](https://github.com/rsmple/eco-vue-js/releases/tag/v0.21.0)
* Add [WNotifyCenter](/components/notify) and [WNotifyCenterButton](/components/notify) — a history of notifications split into action and activity channels, with groups and custom `component` content; `Notify.process` shows a spinner until `Notify.update` settles it, `Notify.*` now returns the notification id, and [WNotify](/components/notify) gets a `position` prop
* Add anchored confirms — pass `anchor` to `Modal.addConfirm` (or open it with `useOverlay` from a menu item) to show the confirm as a dropdown next to the element, a bottom sheet on phones, instead of a modal; bulk actions get `clearSelection` so the selection resets after a confirm from the More menu
* Open [WButtonMore](/components/dropdown-menu), the list's sort, export, settings and filter menus, and input and select menus through one overlay manager — only one menu is open at a time, nested menus open over their parent, dropdowns are centered on their anchor with a tip, and Escape closes input menus; selects scroll to the selected option on open (`cursorSelected` also puts the cursor on it)
* **Breaking**: [WDropdownAdaptive](/components/dropdown-menu) now opens through the overlay manager — `top`, `bottom`, `updateAlign`, `emitUpdate`, `dropdownClass`, `closeOnClickOutside`, the `update:rect` emit and `updateDropdown` are gone; use `horizontalAlign`, `frameClass` and `closeOnClick`, and `close` now fires on any outside click, Escape or swipe
## 0.20
### v0.20.6
2026-10-03 · [GitHub](https://github.com/rsmple/eco-vue-js/releases/tag/v0.20.6)
* Fix async [WUniform](/components/form) blocking every save on one invalid field — valid changed fields now save while the invalid one shows its error and waits, a change made during a save is sent when it finishes, and no "Form contains invalid data" notification pops up; validation also no longer scrolls to a field that is already in view
* Add [WDropdownTip](/components/dropdown) and the `surface-raised` and `shadow` roles — dropdowns and menus sit on a raised surface with a layered `shadow-md` (adjust with `--w-shadow-lift`), [WList](/components/list) filter dropdowns get a pointer tip, and tooltips follow the page theme instead of always being dark
* Drop the `w-checkbox-color-*` and `w-date-picker-day-*` utilities — checked [WCheckbox](/components/checkbox) and selected calendar days now pick black or white text by contrast (`text-tone-on`), so remove them from your `@apply` or the Tailwind build fails
* Add a Clear button to the WList sort menu, stop [WSelectAsync](/components/select) refetching its chips on select and unselect, keep light primaries such as yellow readable in borders and focus rings (`border-tone-line`), match [WButtonGroup](/components/button-group) radius and padding to [WButton](/components/button), and ship the docs as Markdown with an `llms.txt` index in the package
### v0.20.5
2026-10-01 · [GitHub](https://github.com/rsmple/eco-vue-js/releases/tag/v0.20.5)
* Fix the line-number gutter background in multiline [WInput](/components/input) — it now matches the input background, including one set with `w-input-bg-*`
### v0.20.4
2026-10-01 · [GitHub](https://github.com/rsmple/eco-vue-js/releases/tag/v0.20.4)
* Fix `@import "eco-vue-js/tailwind-base/markdown.css"` and `css/markdown/blocks.css` failing to resolve in Vite and ESLint — every stylesheet under `tailwind-base` is now exported, not only `base.css`
### v0.20.3
2026-10-01 · [GitHub](https://github.com/rsmple/eco-vue-js/releases/tag/v0.20.3)
* Add `semanticType` prop to [WMenuItem](/components/dropdown-menu) and [WButtonMoreItem](/components/dropdown-menu) — `negative`, `positive`, `warning` and `info` color the item text at rest, so a red "Delete" no longer needs a `tone-*!` override
* Add markdown styles to the Tailwind base — `@import "eco-vue-js/tailwind-base/markdown.css"` styles rendered markdown in `.w-markdown` with color roles, and `css/markdown/blocks.css` adds GitBook hints, colored text and expandable sections
* Fix `h4` in markdown rendering larger than `h3` in small-text containers, and the code block copy button turning invisible on hover in light mode
### v0.20.2
2026-10-01 · [GitHub](https://github.com/rsmple/eco-vue-js/releases/tag/v0.20.2)
* Add the `surface-subtle` color role (`bg-surface-subtle`) — a fainter layer than `surface-muted` for striped rows, empty chart cells and panels inside a card; themes set it with `--role-surface-subtle` and `--role-surface-subtle-dark`
* Make the default `primary-light` and `primary-darkest` shades softer
* Map `bg-X-light dark:bg-X-darkest` to `tone-X bg-tone-soft` and gray-50 pairs to `bg-surface-subtle` in the color roles codemod
* Fix the color roles codemod collapsing indentation and line breaks in multi-line class strings
### v0.20.1
2026-10-01 · [GitHub](https://github.com/rsmple/eco-vue-js/releases/tag/v0.20.1)
* Fix [WButton](/components/button) and other semantic-type borders rendering in the text color (white on filled buttons) in apps — the kit's semantic type classes are now picked up by Tailwind, so `border-tone-fill` is generated
### v0.20.0
2026-10-01 · [GitHub](https://github.com/rsmple/eco-vue-js/releases/tag/v0.20.0)
* Add color roles: components paint with roles (`text-accent`, `bg-surface`, `border-line`, …) and tones (`tone-negative text-tone`, `surface-fill`) instead of `dark:` pairs, and an app sets any role as `--role-*` in its `@theme` — also adds a `data-*` palette for categories, `gradient-*` roles, and the `npx eco-vue-color-roles` codemod for an app's own `dark:` pairs; see the Theming guide
* **Breaking**: retune default colors for contrast — darker violet primary, deeper status colors, zinc `gray-*`, darker `text-description` in light mode; `-dark` colors now default to their light value, and text on fills and tone text are derived from the fill so they stay readable in any theme
* **Breaking**: drop the `severity-*` and `score-*` colors from the theme — define them in the app
* Fix side padding of [WButton](/components/button) and input options with very large or very small radii — it now stays between `0.5rem` and half the height
## 0.19
### v0.19.4
2026-09-30 · [GitHub](https://github.com/rsmple/eco-vue-js/releases/tag/v0.19.4)
* Fix `goto` in [WInfiniteList](/components/infinite-list) landing on the wrong rows for a page that is not rendered, and ignoring the item index — it now scrolls to the item itself, once its page has loaded
* Fix readonly [WInput](/components/input) and the selects built on it being taller than the editable field — switching to readonly no longer shifts the layout
* Restyle [WProgressBar](/components/progress) and [WProgressStriped](/components/progress), with the percentage readable over the fill and `role="progressbar"` for screen readers; [WSlider](/components/slider)'s thumb now grows on hover like [WSliderRange](/components/slider)'s; set `--w-chart-heatmap-bg` to match [WChartHeatmap](/components/charts)'s fade to a background other than the default
* Accept string ids in list selections (`id__in`, `id__not_in`), and drop the unused WFilterWrapper and `useQueryUpdater`
### v0.19.3
2026-09-30 · [GitHub](https://github.com/rsmple/eco-vue-js/releases/tag/v0.19.3)
* Update [WSpinner](/components/spinner) to match regular icons — 24×24 viewbox with a 1.5 stroke, and 20px by default instead of 2rem; size it with `square-*`
* Add `IconSpinner`, an animated spinner usable anywhere a regular icon is
* Drop the `w-spinner-size-*` utility and the `spinner-dash-small` animation — use `square-*` and `animate-spinner-dash` instead
### v0.19.2
2026-09-29 · [GitHub](https://github.com/rsmple/eco-vue-js/releases/tag/v0.19.2)
* Add `loading` to [WButtonAction](/components/actions-bar) — a shimmer runs over the button while its action is in progress — and close its tooltip on click; add `semanticType` to [WCounter](/components/counter), `label` to [WCheckbox](/components/checkbox) and [WButtonSelectionAction](/components/list), and use `textFilter` of [WActionsBar](/components/actions-bar) as the filter panel heading; list rows, toolbar buttons and [WExpansionItem](/components/expansion) now expose their names and expanded state to screen readers
* Fix [WSlider](/components/slider) filling from the wrong position with a non-zero `min`, ignoring `semanticType` and snapping off-step, and [WSliderRange](/components/slider) rendering without color unless set — it now defaults to primary; [WFilePicker](/components/file-picker) no longer accepts drops while readonly and keeps only the first dropped file without `multiple`
* Fix [WInfiniteList](/components/infinite-list) template ref exposing nothing — `resetPage`, `goto`, `refetchAll`, `isFetching` and `isRefetchingAll` now work — and refetching the wrong pages after an item changes; fix [WModalImport](/components/modal) hiding its progress bar and counting finished requests as aborted, [WPage](/components/report-pages) continuation pages bumping the section number, and [WRouterLink](/components/links) failing without vue-router or `to` — it now renders a plain link
* Drop unused props — `headerTopIgnore`, `lastChild` and `count` from WInfiniteList, `bottom` from WActionsBar, `update:search` from [WHeaderBar](/components/header-bar), `semanticType` and `noBg` from WSliderRange, `title` from [WDatePickerSingle](/components/date-picker) and [WListFilter](/components/list), `small` from WCounter, `showPoints` from [WChartLine](/components/charts); deprecate `searchVisible` on WListFilter; make `watermark` optional on WPage
### v0.19.1
2026-09-27 · [GitHub](https://github.com/rsmple/eco-vue-js/releases/tag/v0.19.1)
* Emit `update:has-changes` from [WTabs](/components/tabs) when any tab has unsaved changes, render the `suffix` slot of [WTabsItem](/components/tabs) and disable the tab button of a disabled WTabsItem
* Fix the text color of [WChip](/components/chip), [WDropdownMenu](/components/dropdown-menu) ignoring `bottom` and [WButtonGroup](/components/button-group) not inheriting readonly and skeleton states from a parent; [WCheckboxGroupMultiple](/components/checkbox) now supports `subgrid` and `embedded` like [WCheckboxGroup](/components/checkbox)
* Add the `w-drag-item-overlay-*` utility to set the drag overlay color in [WDragContainer](/components/drag-container)
* Drop unused props — `top`, `bottom`, `parentElement` and `dropdownClass` from [WButtonDropdown](/components/dropdown-menu), `innerClass` from WDropdownMenu, `divided` from WTabsItem and `noPadding` from [WListCardField](/components/list)
### v0.19.0
2026-09-27 · [GitHub](https://github.com/rsmple/eco-vue-js/releases/tag/v0.19.0)
* Fix [WInput](/components/input) paste button rejecting clipboard text within `maxLength` and accepting text over it
* Fix [WSelectAsync](/components/select) showing at most 8 chips whatever `prefixMax` is — it now defaults to 8 and loads that many; [WSelectAsyncSingle](/components/select) now emits `focus` and `blur`
* **Breaking**: replace `selectOnClose` with `searchModel` on all selects, and drop unused props and events — `light` and `maxHeight` from [WTooltip](/components/tooltip), `mandatory` and `savedText` from [WToggle](/components/toggle) and form fields, `top`, `bottom`, `parentElement` and `innerClass` from [WInputSuggest](/components/input), [WInputDate](/components/input) and [WSelect](/components/select), `useFirstDefault` from WSelectAsync, `keypress:backspace` from WInput and `update:model-value` from [WSelectAsyncList](/components/select)
* Describe props, events and slots of WToggle, WTooltip, WInput and WSelect in the docs, and add a releases page
## 0.18
### v0.18.3
2026-09-26 · [GitHub](https://github.com/rsmple/eco-vue-js/releases/tag/v0.18.3)
* Add `disabledItems` prop to [WButtonGroup](/components/button-group), `href`/`target`/`rel` props to [WButtonAction](/components/actions-bar) (with `tag="a"`) and a `right` slot to [WHeaderBar](/components/header-bar)
* Fix [WInputAsync](/components/input) refusing every save after one invalid value, and [WToggle](/components/toggle) with an array of `validate` functions always reporting an error
* Fix [WInfiniteList](/components/infinite-list) waiting for a scroll when a loaded page is too short to fill the viewport — the next page now loads right away; list headers inside their own scroll container now stick to its top instead of under the page header
* Make [WList](/components/list) and [WTabs](/components/tabs) render on the server — WList now reads its saved config on mount; `useIsMobile` breakpoints now match tailwind `sm` (640px) and `xl` (1280px), [WNavBar](/components/nav-bar) and [WActionsBar](/components/actions-bar) respect `--left-margin`/`--right-margin`, and textarea line styles ship in the tailwind base
### v0.18.2
2026-09-25 · [GitHub](https://github.com/rsmple/eco-vue-js/releases/tag/v0.18.2)
* Icon-only buttons (`WButtonAction`, `WButtonSelectionAction`) expose their tooltip text as `aria-label`, so they have an accessible name
* Select dropdowns use `listbox`/`option` roles, with `aria-selected` and `aria-disabled` on options
* `WModalWrapper` renders as `role="dialog"` with `aria-modal`, and its title is an `h2` that labels the dialog
* Rich/textarea `WInput` (contenteditable) is labelled by its field title
* `WNavBar` renders a `