@xleddyl/443-components-vue 0.10.2 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/CATALOG.md +195 -180
  2. package/README.md +107 -23
  3. package/dist/components/data/Tag.vue +2 -0
  4. package/dist/components/data/Tag.vue.d.ts +2 -0
  5. package/dist/components/data/TagList.vue +239 -0
  6. package/dist/components/data/TagList.vue.d.ts +53 -0
  7. package/dist/components/data/index.d.ts +1 -0
  8. package/dist/components/data/index.mjs +1 -0
  9. package/dist/components/data/types.d.ts +15 -0
  10. package/dist/components/forms/Combobox.vue +33 -4
  11. package/dist/components/forms/Combobox.vue.d.ts +3 -1
  12. package/dist/components/forms/NumberInput.vue.d.ts +1 -1
  13. package/dist/components/forms/PinInput.vue.d.ts +2 -2
  14. package/dist/components/forms/types.d.ts +2 -0
  15. package/dist/components/layout/AppShell.vue +14 -2
  16. package/dist/components/layout/AppShell.vue.d.ts +3 -1
  17. package/dist/components/layout/Sidebar.vue +8 -3
  18. package/dist/components/layout/Sidebar.vue.d.ts +6 -1
  19. package/dist/components/layout/Stepper.vue +148 -0
  20. package/dist/components/layout/Stepper.vue.d.ts +420 -0
  21. package/dist/components/layout/index.d.ts +1 -0
  22. package/dist/components/layout/index.mjs +1 -0
  23. package/dist/components/layout/types.d.ts +13 -0
  24. package/dist/components/overlays/Toaster.vue +10 -0
  25. package/dist/components/overlays/Toaster.vue.d.ts +1 -1
  26. package/dist/components/templates/DashboardLayout.vue +13 -1
  27. package/dist/components/templates/DashboardLayout.vue.d.ts +7 -2
  28. package/dist/components/templates/SettingsModal.vue +95 -4
  29. package/dist/components/templates/SettingsModal.vue.d.ts +6 -1
  30. package/dist/components/templates/types.d.ts +4 -1
  31. package/dist/composables/data.d.ts +1 -0
  32. package/dist/composables/data.mjs +1 -0
  33. package/dist/composables/layout.d.ts +1 -0
  34. package/dist/composables/layout.mjs +1 -0
  35. package/dist/composables/useRouteState.d.ts +13 -0
  36. package/dist/composables/useRouteState.mjs +37 -0
  37. package/dist/composables/useSortable.d.ts +38 -0
  38. package/dist/composables/useSortable.mjs +215 -0
  39. package/dist/grid/DataGrid.vue +7 -1
  40. package/dist/grid/DataGrid.vue.d.ts +8 -1
  41. package/dist/grid/GridToolbar.vue +57 -2
  42. package/dist/grid/GridToolbar.vue.d.ts +13 -2
  43. package/dist/grid/cells/BadgeCell.vue +90 -18
  44. package/dist/grid/cells/BadgeCell.vue.d.ts +14 -14
  45. package/dist/grid/data-grid.css +57 -0
  46. package/dist/grid/spreadsheet.mjs +8 -4
  47. package/dist/grid/types.d.ts +3 -0
  48. package/dist/icons/builtin.d.ts +2 -0
  49. package/dist/icons/builtin.mjs +2 -0
  50. package/dist/internal/data/sortable.d.ts +5 -0
  51. package/dist/internal/data/sortable.mjs +23 -0
  52. package/dist/internal/layout/query.d.ts +6 -0
  53. package/dist/internal/layout/query.mjs +64 -0
  54. package/dist/internal/layout/route.d.ts +9 -2
  55. package/dist/internal/overlays/avoid.d.ts +26 -0
  56. package/dist/internal/overlays/avoid.mjs +134 -0
  57. package/dist/locale/de.mjs +24 -0
  58. package/dist/locale/en.mjs +24 -0
  59. package/dist/locale/it.mjs +24 -0
  60. package/dist/locale/types.d.ts +24 -0
  61. package/dist/nuxt/module.mjs +14 -0
  62. package/dist/nuxt/runtime/layouts/dashboard.vue +9 -1
  63. package/dist/styles/data/tag-list.css +93 -0
  64. package/dist/styles/data.css +1 -0
  65. package/dist/styles/forced-colors.css +10 -0
  66. package/dist/styles/layout/app-shell.css +25 -6
  67. package/dist/styles/layout/sidebar.css +11 -2
  68. package/dist/styles/layout/stepper.css +244 -0
  69. package/dist/styles/layout.css +1 -0
  70. package/dist/styles/overlays/toast.css +17 -12
  71. package/dist/styles/templates/settings-modal.css +5 -1
  72. package/package.json +1 -1
package/README.md CHANGED
@@ -136,7 +136,7 @@ A few component tokens are unset by default and only exist to be overridden on a
136
136
  | Fields | `--k-field-padding-x` |
137
137
  | Overlays | `--k-modal-radius`, `--k-modal-shadow`, `--k-drawer-radius`, `--k-drawer-shadow`, `--k-popover-radius`, `--k-popover-shadow`, `--k-toast-radius`, `--k-toast-shadow`, `--k-scrim-blur` (3px) |
138
138
  | Tooltip | `--k-tooltip-background`, `--k-tooltip-text`, `--k-tooltip-border`, `--k-tooltip-radius`, `--k-tooltip-shadow`, `--k-tooltip-padding` |
139
- | Shell | `--k-sidebar-width` (15rem), `--k-sidebar-rail-width`, `--k-topbar-height` (3rem), `--k-shell-inset`, `--k-shell-padding`, `--k-shell-content` |
139
+ | Shell | `--k-sidebar-width` (16.5rem), `--k-sidebar-rail-width`, `--k-topbar-height` (3rem), `--k-shell-inset`, `--k-shell-padding`, `--k-shell-content` |
140
140
  | Switch | `--k-switch-track-width`, `--k-switch-track-height`, `--k-switch-thumb-size` (md size) |
141
141
  | Separator | `--k-separator-heading-font-size` (0.9375rem), `--k-separator-heading-color` (`--k-text`), `--k-separator-label-font-size` (0.75rem) |
142
142
  | Calendar | `--k-calendar-day-radius`, `--k-calendar-unavailable-color`, `--k-calendar-unavailable-decoration` (line-through), `--k-calendar-unavailable-opacity` (0.6), `--k-calendar-out-of-range-color`, `--k-calendar-out-of-range-decoration`, `--k-calendar-out-of-range-opacity` (each defaults to its unavailable token) |
@@ -350,15 +350,15 @@ Several controls in one `FormField`. Only the primary control takes the field id
350
350
  | core | Root, Icon, Button, Spinner, Input, Textarea, FormField, FieldMessage, FieldBoundary, Form, FormAlerts, Kbd, VisuallyHidden |
351
351
  | forms | Calendar, Checkbox, CheckboxGroup, ColorInput, Combobox, DateInput, DatePicker, FileInput, InputAddon, InputGroup, NumberInput, PasswordInput, PinInput, Radio, RadioGroup, SegmentedControl, Select, Slider, Switch, TimeInput |
352
352
  | overlays | CommandPalette, ConfirmDialog, ConfirmHost, ContextMenu, Drawer, DropdownMenu, HoverCard, Modal, OverlayHost, Popover, Toaster, Tooltip |
