Skip to content

Feat/list editors#214

Open
volarname wants to merge 70 commits into
betafrom
feat/list-editors
Open

Feat/list editors#214
volarname wants to merge 70 commits into
betafrom
feat/list-editors

Conversation

@volarname
Copy link
Copy Markdown
Contributor

No description provided.

volar and others added 27 commits April 24, 2026 09:36
- Labs components: AListEditor (flat), ASortableListEditor (flat + reorder),
  ANestedSortableListEditor (multi-level tree + reorder + drag-and-drop)
- Composables: useListEditor, useNestedListEditor
- Playground views for all three variants
- Unit tests + visual-regression screenshots
- i18n keys for sortable (en/cs/sk): reorder, expandAll, collapseAll,
  unsaved, pendingChanges, and helpers

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Sync AListEditor + ASortableListEditor styling with nested variant:
  compact tokens (48 px row, 6 px pad-y, 13 px font), 8 px radius, no
  shadow, form card wrapper, container-query driven desktop layout.
- Editing row: blue header only (not whole row), primary rail + gradient
  extend through the form body and footer, edit button toggles close.
- Dirty (--dirty / --unsaved) now wins over editing: header bg, title
  colour, rail and gradient switch from primary to warning.
- Flat variants: form body aligns with the row title (no 42 px nest
  indent); action buttons density=comfortable + mx-1 class.
- Sortable drag preview uses forceFallback; ghost = slim row clone
  without actions/status, faint primary placeholder at drop target.
- Reorder mode arrows follow the same hover-reveal / touch-always
  behaviour as the default row actions; disabled arrows keep opacity 0.3.
- Nested: Reorder button v-if-removed entirely when readonly (was only
  disabled); entering reorder mode auto-expands all branches.
- Playground: first AListEditor/ASortableListEditor demo drops
  on-item-save so the Cancel/Save toolbar can be inspected both with
  and without a callback.
- Tests updated to reflect the editing row's unified action set.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Drop the custom absolutely-positioned drop-line overlay (lineTop/Left/Right/
hookOriginX) and lean on SortableJS's built-in ghost placeholder, restyled
as a primary-tinted strip with a dashed outline at the landing position.
Add a "+N children" chip inside the drag clone so branch moves read as a
whole subtree rather than a single row.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replace SortableJS's DOM-driven drop mechanics with Atlassian Pragmatic DnD's
"instruction" pattern. A pointermove handler during drag hit-tests the hovered
row, splits it into Y bands (top 25% sibling-above / mid 50% make-child /
bottom 25% sibling-below), and in the bottom band of a last-in-group row
reads pointer X to emit a `reparent` that jumps out of ancestor chains in a
single continuous drag. maxDepth and cycle violations wrap the desired
instruction in `blocked` so the intended target stays visible in a warning
colour instead of silently refusing.

SortableJS is now only the drag lifecycle + floating clone provider —
onMove always returns false, onEnd applies our instruction via
`editor.moveTo`. The old per-group DOM reorder is gone along with its
`data-parent-id` reconciliation.

The drop indicator overlay renders absolute-positioned over `.rows`:
- sibling-* / reparent → 2px horizontal line with an 8px terminal dot at
  the left that bleeds 4px outside the anchor column; `left` offset encodes
  the target depth
- make-child → 2px primary border around the full target row

Warning-tinted variants signal blocked intent. Pure instruction logic
lives in a new `useDragInstruction` composable so the mapping is
unit-testable in isolation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Moved detection used flat index comparison, which shifted for every row
whose position in the linearised tree changed — so an unmoved row got
flagged orange whenever a row above it moved. Replace with a
parent + sibling-index snapshot: a row is "moved" only when its own
parent or its own position within that parent actually changed.

Source row vanished during drag because SortableJS with
`forceFallback: true` sets inline `display: none` on the dragged element
to avoid a duplicate of the floating clone. Override with
`display: flex !important` on `.row--chosen` / `.row--drop-source` so the
origin slot stays visible (dimmed, opacity 0.4).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Drop indicator left offset was 12 + depth*24, so the dot sat on the row
padding edge rather than on the drag handle's visual centre, making the
depth of the target drop slightly ambiguous. Re-anchor to the drag handle
centre (22 + depth*24, with the dot bleeding 4px left so its visual centre
lands exactly on the handle column).

