@xleddyl/443-components-vue 0.9.3 → 0.10.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.
package/README.md CHANGED
@@ -2,6 +2,8 @@
2
2
 
3
3
  Compact, scoped dashboard components for Vue 3.5 and Nuxt 4. Plain Vue and CSS, no runtime dependency besides `vue`. Every style lives under `.k-root` and inside cascade layers, so the host page is never touched.
4
4
 
5
+ Looking for the right component for a task? Read [CATALOG.md](./CATALOG.md) first: it lists every component and composable with what it is for and when to use it.
6
+
5
7
  ## Install
6
8
 
7
9
  ```sh
@@ -127,21 +129,21 @@ Input tokens, declared only on the outermost root so nested roots inherit them:
127
129
 
128
130
  A few component tokens are unset by default and only exist to be overridden on a root (or any ancestor). Unset, every one keeps the library look. The full list with defaults is in [CONVENTIONS.md](./CONVENTIONS.md#tokens):
129
131
 
130
- | Area | Tokens |
131
- | -------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
132
- | Type | `--k-font-weight` (400), `--k-font-weight-medium` (500), `--k-font-weight-strong` (600), `--k-font-weight-display` (650) |
133
- | Buttons | `--k-btn-highlight`, `--k-btn-shadow` (`0 0 #0000` for a flat button), `--k-btn-border`, `--k-btn-hover-background`, `--k-btn-active-background`, `--k-btn-padding-x`, `--k-btn-font-size`, `--k-btn-icon-size`, `--k-press-scale` (0.97) |
134
- | Fields | `--k-field-padding-x` |
135
- | 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) |
136
- | Tooltip | `--k-tooltip-background`, `--k-tooltip-text`, `--k-tooltip-border`, `--k-tooltip-radius`, `--k-tooltip-shadow`, `--k-tooltip-padding` |
137
- | Shell | `--k-sidebar-width` (15rem), `--k-sidebar-rail-width`, `--k-topbar-height` (3rem), `--k-shell-inset`, `--k-shell-padding`, `--k-shell-content` |
138
- | Switch | `--k-switch-track-width`, `--k-switch-track-height`, `--k-switch-thumb-size` (md size) |
139
- | Separator | `--k-separator-heading-font-size` (0.9375rem), `--k-separator-heading-color` (`--k-text`), `--k-separator-label-font-size` (0.75rem) |
140
- | 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) |
141
- | Checkbox group | `--k-checkbox-group-background`, `--k-checkbox-group-padding` (0.75rem), `--k-checkbox-group-radius`, `--k-checkbox-group-border` (`variant="filled"`) |
142
- | Badge | `--k-badge-radius`, `--k-badge-font`, `--k-badge-font-size`, `--k-badge-font-weight`, `--k-badge-text-transform`, `--k-badge-letter-spacing` |
143
- | DataGrid | `--k-grid-header-background`, `--k-grid-header-text`, `--k-grid-header-font`, `--k-grid-header-font-weight`, `--k-grid-header-font-size` (12px), `--k-grid-font-size` (13px), `--k-grid-radius`, `--k-grid-selected-background` |
144
- | Charts | `--k-chart-1` to `--k-chart-8` (series palette) |
132
+ | Area | Tokens |
133
+ | -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
134
+ | Type | `--k-font-weight` (400), `--k-font-weight-medium` (500), `--k-font-weight-strong` (600), `--k-font-weight-display` (650) |
135
+ | Buttons | `--k-btn-highlight`, `--k-btn-shadow` (`0 0 #0000` for a flat button), `--k-btn-border`, `--k-btn-hover-background`, `--k-btn-active-background`, `--k-btn-padding-x`, `--k-btn-font-size`, `--k-btn-icon-size`, `--k-press-scale` (0.97) |
136
+ | Fields | `--k-field-padding-x` |
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
+ | 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` |
140
+ | Switch | `--k-switch-track-width`, `--k-switch-track-height`, `--k-switch-thumb-size` (md size) |
141
+ | Separator | `--k-separator-heading-font-size` (0.9375rem), `--k-separator-heading-color` (`--k-text`), `--k-separator-label-font-size` (0.75rem) |
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) |
143
+ | Checkbox group | `--k-checkbox-group-background`, `--k-checkbox-group-padding` (0.75rem), `--k-checkbox-group-radius`, `--k-checkbox-group-border` (`variant="filled"`) |
144
+ | Badge | `--k-badge-radius`, `--k-badge-font`, `--k-badge-font-size`, `--k-badge-font-weight`, `--k-badge-text-transform`, `--k-badge-letter-spacing` |
145
+ | DataGrid | `--k-grid-header-background`, `--k-grid-header-text`, `--k-grid-header-font`, `--k-grid-header-font-weight`, `--k-grid-header-font-size` (12px), `--k-grid-font-size` (13px), `--k-grid-radius`, `--k-grid-selected-background`, spreadsheet mode: `--k-grid-range-background`, `--k-grid-range-border`, `--k-grid-dirty-background`, `--k-grid-dirty-marker` |
146
+ | Charts | `--k-chart-1` to `--k-chart-8` (series palette) |
145
147
 
146
148
  Some tokens are meant for one element: set `--k-btn-tone`, `--k-btn-on-tone` and `--k-btn-tone-text` on a button for a brand tone outside `Tone` (a lime fill with green text), and `--k-badge-background`, `--k-badge-color` and `--k-badge-border-color` on a badge for a custom pill color. `variant="subtle"` is a borderless tinted fill (`--k-fill` for the neutral tone). For solid buttons without hover darkening, declare the fill tokens on the button so `var(--k-btn-tone)` resolves per tone: `.k-btn-solid { --k-btn-hover-background: var(--k-btn-tone); --k-btn-active-background: var(--k-btn-tone) }` (the neutral solid button rests on `--k-text`).
147
149
 
@@ -263,6 +265,28 @@ const form = useForm({
263
265
 
264
266
  Issues are keyed by their first path segment and by their full dotted path (`blocks.0.title`), so a nested `FormField name="blocks.0.title"` shows its own error. Pass `issueKey: (path) => string | string[]` to choose the keys yourself. Issues with an empty path become `rootErrors`, which `Form` renders as alerts above the content (`alerts="end"` moves them below, `alerts="false"` hides them, the `#alerts` slot replaces them). `setError('', message)` adds a root error, for example from the server.
265
267
 
268
+ Server errors. `form.setIssues(issues)` takes Standard Schema shaped issues (`FormIssueInput`, `{ message, path? }`), keys them like validator issues (first segment and dotted path) and shows them on their fields; an empty path becomes a root error. Messages go through `translateMessage` (`kind: 'error'`). When `onSubmit` throws, a `submitError` resolver (`FormSubmitErrorResolver`, `(error) => FormSubmitError | null | undefined`, with `FormSubmitError` `{ message?, issues? }`) turns the error into issues: `submit()` then resolves `false` instead of throwing, `Form` focuses the first invalid field (or the alerts), `form.submitError` holds the mapped result, and `message` becomes a root error when no issue landed on a field. Set the resolver once in `createFormConfig` / `provideFormConfig`, or per form in `useForm({ submitError })`, which wins. Without a resolver, or when it returns nothing (or neither a message nor issues), the error is thrown as before. Server errors are stale once the user edits: changing a field drops the server errors of that field and its nested paths, and the next submit drops the rest. `setError` keeps its behavior: its errors stay until the field validates again (after the first submit every edit validates the field, so they clear on edit too; before it they stay).
269
+
270
+ ```ts
271
+ app.use(
272
+ createFormConfig({
273
+ submitError: (error) => {
274
+ const data = (
275
+ error as { data?: { message?: string; data?: { issues?: FormIssueInput[] } } }
276
+ ).data
277
+ return data ? { message: data.message, issues: data.data?.issues } : null
278
+ },
279
+ })
280
+ )
281
+
282
+ const form = useForm({
283
+ values: product,
284
+ onSubmit: (values) => $fetch('/api/products', { method: 'POST', body: values }),
285
+ })
286
+ ```
287
+
288
+ The server answers `422` with `{ message: 'Invalid product', data: { issues: [{ message: 'Taken', path: ['sku'] }] } }` (`createError({ statusCode: 422, message, data: { issues } })` in Nitro), and `$fetch` puts that body on `error.data`.
289
+
266
290
  Built-in messages follow the active language. `messages` overrides them for one form.
267
291
 
268
292
  `schema` also accepts a ref or getter (`MaybeRefOrGetter<FormSchema>`), for one form component reused with different fields, labels or rules per open, mode or language: `schema: () => (mode.value === 'edit' ? editSchema : createSchema)`. Field definitions, labels, `required` and rules follow the source (after the first submit the form validates again when they change). A field that appears starts from its `initial` and is clean; a field that disappears loses its value, error and touched state. With `values` the host owns the values, so only rules and labels change. `form.schema` always returns the current definitions.
@@ -666,6 +690,39 @@ Accessibility related props and defaults:
666
690
  - `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.
667
691
  - `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.
668
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`.
693
+ - `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
+ - 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
+ - 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.
696
+ - Copy: Cmd/Ctrl+C copies the range as TSV (tabs between cells, newlines between rows, cells with a tab, a newline or a leading quote are quoted like Excel). Cells copy their raw value as text (`true`/`false`, dates as `YYYY-MM-DD`); `gridOptions.processCellForClipboard` changes it (it gets `formatValue` and `parseValue` helpers). Right click inside a range copies the range.
697
+ - Paste: Cmd/Ctrl+V reads TSV from Excel, Google Sheets or text (quoted multi line cells, CRLF, trailing newline) and starts at the top left cell of the range. One value fills the whole range; a smaller block that divides the range evenly is tiled; otherwise the block keeps its size and the pasted area becomes the range. Read only cells (`editable` false or a function that returns false) are skipped. Text goes through the column `valueParser` (also the one of `cellDataType`) and then `valueSetter` or `field`; `gridOptions.processCellFromClipboard` replaces the parser. A non empty text that parses to `null`, `undefined`, `NaN` or an invalid date, or a value missing from `cellEditorParams.values` of a select editor, is not written: it is listed in `cellsInvalid` and counted in the toast.
698
+ - Fill: Cmd/Ctrl+D copies the first row of the range down, Cmd/Ctrl+R the first column to the right (a one row or one column range copies from the row above or the column on the left). Drag the small square on the range corner down to repeat the range rows below it. Delete or Backspace clears the editable cells (`null`, or what the column `valueParser` returns for an empty string).
699
+ - Changes: every edit, paste, fill, clear, bulk set, undo and redo is one batch, emitted as `cellsChange` with `{ changes: GridCellChange[], source }` (`GridCellChange` is `{ rowId, colId, field, oldValue, newValue }`, `source` is `'edit' | 'paste' | 'fill' | 'clear' | 'bulk' | 'undo' | 'redo'`). Cmd/Ctrl+Z undoes a batch, Shift+Cmd/Ctrl+Z and Ctrl+Y redo it (100 batches; AG Grid `undoRedoCellEditing` is turned off). The grid writes into the row objects like AG Grid editing does; persist the batch yourself.
700
+ - Dirty cells: a changed cell gets a tint and a corner marker until you clear it; a cell edited back to its first value is clean again. `dirtyChange(count)` fires when the count changes; `getDirtyCells()` returns `{ rowId, colId, field, originalValue }[]` and `clearDirty(rowIds?)` clears all of them or the given rows (after a save).
701
+ - Events: `cellsChange`, `cellsInvalid` (`{ cells: { rowId, colId, field, text }[], source }`), `dirtyChange`, `rangeChange` (`GridCellRange | null`, `{ startRowIndex, endRowIndex, columns, active: { rowIndex, colId } }`), and `copy(text)` for a copied range.
702
+ - Methods (template ref, typed as `GridSpreadsheetMethods<T>`): `getRange()`, `getSelectedCells()` (`{ rowId, rowIndex, colId, field, value, data, editable }[]`), `selectRange({ startRowIndex, endRowIndex?, startColumn, endColumn? })`, `selectAll()`, `clearRange()`, `setRangeValue(value, { parse? })` (writes the value in every editable cell of the range; `parse: true` runs a string through the paste pipeline), `copyRange()`, `paste(text)`, `fillDown()`, `fillRight()`, `clearCells()`, `undo()`, `redo()`, `clearHistory()`, `getDirtyCells()`, `clearDirty(rowIds?)`. Write methods return the number of changed cells.
703
+ - The `actions` slot also gets every method plus `range`, `selectedCellCount`, `dirtyCount`, `canUndo` and `canRedo` (`GridSpreadsheetSlotProps<T>`), for a "set this value on the selected cells" action:
704
+
705
+ ```vue
706
+ <KDataGrid
707
+ ref="grid"
708
+ :rows="products"
709
+ :columns="columns"
710
+ row-id-key="id"
711
+ spreadsheet
712
+ toolbar
713
+ @cells-change="({ changes }) => queue(changes)"
714
+ @dirty-change="(count) => (dirty = count)"
715
+ >
716
+ <template #actions="{ selectedCellCount, setRangeValue, canUndo, undo }">
717
+ <KButton label="Undo" :disabled="!canUndo" @click="undo()" />
718
+ <KButton :label="`Set to Shoes (${selectedCellCount})`" @click="setRangeValue('Shoes')" />
719
+ </template>
720
+ </KDataGrid>
721
+ ```
722
+
723
+ - Rendering stays virtual: the range, active cell and dirty marks are `cellClassRules` (merged with your own) and only the rendered cells of the affected rows refresh. The range tint is `--k-grid-range-background`, its border and the fill handle `--k-grid-range-border`, dirty cells `--k-grid-dirty-background` and `--k-grid-dirty-marker`. `parseTsv` and `formatTsv` are exported from `/grid`.
724
+ - `DataGrid` row classes: `gridOptions.rowClass`, `gridOptions.rowClassRules` (`{ 'is-archived': (params) => params.data?.archived }`) and `gridOptions.getRowClass` reach AG Grid unchanged. Style the classes in host CSS under `.k-data-grid .ag-row.<class>`.
725
+ - `LinkCell` renders internal hrefs (starting with `/`) with the configured link component (`NuxtLink` in Nuxt, `createLinkComponent(RouterLink)` in plain Vue), so they navigate without a reload; other hrefs stay a plain `<a>`.
669
726
  - `DataGrid` translates the AG Grid ARIA strings through the catalog (`grid.*`; your own `localeText` still wins) and announces row count changes.
670
727
 
671
728
  ## Browser support
@@ -50,9 +50,16 @@ export interface FormTranslateContext {
50
50
  params?: MessageParams;
51
51
  }
52
52
  export type FormTranslateMessage = (message: string, context: FormTranslateContext) => string;
53
+ export type FormIssueInput = StandardSchemaIssue;
54
+ export interface FormSubmitError {
55
+ message?: string;
56
+ issues?: ReadonlyArray<FormIssueInput>;
57
+ }
58
+ export type FormSubmitErrorResolver = (error: unknown) => FormSubmitError | undefined | null;
53
59
  export interface FormConfig {
54
60
  messages?: Partial<FormMessages>;
55
61
  translateMessage?: FormTranslateMessage;
62
+ submitError?: FormSubmitErrorResolver;
56
63
  }
57
64
  export type FormValidationTrigger = 'submit' | 'blur' | 'input';
58
65
  export type FormIssueKey = IssueKeyResolver;
@@ -72,6 +79,7 @@ export interface UseFormOptions<S extends FormSchema = FormSchema, V extends obj
72
79
  validateOn?: FormValidationTrigger;
73
80
  onSubmit?: (values: V) => unknown;
74
81
  onInvalid?: (errors: FormErrors<S>) => unknown;
82
+ submitError?: FormSubmitErrorResolver;
75
83
  }
76
84
  export type FormErrors<S extends FormSchema = FormSchema> = Partial<Record<keyof S & string, string>> & FormMessageMap;
77
85
  export interface FormContext {
@@ -105,6 +113,8 @@ export interface UseFormReturn<S extends FormSchema = FormSchema, V extends obje
105
113
  validateField: (name: keyof V & string) => Promise<boolean>;
106
114
  setError: (name: FormPath<V>, message: string | undefined) => void;
107
115
  setErrors: (errors: FormErrors<S>) => void;
116
+ setIssues: (issues: ReadonlyArray<FormIssueInput>) => void;
117
+ submitError: Readonly<Ref<FormSubmitError | null>>;
108
118
  clearErrors: () => void;
109
119
  touch: (name: string) => void;
110
120
  reset: (values?: Partial<V>) => void;
@@ -146,11 +146,14 @@ export function useForm(options) {
146
146
  const touched = reactive({});
147
147
  const submitCount = ref(0);
148
148
  const submitting = ref(false);
149
+ const submitError = shallowRef(null);
149
150
  const ruleErrors = {};
150
151
  const manualErrors = {};
151
152
  let issueErrors = {};
152
153
  let issueRoot = [];
153
154
  let manualRoot = [];
155
+ const serverKeys = /* @__PURE__ */ new Set();
156
+ let serverRoot = [];
154
157
  const readValue = (name) => target()[name];
155
158
  const dirtyFields = computed(() => {
156
159
  void baselineVersion.value;
@@ -172,7 +175,46 @@ export function useForm(options) {
172
175
  }
173
176
  function syncErrors(inScope) {
174
177
  replaceEntries(errors, composeErrors(), inScope);
175
- if (!inScope) rootErrors.value = uniqueMessages(manualRoot, issueRoot);
178
+ if (!inScope) syncRootErrors();
179
+ }
180
+ function syncRootErrors() {
181
+ rootErrors.value = uniqueMessages(manualRoot, serverRoot, issueRoot);
182
+ }
183
+ function dropServerErrors(inScope = () => true) {
184
+ let dropped = false;
185
+ for (const key of serverKeys) {
186
+ if (!inScope(key)) continue;
187
+ delete manualErrors[key];
188
+ serverKeys.delete(key);
189
+ dropped = true;
190
+ }
191
+ return dropped;
192
+ }
193
+ function applyIssues(issues) {
194
+ dropServerErrors();
195
+ const grouped = groupIssues(translateIssues(schemaIssues(issues), "error"), issueKey);
196
+ for (const [key, message] of Object.entries(grouped.fields)) {
197
+ manualErrors[key] = message;
198
+ serverKeys.add(key);
199
+ }
200
+ serverRoot = grouped.root;
201
+ return grouped;
202
+ }
203
+ function setIssues(issues) {
204
+ applyIssues(issues);
205
+ syncErrors();
206
+ }
207
+ function resolveSubmitError(error) {
208
+ const resolver = options.submitError ?? config?.submitError;
209
+ const result = resolver?.(error);
210
+ return result && (result.message || result.issues?.length) ? result : null;
211
+ }
212
+ function applySubmitError(result) {
213
+ const grouped = applyIssues(result.issues ?? []);
214
+ if (result.message && !Object.keys(grouped.fields).length)
215
+ serverRoot = uniqueMessages(serverRoot, [translate(result.message, { kind: "error" })]);
216
+ submitError.value = result;
217
+ syncErrors();
176
218
  }
177
219
  function evaluateValidator() {
178
220
  const validator = toValue(options.validator);
@@ -274,6 +316,8 @@ export function useForm(options) {
274
316
  fields.forEach((name, index) => ruleErrors[name] = results[index]);
275
317
  for (const key of Object.keys(manualErrors)) delete manualErrors[key];
276
318
  manualRoot = [];
319
+ serverKeys.clear();
320
+ serverRoot = [];
277
321
  syncErrors();
278
322
  }
279
323
  function isValidResult(issues, results) {
@@ -309,9 +353,10 @@ export function useForm(options) {
309
353
  function setError(name, message) {
310
354
  if (name === "") {
311
355
  manualRoot = message ? [message] : [];
312
- rootErrors.value = uniqueMessages(manualRoot, issueRoot);
356
+ syncRootErrors();
313
357
  return;
314
358
  }
359
+ serverKeys.delete(name);
315
360
  manualErrors[name] = message;
316
361
  if (!message) {
317
362
  delete ruleErrors[name];
@@ -323,6 +368,8 @@ export function useForm(options) {
323
368
  for (const key of Object.keys(manualErrors)) delete manualErrors[key];
324
369
  for (const key of Object.keys(ruleErrors)) delete ruleErrors[key];
325
370
  issueErrors = {};
371
+ serverKeys.clear();
372
+ serverRoot = [];
326
373
  for (const [key, message] of Object.entries(next)) {
327
374
  if (key === "") manualRoot = message ? [message] : [];
328
375
  else manualErrors[key] = message;
@@ -335,6 +382,9 @@ export function useForm(options) {
335
382
  issueErrors = {};
336
383
  issueRoot = [];
337
384
  manualRoot = [];
385
+ serverKeys.clear();
386
+ serverRoot = [];
387
+ submitError.value = null;
338
388
  syncErrors();
339
389
  }
340
390
  function touch(name) {
@@ -374,6 +424,7 @@ export function useForm(options) {
374
424
  async function submit() {
375
425
  if (submitting.value) return false;
376
426
  submitCount.value++;
427
+ submitError.value = null;
377
428
  const ok = await validate();
378
429
  if (!ok) {
379
430
  await options.onInvalid?.({ ...errors });
@@ -382,6 +433,11 @@ export function useForm(options) {
382
433
  submitting.value = true;
383
434
  try {
384
435
  await options.onSubmit?.(cloneValue(target()));
436
+ } catch (error) {
437
+ const result = resolveSubmitError(error);
438
+ if (!result) throw error;
439
+ applySubmitError(result);
440
+ return false;
385
441
  } finally {
386
442
  submitting.value = false;
387
443
  }
@@ -394,6 +450,8 @@ export function useForm(options) {
394
450
  const current = readValue(name);
395
451
  if (snapshots.has(name) && isEqualValue(current, snapshots.get(name))) continue;
396
452
  snapshots.set(name, cloneValue(current));
453
+ const inField = (key) => isWithin(key, name);
454
+ if (dropServerErrors(inField)) syncErrors(inField);
397
455
  if (validateOn === "input" || submitCount.value > 0 || validateOn === "blur" && touched[name])
398
456
  void validateField(name);
399
457
  }
@@ -406,6 +464,7 @@ export function useForm(options) {
406
464
  if (isWithin(key, name)) delete source[key];
407
465
  }
408
466
  }
467
+ dropServerErrors((key) => isWithin(key, name));
409
468
  }
410
469
  watch(
411
470
  schemaNames,
@@ -500,6 +559,8 @@ export function useForm(options) {
500
559
  validateField,
501
560
  setError,
502
561
  setErrors,
562
+ setIssues,
563
+ submitError: readonly(submitError),
503
564
  clearErrors,
504
565
  touch,
505
566
  isRequired: isFieldRequired,
@@ -3,7 +3,13 @@
3
3
  class="k-data-grid"
4
4
  :class="[
5
5
  `k-data-grid-${density}`,
6
- { 'is-loading': loading, 'is-auto-height': autoHeight, 'is-fill': fill && !autoHeight }
6
+ {
7
+ 'is-loading': loading,
8
+ 'is-auto-height': autoHeight,
9
+ 'is-fill': fill && !autoHeight,
10
+ 'is-spreadsheet': spreadsheet,
11
+ 'is-filling': spreadsheet && sheetFilling
12
+ }
7
13
  ]"
8
14
  >
9
15
  <GridToolbar
@@ -18,15 +24,20 @@
18
24
  :debounce="searchDebounce"
19
25
  @reset-columns="clearStoredState"
20
26
  >
21
- <slot name="actions" :api="api" />
27
+ <slot name="actions" :api="api" v-bind="sheetSlot" />
22
28
  </GridToolbar>
23
29
 
24
30
  <div
31
+ ref="bodyElement"
25
32
  class="k-data-grid-body"
26
33
  role="region"
27
34
  :aria-label="label ?? text.label"
28
35
  :aria-busy="loading ? 'true' : void 0"
29
36
  :style="autoHeight || fill ? void 0 : { height }"
37
+ @keydown.capture="sheet.onKeyDown"
38
+ @pointerdown.capture="sheet.onPointerDown"
39
+ @mousedown.capture="sheet.onMouseDown"
40
+ @pointerover="sheet.onPointerOver"
30
41
  >
31
42
  <AgGridVue
32
43
  v-if="mounted"
@@ -54,12 +65,15 @@
54
65
  :header-height="headerHeight ?? gridOptions?.headerHeight"
55
66
  :tooltip-show-delay="gridOptions?.tooltipShowDelay ?? 600"
56
67
  :suppress-cell-focus="false"
68
+ :undo-redo-cell-editing="spreadsheet ? false : gridOptions?.undoRedoCellEditing"
57
69
  v-on="gridEvents"
58
70
  />
59
71
  <div v-else class="k-data-grid-placeholder" aria-hidden="true">
60
72
  <Spinner decorative />
61
73
  </div>
62
- <p v-if="copied" class="k-data-grid-toast" aria-hidden="true">{{ text.copied }}</p>
74
+ <p v-if="notice || copied" class="k-data-grid-toast" aria-hidden="true">
75
+ {{ notice || text.copied }}
76
+ </p>
63
77
  <LiveRegion :message="loading || !mounted ? text.loading : live.message.value" />
64
78
  </div>
65
79
 
@@ -99,7 +113,7 @@
99
113
 
100
114
  <script setup>
101
115
  import { AgGridVue } from "ag-grid-vue3";
102
- import { computed, nextTick, onMounted, ref, shallowRef, watch } from "vue";
116
+ import { computed, nextTick, onBeforeUnmount, onMounted, ref, shallowRef, watch } from "vue";
103
117
  import Pagination from "../components/data/Pagination.vue";
104
118
  import Spinner from "../components/core/Spinner.vue";
105
119
  import { useCopy } from "../composables/useCopy";
@@ -113,8 +127,10 @@ import { escapeHtml } from "../internal/core/text";
113
127
  import {
114
128
  registerGridEditorModules,
115
129
  registerGridFilterModules,
116
- registerGridModules
130
+ registerGridModules,
131
+ registerGridSpreadsheetModules
117
132
  } from "./modules";
133
+ import { createGridSpreadsheet } from "./spreadsheet";
118
134
  import { getGridTheme } from "./theme";
119
135
  registerGridModules();
120
136
  const props = defineProps({
@@ -150,25 +166,34 @@ const props = defineProps({
150
166
  headerHeight: { type: Number, required: false },
151
167
  headerText: { type: Function, required: false },
152
168
  filterable: { type: Boolean, required: false },
153
- editable: { type: Boolean, required: false }
169
+ editable: { type: Boolean, required: false },
170
+ spreadsheet: { type: Boolean, required: false }
154
171
  });
155
- const emit = defineEmits(["rowClick", "selectionChange", "sortChange", "filterChange", "copy"]);
172
+ const emit = defineEmits(["rowClick", "selectionChange", "sortChange", "filterChange", "copy", "cellsChange", "cellsInvalid", "dirtyChange", "rangeChange"]);
156
173
  const query = defineModel("query", { type: String, ...{ default: "" } });
157
174
  const page = defineModel("page", { type: Number, ...{ default: 1 } });
158
175
  const pageSize = defineModel("pageSize", { type: Number, ...{ default: 25 } });
159
176
  const sort = defineModel("sort", { type: null, ...{ default: void 0 } });
160
177
  if (props.filterable) registerGridFilterModules();
161
178
  function hasEditableColumn() {
162
- return props.editable || Boolean(props.gridOptions?.defaultColDef?.editable) || props.columns.some((column) => Boolean(column.editable));
179
+ return props.editable || props.spreadsheet || Boolean(props.gridOptions?.defaultColDef?.editable) || props.columns.some((column) => Boolean(column.editable));
163
180
  }
164
- if (hasEditableColumn()) registerGridEditorModules();
181
+ if (props.spreadsheet) registerGridSpreadsheetModules();
182
+ else if (hasEditableColumn()) registerGridEditorModules();
165
183
  const columnDefs = computed(() => {
166
184
  const headerText = props.headerText;
167
- if (!headerText) return props.columns;
185
+ const rules = props.spreadsheet ? sheet.classRules : void 0;
186
+ if (!headerText && !rules) return props.columns;
187
+ const defaultRules = props.gridOptions?.defaultColDef?.cellClassRules;
168
188
  return props.columns.map((column) => {
169
- if (column.headerName !== void 0 || !column.field) return column;
170
- const headerName = headerText(String(column.field), column);
171
- return headerName === void 0 ? column : { ...column, headerName };
189
+ let next = column;
190
+ if (headerText && column.headerName === void 0 && column.field) {
191
+ const headerName = headerText(String(column.field), column);
192
+ if (headerName !== void 0) next = { ...next, headerName };
193
+ }
194
+ if (rules)
195
+ next = { ...next, cellClassRules: { ...defaultRules, ...column.cellClassRules, ...rules } };
196
+ return next;
172
197
  });
173
198
  });
174
199
  const { locale, t } = useLocale();
@@ -212,6 +237,52 @@ const selectedCount = ref(0);
212
237
  const clientTotal = ref(0);
213
238
  const clientPageCount = ref(1);
214
239
  const { copied, copy } = useCopy();
240
+ const bodyElement = ref(null);
241
+ const notice = ref("");
242
+ let noticeTimer;
243
+ const noticeMessages = {
244
+ copied: "cellsCopied",
245
+ pasted: "cellsPasted",
246
+ filled: "cellsFilled",
247
+ cleared: "cellsCleared",
248
+ updated: "cellsUpdated",
249
+ undone: "changesUndone",
250
+ redone: "changesRedone"
251
+ };
252
+ function formatCount(template, count) {
253
+ return formatLocaleMessage(locale.value.code, template, { count });
254
+ }
255
+ function showNotice(kind, count, invalid) {
256
+ if (!count && !invalid) return;
257
+ const message = formatCount(text.value[noticeMessages[kind]], count);
258
+ notice.value = invalid ? `${message}, ${formatCount(text.value.invalidValues, invalid)}` : message;
259
+ live.announce(notice.value);
260
+ clearTimeout(noticeTimer);
261
+ noticeTimer = setTimeout(() => notice.value = "", 1600);
262
+ }
263
+ const sheet = createGridSpreadsheet({
264
+ enabled: () => Boolean(props.spreadsheet),
265
+ body: () => bodyElement.value,
266
+ gridOptions: () => props.gridOptions,
267
+ onChange: (event) => emit("cellsChange", event),
268
+ onInvalid: (event) => emit("cellsInvalid", event),
269
+ onDirty: (count) => emit("dirtyChange", count),
270
+ onRange: (range) => emit("rangeChange", range),
271
+ onCopy: (copiedText) => emit("copy", copiedText),
272
+ onSelect: (count) => live.announce(formatCount(text.value.cellsSelected, count)),
273
+ notify: showNotice
274
+ });
275
+ const { filling: sheetFilling, slotProps: sheetSlot } = sheet;
276
+ watch(
277
+ () => props.spreadsheet,
278
+ (enabled) => {
279
+ if (!enabled) sheet.disable();
280
+ }
281
+ );
282
+ onBeforeUnmount(() => {
283
+ clearTimeout(noticeTimer);
284
+ sheet.detach();
285
+ });
215
286
  const paginationEnabled = computed(() => props.pagination || props.serverPagination);
216
287
  const paginated = computed(() => Boolean(props.pagination) && !props.serverPagination);
217
288
  const showFooter = computed(() => paginationEnabled.value || Boolean(props.selectable));
@@ -293,6 +364,7 @@ function persistState(event) {
293
364
  }
294
365
  function onGridReady(event) {
295
366
  api.value = event.api;
367
+ sheet.attach(event.api);
296
368
  const stored = readStoredState();
297
369
  if (stored?.length) event.api.applyColumnState({ state: stored, applyOrder: true });
298
370
  applySort();
@@ -369,6 +441,10 @@ function onFilterChanged(event) {
369
441
  async function onCellContextMenu(event) {
370
442
  if (!event.node || !event.column) return;
371
443
  event.event?.preventDefault();
444
+ if (props.spreadsheet && sheet.containsCell(event.node.rowIndex, event.column.getColId())) {
445
+ await sheet.copyRange();
446
+ return;
447
+ }
372
448
  const value = event.api.getCellValue({
373
449
  rowNode: event.node,
374
450
  colKey: event.column,
@@ -399,6 +475,7 @@ onMounted(() => {
399
475
  mounted.value = true;
400
476
  });
401
477
  defineExpose({
478
+ ...sheet.methods,
402
479
  api,
403
480
  getSelectedRows: () => api.value?.getSelectedRows() ?? [],
404
481
  deselectAll: () => api.value?.deselectAll(),
@@ -1,5 +1,5 @@
1
1
  import type { FilterChangedEvent, FilterModel, ColDef, GetRowIdParams, GridApi, GridOptions, RowClickedEvent } from 'ag-grid-community';
2
- import type { GridDensity, GridMessages, GridSelection, GridSortChange } from './types';
2
+ import type { GridCellRange, GridCellsChange, GridCellsInvalid, GridDensity, GridMessages, GridSelection, GridSortChange } from './types';
3
3
  declare const __VLS_export: <T>(__VLS_props: NonNullable<Awaited<typeof __VLS_setup>>["props"], __VLS_ctx?: __VLS_PrettifyLocal<Pick<NonNullable<Awaited<typeof __VLS_setup>>, "attrs" | "emit" | "slots">>, __VLS_exposed?: NonNullable<Awaited<typeof __VLS_setup>>["expose"], __VLS_setup?: Promise<{
4
4
  props: import("vue").PublicProps & __VLS_PrettifyLocal<({
5
5
  rows?: T[];
@@ -35,6 +35,7 @@ declare const __VLS_export: <T>(__VLS_props: NonNullable<Awaited<typeof __VLS_se
35
35
  headerText?: (field: string, column: ColDef<T>) => string | undefined;
36
36
  filterable?: boolean;
37
37
  editable?: boolean;
38
+ spreadsheet?: boolean;
38
39
  } & {
39
40
  query?: string;
40
41
  page?: number;
@@ -50,6 +51,10 @@ declare const __VLS_export: <T>(__VLS_props: NonNullable<Awaited<typeof __VLS_se
50
51
  onSelectionChange?: ((rows: T[]) => any) | undefined;
51
52
  onSortChange?: ((change: GridSortChange | null) => any) | undefined;
52
53
  onFilterChange?: ((model: FilterModel, event: FilterChangedEvent<T, any>) => any) | undefined;
54
+ onCellsChange?: ((event: GridCellsChange) => any) | undefined;
55
+ onCellsInvalid?: ((event: GridCellsInvalid) => any) | undefined;
56
+ onDirtyChange?: ((count: number) => any) | undefined;
57
+ onRangeChange?: ((range: GridCellRange | null) => any) | undefined;
53
58
  } & Partial<import("vue").HTMLAttributes & import("vue").ReservedProps>> & (typeof globalThis extends {
54
59
  __VLS_PROPS_FALLBACK: infer P;
55
60
  } ? P : {});
@@ -58,14 +63,51 @@ declare const __VLS_export: <T>(__VLS_props: NonNullable<Awaited<typeof __VLS_se
58
63
  getSelectedRows: () => T[];
59
64
  deselectAll: () => void | undefined;
60
65
  resetColumns: () => void;
66
+ getRange: () => GridCellRange | null;
67
+ getSelectedCells: () => import("./types").GridSelectedCell<T>[];
68
+ selectRange: (range: import("./types").GridRangeInput) => void;
69
+ selectAll: () => void;
70
+ clearRange: () => void;
71
+ setRangeValue: (value: unknown, options?: import("./types").GridSetRangeValueOptions) => number;
72
+ copyRange: () => Promise<string | null>;
73
+ paste: (text: string) => number;
74
+ fillDown: () => number;
75
+ fillRight: () => number;
76
+ clearCells: () => number;
77
+ undo: () => boolean;
78
+ redo: () => boolean;
79
+ clearHistory: () => void;
80
+ getDirtyCells: () => import("./types").GridDirtyCell[];
81
+ clearDirty: (rowIds?: string[]) => void;
61
82
  }>) => void;
62
83
  attrs: any;
63
84
  slots: {
64
85
  actions?: (props: {
86
+ range: GridCellRange | null;
87
+ selectedCellCount: number;
88
+ dirtyCount: number;
89
+ canUndo: boolean;
90
+ canRedo: boolean;
91
+ getRange: () => GridCellRange | null;
92
+ getSelectedCells: () => import("./types").GridSelectedCell<T>[];
93
+ selectRange: (range: import("./types").GridRangeInput) => void;
94
+ selectAll: () => void;
95
+ clearRange: () => void;
96
+ setRangeValue: (value: unknown, options?: import("./types").GridSetRangeValueOptions) => number;
97
+ copyRange: () => Promise<string | null>;
98
+ paste: (text: string) => number;
99
+ fillDown: () => number;
100
+ fillRight: () => number;
101
+ clearCells: () => number;
102
+ undo: () => boolean;
103
+ redo: () => boolean;
104
+ clearHistory: () => void;
105
+ getDirtyCells: () => import("./types").GridDirtyCell[];
106
+ clearDirty: (rowIds?: string[]) => void;
65
107
  api: GridApi<T> | null;
66
108
  }) => any;
67
109
  };
68
- emit: (((evt: "copy", text: string) => void) & ((evt: "rowClick", event: RowClickedEvent<T, any>) => void) & ((evt: "selectionChange", rows: T[]) => void) & ((evt: "sortChange", change: GridSortChange | null) => void) & ((evt: "filterChange", model: FilterModel, event: FilterChangedEvent<T, any>) => void)) & (((event: "update:page", value: number) => void) & ((event: "update:pageSize", value: number) => void) & ((event: "update:sort", value: GridSortChange | null | undefined) => void) & ((event: "update:query", value: string) => void));
110
+ emit: (((evt: "copy", text: string) => void) & ((evt: "rowClick", event: RowClickedEvent<T, any>) => void) & ((evt: "selectionChange", rows: T[]) => void) & ((evt: "sortChange", change: GridSortChange | null) => void) & ((evt: "filterChange", model: FilterModel, event: FilterChangedEvent<T, any>) => void) & ((evt: "cellsChange", event: GridCellsChange) => void) & ((evt: "cellsInvalid", event: GridCellsInvalid) => void) & ((evt: "dirtyChange", count: number) => void) & ((evt: "rangeChange", range: GridCellRange | null) => void)) & (((event: "update:page", value: number) => void) & ((event: "update:pageSize", value: number) => void) & ((event: "update:sort", value: GridSortChange | null | undefined) => void) & ((event: "update:query", value: string) => void));
69
111
  }>) => import("vue").VNode & {
70
112
  __ctx?: NonNullable<Awaited<typeof __VLS_setup>>;
71
113
  };
@@ -1,20 +1,16 @@
1
1
  <template>
2
- <a
3
- v-if="text"
4
- class="k-data-grid-link"
5
- :href="href"
6
- :target="params.target"
7
- :rel="params.target === '_blank' ? 'noopener noreferrer' : void 0"
2
+ <component :is="link.is" v-if="text && link" class="k-data-grid-link" v-bind="link.attrs"
8
3
  >{{ text
9
4
  }}<span v-if="params.target === '_blank'" class="k-visually-hidden">
10
5
  {{ newTabText }}</span
11
- ></a
6
+ ></component
12
7
  >
13
8
  <EmptyCell v-else :context="cellContext" />
14
9
  </template>
15
10
 
16
11
  <script setup>
17
12
  import { computed } from "vue";
13
+ import { useLinkResolver } from "../../internal/core/link";
18
14
  import EmptyCell from "./EmptyCell.vue";
19
15
  import { en } from "../../locale/en";
20
16
  const props = defineProps({
@@ -28,6 +24,11 @@ const text = computed(() => {
28
24
  const href = computed(
29
25
  () => props.params.href?.(raw.value, props.params.data) ?? String(raw.value ?? "")
30
26
  );
27
+ const { resolveLink } = useLinkResolver();
28
+ const link = computed(() => {
29
+ const internal = href.value.startsWith("/") && !href.value.startsWith("//");
30
+ return internal ? resolveLink({ to: href.value, target: props.params.target }) : resolveLink({ href: href.value, target: props.params.target });
31
+ });
31
32
  const cellContext = computed(() => props.params.context);
32
33
  const newTabText = computed(() => cellContext.value?.newTabText ?? en.messages.common.opensInNewTab);
33
34
  </script>