353
- | data | Avatar, AvatarGroup, Badge, DescriptionList, FilterPill, FilterPills, Pagination, SelectionBar, Stat, Table, Tag, Timeline, Tree |
354
- | layout | Accordion, AppShell, Breadcrumbs, Card, CopyButton, Grid, NavLink, PageHeader, Separator, Sidebar, SidebarNav, SidebarSection, Stack, Tabs, ThemeSwitch, Toolbar |
353
+ | data | Avatar, AvatarGroup, Badge, DescriptionList, FilterPill, FilterPills, Pagination, SelectionBar, Stat, Table, Tag, TagList, Timeline, Tree |
354
+ | layout | Accordion, AppShell, Breadcrumbs, Card, CopyButton, Grid, NavLink, PageHeader, Separator, Sidebar, SidebarNav, SidebarSection, Stack, Stepper, Tabs, ThemeSwitch, Toolbar |
355
355
  | templates | DashboardLayout, AuthLayout, DashboardPage, ListPage, DetailPage, FormPage, SettingsPage, SettingsSection, SettingsModal, SettingsGroup, AppearanceSettings, SaveBar, ErrorPage, NotFoundPage, EmptyPage, Brand, AccountMenu |
356
356
  | feedback | Alert, EmptyState, LoadingOverlay, Meter, Progress, ProgressRing, Skeleton |
357
357
  | grid | DataGrid, GridToolbar, cell renderers (BadgeCell, BooleanCell, DateCell, EmptyCell, ImageCell, LinkCell) |
358
358
  | editor | RichTextEditor |
359
359
  | chart | ChartPanel, ChartLegend, LineChart, BarChart, DoughnutChart |
360
360
 
361
- Composables: `useTheme`, `usePreset`, `useForm`, `useField`, `useFieldControl`, `useToast` (and `toast`), `useConfirm`, `useAlert`, `usePrompt`, `useModal`, `useDrawer`, `useOverlay`, `useModalContext`, `useLoading` (and `withLoading`), `useLoadingState`, `useHotkey`, `useCopy`, `useFileDrop` (with `readDroppedFiles`), `usePasswordStrength`, `useTableSort`, `useAppShell`, `useScrollLock`, `useDirtyGuard`, `useIcons` helpers and `useLinkComponent`, `provideFieldBoundary`, `provideAuxiliaryControls`, `provideForm`, `useLocale`. With the Nuxt module also `useDashboard()` (runtime account, breadcrumbs and badges for the `k-dashboard` layout).
361
+ Composables: `useTheme`, `usePreset`, `useForm`, `useField`, `useFieldControl`, `useToast` (and `toast`), `useConfirm`, `useAlert`, `usePrompt`, `useModal`, `useDrawer`, `useOverlay`, `useModalContext`, `useLoading` (and `withLoading`), `useLoadingState`, `useHotkey`, `useCopy`, `useFileDrop` (with `readDroppedFiles`), `useSortable`, `usePasswordStrength`, `useTableSort`, `useAppShell`, `useRouteState`, `useScrollLock`, `useDirtyGuard`, `useIcons` helpers and `useLinkComponent`, `provideFieldBoundary`, `provideAuxiliaryControls`, `provideForm`, `useLocale`. With the Nuxt module also `useDashboard()` (runtime account, breadcrumbs and badges for the `k-dashboard` layout).
362
362
 
363
363
  ### Lists, trees and uploads
364
364
 
@@ -368,12 +368,19 @@ Composables: `useTheme`, `usePreset`, `useForm`, `useField`, `useFieldControl`,
368
368
  - `Input` takes `transform: (value: string) => string`, applied to the typed text before it reaches the model (the caret keeps its place; IME composition is transformed when it ends). `:transform="(value) => value.toUpperCase()"` enforces uppercase codes. Values set from outside are not transformed.
369
369
  - `FormField` takes `optional`, a muted localized "Optional" marker in the label row (`form.optional`, `optionalText` overrides it), the counterpart of the required asterisk. It is hidden when the field is required.
370
370
  - `CheckboxGroup` takes `columns` (a number): the options are laid out in a grid with that many equal columns. Unset keeps the `orientation` layout. `variant="filled"` (`CheckboxGroupVariant`, `'default' | 'filled'`, default `'default'`) puts the options in a subtle filled panel, styled by `--k-checkbox-group-background` (`--k-subtle`), `--k-checkbox-group-padding` (0.75rem), `--k-checkbox-group-radius` (`--k-radius-md`) and `--k-checkbox-group-border` (none).
371
+ - `Combobox` with `creatable` takes `createTransform` (`ComboboxCreateTransform`, `(input) => string | null | undefined`) and `createValidate` (`ComboboxCreateValidate`, `(input) => string | true | undefined`). The transform runs on the typed text first: the "Create" option shows its result, and an empty result (or `null`) shows no create option. The validator gets the transformed text when the user picks the create option: a returned string is the error, shown inline through the surrounding `FormField` (`useFieldControl().setControlError`, or a `FieldMessage` under the control and an announcement without a field) with `aria-invalid` on the input, and nothing is created; `true` or nothing accepts it. The error clears when the query changes. Both work with `multiple`, where created values become tags: `<KCombobox v-model="tags" multiple creatable :create-transform="(input) => input.toLowerCase()" :create-validate="(input) => input.length >= 3 || 'Too short'" />`.
371
372
  - `Combobox` with `multiple` takes `hideTags`: the selected values are not rendered as tags inside the control (the host shows them elsewhere), the placeholder stays visible and Backspace no longer removes the last value. Selection is still announced through the options.
372
373
  - `Pagination` takes `showEdges` (first and last page buttons, `firstLabel`, `lastLabel`) and `showJump` (a typed page number, `jumpLabel`). Both are off by default. `page` is clamped to the page count only when `total` is known: while `total` is `0` or `loading` is set (it also sets `aria-busy`), the page is kept, so a server reload does not reset it.
373
374
  - `FilterPills` can list active filters: options with `removable: true` render as active pills with a remove button (`removeLabel`, a `'Remove {label}'` template). Clicking the pill emits `select(value)`, the button emits `remove(value)` (and drops the value from the model when it is selected); focus moves to the next pill when the removed one disappears.
374
375
  - `Calendar` takes `dayText: (iso) => string | undefined`, a short line under each day number that is also added to the day's accessible name (availability, prices). The `#day` slot (`{ date, number, text, selected, unavailable, outOfRange, inMonth }`) replaces the cell content; it is decorative, so announced text still comes from `dayText`. Days before `min` or after `max` get the `is-out-of-range` class (and `is-unavailable`, like days rejected by `isDateDisabled`), so the `--k-calendar-out-of-range-*` tokens style them apart from disabled days.
375
376
  - `Separator` takes `description` (or `#description`) under its label, and an `#actions` slot rendered next to them (a small add or edit button). `variant="heading"` (`SeparatorVariant`, `'label' | 'heading'`, default `'label'`) shows the label as a section title on its own (an `h3`, `level` changes it) with the description below and the actions at the end, under one line above instead of the small muted label between two lines. `:lines="false"` (default `true`) hides the lines of either variant. `align` (`SeparatorAlign`, `'start' | 'center'`, default `'start'`) centers the heading title and description. `--k-separator-heading-font-size` (0.9375rem) sizes the heading, `--k-separator-heading-color` (default `--k-text`) colors it, and `--k-separator-label-font-size` (0.75rem) sizes the small label of `variant="label"`, with or without lines.
376
377
  - `Modal` takes `width` (any CSS length, overrides `size`) and `minHeight` (keeps the height steady while tabs or steps change). The size widths are tokens: `--k-modal-width-sm` (26rem), `--k-modal-width-md` (32rem), `--k-modal-width-lg` (50rem), `--k-modal-width-xl` (64rem).