While dragging, the source node's whole subtree (source row + every
descendant rendered underneath) is now dimmed to 0.4 opacity and made
non-hittable via pointer-events: none. Relying on CSS cascade from the
source wrapper, descendants pick this up automatically. The overlay no
longer flashes a blocked indicator when the pointer strays into your own
children — there's no valid drop there, so the indicator stays silent
rather than shouting about it. `blocked` is now reserved for maxDepth.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Dropping onto a leaf turned it into a parent but left it collapsed, so the
just-moved row vanished into a hidden subtree and the user had to hunt for
it. After a make-child drop, add the target's key to the expanded set so
the new child is immediately visible.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replace Pragmatic DnD's Y-band gesture set (top 25% above / mid 50%
make-child / bottom 25% below, with X active only in the last-in-group
reparent edge case) with dnd-kit SortableTree's pure outliner model:
pointer Y picks the gap (upper 50% of the hovered row = above it, lower
50% = below), pointer X picks the depth inside that gap. Depth is
clamped to [0, anchor.depth + 1] where anchor is the visible row
immediately above the gap — so you can never skip more than one level
deeper than what's already there. Drag right makes the dragged row a
new child (one indent step deeper than the anchor); drag left walks
out of ancestors (reparent). `makeChild` now surfaces as a field on
the instruction instead of a distinct type, driving the same
auto-expand behaviour after the drop.

