Skip to content

Feat/list editors - #214

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

Feat/list editors#214
volarname wants to merge 189 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>
@volarname
volarname deployed to npmjs-publish August 17, 2026 06:36 — with GitHub Actions Active
@volarname
volarname deployed to npmjs-publish August 28, 2026 07:21 — with GitHub Actions Active
volar added 3 commits August 31, 2026 22:12
… silent save failures

ASortableListEditor: SortableJS options are built once at setup, so a `#view-body`
consumer entering reorder mode got an instance re-created from a stale
`disabled: true` object — handles rendered, dragging did nothing. Watch the rows
container alongside `dragEnabled`.

ImageWidgetMultiple:
- gate the editor's mount on the initial fetch, so images arriving later are not
  baselined as "added" ("N unconfirmed changes" on an untouched form)
- re-baseline via `commit()` after a successful save, whose `setImages` replaces
  every row key
- emit the empty id list when the last image is removed, so the parent stops
  holding the old ids and the images do not come back on reload
- refuse to save while the initial fetch is pending or after it failed: the store
  is empty for reasons unrelated to the user, and saving that emptiness detached
  every image the gallery still had
- report the real save error instead of returning a bare `false`
- drop the dead drag handle and arrows from the tile; reordering lives in reorder
  mode, which is the only place SortableJS binds

Tests: dirty baseline at mount and after `commit(rows)`, and a `#view-body`
consumer having a live Sortable instance in reorder mode.
@volarname
volarname deployed to npmjs-publish August 31, 2026 20:15 — with GitHub Actions Active
volar added 9 commits September 1, 2026 17:03
initDamNotifications() was an unguarded factory: every call built a fresh useWebSocket state,
and since `close` was discarded and `autoClose: false` disables both of VueUse's auto-cleanups,
nothing could ever close the previous socket. A consumer calling it from a router guard
accumulated one live socket per attempt, each re-emitting every server message into the shared
event bus.

The handle is now memoized by `enabled|webSocketUrl`, gets a `dispose()`, and
destroyDamNotifications() tears it down and invalidates the previous handle. `autoConnect` is
off because the url is a plain string and VueUse's watch would never fire; openConnection()
also guards on socket status, since VueUse's open() closes and re-inits even a live socket.
imageUploadChunk called `client(CHUNK_UPLOAD_TIMEOUT)`, but every shipped factory is declared
`() => AxiosInstance`, so the argument was dropped and chunk requests ran on the instance
default (15s in ugc/cms, 30s in dam). Had a factory honoured it, the raw 420 would have been
read as 420ms and aborted every chunk almost immediately.

The timeout now travels in the per-request config as `CHUNK_UPLOAD_TIMEOUT * 1000`, where
axios' mergeConfig gives it precedence over the instance default.
removeByIndex() and clearQueue() left notificationFallbackTimer running. The callback
reschedules itself for up to 550s, so a removed item kept polling fetchAsset and holding its
File. Seven other paths in the store already cleared it.

The image widgets waited for the collab field lock by recursing through a 100ms setTimeout,
50 times, with nothing tracking it. A late success ran addByFiles — an upload — into an
unmounted widget, and a late failure showed an error with no context. Waits are now tracked
and settled on unmount, and onDrop checks liveness after the await and before showError.
Each composable kept the current AbortController in a single variable. A second overlapping
call overwrote it, so the first request could no longer be aborted, and the first `finally`
then nulled it while the second was still in flight — leaving abortFetch()/abortRequest() a
no-op for both.

They are now held in a per-instance Set: entries are removed as requests settle, and abort
cancels everything still running. Parallel requests keep working exactly as before.
ASystemBar scheduled its version-check retry without tracking the timeout, so an unmount left
the last one pending to start a fetch the unmount hook could no longer abort.

useDropzoneGlobalDragState built its handlers inside the composable, so every call produced new
function objects. Since removeEventListener matches by identity, destroyGlobalDragState()
removed references that were never added and the original handlers stayed on document forever.
The handlers move to module level, where the shared drag state already lived.
collabRoom returned eight unsubscribe handles but not unsubscribeCollabReconnectListener, so
with disableAutoUnsubscribe it could not be unregistered.

useUnsavedChangesGuard registered a beforeunload listener and relied on onBeforeUnmount to
remove it. Outside a component that hook silently does nothing, so the listener stayed on
window for the life of the page. It now collects its disposers behind an idempotent stop(),
which also settles an open prompt as "stay" — otherwise a teardown mid-prompt left whoever
awaited askToLeave() pending forever — and the dialog watcher bails out once stopped.
@volarname
volarname deployed to npmjs-publish September 1, 2026 19:03 — with GitHub Actions Active
@volarname
volarname deployed to npmjs-publish September 2, 2026 08:35 — with GitHub Actions Active
volar and others added 9 commits September 2, 2026 17:05
oxfmt and eslint-plugin-vue were both formatting .vue templates, rewriting
209 of 258 components on every lint:fix run. The result was stable, so it
was invisible - but it made eslint --cache useless, since ESLint never
writes autofixed files to the cache.

Formatting is now owned by oxfmt alone:

- oxfmt gets singleAttributePerLine so it matches vue/max-attributes-per-line,
  and ignorePatterns ["/*", "!/src"] so it cannot touch docs, tsconfigs or
  reorder package.json (sortPackageJson defaults to true)
- eslint-config-prettier/flat turns off every eslint rule that would fight it,
  replacing @Stylistic entirely (it was 100% redundant with oxfmt)
- stylelint's 21 layout rules are off for the same reason: with them on,
  `yarn format` produced code that `yarn ci` rejected. scss/operator-no-unspaced
  stays on - 1px+2px is a semantic footgun, not formatting
- 16 rules moved from eslint to oxlint, and buildFromOxlintConfigFile now
  derives the disable list from .oxlintrc.json, so enabling a rule in oxlint
  automatically stops eslint from running it

No rule lost coverage: eslint went 129 -> 94 active rules, with all 35
picked up by oxlint or oxfmt.

Also:
- --cache on eslint and stylelint
- ci runs in parallel with aggregated, labelled output and a correct exit code
- format:check added to ci - without it nothing checked formatting any more
- lib:build type-checks before building; the release workflow calls it directly
  and never ran yarn ci
- TypeScript 5.9.3 -> 6.0.3, which flagged the TS7 blockers: baseUrl is
  deprecated (removed from all three tsconfigs, including libdts.json which
  vue-tsc --build never checked) and tsconfig.test.json was missing
  vite-env.d.ts
- files: "src/eslint" -> "src/eslint/**"; yarn pack ignored the former, so
  the ./eslint export shipped empty
- new ./oxlint export runs the Anzu rules under oxlint's JS plugin bridge
- docs:build used ';' and silently ignored a failing build
- .vscode settings and extensions for the whole team; .idea configs dropped

yarn lint:fix 13.2s -> 3.8s, yarn ci 6.0s -> 2.1s
Mechanical, produced by `yarn lint:fix` under the new setup. No logic changes.

- void elements self-close: <img> -> <img />
- single-line content collapses: <div>\n x \n</div> -> <div>x</div>
- wrapped attribute expressions reindent

Collapsing only touched block-level elements (div, h2, h3, VBtn, VCol, ARow,
VSwitch), where leading and trailing whitespace does not render. Padding
spaces oxfmt had to keep around inline content were removed by hand where
the element sits at a block boundary, so nothing renders differently.

htmlWhitespaceSensitivity was deliberately left at its default. Setting it to
"ignore" removes those spaces but also inserts whitespace where there was
none - it split `{{ config.name }}<span class="required" />` and put a space
before the required-field asterisk. All 688 tests passed with that regression
in place, so tests alone do not catch it.

688 tests in 58 files pass, including screenshot tests.
It referenced ./dist/common-admin.es.js, which lib mode has never
emitted. Consumers reading "exports" were unaffected; anything falling
back to "module" resolved to a missing file.
Vite 8 swaps Rollup for Rolldown. The published bundle shrinks 14%
(2 214 750 -> 1 899 690 B; common-admin.js gzip 202 -> 185 kB) and
rollupOptions.external is still honoured, so vue/vuetify/pinia/axios
stay external.

Verified before landing: yarn ci, 688 tests, lib build and docs build
all pass, and the Vite 8 output was linked into a downstream app built
with Vite 8 and loaded headless without a single page error.

The library build gets no faster - unplugin-dts is 87% of it, and
bundleTypes: false is 7s slower, not faster. VitePress keeps its own
nested Vite 5.4.21 and is unaffected either way.
Join and leave raced each other. The server serialises them only while its lease holds, so
a leave arriving during a join could exhaust its retries and answer `failed` with the
membership intact, leaving a ghost that blocks a moderated room until the socket
disconnects. A queue alone was not enough: a stale join still reaches the server, which
broadcasts it and can kick the same user's other tab, so a leave now invalidates a join
still waiting in the queue by generation.

Also:
- `fetchCurrentUser` takes `throwOnError`, so a caller can tell a lost session from a dead
  backend instead of reading `undefined` for both.
- `AUnauthorizedView` accepts `returnUrl`, for apps whose sticky module state would send
  the user straight back after an in-app navigation.
- `ACard` takes `blockInput`, which makes the loader block the keyboard too. Opt-in: `inert`
  drops focus without restoring it, which costs more than it saves on a card whose filter
  sits under the loader.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013LmQp6qBQXvk2u5YjUj8ar
Unrelated to the Vitest 5 upgrade; split out so that change stays
focused on the test runner. Carries the transitive nanoid ^3.3.18
descriptor bump that postcss 8.5.27 requires.
Rename the Vite/Vitest configs to .mts so Node loads them as real ESM,
which also clears Vite 8's configLoader: 'native' warning, and replace
__dirname in the lib config with an import.meta.url equivalent.

Pin clearMocks: true explicitly since v5 flipped that default, and drop
the now-redundant manual clearAllMocks. Point .gitignore at the new
.vitest artifact root, keeping the old entry so pre-upgrade checkouts
stay clean.

Delete 81 stale failure screenshots committed by accident in b48d359.
v5 writes them to .vitest/attachments/failure-screenshots instead. No
ignore rule for __screenshots__, since toMatchScreenshot baselines
still legitimately live there.

Stub a test component with a setup-returned render fn instead of a
template string, which this Vue build cannot compile at runtime.

Fix the contributor guide, which sent people to a Node 20 that v5
rejects, and correct several inaccurate claims in the test README.
Reverts the collaboration part of f409f58. The room membership queue, the
leave generation and the `Superseded` room status are removed; the client no
longer serialises join and leave per room.

The rest of that commit stays: `ACard` `blockInput`, `AUnauthorizedView`
`returnUrl` and `fetchCurrentUser({ throwOnError })` are consumed by admin-cms
work that is not being reverted.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013LmQp6qBQXvk2u5YjUj8ar
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