378
+ - `Stepper` shows the steps of a wizard: `steps` (`StepperStep[]`: `value`, `label`, `description`, `icon`, `disabled`, `state` `'complete' | 'error'`) and `v-model` for the current value. It renders a `nav` (named by `label`, default "Progress") around an ordered list; the current step has `aria-current="step"`, every step carries a hidden "Step 2 of 4" (`stepper.position`, `positionLabel` overrides it) and its state ("completed", "has errors"). Clickable steps are buttons with one tab stop and arrow keys, Home and End between them. `linear` makes only completed steps (`state: 'complete'` or before the current one) and the current step clickable; `readonly` renders no buttons. `orientation="vertical"` stacks the steps; `size="sm"` is denser. `compact` is `'auto'` by default: below 36rem of container width the labels are visually hidden (still read) and a "Step 2 of 4 Columns" line shows the current step; `true` forces it, `false` turns it off. `select(step)` fires on a click before the model changes.
379
+
380
+ ```vue
381
+ <KStepper v-model="step" :steps="steps" linear label="Import" />
382
+ ```
383
+
377
384
  - `Tabs` items take `dot: true` for a small indicator without a number (`dotTone`, default `danger`; `dotLabel` is the hidden text, default "Needs attention").
378
385
  - `DropdownMenu` takes `matchTriggerWidth` (the menu is at least as wide as its trigger) and `minWidth` (a CSS length).
379
386
  - `PageHeader` takes `align` (`'end'` by default, `'start'` or `'center'`) for the title block and the actions, and `truncate` to keep the title on one line with an ellipsis (the full title is in its `title` attribute).
@@ -384,6 +391,23 @@ Composables: `useTheme`, `usePreset`, `useForm`, `useField`, `useFieldControl`,
384
391
  - `FilterPills` is generic: the value type comes from the options, `multiple` makes the model an array and `required` drops `null` from it (like `SegmentedControl`). Options take `menu` items (a dropdown trigger showing `valueLabel`) or `panel: true`, which opens a popover with the `#panel-<value>` slot (`{ option, active, toggle, close }`, `panelLabel` names it). The `#pill` slot renders any trigger and its scope has `defaultPill`, a component that renders the built-in pill for that option, so one custom pill does not mean rewriting the others: `<component :is="defaultPill" v-else />`. `FilterPill` is exported so custom triggers match the other pills.
385
392
  - `SegmentedControl` with `required` narrows `v-model` to a non null value. Options take `title` (tooltip and description) and `muted`, and the `#option` slot replaces the content of each option.
386
393
  - `FileInput` takes `directory` (folder picker; dropped folders are walked, every file keeps its relative path) or `directory="drop"` (the click opens a file picker, dropped folders are still walked), `directoryAction` (an "Upload a folder" button under the dropzone, `directoryActionLabel` renames it) or the `#actions` slot (`{ browse, browseDirectory, disabled }`), `skipHidden` (ignores dot files and dot folders), `list: false` (no file list: picks and drops only emit `add`), `loading` and the `#hint` slot. `add` receives `(files, { entries: [{ file, path }], directories, source, event, dataTransfer })`; `directories` lists every folder path of a folder pick or drop, empty folders included, and `add` also fires for a drop of empty folders. `invalid` fires per file and `reject` once with every rejected file (`(events, detail)`). `acceptMode="hint"` only passes `accept` to the picker and leaves filtering to the host. The component exposes `browse()`, `browseDirectory()`, `addFiles()` and `focus()`.
394
+ - `TagList` renders `v-model` (`TagListItem[]`: `value`, `label`, `tone`, `icon`, `removable`, `disabled`) as `Tag` chips in a labelled list (`label`, default "Items"). `removable` (or `removable` per item) adds the remove button; the chip is dropped from the model, `remove(item, index)` fires, focus moves to the neighbor and "Label removed" is announced. `sortable` reorders by pointer drag (the list updates live while dragging, Escape cancels), and the chips become one tab stop: the arrow keys, Home and End move between chips, Alt with an arrow key (or Alt with Home and End) moves the focused chip, Delete removes it, and every move is announced ("Name moved to position 2 of 4", `sortable.moved`). `moveButtons` adds "Move earlier" and "Move later" buttons to every chip, a single pointer alternative to dragging. A disabled item cannot move or be removed. `move({ item, from, to })` fires after each move. `accept` (a MIME type, a list of them, or `(event) => boolean`) makes the list a drop target for native drags from outside: an insertion marker follows the pointer and `itemDrop({ index, dataTransfer, event })` (`@item-drop`, named apart from the native `drop` event) gives the insertion index, so the host inserts the item (`dropEffect`, default `copy`, must match the `effectAllowed` of the source). The `#item` slot (`{ item, index }`) replaces the chip label.
395
+
396
+ ```vue
397
+ <KTagList
398
+ v-model="columns"
399
+ label="Export columns"
400
+ sortable
401
+ removable
402
+ accept="application/x-column"
403
+ @item-drop="
404
+ ({ index, dataTransfer }) => insert(dataTransfer?.getData('application/x-column'), index)
405
+ "
406
+ />
407
+ ```
408
+
409
+ - `useSortable(container, options)` is the reorder logic of `TagList` for any list. Options: `items` (a ref or getter), `onMove(items, move)` (`SortableMove`: `{ item, from, to, items, source }`, write `items` back to your state), `orientation` (`'vertical'` by default, `'horizontal'` for wrapping rows), `disabled`, `handle` (a selector: only a pointer down on it starts a drag; without it any pointer down outside buttons, links and inputs does), `canMove(item, index)`, `label(item)` and `movedText(move)` for the announcement. Bind `itemProps(index)` on each item element (the attribute marks it, plus pointer and Alt with arrow key handlers) and render `announcement` in a polite live region. `move(from, to, { announce })` moves from your own buttons, `dragging` is the index being dragged (for a lifted style), `canMove(index)` reports locked items. Pointer drags reorder live and follow the pointer, Escape or a cancelled pointer puts the item back, and the click after a drag is swallowed.
410
+
387
411
  - `useFileDrop` returns `{ over, reset }` and takes `ignore(event)`. A drop handled by a nested drop zone is skipped by the outer ones, and the state resets after any drop, even when a child stops propagation. `readDroppedFiles(dataTransfer)` walks dropped folders (call it synchronously in the drop handler) and resolves to `[{ file, path }]`; `readDroppedFiles(dataTransfer, { directories: true })` resolves to `{ files, directories }` with every folder path, empty ones included, and `skipHidden: true` leaves out dot entries.
388
412
  - `Tabs` items take `badgeTone` and `badgeLabel` (the accessible name of the badge); the `#panel="{ item }"` slot renders a single panel for the active tab. `stretch` makes the tabs fill the remaining height of a flex column and the active panel grow with it (`flex: 1; min-height: 0`), and `inset` spaces the tab list from the container edges with the panel flush below it, for inspectors and side panes. `Breadcrumbs` items take `attrs` and `onClick` (an item without a link renders a button, handy for drop targets), `#item` customizes the content, and `ancestors` links every item and ends with a separator (`breadcrumbsAncestors` on `PageHeader`). `Accordion` takes `variant` `card` (default), `outline` (the same card without a shadow), `separated` or `plain`; items take `description`, and `contentVariant="neutral"` drops the content padding and text styling.
389
413
  - `RichTextEditor` labels heading buttons by their real level (`headingLabel="relative"` numbers them from 1) and `toolbar` picks and orders the buttons: `['headings', '|', 'bold', 'italic', 'link']`.
@@ -428,6 +452,7 @@ const { loading: saving, run } = useLoadingState()
428
452
  await run(() => api.save(form))
429
453
  ```
430
454
 
455
+ - Toasts stay clear of an open Drawer: next to a left or right drawer the stack moves beside the panel (by its width) when there is room for it, otherwise it moves above the drawer footer (or below the header for top positions), so the drawer actions stay visible and the toasts stay clickable. The same footer rule applies to a Modal whose footer sits under the toasts (a sheet or a fullscreen modal).
431
456
  - Toasts stack behind the newest one (up to `max`, default 4) and expand on hover or focus, which also pauses their timers. Each tone has its own icon; `description` takes a string or a list of lines. `dismissible: false` hides the close button, `duration: Infinity` keeps a toast open, `progress` on the toaster shows a timer bar.
432
457
  - `useConfirm()` resolves `true` or `false`, `useAlert()` resolves when closed, `usePrompt()` resolves the string or `null`. `useConfirm()` and `useAlert()` also take `body`, a component rendered under the description (for example a list of the pages that use a file). The body can emit `close` to dismiss the dialog as cancelled, for example when one of its links is clicked. Prompt options: `label`, `placeholder`, `value`, `type`, `required`, `requiredText`, `validate(value)` (return an error message to keep the dialog open), `onConfirm(value)`. Without a host they fall back to the native browser dialogs.
433
458
  - `modal.open(Component, props, options)` returns a promise for the result plus `id`, `close(result)` and `update(props)`. Options: `title`, `description`, `size`, `side` (drawers), `sheet`, `flush`, `dismissible`, `hideClose`, `closeLabel`, `as: 'modal' | 'drawer'`. Inside the component, `useModalContext<R>()` gives `close(result?)` and `setLoading(boolean)`. A component can open another modal on top; Escape and the backdrop resolve `undefined`. `modal.close(id?, result?)` and `modal.closeAll()` close from outside.
@@ -450,9 +475,47 @@ const confirmDiscard = () => !dirty.value || confirm({ title: 'Discard changes?'
450
475
  ```
451
476
 
452
477
  - `#title-trailing` puts badges or a status next to the title without replacing `#header`.
478
+ - `#actions` (`{ close }`) puts buttons in the header, on the right before the close button (an edit or a more menu), without replacing `#header`.
453
479
  - `size="fullscreen"` on a Modal fills the viewport edge to edge (no radius, no outer padding, safe area insets inside the panel) and never turns into a bottom sheet.
454
480
  - Overlays reset the field context: controls inside a Modal, Drawer, CommandPalette, menu, Popover or HoverCard panel do not inherit the id, description or invalid state of a `FormField` that contains the trigger. Call `provideFieldBoundary()` in your own overlay components to do the same.
455
481
 
482
+ ### Deep links
483
+
484
+ `useRouteState(key, options?)` binds a ref to one query parameter of the current URL, so any Drawer or Modal can be opened from a link, survives a reload and closes with the browser back button. It reads the router through `$router` (vue-router or Nuxt, no import of either); without a router it is a plain ref.
485
+
486
+ ```ts
487
+ const familyId = useRouteState('family')
488
+ ```
489
+
490
+ ```vue
491
+ <KButton icon="plus" @click="familyId = 'new'">New family</KButton>
492
+ <KDrawer
493
+ :open="!!familyId"
494
+ :title="familyId === 'new' ? 'New family' : family?.name"
495
+ @update:open="familyId = undefined"
496
+ >
497
+ <FamilyForm :id="familyId === 'new' ? undefined : familyId" />
498
+ </KDrawer>
499
+ ```
500
+
501
+ - Reading: the initial value comes from the URL (on the server too, so SSR renders the same state), and the ref follows every later route change (back, forward, links).
502
+ - Writing: setting the ref navigates with `router.push` (default) or `router.replace` (`history: 'replace'`, for filters or tabs that should not add history entries). The path and the other query parameters stay. `undefined`, `null` and `''` remove the parameter. Several refs changed in the same tick make one navigation. A push that returns to the previous history entry goes back instead (`router.back()`), so opening then closing a drawer leaves the history as it was, and the browser back button and the in-app close button agree.
503
+ - Types: `parse(value)` and `serialize(value)` convert the string (`parse` returning `undefined` means no value). `default` gives the value when the parameter is missing, and setting the default removes the parameter:
504
+
505
+ ```ts
506
+ const page = useRouteState('page', { parse: Number, default: 1, history: 'replace' })
507
+ const archived = useRouteState('archived', {
508
+ parse: (value) => value === '1',
509
+ serialize: (value) => (value ? '1' : undefined),
510
+ default: false,
511
+ })
512
+ ```
513
+
514
+ - Create forms: use a reserved value (`?family=new`) and branch on it, like above.
515
+ - Nested views inside one overlay: keep the path in one parameter (`?settings=users/usr_123`, `?family=fam_1/variants`) and split it on `/` in a computed. One parameter keeps every step in one history entry and avoids two refs racing. Encode ids that may contain `/` with `encodeURIComponent` per segment.
516
+ - Pages that set `definePageMeta({ key: (route) => route.fullPath })` remount on every query change; keep the default page key for pages with deep links.
517
+ - To leave the page from inside an overlay, navigate (a link or `router.push`): the new route has no parameter, so the overlay closes. Do not set the ref to `undefined` and navigate in the same tick: the later navigation cancels the earlier one.
518
+
456
519
  `CommandPalette`: items take typed `data` (`CommandItem<T>`, `CommandGroup<T>`), which reaches the `#item` slot and the `select` event typed; `emptyText` also takes `(query) => string`. Groups take a `description` (shown after the label), the `#group-label` slot (`{ group, label, description, recent }`) replaces the label, and `notice` or the `#after-results` slot (`{ query, count }`) adds a line under the results, for example "Showing the first results".
457
520
 
458
521
  - `Brand`: `logo` takes an image URL, trusted inline SVG markup (`'<svg ...>'`) or a component; the `#logo` slot replaces the boxed mark; `show-name` keeps the name visible next to a logo.
@@ -480,23 +543,25 @@ Ready defaults to start a dashboard project: an app frame, an auth frame and pag
480
543
  </KDashboardLayout>
481
544
  ```
482
545
 
483
- | Prop | Default | Notes |
484
- | ---------------------------------------------------------------------------------- | ------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
485
- | `variant` | `'inset'` | `inset` (canvas sidebar, inset content panel), `sidebar` (bordered sidebar, full bleed content), `floating` (raised sidebar card on the canvas). |
486
- | `navigation` | | `NavItem[]` or `NavSection[]` (`{ label?, items }`). Items: `label`, `to`/`href`, `icon`, `badge`, `exact`, `active`, `external`, `disabled`, `children`. |
487
- | `currentPath` | router path | Active state is computed from the router (`$route`) with prefix matching (`/` and `exact` items match exactly). |
488
- | `brandName`, `brandSubtitle`, `brandIcon`, `brandLogo`, `brandShowName`, `brandTo` | `brandTo: '/'` | `brandLogo` takes an image URL, inline SVG markup or a component; the name is hidden next to a logo unless `brandShowName`. Or the `#brand` slot. |
489
- | `account` | | `{ name, email?, avatar?, role?, menu? }`. Shown as a row (avatar, name, email, chevron) at the bottom of the sidebar. |
490
- | `accountAction` | `'settings'` | `settings`: the row opens a `SettingsModal` (`settingsSections`, `settingsPresets`, `v-model:settings-open`, `v-model:settings-section`, `@sign-out`). `menu`: a DropdownMenu with `account.menu`, selections emit `account-select`. Or replace it with the `#account` slot. |
491
- | `breadcrumbs` | | Rendered in the desktop topbar. `#topbar` replaces them, `#actions` adds buttons on the right. |
492
- | `search` | `false` | `true` adds a CommandPalette with every navigation item, an array adds your own command groups, an async function `(query, signal) => CommandGroup<T>[]` searches on your server (item `data` reaches `#search-item` typed) (matching navigation items come first unless `searchNavigation` is `false`). `#search` replaces it. |
493
- | `searchProps` | | Options for the built-in CommandPalette: `minQueryLength`, `debounce`, `placeholder`, `label`, `emptyText` (a string or `(query) => string`), `minQueryText`, `errorText`, `loadingText`, `resultsText`, `notice`, `recentKey`, footer labels. Slots `#search-item`, `#search-empty`, `#search-group-label` and `#search-after-results` reach the palette. |
494
- | `themeSwitch` | `false` | `'sidebar'`, `'topbar'` or `false`. The default keeps the theme choice only in the Appearance section of the settings modal. With `accountAction="menu"` there is no settings modal, so set `'sidebar'` or `'topbar'`. |
495
- | `collapsible`, `v-model:collapsed`, `storageKey` | `true`, `'k-sidebar-collapsed'` | Icon rail on desktop. Persisted in a cookie under Nuxt (rendered on the server, no flash) or `localStorage` elsewhere. `false` disables persistence. |
496
- | `shortcut` | `'mod+b'` | Toggles the rail on desktop and the drawer on mobile. `searchShortcut` defaults to `mod+k`. |
497
- | `v-model:open` | `false` | Mobile drawer (below 768px of shell width): scrim, focus moves into the drawer, the content is `inert`, Escape and the scrim close it and focus returns to the burger. |
498
- | `fluid`, `viewTransition` | `false`, `true` | Full width content; `view-transition-name` on sidebar and panel so page transitions keep the frame still. |
499
- | `fill` | `false` | Full height, full bleed content: no padding, gap or max width, and the content fills the panel (a page that is one DataGrid with `height="100%"`). Also on `AppShell`; in the `k-dashboard` layout per page with `definePageMeta({ fill: true })` or for every page with `dashboard.fill`. |
546
+ | Prop | Default | Notes |
547
+ | ---------------------------------------------------------------------------------- | ------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
548
+ | `variant` | `'inset'` | `inset` (canvas sidebar, inset content panel), `sidebar` (bordered sidebar, full bleed content), `floating` (raised sidebar card on the canvas). |
549
+ | `navigation` | | `NavItem[]` or `NavSection[]` (`{ label?, items }`). Items: `label`, `to`/`href`, `icon`, `badge`, `exact`, `active`, `external`, `disabled`, `children`. |
550
+ | `currentPath` | router path | Active state is computed from the router (`$route`) with prefix matching (`/` and `exact` items match exactly). |
551
+ | `brandName`, `brandSubtitle`, `brandIcon`, `brandLogo`, `brandShowName`, `brandTo` | `brandTo: '/'` | `brandLogo` takes an image URL, inline SVG markup or a component; the name is hidden next to a logo unless `brandShowName`. Or the `#brand` slot. `brandAlign` (`'start'` or `'center'`) centers the brand in the expanded sidebar, with the collapse button on the right. The collapse button uses the icons `sidebar-collapse` and `sidebar-expand` (chevrons by default), so `k.icons.aliases: { 'sidebar-collapse': 'lucide:panel-left-close', 'sidebar-expand': 'lucide:panel-left-open' }` changes only that button. |
552
+ | `account` | | `{ name, email?, avatar?, role?, menu? }`. Shown as a row (avatar, name, email, chevron) at the bottom of the sidebar. |
553
+ | `accountAction` | `'settings'` | `settings`: the row opens a `SettingsModal` (`settingsSections`, `settingsPresets`, `v-model:settings-open`, `v-model:settings-section`, `@sign-out`). `menu`: a DropdownMenu with `account.menu`, selections emit `account-select`. Or replace it with the `#account` slot. |
554
+ | `settingsRoute`, `settingsResolvePage` | `false` | Sync the settings modal with the URL (`?settings=users/usr_123`): `true` uses the key `settings`, a string sets the key. `settingsResolvePage(section, id, parents)` gives the label of a subpage restored from the URL. See SettingsModal. |
555
+ | `breadcrumbs` | | Rendered in the desktop topbar. `#topbar` replaces them, `#actions` adds buttons on the right. |
556
+ | `search` | `false` | `true` adds a CommandPalette with every navigation item, an array adds your own command groups, an async function `(query, signal) => CommandGroup<T>[]` searches on your server (item `data` reaches `#search-item` typed) (matching navigation items come first unless `searchNavigation` is `false`). `#search` replaces it. |
557
+ | `searchProps` | | Options for the built-in CommandPalette: `minQueryLength`, `debounce`, `placeholder`, `label`, `emptyText` (a string or `(query) => string`), `minQueryText`, `errorText`, `loadingText`, `resultsText`, `notice`, `recentKey`, footer labels. Slots `#search-item`, `#search-empty`, `#search-group-label` and `#search-after-results` reach the palette. |
558
+ | `themeSwitch` | `false` | `'sidebar'`, `'topbar'` or `false`. The default keeps the theme choice only in the Appearance section of the settings modal. With `accountAction="menu"` there is no settings modal, so set `'sidebar'` or `'topbar'`. |
559
+ | `collapsible`, `v-model:collapsed`, `storageKey` | `true`, `'k-sidebar-collapsed'` | Icon rail on desktop. Persisted in a cookie under Nuxt (rendered on the server, no flash) or `localStorage` elsewhere. `false` disables persistence. |
560
+ | `shortcut` | `'mod+b'` | Toggles the rail on desktop and the drawer on mobile. `searchShortcut` defaults to `mod+k`. |
561
+ | `v-model:open` | `false` | Mobile drawer (below 768px of shell width): scrim, focus moves into the drawer, the content is `inert`, Escape and the scrim close it and focus returns to the burger. |
562
+ | `fluid`, `viewTransition` | `false`, `true` | Full width content; `view-transition-name` on sidebar and panel so page transitions keep the frame still. |
563
+ | `fill` | `false` | Full height, full bleed content (the same as `content="bleed"`): no padding, gap or max width, and the content fills the panel (a page that is one DataGrid with `height="100%"`). Also on `AppShell`; in the `k-dashboard` layout per page with `definePageMeta({ fill: true })` or for every page with `dashboard.fill`. |
564
+ | `content` | `'page'` | Content layout (`ShellContent`). `page`: max width (`--k-shell-content`, 72rem), padding, the panel scrolls. `fill`: full width and full height with the normal padding and gap; the panel never scrolls and the content is a flex column with `min-height: 0`, so a child with `flex: 1` (a `DataGrid` with `fill`, `Tabs` with `stretch`, a `.k-page` root) takes the rest of the height. `centered`: the same full height, with the width kept at `--k-shell-content` and centered. `bleed`: full width and height without padding or gap. `fill` (boolean) is an alias of `bleed` and `fluid` removes the max width of `page`; `content` wins over both. Also on `AppShell`; in the `k-dashboard` layout per page with `definePageMeta({ content: 'fill' })` (typed) or for every page with `dashboard.content` in the app config. |
500
565
 
501
566
  Slots: `brand`, `search`, `sidebar` (replaces the navigation), `sidebar-footer`, `account`, `topbar-brand`, `topbar`, `actions`, default, plus `settings-section-<id>`, `settings-actions-<id>` and `settings-account-fields`, forwarded to the SettingsModal. The exposed `openSettings(id?)` opens it on a section. Landmarks: skip link, `aside` and `nav` with labels, `main` with an id, `aria-current="page"` on the active link. `SidebarNav` (layout group) renders the same navigation data inside your own `Sidebar`.
502
567
 
@@ -533,7 +598,9 @@ A large dialog (60rem) with a section nav on the left and the content on the rig
533
598
  - Built-in sections, used when no `#section-<id>` slot fills them: `account` (profile card, your `#account-fields`, a sign out button) and `appearance` (`AppearanceSettings`: theme cards light, dark, system and preset cards; `presets` limits or hides them).
534
599
  - `@sign-out` adds a Sign out item at the bottom of the nav and in the account section.