The box overlay for `make-child` is gone — a single line indicator
whose horizontal start encodes the target depth is the entire visual
vocabulary. It was the only way to keep X meaningful across the whole
drag surface (previously X only moved the indicator inside a narrow
reparent zone, so "drag right to nest" wasn't discoverable).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Drop indicator refinements:
- Depth clamp tightened from [0, prev.depth+1] to [next.depth, prev.depth+1]
  so the line only offers depths that actually land at the pointer's Y
  position. Before, dragging far left at depth 5 showed a depth-0 line
  but the item would insert many rows below visually; now the X axis only
  moves between depths that correspond to the current gap.
- Blocked drops (maxDepth overflow) render nothing — the silent empty
  space IS the "not here" signal. No warning-tinted ghost of the intent.
- Floating clone at the cursor hidden via `display: none !important` on
  the wrapper-level selector (beats the source's drop-disabled rule that
  the SortableJS clone inherits). The line and connector carry all the
  where-it-lands information.
- Added a thin vertical connector rail from the drop line up to the row
  whose level the insert matches (previous sibling for same-depth, or
  the ancestor being joined as a peer on reparent). Positioned in JS from
  getBoundingClientRect because CSS Anchor Positioning's reachability
  rules excluded deeply nested anchors in Chrome; the positions are stable
  for a drag's lifetime so the computed approach is fine.

Moved/dirty detection reworked:
- Replaced snapshot-vs-current-index diffing with an explicit `movedKeys`
  Set. Only rows the user actively moved (drag-drop / arrow buttons /
  indent/outdent) get flagged unsaved; neighbours whose flat index or
  sibling order shifted as a side-effect stay clean.
- In the nested editor, moving a parent marks the whole subtree as moved
  — the children travel with the parent visually, so they should read
  as unsaved too.
- `dirty` comparison now strips `position` (both editors) and `parent`
  (nested editor) before JSON-stringifying. Those fields are rewritten
  on every side-effect sibling-index shift; including them in the dirty
  baseline painted ghost unsaved markers on rows the user never touched.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Reorder-mode toolbar:
- Pending-changes count, Cancel and Apply move into the card header where
  the "Reorder" button lives in view mode; the sticky bottom toolbar is
  gone. `toolbarMode` and `toolbarBottomOffset` props are dropped — full
  customisation still available via the `reorder-toolbar` slot, now
  rendered in the header.
- Apply button disabled at 0 pending changes so accidental clicks can't
  fire an empty save.
- Apply no longer clears `movedKeys`: the caller still has to persist the
  new order via their API, and exposed `resetDirtyBaseline` now also
  resets the moved set, so "mark as saved" is one call after the API
  returns.
- Status label "N pending changes" is `text-body-small`; "No pending
  changes" adds `text-medium-emphasis` to fade it into the background.
- "Unsaved" row pill keeps its dot + warning colour but loses the
  pill-shaped border — quieter inline after the row title.

Nested row actions:
- View-mode kebab gets better labels + icons: "Add after this item" with
  `mdi-playlist-plus`, "Add inside" with `mdi-subdirectory-arrow-right`.
  Localised into sk/en/cs.
- "Add inside" now appends to the end of the parent's children (matches
  the root-level Add button's append semantic). Drag-and-drop make-child
  still lands at index 0 because the drop line visually sits in that gap.
- Reorder-mode kebab gains a Delete item (error-coloured) so trashing a
  row no longer requires leaving reorder mode.
- Reorder-mode action buttons pick up `mx-1` spacing to match view-mode.
- First playground demo is wired to actually insert rows on the `@add`
  emit via an exposed ref; the new row gets auto-opened inline editor
  and scrolls into view (double nextTick + `block: 'center'`) so long
  lists don't drop the append below the viewport.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds two new unit-test files for previously-uncovered composables and
extends the three component test suites with coverage for the recently
reworked behaviours.

New tests:
- `useDragInstruction.test.ts` (16 `it`): pure-logic coverage of the
  drop instruction function — null guards on source/descendant hover,
  all instruction types (sibling, make-child, reparent, root-top),
  clamp bounds (`[next.depth, prev.depth + 1]`), subtree exclusion from
  clamp, refEdge, pointer-X rounding, blocked/maxDepth wrapping.
- `useNestedListEditor.test.ts` (57 `it`): comprehensive tree-editor
  coverage — addItem/deleteItem/updateItem, moveUp/Down/Top/Bottom,
  indent/outdent with depth + sibling constraints, moveTo cycle +
  maxDepth guards, viewItems DFS preorder + visibility flags,
  calculateSubtreeDepth, recalculatePositions (clone + no-mutate).

Extended component tests (ASortableListEditor + ANestedSortableListEditor):
- reorder-toolbar rendering in the card header (no bottom `.__toolbar`)
- Apply button enablement bound to `hasPendingChanges`
- `movedKeys` lifecycle: cleared on enter, kept after apply, cleared
  via `resetDirtyBaseline`
- Only actively moved rows flagged (no side-effect index-shift noise)
- Dirty comparison ignores position/parent fields
- Nested: moving a parent marks its whole subtree as moved
- Nested: view-mode kebab order + icons, reorder-mode kebab Delete item
- Nested: "Add inside" appends to end of children

Fix: `headerVisible` in both sortable components now includes
`reorderMode.value` so the relocated toolbar stays visible when the
component has no title — previously the moved Cancel/Apply buttons
disappeared on reorder entry when title was absent.

Totals: 5 suites, 214 tests, all green; TS clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The three list-editor variants (AListEditor, ASortableListEditor,
ANestedSortableListEditor) grew with a lot of copy-pasted logic,
templates, and CSS. Refactor in four stages — each verified against
the 214-test suite (now 244 with the earlier composable coverage) and
TypeScript + Stylelint.

### Stage 1: dirty-baseline + delete-dialog composables

- `composables/useDirtyBaseline.ts` — content-hash map keyed by row id,
  with a `stringifyContent` that strips configurable fields
  (positionField / parentField) before JSON-stringifying. Each caller
  passes its own traversal via `getEntries`.
- `composables/useDeleteDialog.ts` — owns the delete dialog's state
  (`deleteDialog`, `deleteTarget`, `deleteInFlight`, `deleteError`) plus
  the open/confirm/cancel handlers and `performDelete`. Generic over
  the caller's view-item shape; actual mutation happens in caller's
  `onDeleted` hook so each editor can also prune per-variant state.

### Stage 2: inline-editing + reorder-mode composables

- `composables/useInlineEditing.ts` — `editingKeys`, `editingSnapshots`,
  the pending-auto-open watcher (with double-`nextTick` +
  `scrollIntoView({ block: 'center' })` landing for newly-added rows),
  and the edit / cancel / commit / close handlers. Accepts a
  `rowSelector` because each variant's row DOM anchor differs
  (`__row` for flat, `__row-wrapper` for nested).
- `composables/useReorderMode.ts` — mode ref wrapper (accepts a
  `defineModel('mode')` from the component so `v-model:mode` stays
  intact), `snapshot`, `movedKeys` management, and
  `enterReorderMode` / `cancelReorderMode` / `applyReorder` including
  the internal `watch(mode, ...)` that handles external mode flips.
  Only used by the two sortable variants.

### Stage 3: shared Vue components (under internal/)

- `internal/ALeDeleteDialog.vue` — the VDialog + VCard confirmation
  block; accepts a `class` pass-through so each caller supplies its
  legacy BEM class and the existing test selectors keep matching.
- `internal/ALeEmptyState.vue` — empty-state block with `blockClass`
  prop to namespace the `__empty-title` / `__empty-text` descendants.
- `internal/ALeStatus.vue` — reorder-toolbar status pill (error /
  pending / no-changes variants with `text-body-small` +
  `text-medium-emphasis` on the idle state).
- `internal/ALeUnsavedLabel.vue` — warning-dot + "Unsaved" translated
  text span.
- `internal/ALeDragHandle.vue` — mdi-drag VIcon wrapper; legacy
  `__drag-handle` class flows through so SortableJS's handle selector
  keeps matching.

### Stage 4: CSS tokens + shared SCSS partials

- `styles/_tokens.scss` — `le-tokens` mixin declaring the 24 `--le-*`
  custom properties once, plus `le-shell-container` for the shared
  `container-name: le-shell` setup. Unifies the three previous
  prefixes (`--ale-`, `--asle-`, `--ansle-`) into a single namespace.
- `styles/_shared.scss` — 14 mixins parameterised by BEM block (via
  `#{$block}` interpolation). Covers row primitives, row body, form,
  state / empty blocks, header + header-actions, toolbar status,
  unsaved-label, status-badge, drag-handle, row-add, action visibility,
  container-query desktop rules, mobile breakpoints, and chips layout.
  Each editor now calls `@include shared.le-row-primitives('.a-...')`
  etc. and keeps only its truly-variant-specific styles inlined.

### Results

- AListEditor.vue:           1532 → 539 lines (-993)
- ASortableListEditor.vue:  2221 → 1009 lines (-1212)
- ANestedSortableListEditor: 2300 → 1444 lines (-856)
- Net editor reduction: ≈-3061 lines
- New shared code (composables + components + SCSS): ≈+1974 lines
- 244/244 tests pass, vue-tsc clean, stylelint clean

Behaviour-preserving: the public API (props, emits, `defineExpose`, slot
contracts) is unchanged everywhere. DOM class names are unchanged so
existing tests and consumer CSS both still match.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Mixin-based sharing worked at the source level but still emitted each rule
three times in the compiled CSS (once per variant BEM prefix). Replace
with a single `.a-le-*` class namespace applied directly in the templates;
each shared style is declared once and matches all three variants via the
same class.

### Style layer

- `_tokens.scss` — custom properties declared on the three editor roots
  with a plain multi-selector; no mixin wrapper.
- `_shared.scss` — flat nested SCSS targeting `.a-le-*` classes. Nesting
  used only for pseudo-states (`&:hover`), modifiers (`&--editing`),
  media queries, and container queries. Zero mixins, zero interpolation.
- Each editor's `<style>` block drops `@include`s; variant-specific rules
  (validation rails, chips padding deltas, tree-toggle, drop overlay,
  nested-only tokens) stay scoped under the editor's root class
  (`.a-list-editor`, `.a-sortable-list-editor`, `.a-nested-list-editor`).

### Template layer

- Every inner BEM class (`.a-list-editor__row`, `.a-sortable-list-editor__row-main`, `.a-nested-list-editor__toolbar-status`, …) renamed to its
  `.a-le-*` equivalent in all 4 editor files (incl. ANestedRow) + the 5
  `internal/` leaf components.
- Internal components emit `.a-le-*` classes directly; the `blockClass`
  prop on `ALeEmptyState` and the class-pass-through on `ALeDragHandle` /
  `ALeUnsavedLabel` / `ALeStatus` are gone — one style, one source.
- Root variant classes (`.a-list-editor` etc.) are kept to scope
  variant-specific rules via ancestor selectors.
- `--dirty` modifier on AListEditor folded into the shared `--unsaved`
  name; no tests or consumer CSS depended on the old name.

### Test layer

- All three component test files updated: `.a-*-list-editor__*` selectors
  swapped to `.a-le-*` via `replace_all`. Variant-specific selectors
  (`.a-nested-list-editor__tree-toggle`, `__toolbar` absence checks)
  left alone.
- Doc comment in `useInlineEditing.ts` updated to match new selectors.

### Results

Compiled `common-admin.css` (built with Vite):

| metric             | before  | after   |
|--------------------|--------:|--------:|
| CSS bytes          | 83,671  | 55,385  | -34%
| Rules              |    595  |    475  | -120
| Declarations       |  1,286  |    917  | -369

244/244 tests pass, `vue-tsc --noEmit` clean, `yarn lint:stylelint`
clean. Behaviour preserved — no DOM structure changes beyond class
renames, no new selectors consumers could depend on that didn't exist
before.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`A` is the Anzu convention for publicly exported components; the leaf
components used only inside the list-editor package don't need it. Keep
the prefix on the public trio (AListEditor, ASortableListEditor,
ANestedSortableListEditor) and rename the internals for clarity:

- ANestedRow.vue → internal/LeNestedRow.vue (also moves into internal/
  since it's never imported from outside)
- ALeDeleteDialog → LeDeleteDialog
- ALeDragHandle → LeDragHandle
- ALeEmptyState → LeEmptyState
- ALeStatus → LeStatus
- ALeUnsavedLabel → LeUnsavedLabel

Import paths and template refs updated across the three public editors.
244/244 tests still pass, vue-tsc clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- F1 keyboard navigation: roving tabindex, Esc state machine
  (release-grab → cancel-reorder), nested ←/→ collapse + indent.
  New useKeyboardNav composable, focus-visible + grabbed CSS.
- F2 move-to-position dialog wired into Sortable + Nested kebabs.
- F3 change-parent dialog (Nested only): tree picker with cycle
  prevention, max-depth check, conditional first/last placement.
- F4 unsaved-changes guard: v-model:unsavedKeys on all editors,
  hasUnsavedChanges/unsavedCount/clearUnsavedState exposed.
  New useUnsavedChangesGuard composable + AUnsavedConfirmDialog
  under src/labs/unsavedGuard/. Apply does not auto-clear; parent
  Save clears via empty-set assignment or clearUnsavedState().
- F6 mobile/touch consistency: useContainerWidth replaces 5
  viewport-based reads with container-width detection. Pinned
  --up/--down/--add-child in narrow blocks. Dropped sortable
  --menu hover exception and @media (hover: none) block in favor
  of the --touch class as single source of truth.
- Vitest browser mode runs headless by default (set VITEST_HEADED=1
  to see the browser).
- 62 new tests; 339/339 passing, vue-tsc clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Per-item validation surfaces a red 4 px left rail on rows whose
  validationState is 'invalid'. Three input paths, resolved in order:
  (1) provide/inject registry via useListEditorItemValidation, (2) editor
  prop getValidationState, (3) raw.validationState fallback.
- New composable src/labs/listEditor/composables/useListEditorItemValidation.ts
  lets descendants of the editor (e.g. a per-item form/sentinel) register
  their validity ref without prop-drilling.
- Each editor (flat / sortable / nested) provides ListEditorValidationKey
  and exposes a new optional getValidationState prop for centralized
  validation shapes ($each).
- Validation rail CSS consolidated in styles/_shared.scss so all three
  editors share it; nested editor previously had no rail. Red wins over
  orange unsaved when both apply (z-index 3 over 2 + dedicated
  --unsaved.--validation-invalid background tint).
- 6 new tests covering raw, prop, and registry paths plus priority.
- Recipe: todo/sortable/validation-recipe.md (FAQ-shape sentinel pattern,
  centralized $each pattern, plain raw.validationState shortcut).
- 345/345 tests passing, vue-tsc clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- F1 closure polish:
  - Focus management on row delete: when the focused row is removed,
    focus falls to the next sibling (or new last row), instead of
    vanishing to <body>. Snapshots the previous orderedKeys so the old
    index can be looked up after the row is gone.
  - Keyboard grab visualization: composable now exposes grabbedIndex
    and totalCount; each editor template renders a visually-hidden
    aria-live region announcing "Grabbed, position N of M" while a
    grab is active. CSS strengthens the --grabbed state (3 px primary
    outline + tinted background + soft shadow + z-index 4 above the
    unsaved rail).
  - Releases grab if the grabbed row is removed externally.

- New canonical slot mutation pattern via actions.update:
  - All three editors expose actions.update(data) on the #item slot's
    actions bundle. Internally calls editor.updateItem(key, data) — the
    same canonical mutation entry point used for inline-edit save and
    drag/drop commit. Decouples consumers from v-model data shape
    (flat array vs nested tree) and routes writes through the editor's
    update path so future hooks (dirty rebaseline, validation registry,
    etc.) all apply uniformly.
  - migration-recipe.md updated with the new pattern: replace
    `v-model="raw"` (which ESLint flags as updating an iteration
    variable, and silently fails when forms emit a whole-object
    update:modelValue) with the explicit
    `:model-value="raw" @update:model-value="actions.update"` form.

- d.ts hygiene:
  - LeChangeParentDialog Props promoted to a generic exported interface
    so the auto-generated lib types can reference it without the
    private-name TS4082 error.
  - LeMoveToPositionDialog and AUnsavedConfirmDialog Props exported for
    the same reason.
  - tsconfig.libdts.json drops its unused tsconfig.node.json reference
    (project reference required composite=true which conflicts with the
    node config's noEmit:true; the reference was never actually used by
    vite-plugin-dts anyway).

- Test type fixes:
  - useUnsavedChangesGuard tests: explicit (c: unknown[]) annotation on
    addSpy.mock.calls.find callbacks (no more implicit any).
  - LeChangeParentDialog test: let TS infer resolveLabel /
    calculateSubtreeDepth params from the component's erased generic;
    cast inside the closure body.
  - AListEditorUnsavedKeys test: switched from
    InstanceType<typeof AListEditor> (broken for generic components)
    to a local AListEditorExposed shape interface.

- New tests:
  - useKeyboardNav.test.ts: 9 cases covering focus-on-delete and
    grabbedIndex/totalCount.
  - AListEditorSlotUpdate.test.ts: 3 cases covering actions.update
    presence, write-through to v-model, and per-row scoping.

- i18n: new common.sortable.keyboardGrab.status key for en/sk/cs.

vue-tsc clean across tsconfig.app.json, tsconfig.test.json,
tsconfig.libdts.json. yarn build (lib + d.ts) clean. 357/357 tests
passing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…s composable

ASortableListEditor gains two opt-in props for the "stacked editors" pattern
where one reorder gesture should drive multiple editors at once:
  - embedded: hides own reorder button + Cancel/Apply toolbar, skips the
    snapshot/restore (parent's deep snapshot covers nested data), paints
    lighter chrome (transparent bg, uppercase title, no card border)
  - allowEditInReorder: keeps inline edit alive in reorder mode so the open
    row can expose a child editor's drag handles

New useNestedUnsavedKeys composable solves the per-parent unsaved-keys
aggregation with prefix-merged keys (`${parentKey}:${childKey}`), avoiding
the collision when two new rows under different parents share id=0.

Reorder toolbar gets a contextual "Reorder mode" label when no title is
set, plus a header-floating SCSS variant collapses the empty header band
when only the Reorder button would occupy it.

New playground view at /view/quiz-manage demonstrates the pattern with one
shared Reorder driving two stacked editors. 6 new tests cover the embedded
mode lifecycle and allowEditInReorder semantics; 363/363 passing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…nd vuelidate fix

Adds 4 tests asserting the row rail colors across editing/unsaved combinations
— specifically that orange (warning) wins over blue (primary) on both the
row ::before and the body border-left when a row is editing+unsaved. Guards
against a regression on the embedded inner editor where the active+dirty
state must paint orange end-to-end.

Embedded "Add answer" button now matches the answer-row height
(min-height: var(--le-row-min-height)) and uses border-radius: 6px to align
with the row corners — same shape language as the rows above it instead of
a tiny pill at the bottom-left.

Playground quiz demo: switched the inline Vuelidate rules to the v2-correct
helpers.forEach() form (the bare $each: { ... } syntax was a v1 carry-over
and silently produced no per-row errors), and adapted the error-shape
check in getValidationState — `$errors[index]` is an OBJECT keyed by
property name, not an array, so `.length > 0` always returned undefined.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ding count

Adds a SharedReorderRegistryKey provide/inject that lets embedded inner
editors push their own movedCount + hasPendingChanges up to the nearest
non-embedded outer editor. The outer's reorder toolbar then totals across
all levels — drag a question OR drag an answer inside an open question
and the same "N pending changes" counter increments either way.

Without this, a move inside the embedded answers list never bumped the
outer's count and the top-level Apply button stayed disabled until the
user also moved a question.

Wired automatically — consumers don't pass anything; embedded editors
auto-register via inject(SharedReorderRegistryKey, null) on mount.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Six performance/refactoring extractions identified in the audit pass, all
opt-in (no behaviour changes for consumers):

  * useUnsavedKeysSync composable — replaces the 35-LOC computed + 2
    watchers + setsEqual helper + suppressNext flag duplicated 3× across
    editors. ~110 LOC saved.
  * useValidationRegistry composable — provide/inject + reactive Map +
    resolveValidation priority chain duplicated 3×. ~60 LOC saved.
  * resolveCompactText util — pure-function fallback chain extracted from
    3 editors. ~36 LOC saved.
  * Per-key actions cache — slot consumers now receive a stable identity
    for actions.update / actions.edit / etc., eliminating spurious
    re-renders that were cascading through deeply-bound child slots.
  * Per-key decoratorCache for viewItemsDecorated — reuses the cached
    object reference when its base view item AND every flag matches, so
    rows whose state didn't change keep stable identity through Vue's
    diff.
  * dirtyKeys computed extracted out of viewItemsDecorated — depends only
    on modelValue, so editing/expanded/loading flag flips no longer
    re-stringify every row.
  * Drag class names hoisted to internal/constants.ts.
  * Dropped dead effectiveCloseVariant on flat + sortable.
  * Dropped unused useContainerWidth call from flat (its only consumer
    was the dead effectiveCloseVariant).

API polish:
  * Slot scope interfaces (RowActions / RowSlotProps / Empty / AddButton /
    Header / Toolbar / ReorderToggle) hoisted to top-level alongside Props,
    marked `export interface`. vite-plugin-dts now rolls them up correctly.
  * defineSlots now uses proper typed slot scopes in all 3 editors
    (replacing `type NestedSlotScope = any` in nested; adding defineSlots
    to flat + sortable which had none).
  * `unsaved` field added to flat editor row scope (= dirty). Mirrors
    sortable + nested for cross-variant slot ergonomics.
  * `reorderDisabled` → `disableReorder` (consistency with disableDrag,
    disableRowClick, disableDeleteConfirm).
  * @deprecated JSDoc on legacy nested aliases (addAfterId, addChildToId,
    removeById, updateData) pointing to canonical names.
  * labs.ts re-exports ReorderModeValue, SharedReorderRegistry,
    SharedReorderRegistryKey for consumers building custom toolbars.

CI cleanup:
  * Pre-existing oxlint errors in useDeleteDialog (unused TItem) +
    useNestedListEditor.test (unused api) fixed.
  * Pre-existing eslint errors in 4 internal dialogs (defineProps order,
    deprecated text-body-2) fixed.
  * Deprecated CSS `clip` → `clip-path: inset(50%)` in shared SR-only.
  * .playwright-cli/** ignored from eslint (debug scratch files).
  * Stale `eslint-disable` directives removed from test files; new
    file-level disables added where mutation-driven test patterns
    legitimately need them.
  * Unused onItemSave handler removed from playground SortableListEditorView.

367/367 tests passing, vue-tsc clean, vite-plugin-dts rollup clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds direct coverage for the composables extracted in the post-audit
refactor pass (previously tested only transitively through the editor
component tests):

  * resolveCompactText (6) — priority chain (compactField → title → name
    → texts.title → text → key → fallback), null/undefined/empty handling
    on the configured field, non-string coercion.
  * useNestedUnsavedKeys (9) — per-parent set storage, prefix-merge for
    id=0 collision avoidance, mixed key types, replace-vs-drop semantics,
    reactivity of the merged set.
  * useUnsavedKeysSync (10) — internal→external sync, full clear via
    onClearAll, per-key clear via onClearKey, suppress-flag bookkeeping
    that prevents ping-pong, imperative clearUnsavedState API.
  * useDirtyBaseline (13) — initial baseline capture, dirty detection,
    excludeFields (single + nested-editor multi), captureDirtyBaseline
    rebaseline-all + key cleanup, rebaselineKey per-row, stringifyContent
    contract.
  * useValidationRegistry (12) — priority order (registry → prop → raw),
    invalid-string fallthrough, descendant sentinel registration via
    useListEditorItemValidation, unmount unregister, reactive state
    propagation, multi-key non-collision, provide/inject contract.

417/417 tests passing; vue-tsc clean; lint clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Bump dev deps (@vue/test-utils, @vueuse/*, axios, eslint, oxlint,
  postcss, stylelint, vue-i18n, etc.) and add @vue/compiler-dom to
  satisfy the @vue/test-utils peer.
- Swap vite-plugin-dts for unplugin-dts (with @microsoft/api-extractor)
  in the lib build config.
- Switch reorder snapshot refs to shallowRef in
  ASortableListEditor / ANestedSortableListEditor — sidesteps the
  UnwrapRef mismatch against useReorderMode's Ref<T | null> signature.
- Drop the dead effectiveCloseVariant computed in the flat editors
  (only the nested editor wires it through), drop unused TItem param
  on UseDeleteDialogApi, and clean up an unused playground handler +
  dead test bindings.
- Ignore .playwright-cli/** in eslint config.
…in into feat/list-editors

# Conflicts:
#	package.json
#	src/labs.ts
#	src/labs/listEditor/AListEditor.vue
#	src/labs/listEditor/ANestedSortableListEditor.vue
#	src/labs/listEditor/ASortableListEditor.vue
#	src/labs/listEditor/composables/useDeleteDialog.ts
#	src/test/components/ASortableListEditor.test.ts
#	src/test/composables/useNestedListEditor.test.ts
#	yarn.lock
…list-editors

# Conflicts:
#	.gitignore
#	package.json
#	src/labs.ts
#	src/labs/listEditor/AListEditor.vue
#	src/labs/listEditor/ANestedSortableListEditor.vue
#	src/labs/listEditor/ASortableListEditor.vue
#	src/labs/listEditor/composables/resolveCompactText.ts
#	src/labs/listEditor/composables/useDeleteDialog.ts
#	src/labs/listEditor/composables/useDirtyBaseline.ts
#	src/labs/listEditor/composables/useDragInstruction.ts
#	src/labs/listEditor/composables/useInlineEditing.ts
#	src/labs/listEditor/composables/useKeyboardNav.ts
#	src/labs/listEditor/composables/useListEditorItemValidation.ts
#	src/labs/listEditor/composables/useNestedListEditor.ts
#	src/labs/listEditor/composables/useNestedUnsavedKeys.ts
#	src/labs/listEditor/composables/useReorderMode.ts
#	src/labs/listEditor/composables/useUnsavedKeysSync.ts
#	src/labs/listEditor/composables/useValidationRegistry.ts
#	src/labs/listEditor/internal/LeChangeParentDialog.vue
#	src/labs/listEditor/internal/LeMoveToPositionDialog.vue
#	src/labs/listEditor/internal/LeNestedRow.vue
#	src/labs/listEditor/styles/_shared.scss
#	src/labs/unsavedGuard/useUnsavedChangesGuard.ts
#	src/playground/listEditorView/ListEditorView.vue
#	src/playground/nestedSortableListEditorView/NestedSortableListEditorView.vue
#	src/playground/quizManageView/QuizManageQuestionAnswers.vue
#	src/playground/quizManageView/QuizManageQuestions.vue
#	src/playground/quizManageView/QuizManageView.vue
#	src/playground/quizManageView/quizMock.ts
#	src/playground/sortableListEditorView/SortableListEditorView.vue
#	src/test/components/AListEditor.test.ts
#	src/test/components/AListEditorSlotUpdate.test.ts
#	src/test/components/AListEditorValidation.test.ts
#	src/test/components/ANestedSortableListEditor.test.ts
#	src/test/components/ASortableListEditor.test.ts
#	src/test/components/ASortableListEditorRailColors.test.ts
#	src/test/components/LeChangeParentDialog.test.ts
#	src/test/composables/resolveCompactText.test.ts
#	src/test/composables/useDirtyBaseline.test.ts
#	src/test/composables/useKeyboardNav.test.ts
#	src/test/composables/useNestedListEditor.test.ts
#	src/test/composables/useNestedUnsavedKeys.test.ts
#	src/test/composables/useUnsavedChangesGuard.test.ts
#	src/test/composables/useUnsavedKeysSync.test.ts
#	src/test/composables/useValidationRegistry.test.ts
#	yarn.lock
volar and others added 2 commits May 5, 2026 10:56
Adds a `view-body` slot (with `ViewBodySlotProps`) so consumers can render
their own view-mode layout (e.g. a card grid) while reorder mode keeps the
editor's row layout. Pass `watchElement: true` to useSortable so SortableJS
rebinds when the rows container mounts/unmounts across mode flips. Drop the
unused `.a-le-reorder-mode-label` toolbar fallback. Header band now wraps
(min-height + flex-wrap, tighter gap/padding) so a long status pill no
longer pushes the toolbar buttons off-band; status pill gets ellipsis
overflow. New playground view and 8-test spec exercise the slot.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces the hand-rolled `useSortable` plumbing (widgetEl, randomUuid,
sortableInstance, forceRerender, widgetHtmlId, forceRerenderWidgetHtml,
initSortable) with `<ASortableListEditor>`. The existing tile grid renders
through the new `#view-body` slot; reorder mode shows a compact thumb +
title + source row via `#item-compact`. AImageDropzone is hidden while in
reorder mode. Reorder commits flow through `onReorderApplied` which
renumbers positions.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
volar added 2 commits May 22, 2026 07:42
volar and others added 10 commits May 23, 2026 08:09
Optional prop on AListEditor / ASortableListEditor / ANestedSortableListEditor.
When true, dirty/moved are forced false in the row decorator → no orange
markers, internalUnsavedKeys stays empty, never feeds the unsaved-keys
v-model or the unsaved-changes dialog. Default false — no change anywhere
else.

Use case: collab-synced editors where data arrives via broadcast and
non-moderators can never save (e.g. ArticleWidgetAuthors).

+ 6 vitest tests covering all three editors (true blocks flagging, false
keeps current behavior as a control).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Removed the implicit fallback chain (`title → name → texts.title → text →
key`) in `resolveCompactText`. Consumers must now opt in explicitly via the
`compactField` prop or the `#item-compact` slot — otherwise the collapsed
row title is empty. Previous behavior could surface raw row ids (UUIDs)
when none of the well-known fields existed (e.g. the article bookmarks
dialog showing `0edb72c6-…` instead of the article headline).

- AListEditor / ASortableListEditor / ANestedSortableListEditor: drop the
  `key` arg + `fallback` option from the call site; template now renders
  `resolveCompactText(vi.raw)` only.
- `resolveCompactText.ts`: simplified to `compactField`-only; returns `''`
  when not set or empty.
- Tests: rewrote `resolveCompactText.test.ts` for the new semantics;
  component test mount helpers now pass `compactField: 'title'` explicitly
  (matching the pre-change implicit pick).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…evices

In chip mode the only reorder affordance was native HTML5 drag, which doesn't
work on touch devices. When `display.platform.touch` is true and reordering
isn't disabled, render up/down arrow buttons next to the chip-close X. Drag
remains the primary affordance on desktop.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Drag was enabled in chip mode even when the editor was readonly/disabled,
letting non-moderators in collab visually reorder rows that wouldn't persist
or broadcast. Gate dragEnabled on canInteract (readonly/disabled/loading)
so readonly users see no drag handle and SortableJS stays disabled.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
defaultExpanded:
- New optional prop on AListEditor. When true, every row renders its #item
  slot from mount, edit pencil is hidden, default inline Save/Cancel footer
  is hidden, row click is a no-op. Use for non-sortable editors where the
  form should always be visible at once (e.g. ThirdPartyTracker, bookmark
  dialogs). Slot scope `editing` is set to true for every row.
- 9 vitest cases covering mount, slot scope, hidden chrome, row-click
  no-op, newly-added rows.

Empty state:
- Drop the secondary `<p>` hint text from LeEmptyState — title + add
  button only. Removes the `text`/`emptyText` props from all three editors
  (AListEditor / ASortableListEditor / ANestedSortableListEditor) and the
  `.a-le-empty-text` style. Old "Začnite pridaním novej položky." line is
  gone.
- `.a-le-state--empty` modifier with padding 12px 16px (vs the 32px 16px
  baseline) tightens the vertical box around the empty hint.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Replace watch+stop with computed so a cached chip reused by Vuetifys
positional VDataTable diff (e.g. toggling column visibility) reflects
its new props.id instead of showing the previously loaded entrys data.
volar added 2 commits June 1, 2026 13:35
@volarname volarname deployed to npmjs-publish June 6, 2026 18:03 — with GitHub Actions Active
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant