Frappe UI v1 Changelog
This page lists the v1 changes that affect apps. The Unreleased section lists every change since v0.1.278. It records breaking changes, deprecations, behavior changes you can see, and migration steps. It does not record internal refactors or new tests.
A deprecated API keeps working through v1.x and logs a one-time warning in development, unless the entry says otherwise. Deprecated APIs are removed after v1. This rule does not cover members deprecated during the betas. Under ADR-0008 (no deprecated member ships in 1.0.0), each of them was removed before 1.0.0, or moved to frappe-ui/experimental, which has no stability promise. ThemeSwitcher and the v0 TextEditor took the second path. Each entry says which, and the Deprecation log lists them.
How to read an entry
Each entry says what changed, what to do, and where the migration guide covers it. The tag in the heading says how the change reaches your app:
| Tag | What it means for your app |
|---|---|
| breaking, loud | The build, the type-check or the import fails, so the tools show you every place to change. |
| breaking, silent | Old code still compiles and runs, but it behaves differently. Nothing fails. Search your code for the old names. The migration guide usually has a before/after for these. |
| breaking; loud in TS, silent in JS | TypeScript reports the change. JavaScript call sites, and values bound at runtime, do not. |
| breaking | The entry lists which parts are loud and which are silent. |
| fix, additive | Nothing to change, unless you depended on the old behavior. |
Entries are grouped by area. Inside each area, they keep the order they had in the single list this page used before.
Unreleased
- Packaging and setup
- Tailwind preset and design tokens
- Data fetching, HTTP and the
FrappeUIplugin - Uploads and FileUploader
- Inputs and forms
- Date and time pickers
- Select, Combobox, MultiSelect and Dropdown
- Dialog, Popover, Tooltip, HoverCard and Toast
- Sidebar, tabs and navigation
- App shell, page header and color scheme
- Keyboard shortcuts
- Other components
- Lists
- Editor
- Code editor
- Charts
- Calendar
Packaging and setup
npm create frappe-ui sets up a new app (additive)
npm create frappe-ui@latest creates a frappe-ui project with Vue, TypeScript, Vite and Tailwind CSS. Run it from apps/<app> for the frontend of a Frappe app, or anywhere else for a plain Vite app. In a Frappe app it also adds:
- the page that serves the frontend
- the route rule in
hooks.py - the scripts in the app's
package.jsonthat let bench build it - the build output to
.gitignore
It lists these changes and asks before it writes them.
Package contract: peers, dependencies and the tarball (breaking)
The package now declares what it needs, ships less, and resolves its own imports for your type-checker.
tailwindcssis a peer dependency, pinned to>=3.4.2 <4. 3.4 is the first version that derives the sizing families fromtheme('spacing'), and the preset depends on that. Tailwind v4 does not read the JavaScript config the preset is written in. The install now fails, instead of half-working at build time. The floor is 3.4.2, not 3.4.0: the preset usesimport.meta.url, and the config loader in 3.4.0 and 3.4.1 cannot transform it, so the config fails to load with aSyntaxError.viteandvitepressare optional peers, together withshiki,@shikijs/transformersand@vue/compiler-dom, whichfrappe-ui/vitepressimports. An app that never importsfrappe-ui/viteorfrappe-ui/vitepressinstalls none of them.@floating-ui/vueis now a direct dependency, not a transitive install. So are the five@codemirror/*packages and@lezer/highlightthat the CodeEditor imports. They resolved before only because another package happened to install them.- Removed from
dependencies:@tailwindcss/line-clamp(in Tailwind core since 3.3),prosemirror-tables,ora,slugify, and the@tiptap/extension-{bubble-menu,color,highlight,image,mention,node-range}packages. The shipped code imports none of them.prettiermoved todevDependencies. - The
frappe-ui/src/utils/tailwind.configshim is deleted. Theexportsmap already refused that path, so an app still on it fails withPackage subpath '…' is not defined. Now the file is gone too. Nothing underfrappe-ui/src/...resolves, because the map has no wildcard. frappe-ui/tailwind,frappe-ui/iconsandfrappe-ui/vite/lucideIconsPluginnow carry atypescondition. The preset's type declaration says which Tailwind sections it replaces.frappe-ui/style.cssalso resolves under thestylecondition (additive). Tailwind v4 looks up package CSS with that condition. The export listed onlyimport, so@import "frappe-ui/style.css"failed withnot exported under the condition "style". Both conditions point at the same file, and the file is unchanged.- The tarball ships no tests.
*.test.*,*.spec.ts,*.cy.ts,*.story.vue,*.playground.vue,stories/andsrc/mocksare excluded. The tarball no longer ships the 520 test, story, playground, mock and helper files it used to carry. - Fix: the internal
#imports resolve for your app. The package ships TypeScript source, so your compiler resolves these imports through theimportsmap. A type-check of an app that importsfrappe-ui/editorused to report 136 unresolved modules. It now reports none.
What to do: import the preset from frappe-ui/tailwind. Run npx -p frappe-ui packaging-v1 . from your app's root to fix the preset path. Point it at the root, not ./src: the preset is imported in tailwind.config.js, which sits outside src. The packaging migration guide lists the manual steps.
frappe-ui/vite: lucideIcons is off by default (breaking, loud at build time)
frappeui() no longer installs unplugin-icons, unplugin-auto-import and unplugin-vue-components. frappe-ui draws its own icons as CSS mask classes (lucide-check), so the library needs none of them. Before this change, every app ran three extra plugins over every file it built.
If your own code writes <LucideCheck /> or imports ~icons/lucide/*, you need the option. Without it, Vite fails the import with Failed to resolve import "~icons/lucide/check", and a <LucideCheck /> tag renders as an unknown element with a Vue warning.
What to do: pass lucideIcons: true.
frappeui({ lucideIcons: true })npx -p frappe-ui packaging-v1 . adds the option when it finds either form in the folder you point it at. When it cannot tell, it reports the plugin call.
Root exports — FrappeUIError removed, InputLabelingProps and RouteDestination added (breaking, loud)
FrappeUIErroris removed fromfrappe-uiand fromfrappe-ui/experimental. An input'serrorprop is typed where it is declared. To forward it, useInputLabelingProps['error'], which the root now exports. The prop accepts everything it accepted before.RouteDestinationandRouteLocationObjectare exported. Every prop that takes a router destination is typed with them, instead of vue-router'sRouteLocationRaw. The accepted values are the same. The generated API docs can now print the name.dayjsanddayjsLocalstay exported and public.dayjsSystemstays internal.
frappe-ui/resources and frappe-ui/editor — named exports (breaking, loud)
Both entry files used export * from implementation files, so they published whatever those files exported next. Each now lists its exports by name, and a test fails when the lists drift apart. Every name that apps import is still there.
frappe-ui/vite — types and docs (additive)
frappe-ui/vite now ships hand-written types (vite/index.d.ts, wired through the types export condition). frappeui(...) and its options (frontendRoute, lucideIcons, barrelImports, frappeProxy, jinjaBootData, buildConfig, frappeTypes) are typed, so you no longer need a // @ts-expect-error. A new docs page covers every sub-plugin, including barrelImports, which had no docs before.
list-style.css and editor-style.css exports — removed (breaking, loud)
The manual frappe-ui/list-style.css and frappe-ui/editor-style.css exports are gone. The build fails with Missing "./list-style.css" specifier.
They existed only because bundlers tree-shook the side-effect import './style.css' inside the frappe-ui/list and frappe-ui/editor entry files. This was never specific to Rolldown: plain Rollup and Vite production builds dropped the CSS too. Those entry files are now listed in sideEffects, so each family's CSS ships as soon as you import anything from its subpath.
What to do: delete the manual @import lines.
Node.js requirement (breaking)
The minimum Node version is now >=20.19.0. It was Node 18 on 0.1.x. It is declared in package.json engines, so installers and CI show the requirement, instead of unclear engine errors from dependencies.
experimental entry — tidied, and FrappeUIError (breaking)
- Breaking:
LabelingWrapperis no longer exported fromfrappe-ui/experimental. It stays exported from its own entry file (src/components/InputLabeling), whereCombobox,Select,MultiSelectandMultiEmailInputimport it internally. Only theexperimentalre-export had zero outside importers, so only that goes. It is the only member removed in this tidy-up. FrappeUIErrorwas exported fromfrappe-ui/experimentalas a type during the betas. It is removed again before1.0.0; see the root exports entry. Type a forwardederrorprop withInputLabelingProps['error'].
tsconfig.base.json — cleaned up (breaking for extenders)
Breaking: tsconfig.base.json no longer sets types (vitest/globals, unplugin-icons/types/vue, node). If your app extends this file and relies on any of these globals, tsc fails with a missing-global error (for example Cannot find name 'vi') the first time the code uses a global that came from vitest/globals or unplugin-icons/types/vue.
noEmit stays, because allowImportingTsExtensions is only legal with it. The declaration / emitDeclarationOnly pair is gone: it contradicted noEmit, and frappe-ui's own build did not use it.
What to do: add types to your own tsconfig.json.
./hljs-theme.css export removed (breaking)
Breaking: frappe-ui/hljs-theme.css is no longer exported. It had zero importers. The file behind it (experimental/TextEditor/hljs-github.css) ships until the deprecated TextEditor is removed.
frappe and drive subpaths — removed (breaking)
frappe-ui is a plain UI library, so framework-specific code moves out. This was decided in #867, and the code moved in frappe/frappe#41671.
- Breaking:
frappe-ui/frappeis removed and thefrappe/directory is deleted.useTelemetry,telemetryPlugin,useOnboarding,GettingStartedBanner,IntermediateStepModal,HelpModal,showHelpModal,minimize,TrialBanner,SignupBanner,DataImport,Link,FilterandLinkPropsnow live in@framework/ui. TheLinkandFilterthere do more than the old ones.Linkadds theredirectable/editableprops and theredirect/editemits.FilteraddsuseFilters,parseFilters/serializeFiltersand an operator registry. - Breaking:
OnboardingSteps,HelpCenterandshowHelpCenterare removed with no standalone replacement. They had zero call sites across all consumer apps. They still powerHelpModalinside@framework/ui. - Breaking:
frappe-ui/driveandfrappe-ui/drive/*are removed with no replacement. No app imported them. The drive app owns the live copy of all six components. - The
contentexport fromfrappe-ui/tailwindand the docs no longer list afrappe/**glob.
What to do: import these from @framework/ui. If your tailwind.config.js lists node_modules/frappe-ui/frappe/** by hand, delete that line.
Tailwind preset and design tokens
Typography — text-* line height is 1.35 (breaking, silent)
The tight text styles, text-2xs to text-4xl and their -medium, -semibold and -bold variants, move from line height 1.15 to 1.35. At 14px, one line of text-base is now 18.9px, not 16.1px. Text that wraps now has room between its lines. The text-p-* styles, and text-5xl and up, do not change.
leading-tighter is a new class that sets 1.15. Use it to keep the old box: text-base leading-tighter is the old 1.15. leading-tight keeps Tailwind's 1.25.
- frappe-ui components keep their height. Their single-line label text carries
leading-tighter: Button, Badge, Breadcrumbs, the input, the Select, Combobox and MultiSelect triggers, menu items and group labels, picker cells,FormLabel,InputLabel,ItemListRow,MobileNavItem, theSettingsRowtitle, Sidebar, Tree items, tabs and the list header. A bareSwitchno longer takes its height from the parent's line height. - Who is affected: app code with single-line text in a tight style. A fixed-height box can clip its text or push it off-centre. Repeated rows grow by 2.4 to 5px each: menus, kanban cards, timelines, search results, tree rows. Text does not re-wrap, because line height does not move line breaks.
What to do: add leading-tighter to single-line UI text in a fixed-height box. Use text-p-* for long prose. See the migration guide.
Tailwind preset — the design tokens are exported as data
frappe-ui/tailwind/tokens exports the tokens by name: colors, cssVariables, focusRing, fontFamily, fontSize, fontWeight, radius, screens, semanticColors, shadows, spacing and tracking. One type ships with them: TextStyle, the shape of one fontSize entry.
Every value works without Tailwind: a resolved oklch(...) colour, a plain px string, a plain number. No value carries a Tailwind placeholder, so no <alpha-value> and no color-mix(...) reaches code that wants a value rather than a class. Tailwind-only shaping stays in colorPalette.js and plugin.js.
The tokens get their own subpath because frappe-ui/tailwind imports tailwindcss/plugin at the top level, and plain Node does not resolve it. So the preset entry only works inside a bundler. The token module imports nothing but the four data modules next to it, and it loads anywhere. Per ADR-0010, build-time entries are additive-only until 2.0.0: a new one can be added, but none can be renamed or removed. So a second entry is allowed, and neither one may be renamed.
semanticColorsis{ light, dark }with resolved values, keyed by category (surface,surface-alpha,ink,outline,outline-alpha).fontSizeentries are objects{ fontSize, lineHeight, letterSpacing, fontWeight }, not Tailwind's[size, meta]tuple. Both families ship:basefor text,p-basefor paragraph.shadowsis a flat map of completebox-shadowstrings, keyed like theshadow-*utilities:none,smthrough2xl, andDEFAULT. Only the light elevation ramp ships, because both themes render that one.focusRingis{ light, dark }and holdsoutlineshorthands, not box-shadows. frappe-ui draws focus withoutline(ADR-0005).cssVariablesis keyed by theme:lightgoes on:root,darkon[data-theme="dark"].darkgives new values to the semantic and focus properties, and adds the dark ramps under--dark-*names. The light ramps, elevation and radius are inlightonly, because they do not change with the theme.- Keys are typed literally, so a wrong key does not compile and your editor completes the real names. Values stay
string, ornumberfor a font weight, so a token sync changes values and never a type. A font weight is a number in both places:fontSize.base.fontWeight === fontWeight.regular.
This is additive. It replaces the removed tailwind/tokens.js, but the names and shapes differ from that module. The migration guide gives the before and after, and Tailwind Setup documents each export.
Tailwind tokens — one committed source, no vendored Figma export
The token files moved. tailwind/generated/*.json is now tailwind/tokens/*.js (colors, radius, typography, effects), and those four files are the one source for the tokens. The importer tailwind/figma-tokens-to-theme.js is now tailwind/tokens/build.js. It still runs through yarn sync-tokens.
One token value changed, and only its type. Each fontSize entry now carries the number 420 for fontWeight, where it carried the string "420" before. fontWeight.regular was already a number, so fontSize.base.fontWeight === fontWeight.regular is now true. The resolved preset theme carries the number too: theme.fontSize.base[1].fontWeight is 420, not '420'. Tailwind writes font-weight: 420 from either, so the compiled CSS is byte-identical. Every other value is unchanged.
No package export pointed at any of these paths, so nothing you can import moves. A fork, or a script that reads the files from node_modules by path, needs the new path and the new extension.
tailwind/colors.jsonis deleted. It was byte-identical to the generated copy. A hand-edit to it took effect in the build, and the next sync silently undid it.espresso-v2-design-tokens/is deleted. This raw Figma export never shipped (it was not infiles). It changed six times in four months, and four of its ten files were never read. It also does not describe what frappe-ui uses:build.jsoverrides the radius9value, overrules a corrupt font-weight column, drops five sizes, and converts every colour to oklch. The export is an input, so it now goes in a gitignored.figma-export/folder that the person running the sync fills.tailwind/tokens/provenance.jsonis new. It holds the Figma file id and a sha256 per input file. This records which export produced the committed values.- The four token files are JS modules, not JSON. They are
tailwind/tokens/*.js. Each is anexport defaultof the same data under a generated-file header. Reading JSON from an ES module needs an import attribute (with { type: 'json' }), and the oldest config loaders in the supported peer range cannot parse one. Yourtailwind.config.jsreaches this data through the preset, so the attribute is gone. Values are unchanged.
A script that reads tailwind/generated/*.json or tailwind/colors.json out of node_modules by path has no file to read. Raven, wiki and the LMS tests read by path today.
What to do: import the values instead: import { semanticColors } from 'frappe-ui/tailwind/tokens'.
To re-sync the tokens: export into .figma-export/, run yarn sync-tokens, review the diff on tailwind/tokens/*.js, and commit.
Tailwind preset — hover: applies only where hovering is possible
The preset sets future.hoverOnlyWhenSupported, so every hover: utility compiles under @media (hover: hover) and (pointer: fine). A phone applied :hover on tap and kept it until the next tap landed somewhere else, so every ghost button a thumb touched stayed filled.
- Behavior change: touch screens no longer show hover styles at all. This applies to your app's own
hover:classes too, not only the library's. - Anything shown only on hover, such as
opacity-0 group-hover:opacity-100or ahiddenthat hover undoes, is now unreachable on touch unless a tap also reaches it.
What to do: give such controls a touch path. Add [@media(hover:none)]:opacity-100 so a device that cannot hover shows them all the time, as the library's own editor controls now do. See Tailwind setup.
Tailwind preset: one sizing scale, one focus ring form (breaking)
- Spacing is declared once and every sizing family reads it. The scale is the integers 1 to 128 and the half steps 0.5 to 19.5, all
n * 0.25rem. Tailwind 3.4 readstheme('spacing')forwidth,height,size,minWidth,maxWidthandminHeight. Sop-4.5,w-112,h-13,max-h-52andgap-7.5now exist because the scale has them, not because someone added that one value. The plugin's hand-writtenspacing,width,height,minWidthandmaxHeightblocks are deleted. Two classes change:w-wizard(650px, named after an app screen) is removed. Usew-[650px].min-w-50follows the scale, so it is 12.5rem instead of 18rem. Usemin-w-[18rem]to keep the old size.
- The
--focus-<name>box-shadow variables are removed. Keep--focus-outline-<name>and apply it as an outline:outline: var(--focus-outline-default). The names aredefault,red,green,amber,blueandviolet. There is one form: 2px in light mode and 3px in dark. A ring no longer changes the element's size. rounded-9is 100px, not 999px. It is the pill radius for large surfaces.rounded-full(9999px) is still the circle.- Documented, not changed: the preset replaces Tailwind's
colors,fontSize,screens,borderRadiusandboxShadowsections. Stock classes from those five sections (text-baseat Tailwind's own size,2xl:,shadow-inner) are not generated. Alpha token names are unchanged:alphastays a suffix on the group key (bg-gray-alpha-100,bg-surface-alpha-gray-2). frappe-ui/tailwindexportscontent, the source globs that produce classes inside the package.experimental/FloatingWindowandvitepressare now in that list. Without thevitepressglob, a docs site onfrappe-ui/vitepressnever gets the theme's own layout classes.
Radius aliases and text-*-black styles removed (breaking, silent)
Per ADR-0006 and ADR-0008 (#998, decided in #993):
The named radius aliases (
rounded,rounded-sm,rounded-md,rounded-lg,rounded-xl,rounded-2xl, and their directional forms) are removed. The numbered tokens are the only radius names. The px values are identical:Removed Use roundedrounded-4rounded-smrounded-1rounded-mdrounded-5rounded-lgrounded-6rounded-xlrounded-7rounded-2xlrounded-8rounded-noneandrounded-fullstay. Silent break: the preset replaces Tailwind's scale, so an alias you did not migrate produces no CSS. You get square corners and no build error.The
text-<size>-blackandtext-p-<size>-blackstyle classes are removed. They had zero usage, and the Figma black weights were corrupt export data. This is also a silent break.
What to do: run the tokens-v2 codemod. It now does these renames. It is safe to run more than once, and it runs in every mode. It rewrites a bare rounded only inside quoted strings and @apply rules, so search for leftovers. For font-extrabold / font-black next to a text size, the codemod reports the site instead of merging it onto the removed class.
Tailwind preset — content export added (additive)
frappe-ui/tailwind exports content, the list of globs for the frappe-ui source folders that produce Tailwind classes. Tailwind v3 does not merge a preset's content, so apps had to maintain these paths by hand. They had already drifted: some apps on [email protected] glob src/components/** only, which silently drops every class that the editor and list components produce.
What to do: spread content into the content array of your app's tailwind.config.js, instead of maintaining the paths by hand. See the new Tailwind Setup docs page.
Tailwind preset — tokens.js export removed (breaking)
The ./tailwind/tokens.js export is removed outright, with no deprecation period. It re-exported colorPalette.js through export *. The library does not allow export * from an implementation module.
This ships before the 1.0.0 tag, while the library can still change freely. The rule that requires a deprecation period starts at the tag.
Correction. This entry used to say the export had zero importers anywhere. That was wrong. frappe/studio imports it at frontend/src/utils/espressoTokens.ts. A code search across org:frappe finds Studio as the only consumer, and Studio pins [email protected], so the break has not reached it. The advice to use the preset directly was also wrong: the preset is a Tailwind Config and carries no readable values. The subpath is back as frappe-ui/tailwind/tokens, without the .js and in a new shape.
What to do: import from frappe-ui/tailwind/tokens. See the design tokens entry and the migration guide.
Tailwind preset — unused token vocabulary and utilities removed (breaking)
This is the design-token audit before the additive-only token freeze (#940). Every family in tailwind/generated/*.json, and every utility and --* variable that plugin.js produces, was checked against frappe-ui's own source, docs and stories. It was also checked against a new survey of all consumer apps (crm, helpdesk, gameplan, insights, builder, suite, central, frappe_calendar, frappe-ui-starter, and frappe's ui/ package).
These are in use and are not touched: the primitive and semantic color ramps (all twelve hues, in surface-*/ink-*/outline-*), every typography weight (including bold/black), and every size through text-12xl.
These had zero call sites anywhere, and are removed:
text-tinyand its uppercase text-transform. Even the docs' own type-scale page did not show it.text-13xlthroughtext-16xl(and their-medium/-semibold/-bold/-blackvariants). The docs' own "display sizes" showcase stops attext-12xl. Even the type-scale demo did not use these four sizes.shadow-statusand its--elevation-statusvariable. It was named once in the text of the elevation docs page, but never rendered there or anywhere else.surface-alert-button-*/ink-alert-button-*(default,info,success,warning,error).Alert's buttons get their color from the sharedvariant+themeprops. This Figma spec was never wired to code.surface-alpha-gray-2-overlay. It resolved to the black/white overlay ramp rather than the gray-alpha ramp its name implies. That broke the{family}-{step}pattern every othersurface-alphaentry follows.
All five are silent breaks. A missing Tailwind class or --* variable stops applying, with no build or type error. See the migration guide.
The token generator (tailwind/figma-tokens-to-theme.js, since renamed; see the tokens source entry) now filters these out at the source. They stay gone on the next yarn sync-tokens run. ALPHA_FAMILIES also dropped a dead 'red-alpha' entry that never matched anything in the Figma export. No generated token changed.
Also removed: tailwind/colors.js, a 642-line legacy color module that colors.json + colorPalette.js replaced. It had zero importers, and no frappe-ui package export reached it (there is no ./tailwind/* wildcard). So deleting it changes nothing for apps.
Data fetching, HTTP and the FrappeUI plugin
Data fetching (v2) — transform ran twice on cached data (fix)
With cacheKey and transform both set, useList and useCall saved the rows after transform had changed them. When they showed the saved rows again, they ran transform on them a second time. A transform that gives a different result the second time broke: one that parses a JSON field threw "[object Object]" is not valid JSON the next time the list opened. The same was true for useDoc methods with cacheKey.
- The cache now holds the response as the server sent it.
transformruns on it once, each time it is read, the same as on a fresh response. Values that do not survive JSON, such asDateobjects, now come back correctly. - The cache key format changed, so entries saved by older versions are ignored. The first load after the update fetches from the server, the same as with no cache. The old entries stay in IndexedDB until the app clears it.
useListrunstransformon every loaded row at once, not one page at a time. It runs again after each new page and after eachupdateRoworremoveRow. Atransformthat sorts, groups or removes duplicates now gives the same result on a fresh list and on a cached one.transformgets a copy of the rows, so it may change them in place.updateRowandremoveRowchange the rows as the server sent them, and save the changed rows to the cache at once.updateRowtakes values in the shape the server sends. With atransformthat parses a JSON field, an updated row now shows the parsed value, not the raw string.initialDatais now in the shape the server sends, and goes throughtransformthe same as a response.updateRowandremoveRowchange its rows before the first response arrives. Before, they did nothing on these rows.useListworks on its own copy, so the array you pass never changes.initialDatarows are not saved to the cache.
No API change: the types stay the same, and the behaviour changes are the ones listed above.
Data fetching (v2) — each user has their own cache (fix)
useCall and useList save a cacheKey response in IndexedDB, and useDoc saves every document there. The keys did not include the user, so a second user on the same browser could see the first user's saved data.
- The keys now include the signed-in user, read from the
user_idcookie that Frappe sets at login. A user reads only their own saved data. The cookie is read each time data is saved, so a user who signs in without a page reload has their data saved under their own key. Still, reload the page when the user changes: data loaded for the previous user stays in memory and can be saved under the new user's key. - Guests and requests with no session share one cache that is not tied to a user. A signed-in user never reads from it.
- Documents saved by older versions are ignored, like lists and calls. The first load after the update fetches from the server.
See One cache per user.
No API change.
Data fetching (v2) — stale responses no longer write the shared stores (fix)
Two writes to one document at the same time could leave docStore and listStore (and every view bound to them) on the response that arrived last, instead of the newest one. Meanwhile data already held the fresh response (#1017).
The check now lives in the stores:
- Every request takes an increasing sequence number when it is sent. The response's store writes carry that number. The stores reject a write for a document that a request sent later has already written. It is a sequence, not a timestamp, because clocks move and two responses can share a millisecond.
- One ordering covers every writer: the
docsside channel, theuseDoctype/useList/useDochooks, and any mix of instances or paths that write the same document. - A sequence is recorded only when a write that changes data lands. A newer request that failed wrote nothing on the server, so it does not make the older success stale.
- A read (GET) is accepted on its sequence but records nothing. The server may answer a later reload before an earlier save commits, and the save must still land.
- A delete seals the document when it settles, because a delete is final. No write or reload still in flight, whatever order it was sent in, can re-create a deleted document.
- One known limitation: the check orders by send time. A read sent after a save, handled by the server before the save committed, and answered after it, is accepted and puts the pre-save value back. Fixing that needs ordering on the server, which this design does not do.
useAction still skips the onSuccess/onError hooks of a submit when a newer submit with the same key, on the same instance, has already overtaken it. The store check protects the stores. This skip only avoids re-running hook side effects with a stale response. useDoc's write members and useNewDoc skip their hooks the same way. None of these skips decides a store write: only the store check does. It compares per document and knows whether the newer request landed. So an overtaken insert of a different document still lands, and an older success is kept when the newer submit failed.
No API change. Behavior changes, if you relied on the old behavior:
- A stale
setValue/deleteon the same document no longer triggersuseList's automatic refetch. The newest submit's refetch already ran. - Submits with different keys, and submits with no key (inserts), are independent. All of their hooks still fire, as before.
Data fetching (v2) — useDoc writes and useNewDoc get one request per submit (fix)
useDoc's setValue, delete and every methods: entry, and useNewDoc, still shared one request after the useDoctype/useList fix. Two submits at once cancelled each other, and every submit resolved from the same data, so a caller could get another caller's answer, or null. Each submit now sends its own request and resolves with its own response (#991).
- No API change. These members keep the full
useCallsurface: same members, same types. (At the time,submit()resolvednullon a failed request. It rejects now; see writes reject, reads resolve.) dataanderrorbelong to the submit that started last, the same asuseDoctypeanduseList. A stale submit answers its own caller and writes nothing shared.loadingstaystrueuntil every submit settles.- Behavior change, if you relied on it: a second submit no longer cancels the first. Both requests reach the server.
Data fetching (v2) — writes reject, reads resolve (breaking, silent)
One rule now covers every composable: a write rejects when it fails, and a read resolves. A failed write must not let its caller continue down the success path.
- Now rejects:
useCall'ssubmit(), anduseDoc'ssetValue,deleteand everymethods:member. They used to resolve withnull. - Still resolves:
execute(),fetch()andreload()in every composable. Readerrorafter you await them. useDoctypeanduseListwrite methods already rejected. Nothing changes for them.useCall({ refetch: true }).submit()follows the rule too. It used to returnundefinedat once and leave the request to the params watcher, so a failed write could neither reject nor resolve with its response. It now settles on a request that carried its params. That is the request the params change started, or a request it sends itself when the change started none: the same object twice, aGETwhose params build the same URL, or asubmit()with no argument (which used to send nothing at all). Two submits in the same tick still share one request, and a params change replaces the request in flight. Therefetchandsubmitsection explains what that means.
A related fix: a successful response now clears error. Before, two overlapping submits ended with the newer one rejecting, on the cancelled request it replaced, even though its own request succeeded.
error, onError and the stores otherwise behave as before. This break is silent: nothing fails to build, the success path simply stops running, and a submit() you do not await becomes an unhandled rejection. null is a valid response now, so if (!result) after a submit() is no longer a failure check.
What to do: await write calls inside try/catch, or add .catch(). Do not treat a null result as a failure. The migration guide has the before/after and what to search for.
Data fetching (v2) — useDoc method names cannot shadow built-in members (breaking, loud)
A methods: key with the same name as a member of the object useDoc returns (doc, error, loading, reload, setValue, delete, and the rest) used to replace that member silently, which broke the document. useDoc now throws at setup, and names the key and the collision.
What to do: rename the key and keep the server method name: { reloadItems: { name: 'reload_items' } }.
Data fetching (v2) — useNewDoc and useDoc methods take fewer options (breaking)
An insert and a document method run when submit() is called, so the options that say otherwise are gone.
useNewDocoptions no longer acceptrefetch,cacheKeyorstaleOnError. It already fixedurl,method,paramsandimmediate.refetch: trueused to re-send the insert on every edit todoc.useDoc'smethods:options no longer acceptimmediateorrefetch.- Both force
immediate: false, refetch: falseat runtime. A JavaScript app that still passes them is safe: the values are ignored.
What to do: delete these options.
Data fetching — FrappeRequestError is renamed FrappeResourceError (breaking, loud)
The error type that the resource layer raises (call, frappeRequest, createResource and the other v1 resources) is now FrappeResourceError. Both errors in the library are server responses, so "request" versus "response" said nothing. Each is now named for the layer that raises it. Only the v1 betas ever exported the old name: v0.1.278 did not export it at all.
FrappeResponseError, which the v2 composables raise, does not change.- The fields do not change, and neither does how you narrow either one.
FrappeResponseErroris a class.FrappeResourceErroris a type over a plainError, so read a field such asexc_type. - There is no alias (ADR-0008), so importing the old name fails the build.
What to do: rename the import. The migration guide has the before/after.
Data fetching (v2) — error classes and read aliases documented
No code change. The docs now state two facts:
FrappeResponseError(the v2 composables) andFrappeResourceError(call,frappeRequest, v1 resources) stay separate, with different fields.UploadErroris the third error. A table says which API raises which. It also says which of the three you can narrow withinstanceof:FrappeResourceErroris a TypeScript type over a plainError, not a class, so only the other two have a value to test.execute/fetch/reload, andloading/isFetching, stay as aliases of one another. This is the one place the library publishes two names for one thing. The docs usereload()andloading.
createListResource — hasPreviousPage stale after reload() (fix)
reload() sets start to 0 for a moment, to re-fetch the loaded pages as one request, and then restores it. hasPreviousPage was computed while start was still 0 and was not computed again after the restore. So it stayed false even when start was back above 0. reload() now recomputes hasPreviousPage after restoring start.
v1 resources — stay JavaScript, and createListResource gets tests
The v1 resources (createResource, createListResource, createDocumentResource, getCachedResource, getCachedListResource, getCachedDocumentResource, resourcesPlugin, saveLocal, getLocal, deleteLocal, onDocUpdate) ship without deprecation, frozen at 1.0.0 (#886).
Every other export had to meet the v1 quality bar. ADR-0013 records the one exception: the implementation stays hand-written JavaScript, not TypeScript, permanently. With 344 call sites in production, a rewrite is riskier than the freeze. createListResource, the second-most-used export with 57 call sites, gets tests for the first time (listResource.test.ts): pagination, insert/setValue refreshing the list, caching, and how reload() restores the pagination state.
HTTP transport — four paths collapse to one (breaking)
frappeRequest is now the only transport. Removed from the root export:
request: the barefetchwrapper underfrappeRequest, now internal. UsefrappeRequest.createCall: no app used it.initSocket: no app used it; every app defines its own.socket.io-clientstays a dependency, becauseresources/realtime.tsexports functions typed against itsSocket.
All three fail the build at the import.
call now honours setConfig (silent). call keeps its (method, args, options) signature, the value it resolves to, and the { response, status, error } shape it passes to onError. But it now hands the request to frappeRequest instead of building its own fetch. It never imported getConfig, so requestBaseUrl and requestHeaders were ignored on every call(), while frappeRequest respected them. In apps that set either one, there are two effects: call now goes to the configured base URL with credentials: 'include', and _server_messages from a call now reach serverMessagesHandler.
FrappeResourceError is now exported. frappeRequest threw it, but nothing exported it, so you could not type a catch.
frappeRequest — onError fired twice per failure (fix)
request() attached transformError with a .catch at the end, which also caught what transformResponse threw. So every failed HTTP response ran onError twice. It now runs once. The fix is that frappeRequest marks an error it has already reported, not that the rejection handler sees less. That matters: a response that is ok but whose body does not parse is a failure only that handler sees, and it still reaches onError. Frappe answers an expired session with 200 and its login page, so response.json() throws.
A method name starting with http skipped the /api/method/ prefix (fix). The check for an absolute URL was url.startsWith('http'). That matches the four letters, not a scheme, so http_utils.api.run was fetched as a relative path. It now matches https?://.
frappeRequest also has an explicit return type now and passes its type argument through, so frappeRequest<Foo>() resolves to Foo rather than unknown.
login returned only message when requestBaseUrl was set (fix).login is the one endpoint that resolves to the whole body, so a caller can read full_name and home_page. The check compared the whole URL with /api/method/login. requestBaseUrl makes that URL absolute, so it stopped matching, and login quietly resolved to data.message. It now matches on the path.
FrappeUI plugin — one option left (breaking)
app.use(FrappeUI) accepts resources and nothing else, and it no longer installs it by default.
socketioremoved. It defaulted totrue, so apps that also built their own socket opened two live socket.io connections per page load.callremoved. It installed a$callglobal that nothing used.configremoved (silent).setConfigis the entry point. One app passed it.resourcesno longer defaults totrue. The v1 resources Options API mixin (this.$resources,$getResource,$getDoc,$getListResource,$refetchResource) installs only withapp.use(FrappeUI, { resources: true }). Composition API resources are not affected.resourcesPluginstays exported for direct installation.
A removed option is ignored rather than rejected, so the plugin logs a warning in development that names any option it does not accept and what to use instead.
Removed features fail loudly, in production too. A dropped option that disappears is an annoyance. A dropped feature that disappears causes a crash somewhere else that is hard to trace. So:
- A component that declares a
resourcesoption withoutapp.use(FrappeUI, { resources: true })throws when it is created. The error names the component and the fix. - Reading
this.$resourceswith the option off throws the same advice. Both checks exist because Vue passes what a lifecycle hook throws through its own error handling, which only logs in production. A read throws straight into app code in every build. - Reading
this.$socketorthis.$call, the two globals the plugin no longer installs, throws a message that names the replacement, instead of returningundefined. If you assign your own (app.config.globalProperties.$socket = io(…)), it replaces the check. This works before or afterapp.use(FrappeUI).
realtime: true on a v1 resource still falls back quietly to a non-realtime resource when no socket is set. That was always its behaviour, and it does not change.
What to do: if you use this.$resources, write app.use(FrappeUI, { resources: true }). Move config to setConfig.
FrappeUI plugin — resources is a boolean (breaking)
The option was typed as an object of resource definitions, but the plugin never read what was in it, only whether it was set. It is boolean now.
Passing an object is a type error, and it still installs the mixin at runtime, so an app that misses the change keeps working.
What to do: run npx -p frappe-ui data-v1 ./src. It rewrites the call. It reports any site it will not touch (a spread, a computed key, a variable) instead of guessing.
Data fetching (v2) — one request per submit (breaking)
useDoctype's insert, delete, setValue, runDocMethod and runMethod, and useList's insert, setValue and delete, each shared one request. Two submits at once cancelled each other, and every submit resolved from the same data, so a caller could get another caller's answer, or null. Each submit now sends its own request and resolves with its own response.
- Breaking: these eight members no longer carry the
useCallsurface. Removed:params,promise,url,reset,abort,execute,fetch,reload,isFetching,isFinished,canAbort,aborted. Each of them described one shared request, which no longer exists. - What is left, on all eight:
submit(),data,error,loadingandisLoading().loadingis true while any submit is in flight. - New:
delete.isLoading(name)andsetValue.isLoading(name)on bothuseDoctypeanduseList. This replaces thedelete.loading && delete.params.name === row.namepattern, which showed the same spinner on every row once two deletes overlapped. - New:
insert.isLoading()on both, with no argument. A new document has no name to key on, so it answers for the whole method, with the same value asinsert.loading. It exists so all eight methods read the same way. runDocMethod.isLoading(name, method)andrunMethod.isLoading(method)keep their signatures, and now answer correctly with several submits in flight. Before, they compared the shared URL, so only the newest submit read as loading.dataanderrorbelong to the submit that started last, not the one that answered last. A slow submit that comes back after a newer one is dropped: it writes nodata, writes noerrorand clears nothing. It still answers its own caller with its own outcome: it resolves with its response, or rejects with its error.- The winning submit writes
dataanderrortogether. Success setsdataand clearserror. Failure setserrorand leavesdataalone. datais no longer reset on failure. The old shareduseCallsetdataback tonullwhenever a response came back not-ok. It now keeps the last successful response. Readerror, notdata, to tell a failed submit from a successful one.erroris no longer cleared when a submit starts. Clearing it there erased the error of another submit that was still in flight. An error stays until the newest submit settles.submit()now rejects on any failure. It resolves with the response or rejects with the error: one channel, not two. A failedvalidatealready rejected. A failed request used to resolve withnull. Both reject now, and a server that answers withnullresolves withnull.useList'sinsertanddeletenow send tobaseUrl, which they silently dropped before.setValuealready used it, so all three write methods now agree.useDoctypewas never affected: every one of its methods already passedbaseUrl.
What to do: replace reads of the removed members. Use delete.isLoading(name) for per-row spinners. Check error, not data, after a submit. See the migration guide.
Data fetching (v2) — useFrappeFetch off the root export (breaking)
useFrappeFetch removed. It is the raw createFetch instance that useCall, useDoc and useList are built on. It handles headers, response parsing and error shaping, and nothing above that: no URL building, no params, no caching, no typed return. No app imports it. The build fails at the import.
What to do: use useCall for a whitelisted method, useDoc for one document, and useList for a query.
FrappeResponseError is now exported. The composables raise it on a Frappe error response and put it on .error, and submit() rejects with it. But nothing exported the class, so you could not narrow the error. This is the same gap that exporting FrappeResourceError closed for frappeRequest.
Data fetching (v2) — docs, and the sidebar splits from Resources
useCall, useDoc, useList, useDoctype and useNewDoc each get a docs page for the first time, under a new Data Fetching sidebar section: useCall for a whitelisted method, useDoc for one document, useList for a query, useDoctype for write-only access to a DocType, and useNewDoc for a draft-and-insert form.
The old Data Fetching section is renamed Resources and keeps its three pages (Resource, List Resource, Document Resource) unchanged. Both sections link to each other. Resources stays fully supported through 1.x. The new composables are the recommended layer for new code.
Data fetching (v2) — useNewDoc lost reactivity after submit (fix)
useNewDoc built its return value with reactive({ ...out, submit, doc }). Spreading a reactive() proxy reads every ref and computed once and freezes the result. So data, error and loading stopped updating as soon as the object was built: a template bound to newDoc.loading never saw it change. The return value is now built by changing the underlying object in place, so its properties stay live.
Data fetching (v2) — initialData did nothing on useCall and useList (fix)
Both documented an initialData option, to show a placeholder before the first response. Neither worked.
useCallpassed it straight to the underlying fetch, which expects the wrapped{ data: ... }shape the API actually returns. The unwrapped value was invisible, socall.datastayednulluntil the first response.useListreadsdatafrom a separate list built inafterFetch, whichinitialDatanever touched. Solist.datastayednullthe same way.
Both now show the seeded value immediately, as documented.
Uploads and FileUploader
FileUploader — uploads default to private (security fix, breaking)
- Silent break:
isPrivateUpload()is the one function thatuseFileUploadandFileUploadHandleruse to decide privacy. It now makes an upload with no statedprivate/is_privateprivate, not public. Before,FileUploaderset this default itself, whileuseFileUpload().upload(file, {})andnew FileUploadHandler().upload(file, {})made the same upload public. That mismatch was #922.FileUploaderitself already uploaded private (sincev1.0.0-beta.21) and is not affected. A caller ofuseFileUploadorFileUploadHandlerthat sets no privacy option now gets a private file, where it got a public one before. - Fixed: the standalone
upload(file, options)export (exported fromfrappe-uinext touseFileUpload) crashed at runtime. It required internalstate/resetarguments that the public signature gave no way to pass. It is now a real standalone function, anduseFileUpload()wraps it with reactive state.
What to do: pass private: false where an upload must be public. See the migration guide. (The is_private option was removed later; see the next entry.)
Uploads — UploadError, and the is_private option removed (breaking)
upload, useFileUpload and FileUploadHandler reject with an exported UploadError instead of a plain Error. useFileUpload's state.error is typed with it.
error.kindis'file-size' | 'network' | 'server' | 'abort'. Branch on it instead of matching the message text.- A server failure also carries
status, the parsedmessagesand the rawresponse.FileUploadHandlernow rejects on abort, instead of leaving the promise open.uploadanduseFileUploadalready rejected. - Breaking, silent in JS: an aborted
upload()/useFileUpload()used to reject withnew DOMException('Upload cancelled', 'AbortError'). It now rejects with anUploadErrorwithkind: 'abort'.error.nameis'UploadError'anderror instanceof DOMExceptionis false. So acatchthat tells a cancel from a failure by either check stops matching.options.signalis public. See the migration guide. - Existing
catchblocks keep working:UploadErroris anError, and itsmessageis unchanged. - Breaking, silent in JS: the
is_privateupload option is removed.privateis the only spelling. Anis_privatethat is still passed is ignored, so{ is_private: 0 }now uploads private. Theis_privatefield on the returned file record is unchanged. isPrivateUploadand theUploadPrivacytype are no longer exported.
What to do: check error.kind === 'abort' to detect a cancel. Replace is_private with private.
FileUploader — flat props replace the uploadArgs blob (breaking, silent)
Silent break: uploadArgs is removed. The fields of it that apps actually use are now flat props: private, folder, doctype, docname, fieldname, uploadEndpoint, optimize. Old code keeps compiling, because uploadArgs becomes an unused attribute on the root element. So an app that relied on it (folder, doctype, custom private, …) silently stops applying those options.
The advanced options with no flat prop (file_url, method, type, params, maxWidth/maxHeight, upload cancellation) had zero measured use on the component. For those, use useFileUpload() directly.
What to do: move each uploadArgs field to its flat prop. See the migration guide.
FileUploader — success / failure emits declared stable
- Both emits lost their
@deprecatedtag (ADR-0008 does not allow@deprecatedmembers in1.0.0) and gained real types:success: [data: UploadedFile]andfailure: [error: unknown]. They were untypedanybefore. Apps depend on both, and they keep their names, because the names already describe the result rather than the user action. - Fixed:
failuredid not fire whenvalidateFilerejected a file, only on an actual upload error.errorwas set on the slot props either way, but a@failurelistener never heard about a rejected file. It now emitsfailurewith the validation error (a string or anError) in both cases, as the type already promised.
FileUploader — slot prop error is now always a string (breaking, silent)
Silent break: FileUploaderSlotProps.error changed from unknown to string | null. Upload failures were already turned into a message string. Validation failures (validateFile returning an Error) were not, so a custom slot could get either a string or an Error object. Both paths now turn the error into a message string before it reaches the slot. A slot that did {{ error.message }} for the validation Error case (uncommon, but possible) now renders nothing, because error is always a string.
What to do: render {{ error }} instead. See the migration guide.
FileUploader — inputRef removed, nothing in its place
Breaking, loud: per ADR-0012, FileUploader exposes nothing on its template ref. inputRef was a function that looked like a ref (uploader.value.inputRef().focus()). The openFileSelector slot prop already covers what it was used for. There were zero known call sites.
What to do: use the openFileSelector slot prop.
FileUploader — TypeScript types, tests, docs and styling hooks
FileUploaderis now fully typed (FileUploaderProps,FileUploaderEmits,FileUploaderSlotPropsintypes.ts).- It has
*.cy.tscomponent tests that cover the five required behaviors: render,v-model, disabled and loading, keyboard and focus, and slots. - It has a
data-slot="root"/data-state="idle" | "uploading" | "success" | "error"pair for CSS hooks, so you can style it without class props. - The default trigger now shows validation and upload errors through
<ErrorMessage>(role="alert"). Before, errors were invisible unless the caller used a custom slot.
fileToBase64 and the fileSize helpers — unexported from root (breaking, loud)
Breaking, loud: fileToBase64, formatBytes, getMaxFileSize, and fileSizeLimitMessage are no longer exported from frappe-ui. They had zero outside call sites at the v1 review. All four stay as internal helpers shared by useFileUpload, FileUploadHandler, and the editor's media upload engine.
Inputs and forms
Inputs — the size scale is xs / sm / md / lg (breaking; loud in TS, silent in JS)
The input family gains xs and loses xl. The four sizes are fixed single-line heights: 24 / 28 / 32 / 40px. sm, md and lg render as before. xs matches Button's own xs, so a 24px input and a 24px button line up.
xl was never a bigger box. It drew lg's 40px height with an 18px font, so it was a font override with a size name.
Every component on the shared scale changes together: TextInput, Textarea, Password, Rating, Select, Combobox, MultiSelect, ItemListRow, FormControl, the DatePicker family, TimePicker, Duration, and the experimental CodeEditor and MultiEmailInput. Progress, Slider, Switch, Checkbox, Radio, Avatar, Badge, Button and Dialog keep their own scales, so <Dialog size="xl"> and <Avatar size="xl"> still work.
FormControl.sizewidens fromsm | mdto the whole scale.type="checkbox"renders on the smaller toggle scale, solgthere becomesmdrather than falling off the end.- A leftover
size="xl"no longer loses its styling. Every input size lookup goes throughresolvePropValue. An unsupported value falls back to the component's own default and warns once in development. Before, it shipped an element with no height, font, radius or padding class, and no warning.Badgegot the same treatment fortheme. InputSize,InputVariant,ToggleSizeandRangeSizeare now exported from the root, so a typed wrapper can name the scale that the API tables already print.
TypeScript flags xl at the call site. JavaScript call sites and bound values (:size="config.size") only show up through the development warning.
What to do: replace size="xl" with size="lg". After upgrading, check the console for the warning.
Form typography — 13px labels, descriptions and Textarea text (breaking, silent)
Labels, descriptions and Textarea text are a fixed 13px. Labels and descriptions default to ink-gray-6. This is a rendering change: there is nothing to change in your source, except FormLabel.size below.
| Member | Before | After |
|---|---|---|
Label (InputLabel) | 14px, ink-gray-5 | 13px, ink-gray-6 |
Label (FormLabel) | 12px sm / 14px md, ink-gray-5 | 13px, ink-gray-6 |
| Description | 13px, ink-gray-5 | 13px, ink-gray-6 |
| Description, disabled | ink-gray-3 | ink-gray-4 |
Textarea text, every size | 14 / 16 / 18 / 20px | 13px |
The two label components used to disagree. InputLabel, which FormControl renders through, was a flat 14px. FormLabel was 12px or 14px depending on size. Both are 13px now, so a FormLabel and a TextInput label match.
ink-gray-5 measured 4.18:1 against the page in both themes, which is below WCAG AA for body text. ink-gray-6 measures 7.80:1 in light and 6.29:1 in dark. The disabled description moved off ink-gray-3 (1.69:1) to match the disabled label.
FormLabel.sizeis removed, not kept as a prop that does nothing: with the size fixed, the prop had nothing to choose.sizefalls through as a plain HTML attribute, so nothing throws, and the label renders at 13px. TypeScript call sites get a build error.size="md"was 14px and thesmdefault was 12px. Both become 13px.Textarea.sizestill changes padding, corner radius and minimum height. It no longer changes the text size. The single-line input heights do not set aTextareaheight, so algTextareais a roomier box with the same 13px text. The colour of theTextareavalue is unchanged.
What to do: delete size from FormLabel.
ErrorMessage — several messages (additive)
message accepts string[]. An Error that carries a messages array (what Frappe's whitelisted methods return) renders one line per message, instead of [object Object]. A single string and a plain Error render as before. The prop type is exported as ErrorMessageValue.
Every input takes the same value on its error prop, so <TextInput :error="['Email is required', 'Password too short']" /> renders both lines and sets aria-invalid. An empty array means no error. One function decides this for the whole library, so the error text and the input state always agree.
Inputs — focus() on every control, open()/close() on the pickers (additive)
Every input exposes focus() on a template ref, typed as the new exported InputExposed. A generic form that holds a ref to a control it did not choose now has one action it can always call. FormControl forwards focus() to the control its type resolved to.
Where focus lands is part of the contract. It is the element that Tab reaches: Slider focuses the thumb. RadioGroup focuses the selected option (the first enabled one when nothing is selected). Rating focuses the selected star (the first star when the value is empty), except in half-star mode, where the whole control is one slider.
The three date pickers and TimePicker add open() and close() next to focus(), typed as the new exported PickerExposed. They render their own trigger, so a parent's script has no other handle on the panel. open() does nothing while the picker is disabled. clear() stays on Select, Combobox and MultiSelect and is not added anywhere else (ADR-0012).
Duration now forwards the #label and #description slots to its TextInput. It used to drop them.
Inputs — attributes route to the control, class and style to the wrapper (breaking, silent)
An input has a layout wrapper and an interactive element. An attribute you pass that the component does not declare has to land on one of them. The rule is now the same everywhere: class and style go to the wrapper. name, aria-*, data-* and listeners go once to the interactive element.
Checkbox applied the whole set twice, so aria-label also named a <div>, and a caller's @click ran twice. Switch, RadioGroup and Rating sent everything to the wrapper, so aria-label never reached the control at all.
This is silent. Check your code if you relied on the old placement:
<!-- the listener used to fire on the padded row too; now only on the input -->
<Checkbox padded label="Agree" @click="onClick" />TextInput, Textarea, Password, Select, Combobox, MultiSelect, Slider, FormControl, Duration and the date pickers already followed the rule and are unchanged.
Rating — size defaults to sm (breaking, silent)
Every other input defaults to sm, but Rating defaulted to md. A <Rating> with no size now renders smaller. An unrecognized size resolves to sm too, so the default and the fallback agree.
What to do: pass size="md" to keep the old size.
Input types — SelectionOption, SelectionGroup, Dayjs, DateRangeValue (additive, with one removal)
SelectionOptionandSelectionGroupare exported from the root, so a wrapper around any of the three selection components can name its option shape once. The component-specific types stay.Dayjsis exported. The date pickers pass one toformatter,disabledDateand the setter slot props, so you need to be able to name the type.DateRangeValuetypes both sides ofDateRangePicker'sv-model. The prop wasstring[], which let a one-element array in and made a round-trip through the model fail to type-check.- Removal:
DatePicker's entry file lists its public types, instead of re-exporting the whole module.DatePickerViewModeandDatePickerDateObjwere calendar internals that the wildcard published. They leave the root. SelectEmits,ComboboxEmitsandMultiSelectEmitsno longer declare the model events a second time (defineModelalready declares them). See Selection emit interfaces.RadioGroupEmitssaysRadioValue | undefined, which is what an unbound group starts at.
Inputs — control versus trigger, and picker ARIA (additive)
data-slot="trigger" belongs to the selection family only: Select, Combobox and MultiSelect render a box that shows the selection and opens the popover. Every other input, the date and time pickers included, marks its main interactive element data-slot="control". You type into a picker's <input>, so it is a control that also opens a panel.
FormLabelcarriesdata-slot="label", the hookInputLabelalready rendered. One selector now reaches every label in the library.- The picker chevron carries
data-slot="chevron". - The picker
<input>carriesrole="combobox",aria-haspopup(dialogon the date pickers,listboxonTimePicker) andaria-expanded. While the panel is open, it also carriesaria-controlspointing at the panel element.TimePickerputsaria-activedescendanton the input, where the ARIA combobox pattern expects it, instead of on the listbox.
FormControl — variant is not forwarded to a checkbox (breaking, silent)
A checkbox draws no container surface, so it has no variant. FormControl forwarded one anyway, and it landed on the <input> as a stray variant="subtle" attribute. type="checkbox" no longer receives it.
The type routes are unchanged: date renders DatePicker, time renders TimePicker, and a native date field is <TextInput type="date" />.
Toggles and ranged inputs — deprecated members removed (breaking, silent)
Per ADR-0008, this family's deprecated aliases are removed, not kept. All five had zero call sites across the consumer apps. These are silent breaks: old code compiles and runs, but the old name is ignored. See the migration guide's Inputs table.
| Removed | Use |
|---|---|
Rating.rating_from | max |
Rating.readonly | disabled |
Switch.change emit | v-model / @update:modelValue |
Switch.labelClasses | data-* styling hooks |
Checkbox.padding | padded |
Slider now exports its types (SliderProps, SliderEmits, SliderValue), and Rating exports RatingEmits.
Input family — shared labeling contract
TextInput, Textarea, Password, Checkbox, Switch, Rating, and Slider accept label, description, error and required. The id is generated for you. <label for>, aria-describedby, aria-errormessage, aria-invalid and aria-required are wired automatically. error accepts a string or an Error (with Error.messages rendered as stacked plain text). Existing call sites do not change.
Input family — data-* styling hooks
Every input renders the same data-* attributes, so your CSS can target inputs without props that inject classes:
data-slot("label","control","description","error")data-size,data-variant(where the input has them)data-state("valid" | "invalid" | "checked" | "unchecked" | …)data-disabled,data-required
Password — v-model fix
Password now uses defineModel<string>(). This fixes a bug where <Password v-model> did not update when the user typed. Explicit size, variant, disabled, placeholder, id and required props replace $attrs routing.
Password — value prop removed (breaking)
Per ADR-0008, no deprecated member ships in 1.0.0. Since value was deprecated earlier in this cycle, it warned and seeded v-model. A survey of every downstream app found zero call sites still passing it.
What to do: use v-model / modelValue.
TextInput / Textarea / Password / Duration — focus() and inputElement on the ref (breaking)
This implements ADR-0012.
- Breaking:
TextInput.elandTextarea.elare renamed toinputElement. It is a computed, typedHTMLInputElement | null/HTMLTextAreaElement | null, never a raw ref. - All three, and
Duration, now exposefocus(options?: FocusOptions).Passwordexposed nothing before. TextInput,TextareaandPasswordshare one exported type,TextInputExposed, fromTextInput'stypes.ts.DurationExposed.focusgained the sameoptions?parameter. Its set of members is unchanged.
What to do: rename .el to .inputElement.
Rating — max replaces rating_from
The default is 5. The old name was kept as a deprecated alias during the betas and is now removed; see Toggles and ranged inputs. Rating no longer imports FeatherIcon. The default star comes from lucide-star through the shared Tailwind plugin. Filled stars now render visibly for values above zero.
Slider — additive props and a11y fix
disabledprop added.size: 'sm' | 'md'added.'md'scales the track and thumb in proportion.- New
value-commitemit. It fires when dragging ends. Use it for side effects you do not want on every step. - Removed the hardcoded
aria-label="Volume". The label now comes from the shared labeling contract, so passlabelexplicitly. This counts as a bug fix: assistive tech announced every Slider that was not a volume control as "Volume". - The track is visible in collapsed wrappers, and the slider is full-width by default.
- An uncontrolled
Sliderstarts atmin, instead of rendering with no thumb.
Switch — Lucide icons; deprecations
Switch no longer imports FeatherIcon. icon is now string | Component, and lucide-* strings go through the shared Tailwind plugin. labelClasses and the change emit were deprecated during the betas and are now removed; see Toggles and ranged inputs. The row's hover and active background is removed.
Checkbox — padding deprecated
padding was deprecated in favor of padded. It is now removed; see Toggles and ranged inputs.
Textarea — ghost variant; required prop
Textarea now accepts the 'ghost' variant (like TextInput and Password) and the shared required prop.
TextInput / Textarea — ghost variant paints transparent (fix)
ghost set no bg-* class, so the @tailwindcss/forms preflight painted the input #fff, which showed as a white pill in dark mode. ghost now sets bg-transparent, like Combobox's own ghost search input. Closes #851.
Input — removed (breaking)
Breaking: Input and its Input.cy.ts tests are deleted. Per ADR-0008, no deprecated member ships in 1.0.0. A survey of downstream apps found no live call sites that render <Input>. Five registrations were global component registrations that never rendered it.
What to do: use TextInput for text-like modes, or Textarea / Select / Checkbox for the other type modes that Input accepted.
FormLabel — moved to a component directory (non-breaking)
FormLabel now lives at src/components/FormLabel/FormLabel.vue instead of a bare src/components/FormLabel.vue, like the rest of the input family. It gains types.ts, tests, stories and a docs page. The import path (import { FormLabel } from 'frappe-ui') is unchanged.
InputLabel — slot polish
The default required indicator is not rendered when you use #label (the slot receives { required }). The labeling wrapper is not rendered at all when there is nothing to label.
Rating — stars line up with text beside a labeled rating (fix)
With a label, the stars sat inside a line of text, which added a few pixels under them. Text placed next to the rating sat lower than the stars. The stars are now their own row, so they line up with the text.
What to do: nothing. If you added a class to work around the gap, you can remove it.
Date and time pickers
Pickers — open is honored at mount (breaking, silent)
DatePicker, DateRangePicker, DateTimePicker and TimePicker set their own open state to false at the start, and only watched open for later changes. So a parent that mounted one with open already true got a closed panel.
The initial value is now read on the first render, and the panel opens fully set up: the calendar shows the bound date, TimePicker scrolls to the bound time, and the trigger's aria-controls points at the panel. Mounting open emits no update:open and moves no focus: the panel is on the page from the first render, so focus stays wherever you put it. Opening the panel later still moves focus into it when the trigger is a custom #trigger.
- Behavior change: if you passed
openas a constanttrueand relied on it being ignored, the panel now opens. open: truetogether withdisabled: trueshows the panel. This matches the controlled path, which already skipped the disabled check. The imperativeopen()still does nothing while disabled.
What to do: if you passed a constant open, drop the prop or bind it to your own state.
DatePicker internals are no longer exported (breaking, loud)
src/components/DatePicker/index.ts re-exported its whole utils module, so months, monthStart, generateWeeks and getDateValue reached the package root. The library does not allow export * from an implementation module: any helper a later commit added to utils.ts would have joined the public API without review, and been frozen.
Breaking, loud: import { months, getDateValue } from 'frappe-ui' fails to resolve. Nothing replaces them. months was a hardcoded English-only 'Jan'..'Dec' array with no way to translate it, so freezing it would have been worse.
What to do: copy what you need into your app, or use dayjs directly.
The DatePicker, DateTimePicker and DateRangePicker components and their types are unchanged.
DatePicker family — trigger slot props renamed to open / toggle (breaking, silent)
#trigger, #prefix and #suffix on DatePicker, DateRangePicker, DateTimePicker and TimePicker now receive { open, toggle } instead of { isOpen, togglePopover } (#1054).
Popover, HoverCard, Dropdown, Select, Combobox and MultiSelect all name the boolean open, and Popover's trigger slot already names the flip toggle. isOpen is on CONTEXT.md's list of names to avoid in a public API, and togglePopover named the mechanism rather than the behavior. These components also already gave #actions a close(), so one component used two sets of names for one concept. displayLabel and inputValue are unchanged.
Silent break: a destructured isOpen becomes undefined, so a class bound to it stops applying, with no error. togglePopover() throws only when you call it.
What to do: search for both names and rename them. See the migration guide.
TimePicker — four emits removed (breaking, loud in TS, silent in JS)
update:open carries both the open and the close, with the state in the payload, so open and close are gone. input-invalid and invalid-change are gone too: typed text that does not parse goes back to the last valid value, which the user sees.
<!-- Before -->
<TimePicker @open="onOpen" @close="onClose" @invalid-change="setInvalid" />
<!-- After -->
<TimePicker @update:open="(open) => (open ? onOpen() : onClose())" />TimePickerEmits is exported. Variant is an alias of the shared InputVariant, not a second scale.
DatePicker family — v1 spec
DatePicker, DateRangePicker and DateTimePicker use the same popover-trigger names as Combobox / Dropdown / Select.
side(default'bottom') +align(default'start') +offset(default4) replaceplacement(removed).keepOpen(defaultfalse) replacesautoClose(removed; it is the inverse).typeable(defaulttrue) replaces picker-levelreadonlyandallowCustom(both removed).:typeable="false"blocks typing and keeps the popover interactive.- Constraints:
min?: stringandmax?: string(YYYY-MM-DD, plusYYYY-MM-DD HH:mm:ssonDateTimePicker), andisDateUnavailable?: (date: Dayjs) => booleanto disable any date. Min/max and the function work together. OnDateTimePicker,minDateTime/maxDateTimeare removed in favor ofmin/max. v-model:openworks on all three pickers, throughopen+update:open.openOnFocus(defaultfalse) andopenOnClick(defaulttrue) let you turn off either way of opening.Comboboxgot the same defaults.#triggeris the slot for a custom trigger.#targetis removed.DateRangePicker.clearablenow defaults totrue. The footer hides when there is nothing to clear. (The footer was removed later; see footer removed.) The same change added a live hover preview while picking the end date, and a stable trigger width derived fromformat.- Public type exports added:
DateTimePickerProps,DateRangePickerEmits,DateTimePickerEmits,DateRangeValue.
DatePicker family — DateRangePicker emit shape (breaking)
DateRangePicker emits update:modelValue / change as a [from, to] tuple (DateRangeValue = [string, string] | []) instead of a comma-joined string. The modelValue prop already accepted string[]. Only the emit changes.
// before
function onChange(v: string) { const [from, to] = v.split(',') }
// after
function onChange(v: DateRangeValue) { const [from, to] = v } // [] when clearedReactive forms that pass the value through unchanged are not affected.
DatePicker family — footer removed; new #actions sidebar slot
The popover footer on DatePicker, DateRangePicker and DateTimePicker is removed, including the Clear button it rendered automatically when clearable && hasValue. clearable still controls the clear control on the input.
- The new
#actionsslot renders as a left sidebar inside the popover. Its slot props includeclose,setDate/setRange, andclear.DateRangePicker'ssetRange([from, to])sets both ends in one step. Use it for fixed-window presets ("Last 7 days"). - The popover content is
w-fitwhen you provide the slot. - The sidebar
<aside>carriesdata-slot="actions"for CSS.
What to do: if you relied on the automatic Clear button, render your own Clear row inside #actions, using the clear slot prop.
DateTimePicker — date selection keeps popover open (breaking)
Selecting a date in DateTimePicker no longer closes the popover. Focus moves into the embedded TimePicker instead, so the user goes straight from date to time. The popover closes on Esc, on a click outside, or on a call to close().
What to do: if you relied on the automatic close, bind v-model:open and close it from @update:modelValue. Or render an "Apply" button in #actions, which receives close in its slot props.
TimePicker — v1 refresh
The same names as the DatePicker family, plus a flexible parser.
side/align/offsetreplaceplacement(removed).keepOpen(defaultfalse) replacesautoClose(removed).typeable(defaulttrue) replaces picker-levelreadonly/allowCustom(both removed).v-model:openthroughopen+update:open. NewopenOnFocus(defaultfalse) andopenOnClick(defaulttrue) props.- Flexible typed input:
"3pm","3.30pm","1500"and"9:30:15 am"parse to the canonicalHH:mm[:ss]. min/maxreplaceminTime/maxTime(removed).scrollModeis removed. The list is always centered on the selection.- The template ref exposes only
focus()(ADR-0012).selectAll()andblurInput()had no callers and are removed.
DatePicker family — keyboard navigation
Full keyboard navigation inside the calendar grid, following the WAI-ARIA APG Date Picker Dialog pattern.
↓on the trigger input opens the popover and moves focus to the selected cell, or to today.- In the grid:
←/→move ±1 day,↑/↓±1 week,Home/Endto the week edges,PageUp/PageDown±1 month,Shift+PageUp/Shift+PageDown±1 year. Enter/Spaceselects.Esccloses and returns focus to the input.- Disabled dates (from
min/max/isDateUnavailable) are skipped. - Arrow keys move across month boundaries on their own.
DateRangePickershows two months: arrow keys cross between them without moving the view, and the range shading follows the cell with keyboard focus.
Only one cell is in the tab order at a time, so Tab enters and leaves the grid as one unit. Custom #trigger slots get this too: any way of opening moves focus into the grid, because a trigger that is not a TextInput has nothing to type into.
DatePicker family — legacy composable removed (breaking, loud)
useDatePicker and its helpers (getDate, getDatesAfter, getDaysInMonth, isLeapYear) were not used by any picker component and were not part of the v1 API. They are deleted outright. The import fails, so the break is loud.
What to do: use the picker components directly.
DatePicker / TimePicker family — deprecated aliases removed (breaking, silent)
The compatibility aliases these components carried through the betas are deleted, not kept as shims that warn and map to the new name. Per ADR-0008, no deprecated member ships in 1.0.0.
placement,autoClose,allowCustom, picker-levelreadonly,inputClass, and thevalueprop are removed. All silent: a leftover prop lands as an unused extra attribute and does nothing.- The
#targetslot is removed. Content in a leftover<template #target>silently stops rendering. Use#trigger. DateTimePicker.minDateTime/maxDateTimeandTimePicker.minTime/maxTimeare removed. Silent: the limit is no longer enforced. Usemin/max.TimePicker.scrollModeis removed. Silent. The list is always centered.
change stays as a supported second emit next to update:modelValue. It was never deprecated on TimePicker, and DateTimePicker depends on it internally, so removing it from the other two pickers would have been an inconsistent break that nothing required.
What to do: see the before/after for each in the migration guide, and the Deprecation log for each replacement.
MonthPicker — removed (breaking)
MonthPicker and all its files (MonthPicker.vue, types, stories) are deleted. It duplicated Select for a narrower case. The import fails, so the break is loud.
What to do: use Select with month options. See the migration guide.
Select, Combobox, MultiSelect and Dropdown
Selection emit interfaces drop the model events (breaking in TS only)
ComboboxEmits and MultiSelectEmits no longer declare 'update:open' and 'update:query'. SelectEmits no longer declares 'update:modelValue'. Each component already declares those events through defineModel. Declaring them twice collapsed $emit's signature to (event, ...args: unknown[]), so a typed @update:open listener did not compile. SelectEmits also disagreed with the generated payload, which carries undefined because the model prop is optional. So a wrapper that re-bound @update:model-value failed to compile.
The runtime events are unchanged. v-model, v-model:open, v-model:query and the matching listeners all fire exactly as before, and the API tables still list every event. Only these five interface members are gone:
| Removed member | Still emitted at runtime |
|---|---|
ComboboxEmits['update:open'] | yes |
ComboboxEmits['update:query'] | yes |
MultiSelectEmits['update:open'] | yes |
MultiSelectEmits['update:query'] | yes |
SelectEmits['update:modelValue'] | yes |
You are affected only if you indexed those interfaces by hand, as in type Handler = ComboboxEmits['update:open'], or passed one to defineEmits in a wrapper.
What to do: type the handler from the model instead: (value: boolean) => void for open, and (value: SelectOptionValue | null | undefined) => void for a selection model. The undefined is there because the model prop is optional. The component never emits it.
Select — nothing selected is null (breaking, silent)
Select emitted undefined for an empty value, while Combobox emitted null. So one single-value family had two answers. Both are null now. MultiSelect keeps [], because apps loop over an empty array.
// Before — Select
watch(value, (v) => { if (v === undefined) reset() })
// After
watch(value, (v) => { if (v === null) reset() })An empty string is still a real value, so a "None" row with value: '' still round-trips. clear() and the clear slot prop both write null.
Autocomplete — removed (breaking)
- Breaking:
Autocompleteand itsAutocompletePropstype are deleted. UseComboboxfor one value andMultiSelectfor several. The import fails, so the build names every call site.trigger="button"on either replacement gives the shape ofAutocomplete's default target: a button that shows the selection, with the search box inside the popover. - Breaking, silent:
FormControl type="autocomplete"is removed.FormControlfalls through toTextInputand still forwards the type, so the result is<input type="autocomplete">: a plain text box, with no build or runtime error. Aconsole.errorin development names the removal. Usetype="combobox", orComboboxon its own. - Breaking, silent: the
v-modelvalue changes shape.Autocompleteheld the whole option object. Both replacements hold only the value. Listen to@update:selectedOptionwhere you need the whole option. - Breaking, silent:
#target'sopenslot prop was the function that opened the popover.#trigger'sopenis the open state. Anything that read it as a value (v-if="open") was always true, and now is not. - Otherwise,
#targetbecomes#trigger.ComboboxandMultiSelectattach the open toggle to the trigger element themselves, so drop your click handler. AtogglePopover()carried through the rename throws on click. The popover still opens, so it looks like it works while it logs an error. - Grouped options use
{ group, options }, not{ group, items }. Both option normalizers now throw an error that names the group and the rename, instead of failing inside amapcall.
What to do: see the before/after for each silent break in the migration guide.
Dropdown / ContextMenu — deprecated members removed (breaking; loud in TS, silent in JS)
Per ADR-0008, three surfaces that shipped as deprecated aliases in the betas are deleted, not aliased. All three are silent breaks in plain-JavaScript apps. TypeScript callers get compile errors, because the removed keys stay typed as never. A warning also fires in development when the old shape reaches the menu at runtime.
- The
placementprop and theDropdownPlacementtype are removed. Usealign(left→start,center→center,right→end). A leftoverplacementis ignored, and the menu falls back toalign="start". { group, items }is removed. Use{ group, options }, likeCombobox/MultiSelect/Select. A leftoveritemsgroup renders as an empty menu.component:option rows are removed (DropdownComponentOption,ContextMenuComponentOption). Useslots: { item: fn }. A leftovercomponent:row renders as a plain action row from itslabel.
Also removed: the DropdownExposed type. It described a close() template-ref member that Dropdown never had. ADR-0012 keeps Dropdown's template ref empty: v-model:open and the close slot prop cover it. It is type-only, so this break is loud.
What to do: see the before/afters in the migration guide.
Dropdown — disabled state reaches the menu primitive
The trigger now passes its disabled state (from button.disabled or a disabled attribute you pass) to the underlying menu primitive. Before, only the generated Button was natively disabled. A custom trigger slot with a disabled attribute could still open the menu from the keyboard or with synthetic clicks.
Select — #item-* slot prop renamed to item (breaking, silent)
#item-prefix, #item-label and #item-suffix on Select pass the option as item, like Combobox and MultiSelect. The old option key is removed with the rest of the deprecated surface (ADR-0008). Destructuring { option } gives undefined, silently. No runtime warning is possible, because destructuring a slot prop cannot be detected.
What to do: search for #item- slots that destructure option, and rename them to item.
<!-- before -->
<Select :options="people">
<template #item-prefix="{ option }">
<Avatar :image="option.image" />
</template>
</Select>
<!-- after -->
<Select :options="people">
<template #item-prefix="{ item }">
<Avatar :image="item.image" />
</template>
</Select>Combobox — trigger sizing matches Select
The root renders as a transparent layout box, so the trigger sizes like Select inside flex and grid containers. In button mode, the query is separate from the model.
Combobox / MultiSelect — #suffix slot replaces the chevron
A new #suffix slot on Combobox (input and button modes) and MultiSelect, like the one Select already has. When you provide the slot, it replaces the default chevron. If your content is conditional, render a chevron yourself as the fallback. The main use is an inline clear button. See Combobox/stories/Clearable.vue.
Combobox — condition authoritative for type: 'custom' rows
A custom row's condition({ query }) is now called even before the user types after opening. So it can fully control its own visibility, based on the selection and the typed query. Selectable rows are unchanged. This lets you build "create new" rows directly with condition, with no need for a separate createOption prop. See Combobox/stories/CreateNew.vue.
MultiSelect — #summary suppresses the phantom sizer
By default, the trigger sets a minimum width from the longest default summary (placeholder or "N selected"), so the trigger does not change width as the count changes. That sizing cannot predict custom text, so it is now skipped when you provide #summary. The trigger then sizes to its content, and you control the width.
Dialog, Popover, Tooltip, HoverCard and Toast
Toast: the compat shims are removed (breaking, one of them silent)
spec/toast.md defines the official API as sonner's namespace. These four surfaces were never part of it. They carried no @deprecated tag, only a runtime warning, so a search for the tag could not find them. (A "census" below is a count of call sites across the surveyed apps.)
Loud: the member is gone and the call throws.
toast.create({ message, … })→toast(message, { … })ortoast.message(…). Census: 7 sites, helpdesk 5 and suite 2. Suite's two are local wrapper functions, so fixing them covers roughly 42 files downstream.toast.remove(id)→toast.dismiss(id). Census: 0 sites.toast.removeAll()→toast.dismiss(). Census: 4 sites in suite (mail 3, calendar 1). An earlier count said 0; that was wrong.
Silent, and this is the dangerous one.
- The legacy object form
toast({ title, text, … })is gone. Nothing throws. The object goes straight to sonner as the message, and sonner expects a string, a component or a VNode, so the toast renders empty or wrong. The census found no direct call sites in the surveyed apps. But a search fortoast(will not find these calls; search for thetitle,textandmessagekeys instead. See the before/after in the migration guide.
renderSafeHTML, dispatch and the four semantic creators (success/error/warning/info) are unchanged.
Toast: description supports the same limited inline HTML as the message (breaking, silent)
description passed through the options object untouched, so sonner rendered it as text, while the message was sanitized and rendered as HTML. It now goes through the same DOMPurify safelist (a, em, strong, i, b, u).
- Breaking, silent: a description that holds a
<outside that safelist loses those characters.description: 'Set <Button> variant'rendered literally before. Now DOMPurify strips<Button>and the user seesSet variant. There is no warning and no error. - A census across builder, crm, gameplan, helpdesk, insights and suite found no toast
descriptioncontaining a<. The one hit was a Gameplan Cypress fixture for a space description, not a toast. - Descriptions that are not strings (components, VNodes, render functions) pass through untouched, as before.
- Also breaking, silent:
toast.messageandtoast.loadingnow sanitize their message and render it as inline HTML too. Before, they came from vue-sonner's namespace untouched, sotoast.message('<b>hi</b>')printed the tags literally. Now it renders bold. This makes them consistent withtoast()and the four semantic creators, and thetoast.createmigration advice in the entry above depends on it. toast.customtakes a component rather than a message, so only itsdescriptionis covered.toast.promisekeys its strings by state, and itssuccess/errormay be async functions, so only itsdescriptionis covered. The state strings render as vue-sonner renders them.
This lands before the tag on purpose. Doing it in a 1.x release would silently change the rendered output for every existing caller.
HoverCard's side, align and portalTo use our own vocabulary (breaking in TS)
HoverCard took the types of these three props from reka-ui instead of declaring them. So portalTo accepted null and any object, while the other six overlays accepted string | HTMLElement. :portal-to="document.querySelector('#panel')" compiled against HoverCard and failed against Popover.
sideis nowPopoverSide,alignisPopoverAlign, andportalToisPortalTarget(string | HTMLElement), like every other overlay.- Breaking in TS: passing
nullor an object toportalTono longer compiles. Runtime behavior is unchanged. sideandalignare not a break. reka'sSideandAlignresolve to exactlyPopoverSideandPopoverAligntoday.
A type cannot be narrowed inside 1.x once it is wide, which is why this lands before the tag rather than after.
What to do: pass a selector string or an element to portalTo.
SettingsDialog — open state moves to v-model:open (breaking, silent)
v-model → v-model:open, and update:modelValue → update:open (#1054). CONTEXT.md says the visibility of an overlay is open, "always bound via v-model:open", and lists bare v-model for visibility among the names to avoid. Dialog's support for both bindings is a documented exception for Dialog only, and new components do not copy it. v-model:tab is unchanged.
Silent break: Vue accepts the unknown modelValue prop with no error, so the dialog never opens.
What to do: change v-model to v-model:open. See the migration guide.
Dialog — theme: 'yellow' renamed to 'amber' (breaking, silent)
DialogTheme held the library's last yellow (#1054). Alert, SidebarCard, Badge and Avatar all spell the warning tone amber, and Dialog already rendered yellow with the amber tokens (bg-surface-amber-2, text-ink-amber-5). So the value name disagreed with the token it resolved to. This applies to icon.theme and to the theme argument of dialog.confirm / dialog.danger. Only the word changes, not the color.
Silent break for JavaScript call sites: yellow is no longer a key in the tone maps, so the icon renders with no tint and nothing throws. TypeScript call sites get a union error. The same change corrected the mapping in the icon.appearance entry, which used to say warning → yellow and now says warning → amber.
What to do: replace yellow with amber. See the migration guide.
Dialog — icon takes a string or a component, tone moves to theme (breaking)
The structured DialogIcon object is gone. icon is a lucide-* class name or a Vue component, and theme (amber | blue | red | green) colors the badge behind it. The same split applies to dialog.confirm, dialog.danger and dialog.prompt, which already had a top-level theme.
- Loud in development, silent in production: an object still passed to
iconrenders an empty icon badge. The circle paints in the neutral tone with no glyph in it. A development build warns once per component and prop:[frappe-ui] Dialog.icon received a plain object .... - Loud in TypeScript: the
DialogIconexport is removed. paddingTopaccepts a number again. A length with no unit never reached the CSSOM, so:padding-top="80"removed the position padding and added nothing back. A number is pixels now.- Styling hooks:
data-slot="content"on the card,data-slot="icon"on the header badge,data-slot="actions"on the footer row. BottomSheet's content carriesdata-slot="content"too, and the sheet finds itself through that hook instead of a class name.
What to do: pass the icon name or component to icon, and the tone to theme. See the migration guide.
dialog.* actions are typed ImperativeDialogAction (breaking, loud)
Two different action shapes shared the name DialogAction. The component's actions prop keeps it. The array for the imperative helpers is ImperativeDialogAction, which the root now exports. Its onClick receives { close, setError } and is awaited. This changes types only.
Toast — the default duration is 4000ms, and the option types are exported
- No behavior change. The toast container never set a duration, so vue-sonner's own 4000ms applied, while
spec/toast.mdpromised 5000ms. The spec and the docs say 4000ms now. Passdurationper toast to change it. ToastOptions,ToastActionandToastIdare exported fromfrappe-ui. An app that wrapstoastno longer imports types from vue-sonner, which it does not depend on.ToastProviderstill takes no props, on purpose.
Documented, not changed: Dialog, Breadcrumbs and Alert contracts
Dialog.Title,Dialog.DescriptionandDialog.Closeare public parts. Abaredialog needsDialog.Titlefor its accessible name.- Dialog
messagestays. It is the body of a confirm-style dialog, and it is announced with the dialog through reka'sDialogDescription. BreadcrumbItemkeeps its open index signature, so a crumb can carry extra fields for the#prefix/#suffixslots.- Alert and SidebarCard keep
data-colorfor tone.data-themeis the light/dark attribute on the document, which is why the tone hook does not use that name.PHILOSOPHY.mdlists it with the other styling hooks. - Alert
icon: truemeans "the theme's automatic icon", the same as leaving it unset.
SettingsDialog — SettingsBody's exposed type
SettingsBody's exposed viewportElement now has a type, SettingsBodyExposed (ADR-0012), exported from frappe-ui. No behavior change.
Portal target for embedded apps (additive)
portalToonPopover,HoverCard,Dropdown,Select,ComboboxandMultiSelectno longer declares a'body'prop default. An app that is not embedded still gets'body', now as a fallback. No existing call behaves differently.- New
usePortalTarget/providePortalTarget/portalTargetKeyexports let a host page that embeds your app redirect every overlay at once. Seespec/portal-target.md.
Dialog — v1 spec
- Flat top-level props (
title,message,icon,size,position,paddingTop,actions) are the standard way. The legacyoptionsblob is removed; see deprecated surface removed. v-model:openis the standard binding.v-model(modelValue) still works, with no warning.- New props:
dismissible(defaulttrue, replacesdisableOutsideClickToClose),bare, andshowCloseButton(defaulttrue, independent of the automatic header). - The standard slots are
#default,#titleand#actions(with slot props{ close, actions }). The legacy#body*slots are removed. icon.theme(amber | blue | red | green) replacesicon.appearance, which is removed.- The automatic header no longer shows "Untitled" when there is no title.
Dialog — imperative dialog.* API
- New callback-based helpers:
dialog.confirm(),dialog.danger(),dialog.prompt(). TheonConfirmcallback runs on click. If it resolves, the dialog closes. If it throws, the dialog stays open and shows the thrown message inline. The action button shows a loading state untilonConfirmsettles. Each helper also returns a handle right away, withclose()to dismiss the dialog from code. <FrappeUIProvider>now renders<Dialogs />next to<Toasts />, so apps wrapped with the provider get the imperative dialogs with no extra setup.<Dialogs />is still exported for apps that do not use the provider.ConfirmDialogandconfirmDialog()are removed (see below). Usedialog.confirm()/dialog.danger().- New root exports:
DangerArgs,DialogControl,PromptControl,DialogHandle,PromptFieldValidator.DialogSlotPropsis exported from theDialogentry file.
Dialog — deprecated surface removed (breaking)
Every member marked @deprecated is deleted, per ADR-0008. Nothing is aliased and nothing warns.
- Breaking, silent: the
optionsblob prop and theDialogOptionstype are gone. It bundledtitle/size/icon/actionsinto one object. An:options="{...}"call site still compiles, because Vue drops the unknown prop as an unused attribute. But the dialog silently loses its title, size and actions. Use the flat top-level props. - Breaking, silent:
disableOutsideClickToCloseis gone. It still lands as an unused attribute, anddismissible(the inverse) defaults totrue, so the dialog silently becomes dismissible. Usedismissible. - Breaking, silent:
icon.appearanceandDialogIconAppearanceare gone. Onlyicon.themeremains. Anappearancekey is dropped, so the icon renders with no tone. Mapwarning → amber,info → blue,danger → red,success → green. - Breaking, silent: the legacy
#body,#body-content,#body-main,#body-titleand#body-headerslots are gone. Vue drops an unknown named slot with no error, so a call site you missed renders nothing where that slot's content used to be. Use#default,#titleand#actions.#bodymaps tobare+#default. - Breaking: the callable context on action
onClickis gone. The context used to be callable as well as a plain object (ctx()closed the dialog). It is{ close }only now, so calling it as a function throwsTypeError: ctx is not a function. - Breaking:
defineExpose({ close })and theDialogExposedtype are gone. Dialog exposes nothing on its template ref (ADR-0012). A template-ref.close()call throws aTypeError. Usev-model:open = false, or thecloseslot prop. There were zero known call sites. - Breaking:
ConfirmDialogandconfirmDialog()are deleted. The import fails, so the build names every call site. Usedialog.confirm()/dialog.danger().
What to do: see the before/after for the silent breaks in the migration guide.
Popover — v0 API removed (breaking)
Every member marked @deprecated is deleted, per ADR-0008. Nothing is aliased and nothing warns.
- Breaking, silent: the
#target,#bodyand#body-mainslots are gone. Vue drops an unknown slot with no error, so a call site you missed renders a popover with no trigger, or an empty one. Use#triggerand#default. - Breaking, silent:
#triggerwires the click itself, through reka'sPopoverTrigger. A click handler carried over from#targettoggles the popover a second time, so it opens and closes on one click. - Breaking, silent: the
togglePopoverandupdatePositionslot props are gone.togglereplaces the first. reka repositions on its own, so the second has no replacement. - Breaking, silent:
placement,show,hideOnBlur,matchTargetWidth,trigger,hoverDelay,leaveDelay,popoverClassandtransitionare removed, and theupdate:showemit no longer fires. An unknown prop is ignored, so the popover renders in its default position and state. - Breaking, silent: attributes on
<Popover>are no longer inherited. They used to land on a wrapper that the legacy#targetrendered.#triggerrenders its child directly (as-child), with no wrapper. Moveclassandstyleonto the element inside#trigger. - Breaking: the
PopoverPlacementandPopoverLegacySlotPropstypes are removed. UsePopoverSide+PopoverAlignandPopoverSlotProps. The import fails, so the build names every call site. - Fixed:
CalendarWeekDayEventpassedplacement="center"in month view, which is not a side, and reached reka as one. It is nowside="bottom"+align="center". - Breaking, silent: the slot props are
{ open, close, toggle }.openis now the boolean state, likeDropdown,Select,MultiSelect,HoverCardandSidebar. Theopen()method it used to be had no callers, because#triggeropens itself.isOpenis gone; readopeninstead. A destructuredisOpenbecomesundefinedwith no error, so styling that depends on it silently stops applying. - Fixed:
MonthPickerstyled its panel throughpopoverClass, which already did nothing, so the panel rendered with no surface at all. It uses the standard panel now. - Fixed:
:dismissible="false"still closed onEscape. Only the outside-click path was wired, whileCONTEXT.mddefinesdismissibleas covering both.
What to do: see the before/after for each silent break in the migration guide.
NestedPopover — removed (breaking)
Breaking: NestedPopover is deleted. It never nested anything. It was the library's last popover built on @headlessui/vue + @popperjs/core, and @popperjs/core leaves dependencies with it. The import fails, so the build names every call site.
What to do: use Popover.
Tooltip — vocabulary aligned with Popover and HoverCard (breaking)
- Breaking, silent: the delay props on Tooltip, TooltipProvider and HoverCard now use milliseconds. For example, change
0.5to500. Runoverlays-v1to convert fixed values. It reports expressions it cannot convert, for you to check by hand. - Breaking, silent:
placementis renamed toside, likePopoverandHoverCard. An unknown prop is ignored, so the tooltip keeps working and points at its default side. - Breaking, silent:
arrowClassis removed, because the library does not use props that inject classes. Style the arrow through[data-slot="arrow"]. It was documented as the arrow's fill, but was mostly used to nudge the bubble's position, which the newoffsetprop does directly. - Added:
offsetsets the gap in px between the trigger and the bubble, likePopoverandHoverCard. The bubble is no longer fixed at 4px. - Added:
[data-slot="content"],[data-slot="bubble"]and[data-slot="arrow"]styling hooks. - Breaking, silent: the
#bodyslot is replaced by#content, which renders inside the bubble instead of replacing it.#bodyis not one of the library's standard slot names, and it had the wrong shape: it removed the bubble's surface. So six of the seven call sites in the apps copiedrounded bg-surface-gray-10 px-2 py-1 text-xs text-ink-base shadow-xlby hand to put it back. Moving to#contentusually means deleting that wrapper. Vue drops an unknown slot with no error, so a call site you missed shows an empty tooltip. - Added:
barerenders#contentwithout the bubble, for content that brings its own surface, such as an image preview. The arrow still renders. This is the proper way to do what#bodywas used for. TooltipBubbleis no longer exported. It is the internal bubble shared byTooltipandButton, with no call sites outside the library.Tooltipkeeps#defaultas the trigger, on purpose. It is the one exception in the library to the standard slot names, andPHILOSOPHY.mdrecords it. Over 200 call sites use the<Tooltip text="…"><Button /></Tooltip>shorthand, and renaming the slot would change every one of them for no change in behavior.
What to do: see the before/after in the migration guide.
HoverCard — open() and close() on the template ref
- Added:
open()andclose()on the component instance, likePopover. - The trigger slot's props are now typed (
HoverCardSlotProps) instead ofany.
BottomSheet — focus stays inside an open sheet
The sheet does not focus its first field on open, so it does not bring up the keyboard on a phone. That also left focus on the trigger behind the overlay, with nothing holding it, so Tab moved through the page behind an open modal. The sheet now takes focus itself on open. The keyboard still stays down.
Sidebar, tabs and navigation
SidebarItem attributes land on the link or the button (breaking, silent)
A SidebarItem is a row container that holds the link or the button, with the #suffix area next to it. Every attribute you wrote on the component used to stop at that container, so target, rel, id, title, data-* and aria-* never reached the control. They now land on the control. class, style and event listeners stay on the container, which is what a row background, a drag-and-drop ring and a @drop over the suffix need.
- Who is affected: apps that select a row by an attribute they pass in (
data-testid,id), and apps that setaria-labelon a row.
What to do: select the container with [data-slot="sidebar-item"], and the control with [data-slot="sidebar-item"] > a or > button. An aria-label you pass now wins over the one taken from the label text, so drop any workaround for the old behavior.
Sidebar is the <nav> landmark; sections are groups
Sidebar's root element is a <nav>, labelled Main. The new ariaLabel prop changes the label. SidebarSection's body is a <div role="group">, not a nested <nav>, and it takes its accessible name from the section's heading. Before, only a collapsible section had a name.
- Who is affected: apps whose sidebar body wraps rows in their own
<nav>, and CSS or tests that selectnavinside the sidebar.
What to do: turn the <nav> wrappers your app writes inside Sidebar into divs, so the page reports one navigation landmark. Select [data-slot="sidebar-section"] [role="group"] for a section body.
SidebarHeader without menuItems is no longer a button
A header with no menuItems rendered a dropdown trigger anyway: it took focus, showed a hover background and a chevron, and opened an empty menu. It now renders a plain <div> with the same box, without the chevron and the tab stop. The logo and the title sit in the same place either way.
- Who is affected: apps that style or query the header's inner
<button>, or that relied on the chevron to signal a menu that was never there.
What to do: nothing, unless a selector targets that button. Pass menuItems to get the trigger back.
SidebarItem.icon takes the same strings as every other icon (breaking, silent)
SidebarItem rendered its icon prop through a private SidebarItemIcon component, which printed any string that was not a lucide name as text. It now uses the shared Icon, so a lucide-* class, an emoji and a component render exactly as before, at the same size-4 text-ink-gray-6.
- Who is affected: rows with a plain-text
icon, for exampleicon="home"oricon="AB". The text used to show. It now renders nothing and warns once in development, the same asIcon,ButtonandMobileNavItem.
What to do: pass a lucide-* class for an icon. Put initials and other text in the #prefix slot: <SidebarItem><template #prefix>AB</template></SidebarItem>.
Tabs — a clicked tab clears when the route leaves its page (fix)
In route mode, a tab without a route that you click stays selected until the route moves. "Moves" used to mean "a different tab matches". So opening a child route under one routed tab (/inbox → /inbox/42) left the clicked tab selected on a page it does not stand for. A navigation that lands on a different path now clears it too.
- A navigation that did not land keeps the clicked tab: one stopped by a guard, one cancelled by a newer navigation, or a duplicate of the URL already showing. A redirect clears it, because the redirect target lands.
- The trade-off: a navigation that changes only the query or the hash and keeps the same tab matched (
/inbox→/inbox?page=2) no longer clears the click. A panel that keeps its own state in the URL makes exactly that change, and clearing there threw the user out of the panel they were in. A change of matched tab with no navigation behind it does still clear it: a trigger'srouteprop changing, or a routed trigger mounting that matches the current URL. - Tabs still works with no router installed. The listener is registered only when a router is present, and is removed on unmount.
TabButtons — focus() on the component ref
A template ref now exposes focus(options?), the method every focusable control in the library shares. It focuses the selected option, or the first enabled one when nothing is selected. Disabled options are skipped, including route and href options that render disabled. A group with no options, or with every option disabled, does nothing. FocusOptions is passed through, preventScroll included. The type is exported as TabButtonsExposed.
Rail renamed to SidebarRail, RailItem to SidebarRailItem (breaking)
The rail joins the Sidebar family by name. Nothing else changes. SidebarRail is still a bare frame that renders on its own or next to Sidebar. Sidebar does not own the rail layout, and the rail is not a collapsed mode of the sidebar. Props, slots and events are unchanged.
RailItemPropsis nowSidebarRailItemProps, and the root exports it the way it exportsSidebarItemProps.- Breaking, silent: the styling hooks follow the name.
data-slot="rail"becomes"sidebar-rail",rail-itembecomessidebar-rail-item, and the same goes forrail-item-indicatorandrail-item-badge-dot. A CSS rule written against an old value still parses. It just stops matching. DesktopShell's#railslot keeps its name. It names a layout area, not the component that goes in it.
There is no alias export. The import fails, so the build names every call site.
What to do: find and replace the old names, as with every other component rename in v1. Update CSS selectors on the data-slot values.
Tabs, TabButtons — side is now edge, with start and end (breaking; loud in TS, silent in JS)
On a vertical browser-tab list, the prop that picks which edge of the list the tabs attach to is now edge="start" | "end" instead of side="left" | "right". start and end follow text direction: start is the left edge in left-to-right text and the right edge in right-to-left text. The type TabsSide is now TabsEdge.
What to do: replace side="left" with edge="start" and side="right" with edge="end". In JS, the old prop is ignored and the tabs attach to the start edge.
TabButtons: class on an option is replaced by data-value (breaking, silent in JS)
The library customizes through slots and data-* attributes, never through props that take class names. class on a TabButton option was the last per-item class field in the library, and it added two more names to the frozen API.
- The tab button now renders
:data-value, so CSS can target one tab:[data-slot="tab-button"][data-value="open"] { … }. This was not possible before: the rendered button carrieddata-slot,data-stateanddata-disabled, but no per-value hook. - Breaking, silent in JS:
classon an option object stops applying. A JavaScript caller keeps compiling and loses the styling with no warning. - Breaking, loud: the
NativeButtonClasstype is no longer exported. - Breaking:
customClassis removed from the#prefixand#suffixslot props. This is loud if you destructure it, and silent if you spread it.
The composed Tabs family is unchanged. There you write the <TabTrigger> yourself, so a class goes on the element directly.
What to do: move the styling to a [data-value="…"] selector. See the migration guide for the before/after.
Sidebar — deprecated config API removed (breaking)
Per ADR-0008, every member marked @deprecated is deleted. Sidebar is now a bare frame that you compose: SidebarHeader / SidebarSection / SidebarLabel / SidebarItem go in the default slot, as agreed in v1-release/plan.md.
- Breaking, silent:
Sidebar'sheaderandsectionsconfig-object props are gone. Old code still compiles, because Vue drops them as unused attributes, but the sidebar renders empty instead of the configured header and sections. ComposeSidebarHeaderandSidebarLabel+SidebarItem(orSidebarSection) directly in the default slot. - Breaking, silent:
Sidebar's#header-logoand#footer-itemsslots are gone. They only existed to reach into the config-object layout. Old<template #header-logo>/#footer-items>content stops rendering. Put that markup directly in the default slot. - Breaking, silent:
SidebarSection'sitemsprop and#sidebar-itemscoped slot are gone. It is now a plain collapsible group (label,collapsible,v-model:collapsed) whose children areSidebarItems you put directly in its default slot. Before, it took anitemsarray plus a slot to customize each row. - Breaking, silent:
SidebarItemProps.isActive(an alias foractive) and.condition(a config-object visibility filter) are gone. Useactive. Usev-ifon the composedSidebarIteminstead ofcondition. - Breaking, silent:
SidebarHeader's#logoslot is renamed to#prefix, because the library uses generic slot names when one covers the case. Old<template #logo>content stops rendering, and the default logo/initial box shows instead. SidebarItem's icon in the collapsed rail no longer jumps to a centered square and back while the sidebar's width animates. It holds one position through the transition. This also fixes the icon sitting 2px off the rail's center line.SidebarSection's collapsible label is now a real<button>witharia-expanded/aria-controls, and works from the keyboard. It was a<div>with a click handler and no keyboard path.
What to do: see the migration guide.
Navigation destinations use route and href (breaking)
Router destinations are now named route on ListRow, SidebarItem, SidebarRailItem and MobileNavItem. Plain external URLs use href, and that includes the former Button link prop.
TabButton options keep route, href and onClick, require a string label, and no longer accept tooltip. Move extra help text into UI your app owns.
What to do: run npx destinations-v1 .. It rewrites template props with fixed names on components imported from frappe-ui. Then check by hand: globally registered components, option objects, render functions, and v-bind spreads. See the migration guide.
PageHeaderBackButton — to is now fallbackRoute (breaking)
fallbackRoute is used only when there is no in-app history to go back to, such as a cold load onto a deep link. Every other tap goes back through history. The old to prop is removed.
A back button that always lands on one fixed route is not a back button. It sends the user wherever the page author guessed they came from, which is wrong for every other way into the page.
What to do: rename to to fallbackRoute if it already named the page users came from. If you need a push that always happens, use a plain Button with your own router.push.
App shell, page header and color scheme
ThemeSwitcher — moved to frappe-ui/experimental (breaking, loud)
ThemeSwitcher is not being brought up to the v1 quality bar at the root for 1.0.0 (#1094). It moves to frappe-ui/experimental, which has no stability promise. It stays deprecated there while apps migrate.
- Breaking, loud:
import { ThemeSwitcher } from 'frappe-ui'fails to resolve. ImportThemeSwitcherand the typeThemeSwitcherPropsfromfrappe-ui/experimentalinstead. - The replacement covers the behavior, not the look.
ThemeSwitcherrenders a reka-uiRadioGroupRootof theme preview cards. ASelectbound to theuseColorSchemecomposable replaces the behavior, not the markup, so an app that wants the cards has to rebuild them. Changing the import is the smaller change and keeps the current UI.
What to do: change the import, or build your switcher from Select and useColorScheme. See the before/after in the migration guide.
Shells — a page reads the shell it is inside (fix)
DesktopShell and MobileShell now pass their scroll element and their PageHeaderTarget to everything they render, and a page prefers the shell above it over the module-level registry. No caller code changes.
The registries answer "the shell that mounted most recently". That is the wrong answer while two shells are mounted at once: during a desktop-to-mobile swap, or in a test that mounts both. A PageHeader could teleport into the other frame, and useShellScrolled() could track the other scroll element. The registries stay as the fallback for code that inject cannot reach: a router scrollBehavior, a navigation guard, a header teleported out of the shell. shellScrollContainer itself is unchanged.
DesktopShell — :scroll="false" (additive)
scroll defaults to true. Pass false and the content area fills the remaining height and never scrolls as a page. This is for layouts whose panes handle their own overflow: a list-and-detail split, or a board where each column scrolls. Apps used to fake this with absolute inset-0, a hardcoded h-[calc(100vh-3rem)], or [&>div]:h-full.
With :scroll="false" the shell has no scroll element, so shellScrollContainer is null and useShellScrolled() stays false. DesktopShellProps is exported and now includes the prop.
useShellScrolled — threshold is required (breaking, loud in TS)
useShellScrolled() with no argument is a type error. At runtime it warns in development and stays false. The old default was 200px, which suits a long document and nothing else: a header border that appears 200px late looks like a bug, not like a missing argument.
What to do: pass { threshold: 12 }.
ScrollBar — no longer exported (breaking, loud)
import { ScrollBar } from 'frappe-ui' fails, and so does ScrollBarProps. There is no replacement. ScrollArea draws its own scrollbars, and ScrollBar only worked inside reka-ui's ScrollAreaRoot, which frappe-ui does not export. So the export named a component you could not use.
ScrollArea.viewportClass stays. It is the one class-name prop the library ships, and the documented exception to the rule against class-name props in PHILOSOPHY.md: the scrolling viewport is an element reka-ui owns inside the root, so a class on the root cannot reach it.
What to do: use ScrollArea and its orientation prop. orientation="both" renders one scrollbar per axis.
useSheetDrag — no longer exported (breaking, loud)
useSheetDrag, UseSheetDrag and UseSheetDragOptions leave the root. BottomSheet still uses the composable internally and is unchanged. There is no standalone replacement: the drag thresholds are constants tuned for that one component. It can come back when a second component needs it.
Documented, not changed: shell slot names
DesktopShell keeps #rail and #sidebar. MobileShell keeps #nav. The names describe areas of the screen, not components. The desktop frame has two side areas that can appear together, and the mobile frame has one bar along the bottom, so one shared name would have to mean three things at once.
resolvedColorScheme is a ref on useColorScheme() (breaking, loud)
import { resolvedColorScheme } from 'frappe-ui' fails. The same name now comes from useColorScheme() as a read-only Ref<'light' | 'dark'>. So an app reads .value instead of calling a function, and drops the MutationObserver it needed to know when to call it again. The ref follows setColorScheme, and follows the OS setting while the preference is system.
colorScheme is the preference and can be system. resolvedColorScheme is what the page shows. ColorScheme and ResolvedColorScheme stay exported. Internally the function is getResolvedColorScheme, which charts use to pick a palette outside a component. It is not part of the package's public API.
There is no codemod, because a call has to become a .value read, and that depends on the code around it.
What to do: replace resolvedColorScheme() with useColorScheme().resolvedColorScheme.value, and delete your observer.
useResolvedColorScheme() reads the scheme without owning it (addition)
A new root export for a page that must not write data-theme: an app that applies its own theme before paint, a page inside a host shell, or a demo in an iframe. It returns the same read-only Ref<'light' | 'dark'>, read from the document and kept current. It writes no attribute, no class and no storage key. useColorScheme() applies the saved preference on its first call, so calling it only to read creates a second writer.
Nothing changes for an app that owns the scheme: keep reading useColorScheme().resolvedColorScheme.
toggleColorScheme() flips what is on screen (breaking, silent)
It used to read the stored preference. So under system on a dark OS, it wrote dark, the value the page already showed, and the first press did nothing visible. It now reads the resolved value, so every press changes the scheme. Apps that shipped their own toggle for this reason can delete it.
--mobile-header-height — removed (breaking, silent)
PageHeaderMobile is 52px tall, fixed, and reads no CSS variable for its height. Setting --mobile-header-height changes nothing in the library. Your app can keep using the name for its own rules. The variable had no prefix, no docs, and was not named after a component, which is the kind of variable ADR-0017 exists to prevent.
The internal title inset follows the ADR's naming for internal variables and is now --_page-header-mobile-title-inset.
What to do: for a header of another height, use PageHeaderBase with your own class.
PageHeader — every prop type has a name (additive)
PageHeaderTitleProps, PageHeaderMobileProps, PageHeaderMobileTitleProps and PageHeaderBackButtonProps are exported from the root, so a wrapper component can extend them. PageHeader, PageHeaderBase and PageHeaderTarget take no props and get no empty interface.
Documented, not changed: header click-to-top
A single click on the empty area of the header scrolls the page to the top. The attributes that turn this off keep their names: data-no-scroll-top on PageHeader, and data-no-sheet-drag on BottomSheet. Both are now documented. There is no data-fui- prefix rule for data attributes.
Root composables and directives — renamed and shrunk (breaking, loud)
Every change below is a loud break: the import fails, so the build, the type-check or the dev server tells you. There are no silent behavior changes, and none of these needs a before/after in the migration guide.
useTheme is now useColorScheme. theme means color tone everywhere else in the library (theme="blue" on a Button, ~300 sites), so the light/dark composable stops using the word.
// before
import { useTheme, type Theme } from 'frappe-ui'
const { currentTheme, setTheme, toggleTheme } = useTheme()
// after
import { useColorScheme, type ColorScheme } from 'frappe-ui'
const { colorScheme, setColorScheme, toggleColorScheme } = useColorScheme()colorSchemeis read-only. Assigning to the oldcurrentThemeref changed the ref without settingdata-themeorlocalStorage, so the app silently fell out of sync. UsesetColorScheme.initializeThemeandgetSystemThemeare gone.useColorScheme()already restores the saved preference and follows the OS on its first call.- The
data-themeattribute and thethemelocalStoragekey are unchanged. App CSS that targets[data-theme='dark'], and users' saved preferences, keep working.
Nine scroll members become two.
// before
import { activeScrollContainer, useScrollContainer, scrollToTop } from 'frappe-ui'
const { isScrolled } = useScrollContainer({ threshold: 12 })
// after
import { shellScrollContainer, useShellScrolled } from 'frappe-ui'
const scrolled = useShellScrolled({ threshold: 12 })
shellScrollContainer.value?.scrollTo({ top: 0, behavior: 'smooth' })| Removed | Use instead |
|---|---|
activeScrollContainer | shellScrollContainer |
useScrollContainer().isScrolled | useShellScrolled({ threshold }) |
useScrollContainer().el | shellScrollContainer |
getScrollContainer() | shellScrollContainer.value (works outside setup() too) |
scrollTo(o) | shellScrollContainer.value?.scrollTo(o) |
scrollToTop() | shellScrollContainer.value?.scrollTo({ top: 0, behavior: 'smooth' }) |
registerScrollContainer / unregisterScrollContainer | internal to DesktopShell / MobileShell |
UseScrollContainer, UseScrollContainerOptions | no replacement needed |
The shell prefix is on purpose: both work only while a DesktopShell or MobileShell is mounted. useShellScrolled now warns once in development when no shell is registered, instead of silently reporting false forever.
Directives are vFocus and vOnOutsideClick. <script setup> registers a directive automatically only when the binding is named vFoo, so the old names forced a manual alias at every call site.
<!-- before -->
<script setup>
import { onOutsideClickDirective as vOnOutsideClick } from 'frappe-ui'
</script>
<!-- after -->
<script setup>
import { vOnOutsideClick } from 'frappe-ui'
</script>visibilityDirective is removed with no replacement (0 call sites). Use an IntersectionObserver directly, or @vueuse/core's useIntersectionObserver.
useScreenSize, useIsMobile and ScreenSize are no longer exported. They were a thin wrapper over a resize listener that the library itself never used. Copy the ~20 lines into your app, or use @vueuse/core's useWindowSize / useMediaQuery.
pageMetaPlugin — removed (breaking)
- Breaking, loud:
pageMetaPluginis no longer exported, soimport { pageMetaPlugin } from 'frappe-ui'fails. Deleteapp.use(pageMetaPlugin). - Silent break:
pageMetaPluginand the global mixin it installed are gone. A leftoverpageMeta()component option still compiles, but nothing reads it, sodocument.titleand the favicon quietly stop updating. See the migration guide. usePageMetais unchanged, and now exports itsPageMetatype.
What to do: move pageMeta() options to usePageMeta.
App shell family — docs, tests and slot renames (breaking)
DesktopShell, MobileShell, MobileNav, PageHeader, ScrollArea and FrappeUIProvider all keep their current exports and names. The rail was in this list until it was renamed to SidebarRail; see the rename entry.
- Every slot across the family now has a documented description. Each component has a docs page, a story, and Cypress tests (several had none).
- Breaking, silent:
PageHeaderMobile's#left/#rightslots andPageHeaderMobileTitle's#iconslot are renamed to the shared#prefix/#suffixnames.PHILOSOPHY.mddoes not allow type-specific slots like#iconoutsideButton, and#left/#rightwere never among the standard names. Vue drops content passed to an unknown slot name with no error, so the old names do not warn. They just stop rendering. See the migration guide. ScrollAreagets atypes.ts(ScrollAreaProps,ScrollAreaExposed) and the styling hooksdata-slot="scroll-area"/"scroll-area-viewport"/"scroll-area-scrollbar"/"scroll-area-thumb". It had none.viewportElementon the template ref is now typed throughScrollAreaExposed. (SettingsDialog'sSettingsBodyexposes the same shape, but it is typed by its ownSettingsBodyExposed, not byScrollAreaExposed; see its entry.)FrappeUIProvider's source folder moved fromsrc/components/Providertosrc/components/FrappeUIProvider, to match its file name. This is internal only:import { FrappeUIProvider } from 'frappe-ui'is not affected.- Breaking:
FrappeUIProviderPropsis no longer exported. The component has no props, so the type was empty and was never wired todefineProps. Freezing it would lock in nothing. There were zero known consumers. The mismatched folder name had also hidden the whole component from the docs generator, so it had no docs page before.
Keyboard shortcuts
useShortcut renamed to useKeyboardShortcut, config reshaped (breaking; loud in TS, silent in JS)
The import fails to resolve, so the rename itself is loud. The config is the silent part: 14 fields become 10, and Vue drops the removed ones without a word.
key+ctrl+shift+altbecome onecombostring, writtenMod+Ctrl+Alt+Shift+<Key>.ctrlnever meant Control. It matchedctrlKey || metaKey, so{ key: 's', ctrl: true }fired on ⌘S, on ⌃S and on Win+S alike.Mod+Scompares every modifier exactly, so only ⌘S fires on macOS, and only Ctrl+S elsewhere. The two extra ways to trigger it stop.conditionbecomesenabled, and takes a ref, a getter or a boolean.triggeredOnis removed.onHoldselects hold mode, and a hold registration takes nohandler. This ends the old surprise wheretriggeredOn: 'hold'firedhandlertoo.- Punctuation and digits now use a key name (
Mod+Slash,Mod+Shift+Digit1), because+is the separator and'Mod++'splits into empty parts. Digits and punctuation matchevent.code, so a shifted character still resolves. Letters and named keys matchevent.key. The old US-layout guesswork is deleted.
TypeScript rejects an unknown combo at compile time. A JavaScript call site that still passes the v0 shape logs one warning in development and never fires.
What to do: see the useShortcut migration.
Shortcut precedence is now last-registered-wins (breaking, silent)
When two shortcuts share one combo, the first one the registry reached used to run. Now the last registration that is enabled at the time of the keypress wins. enabled is checked before precedence, so two registrations with mutually exclusive conditions both keep working. Suite's slides app uses that pattern on seven combos.
A real collision, where two live shortcuts match one keypress, logs one warning per combo in development. It names the hidden shortcut and the active one.
formatShortcutLabel and getActiveShortcuts removed (breaking, loud)
Neither had a consumer in ten apps, and formatShortcutLabel was not used inside the library either. Reading the registry is now internal. KeyboardShortcutsDialog's new default slot passes out the same data.
KeyboardShortcutsModal renamed to KeyboardShortcutsDialog (breaking, loud)
The library calls every modal a dialog. Props are unchanged. The component gains a default slot that carries the grouped shortcuts, and a data-slot on every part.
What to do: rename the import. See the KeyboardShortcutsModal migration.
KeyboardShortcut — one combo vocabulary, and data-slot hooks (breaking, silent)
The root, each key, the + separators and the alternative combos carry a data-slot. The root also carries data-bg when bg is set. Style through those, not through a class prop. The parser also reads the key names a combo uses, so Digit1 renders 1 and Slash renders /.
combo now reads the one grammar that useKeyboardShortcut fires on. The older display-only spellings are gone: a chip for a combo that can never fire is the exact problem this family exists to remove. An unknown token renders as written and warns once in development.
Silent break: combo stays typed string, because callers compute it. So no type-check finds the call sites, and production logs nothing.
| Gone | Write |
|---|---|
Cmd, Command, ⌘, Meta | Mod |
Control | Ctrl |
Option, Opt, ⌥ | Alt |
⇧ | Shift |
Win, Windows | nothing; the grammar has no Windows key |
Esc | Escape |
Return | Enter |
Del | Delete |
Up, Down, Left, Right | ArrowUp, ArrowDown, ArrowLeft, ArrowRight |
= | Equal |
F13 and above | nothing; the grammar stops at F12 |
The root's role is now img when combo is set, and absent otherwise. It was always note before. A labelled img replaces its content for screen readers, so a screen reader reads each key once, instead of once per chip.
useIcons now also applies in bg mode, where it was ignored. :use-icons="false" now drops the arrow, Enter, Backspace and Delete icons in both modes. The default is true, so a chip that never set the prop draws what it drew before.
What to do: rewrite old spellings with the table above. See the KeyboardShortcut migration.
KeyboardShortcut — deprecated shortcut and unused modifier props removed (breaking)
Per ADR-0008, the deprecated shortcut prop (replaced by combo) is removed rather than frozen. It had zero call sites. The meta / ctrl / shift / alt boolean props are also removed: they had no real usage, and combo replaces them. Both are loud breaks (removed props on a typed component).
The component also moved from a bare KeyboardShortcut.vue into its own folder, with a types.ts, a docs page and a Cypress test.
useShortcut — matchesShortcut no longer public (breaking)
matchesShortcut is removed from the frappe-ui package export. Its own doc comment already said "exported for unit tests only". It was never meant to be public API, and it had no real usage. This is a loud break (an import error) for anyone who imported it directly. There is no sign of any real consumer doing so. The composable itself is now useKeyboardShortcut; see the rename entry.
KeyboardShortcutsModal / useShortcut — types, tests and docs
KeyboardShortcutsModal gained a types.ts and a Cypress test (it only had unit tests before). useShortcut gained a short entry on the composables page. Both were renamed later in this release, to KeyboardShortcutsDialog and useKeyboardShortcut. Read the entries at the top of this section for the API that ships.
KeyboardShortcut — + only between two text keys
A plain KeyboardShortcut drew a + between every pair of keys. showPlus now takes 'auto', the new default, which draws one only where two keys drawn as text meet, as in "Ctrl+K". A key drawn as an icon, like ⌘, ⇧, ⌥, an arrow or ↵, needs none, so Mod+K renders "⌘K" on macOS and Shift+F2 renders "⇧F2" everywhere. show-plus still draws every plus, and :show-plus="false" none. bg chips never drew it.
Other components
Tree — expansion moves to a keyed v-model:expanded (breaking, silent)
expanded was a boolean that expanded everything, and each node's open or closed state was the expanded field on the node object, which the component wrote (#1142). A library must not write to the data its caller passes in: that defeats readonly and frozen data, writes through to a store, and a shallow watcher never sees it.
expanded is now TreeKey[], the keys of the open nodes. It is replaced as a whole on every toggle. The per-node expanded field is gone. expand, collapse, toggle, expandAll and collapseAll on the component ref cover what the boolean did. Their type is exported as TreeExposed.
- The default flips: an unbound tree used to open every node under
:expanded="true". It now renders only its root nodes. - Silent break: a node that keeps
expanded: truestill type-checks, becauseTreeNodehas an index signature, and the node stays closed. Development mode warns about the leftover field, and about the removed boolean model.
What to do: bind v-model:expanded to an array of keys, or call expandAll() on the ref. See the migration guide.
Base component contracts for v1 (breaking)
Iconadds the standardiconprop and keepsnamefully supported. Both acceptstring | Component | null.iconwins when both are passed. No migration is needed.Progress.intervalsis now the number of segments, andintervalCountis removed. Leave outintervalsfor a continuous bar. Labels and hints now render independently.Badge.labelaccepts onlystring | number. Put rich content in the default slot.Divider.positionis replaced byalign. An oldpositionvalue is ignored, and the action falls back to the center.DividerActionnow accepts the shared Button action fields.
What to do: the base component migration guide has the codemod and the manual steps.
CommandPalette — removed from the root export, rebuilt in frappe-ui/experimental (breaking, loud)
CommandPalette and CommandPaletteItem leave the root export. The family is rebuilt as seven parts you compose (CommandPalette, CommandPaletteInput, CommandPaletteList, CommandPaletteGroup, CommandPaletteItem, CommandPaletteEmpty and CommandPaletteFooter) in frappe-ui/experimental, which has no stability promise. It stays there until gameplan, helpdesk and this site all run on it.
Four apps copied and changed the old palette instead of using it, because it had one shape and no filtering. The new parts fit all four.
- Breaking, loud:
import { CommandPalette } from 'frappe-ui'fails to resolve. - Breaking: the
groupsprop is gone. Groups and items are markup now, so a group renders whatever it needs, without acomponentescape hatch. - Breaking:
selectcarries the value and the click that picked it. Callevent.preventDefault()to keep the palette open. - Behavior change: the palette filters against the query. The old one rendered
groupsas given and left filtering to the caller.filterable(defaulttrue) turns it off for server search. It is the same wordComboboxandMultiSelectuse (ADR-0009). - Breaking:
Mod+Kmoves to the caller. The old component registered it itself, and skipped it whenever a rich-text editor had focus, which put knowledge of the editor inside the palette. - Every part sets
data-slot. An item setsdata-state="active"anddata-disabled, and passesactiveanddisabledto its slots. - Rows go inside
CommandPaletteList, the only part that scrolls. A list may hold rows and groups and nothing else, so the input, the empty state and the footer sit next to it. @headlessui/vueleavesdependencies. The palette was its last import in the library.
What to do: see the before/after for each break in the migration guide.
Button no longer hands back rootRef (breaking; loud in TS, silent in JS)
Button exposed an untyped, writable template ref to its root element. ADR-0012 does not allow that: a component exposes a template ref only when a parent's script needs it and nothing else can reach it.
- Breaking:
buttonRef.value.rootRefis gone. In TypeScript it fails to compile. In JavaScript it readsundefined, so guard for it. - What it returned was never one thing. Depending on props, it was a
<button>, an<a>, or a vue-router component instance, and you could assign to it. - Nothing replaces it. If you need control from script, ask for it: a typed
focus(options?)can ship in a1.xminor release, and additions are cheap.
Badge — theme="orange" removed (breaking; loud in TS, silent in JS)
orange was a deprecated alias that resolved to amber, so theme="amber" renders exactly what theme="orange" used to. ADR-0008 keeps nothing deprecated in 1.0.0. The alias was never on the removal list, so no earlier survey counted it (found by #1054).
TypeScript call sites fail at vue-tsc, because the theme union no longer accepts the string. JavaScript call sites and bound values render the default gray theme, and log a one-time warning in development that names the component, prop and value. Check bound themes as well as literal attributes. A status-to-theme map that returns 'orange', or an ?? 'orange' default, turns grey, and a search for theme="orange" does not find it.
What to do: replace orange with amber, including in maps and defaults. See the migration guide.
Badge — an unsupported theme, variant or size no longer crashes (fix)
Badge looked up a class map by theme, and then looked up that result by variant. A theme outside the union made the second lookup read a property of undefined, which threw TypeError: Cannot read properties of undefined (reading 'subtle') during render. The badge disappeared and the error took the parent render with it, so one old colour name in a status map could blank a page.
All three props now fall back to their defaults (gray / subtle / md) and warn once per bad value in development. variant and size never threw: their lookups ended early and rendered without tint or size. But they were silently wrong, and now they report themselves too.
The public types are unchanged. theme still accepts only the six supported values, so TypeScript still rejects anything else at compile time. The fallback is a runtime safety net for JavaScript call sites and bound values. It does not widen the API.
Sprite icon trio — moved to frappe-ui/experimental (breaking)
The sprite-based Icon, IconPicker and spritePlugin leave frappe-ui/icons (#904). Apps still use them, so they move to frappe-ui/experimental, which has no stability promise, instead of being deleted. lucide-* classes are the standard way to render icons. The named SFC icons (CircleCheckIcon, HelpIcon, ...) stay on frappe-ui/icons.
- Breaking, loud:
import { Icon, IconPicker, spritePlugin } from 'frappe-ui/icons'fails to resolve. Import them fromfrappe-ui/experimentalinstead. Changing the import path is the whole migration. Apps that spreadcontentfromfrappe-ui/tailwindkeep theIconPickerstyles automatically, with no Tailwind change. frappe-ui/experimentalexportsIcon(sprite), and the rootfrappe-uiexports a differentIcon. If you import both, rename one:import { Icon as SpriteIcon } from 'frappe-ui/experimental'.
See the migration guide.
CircularProgressBar — removed (breaking, loud)
Breaking: CircularProgressBar is no longer exported. The import fails. It was a second component for the same idea as Progress, with hardcoded light-mode colors and a structured theme object prop. One call site existed across all consumer apps.
What to do: use Progress, or copy the old SFC into your app if you need the round form.
FeatherIcon — removed (breaking)
Per ADR-0008, the deprecated FeatherIcon component is deleted, along with the feather-icons dependency. lucide-* strings (or a Component) are now the only supported icon forms.
- Breaking, loud:
import { FeatherIcon } from 'frappe-ui'and<FeatherIcon>fail at the import. - Breaking, silent: every icon-name prop across the library (
Button.icon/iconLeft/iconRight,Dialog.icon,Dropdown/ContextMenuitemicon,TabButtonsoptionsicon/iconLeft/iconRight,Icon.name) used to fall back toFeatherIconfor a bare feather-style name (for example"edit"). That fallback is gone. An unrecognized string now renders nothing, with a console warning in development, once per component and prop.
What to do: add the lucide- prefix to the name.
<!-- before -->
<FeatherIcon name="plus" class="size-4" />
<Button icon="plus" />
<!-- after -->
<span class="lucide-plus size-4" aria-hidden="true" />
<Button icon="lucide-plus" />The core components' own hardcoded FeatherIcon usages were moved to lucide-* in this release.
See the before/after for the silent break in the migration guide.
Card, ListItem, standalone Toast — removed (breaking)
Per ADR-0008, three unmaintained wrappers that shipped @deprecated in code are deleted, not carried forward. All three had zero call sites across the survey of downstream apps.
- Breaking:
Cardand its.vuefile are removed. There is no drop-in replacement. Rebuild the title/subtitle/actions/loading layout with plain markup, and useLoadingTextorSkeletonfor the loading state. - Breaking:
ListItemand its.vuefile are removed. There is no drop-in replacement. Rebuild the title/subtitle/actions row with plain markup. - Breaking: the standalone
ToastSFC (import { Toast } from 'frappe-ui') is removed. This only affects direct use of the rawToastRoot-based component. The imperative API (toast()/toast.success()/toast.error()/toast.info()) and<ToastProvider>are not affected.
All three fail loudly at the import. The migration guide has before/after examples.
LoadingIndicator / LoadingText — moved to component directories (non-breaking)
The same move as FormLabel, for the same reason: both now live at src/components/LoadingIndicator/ and src/components/LoadingText/, instead of as bare .vue files directly under src/components/. Each gains types.ts (LoadingIndicatorProps, LoadingTextProps), stories, a docs page and Cypress tests. The import path (import { LoadingIndicator, LoadingText } from 'frappe-ui') is unchanged.
They stay separate from Spinner and Skeleton. Usage across the surveyed apps shows real, separate demand: LoadingIndicator (~60 files) and LoadingText (~11 files) are both in active use, not duplicates.
LoadingIndicator is now Spinner under its older name (breaking; loud in TS, silent in JS)
This replaces the entry above. LoadingIndicator was a Spinner scaled by a percentage. It is now the same component as Spinner, so it takes Spinner's props, and it has no docs page of its own. Its scale prop is removed: use size or a class instead. See the migration guide.
Icon — docs page and stories added
Icon had no stories/ folder, so it did not appear in the docs site, even though it is a public export. It now has a docs page and two stories (the lucide string form, and the Component form).
Pill unexported, ThemeSwitcher warns in development
Pill is no longer exported from the package entry point. It stays an internal part of TabButtons.
ThemeSwitcher moved to frappe-ui/experimental and stays deprecated there. It logs a one-time deprecation warning in development. For new theme switchers, compose Select with the useColorScheme composable. See the ThemeSwitcher entry.
Divider — action.handler removed (breaking)
Per ADR-0008, action.handler is deleted, not kept with a warning. There were zero call sites across the survey. It is a silent break: a leftover handler is dropped as an unknown key, so the action button renders but does nothing on click. In action mode the divider still reads as a separator for assistive technologies.
What to do: use action.onClick. See the migration guide.
GridLayout — removed (breaking)
- Breaking:
GridLayoutis no longer exported. It was a thin passthrough togrid-layout-plus, with no docs page and no tests. The import fails, so the build names every call site. grid-layout-plusis removed fromdependencies. Nothing else insrc/imported it.- The deleted component had two bugs, so if you now wire up
grid-layout-plusyourself, expect different behavior:colsandrowHeightwere read once at setup inside areactive()options object, notcomputed, so changing either prop after mount did nothing.- The drag placeholder color was a hardcoded
#b1b1b1, not a theme token, so it ignored dark mode.
What to do: depend on grid-layout-plus directly.
Lists
List — select-all follows items that change in place (fix)
<ListRows> updated the set of rows that the header's select-all covers only when items was replaced with a different array. A row pushed in or spliced out, an entry swapped in place, a changed id, or a changed rowKey left select-all working from old values: it checked rows that were gone and missed rows that were there. It now tracks the same identities the rows render with.
Selection itself is still yours to manage: a removed row's value stays in v-model:selection until you drop it.
frappe-ui/list — responsive columns, and the CSS hook contract frozen (breaking)
List.columns now takes a breakpoint object as well as a plain array:
<List
:columns="{
base: ['minmax(0,1fr)', '80px', '64px'],
md: ['minmax(0,1fr)', '140px', '100px'],
lg: ['minmax(0,2fr)', '180px', '120px'],
}"
>base is required and applies from zero width. Every other key names a breakpoint from your own Tailwind screens. It applies from that width up to the next key you supplied, so a breakpoint you leave out keeps the template below it. Each value replaces the whole template. Nothing is merged track by track, so a breakpoint can also change the number of tracks. Changing the number does not hide cells: pair it with matching visibility classes on the header and rows.
The switch is plain CSS, generated from your app's own breakpoint values. So the tracks change together with your md: utilities, and the server-rendered markup is already correct: there is no viewport measurement, no resize listener, and no flash on first paint. Arrays keep working exactly as before. A key can name any screen your config defines, not only a plain width. A { min, max } screen gives a tier that ends where the screen ends, and { max } and { raw } screens work too. Each tier applies in exactly the same places as that screen's own variants.
Responsive columns need the frappe-ui Tailwind preset, because the preset knows your breakpoints. Without it, a breakpoint object uses its base tier at every width.
A key that is not one of your screens is ignored: { base, medium } renders base everywhere, because medium names no breakpoint. Screen names come from your Tailwind config, so ListColumnsByBreakpoint keeps an open index signature, and the type cannot reject the key. A development build now warns instead. The warning names the key and lists the screens it could have been. It is removed from production builds.
The list family's public CSS hooks for v1 are now exactly --list-gap and --list-row-padding-x (with the preset shortcuts list-gap-* and list-row-px-*). The changes you can see from freezing them:
--list-columnsandlist-cols-[…]are gone. Column templates come from thecolumnsprop, which now covers the responsive case the hook existed for. The variable is internal (--_list-columns), and setting it by hand is not supported.- Every
Listowns its columns. A wrapper's--list-columnsused to override a nested list's owncolumnsprop, with[--list-columns:initial]as the way out. Nested lists now always keep their own template, or the default feed template when they set none. - Both remaining hooks work from any ancestor. They resolve through
var()fallbacks where they are used, so one declaration on a wrapper styles every list inside it. - Internal variables are renamed with a
--_listprefix, and are explicitly not API:--list-columns-default→--_list-columns(plus one--_list-columns-<breakpoint>variable per tier and one generated--_list-tier-<screen>),--list-checkbox-width→--_list-checkbox-width,--list-row-height→--_list-row-height. None of them was documented. Anything that targets the old names breaks. Row height is therowHeightprop. Overriding the variable would breakvirtualwindowing, so it is not a hook on purpose, and it stays one number at every width. - Internal variables reset at every
List. A list nested inside another list no longer inherits the outer list'scolumnstemplate, checkbox inset, orrowHeightwhen it does not set those props itself. This was an existing leak, fixed during the freeze. --list-row-padding-xnow reaches static rows too. Before, only interactive rows and the header used the hook, so a static table with a header came out misaligned when it was set. Now one declared value applies to every row and the header. The defaults when it is unset do not change (interactive rows0.75rem, everything else flush).
What to do: replace class="max-sm:list-cols-[auto_minmax(0,1fr)_auto]" with a base tier in the columns prop. Delete any [--list-columns:initial]; they are no longer needed. Stop targeting the renamed internal variables.
The rules behind this, which apply to every future component with CSS hooks, are in ADR-0017.
frappe-ui/list — row state and slot vocabulary (breaking, silent)
ListRowBasenow usesdata-state="active|inactive", plus separate booleandata-selectedanddata-interactiveattributes.ListGroup's#headerslot is renamed to#label.ListHeaderCellSort's#suffixslot is renamed to#sort-indicator. Its edge-aware placement is unchanged.
What to do: run npx list-v1 . for the mechanical selectors and the slots with fixed names. See the migration guide for the manual checks.
ListFilter — removed (breaking)
Breaking, loud: ListFilter is no longer exported, so the import fails. Its internals (SearchComplete, FilterIcon) are gone with it (#992, #999). No consumer app used it.
What to do: build filter UI in your app with Select and Combobox.
ListView family — moved to frappe-ui/experimental (breaking)
ListView is not being brought up to the v1 quality bar at the root for 1.0.0. frappe-ui/list is the recommended building block for new code, but it is a narrower family that you compose, by design. It has no equivalent for ListView's config-driven columns: resizable widths, per-column getLabel/prefix functions, cell tooltips, excluding disabled rows, and the built-in select banner. So instead of freezing all 13 exports at the root without deprecation (12 components: List and ListView are two names for one component), the family moves to frappe-ui/experimental, which has no stability promise. It stays there until frappe-ui/list can do everything it does.
Breaking, loud: import { ListView, ... } from 'frappe-ui' fails to resolve.
What to do: import from frappe-ui/experimental instead: List, ListView, ListEmptyState, ListFooter, ListGroupHeader, ListGroupRows, ListGroups, ListHeader, ListHeaderItem, ListRow, ListRowItem, ListRows, ListSelectBanner.
Editor
The editor's UploadedFile is now UploadedMedia (breaking)
frappe-ui and frappe-ui/editor both exported a type named UploadedFile, and the two shapes did not match. The editor type is now UploadedMedia. The root UploadedFile keeps its name and its shape.
uploadfitsuploadFunction. The rootUploadedFileis now a type alias, not an interface, souseEditor({ uploadFunction: upload })compiles. Before, TypeScript reported TS2322 because the index signature was missing. A bareuploadstores a private file that is attached to nothing, so pass an owner document orprivate: false.UploadedFileno longer merges. A type alias does not support declaration merging, sodeclare module 'frappe-ui'cannot add fields to it. Extend it instead:type MyFile = UploadedFile & { ... }.- Who is affected: code that imports
UploadedFilefromfrappe-ui/editor. TypeScript reports the missing export.
What to do: rename the import to UploadedMedia. See the migration guide.
Editor suggestion roles and fixed-menu size have explicit names (breaking)
Suggestion configuration now calls the rendered mention node nodeView and the suggestion popup listComponent. Both replace the unclear component key. EditorFixedMenu.buttonSize is now size, like the rest of the component library. TypeScript reports the removed names.
What to do: in JavaScript, run the editor-v1 codemod for fixed menus. Migrate suggestion options by hand, because the replacement depends on the component's role. See the migration guide.
Editor kit, upload and menu options are typed (breaking)
Every kit member, the upload handler and the two floating menus now have a named option type. TypeScript reports a misspelled or removed key at the call site. The runtime ignores it, as it always did.
- StarterKit drops
code,codeBlockandlink. The frappeCode,CodeBlockandLinkextensions own those names, so the three keys never reached an extension. Delete them. - A kit's
starterKitleaves outheading. The kit's own top-levelheadingmember overwrites it. Move the value up one level. InlineKit.starterKitaccepts onlyfalseper member. InlineKit registers a stock extension or none, sobold: { HTMLAttributes: … }was read as "keep bold" and its options were dropped. The newInlineStarterKitOptionstype says so.slashCommandshonoursitems.{}keeps the built-in menu,falseremoves it, and{ items }now replaces the built-in list, where it used to be ignored.itemstakes a ref, a getter or a plain array, and is read every time the menu opens.tocandstyleClipboardare off by default inRichTextKit. Both add UI most editors never show: a table-of-contents node and a format painter. Addtoc: {}orstyleClipboard: {}where you use them.imageViewerstays on.- Mention and tag items are
{ label, value }.labelis the text, andvalueis the stored id. Extra fields are yours: the list passes your own object to the item slot untouched, so an avatar or a colour still reaches the template.getMentions()returns the same two fields. UploadedMedia.file_urlis required, and the handler type is exported asUploadFunction. A handler may take a secondMediaUploadRequestOptionsargument to report progress. A handler with one argument still compiles.EditorBubbleMenuandEditorFloatingMenushareEditorMenuOptions.side,align,strategy,offset,flip,shift,hide,inline,scrollTargetandshouldShoware the whole supported set.- The menus position with
sideandalign, the same two axes asPopoverand every overlay built on it, with the samePopoverSideandPopoverAligntypes.placementandEditorMenuPlacementare removed.placement: 'top-start'becomesside: 'top', align: 'start', and a bareplacement: 'bottom'becomesside: 'bottom'. If you set neither, TipTap's own default still applies:topfor the bubble menu andrightfor the floating menu. - The rest of TipTap's Floating UI options are gone. The prop used to take all of them, so
arrow,size,autoPlacement,onShow,onHide,onUpdate,onDestroy, and the object forms of theoffset,flip,shift,hideandinlinemiddleware type-checked and reached Floating UI. They are removed. Replace an object form withtrueto keep the middleware on its defaults, or drop the key:flip: { fallbackPlacements: ['bottom'] }becomesflip: true. Editor.extensionsanduseEditor({ extensions })take TipTap'sExtensions, so a nested array of extensions is accepted.
New exports: UploadFunction, MediaUploadProgress, InlineStarterKitOptions, CommandItem, SlashCommandsOptions, EditorMenuOptions, EditorMenuShouldShowContext.
What to do: the editor migration guide lists the edits per file.
Editor — mentions open after brackets and quotes
Typing @ after an opening bracket or quote ((@jane, [@jane, "@jane) now opens the mention list. TipTap only allowed a space before the trigger, so those sequences never matched. Emails ([email protected]) still do not open it.
SuggestionExtension.configure accepts allowedPrefixes for custom suggestion extensions that need the same behaviour.
Editor — images and embeds resize from a bottom-right corner handle
Selecting an image, video or embed used to show two vertical handles centered on its left and right edges. They sat in the middle of the media, were easy to miss, and only responded to horizontal drags. A selected image or embed now shows one handle in its bottom-right corner, with the diagonal resize icon that every OS window and image editor already uses. It uses the same 28px button as the actions menu in the opposite corner, mirrored across the media.
The drag reads both axes. The pointer's movement is projected onto the diagonal that keeps the aspect ratio, so dragging down grows the media (an edge handle could not do that), and a diagonal drag keeps the corner under the cursor. Media keeps its aspect ratio, as before. Keyboard resize on a focused handle now takes Up/Down as well as Left/Right. The embed's handle responds to the arrow keys for the first time; they used to move the caret out of the node instead.
Videos keep the edge handles. Their playback bar takes the bottom of the frame, so a corner handle there would either sit on the controls or float above them.
No API change: the handles and controls are internal to the media and iframe node views.
Editor — video controls sit on a gradient instead of a floating pill
The playback row was a dark pill set in from the video's edges. It drew a hard rectangle across the picture, and had to stay dark enough for white icons on any frame. It now spans the full width of the video, on a gradient that fades up into the footage, so the contrast is only where the controls are. The gradient follows an eased curve to a lighter peak, instead of a straight two-stop fade, which left a visible edge where the band ended.
The row itself was restyled to look like a player rather than a toolbar: solid play/pause and volume icons, a seek bar the width of the video with a round thumb marking the playhead, and the elapsed/total time moved to the right, next to the volume and fullscreen buttons. The controls used ink-* color tokens, which flip with the theme and dimmed them into the video in dark mode. They now use the fixed white scale in both themes.
The playhead moves with the footage. It used to be driven by timeupdate, which fires about four times a second and moved the thumb along the track in steps. While the video plays, the position is now read once per frame. A tap elsewhere on the track slides the thumb there instead of jumping. A drag follows the pointer directly. This also fixes a scrub that stopped at the first pixel of movement: pressing and moving started a native drag of the node view, and the browser cancelled the pointer.
No API change: the handles and controls are internal to the media and iframe node views.
Editor — media actions live in a single menu
Selecting an image, video or embed used to show a row of six buttons across its top-right corner (caption, three alignments, replace, and on video a playback dropdown) on one 65%-black pill. It covered every selected image with controls, most of them rarely used, and it grew with each new action.
There is now one 28px ⋯ button there instead. It uses the black-overlay-300 fill from the design (espresso-2.0, node 31403-45433), rounded-4, and a full-white 16px icon. It opens a menu that holds the same actions, grouped: a caption switch, Align (left/center/right, with the current one marked), the video-only Playback switches (autoplay, loop, muted), and replace. The resize handle in the opposite corner is the same button, so the media has one control style instead of the raw black/65 and white/50 it used to approximate.
No API change: the handles and controls are internal to the media and iframe node views.
Editor — selected media no longer wears a white halo in dark mode (fix)
The ring around a selected image, video, embed or gallery is drawn with a 2px offset, and Tailwind's default offset color is a hard #fff. On a white page that gap is invisible. In dark mode it was a bright white band around every selected node. The offset now uses --surface-base, the page background in both themes.
No API change: the handles and controls are internal to the media and iframe node views.
Editor — fullscreen video fills the screen (fix)
Fullscreen stretched the container to the viewport, but the video kept its saved pixel size. It sat small at the top of the screen, with the playback bar right under it and a black area filling everything below. The video is now centered and scaled to fit the screen, and the controls run along its bottom. The editing controls (toolbar, resize handles, caption) are not rendered in fullscreen, where you cannot use any of them.
No API change: the handles and controls are internal to the media and iframe node views.
TextEditor and its v0 exports — removed from root (breaking)
Per ADR-0008, the deprecated v0 editor exports are removed from the top-level frappe-ui. These are loud breaks: the import fails to resolve.
TextEditor,TextEditorBubbleMenu,TextEditorFixedMenu,TextEditorFloatingMenu,TextEditorContent,createEditorButtonImageExtension,SetImageOptions,createSuggestionExtension,BaseSuggestionItem,CreateSuggestionExtensionOptions(the twoTextEditor/extensions/*entry files that were also re-exported from the root)
This confirms CONTEXT.md's rule: the editor family is the only one that exports from a subpath instead of the root, and nothing editor-related is exported from the root anymore.
The v0 component files still ship, unchanged, as a safety net while apps move to frappe-ui/editor. They are parked in frappe-ui/experimental (experimental/TextEditor/, #1007), so apps in the middle of migrating keep an import path:
import { TextEditor } from 'frappe-ui/experimental'This path is unstable, with no deprecation period. Sharing the experimental entry costs its other importers nothing in production: #870's rollup measurement shows that unused re-export chains are removed before sideEffects marking applies, so the editor code is tree-shaken out of imports that do not use the editor. Removing the files is a separate cleanup, which a person decides, once every consumer has migrated (spec/editor.md §12). Redesigning the TextEditor public API is out of scope for 1.0.0 and moved to 1.1.
What to do: use Editor and its kits and building blocks from the frappe-ui/editor subpath. See the migration guide's Editor section.
Editor and TextEditor styles — Tailwind v4 theme() call fixed
.ProseMirror ul[data-type='taskList'] input[type='checkbox'] used a theme('colors.gray.900') call that only works in Tailwind v3, in both frappe-ui/editor's and the v0 TextEditor's stylesheet. It broke Tailwind v4 builds (#861, a remaining case of #299). It is replaced with the same var(--ink-gray-9) token the rest of both files already use.
Editor — media captions moved off alt (breaking)
Text in an image's or video's alt no longer renders as a caption. Captions live in a separate caption attribute, saved as data-caption. alt goes back to being only the description for screen readers.
Existing alt values still parse and still round-trip untouched. They just do not show as a caption any more.
The editor no longer edits alt at all. The caption field used to write it. It now writes caption, and no other control took over alt. Set alt from your own content pipeline until an alt field is added.
There is no fallback from caption to alt, on purpose. Stored alt values are mostly upload filenames and emoji shortcodes, and a real caption cannot be told apart from those automatically. Showing all of them is worse than showing none.
What to do: to keep a caption visible, copy the text into caption. A one-off content migration can do that where you know the old alt values were captions.
Code editor
Code editor — a new family at frappe-ui/code-editor (breaking, loud)
Breaking: the
CodeEditorandCodePreviewpair is deleted fromfrappe-ui/experimental, with no deprecation period (experimentalhas no stability promise). A CodeMirror 6 family ships atfrappe-ui/code-editorinstead: theuseCodeEditorengine, a renderlessCodeEditor,CodeEditorContent,CodeKit, thecodeChrome,codeHighlightandcodeKeymapextensions, andloadLanguage.ts// before import { CodeEditor, loadLanguage } from 'frappe-ui/experimental' // after import { CodeEditor, CodeEditorContent, CodeKit } from 'frappe-ui/code-editor'The library ships no labeled field. Each app builds its own thin component on
<CodeEditor>, the way each app already builds its own editor component. The Desk/FormLayout field lives in@framework/ui.Breaking: features come from a required
extensionsarray of raw CodeMirror extensions. Thelanguage,variant,sizeandplaceholderprops are gone, and so are the label, description, error and required props.--cm-max-heightbecomes--code-max-height.Breaking:
CodePreviewleaves frappe-ui. It is a markdown renderer, not a code editor, and it moves to@framework/ui.Breaking: the ten
@codemirror/lang-*packages and@codemirror/lintmove from dependencies to optional peer dependencies. Every app used to download all eleven. Install the ones you render.loadLanguagethrows an error that names the missing package.frappe-ui/vitestubs the language packages an app did not install, so the build no longer fails on a language nobody renders.
What to do: see the code editor docs and the migration guide.
Charts
A series can carry its own number format
SeriesStyle.format prints one series' values in the tooltip and in its data labels. Use it for a series whose unit differs from the others on its axis, such as a margin in percent next to revenue in currency. Without it, the tooltip uses the axis' format and a label prints a compact number, as before. The axis ticks keep the axis' format.
Line and area data labels show without data points
A line or area series with showDataLabels and no showDataPoints drew no labels, because echarts attaches a line's labels to its point symbols. The labels now show, and the points are drawn at zero size. This also applies to a combo chart's line series when showDataLabels is set on the chart.
Charts — a new family at frappe-ui/charts (breaking for beta users)
This is the chart family for v1. It replaces the family that used to sit at the package root, which moved to frappe-ui/experimental (see the next entry). Import the components from frappe-ui/charts, which brings the --chart-* color tokens with it. spec/charts.md states the conventions, and spec/adr/0015-what-enters-charts.md records what the family accepts.
It first shipped in 1.0.0-beta.41. If you used it in a beta, read the Breaking items below.
What the family includes:
- The chart frame parts are exported (
ChartCard,ChartContainer,ChartLegendandChartTooltip), so a plot an app draws itself can look like the rest of the family.ChartCardowns the card surface, andcard: falseturns it off. - Combo series through
seriesConfig[key].type. This also merged the three axis option builders into one. Area fill per series comes with it. - Reference lines on the axis charts and on
ScatterChart. A line outside the data range is clipped, instead of stretching the value axis. stacked: 'normalized'for shares, andmaxSeriesto limit a long grouping column.maxSerieshas no default.ScatterChartandSankeyChart.- Category labels fit themselves: the library measures, tilts and truncates them, instead of taking an angle prop.
xAxis.type: 'value'reads the x column as a quantity: a point sits at its own number instead of in its row's slot, and the rows draw in numeric order. You have to ask for it: a column of numbers still reads as categories by default.- The three states are slots (
#loading,#errorand#empty) onChartContainerand on every chart component. So an app can put a retry button next to a failed query without drawing its own frame.#loadingreplaces the whole placeholder, not a caption under a spinner. - A loading chart draws a skeleton the size of its plot. It used to draw a spinner and the words "Loading chart…". A dashboard fills in one card at a time, and a placeholder that keeps the grid's shape reads better than eight spinners turning out of step.
#loadinglets you replace it. y2names the value column or columns measured against the second value axis. This completes the pairsx/xAxis,y/yAxisandy2/y2Axis. The axis is drawn only wheny2names a column.y2Axisis unchanged.ScatterCharttakesshowDataLabels, which prints each point'slabelnext to it. Labels that collide with a neighbour are dropped.NumberCardtakescolor, the ink the reading is printed in, for a card that stands for a series drawn in that color elsewhere. It does not change the card, the title or the delta tone.dirandlangare followed, not read once. If a page changesdocument.documentElement.dirorlangafter a chart mounts, the chart redraws in the new direction and reprints its numbers in the new locale. A chart that sets its owndiris not affected.- One rule, across the family, for a value that cannot be plotted. A cell that does not read as a number has no mark, so
selectno longer fires for it on Enter, and the keyboard cursor no longer stops on a series that has nothing at that row. A row whose x cannot be read as a date is dropped from atimeaxis, the same way a non-numeric x is already dropped from avalueaxis, and the rows sort by time. The funnel drops a stage whose count is missing, unreadable or negative, instead of drawing it at 0, so its indexes and conversion rates count only the stages that remain. Breaking, silent: a chart that draws any of these now draws something else, with no error. - The empty state is what the plot draws. Bar, line and area now show it when no visible series has a number at any row. They used to draw bare axes. A
ykey that no row carries reads as empty, and so does turning every series off through the legend. Also silent. NumberCardprints throughformatanddeltaFormat, the sameChartValueFormatterevery other chart takes.precisionandcompactare removed: write either one informatinstead.formatprints the value and the target.deltaFormatprints the delta and receives it without a sign. A card with neither prints what it printed before.
Changed since 1.0.0-beta.41, the first beta that shipped the family:
- Breaking, silent: the six mark emits are renamed to
select:datapointClickonAreaChart,BarChartandLineChart,sliceClickonDonutChart,stageClickonFunnelChart,cellClickonHeatmapChart,linkClickonSankeyChartandpointClickonScatterChart. The library names an emit after the behavior, and all six now fire from the keyboard too, so "click" was wrong. A listener on an old name stops firing with no error. See the migration guide. The payload types are unchanged. - Breaking, loud:
ChartThemeis nowChartTokens, anduseChartThemeis nowuseChartTokens, which returns{ tokens }rather than{ theme }.thememeans a color tone everywhere else in the library, and these are the resolved--chart-*values. - Breaking, loud:
formatValue,formatDate,formatLabel,formatPercent,formatAxisValue,currentColorSchemeandresolveChartThemeare no longer exported. They had no documented use, and they are the library's own printing, not utilities to build on. Read the plot-area colors withuseChartTokens, which re-resolves when the theme changes.currentColorSchemewas the internalgetResolvedColorSchemeunder another name.
Settled by the RC API review (#1139) before the frappe-ui/charts entry freezes. Every item is loud in TypeScript unless it says otherwise. The migration guide has the rewrites.
- Breaking: one shape for every
selectpayload.ChartDatapointEvent.dataIndexandFunnelStageEvent.indexare gone; read the row, which every event carries.seriesNameonChartDatapointEventandScatterPointEventis nowname, the field every other payload, item and legend entry already used for an identity.FunnelStageEventgains thenameit never had: the category value, wherelabelis what the column printed. - Breaking, silent:
DonutSliceEvent.nameis now the slice's identity. It was the printed name, and the printed name moves to a newlabel. Both are strings, so a handler that readsnamekeeps running, on a different value:OTHERS_KEYfor the collapsed tail, and"A (2)"for a label that a second row repeats. Search for@selecthandlers onDonutChartthat read.name. - Breaking: one tooltip slot shape. Every
#tooltipslot, andChartTooltipitself, carries{ label, items, rows }.rowis gone.rowsholds one row for a point, cell, band or stage; every grouped row for a donut's "Others" slice; and no rows for a sankey node, which stands for every row through it.FunnelChart'sstageslot prop is gone too: the two conversion rates are items namedofFirstandofPrevious.ChartTooltipItem.kindis required, and the value'column'is now'context': a reading the plot does not draw, which is atooltipColumnsentry or a funnel rate. An item you build by hand needskind: 'series'. - Breaking:
DonutChart's#centerslot passes{ label, value, formattedValue, percent }instead of preformatted strings, the wayChartTooltipItemdoes.valueandpercentare numbers, so your own readout can do its own arithmetic.formattedValueis what the default prints. - Breaking: one name for the data label.
showValuesonHeatmapChartandshowInlineLabelsonDonutChartare both nowshowDataLabels. Axis charts take it at the chart level too, so one prop replaces oneseriesConfigentry per series. - Breaking, silent:
FunnelChart.showPercentagesis removed. A funnel now always prints its conversion rates. It defaulted totrue, so a chart that setfalsegets them back with no error. - Breaking, silent: numbers print in the page's language. Charts read
document.documentElement.lang, the attributediris already read from. A page that declares no language, or a malformed one, prints what it printed before. Dates and time-axis labels still print in English;xAxis.formatprints them in another language. - Breaking:
ChartTokens.splitLineis nowgridline, after the--chart-gridlinevariable it reads.splitLineis still echarts' own option key. - Breaking:
NumberCardSparklineTypeis gone.NumberCardSparkline.typetakes aChartMark. - Breaking, silent: a sparkline's
'line'draws only the stroke. It used to draw a stroke over a fill, which the family calls an area, so that shape is now'area', and it is the default.'line'is still aChartMark, so a card that names it compiles unchanged and draws something else. A card that names no type is not affected. Search forsparklineobjects withtype: 'line'. - Breaking:
OTHERS_LABELis no longer exported, andResolvedColorSchemeis exported from the root only.OTHERS_KEYstays.ReferenceLineLabelPlacementis now exported.ChartExposedis the caller's view of the template ref:chartis typedECharts | undefined, the value Vue passes back, where the type saidComputedRef<ECharts | undefined>.ChartContainer'splotLabel,plotLabelSecondaryandplotLabelPlacementare nowyAxisTitle,y2AxisTitleandaxisTitlePlacement, after theyAxis.titleandy2Axis.titlethat every chart already uses.PlotLabelPlacementis nowAxisTitlePlacement.paletteColorstakes thepaletteprop's own value (a ramp name, a list of colors, or nothing) plus the fallback ramp, so the exported helper is the same resolver the charts call:paletteColors(palette, tokens, count, fallback?). - Breaking: four props that set a look, rather than state a reading, are gone.
SeriesStyle.lineWidthandfillOpacity(at the chart level and per series) are removed: the library draws one stroke weight, and it decides the fill (a fading gradient for a free area, a solid wash for a banded one). The per-seriesechartOptionsreaches both:lineStyle: { width }andareaStyle: { opacity }.SeriesStyle.lineTypeis nowdashed?: boolean, the same shape asReferenceLine.dashed, drawn with the same dash.SankeyChart'sorient="vertical"is nowvertical, likeBarChart'shorizontal, and theSankeyOrienttype is gone. DonutCharttakesv-model:hiddenSlices.AxisChartEmitsandScatterChartEmitsdeclareupdate:hiddenSeries, which both already fired.
Settled after the review, on the same terms:
- Breaking: the column that splits long data into series is
splitBy, notseries, on the axis charts and onScatterChart. "Series" now means only a drawn series, which is whatseriesConfig,hiddenSeriesandmaxSeriesare keyed by. TypeScript reports the removed prop. But a plain template passes it through as an attribute and draws every row as one series, so search forseries=and:series=on the charts after upgrading. - The axis charts take
smooth,showDataPointsanddashedat the chart level, next toshowDataLabelsandconnectNulls, andconnectNullsjoins the four inseriesConfig. One rule covers all five: the chart-level value is the default for every series, and aseriesConfigentry overrides it for one series, on or off. WithsplitBy, the data names the series, so a chart-level value is the only way to reach all of them. - Breaking:
SeriesStyle.axisis removed, replaced by they2column prop above: one mechanism per concept. Series draw, and take palette slots, inyorder and theny2order.splitBysplitsyonly. Ay2column draws as the chart's own mark untilseriesConfig[key].typesays otherwise. - Breaking:
DonutChart's model isv-model:hiddenSlices, and its emit isupdate:hiddenSlices. A donut has no series (maxSlices,DonutSliceandDonutSliceEventall say slice), so the one prop that said series was the odd one out.hiddenSeriesis unchanged on the axis charts and onScatterChart, and so isChartLegend's own API.
useChart, registerChartModules and their three types stay on frappe-ui/charts and freeze there. frappe-ui owns the composable's shape and lifecycle. echarts owns the option and instance types it carries. The four --chart-* properties that are not ramps are documented on the chart colors page, and the echarts template ref on the charts overview.
Charts (v1) family — moved to frappe-ui/experimental (breaking)
The first chart family is not being brought up to the v1 quality bar at the root for 1.0.0 (#942). It moves to frappe-ui/experimental, which has no stability promise, with its public API unchanged, while apps migrate to frappe-ui/charts, which draws everything it did.
- Breaking, loud:
import { AxisChart, ... } from 'frappe-ui'fails to resolve. Apps that spreadcontentfromfrappe-ui/tailwindkeep the styles automatically. - For new code, use
frappe-ui/charts. Its props are flat and name the columns of your rows, so aconfigobject becomes props. See the migration guide.
What to do: import AxisChart, DonutChart, ECharts, FunnelChart, NumberChart and useAxisChartOptions from frappe-ui/experimental. Changing the import path is the whole migration.
Calendar
Calendar is now in frappe-ui/experimental; see the move entry.
Calendar — the Month view is a continuous strip that shows every event
The Month view no longer draws a fixed 5- or 6-row grid that hid whatever did not fit behind an "n more" button. Its week rows grow to fit their events, the view scrolls when they outgrow it, and titles wrap instead of being cut off. Below the sm breakpoint it is the same grid one size smaller, and a cell shows what fits in it, with a +n count for the rest.
- Behavior change:
rangeChangefor theMonthview now reports the grid's full extent, including the padding days of its first and last weeks, rather than the first to the last day of the month. - Days outside the month in view are no longer dimmed. Instead, the first day of each month is labelled with its month (
Sep 1). - Event titles in the Week and Day views are
ink-gray-8, as in the Month view, rather than the text shade of the event's colour. The colour stays on the bar and the background. CalendarActions(theCALENDAR_ACTIONS_KEYinjection) gainssetCalendarDate.- An event with
isDraft: truedraws as a dashed outline in the event's colour, without the colour bar, rather than as a filled pill, in every view. - An event with
isDeclined: truekeeps its fill and bar, but its title is struck through and muted. In the Week and Day views it no longer takes a column of its own, so overlapping events lay out as if it were not there. - Behavior change:
reloadEvents()on the template ref now re-reads theeventsprop and discards edits made in the calendar since then (a drag or resize not yet saved). It used to hand the edited objects back.
Calendar — toDate is honored, so events span the days they cover (breaking, silent)
An event's toDate was ignored: everything rendered on fromDate. The event now shows on every day from fromDate to toDate, inclusive. In the Month view and the Week view's all-day row, it is one bar across those days. A timed event shorter than a day that crosses midnight shows as one piece per day in the time grid. A timed event that ends at 00:00 does not occupy that day.
- Breaking, silent: an event passed with an exclusive end (
toDateset to the day after its last day, the way iCalendar stores all-day spans) now shows one day too many. - Behavior change: dragging keeps an event's length.
updateemitstoDateshifted by the same number of days asfromDate. It used to set both to the drop day. A drag past midnight movestoDate(orfromDate) to the next day with a00:00:00time. It used to stop at24:00:00. - Full-day events (
isFullDay) coverfromDate..toDatewhole. Their times are ignored.
What to do: send the inclusive last day as toDate.
Calendar family — moved to frappe-ui/experimental (breaking)
Calendar is not being brought up to the v1 quality bar at the root for 1.0.0 (#1020, redirect of #989). It moves to frappe-ui/experimental, which has no stability promise, with its public API unchanged, until a redesigned calendar family replaces it.
- Breaking, loud:
import { Calendar, ... } from 'frappe-ui'fails to resolve. Changing the import path is the whole migration. Apps that spreadcontentfromfrappe-ui/tailwindkeep the Calendar styles automatically. - Fix: the default header's month-title button renders again. It broke when DatePicker's
#targetslot became#trigger. The all-day collapse buttons show their chevron icons again.
What to do: import these from frappe-ui/experimental instead: Calendar, CalendarColorMap, CalendarActiveEvent, and the types CalendarActions, CalendarCellClickData, CalendarConfig, CalendarEvent, CalendarMode, CalendarPublicProps, CalendarTimeFormat, GroupedCalendarEvents.
Deprecation log
Each row names an old API, what replaces it, and what happens to code that still uses it. "Silent" means old code still runs and nothing fails. "Loud" means the import, build or call fails.
| API | Replacement | Notes |
|---|---|---|
Divider.action.handler | Divider.action.onClick | Removed, silent: the key is dropped and the click does nothing |
Password.value prop | v-model / modelValue | Removed in 1.0.0 (ADR-0008) |
Rating.rating_from prop | max | Removed, silent: prop ignored |
Rating.readonly prop | disabled | Removed, silent: prop ignored |
Switch.change emit | update:modelValue / v-model | Removed, silent: listener never fires |
Switch.labelClasses prop | data-* styling hooks | Removed, silent: prop ignored |
Checkbox.padding prop | padded | Removed, silent: prop ignored |
Dropdown { group, items } | { group, options } | Removed, silent: renders empty, warning in development only |
Dropdown.placement prop | align | Removed, silent: falls back to align="start" |
Dropdown/ContextMenu component: rows | slots: { item: fn } | Removed, silent: renders a row with only the label, warning in development only |
DropdownExposed type | v-model:open / close slot prop | Removed, loud: it described an exposed member that never existed |
Select #item-* slot prop option | item | Removed, silent: { option } destructures to undefined |
Input.vue | TextInput | Removed in 1.0.0 (ADR-0008) |
Autocomplete | Combobox or MultiSelect | Removed: import fails |
GridLayout | depend on grid-layout-plus directly | Removed, loud: import fails |
FormControl type='autocomplete' | type="combobox", or Combobox on its own | Removed, silent: console.error in development only |
DatePicker family placement | side + align + offset | Removed, silent: unused extra attribute |
DatePicker family autoClose | keepOpen (inverse) | Removed, silent: unused extra attribute |
DatePicker family allowCustom | typeable: false | Removed, silent: unused extra attribute |
DatePicker family readonly | typeable: false | Removed, silent: unused extra attribute |
DatePicker family inputClass | class on the component element | Removed, silent: unused extra attribute |
DatePicker family value prop | v-model / modelValue | Removed, silent: unused extra attribute |
DatePicker family #target slot | #trigger | Removed, silent: slot content stops rendering |
TimePicker.scrollMode | none (always centered) | Removed, silent: unused extra attribute |
DateTimePicker.minDateTime | min | Removed, silent: limit no longer enforced |
DateTimePicker.maxDateTime | max | Removed, silent: limit no longer enforced |
TimePicker.minTime | min | Removed, silent: limit no longer enforced |
TimePicker.maxTime | max | Removed, silent: limit no longer enforced |
TimePicker.selectAll() / .blurInput() | none: unused, no callers | Removed, loud: template-ref member gone |
useDatePicker composable | use the picker components directly | Removed, loud: import fails |
getDate / getDatesAfter / etc. | use the picker components directly | Removed, loud: import fails |
MonthPicker | Select | Removed, loud: import fails |
FeatherIcon | lucide-* strings (or a Component) | Removed: import fails; props with feather names render nothing and warn once in development |
Card | layout markup | Removed in 1.0.0 (ADR-0008), import fails |
ListItem | layout markup | Removed in 1.0.0 (ADR-0008), import fails |
Toast (SFC) | imperative toast(...) API | Removed in 1.0.0 (ADR-0008), import fails |
Dialog legacy options blob | flat top-level props | Removed, silent: unused attribute |
Dialog disableOutsideClickToClose | dismissible (inverted) | Removed, silent: unused attribute |
Dialog #body* slots | #default / #title / #actions | Removed, silent: renders nothing |
Dialog icon.appearance | icon.theme | Removed, silent: icon loses its tone |
Dialog action onClick callable context | { close } object | Removed: throws on call |
Dialog template-ref close() | v-model:open / close slot prop | Removed: throws on call |
ConfirmDialog component | dialog.confirm() / dialog.danger() | Removed: import fails |
confirmDialog() | dialog.confirm() | Removed: import fails |
FileUploader.uploadArgs | flat props (private, folder, doctype, docname, fieldname, uploadEndpoint, optimize) | Removed, silent: unused attribute |
FileUploader template-ref inputRef | openFileSelector slot prop | Removed: throws on call |
FileUploader slot prop error | always string | null, was unknown | Changed, silent: .message access renders nothing |
useFileUpload / FileUploadHandler unset privacy | explicit private | Default changed, silent: now resolves to private |
fileToBase64, formatBytes, getMaxFileSize, fileSizeLimitMessage | none (internal only) | Removed: import fails |
frappe-ui/charts ColorScheme type | root ResolvedColorScheme (import it from frappe-ui) | Removed, loud: type import fails |
Badge theme="orange" | theme="amber" | Removed in 1.0.0 (ADR-0008). Loud in TS (compile error); silent in JS (renders gray, warning in development only) |