535
600
  - Labels: `title` (accessible name of the dialog, default `Settings`), `closeLabel`, `navLabel`, `signOutLabel`, `accountLabel`, `accountText`, `appearanceLabel`, `appearanceText`, `sessionLabel`, `sessionText`, `themeLabel`, `themeText`, `presetLabel`, `presetText`.
601
+ - Header actions: `#actions-<id>` renders buttons on the right of the section header row (an "Add user" button), next to the title instead of on a row of its own in the body. It stays there when a subpage is open, so render different actions per page from its `page` prop. When the content is too narrow, the actions wrap under the title. On `DashboardLayout` the slot is `#settings-actions-<id>`.
536
602
  - Subpages: `#section-<id>` and `#actions-<id>` get `page`, `pages`, `openPage({ id, label })` and `back()`. With a page open, the header title becomes the path (`Members > Grace Hopper`, same size, only the last part in the text color, earlier parts are buttons that go back to them), the section description stays, and a Back button (`backLabel`, default `common.back`) shows on the right before `#actions-<id>`. `v-model:pages` binds the stack (it also works unbound). Changing section clears it. Render the detail when `page` is set, the list otherwise.
603
+ - Deep links: `route` (`true` for the key `settings`, or your own query key, default `false`) syncs the open state, the section and the subpage stack with the URL: `?settings=users` for a section, `?settings=users/usr_123` for a subpage, deeper pages add segments. Opening, switching section, opening a subpage, Back and the crumbs push history entries, so the browser back button steps back through them and finally closes the dialog; closing removes the parameter. Loading a URL with the parameter opens the dialog at that section and subpage after mount. A section id that is not selectable falls back to the first section and the URL is corrected with `replace`. The URL holds only page ids, so `resolvePage(section, id, parents)` returns the label of a page restored from the URL: a string, `undefined` (the page and the ones after it are dropped, the URL is corrected) or a promise of either (the page opens at once with an empty label, then the label fills in). Without `resolvePage` the label is the id. Pages opened with `openPage` keep their own label. Link sections (`to`) do not close the dialog themselves when the route is synced: the navigation drops the parameter and closes it. On `DashboardLayout` the props are `settingsRoute` and `settingsResolvePage`; in the `k-dashboard` layout `settings.route` in `app.config`.
537
604
  - `hide-session` hides the Session group (sign out) in the account section; the Sign out item in the nav stays. `beforeClose` works as on Modal.
538
605
  - Accessibility: native modal `<dialog>` (focus trap, Escape, focus returns to the trigger), the nav is a labelled `nav` of buttons with `aria-current` and `aria-controls`, the content is a `section` labelled by its heading.
539
606
 
@@ -635,7 +702,7 @@ export default defineAppConfig({
635
702
  })
636
703
  ```
637
704
 
638
- The account row opens the SettingsModal (`account.action: 'menu'` with `account.menu` for a dropdown instead). Sections from `app.config` can be built-ins or links (`to`); `settings.presets` limits the preset cards, `settings.signOutTo` adds Sign out.
705
+ The account row opens the SettingsModal (`account.action: 'menu'` with `account.menu` for a dropdown instead). Sections from `app.config` can be built-ins or links (`to`); `settings.presets` limits the preset cards, `settings.signOutTo` adds Sign out, `settings.route` (`true` or a query key) syncs the modal with the URL (see SettingsModal, deep links).
639
706
 
640
707
  Pages pick them with `definePageMeta({ layout: 'k-dashboard', title: 'Items' })` or `layout: 'k-auth'` (the auth card shows `title` and `description` from the page meta). Both layouts set the document title from `meta.title` and the brand name (`Items · Acme`; a page's own `useHead({ title })` wins) and announce route changes through a polite live region, unless the app already renders `<NuxtRouteAnnouncer />`. Breadcrumbs come from `meta.breadcrumbs`, otherwise from the matching navigation item (plus `meta.title` on deeper pages). At runtime `useDashboard().value = { account, breadcrumbs, badges: { '/items': 12 } }` overrides the account, the breadcrumbs and the badges. Set it in route middleware (`definePageMeta({ middleware })`) or a plugin, not in the page `setup`: the layout renders the sidebar and breadcrumbs before the page, so a value set during the page render would differ between the server HTML and hydration.
641
708
 
@@ -682,6 +749,23 @@ Accessibility related props and defaults:
682
749
  - `DataGrid` sort is a model: `v-model:sort` (`{ column, direction } | null`) sets the sorted column from outside (server side sort, a sort restored from the URL) and follows header clicks. Leave it unset for an uncontrolled grid. `sortChange` still fires with `serverSort`.
683
750
  - `DataGrid` takes `rowHeight` and `headerHeight` (pixels) for an exact row size on top of `density`, and `headerText: (field, column) => string | undefined` to name every column that has a `field` and no `headerName` (from your i18n).
684
751
  - `DataGrid` `filterable` registers the AG Grid text, number and date filters (`registerGridFilterModules()` does it without the prop) and turns `filter` on for every column. `filterChange(model, event)` fires with `api.getFilterModel()` whenever a column filter changes, also when the filters come from `gridOptions` or your own registered modules.
752
+ - `DataGrid` `#filters` slot: put your own filter controls (Select, Combobox, DateInput bound with `v-model`) in it and the toolbar shows a square funnel button right after the search input (at the start with `:searchable="false"`), before the `actions`. The button opens a `Modal` titled "Filters" that hosts the slot, so the filters still apply live; its footer has "Reset filters" (emits `filtersReset`, disabled while `filterCount` is 0) and "Show results" (closes it). `filterCount` shows the number of active filters as a badge on the button and in its accessible name ("Filters, 2 active", `grid.filtersActive`). Slot props: `{ close, count }`. Focus returns to the button on close, and the modal becomes a bottom sheet on small screens. `GridToolbar` takes the same slot, prop and event.
753
+
754
+ ```vue
755
+ <KDataGrid
756
+ :rows="rows"
757
+ :columns="columns"
758
+ toolbar
759
+ :filter-count="[status, owner].filter(Boolean).length"
760
+ @filters-reset="status = owner = null"
761
+ >
762
+ <template #filters>
763
+ <KFormField label="Status"><KSelect v-model="status" :options="statuses" /></KFormField>
764
+ <KFormField label="Owner"><KSelect v-model="owner" :options="owners" /></KFormField>
765
+ </template>
766
+ </KDataGrid>
767
+ ```
768
+
685
769
  - `DateCell` takes `relative: true` (or `Intl.RelativeTimeFormatOptions`) in `cellRendererParams` for "3 days ago"; the absolute date stays in the `title`. `relativeWithin` (milliseconds, default unset: always relative) shows the absolute date instead once the value is further than that from now, in the past or the future (`relativeWithin: 7 * 86_400_000` keeps "3 days ago" and prints older dates in full).
686
770
  - `DataGrid` takes `gridOptions` for any AG Grid option it does not wrap (`rowHeight`, `onCellEditingStopped`, `onFilterChanged`, ...). A prop of `DataGrid` wins only when it is set: `getRowId`, `rowSelection` (when `selectable` is off), `autoSizeStrategy` (also `undefined` to turn it off), `tooltipShowDelay`, `overlayNoRowsTemplate` and `overlayLoadingTemplate` from `gridOptions` are used otherwise; `defaultColDef` and `localeText` are merged over the component defaults, and `context` is merged under the cell context. Handlers from both run.
687
771
  - `DataGrid` row ids: `getRowId: (params) => string` (wins), `rowIdKey` (a field of the row), or `gridOptions.getRowId`.
@@ -689,7 +773,7 @@ Accessibility related props and defaults:
689
773
  - `DataGrid` `showEdges` and `showJump` reach the footer pager (first and last page buttons, page number input). `loading` also keeps the pager from resetting the page while a server total is unknown.
690
774
  - `DataGrid` registers the AG Grid cell editors (text, large text, number, date, checkbox, select, custom and undo/redo) when `editable` is set or when a column or `gridOptions.defaultColDef` has `editable` at mount. `registerGridEditorModules()` from `/grid` does it by hand, before the first grid mounts, for columns that become editable later.
691
775
  - `ImageCell` takes `fit` (`'cover'` default, or `'contain'` for logos) and `size` (any CSS length, default `1.75rem`) in `cellRendererParams`. `EmptyCell` (the hidden "Empty" dash) is exported from `/grid` for custom renderers.
692
- - `BadgeCell` takes `variant` (`BadgeVariant`, `'soft' | 'solid' | 'outline'`) in `cellRendererParams`: set, the cell renders a `KBadge` with that variant and the resolved tone (`size`, `BadgeSize`, default `'sm'`); unset keeps the compact grid pill. `badgeClass` (`string | ((value, data) => string | undefined)`) adds host classes to the badge, to set the per instance badge tokens (`--k-badge-background`, `--k-badge-color`, `--k-badge-border-color`, which the grid pill also reads) for values outside `Tone`. `BadgeCellParams<T>` types the row of `data`.
776
+ - `BadgeCell` takes `variant` (`BadgeVariant`, `'soft' | 'solid' | 'outline'`) in `cellRendererParams`: set, the cell renders a `KBadge` with that variant and the resolved tone (`size`, `BadgeSize`, default `'sm'`); unset keeps the compact grid pill. `badgeClass` (`string | ((value, data) => string | undefined)`) adds host classes to the badge, to set the per instance badge tokens (`--k-badge-background`, `--k-badge-color`, `--k-badge-border-color`, which the grid pill also reads) for values outside `Tone`. `BadgeCellParams<T>` types the row of `data`. When the value is an array, the cell renders one badge per item (each item resolves its own `tones`, `labels` and `badgeClass`), on one line clipped to the cell, with the full list in the `title` attribute. `max` (a number) shows the first items and a `+N` badge for the rest (announced as "N more", `grid.moreValues`); `wrap: true` lets the badges wrap onto more lines (pair it with `autoHeight` on the column). Give an array column a `valueFormatter` (`(params) => params.value.join(', ')`): AG Grid infers an object type for arrays and uses the formatter for export, copy and the quick filter. Single values render as before.
693
777
  - `DataGrid` `spreadsheet` turns on Excel like editing on AG Grid Community (no Enterprise modules): it registers the editor modules and the AG Grid `ScrollApiModule` (`registerGridSpreadsheetModules()` from `/grid` does it by hand) and leaves the normal grid unchanged when it is off. Set `rowIdKey` or `getRowId`: changes, dirty cells and undo refer to row ids, never to indexes.
694
778
  - Selection: click selects a cell; drag, Shift+click and Shift+arrows extend a rectangular range (Shift+Cmd/Ctrl+arrow to the edge); Cmd/Ctrl+A selects every cell. The range follows the displayed row order (after sort, filter and quick search) and the displayed column order; the row selection checkbox column is not part of it. Escape collapses the range to the active cell.
695
779
  - Keyboard: arrows and Tab move the active cell, Enter, F2 or typing start the AG Grid editor, Escape cancels it. While an editor (or any input) has focus no shortcut fires.
@@ -19,6 +19,7 @@
19
19
  :aria-label="removeText"
20
20
  :title="removeText"
21
21
  :disabled="disabled"
22
+ :tabindex="removeTabindex"
22
23
  @click="onRemove"
23
24
  @keydown="onKeydown"
24
25
  >
@@ -40,6 +41,7 @@ const props = defineProps({
40
41
  icon: { type: null, required: false },
41
42
  removable: { type: Boolean, required: false },
42
43
  removeLabel: { type: String, required: false },
44
+ removeTabindex: { type: Number, required: false },
43
45
  disabled: { type: Boolean, required: false }
44
46
  });
45
47
  const { t, locale } = useLocale();
@@ -12,6 +12,7 @@ declare const __VLS_base: import("vue").DefineComponent<{
12
12
  icon?: IconName;
13
13
  removable?: boolean;
14
14
  removeLabel?: string;
15
+ removeTabindex?: number;
15
16
  disabled?: boolean;
16
17
  key?: PropertyKey | undefined;
17
18
  prefix?: string | undefined | undefined;
@@ -214,6 +215,7 @@ declare const __VLS_base: import("vue").DefineComponent<{
214
215
  icon?: IconName;
215
216
  removable?: boolean;
216
217
  removeLabel?: string;
218
+ removeTabindex?: number;
217
219
  disabled?: boolean;
218
220
  key?: PropertyKey | undefined;
219
221
  prefix?: string | undefined | undefined;
@@ -0,0 +1,239 @@
1
+ <template>
2
+ <div
3
+ ref="root"
4
+ class="k-tag-list"
5
+ :class="{
6
+ 'is-sortable': canSort,
7
+ 'is-dragging': sorter.dragging.value !== null,
8
+ 'is-drop-target': dropIndex !== null,
9
+ 'is-empty': !items.length,
10
+ 'is-disabled': disabled
11
+ }"
12
+ @dragenter="onDragEnter"
13
+ @dragover="onDragOver"
14
+ @dragleave="onDragLeave"
15
+ @drop="onDrop"
16
+ >
17
+ <ul
18
+ v-if="items.length"
19
+ ref="list"
20
+ class="k-tag-list-items"
21
+ :aria-label="labelText"
22
+ :aria-describedby="hintText ? hintId : void 0"
23
+ >
24
+ <li
25
+ v-for="(item, index) in items"
26
+ :key="item.value"
27
+ class="k-tag-list-item"
28
+ :class="{
29
+ 'is-moving': sorter.dragging.value === index,
30
+ 'is-locked': canSort && !sorter.canMove(index)
31
+ }"
32
+ :data-drop="dropIndex === index ? 'before' : void 0"
33
+ :tabindex="canSort ? index === focusIndex ? 0 : -1 : void 0"
34
+ v-bind="canSort ? sorter.itemProps(index) : void 0"
35
+ @focus="focusIndex = index"
36
+ @keydown="onItemKeydown($event, index)"
37
+ >
38
+ <Tag
39
+ :label="item.label"
40
+ :tone="item.tone ?? tone"
41
+ :icon="item.icon"
42
+ :size="size"
43
+ :removable="isRemovable(item)"
44
+ :remove-label="removeLabel"
45
+ :remove-tabindex="canSort ? -1 : void 0"
46
+ :disabled="disabled || item.disabled"
47
+ @remove="remove(index)"
48
+ >
49
+ <slot name="item" :item="item" :index="index">{{ item.label }}</slot>
50
+ </Tag>
51
+ <span v-if="canSort && moveButtons" class="k-tag-list-move">
52
+ <Button
53
+ variant="ghost"
54
+ tone="neutral"
55
+ size="xs"
56
+ icon="chevron-left"
57
+ tabindex="-1"
58
+ :aria-label="t('tagList.moveEarlier', { label: item.label })"
59
+ :title="t('tagList.moveEarlier', { label: item.label })"
60
+ :disabled="index === 0 || !sorter.canMove(index)"
61
+ @click="moveBy(index, -1)"
62
+ />
63
+ <Button
64
+ variant="ghost"
65
+ tone="neutral"
66
+ size="xs"
67
+ icon="chevron-right"
68
+ tabindex="-1"
69
+ :aria-label="t('tagList.moveLater', { label: item.label })"
70
+ :title="t('tagList.moveLater', { label: item.label })"
71
+ :disabled="index === items.length - 1 || !sorter.canMove(index)"
72
+ @click="moveBy(index, 1)"
73
+ />
74
+ </span>
75
+ </li>
76
+ <li
77
+ v-if="dropIndex !== null && dropIndex >= items.length"
78
+ class="k-tag-list-end"
79
+ data-drop="before"
80
+ aria-hidden="true"
81
+ />
82
+ </ul>
83
+ <p v-else class="k-tag-list-empty">
84
+ <slot name="empty">{{ emptyLabel }}</slot>
85
+ </p>
86
+ <span v-if="hintText" :id="hintId" class="k-visually-hidden">{{ hintText }}</span>
87
+ <LiveRegion :message="liveText" />
88
+ </div>
89
+ </template>
90
+
91
+ <script setup>
92
+ import { computed, nextTick, ref, useId, useTemplateRef, watch } from "vue";
93
+ import { useLocale } from "../../composables/useLocale";
94
+ import { useSortable } from "../../composables/useSortable";
95
+ import LiveRegion from "../../internal/core/LiveRegion.vue";
96
+ import { useLiveMessage } from "../../internal/core/live";
97
+ import { focusStable } from "../../internal/core/focus";
98
+ import { rovingIndex } from "../../internal/core/roving";
99
+ import { insertionIndex, isRtl, sortableElements } from "../../internal/data/sortable";
100
+ import Button from "../core/Button.vue";
101
+ import Tag from "./Tag.vue";
102
+ const props = defineProps({
103
+ label: { type: String, required: false },
104
+ sortable: { type: Boolean, required: false },
105
+ removable: { type: Boolean, required: false },
106
+ moveButtons: { type: Boolean, required: false },
107
+ accept: { type: [String, Array, Function], required: false },
108
+ dropEffect: { type: String, required: false, default: "copy" },
109
+ tone: { type: String, required: false, default: "neutral" },
110
+ size: { type: String, required: false, default: "md" },
111
+ disabled: { type: Boolean, required: false },
112
+ emptyText: { type: String, required: false },
113
+ removeLabel: { type: String, required: false }
114
+ });
115
+ const model = defineModel({ type: Array, ...{ default: () => [] } });
116
+ const emit = defineEmits(["remove", "move", "itemDrop"]);
117
+ defineSlots();
118
+ const { t } = useLocale();
119
+ const hintId = `${useId()}-hint`;
120
+ const root = useTemplateRef("root");
121
+ const list = useTemplateRef("list");
122
+ const focusIndex = ref(0);
123
+ const dropIndex = ref(null);
124
+ const removal = useLiveMessage();
125
+ const items = computed(() => model.value);
126
+ const canSort = computed(() => props.sortable && !props.disabled);
127
+ const labelText = computed(() => props.label ?? t("tagList.label"));
128
+ const emptyLabel = computed(() => props.emptyText ?? t("tagList.empty"));
129
+ const hintText = computed(() => {
130
+ const parts = [];
131
+ if (canSort.value) parts.push(t("tagList.sortHint"));
132
+ if (canSort.value && items.value.some(isRemovable)) parts.push(t("tagList.removeHint"));
133
+ return parts.join(". ");
134
+ });
135
+ function isRemovable(item) {
136
+ return !props.disabled && !item.disabled && (item.removable ?? props.removable ?? false);
137
+ }
138
+ const sorter = useSortable(list, {
139
+ items,
140
+ orientation: "horizontal",
141
+ disabled: () => !canSort.value,
142
+ canMove: (item) => !item.disabled,
143
+ label: (item) => item.label,
144
+ onMove: (next, move) => {
145
+ model.value = next;
146
+ focusIndex.value = move.to;
147
+ emit("move", { item: move.item, from: move.from, to: move.to });
148
+ }
149
+ });
150
+ const liveText = computed(() => removal.message.value || sorter.announcement.value);
151
+ watch(
152
+ () => items.value.length,
153
+ (length) => {
154
+ if (focusIndex.value >= length) focusIndex.value = Math.max(0, length - 1);
155
+ }
156
+ );
157
+ function itemElement(index) {
158
+ return list.value?.querySelectorAll(":scope > .k-tag-list-item")[index] ?? null;
159
+ }
160
+ function moveBy(index, delta) {
161
+ sorter.move(index, index + delta, { announce: true, source: "api" });
162
+ }
163
+ function remove(index) {
164
+ const item = items.value[index];
165
+ if (!item || !isRemovable(item)) return;
166
+ const hadFocus = Boolean(root.value?.contains(document.activeElement));
167
+ const next = items.value.filter((_, position) => position !== index);
168
+ model.value = next;
169
+ emit("remove", item, index);
170
+ removal.announce(t("tagList.removed", { label: item.label }));
171
+ if (!hadFocus) return;
172
+ const target = Math.min(index, next.length - 1);
173
+ focusIndex.value = Math.max(0, target);
174
+ nextTick(() => {
175
+ const element = target >= 0 ? itemElement(target) : null;
176
+ const focusable = canSort.value ? element : element?.querySelector(".k-tag-remove:not(:disabled)");
177
+ if (focusable) focusable.focus({ preventScroll: true });
178
+ else if (root.value) focusStable(root.value);
179
+ });
180
+ }
181
+ function onItemKeydown(event, index) {
182
+ if (!canSort.value || event.target !== event.currentTarget) return;
183
+ if (event.altKey) return;
184
+ if (event.key === "Delete" || event.key === "Backspace") {
185
+ event.preventDefault();
186
+ remove(index);
187
+ return;
188
+ }
189
+ const rtl = isRtl(root.value);
190
+ const key = rtl && event.key === "ArrowLeft" ? "ArrowRight" : rtl && event.key === "ArrowRight" ? "ArrowLeft" : event.key;
191
+ const target = rovingIndex(key, index, items.value.length, "both");
192
+ if (target === null) return;
193
+ event.preventDefault();
194
+ focusIndex.value = target;
195
+ itemElement(target)?.focus();
196
+ }
197
+ function accepts(event) {
198
+ const accept = props.accept;
199
+ if (!accept || props.disabled) return false;
200
+ if (typeof accept === "function") return accept(event);
201
+ const types = Array.from(event.dataTransfer?.types ?? []);
202
+ return (Array.isArray(accept) ? accept : [accept]).some((type) => types.includes(type));
203
+ }
204
+ function dropTarget(event) {
205
+ if (!items.value.length) return 0;
206
+ const elements = sortableElements(list.value);
207
+ const targets = elements.length ? elements : Array.from(list.value?.querySelectorAll(":scope > .k-tag-list-item") ?? []);
208
+ return insertionIndex(targets, event.clientX, event.clientY, "horizontal", isRtl(root.value));
209
+ }
210
+ let dragDepth = 0;
211
+ function resetDrop() {
212
+ dragDepth = 0;
213
+ dropIndex.value = null;
214
+ }
215
+ function onDragEnter(event) {
216
+ if (!accepts(event)) return;
217
+ event.preventDefault();
218
+ dragDepth++;
219
+ }
220
+ function onDragOver(event) {
221
+ if (!accepts(event)) return;
222
+ event.preventDefault();
223
+ if (event.dataTransfer) event.dataTransfer.dropEffect = props.dropEffect;
224
+ const index = dropTarget(event);
225
+ if (dropIndex.value !== index) dropIndex.value = index;
226
+ }
227
+ function onDragLeave(event) {
228
+ if (!accepts(event)) return;
229
+ dragDepth = Math.max(0, dragDepth - 1);
230
+ if (!dragDepth) dropIndex.value = null;
231
+ }
232
+ function onDrop(event) {
233
+ if (!accepts(event)) return;
234
+ event.preventDefault();
235
+ const index = dropIndex.value ?? dropTarget(event);
236
+ resetDrop();
237
+ emit("itemDrop", { index, dataTransfer: event.dataTransfer, event });
238
+ }
239
+ </script>