From b48d359c13408bbab9444acc456686be1fb9a7d6 Mon Sep 17 00:00:00 2001 From: volar Date: Fri, 24 Apr 2026 09:36:31 +0200 Subject: [PATCH 01/98] Add list editor components (flat, sortable, nested) - 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) --- .gitignore | 3 + .yarnrc.yml | 5 + package.json | 42 +- src/labs.ts | 42 + src/labs/listEditor/AListEditor.vue | 1329 ++++++++++ src/labs/listEditor/ANestedRow.vue | 523 ++++ .../listEditor/ANestedSortableListEditor.vue | 2233 +++++++++++++++++ src/labs/listEditor/ASortableListEditor.vue | 1845 ++++++++++++++ .../listEditor/composables/useListEditor.ts | 127 + .../composables/useNestedListEditor.ts | 471 ++++ src/labs/listEditor/types/listEditorTypes.ts | 75 + src/locales/cs/common/sortable.json | 23 + src/locales/en/common/sortable.json | 23 + src/locales/sk/common/sortable.json | 23 + .../listEditorView/ListEditorView.vue | 485 ++++ .../NestedSortableListEditorView.vue | 510 ++++ .../SortableListEditorView.vue | 401 +++ src/router/playground.ts | 18 + src/test/components/AListEditor.test.ts | 782 ++++++ .../ANestedSortableListEditor.test.ts | 672 +++++ .../components/ASortableListEditor.test.ts | 565 +++++ ...er-below-the-row-header-when-editing-1.png | Bin 0 -> 2082 bytes ...er-below-the-row-header-when-editing-2.png | Bin 0 -> 2082 bytes ...er-below-the-row-header-when-editing-3.png | Bin 0 -> 2082 bytes ...-row-header-instead-of-edit---delete-1.png | Bin 0 -> 2082 bytes ...-row-header-instead-of-edit---delete-2.png | Bin 0 -> 2082 bytes ...-row-header-instead-of-edit---delete-3.png | Bin 0 -> 2082 bytes ...e-when-onDeleteConfirm-returns-false-1.png | Bin 0 -> 8917 bytes ...e-when-onDeleteConfirm-returns-false-2.png | Bin 0 -> 8910 bytes ...e-when-onDeleteConfirm-returns-false-3.png | Bin 0 -> 8872 bytes ...em-from-model-when-delete-is-clicked-1.png | Bin 0 -> 8881 bytes ...em-from-model-when-delete-is-clicked-2.png | Bin 0 -> 8907 bytes ...em-from-model-when-delete-is-clicked-3.png | Bin 0 -> 8897 bytes ...te-when-onDeleteConfirm-returns-true-1.png | Bin 0 -> 8859 bytes ...te-when-onDeleteConfirm-returns-true-2.png | Bin 0 -> 8828 bytes ...te-when-onDeleteConfirm-returns-true-3.png | Bin 0 -> 8794 bytes ...er-slot-overrides-the-default-footer-1.png | Bin 0 -> 2082 bytes ...er-slot-overrides-the-default-footer-2.png | Bin 0 -> 2082 bytes ...er-slot-overrides-the-default-footer-3.png | Bin 0 -> 2082 bytes ...ing-state-without-calling-onItemSave-1.png | Bin 0 -> 2082 bytes ...ing-state-without-calling-onItemSave-2.png | Bin 0 -> 2082 bytes ...ing-state-without-calling-onItemSave-3.png | Bin 0 -> 2082 bytes ...-onItemSave-and-clears-editing-state-1.png | Bin 0 -> 2082 bytes ...-onItemSave-and-clears-editing-state-2.png | Bin 0 -> 2082 bytes ...-onItemSave-and-clears-editing-state-3.png | Bin 0 -> 2082 bytes ...editing-class--tonal-active-styling--1.png | Bin 0 -> 2082 bytes ...editing-class--tonal-active-styling--2.png | Bin 0 -> 2082 bytes ...editing-class--tonal-active-styling--3.png | Bin 0 -> 2082 bytes ...er-when-the-row-enters-editing-state-1.png | Bin 0 -> 2082 bytes ...er-when-the-row-enters-editing-state-2.png | Bin 0 -> 2082 bytes ...er-when-the-row-enters-editing-state-3.png | Bin 0 -> 2082 bytes ...em-with-afterId-hint-via-exposed-API-1.png | Bin 0 -> 2082 bytes ...em-with-afterId-hint-via-exposed-API-2.png | Bin 0 -> 2082 bytes ...em-with-afterId-hint-via-exposed-API-3.png | Bin 0 -> 2082 bytes ...with-afterId-hint-via-imperative-ref-1.png | Bin 0 -> 2082 bytes ...with-afterId-hint-via-imperative-ref-2.png | Bin 0 -> 2082 bytes ...with-afterId-hint-via-imperative-ref-3.png | Bin 0 -> 2082 bytes ...model-additions-in-the-rendered-rows-1.png | Bin 0 -> 2082 bytes ...model-additions-in-the-rendered-rows-2.png | Bin 0 -> 2082 bytes ...model-additions-in-the-rendered-rows-3.png | Bin 0 -> 2082 bytes ...es-not-re-trigger-edit-via-row-click-1.png | Bin 0 -> 8783 bytes ...es-not-re-trigger-edit-via-row-click-2.png | Bin 0 -> 8760 bytes ...es-not-re-trigger-edit-via-row-click-3.png | Bin 0 -> 8747 bytes ...ing-the-row-triggers-edit-by-default-1.png | Bin 0 -> 2082 bytes ...ing-the-row-triggers-edit-by-default-2.png | Bin 0 -> 2082 bytes ...ing-the-row-triggers-edit-by-default-3.png | Bin 0 -> 2082 bytes ...d-swaps-edit-delete-for-a-close-icon-1.png | Bin 0 -> 2082 bytes ...d-swaps-edit-delete-for-a-close-icon-2.png | Bin 0 -> 2082 bytes ...d-swaps-edit-delete-for-a-close-icon-3.png | Bin 0 -> 2082 bytes ...-with-Cancel---Save-below-the-header-1.png | Bin 0 -> 2082 bytes ...-with-Cancel---Save-below-the-header-2.png | Bin 0 -> 2082 bytes ...-with-Cancel---Save-below-the-header-3.png | Bin 0 -> 2082 bytes ...rApply-callback-and-exits-on-success-1.png | Bin 0 -> 2082 bytes ...rApply-callback-and-exits-on-success-2.png | Bin 0 -> 2082 bytes ...rApply-callback-and-exits-on-success-3.png | Bin 0 -> 2082 bytes ...-drag-handle-per-row-in-reorder-mode-1.png | Bin 0 -> 2082 bytes ...-drag-handle-per-row-in-reorder-mode-2.png | Bin 0 -> 2082 bytes ...-drag-handle-per-row-in-reorder-mode-3.png | Bin 0 -> 2082 bytes ...arks-moved-rows-with-the-moved-class-1.png | Bin 0 -> 2082 bytes ...arks-moved-rows-with-the-moved-class-2.png | Bin 0 -> 2082 bytes ...arks-moved-rows-with-the-moved-class-3.png | Bin 0 -> 2082 bytes ...riggers-edit-in-view-mode-by-default-1.png | Bin 0 -> 2082 bytes ...riggers-edit-in-view-mode-by-default-2.png | Bin 0 -> 2082 bytes ...riggers-edit-in-view-mode-by-default-3.png | Bin 0 -> 2082 bytes ...er-mode-but-hides-the-reorder-toggle-1.png | Bin 0 -> 2082 bytes ...er-mode-but-hides-the-reorder-toggle-2.png | Bin 0 -> 2082 bytes ...er-mode-but-hides-the-reorder-toggle-3.png | Bin 0 -> 2082 bytes ...-and-the-reorder-toggle-alongside-it-1.png | Bin 0 -> 2082 bytes ...-and-the-reorder-toggle-alongside-it-2.png | Bin 0 -> 2082 bytes ...-and-the-reorder-toggle-alongside-it-3.png | Bin 0 -> 2082 bytes ...dItem-adds-at-end-when-no-hint-given-1.png | Bin 0 -> 2082 bytes ...dItem-adds-at-end-when-no-hint-given-2.png | Bin 0 -> 2082 bytes ...dItem-adds-at-end-when-no-hint-given-3.png | Bin 0 -> 2082 bytes ...em-clamps-index-hint-to-array-length-1.png | Bin 0 -> 2082 bytes ...em-clamps-index-hint-to-array-length-2.png | Bin 0 -> 2082 bytes ...em-clamps-index-hint-to-array-length-3.png | Bin 0 -> 2082 bytes ...eItem-replaces-item-identified-by-id-1.png | Bin 0 -> 2082 bytes ...eItem-replaces-item-identified-by-id-2.png | Bin 0 -> 2082 bytes ...eItem-replaces-item-identified-by-id-3.png | Bin 0 -> 2082 bytes ...es-item-identified-by-item-reference-1.png | Bin 0 -> 2082 bytes ...es-item-identified-by-item-reference-2.png | Bin 0 -> 2082 bytes ...es-item-identified-by-item-reference-3.png | Bin 0 -> 2082 bytes src/test/composables/useListEditor.test.ts | 362 +++ yarn.lock | 955 +++---- 104 files changed, 11017 insertions(+), 497 deletions(-) create mode 100644 src/labs/listEditor/AListEditor.vue create mode 100644 src/labs/listEditor/ANestedRow.vue create mode 100644 src/labs/listEditor/ANestedSortableListEditor.vue create mode 100644 src/labs/listEditor/ASortableListEditor.vue create mode 100644 src/labs/listEditor/composables/useListEditor.ts create mode 100644 src/labs/listEditor/composables/useNestedListEditor.ts create mode 100644 src/labs/listEditor/types/listEditorTypes.ts create mode 100644 src/playground/listEditorView/ListEditorView.vue create mode 100644 src/playground/nestedSortableListEditorView/NestedSortableListEditorView.vue create mode 100644 src/playground/sortableListEditorView/SortableListEditorView.vue create mode 100644 src/test/components/AListEditor.test.ts create mode 100644 src/test/components/ANestedSortableListEditor.test.ts create mode 100644 src/test/components/ASortableListEditor.test.ts create mode 100644 src/test/components/__screenshots__/AListEditor.test.ts/AListEditor-active-row-keeps-title---close-button-renders-row-body---footer-below-the-row-header-when-editing-1.png create mode 100644 src/test/components/__screenshots__/AListEditor.test.ts/AListEditor-active-row-keeps-title---close-button-renders-row-body---footer-below-the-row-header-when-editing-2.png create mode 100644 src/test/components/__screenshots__/AListEditor.test.ts/AListEditor-active-row-keeps-title---close-button-renders-row-body---footer-below-the-row-header-when-editing-3.png create mode 100644 src/test/components/__screenshots__/AListEditor.test.ts/AListEditor-active-row-keeps-title---close-button-shows-a-close--X--button-in-the-editing-row-header-instead-of-edit---delete-1.png create mode 100644 src/test/components/__screenshots__/AListEditor.test.ts/AListEditor-active-row-keeps-title---close-button-shows-a-close--X--button-in-the-editing-row-header-instead-of-edit---delete-2.png create mode 100644 src/test/components/__screenshots__/AListEditor.test.ts/AListEditor-active-row-keeps-title---close-button-shows-a-close--X--button-in-the-editing-row-header-instead-of-edit---delete-3.png create mode 100644 src/test/components/__screenshots__/AListEditor.test.ts/AListEditor-events-and-interactions-aborts-delete-when-onDeleteConfirm-returns-false-1.png create mode 100644 src/test/components/__screenshots__/AListEditor.test.ts/AListEditor-events-and-interactions-aborts-delete-when-onDeleteConfirm-returns-false-2.png create mode 100644 src/test/components/__screenshots__/AListEditor.test.ts/AListEditor-events-and-interactions-aborts-delete-when-onDeleteConfirm-returns-false-3.png create mode 100644 src/test/components/__screenshots__/AListEditor.test.ts/AListEditor-events-and-interactions-emits-delete-and-removes-item-from-model-when-delete-is-clicked-1.png create mode 100644 src/test/components/__screenshots__/AListEditor.test.ts/AListEditor-events-and-interactions-emits-delete-and-removes-item-from-model-when-delete-is-clicked-2.png create mode 100644 src/test/components/__screenshots__/AListEditor.test.ts/AListEditor-events-and-interactions-emits-delete-and-removes-item-from-model-when-delete-is-clicked-3.png create mode 100644 src/test/components/__screenshots__/AListEditor.test.ts/AListEditor-events-and-interactions-proceeds-with-delete-when-onDeleteConfirm-returns-true-1.png create mode 100644 src/test/components/__screenshots__/AListEditor.test.ts/AListEditor-events-and-interactions-proceeds-with-delete-when-onDeleteConfirm-returns-true-2.png create mode 100644 src/test/components/__screenshots__/AListEditor.test.ts/AListEditor-events-and-interactions-proceeds-with-delete-when-onDeleteConfirm-returns-true-3.png create mode 100644 src/test/components/__screenshots__/AListEditor.test.ts/AListEditor-inline-edit-footer--item-footer-slot-overrides-the-default-footer-1.png create mode 100644 src/test/components/__screenshots__/AListEditor.test.ts/AListEditor-inline-edit-footer--item-footer-slot-overrides-the-default-footer-2.png create mode 100644 src/test/components/__screenshots__/AListEditor.test.ts/AListEditor-inline-edit-footer--item-footer-slot-overrides-the-default-footer-3.png create mode 100644 src/test/components/__screenshots__/AListEditor.test.ts/AListEditor-inline-edit-footer-default-Cancel-button-clears-editing-state-without-calling-onItemSave-1.png create mode 100644 src/test/components/__screenshots__/AListEditor.test.ts/AListEditor-inline-edit-footer-default-Cancel-button-clears-editing-state-without-calling-onItemSave-2.png create mode 100644 src/test/components/__screenshots__/AListEditor.test.ts/AListEditor-inline-edit-footer-default-Cancel-button-clears-editing-state-without-calling-onItemSave-3.png create mode 100644 src/test/components/__screenshots__/AListEditor.test.ts/AListEditor-inline-edit-footer-default-Save-button-calls-onItemSave-and-clears-editing-state-1.png create mode 100644 src/test/components/__screenshots__/AListEditor.test.ts/AListEditor-inline-edit-footer-default-Save-button-calls-onItemSave-and-clears-editing-state-2.png create mode 100644 src/test/components/__screenshots__/AListEditor.test.ts/AListEditor-inline-edit-footer-default-Save-button-calls-onItemSave-and-clears-editing-state-3.png create mode 100644 src/test/components/__screenshots__/AListEditor.test.ts/AListEditor-inline-edit-footer-editing-row-has---editing-class--tonal-active-styling--1.png create mode 100644 src/test/components/__screenshots__/AListEditor.test.ts/AListEditor-inline-edit-footer-editing-row-has---editing-class--tonal-active-styling--2.png create mode 100644 src/test/components/__screenshots__/AListEditor.test.ts/AListEditor-inline-edit-footer-editing-row-has---editing-class--tonal-active-styling--3.png create mode 100644 src/test/components/__screenshots__/AListEditor.test.ts/AListEditor-inline-edit-footer-renders-default-Cancel-Save-footer-when-the-row-enters-editing-state-1.png create mode 100644 src/test/components/__screenshots__/AListEditor.test.ts/AListEditor-inline-edit-footer-renders-default-Cancel-Save-footer-when-the-row-enters-editing-state-2.png create mode 100644 src/test/components/__screenshots__/AListEditor.test.ts/AListEditor-inline-edit-footer-renders-default-Cancel-Save-footer-when-the-row-enters-editing-state-3.png create mode 100644 src/test/components/__screenshots__/AListEditor.test.ts/AListEditor-position-hints-supports-addItem-with-afterId-hint-via-exposed-API-1.png create mode 100644 src/test/components/__screenshots__/AListEditor.test.ts/AListEditor-position-hints-supports-addItem-with-afterId-hint-via-exposed-API-2.png create mode 100644 src/test/components/__screenshots__/AListEditor.test.ts/AListEditor-position-hints-supports-addItem-with-afterId-hint-via-exposed-API-3.png create mode 100644 src/test/components/__screenshots__/AListEditor.test.ts/AListEditor-position-hints-supports-addItem-with-afterId-hint-via-imperative-ref-1.png create mode 100644 src/test/components/__screenshots__/AListEditor.test.ts/AListEditor-position-hints-supports-addItem-with-afterId-hint-via-imperative-ref-2.png create mode 100644 src/test/components/__screenshots__/AListEditor.test.ts/AListEditor-position-hints-supports-addItem-with-afterId-hint-via-imperative-ref-3.png create mode 100644 src/test/components/__screenshots__/AListEditor.test.ts/AListEditor-reactivity-reflects-model-additions-in-the-rendered-rows-1.png create mode 100644 src/test/components/__screenshots__/AListEditor.test.ts/AListEditor-reactivity-reflects-model-additions-in-the-rendered-rows-2.png create mode 100644 src/test/components/__screenshots__/AListEditor.test.ts/AListEditor-reactivity-reflects-model-additions-in-the-rendered-rows-3.png create mode 100644 src/test/components/__screenshots__/AListEditor.test.ts/AListEditor-row-click-clicking-action-buttons-does-not-re-trigger-edit-via-row-click-1.png create mode 100644 src/test/components/__screenshots__/AListEditor.test.ts/AListEditor-row-click-clicking-action-buttons-does-not-re-trigger-edit-via-row-click-2.png create mode 100644 src/test/components/__screenshots__/AListEditor.test.ts/AListEditor-row-click-clicking-action-buttons-does-not-re-trigger-edit-via-row-click-3.png create mode 100644 src/test/components/__screenshots__/AListEditor.test.ts/AListEditor-row-click-clicking-the-row-triggers-edit-by-default-1.png create mode 100644 src/test/components/__screenshots__/AListEditor.test.ts/AListEditor-row-click-clicking-the-row-triggers-edit-by-default-2.png create mode 100644 src/test/components/__screenshots__/AListEditor.test.ts/AListEditor-row-click-clicking-the-row-triggers-edit-by-default-3.png create mode 100644 src/test/components/__screenshots__/ASortableListEditor.test.ts/ASortableListEditor-active-row-keeps-title---close-button-in-edit-mode-keeps-the-row-title-visible-and-swaps-edit-delete-for-a-close-icon-1.png create mode 100644 src/test/components/__screenshots__/ASortableListEditor.test.ts/ASortableListEditor-active-row-keeps-title---close-button-in-edit-mode-keeps-the-row-title-visible-and-swaps-edit-delete-for-a-close-icon-2.png create mode 100644 src/test/components/__screenshots__/ASortableListEditor.test.ts/ASortableListEditor-active-row-keeps-title---close-button-in-edit-mode-keeps-the-row-title-visible-and-swaps-edit-delete-for-a-close-icon-3.png create mode 100644 src/test/components/__screenshots__/ASortableListEditor.test.ts/ASortableListEditor-active-row-keeps-title---close-button-in-edit-mode-renders-row-body---default-footer-with-Cancel---Save-below-the-header-1.png create mode 100644 src/test/components/__screenshots__/ASortableListEditor.test.ts/ASortableListEditor-active-row-keeps-title---close-button-in-edit-mode-renders-row-body---default-footer-with-Cancel---Save-below-the-header-2.png create mode 100644 src/test/components/__screenshots__/ASortableListEditor.test.ts/ASortableListEditor-active-row-keeps-title---close-button-in-edit-mode-renders-row-body---default-footer-with-Cancel---Save-below-the-header-3.png create mode 100644 src/test/components/__screenshots__/ASortableListEditor.test.ts/ASortableListEditor-apply-flow-awaits-onReorderApply-callback-and-exits-on-success-1.png create mode 100644 src/test/components/__screenshots__/ASortableListEditor.test.ts/ASortableListEditor-apply-flow-awaits-onReorderApply-callback-and-exits-on-success-2.png create mode 100644 src/test/components/__screenshots__/ASortableListEditor.test.ts/ASortableListEditor-apply-flow-awaits-onReorderApply-callback-and-exits-on-success-3.png create mode 100644 src/test/components/__screenshots__/ASortableListEditor.test.ts/ASortableListEditor-drag-handle-renders-drag-handle-per-row-in-reorder-mode-1.png create mode 100644 src/test/components/__screenshots__/ASortableListEditor.test.ts/ASortableListEditor-drag-handle-renders-drag-handle-per-row-in-reorder-mode-2.png create mode 100644 src/test/components/__screenshots__/ASortableListEditor.test.ts/ASortableListEditor-drag-handle-renders-drag-handle-per-row-in-reorder-mode-3.png create mode 100644 src/test/components/__screenshots__/ASortableListEditor.test.ts/ASortableListEditor-movement-in-reorder-mode-marks-moved-rows-with-the-moved-class-1.png create mode 100644 src/test/components/__screenshots__/ASortableListEditor.test.ts/ASortableListEditor-movement-in-reorder-mode-marks-moved-rows-with-the-moved-class-2.png create mode 100644 src/test/components/__screenshots__/ASortableListEditor.test.ts/ASortableListEditor-movement-in-reorder-mode-marks-moved-rows-with-the-moved-class-3.png create mode 100644 src/test/components/__screenshots__/ASortableListEditor.test.ts/ASortableListEditor-row-click-clicking-the-row-triggers-edit-in-view-mode-by-default-1.png create mode 100644 src/test/components/__screenshots__/ASortableListEditor.test.ts/ASortableListEditor-row-click-clicking-the-row-triggers-edit-in-view-mode-by-default-2.png create mode 100644 src/test/components/__screenshots__/ASortableListEditor.test.ts/ASortableListEditor-row-click-clicking-the-row-triggers-edit-in-view-mode-by-default-3.png create mode 100644 src/test/components/__screenshots__/ASortableListEditor.test.ts/ASortableListEditor-widget-header---reorder-toggle-placement-keeps-title-visible-in-reorder-mode-but-hides-the-reorder-toggle-1.png create mode 100644 src/test/components/__screenshots__/ASortableListEditor.test.ts/ASortableListEditor-widget-header---reorder-toggle-placement-keeps-title-visible-in-reorder-mode-but-hides-the-reorder-toggle-2.png create mode 100644 src/test/components/__screenshots__/ASortableListEditor.test.ts/ASortableListEditor-widget-header---reorder-toggle-placement-keeps-title-visible-in-reorder-mode-but-hides-the-reorder-toggle-3.png create mode 100644 src/test/components/__screenshots__/ASortableListEditor.test.ts/ASortableListEditor-widget-header---reorder-toggle-placement-renders-the-header-with-title-and-the-reorder-toggle-alongside-it-1.png create mode 100644 src/test/components/__screenshots__/ASortableListEditor.test.ts/ASortableListEditor-widget-header---reorder-toggle-placement-renders-the-header-with-title-and-the-reorder-toggle-alongside-it-2.png create mode 100644 src/test/components/__screenshots__/ASortableListEditor.test.ts/ASortableListEditor-widget-header---reorder-toggle-placement-renders-the-header-with-title-and-the-reorder-toggle-alongside-it-3.png create mode 100644 src/test/composables/__screenshots__/useListEditor.test.ts/useListEditor-addItem-adds-at-end-when-no-hint-given-1.png create mode 100644 src/test/composables/__screenshots__/useListEditor.test.ts/useListEditor-addItem-adds-at-end-when-no-hint-given-2.png create mode 100644 src/test/composables/__screenshots__/useListEditor.test.ts/useListEditor-addItem-adds-at-end-when-no-hint-given-3.png create mode 100644 src/test/composables/__screenshots__/useListEditor.test.ts/useListEditor-addItem-clamps-index-hint-to-array-length-1.png create mode 100644 src/test/composables/__screenshots__/useListEditor.test.ts/useListEditor-addItem-clamps-index-hint-to-array-length-2.png create mode 100644 src/test/composables/__screenshots__/useListEditor.test.ts/useListEditor-addItem-clamps-index-hint-to-array-length-3.png create mode 100644 src/test/composables/__screenshots__/useListEditor.test.ts/useListEditor-updateItem-replaces-item-identified-by-id-1.png create mode 100644 src/test/composables/__screenshots__/useListEditor.test.ts/useListEditor-updateItem-replaces-item-identified-by-id-2.png create mode 100644 src/test/composables/__screenshots__/useListEditor.test.ts/useListEditor-updateItem-replaces-item-identified-by-id-3.png create mode 100644 src/test/composables/__screenshots__/useListEditor.test.ts/useListEditor-updateItem-replaces-item-identified-by-item-reference-1.png create mode 100644 src/test/composables/__screenshots__/useListEditor.test.ts/useListEditor-updateItem-replaces-item-identified-by-item-reference-2.png create mode 100644 src/test/composables/__screenshots__/useListEditor.test.ts/useListEditor-updateItem-replaces-item-identified-by-item-reference-3.png create mode 100644 src/test/composables/useListEditor.test.ts diff --git a/.gitignore b/.gitignore index 258857fa..8aa79669 100644 --- a/.gitignore +++ b/.gitignore @@ -25,3 +25,6 @@ dist-ssr *.njsproj *.sln *.sw? + +.playwright-cli +.vitest-attachments diff --git a/.yarnrc.yml b/.yarnrc.yml index 91b1101f..3f8de620 100644 --- a/.yarnrc.yml +++ b/.yarnrc.yml @@ -1,5 +1,10 @@ +approvedGitRepositories: + - "**" + compressionLevel: mixed enableGlobalCache: false +enableScripts: true + nodeLinker: node-modules diff --git a/package.json b/package.json index 13542fdd..95b6f08a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@anzusystems/common-admin", - "packageManager": "yarn@4.13.0", + "packageManager": "yarn@4.14.1", "files": [ "dist", "src/eslint" @@ -57,10 +57,10 @@ }, "devDependencies": { "@anzusystems/common-admin": "workspace:*", - "@intlify/unplugin-vue-i18n": "^11.1.1", + "@intlify/unplugin-vue-i18n": "^11.1.2", "@kyvg/vue3-notification": "^3.4.2", "@mdi/font": "^7.4.47", - "@sentry/vue": "^10.48.0", + "@sentry/vue": "^10.49.0", "@shikijs/vitepress-twoslash": "^4.0.2", "@stylistic/eslint-plugin": "^5.10.0", "@tsconfig/node22": "^22.0.5", @@ -69,53 +69,53 @@ "@types/sortablejs": "^1.15.9", "@types/webfontloader": "^1.6.38", "@vitejs/plugin-vue": "^6.0.6", - "@vitest/browser": "^4.1.4", - "@vitest/browser-playwright": "^4.1.4", - "@vitest/ui": "^4.1.4", + "@vitest/browser": "^4.1.5", + "@vitest/browser-playwright": "^4.1.5", + "@vitest/ui": "^4.1.5", "@vue/eslint-config-typescript": "^14.7.0", - "@vue/language-server": "3.2.6", + "@vue/language-server": "3.2.7", "@vue/test-utils": "^2.4.6", "@vue/tsconfig": "0.9.1", "@vuelidate/core": "^2.0.3", "@vuelidate/validators": "^2.0.4", "@vueuse/core": "^14.2.1", "@vueuse/integrations": "^14.2.1", - "axios": "1.15.0", + "axios": "1.15.2", "cropperjs": "^1.6.2", "dayjs": "1.11.20", - "eslint": "^10.2.0", - "eslint-plugin-oxlint": "1.60.0", - "eslint-plugin-vue": "10.8.0", + "eslint": "^10.2.1", + "eslint-plugin-oxlint": "1.61.0", + "eslint-plugin-vue": "10.9.0", "eslint-plugin-vuetify": "^2.7.2", "npm-run-all2": "^8.0.4", - "oxfmt": "^0.45.0", - "oxlint": "1.60.0", + "oxfmt": "^0.46.0", + "oxlint": "1.61.0", "pinia": "3.0.4", "playwright": "^1.59.1", - "postcss": "^8.5.9", + "postcss": "^8.5.10", "postcss-html": "^1.8.1", "postcss-prefix-selector": "^2.1.1", "rusha": "^0.8.14", "sass": "1.99.0", "socket.io-client": "4.8.3", "sortablejs": "^1.15.7", - "stylelint": "17.7.0", + "stylelint": "17.8.0", "stylelint-config-recommended-vue": "^1.6.1", "stylelint-config-standard-scss": "^17.0.0", "typescript": "5.9.3", "unplugin": "3.0.0", - "uuid": "^13.0.0", + "uuid": "^14.0.0", "vite": "7.3.2", "vite-plugin-dts": "4.5.4", "vite-plugin-vuetify": "^2.1.3", "vitepress": "1.6.4", - "vitest": "^4.1.4", - "vue": "3.5.32", + "vitest": "^4.1.5", + "vue": "3.5.33", "vue-eslint-parser": "^10.4.0", "vue-i18n": "11.3.2", - "vue-router": "5.0.4", - "vue-tsc": "3.2.6", - "vuetify": "4.0.5", + "vue-router": "5.0.6", + "vue-tsc": "3.2.7", + "vuetify": "4.0.6", "webfontloader": "^1.6.28" }, "peerDependencies": { diff --git a/src/labs.ts b/src/labs.ts index 2a3e9e6c..6f47677e 100644 --- a/src/labs.ts +++ b/src/labs.ts @@ -42,6 +42,30 @@ import { createDatatableColumnsConfig } from '@/labs/filters/datatableColumns' import { useSubjectSelect } from '@/labs/subjectSelect/useSubjectSelect' import type { AxiosClientFn } from '@/labs/api/client' import ASubjectSelect from '@/labs/subjectSelect/ASubjectSelect.vue' +import AListEditor from '@/labs/listEditor/AListEditor.vue' +import ASortableListEditor from '@/labs/listEditor/ASortableListEditor.vue' +import ANestedSortableListEditor from '@/labs/listEditor/ANestedSortableListEditor.vue' +import { + useListEditor, + type ListEditorApi, +} from '@/labs/listEditor/composables/useListEditor' +import { + useNestedListEditor, + type NestedListEditorApi, + type NestedViewItem, +} from '@/labs/listEditor/composables/useNestedListEditor' +import type { + ListEditorKey, + ListEditorValidationState, + ListViewItem, + NestedPositionHint, + NestedTree, + NestedTreeNode, + PositionHint, + SortableNested, + SortableNestedItem, + UseListEditorOptions, +} from '@/labs/listEditor/types/listEditorTypes' import { useUserAdminConfigApi } from '@/labs/filters/userAdminConfig' import { useUserAdminConfigFactory } from '@/model/factory/UserAdminConfigFactory' import { @@ -96,6 +120,24 @@ export { createDatatableColumnsConfig, useSubjectSelect, ASubjectSelect, + AListEditor, + ASortableListEditor, + ANestedSortableListEditor, + useListEditor, + type ListEditorApi, + useNestedListEditor, + type NestedListEditorApi, + type NestedViewItem, + type ListEditorKey, + type ListEditorValidationState, + type ListViewItem, + type NestedPositionHint, + type NestedTree, + type NestedTreeNode, + type PositionHint, + type SortableNested, + type SortableNestedItem, + type UseListEditorOptions, useJobApi, type AxiosClientFn, useUserAdminConfigApi, diff --git a/src/labs/listEditor/AListEditor.vue b/src/labs/listEditor/AListEditor.vue new file mode 100644 index 00000000..f748e284 --- /dev/null +++ b/src/labs/listEditor/AListEditor.vue @@ -0,0 +1,1329 @@ + + + + + diff --git a/src/labs/listEditor/ANestedRow.vue b/src/labs/listEditor/ANestedRow.vue new file mode 100644 index 00000000..3779c38d --- /dev/null +++ b/src/labs/listEditor/ANestedRow.vue @@ -0,0 +1,523 @@ + + + diff --git a/src/labs/listEditor/ANestedSortableListEditor.vue b/src/labs/listEditor/ANestedSortableListEditor.vue new file mode 100644 index 00000000..9242c14c --- /dev/null +++ b/src/labs/listEditor/ANestedSortableListEditor.vue @@ -0,0 +1,2233 @@ + + + + + diff --git a/src/labs/listEditor/ASortableListEditor.vue b/src/labs/listEditor/ASortableListEditor.vue new file mode 100644 index 00000000..6d4585cb --- /dev/null +++ b/src/labs/listEditor/ASortableListEditor.vue @@ -0,0 +1,1845 @@ + + + + + diff --git a/src/labs/listEditor/composables/useListEditor.ts b/src/labs/listEditor/composables/useListEditor.ts new file mode 100644 index 00000000..7f1d379d --- /dev/null +++ b/src/labs/listEditor/composables/useListEditor.ts @@ -0,0 +1,127 @@ +import { computed, type ComputedRef, type Ref } from 'vue' +import type { + ListEditorKey, + ListViewItem, + PositionHint, + UseListEditorOptions, +} from '@/labs/listEditor/types/listEditorTypes' + +export interface ListEditorApi { + viewItems: ComputedRef[]> + addItem: (data: TItem, positionHint?: PositionHint) => TItem[] + deleteItem: (idOrItem: ListEditorKey | TItem) => TItem[] + updateItem: (idOrItem: ListEditorKey | TItem, data: TItem) => TItem[] + moveItem: (fromIndex: number, toIndex: number) => TItem[] + recalculatePositions: (items: TItem[]) => TItem[] +} + +/** + * Flat list editor core. Pure data behavior shared by AListEditor and + * ASortableListEditor. Does not touch the DOM, SortableJS, or emits. + * + * The caller passes a writable ref (typically `defineModel`). + * Mutators write a new array to `model.value` and also return it. + * Input items are never mutated — when positions need to change, a new + * item object is produced with `{ ...item, [positionField]: n }`. + */ +export function useListEditor>( + model: Ref, + options: UseListEditorOptions = {}, +): ListEditorApi { + const keyField = options.keyField ?? 'id' + const positionField = options.positionField ?? 'position' + const positionMultiplier = options.positionMultiplier ?? 1 + const updatePositionEnabled = options.updatePosition === true + + const viewItems = computed[]>(() => + model.value.map((raw, index) => ({ + key: raw[keyField] as ListEditorKey, + index, + raw, + position: raw[positionField] as number | undefined, + })), + ) + + const recalculatePositions = (items: TItem[]): TItem[] => + items.map((item, idx) => { + const newPosition = (idx + 1) * positionMultiplier + if (item[positionField] === newPosition) return item + return { ...item, [positionField]: newPosition } + }) + + const finalize = (arr: TItem[]): TItem[] => { + const result = updatePositionEnabled ? recalculatePositions(arr) : arr + model.value = result + return result + } + + const isItem = (value: ListEditorKey | TItem): value is TItem => + value !== null && typeof value === 'object' + + const resolveIndexByKey = (items: TItem[], key: ListEditorKey): number => + items.findIndex((x) => x[keyField] === key) + + const resolveIndex = (items: TItem[], idOrItem: ListEditorKey | TItem): number => { + const key = isItem(idOrItem) ? (idOrItem[keyField] as ListEditorKey) : idOrItem + return resolveIndexByKey(items, key) + } + + const resolveInsertIndex = (items: TItem[], hint?: PositionHint): number => { + if (!hint) return items.length + if (hint.afterId !== undefined) { + const idx = resolveIndexByKey(items, hint.afterId) + return idx === -1 ? items.length : idx + 1 + } + if (hint.afterIndex !== undefined) { + if (hint.afterIndex < 0) return items.length + return Math.min(hint.afterIndex + 1, items.length) + } + if (hint.index !== undefined) { + return Math.max(0, Math.min(hint.index, items.length)) + } + return items.length + } + + const addItem = (data: TItem, positionHint?: PositionHint): TItem[] => { + const arr = [...model.value] + const insertIndex = resolveInsertIndex(arr, positionHint) + arr.splice(insertIndex, 0, data) + return finalize(arr) + } + + const deleteItem = (idOrItem: ListEditorKey | TItem): TItem[] => { + const idx = resolveIndex(model.value, idOrItem) + if (idx === -1) return model.value + const arr = [...model.value] + arr.splice(idx, 1) + return finalize(arr) + } + + const updateItem = (idOrItem: ListEditorKey | TItem, data: TItem): TItem[] => { + const idx = resolveIndex(model.value, idOrItem) + if (idx === -1) return model.value + const arr = [...model.value] + arr[idx] = data + return finalize(arr) + } + + const moveItem = (fromIndex: number, toIndex: number): TItem[] => { + const len = model.value.length + if (fromIndex === toIndex) return model.value + if (fromIndex < 0 || fromIndex >= len) return model.value + if (toIndex < 0 || toIndex >= len) return model.value + const arr = [...model.value] + const [el] = arr.splice(fromIndex, 1) + arr.splice(toIndex, 0, el) + return finalize(arr) + } + + return { + viewItems, + addItem, + deleteItem, + updateItem, + moveItem, + recalculatePositions, + } +} diff --git a/src/labs/listEditor/composables/useNestedListEditor.ts b/src/labs/listEditor/composables/useNestedListEditor.ts new file mode 100644 index 00000000..5f02b575 --- /dev/null +++ b/src/labs/listEditor/composables/useNestedListEditor.ts @@ -0,0 +1,471 @@ +import { computed, type ComputedRef, type Ref } from 'vue' +import { cloneDeep, isUndefined } from '@/utils/common' +import type { + ListEditorKey, + ListViewItem, + NestedTree, + NestedTreeNode, + PositionHint, +} from '@/labs/listEditor/types/listEditorTypes' + +export interface NestedViewItem extends ListViewItem { + node: NestedTreeNode + depth: number + parent: TItem | null + parentKey: ListEditorKey | null + childrenCount: number + hasChildren: boolean + childrenAllowed: boolean + siblingIndex: number + siblingCount: number + firstInParent: boolean + lastInParent: boolean + canAddChild: boolean + canIndent: boolean + canOutdent: boolean +} + +export interface UseNestedListEditorOptions { + keyField?: string + positionField?: string + parentField?: string + positionMultiplier?: number + maxDepth: number + expandedKeys?: Ref> +} + +export interface NestedListEditorApi> { + viewItems: ComputedRef[]> + findNode: (id: ListEditorKey) => { node: NestedTreeNode | null; parent: NestedTreeNode | null } + addItem: ( + data: TItem, + hint?: PositionHint & { parentId?: ListEditorKey | null; asFirstChild?: boolean; childrenAllowed?: boolean }, + ) => NestedTree + deleteItem: (id: ListEditorKey) => NestedTree + updateItem: (id: ListEditorKey, data: TItem) => NestedTree + moveUp: (id: ListEditorKey) => NestedTree | null + moveDown: (id: ListEditorKey) => NestedTree | null + moveTop: (id: ListEditorKey) => NestedTree | null + moveBottom: (id: ListEditorKey) => NestedTree | null + indent: (id: ListEditorKey) => NestedTree | null + outdent: (id: ListEditorKey) => NestedTree | null + moveTo: ( + id: ListEditorKey, + targetParentId: ListEditorKey | null, + targetIndex: number, + ) => NestedTree | null + recalculatePositions: (model: NestedTree) => NestedTree + calculateSubtreeDepth: (node: NestedTreeNode) => number +} + +const DEFAULT_KEY_FIELD = 'id' +const DEFAULT_POSITION_FIELD = 'position' +const DEFAULT_PARENT_FIELD = 'parent' + +/** + * Nested list editor core. Pure data behavior shared by ANestedSortableListEditor. + * Works on a tree of `{ data, children, meta }` nodes — shape-compatible with the + * legacy `SortableNested` wrapper so migration can pass existing data through + * unchanged. Mutators return a cloned tree and also assign it to `model.value`. + */ +export function useNestedListEditor>( + model: Ref>, + options: UseNestedListEditorOptions, +): NestedListEditorApi { + const keyField = options.keyField ?? DEFAULT_KEY_FIELD + const positionField = options.positionField ?? DEFAULT_POSITION_FIELD + const parentField = options.parentField ?? DEFAULT_PARENT_FIELD + const positionMultiplier = options.positionMultiplier ?? 1 + const maxDepth = options.maxDepth + + const getKey = (data: TItem): ListEditorKey => data[keyField] as ListEditorKey + + const calculateSubtreeDepth = (node: NestedTreeNode): number => { + if (!node.children || node.children.length === 0) return 1 + let max = 0 + for (const child of node.children) { + const d = calculateSubtreeDepth(child) + if (d > max) max = d + } + return max + 1 + } + + const findNode = ( + id: ListEditorKey, + arr: NestedTreeNode[] = model.value.children, + parent: NestedTreeNode | null = null, + ): { node: NestedTreeNode | null; parent: NestedTreeNode | null } => { + for (const item of arr) { + if (getKey(item.data) === id) return { node: item, parent } + if (item.children && item.children.length > 0) { + const found = findNode(id, item.children, item) + if (found.node) return found + } + } + return { node: null, parent: null } + } + + const recalculateSiblings = (siblings: NestedTreeNode[]) => { + let pos = 1 * positionMultiplier + for (const sibling of siblings) { + if (sibling.data[positionField] !== pos) { + ;(sibling.data as any)[positionField] = pos + } + pos += positionMultiplier + } + } + + const recalculateAll = (tree: NestedTree) => { + const walk = (arr: NestedTreeNode[]) => { + recalculateSiblings(arr) + for (const item of arr) if (item.children && item.children.length) walk(item.children) + } + walk(tree.children) + } + + const recalculatePositions = (tree: NestedTree): NestedTree => { + const cloned = cloneDeep(tree) as NestedTree + recalculateAll(cloned) + return cloned + } + + const buildViewItems = (tree: NestedTree): NestedViewItem[] => { + const flat: NestedViewItem[] = [] + const expandedKeys = options.expandedKeys?.value + let flatIndex = 0 + const walk = ( + nodes: NestedTreeNode[], + depth: number, + parentNode: NestedTreeNode | null, + ) => { + for (let i = 0; i < nodes.length; i++) { + const node = nodes[i] + const key = getKey(node.data) + const childrenAllowed = !isUndefined(node.children) + const hasChildren = childrenAllowed && (node.children?.length ?? 0) > 0 + const childrenCount = node.children?.length ?? 0 + const isExpanded = expandedKeys ? expandedKeys.has(key) : true + const remainingDepth = maxDepth - (depth + 1) + const canAddChild = childrenAllowed && remainingDepth > 0 + const canOutdent = depth > 0 + const canIndent = i > 0 + flat.push({ + key, + index: flatIndex++, + raw: node.data, + position: node.data[positionField] as number | undefined, + node, + depth, + parent: parentNode?.data ?? null, + parentKey: parentNode ? getKey(parentNode.data) : null, + childrenCount, + hasChildren, + childrenAllowed, + siblingIndex: i, + siblingCount: nodes.length, + firstInParent: i === 0, + lastInParent: i === nodes.length - 1, + canAddChild, + canIndent, + canOutdent, + }) + if (hasChildren && isExpanded) { + walk(node.children as NestedTreeNode[], depth + 1, node) + } + } + } + walk(tree.children, 0, null) + return flat + } + + const viewItems = computed[]>(() => buildViewItems(model.value)) + + const insertAfter = ( + siblings: NestedTreeNode[], + afterIdx: number, + newNode: NestedTreeNode, + ) => { + siblings.splice(afterIdx + 1, 0, newNode) + } + + const resolveInsertIndex = ( + siblings: NestedTreeNode[], + hint?: PositionHint, + ): number => { + if (!hint) return siblings.length + if (hint.afterId !== undefined) { + const idx = siblings.findIndex((s) => getKey(s.data) === hint.afterId) + return idx === -1 ? siblings.length : idx + 1 + } + if (hint.afterIndex !== undefined) { + if (hint.afterIndex < 0) return siblings.length + return Math.min(hint.afterIndex + 1, siblings.length) + } + if (hint.index !== undefined) { + return Math.max(0, Math.min(hint.index, siblings.length)) + } + return siblings.length + } + + const addItem = ( + data: TItem, + hint?: PositionHint & { parentId?: ListEditorKey | null; asFirstChild?: boolean; childrenAllowed?: boolean }, + ): NestedTree => { + const cloned = cloneDeep(model.value) as NestedTree + const childrenAllowed = hint?.childrenAllowed ?? true + const newNode: NestedTreeNode = { + data: cloneDeep(data) as TItem, + children: childrenAllowed ? [] : undefined, + meta: { dirty: false }, + } + + let targetSiblings: NestedTreeNode[] = cloned.children + let parentNode: NestedTreeNode | null = null + + if (hint?.parentId !== undefined && hint.parentId !== null) { + const { node } = findNode(hint.parentId, cloned.children) + if (node && node.children) { + targetSiblings = node.children + parentNode = node + } + } + + if (hint?.afterId !== undefined) { + const { node: afterNode, parent: afterParent } = findNode(hint.afterId, cloned.children) + if (afterNode) { + const siblings = afterParent ? (afterParent.children as NestedTreeNode[]) : cloned.children + const idx = siblings.indexOf(afterNode) + if (idx !== -1) { + insertAfter(siblings, idx, newNode) + ;(newNode.data as any)[parentField] = afterParent ? getKey(afterParent.data) : null + recalculateSiblings(siblings) + model.value = cloned + return cloned + } + } + } + + const insertIdx = hint?.asFirstChild ? 0 : resolveInsertIndex(targetSiblings, hint) + targetSiblings.splice(insertIdx, 0, newNode) + ;(newNode.data as any)[parentField] = parentNode ? getKey(parentNode.data) : null + recalculateSiblings(targetSiblings) + model.value = cloned + return cloned + } + + const deleteItem = (id: ListEditorKey): NestedTree => { + const cloned = cloneDeep(model.value) as NestedTree + const { node, parent } = findNode(id, cloned.children) + if (!node) return cloned + const siblings = parent ? (parent.children as NestedTreeNode[]) : cloned.children + const idx = siblings.indexOf(node) + if (idx === -1) return cloned + siblings.splice(idx, 1) + recalculateSiblings(siblings) + model.value = cloned + return cloned + } + + const updateItem = (id: ListEditorKey, data: TItem): NestedTree => { + const cloned = cloneDeep(model.value) as NestedTree + const { node } = findNode(id, cloned.children) + if (!node) return cloned + node.data = cloneDeep(data) as TItem + model.value = cloned + return cloned + } + + const swapSiblings = ( + cloned: NestedTree, + id: ListEditorKey, + getTargetIndex: (siblings: NestedTreeNode[], currentIdx: number) => number | null, + ): NestedTree | null => { + const { node, parent } = findNode(id, cloned.children) + if (!node) return null + const siblings = parent ? (parent.children as NestedTreeNode[]) : cloned.children + const idx = siblings.indexOf(node) + if (idx === -1) return null + const target = getTargetIndex(siblings, idx) + if (target === null || target === idx) return null + const [removed] = siblings.splice(idx, 1) + siblings.splice(target, 0, removed) + recalculateSiblings(siblings) + return cloned + } + + const moveUp = (id: ListEditorKey): NestedTree | null => { + const cloned = cloneDeep(model.value) as NestedTree + const res = swapSiblings(cloned, id, (_s, idx) => (idx > 0 ? idx - 1 : null)) + if (res) model.value = res + return res + } + + const moveDown = (id: ListEditorKey): NestedTree | null => { + const cloned = cloneDeep(model.value) as NestedTree + const res = swapSiblings(cloned, id, (s, idx) => (idx < s.length - 1 ? idx + 1 : null)) + if (res) model.value = res + return res + } + + const moveTop = (id: ListEditorKey): NestedTree | null => { + const cloned = cloneDeep(model.value) as NestedTree + const res = swapSiblings(cloned, id, (_s, idx) => (idx > 0 ? 0 : null)) + if (res) model.value = res + return res + } + + const moveBottom = (id: ListEditorKey): NestedTree | null => { + const cloned = cloneDeep(model.value) as NestedTree + const res = swapSiblings(cloned, id, (s, idx) => (idx < s.length - 1 ? s.length - 1 : null)) + if (res) model.value = res + return res + } + + /** + * Indent: move the item under its previous sibling as its last child. + * Fails if: no previous sibling, previous sibling doesn't allow children, + * or the resulting depth would exceed maxDepth. + */ + const indent = (id: ListEditorKey): NestedTree | null => { + const cloned = cloneDeep(model.value) as NestedTree + const { node, parent } = findNode(id, cloned.children) + if (!node) return null + const siblings = parent ? (parent.children as NestedTreeNode[]) : cloned.children + const idx = siblings.indexOf(node) + if (idx <= 0) return null + const prev = siblings[idx - 1] + if (isUndefined(prev.children)) return null + const subtreeDepth = calculateSubtreeDepth(node) + const prevDepth = calculateParentDepth(cloned, getKey(prev.data)) + if (prevDepth + 1 + subtreeDepth > maxDepth) return null + + siblings.splice(idx, 1) + prev.children!.push(node) + ;(node.data as any)[parentField] = getKey(prev.data) + recalculateSiblings(siblings) + recalculateSiblings(prev.children!) + model.value = cloned + return cloned + } + + /** + * Outdent: move the item up one level — it becomes the next sibling of its current parent. + * Fails if the item is already at root. + */ + const outdent = (id: ListEditorKey): NestedTree | null => { + const cloned = cloneDeep(model.value) as NestedTree + const { node, parent } = findNode(id, cloned.children) + if (!node || !parent) return null + + const { parent: grandParent } = findNode(getKey(parent.data), cloned.children) + const grandSiblings = grandParent ? (grandParent.children as NestedTreeNode[]) : cloned.children + const parentIdx = grandSiblings.indexOf(parent) + if (parentIdx === -1) return null + + const currentSiblings = parent.children as NestedTreeNode[] + const idx = currentSiblings.indexOf(node) + if (idx === -1) return null + currentSiblings.splice(idx, 1) + grandSiblings.splice(parentIdx + 1, 0, node) + ;(node.data as any)[parentField] = grandParent ? getKey(grandParent.data) : null + + recalculateSiblings(currentSiblings) + recalculateSiblings(grandSiblings) + model.value = cloned + return cloned + } + + const calculateParentDepth = (tree: NestedTree, id: ListEditorKey): number => { + const walk = ( + arr: NestedTreeNode[], + depth: number, + ): number | null => { + for (const item of arr) { + if (getKey(item.data) === id) return depth + if (item.children && item.children.length) { + const d = walk(item.children, depth + 1) + if (d !== null) return d + } + } + return null + } + return walk(tree.children, 0) ?? 0 + } + + /** + * Move a node to a specific parent at a specific index. Used by drag/drop. + * Respects maxDepth — returns null if the move would exceed it. + */ + const moveTo = ( + id: ListEditorKey, + targetParentId: ListEditorKey | null, + targetIndex: number, + ): NestedTree | null => { + const cloned = cloneDeep(model.value) as NestedTree + const { node, parent } = findNode(id, cloned.children) + if (!node) return null + + if (targetParentId !== null) { + const isDescendant = (n: NestedTreeNode): boolean => { + if (!n.children) return false + for (const c of n.children) { + if (getKey(c.data) === targetParentId) return true + if (isDescendant(c)) return true + } + return false + } + if (getKey(node.data) === targetParentId || isDescendant(node)) return null + } + + let targetSiblings: NestedTreeNode[] + let newParentNode: NestedTreeNode | null = null + let newParentDepth = 0 + if (targetParentId === null) { + targetSiblings = cloned.children + newParentDepth = 0 + } else { + const { node: targetParent } = findNode(targetParentId, cloned.children) + if (!targetParent || isUndefined(targetParent.children)) return null + targetSiblings = targetParent.children + newParentNode = targetParent + newParentDepth = calculateParentDepth(cloned, targetParentId) + 1 + } + + const subtreeDepth = calculateSubtreeDepth(node) + if (newParentDepth + subtreeDepth > maxDepth) return null + + const sourceSiblings = parent ? (parent.children as NestedTreeNode[]) : cloned.children + const sourceIdx = sourceSiblings.indexOf(node) + if (sourceIdx === -1) return null + + const samelist = sourceSiblings === targetSiblings + const [removed] = sourceSiblings.splice(sourceIdx, 1) + let insertAt = targetIndex + if (samelist && sourceIdx < targetIndex) insertAt = targetIndex - 1 + insertAt = Math.max(0, Math.min(insertAt, targetSiblings.length)) + targetSiblings.splice(insertAt, 0, removed) + ;(removed.data as any)[parentField] = newParentNode ? getKey(newParentNode.data) : null + + if (!samelist) recalculateSiblings(sourceSiblings) + recalculateSiblings(targetSiblings) + model.value = cloned + return cloned + } + + return { + viewItems, + findNode, + addItem, + deleteItem, + updateItem, + moveUp, + moveDown, + moveTop, + moveBottom, + indent, + outdent, + moveTo, + recalculatePositions, + calculateSubtreeDepth, + } +} diff --git a/src/labs/listEditor/types/listEditorTypes.ts b/src/labs/listEditor/types/listEditorTypes.ts new file mode 100644 index 00000000..74a2d56e --- /dev/null +++ b/src/labs/listEditor/types/listEditorTypes.ts @@ -0,0 +1,75 @@ +import type { DocId, IntegerId } from '@/types/common' +import type { + SortableItemDataAware, + SortableItemNewPosition, + SortableItemNewPositions, + SortableItemWithParentDataAware, +} from '@/components/sortable/sortableUtils' +import type { + SortableNested, + SortableNestedItem, +} from '@/components/sortable/sortableNestedActions' + +export type { + SortableItemDataAware, + SortableItemNewPosition, + SortableItemNewPositions, + SortableItemWithParentDataAware, + SortableNested, + SortableNestedItem, +} + +export type ListEditorKey = DocId | IntegerId | string + +export type ListEditorValidationState = 'valid' | 'invalid' | 'warning' | null + +export interface ListViewItem { + key: ListEditorKey + index: number + raw: TItem + position?: number + moved?: boolean + expanded?: boolean + editing?: boolean + validationState?: ListEditorValidationState +} + +export interface PositionHint { + afterId?: ListEditorKey + afterIndex?: number + index?: number +} + +export interface UseListEditorOptions { + keyField?: string + positionField?: string + positionMultiplier?: number + updatePosition?: boolean +} + +export interface NestedPositionHint { + parentId?: ListEditorKey | null + afterId?: ListEditorKey + afterIndex?: number + index?: number + asFirstChild?: boolean + childrenAllowed?: boolean +} + +/** + * Shape-compatible with legacy SortableNestedItem but without the + * `SortableItemWithParentDataAware` constraint — so the nested editor can + * accept any record shape that has stable keys addressable via configurable + * fields (keyField, positionField, parentField). Admin-cms data types like + * `LinkedListItemKind` are assignable to this. + */ +export interface NestedTreeNode { + data: TItem + children?: NestedTreeNode[] + meta: { dirty: boolean } +} + +export interface NestedTree { + children: NestedTreeNode[] + meta: { dirty: boolean } +} diff --git a/src/locales/cs/common/sortable.json b/src/locales/cs/common/sortable.json index 76baf061..c6d18650 100644 --- a/src/locales/cs/common/sortable.json +++ b/src/locales/cs/common/sortable.json @@ -1,10 +1,33 @@ { "addNewAtEnd": "Přidání nové položky na poslední pozici", + "add": "Přidat položku", + "addFirst": "Přidat první položku", "edit": "Upravit", + "close": "Zavřít", + "delete": "Smazat", + "deleteConfirmTitle": "Smazat položku?", + "deleteConfirmText": "Opravdu chcete smazat tuto položku? Tuto akci nelze vrátit zpět.", "remove": "Odstranit", "addAfter": "Přidání nové položky za", "addChild": "Přidat dítě", "more": "Další možnosti", + "emptyTitle": "Zatím žádné položky", + "emptyText": "Začněte přidáním nové položky.", + "itemFallback": "Položka", + "reorder": "Přeskupit", + "expandAll": "Rozbalit vše", + "collapseAll": "Sbalit vše", + "reorderApply": "Použít", + "reorderCancel": "Zrušit", + "moveUp": "Posunout nahoru", + "moveDown": "Posunout dolů", + "moveToTop": "Přesunout na začátek", + "moveToBottom": "Přesunout na konec", + "indent": "Zanořit (udělat podřízenou)", + "outdent": "Vynořit (na úroveň rodiče)", + "unsaved": "Neuloženo", + "pendingChanges": "{count} nepotvrzená změna | {count} nepotvrzené změny | {count} nepotvrzených změn", + "noPendingChanges": "Žádné nepotvrzené změny", "error": { "maxDeepExceed": "Nemožnost přesunu. Nová hloubka překročí maximální hloubku.", "unableToAdd": "Nelze přidat položku." diff --git a/src/locales/en/common/sortable.json b/src/locales/en/common/sortable.json index 61a9ebe1..6a54c22c 100644 --- a/src/locales/en/common/sortable.json +++ b/src/locales/en/common/sortable.json @@ -1,10 +1,33 @@ { "addNewAtEnd": "Add new item at last position", + "add": "Add item", + "addFirst": "Add first item", "edit": "Edit", + "close": "Close", + "delete": "Delete", + "deleteConfirmTitle": "Delete item?", + "deleteConfirmText": "Are you sure you want to delete this item? This action cannot be undone.", "remove": "Remove", "addAfter": "Add new item after", "addChild": "Add child", "more": "More options", + "emptyTitle": "No items yet", + "emptyText": "Start by adding a new item.", + "itemFallback": "Item", + "reorder": "Reorder", + "expandAll": "Expand all", + "collapseAll": "Collapse all", + "reorderApply": "Apply", + "reorderCancel": "Cancel", + "moveUp": "Move up", + "moveDown": "Move down", + "moveToTop": "Move to top", + "moveToBottom": "Move to bottom", + "indent": "Indent (make child)", + "outdent": "Outdent (move to parent level)", + "unsaved": "Unsaved", + "pendingChanges": "{count} pending change | {count} pending changes", + "noPendingChanges": "No pending changes", "error": { "maxDeepExceed": "Unable to move. New deep will exceed max deep.", "unableToAdd": "Unable to add item." diff --git a/src/locales/sk/common/sortable.json b/src/locales/sk/common/sortable.json index 7d3257ad..bf1d0254 100644 --- a/src/locales/sk/common/sortable.json +++ b/src/locales/sk/common/sortable.json @@ -1,10 +1,33 @@ { "addNewAtEnd": "Pridať novú položku na poslednú pozíciu", + "add": "Pridať položku", + "addFirst": "Pridať prvú položku", "edit": "Upraviť", + "close": "Zavrieť", + "delete": "Zmazať", + "deleteConfirmTitle": "Zmazať položku?", + "deleteConfirmText": "Naozaj chcete zmazať túto položku? Túto akciu nie je možné vrátiť späť.", "remove": "Odstrániť", "addAfter": "Pridať novú položku za", "addChild": "Pridať podradenú položku", "more": "Ďalšie možnosti", + "emptyTitle": "Zatiaľ žiadne položky", + "emptyText": "Začnite pridaním novej položky.", + "itemFallback": "Položka", + "reorder": "Preskupiť", + "expandAll": "Rozbaliť všetko", + "collapseAll": "Zbaliť všetko", + "reorderApply": "Použiť", + "reorderCancel": "Zrušiť", + "moveUp": "Posunúť hore", + "moveDown": "Posunúť dole", + "moveToTop": "Presunúť na začiatok", + "moveToBottom": "Presunúť na koniec", + "indent": "Zanoriť (spraviť podradenou)", + "outdent": "Vynoriť (na úroveň rodiča)", + "unsaved": "Neuložené", + "pendingChanges": "{count} nepotvrdená zmena | {count} nepotvrdené zmeny | {count} nepotvrdených zmien", + "noPendingChanges": "Žiadne nepotvrdené zmeny", "error": { "maxDeepExceed": "Nie je možné presunúť. Nová hĺbka prekročí maximálnu hĺbku.", "unableToAdd": "Nie je možné pridať položku." diff --git a/src/playground/listEditorView/ListEditorView.vue b/src/playground/listEditorView/ListEditorView.vue new file mode 100644 index 00000000..4378bc9c --- /dev/null +++ b/src/playground/listEditorView/ListEditorView.vue @@ -0,0 +1,485 @@ + + + + + diff --git a/src/playground/nestedSortableListEditorView/NestedSortableListEditorView.vue b/src/playground/nestedSortableListEditorView/NestedSortableListEditorView.vue new file mode 100644 index 00000000..10d9dc3f --- /dev/null +++ b/src/playground/nestedSortableListEditorView/NestedSortableListEditorView.vue @@ -0,0 +1,510 @@ + + + + + diff --git a/src/playground/sortableListEditorView/SortableListEditorView.vue b/src/playground/sortableListEditorView/SortableListEditorView.vue new file mode 100644 index 00000000..747ca7b9 --- /dev/null +++ b/src/playground/sortableListEditorView/SortableListEditorView.vue @@ -0,0 +1,401 @@ + + + + + diff --git a/src/router/playground.ts b/src/router/playground.ts index e1cfb112..421a1d99 100644 --- a/src/router/playground.ts +++ b/src/router/playground.ts @@ -18,6 +18,9 @@ import ApiFetchListBatchView from '@/playground/apiFetchListBatchView/ApiFetchLi import ImageView from '@/playground/imageView/ImageView.vue' import FileView from '@/playground/fileView/FileView.vue' import SortableView from '@/playground/sortableView/SortableView.vue' +import ListEditorView from '@/playground/listEditorView/ListEditorView.vue' +import SortableListEditorView from '@/playground/sortableListEditorView/SortableListEditorView.vue' +import NestedSortableListEditorView from '@/playground/nestedSortableListEditorView/NestedSortableListEditorView.vue' import { initLanguageMessagesLoaded, initLoadLanguageMessages, @@ -138,6 +141,21 @@ const router = createRouter({ name: 'view-sortable', component: SortableView, }, + { + path: '/view/list-editor', + name: 'view-list-editor', + component: ListEditorView, + }, + { + path: '/view/sortable-list-editor', + name: 'view-sortable-list-editor', + component: SortableListEditorView, + }, + { + path: '/view/nested-sortable-list-editor', + name: 'view-nested-sortable-list-editor', + component: NestedSortableListEditorView, + }, { path: '/view/alert', name: 'view-alert', diff --git a/src/test/components/AListEditor.test.ts b/src/test/components/AListEditor.test.ts new file mode 100644 index 00000000..c59f242e --- /dev/null +++ b/src/test/components/AListEditor.test.ts @@ -0,0 +1,782 @@ +import { describe, it, expect, vi } from 'vitest' +import { mount, flushPromises, type VueWrapper } from '@vue/test-utils' +import { defineComponent, h, ref, nextTick } from 'vue' +import AListEditor from '@/labs/listEditor/AListEditor.vue' + +const findAListEditor = (w: VueWrapper): VueWrapper => + w.findComponent(AListEditor as unknown as Parameters[0]) as VueWrapper + +interface FaqItem { + id: number + position: number + title: string + status?: string +} + +const items = (): FaqItem[] => [ + { id: 1, position: 1, title: 'First', status: 'Active' }, + { id: 2, position: 2, title: 'Second', status: 'Draft' }, + { id: 3, position: 3, title: 'Third', status: 'Active' }, +] + +const mountEditor = (data: FaqItem[] = items(), extra: Record = {}) => { + const model = ref(data) + const Host = defineComponent({ + setup() { + return () => + h(AListEditor, { + modelValue: model.value, + 'onUpdate:modelValue': (v: FaqItem[]) => { + model.value = v + }, + ...extra, + }) + }, + }) + const wrapper = mount(Host) + return { wrapper, model, editor: () => findAListEditor(wrapper) } +} + +describe('AListEditor', () => { + describe('rendering', () => { + it('renders one row per item using compact fallback text', () => { + const { wrapper } = mountEditor() + const rows = wrapper.findAll('.a-list-editor__row') + expect(rows).toHaveLength(3) + expect(rows[0].text()).toContain('First') + expect(rows[1].text()).toContain('Second') + expect(rows[2].text()).toContain('Third') + }) + + it('renders status badge when statusField is set', () => { + const { wrapper } = mountEditor(items(), { statusField: 'status' }) + const badges = wrapper.findAll('.a-list-editor__status-badge') + expect(badges).toHaveLength(3) + expect(badges[0].text()).toBe('Active') + }) + + it('uses compactField when provided', () => { + const data: FaqItem[] = [{ id: 1, position: 1, title: 'X' }] + const { wrapper } = mountEditor(data, { compactField: 'id' }) + expect(wrapper.find('.a-list-editor__title').text()).toBe('1') + }) + + it('falls back to fallback label when nothing resolves', () => { + const data = [{ id: 1, position: 1 }] as unknown as FaqItem[] + const { wrapper } = mountEditor(data, { compactField: 'nonexistent' }) + // with compactField set but nothing to pick from fallback chain, falls through + // (id=1 is still picked via 'key' in fallback chain) + expect(wrapper.find('.a-list-editor__title').text()).toBe('1') + }) + + it('renders the default add button row', () => { + const { wrapper } = mountEditor() + expect(wrapper.find('.a-list-editor__row-add').exists()).toBe(true) + }) + + it('does not render the add button when showAddButton=false', () => { + const { wrapper } = mountEditor(items(), { showAddButton: false }) + expect(wrapper.find('.a-list-editor__row-add').exists()).toBe(false) + }) + + it('does not render reorder UI (no drag handle, no reorder toggle)', () => { + const { wrapper } = mountEditor() + expect(wrapper.find('.a-list-editor__reorder-toggle').exists()).toBe(false) + expect(wrapper.find('[class*="handle"]').exists()).toBe(false) + expect(wrapper.html()).not.toContain('mdi-drag') + }) + + it('renders #item slot when the row is in editing state', async () => { + const ItemHost = defineComponent({ + setup() { + const model = ref(items()) + return () => + h( + AListEditor, + { + modelValue: model.value, + 'onUpdate:modelValue': (v: FaqItem[]) => { + model.value = v + }, + }, + { + item: ({ raw }: { raw: FaqItem }) => + h('div', { class: 'custom-editor' }, `editing ${raw.id}`), + }, + ) + }, + }) + const wrapper = mount(ItemHost) + // trigger edit on first row + const editBtn = wrapper.findAll('button').find((b) => b.attributes('class')?.includes('a-list-editor__action--edit')) + expect(editBtn).toBeTruthy() + await editBtn!.trigger('click') + await nextTick() + expect(wrapper.find('.custom-editor').exists()).toBe(true) + expect(wrapper.find('.custom-editor').text()).toBe('editing 1') + }) + }) + + describe('states', () => { + it('renders loading state', () => { + const { wrapper } = mountEditor([], { loading: true }) + expect(wrapper.find('.a-list-editor__state--loading').exists()).toBe(true) + }) + + it('renders error state', () => { + const { wrapper } = mountEditor([], { error: 'Something went wrong' }) + const err = wrapper.find('.a-list-editor__state--error') + expect(err.exists()).toBe(true) + expect(err.text()).toContain('Something went wrong') + }) + + it('renders empty state with default title/text', () => { + const { wrapper } = mountEditor([]) + expect(wrapper.find('.a-list-editor__state--empty').exists()).toBe(true) + expect(wrapper.find('.a-list-editor__empty-title').text()).toBeTruthy() + expect(wrapper.find('.a-list-editor__empty-text').text()).toBeTruthy() + }) + + it('renders empty state with custom title/text', () => { + const { wrapper } = mountEditor([], { + emptyTitle: 'Nothing here', + emptyText: 'Add one', + }) + expect(wrapper.find('.a-list-editor__empty-title').text()).toBe('Nothing here') + expect(wrapper.find('.a-list-editor__empty-text').text()).toBe('Add one') + }) + }) + + describe('events and interactions', () => { + it('emits add with no hint when the add button is clicked', async () => { + const { wrapper, editor } = mountEditor() + await wrapper.find('.a-list-editor__row-add').trigger('click') + expect(editor().emitted('add')).toBeTruthy() + expect(editor().emitted('add')![0][0]).toBeUndefined() + }) + + it('emits edit when the edit button is clicked', async () => { + const { wrapper, editor } = mountEditor() + const editBtns = wrapper.findAll('.a-list-editor__action--edit') + await editBtns[1].trigger('click') + expect(editor().emitted('edit')).toBeTruthy() + const payload = editor().emitted('edit')![0][0] as { key: number; raw: FaqItem } + expect(payload.key).toBe(2) + expect(payload.raw.title).toBe('Second') + }) + + it('emits deleted and removes item from model when disableDeleteConfirm=true', async () => { + const { wrapper, model, editor } = mountEditor(items(), { disableDeleteConfirm: true }) + const deleteBtns = wrapper.findAll('.a-list-editor__action--delete') + await deleteBtns[0].trigger('click') + await flushPromises() + + expect(editor().emitted('deleted')).toBeTruthy() + expect(model.value.map((i) => i.id)).toEqual([2, 3]) + }) + + it('aborts delete when onDeleteConfirm returns false', async () => { + const confirm = vi.fn().mockResolvedValue(false) + const { wrapper, model, editor } = mountEditor(items(), { + onDeleteConfirm: confirm, + disableDeleteConfirm: true, + }) + const deleteBtns = wrapper.findAll('.a-list-editor__action--delete') + await deleteBtns[0].trigger('click') + await flushPromises() + + expect(confirm).toHaveBeenCalledTimes(1) + expect(confirm.mock.calls[0][0]).toMatchObject({ id: 1 }) + expect(model.value.map((i) => i.id)).toEqual([1, 2, 3]) + expect(editor().emitted('deleted')).toBeFalsy() + }) + + it('proceeds with delete when onDeleteConfirm returns true', async () => { + const confirm = vi.fn().mockResolvedValue(true) + const { wrapper, model, editor } = mountEditor(items(), { + onDeleteConfirm: confirm, + disableDeleteConfirm: true, + }) + const deleteBtns = wrapper.findAll('.a-list-editor__action--delete') + await deleteBtns[0].trigger('click') + await flushPromises() + + expect(confirm).toHaveBeenCalledTimes(1) + expect(model.value.map((i) => i.id)).toEqual([2, 3]) + expect(editor().emitted('deleted')).toBeTruthy() + }) + + it('does not emit or mutate when readonly', async () => { + const { wrapper, model } = mountEditor(items(), { readonly: true }) + // edit/delete buttons should not render when !canInteract + expect(wrapper.findAll('.a-list-editor__action--edit')).toHaveLength(0) + expect(wrapper.findAll('.a-list-editor__action--delete')).toHaveLength(0) + expect(wrapper.find('.a-list-editor__row-add').exists()).toBe(false) + expect(model.value).toHaveLength(3) + }) + }) + + describe('position hints', () => { + it('emits add with afterId hint when the slot addAfter action is triggered', async () => { + const Host = defineComponent({ + setup() { + const model = ref(items()) + return () => + h( + AListEditor, + { + modelValue: model.value, + 'onUpdate:modelValue': (v: FaqItem[]) => { + model.value = v + }, + }, + { + 'item-actions': ({ actions }: { actions: { addAfter: () => void } }) => + h( + 'button', + { class: 'test-add-after', onClick: actions.addAfter }, + 'add after', + ), + }, + ) + }, + }) + const wrapper = mount(Host) + const btns = wrapper.findAll('.test-add-after') + await btns[1].trigger('click') + const editor = findAListEditor(wrapper) + const addEvents = editor.emitted('add') as Array<[{ afterId: number } | undefined]> + expect(addEvents).toBeTruthy() + expect(addEvents[0][0]).toEqual({ afterId: 2 }) + }) + }) + + describe('widget header / title', () => { + it('does not render a header when no title and no header slot are provided', () => { + const { wrapper } = mountEditor() + expect(wrapper.find('.a-list-editor__header').exists()).toBe(false) + }) + + it('renders the header with the given title prop', () => { + const { wrapper } = mountEditor(items(), { title: 'Časté otázky (FAQ)' }) + const header = wrapper.find('.a-list-editor__header') + expect(header.exists()).toBe(true) + expect(header.text()).toContain('Časté otázky (FAQ)') + }) + + it('renders the header with the #header slot override', () => { + const model = ref(items()) + const Host = defineComponent({ + setup() { + return () => + h( + AListEditor, + { + modelValue: model.value, + 'onUpdate:modelValue': (v: FaqItem[]) => { + model.value = v + }, + title: 'X', + }, + { + header: ({ title }: { title: string }) => + h('div', { class: 'custom-header' }, `CUSTOM: ${title}`), + }, + ) + }, + }) + const wrapper = mount(Host) + expect(wrapper.find('.custom-header').exists()).toBe(true) + expect(wrapper.find('.custom-header').text()).toBe('CUSTOM: X') + }) + + it('header stays visible over the empty / loading / error states', () => { + const { wrapper: e } = mountEditor([], { title: 'FAQ' }) + expect(e.find('.a-list-editor__header').exists()).toBe(true) + expect(e.find('.a-list-editor__state--empty').exists()).toBe(true) + + const { wrapper: l } = mountEditor([], { title: 'FAQ', loading: true }) + expect(l.find('.a-list-editor__header').exists()).toBe(true) + expect(l.find('.a-list-editor__state--loading').exists()).toBe(true) + }) + }) + + describe('active row keeps title + close button', () => { + const mountWithItemSlot = (extra: Record = {}) => { + const model = ref(items()) + const Host = defineComponent({ + setup() { + return () => + h( + AListEditor, + { + modelValue: model.value, + 'onUpdate:modelValue': (v: FaqItem[]) => { + model.value = v + }, + ...extra, + }, + { + item: ({ raw }: { raw: FaqItem }) => + h('input', { class: 'edit-input', value: raw.title }), + }, + ) + }, + }) + const wrapper = mount(Host) + return { wrapper, model } + } + + it('keeps the row title visible when the row is editing', async () => { + const { wrapper } = mountWithItemSlot() + await wrapper.find('.a-list-editor__row-header').trigger('click') + await nextTick() + + const row = wrapper.find('.a-list-editor__row--editing') + expect(row.exists()).toBe(true) + const title = row.find('.a-list-editor__title') + expect(title.exists()).toBe(true) + expect(title.text()).toBe('First') + }) + + it('shows a close (X) button + delete icon in the editing row header, no edit', async () => { + const { wrapper } = mountWithItemSlot() + await wrapper.find('.a-list-editor__row-header').trigger('click') + await nextTick() + + const editingRow = wrapper.find('.a-list-editor__row--editing') + expect(editingRow.find('.a-list-editor__action--close').exists()).toBe(true) + expect(editingRow.find('.a-list-editor__action--delete').exists()).toBe(true) + expect(editingRow.find('.a-list-editor__action--edit').exists()).toBe(false) + }) + + it('clicking the close button exits editing without saving', async () => { + const save = vi.fn() + const { wrapper } = mountWithItemSlot({ onItemSave: save }) + await wrapper.find('.a-list-editor__row-header').trigger('click') + await nextTick() + + const closeBtn = wrapper.find('.a-list-editor__action--close') + expect(closeBtn.exists()).toBe(true) + await closeBtn.trigger('click') + await nextTick() + + expect(save).not.toHaveBeenCalled() + expect(wrapper.find('.a-list-editor__row--editing').exists()).toBe(false) + }) + + it('renders row body below the row header when editing (no footer by default)', async () => { + const { wrapper } = mountWithItemSlot() + await wrapper.find('.a-list-editor__row-header').trigger('click') + await nextTick() + + const row = wrapper.find('.a-list-editor__row--editing') + expect(row.find('.a-list-editor__row-header').exists()).toBe(true) + expect(row.find('.a-list-editor__row-body').exists()).toBe(true) + // Footer (Cancel/Save) only renders when the consumer supplies onItemSave. + expect(row.find('.a-list-editor__row-footer').exists()).toBe(false) + }) + }) + + describe('inline edit footer', () => { + const mountWithItemSlot = (onItemSave?: (item: FaqItem) => Promise | void) => { + const model = ref(items()) + const Host = defineComponent({ + setup() { + return () => + h( + AListEditor, + { + modelValue: model.value, + 'onUpdate:modelValue': (v: FaqItem[]) => { + model.value = v + }, + onItemSave, + }, + { + item: ({ raw }: { raw: FaqItem }) => + h('input', { class: 'edit-input', value: raw.title }), + }, + ) + }, + }) + const wrapper = mount(Host) + return { wrapper, model, editor: () => findAListEditor(wrapper) } + } + + it('renders default Cancel/Save footer when onItemSave is provided and row enters editing', async () => { + const { wrapper } = mountWithItemSlot(() => Promise.resolve()) + expect(wrapper.find('.a-list-editor__row-footer').exists()).toBe(false) + + await wrapper.find('.a-list-editor__row-header').trigger('click') + await nextTick() + + const footer = wrapper.find('.a-list-editor__row-footer') + expect(footer.exists()).toBe(true) + const buttons = footer.findAll('button') + expect(buttons.map((b) => b.text().trim().toLowerCase())).toEqual( + expect.arrayContaining([expect.stringMatching(/cancel/), expect.stringMatching(/save/)]), + ) + }) + + it('default Save button calls onItemSave and clears editing state', async () => { + const save = vi.fn().mockResolvedValue(undefined) + const { wrapper, editor } = mountWithItemSlot(save) + + await wrapper.find('.a-list-editor__row-header').trigger('click') + await nextTick() + + const saveBtn = wrapper + .find('.a-list-editor__row-footer') + .findAll('button') + .find((b) => b.text().toLowerCase().includes('save'))! + await saveBtn.trigger('click') + await flushPromises() + + expect(save).toHaveBeenCalledTimes(1) + expect(save.mock.calls[0][0]).toMatchObject({ id: 1 }) + expect(editor().emitted('item-saved')).toBeTruthy() + // Footer should be gone — editing state cleared + expect(wrapper.find('.a-list-editor__row-footer').exists()).toBe(false) + }) + + it('default Cancel button clears editing state without calling onItemSave', async () => { + const save = vi.fn() + const { wrapper } = mountWithItemSlot(save) + + await wrapper.find('.a-list-editor__row-header').trigger('click') + await nextTick() + + const cancelBtn = wrapper + .find('.a-list-editor__row-footer') + .findAll('button') + .find((b) => b.text().toLowerCase().includes('cancel'))! + await cancelBtn.trigger('click') + await nextTick() + + expect(save).not.toHaveBeenCalled() + expect(wrapper.find('.a-list-editor__row-footer').exists()).toBe(false) + }) + + it('editing row has --editing class (tonal active styling)', async () => { + const { wrapper } = mountWithItemSlot() + await wrapper.find('.a-list-editor__row-header').trigger('click') + await nextTick() + const editingRow = wrapper.find('.a-list-editor__row') + expect(editingRow.classes()).toContain('a-list-editor__row--editing') + }) + + it('#item-footer slot overrides the default footer', async () => { + const model = ref(items()) + const Host = defineComponent({ + setup() { + return () => + h( + AListEditor, + { + modelValue: model.value, + 'onUpdate:modelValue': (v: FaqItem[]) => { + model.value = v + }, + }, + { + item: ({ raw }: { raw: FaqItem }) => h('div', `editing ${raw.id}`), + 'item-footer': () => h('div', { class: 'custom-footer' }, 'custom footer'), + }, + ) + }, + }) + const wrapper = mount(Host) + await wrapper.find('.a-list-editor__row-header').trigger('click') + await nextTick() + expect(wrapper.find('.custom-footer').exists()).toBe(true) + expect(wrapper.find('.a-list-editor__row-footer').exists()).toBe(false) + }) + }) + + describe('row click', () => { + it('clicking the row triggers edit by default', async () => { + const { wrapper, editor } = mountEditor() + await wrapper.findAll('.a-list-editor__row-header')[1].trigger('click') + const edits = editor().emitted('edit') as Array<[{ key: number }]> | undefined + expect(edits).toBeTruthy() + expect(edits![0][0].key).toBe(2) + }) + + it('row has clickable class by default', () => { + const { wrapper } = mountEditor() + const row = wrapper.findAll('.a-list-editor__row')[0] + expect(row.classes()).toContain('a-list-editor__row--clickable') + }) + + it('does not trigger edit when disableRowClick=true', async () => { + const { wrapper, editor } = mountEditor(items(), { disableRowClick: true }) + const row = wrapper.findAll('.a-list-editor__row')[0] + expect(row.classes()).not.toContain('a-list-editor__row--clickable') + await wrapper.findAll('.a-list-editor__row-header')[0].trigger('click') + expect(editor().emitted('edit')).toBeFalsy() + }) + + it('does not trigger edit when showEditButton=false', async () => { + const { wrapper, editor } = mountEditor(items(), { showEditButton: false }) + const row = wrapper.findAll('.a-list-editor__row')[0] + expect(row.classes()).not.toContain('a-list-editor__row--clickable') + await wrapper.findAll('.a-list-editor__row-header')[0].trigger('click') + expect(editor().emitted('edit')).toBeFalsy() + }) + + it('does not trigger edit when readonly', async () => { + const { wrapper, editor } = mountEditor(items(), { readonly: true }) + await wrapper.findAll('.a-list-editor__row-header')[0].trigger('click') + expect(editor().emitted('edit')).toBeFalsy() + }) + + it('clicking action buttons does not re-trigger edit via row click', async () => { + const { wrapper, editor } = mountEditor(items(), { disableDeleteConfirm: true }) + const deleteBtn = wrapper.findAll('.a-list-editor__action--delete')[0] + await deleteBtn.trigger('click') + await flushPromises() + expect(editor().emitted('deleted')).toBeTruthy() + expect(editor().emitted('edit')).toBeFalsy() + }) + }) + + describe('reactivity', () => { + it('reflects model additions in the rendered rows', async () => { + const { wrapper, model } = mountEditor() + model.value = [...model.value, { id: 99, position: 4, title: 'Added' }] + await nextTick() + const rows = wrapper.findAll('.a-list-editor__row') + expect(rows).toHaveLength(4) + expect(rows[3].text()).toContain('Added') + }) + }) + + describe('showAddAfterAction (kebab "add after this")', () => { + it('does not render a kebab menu button by default', () => { + const { wrapper } = mountEditor() + expect(wrapper.find('.a-list-editor__action--menu').exists()).toBe(false) + }) + + it('renders a kebab menu button when showAddAfterAction=true', () => { + const { wrapper } = mountEditor(items(), { showAddAfterAction: true }) + const menus = wrapper.findAll('.a-list-editor__action--menu') + expect(menus.length).toBe(3) + }) + }) + + describe('delete icon inside editing row header', () => { + const mountWithSlot = () => { + const model = ref(items()) + const Host = defineComponent({ + setup() { + return () => + h( + AListEditor, + { + modelValue: model.value, + 'onUpdate:modelValue': (v: FaqItem[]) => { + model.value = v + }, + }, + { item: ({ raw }: { raw: FaqItem }) => h('input', { value: raw.title }) }, + ) + }, + }) + return { wrapper: mount(Host), model } + } + + it('shows a delete trash icon in the editing row header', async () => { + const { wrapper } = mountWithSlot() + await wrapper.find('.a-list-editor__row-header').trigger('click') + await nextTick() + const row = wrapper.find('.a-list-editor__row--editing') + expect(row.find('.a-list-editor__action--delete').exists()).toBe(true) + }) + }) + + describe('auto-open on add', () => { + it('auto-enters editing on the newly-added row after @add is handled by the parent', async () => { + const model = ref(items()) + const Host = defineComponent({ + setup() { + return () => + h( + AListEditor, + { + modelValue: model.value, + 'onUpdate:modelValue': (v: FaqItem[]) => { + model.value = v + }, + onAdd: () => { + model.value = [ + ...model.value, + { id: 999, position: 0, title: 'New', status: 'Draft' }, + ] + }, + }, + { item: ({ raw }: { raw: FaqItem }) => h('input', { value: raw.title }) }, + ) + }, + }) + const wrapper = mount(Host) + // Trigger add via the bottom "add" button + await wrapper.find('.a-list-editor__row-add').trigger('click') + await flushPromises() + const editing = wrapper.findAll('.a-list-editor__row--editing') + expect(editing.length).toBe(1) + expect(editing[0].text()).toContain('New') + }) + }) + + describe('multi-open editing', () => { + it('opening a second row keeps the first editing (multi-open allowed)', async () => { + const model = ref(items()) + const Host = defineComponent({ + setup() { + return () => + h( + AListEditor, + { + modelValue: model.value, + 'onUpdate:modelValue': (v: FaqItem[]) => { + model.value = v + }, + }, + { item: ({ raw }: { raw: FaqItem }) => h('input', { value: raw.title }) }, + ) + }, + }) + const wrapper = mount(Host) + const headers = wrapper.findAll('.a-list-editor__row-header') + await headers[0].trigger('click') + await nextTick() + await headers[1].trigger('click') + await nextTick() + expect(wrapper.findAll('.a-list-editor__row--editing').length).toBe(2) + }) + }) + + describe('slot overrides', () => { + it('#empty slot replaces the default empty state', () => { + const model = ref([]) + const Host = defineComponent({ + setup() { + return () => + h( + AListEditor, + { + modelValue: model.value, + 'onUpdate:modelValue': (v: FaqItem[]) => { + model.value = v + }, + }, + { empty: () => h('div', { class: 'my-empty' }, 'Nothing here yet') }, + ) + }, + }) + const wrapper = mount(Host) + expect(wrapper.find('.my-empty').exists()).toBe(true) + expect(wrapper.find('.a-list-editor__empty').exists()).toBe(false) + }) + + it('#add-button slot replaces the default add button row', () => { + const model = ref(items()) + const Host = defineComponent({ + setup() { + return () => + h( + AListEditor, + { + modelValue: model.value, + 'onUpdate:modelValue': (v: FaqItem[]) => { + model.value = v + }, + }, + { + 'add-button': ({ + actions, + }: { + actions: { add: () => void } + }) => + h( + 'button', + { class: 'my-add', onClick: actions.add }, + 'Custom add', + ), + }, + ) + }, + }) + const wrapper = mount(Host) + expect(wrapper.find('.my-add').exists()).toBe(true) + expect(wrapper.find('.a-list-editor__row-add').exists()).toBe(false) + }) + + it('#item-actions slot replaces the default row buttons', () => { + const model = ref(items()) + const Host = defineComponent({ + setup() { + return () => + h( + AListEditor, + { + modelValue: model.value, + 'onUpdate:modelValue': (v: FaqItem[]) => { + model.value = v + }, + }, + { + 'item-actions': ({ raw }: { raw: FaqItem }) => + h('span', { class: 'my-actions' }, `actions-${raw.id}`), + }, + ) + }, + }) + const wrapper = mount(Host) + expect(wrapper.findAll('.my-actions').length).toBe(3) + // default action buttons should not appear + expect(wrapper.find('.a-list-editor__action--edit').exists()).toBe(false) + expect(wrapper.find('.a-list-editor__action--delete').exists()).toBe(false) + }) + }) + + describe('exposed imperative API', () => { + it('exposes resetDirtyBaseline via defineExpose', () => { + const { editor } = mountEditor() + const exposed = (editor().vm as unknown as { $: { exposed: Record } }).$ + .exposed + expect(typeof exposed.resetDirtyBaseline).toBe('function') + expect(typeof exposed.addItem).toBe('function') + expect(typeof exposed.deleteItem).toBe('function') + expect(typeof exposed.updateItem).toBe('function') + }) + }) + + describe('position recalculation (updatePosition)', () => { + it('recalculates positions on add when updatePosition=true', async () => { + const { model, editor } = mountEditor(items(), { + updatePosition: true, + positionMultiplier: 10, + }) + const exposed = (editor().vm as unknown as { + $: { exposed: { addItem: (d: FaqItem) => void } } + }).$.exposed + exposed.addItem({ id: 999, position: 0, title: 'Extra' }) + await flushPromises() + const positions = model.value.map((i) => i.position) + expect(positions).toEqual([10, 20, 30, 40]) + }) + + it('does not touch positions when updatePosition=false (default)', async () => { + const { model, editor } = mountEditor(items()) + const exposed = (editor().vm as unknown as { + $: { exposed: { addItem: (d: FaqItem) => void } } + }).$.exposed + exposed.addItem({ id: 999, position: 99, title: 'Extra' }) + await flushPromises() + // Original positions preserved, new item keeps its own + expect(model.value.map((i) => i.position)).toEqual([1, 2, 3, 99]) + }) + }) +}) diff --git a/src/test/components/ANestedSortableListEditor.test.ts b/src/test/components/ANestedSortableListEditor.test.ts new file mode 100644 index 00000000..5733d630 --- /dev/null +++ b/src/test/components/ANestedSortableListEditor.test.ts @@ -0,0 +1,672 @@ +import { describe, it, expect, vi } from 'vitest' +import { mount, flushPromises, type VueWrapper } from '@vue/test-utils' +import { defineComponent, h, nextTick, ref } from 'vue' +import ANestedSortableListEditor from '@/labs/listEditor/ANestedSortableListEditor.vue' +import type { NestedTree } from '@/labs/listEditor/types/listEditorTypes' +import type { NestedViewItem } from '@/labs/listEditor/composables/useNestedListEditor' + +interface MenuItem { + id: number + position: number + parent: number | null + title: string + status?: string +} + +const tree = (): NestedTree => ({ + children: [ + { + data: { id: 1, position: 1, parent: null, title: 'Home', status: 'Active' }, + children: [], + meta: { dirty: false }, + }, + { + data: { id: 2, position: 2, parent: null, title: 'News', status: 'Active' }, + children: [ + { + data: { id: 21, position: 1, parent: 2, title: 'Sport', status: 'Draft' }, + children: [], + meta: { dirty: false }, + }, + { + data: { id: 22, position: 2, parent: 2, title: 'Weather', status: 'Active' }, + children: [], + meta: { dirty: false }, + }, + ], + meta: { dirty: false }, + }, + { + data: { id: 3, position: 3, parent: null, title: 'About', status: 'Draft' }, + children: [], + meta: { dirty: false }, + }, + ], + meta: { dirty: false }, +}) + +const findEditor = (w: VueWrapper): VueWrapper => + w.findComponent( + ANestedSortableListEditor as unknown as Parameters[0], + ) as VueWrapper + +const mountEditor = (data: NestedTree = tree(), extra: Record = {}) => { + const model = ref>(data) + const mode = ref<'view' | 'reorder'>('view') + const Host = defineComponent({ + setup() { + return () => + h(ANestedSortableListEditor, { + modelValue: model.value, + 'onUpdate:modelValue': (v: NestedTree) => { + model.value = v + }, + mode: mode.value, + 'onUpdate:mode': (v: 'view' | 'reorder') => { + mode.value = v + }, + maxDepth: 2, + ...extra, + }) + }, + }) + const wrapper = mount(Host) + return { + wrapper, + model, + mode, + editor: () => findEditor(wrapper), + } +} + +const clickReorder = (wrapper: VueWrapper) => + wrapper + .findAll('button') + .find( + (b) => + b.text().toLowerCase().includes('reorder') + || (b.find('.mdi-sort').exists() && !b.classes().includes('v-btn--disabled')), + )! + .trigger('click') + +describe('ANestedSortableListEditor', () => { + describe('view mode', () => { + it('renders root rows + expanded child rows', () => { + const { wrapper } = mountEditor() + // 3 root + 2 children of News (News is expanded by default, it had children) + const rows = wrapper.findAll('.a-nested-list-editor__row') + expect(rows.length).toBe(5) + }) + + it('hides children when their parent is collapsed', async () => { + const { wrapper } = mountEditor() + // Click the chevron of News (second root) + const chevrons = wrapper.findAll('.a-nested-list-editor__tree-toggle') + // Chevrons exist for items that have children + expect(chevrons.length).toBeGreaterThan(0) + // Click the second root's chevron (News has children) + await chevrons[1].trigger('click') + await nextTick() + const rows = wrapper.findAll('.a-nested-list-editor__row') + expect(rows.length).toBe(3) + }) + + it('renders the reorder toggle by default', () => { + const { wrapper } = mountEditor() + const toggle = wrapper.findAll('button').find((b) => b.text().toLowerCase().includes('reorder')) + expect(toggle).toBeTruthy() + }) + }) + + describe('reorder mode — arrow movement within siblings', () => { + it('moves a root sibling down without affecting its children', async () => { + const { wrapper, model } = mountEditor() + await clickReorder(wrapper) + await flushPromises() + const downs = wrapper.findAll('.a-nested-list-editor__action--down') + // First root down: Home <-> News + await downs[0].trigger('click') + expect(model.value.children.map((n) => n.data.id)).toEqual([2, 1, 3]) + // News' children unchanged + const news = model.value.children.find((n) => n.data.id === 2)! + expect(news.children!.map((c) => c.data.id)).toEqual([21, 22]) + }) + + it('moves a child up within its siblings', async () => { + const { wrapper, model } = mountEditor() + await clickReorder(wrapper) + await flushPromises() + // Move Weather (second child of News) up → should swap with Sport + const ups = wrapper.findAll('.a-nested-list-editor__action--up') + // Ups: root[0]=Home (disabled), root[1]=News, child[0]=Sport (disabled), child[1]=Weather, root[2]=About + // Find the weather-level up — fourth in the DOM order if News is expanded + const weatherUp = ups[3] + await weatherUp.trigger('click') + const news = model.value.children.find((n) => n.data.id === 2)! + expect(news.children!.map((c) => c.data.id)).toEqual([22, 21]) + }) + + it('disables up on first sibling and down on last sibling (per group)', async () => { + const { wrapper } = mountEditor() + await clickReorder(wrapper) + await flushPromises() + const ups = wrapper.findAll('.a-nested-list-editor__action--up') + const downs = wrapper.findAll('.a-nested-list-editor__action--down') + expect(ups[0].attributes('disabled')).toBeDefined() // Home (first root) + expect(downs[downs.length - 1].attributes('disabled')).toBeDefined() // About (last root) + }) + + it('marks moved rows as unsaved', async () => { + const { wrapper } = mountEditor() + await clickReorder(wrapper) + await flushPromises() + await wrapper.findAll('.a-nested-list-editor__action--down')[0].trigger('click') + await flushPromises() + const unsaved = wrapper.findAll('.a-nested-list-editor__row--unsaved') + expect(unsaved.length).toBeGreaterThan(0) + }) + }) + + describe('cancel restores the tree', () => { + it('cancel reverts root reordering', async () => { + const { wrapper, model, mode } = mountEditor() + await clickReorder(wrapper) + await flushPromises() + await wrapper.findAll('.a-nested-list-editor__action--down')[0].trigger('click') + expect(model.value.children.map((n) => n.data.id)).toEqual([2, 1, 3]) + + const cancel = wrapper + .findAll('button') + .find((b) => b.text().toLowerCase().includes('cancel'))! + await cancel.trigger('click') + await flushPromises() + + expect(model.value.children.map((n) => n.data.id)).toEqual([1, 2, 3]) + expect(mode.value).toBe('view') + }) + }) + + describe('apply flow', () => { + it('apply without callback commits new order and exits reorder mode', async () => { + const { wrapper, model, mode, editor } = mountEditor() + await clickReorder(wrapper) + await flushPromises() + await wrapper.findAll('.a-nested-list-editor__action--down')[0].trigger('click') + + const apply = wrapper + .findAll('button') + .find((b) => b.text().toLowerCase().includes('apply'))! + await apply.trigger('click') + await flushPromises() + + expect(mode.value).toBe('view') + expect(model.value.children.map((n) => n.data.id)).toEqual([2, 1, 3]) + expect(editor().emitted('reorder-applied')).toBeTruthy() + expect(editor().emitted('reorder-end')).toBeTruthy() + }) + + it('awaits onReorderApply callback and exits on success', async () => { + const save = vi.fn().mockImplementation(async () => { + await Promise.resolve() + }) + const { wrapper, model, mode } = mountEditor(tree(), { onReorderApply: save }) + await clickReorder(wrapper) + await wrapper.findAll('.a-nested-list-editor__action--down')[0].trigger('click') + const apply = wrapper + .findAll('button') + .find((b) => b.text().toLowerCase().includes('apply'))! + await apply.trigger('click') + await flushPromises() + + expect(save).toHaveBeenCalledTimes(1) + expect(mode.value).toBe('view') + expect(model.value.children.map((n) => n.data.id)).toEqual([2, 1, 3]) + }) + + it('keeps reorder mode open on callback failure', async () => { + const save = vi.fn().mockRejectedValue(new Error('boom')) + const { wrapper, mode, editor } = mountEditor(tree(), { onReorderApply: save }) + await clickReorder(wrapper) + await wrapper.findAll('.a-nested-list-editor__action--down')[0].trigger('click') + const apply = wrapper + .findAll('button') + .find((b) => b.text().toLowerCase().includes('apply'))! + await apply.trigger('click') + await flushPromises() + + expect(mode.value).toBe('reorder') + expect(editor().emitted('reorder-apply-error')).toBeTruthy() + expect(wrapper.text()).toContain('boom') + }) + }) + + describe('indent / outdent', () => { + it('indents a root sibling under its previous sibling as last child', async () => { + const { wrapper, model } = mountEditor() + await clickReorder(wrapper) + await flushPromises() + const api = editorExposed(wrapper) + expect(api.indent).toBeTypeOf('function') + const res = api.indent(3) // indent About (id=3) + await flushPromises() + expect(res).not.toBeNull() + // About should now be a child of News (the prev sibling) + const news = model.value.children.find((n) => n.data.id === 2)! + expect(news.children!.map((c) => c.data.id)).toEqual([21, 22, 3]) + expect(model.value.children.map((n) => n.data.id)).toEqual([1, 2]) + }) + + it('blocks indent when the result would exceed maxDepth', async () => { + const deep: NestedTree = { + children: [ + { + data: { id: 10, position: 1, parent: null, title: 'A' }, + children: [], + meta: { dirty: false }, + }, + { + data: { id: 20, position: 2, parent: null, title: 'B' }, + children: [ + { + data: { id: 21, position: 1, parent: 20, title: 'B1' }, + children: [ + { + data: { id: 22, position: 1, parent: 21, title: 'B1a' }, + children: [], + meta: { dirty: false }, + }, + ], + meta: { dirty: false }, + }, + ], + meta: { dirty: false }, + }, + ], + meta: { dirty: false }, + } + const { wrapper, model } = mountEditor(deep, { maxDepth: 3 }) + await clickReorder(wrapper) + await flushPromises() + const api = editorExposed(wrapper) + // Try to indent B (id=20) under A — would make B's subtree depth 4 (A>B>B1>B1a), exceeds 3 + const res = api.indent(20) + expect(res).toBeNull() + // Model unchanged + expect(model.value.children.map((n) => n.data.id)).toEqual([10, 20]) + }) + + it('outdents a child to the root level as next sibling of its old parent', async () => { + const { wrapper, model } = mountEditor() + await clickReorder(wrapper) + await flushPromises() + const api = editorExposed(wrapper) + api.outdent(21) // Sport becomes next sibling of News + await flushPromises() + expect(model.value.children.map((n) => n.data.id)).toEqual([1, 2, 21, 3]) + const news = model.value.children.find((n) => n.data.id === 2)! + expect(news.children!.map((c) => c.data.id)).toEqual([22]) + }) + + it('blocks outdent at root level', () => { + const { wrapper } = mountEditor() + const api = editorExposed(wrapper) + const res = api.outdent(1) // Home is already root + expect(res).toBeNull() + }) + }) + + describe('imperative ref API (migration parity with legacy ASortableNested)', () => { + it('addAfterId inserts after target in the same sibling group', async () => { + const { wrapper, model } = mountEditor() + const api = editorExposed(wrapper) + api.addAfterId(1, { id: 99, position: 0, parent: null, title: 'Inserted' }, true) + await flushPromises() + expect(model.value.children.map((n) => n.data.id)).toEqual([1, 99, 2, 3]) + }) + + it('addChildToId appends as child and auto-expands the parent', async () => { + const { wrapper, model } = mountEditor() + const api = editorExposed(wrapper) + // Home currently has no children. Add one. + api.addChildToId(1, { id: 101, position: 0, parent: 1, title: 'Sub' }, true) + await flushPromises() + const home = model.value.children.find((n) => n.data.id === 1)! + expect(home.children!.map((c) => c.data.id)).toEqual([101]) + }) + + it('removeById removes the node and recalculates sibling positions', async () => { + const { wrapper, model } = mountEditor() + const api = editorExposed(wrapper) + api.removeById(1) + await flushPromises() + expect(model.value.children.map((n) => n.data.id)).toEqual([2, 3]) + }) + + it('updateData replaces data of a node by id', async () => { + const { wrapper, model } = mountEditor() + const api = editorExposed(wrapper) + api.updateData(1, { id: 1, position: 1, parent: null, title: 'Home Renamed' }) + await flushPromises() + expect(model.value.children[0].data.title).toBe('Home Renamed') + }) + + it('resetDirtyBaseline clears the unsaved indicator after server-confirmed operation', async () => { + const { wrapper } = mountEditor() + const api = editorExposed(wrapper) + // Simulate external mutation — change title in-place (dirty) + await wrapper.vm.$nextTick() + // Re-capture baseline after the supposed save + api.resetDirtyBaseline() + await flushPromises() + // No dirty rows in DOM + expect(wrapper.findAll('.a-nested-list-editor__row--unsaved').length).toBe(0) + }) + }) + + describe('readonly mode', () => { + it('hides edit/delete/add buttons and the reorder toggle', () => { + const { wrapper } = mountEditor(tree(), { readonly: true }) + // Reorder toggle should be absent (disabled) in readonly + const reorder = wrapper + .findAll('button') + .find((b) => b.text().toLowerCase().includes('reorder')) + if (reorder) { + expect(reorder.attributes('disabled')).toBeDefined() + } + // Edit + delete buttons should be 0 in readonly (canInteract === false) + expect(wrapper.findAll('.a-nested-list-editor__action--edit').length).toBe(0) + expect(wrapper.findAll('.a-nested-list-editor__action--delete').length).toBe(0) + }) + }) + + describe('slots', () => { + it('renders custom #item-compact slot for every row', () => { + const model = ref>(tree()) + const Host = defineComponent({ + setup() { + return () => + h( + ANestedSortableListEditor, + { + modelValue: model.value, + 'onUpdate:modelValue': (v: NestedTree) => { + model.value = v + }, + maxDepth: 2, + }, + { + 'item-compact': ({ raw }: { raw: MenuItem }) => + h('span', { class: 'my-compact' }, `#${raw.id}`), + }, + ) + }, + }) + const wrapper = mount(Host) + const compacts = wrapper.findAll('.my-compact') + expect(compacts.length).toBe(5) + expect(compacts[0].text()).toBe('#1') + }) + }) + + describe('events', () => { + it('emits edit when a row is clicked in view mode', async () => { + const { wrapper, editor } = mountEditor() + await wrapper.findAll('.a-nested-list-editor__row-header')[0].trigger('click') + const events = editor().emitted('edit') as Array<[NestedViewItem]> | undefined + expect(events).toBeTruthy() + expect(events![0][0].key).toBe(1) + }) + + it('emits add when the add button at the bottom is clicked', async () => { + const { wrapper, editor } = mountEditor() + await wrapper.find('.a-nested-list-editor__row-add').trigger('click') + expect(editor().emitted('add')).toBeTruthy() + }) + }) + + describe('childrenAllowed=false nodes (children: undefined in source)', () => { + const mkLeafTree = (): NestedTree => ({ + children: [ + { + data: { id: 1, position: 1, parent: null, title: 'Parent' }, + children: [ + { + data: { id: 10, position: 1, parent: 1, title: 'PageChildren-style' }, + // children: undefined means "no nesting allowed under this node" + children: undefined, + meta: { dirty: false }, + }, + ], + meta: { dirty: false }, + }, + ], + meta: { dirty: false }, + }) + + it('hides the add-child button for nodes whose children are undefined', () => { + const { wrapper } = mountEditor(mkLeafTree(), { showAddChildButton: true }) + // 2 rows total (Parent + PageChildren-style). Parent has children allowed, leaf does not. + // Add-child now lives inside the overflow (⋮) menu — so the menu button itself only + // renders on rows where add-child or add-after is available. With add-after disabled + // the menu button exists iff canAddChild is true, i.e. on the Parent row only. + const overflow = wrapper.findAll('.a-nested-list-editor__action--menu') + expect(overflow.length).toBe(1) + }) + + it('does not expand-toggle a leaf node with children: undefined', () => { + const { wrapper } = mountEditor(mkLeafTree()) + // Filter out spacer elements — those are invisible alignment stand-ins + // rendered for `children: []` leaves to keep caret columns aligned. + const toggles = wrapper.findAll( + '.a-nested-list-editor__tree-toggle:not(.a-nested-list-editor__tree-toggle--spacer)', + ) + // Only the Parent row gets a real chevron; the leaf has none at all. + expect(toggles.length).toBe(1) + }) + }) + + describe('move-to-top / move-to-bottom within sibling group', () => { + it('moveTop via exposed API puts the item at idx 0 of its siblings', async () => { + const { wrapper, model } = mountEditor() + await clickReorder(wrapper) + await flushPromises() + const editor = findEditor(wrapper) + const exposed = (editor.vm as unknown as { + $: { exposed: { moveTop: (id: number) => unknown } } + }).$.exposed + exposed.moveTop(3) // About is last root; move to top + await flushPromises() + expect(model.value.children.map((n) => n.data.id)).toEqual([3, 1, 2]) + }) + + it('moveBottom via exposed API puts the item at last idx of its siblings', async () => { + const { wrapper, model } = mountEditor() + await clickReorder(wrapper) + await flushPromises() + const editor = findEditor(wrapper) + const exposed = (editor.vm as unknown as { + $: { exposed: { moveBottom: (id: number) => unknown } } + }).$.exposed + exposed.moveBottom(1) // Home is first root; move to bottom + await flushPromises() + expect(model.value.children.map((n) => n.data.id)).toEqual([2, 3, 1]) + }) + }) + + describe('states', () => { + it('renders loading state', () => { + const { wrapper } = mountEditor(tree(), { loading: true }) + expect(wrapper.find('.a-nested-list-editor__state--loading').exists()).toBe(true) + }) + + it('renders error state with the message', () => { + const { wrapper } = mountEditor(tree(), { error: 'Server offline' }) + expect(wrapper.find('.a-nested-list-editor__state--error').exists()).toBe(true) + expect(wrapper.text()).toContain('Server offline') + }) + + it('renders empty state when the tree has no children', () => { + const empty: NestedTree = { children: [], meta: { dirty: false } } + const { wrapper } = mountEditor(empty) + expect(wrapper.find('.a-nested-list-editor__state--empty').exists()).toBe(true) + }) + }) + + describe('showExpandToggle', () => { + it('hides the chevron toggle when showExpandToggle=false', () => { + const { wrapper } = mountEditor(tree(), { showExpandToggle: false }) + expect(wrapper.find('.a-nested-list-editor__tree-toggle').exists()).toBe(false) + }) + }) + + describe('#item-readonly slot expansion', () => { + it('renders #item-readonly body when row is expanded in readonly mode', async () => { + const model = ref>(tree()) + const Host = defineComponent({ + setup() { + return () => + h( + ANestedSortableListEditor, + { + modelValue: model.value, + 'onUpdate:modelValue': (v: NestedTree) => { + model.value = v + }, + maxDepth: 2, + readonly: true, + }, + { + 'item-readonly': ({ raw }: { raw: MenuItem }) => + h('div', { class: 'my-readonly' }, `ro-${raw.id}`), + }, + ) + }, + }) + const wrapper = mount(Host) + // Click first row header to expand + await wrapper.findAll('.a-nested-list-editor__row-header')[0].trigger('click') + await nextTick() + expect(wrapper.find('.my-readonly').exists()).toBe(true) + }) + }) + + describe('multi-open editing', () => { + it('opening a second row keeps the first editing', async () => { + const model = ref>(tree()) + const Host = defineComponent({ + setup() { + return () => + h( + ANestedSortableListEditor, + { + modelValue: model.value, + 'onUpdate:modelValue': (v: NestedTree) => { + model.value = v + }, + maxDepth: 2, + }, + { + item: ({ raw }: { raw: MenuItem }) => h('input', { value: raw.title }), + }, + ) + }, + }) + const wrapper = mount(Host) + const headers = wrapper.findAll('.a-nested-list-editor__row-header') + await headers[0].trigger('click') + await nextTick() + await headers[2].trigger('click') // skip the 2nd which is News (was opened?) — target 3rd + await nextTick() + expect(wrapper.findAll('.a-nested-list-editor__row--editing').length).toBeGreaterThanOrEqual(2) + }) + }) + + describe('resetDirtyBaseline DOM verification', () => { + it('row shown as unsaved after external mutation, clears after baseline reset', async () => { + const model = ref>(tree()) + const Host = defineComponent({ + setup() { + return () => + h(ANestedSortableListEditor, { + modelValue: model.value, + 'onUpdate:modelValue': (v: NestedTree) => { + model.value = v + }, + maxDepth: 2, + }) + }, + }) + const wrapper = mount(Host) + await nextTick() + expect(wrapper.findAll('.a-nested-list-editor__row--unsaved').length).toBe(0) + // Simulate in-place mutation by replacing a node's data via fresh cloned tree + // eslint-disable-next-line vue/no-ref-object-reactivity-loss + const fresh = JSON.parse(JSON.stringify(model.value)) as NestedTree + fresh.children[0].data.title = 'Home RENAMED' + model.value = fresh + await nextTick() + expect(wrapper.findAll('.a-nested-list-editor__row--unsaved').length).toBe(1) + // Reset baseline via exposed API + const editor = findEditor(wrapper) + const exposed = (editor.vm as unknown as { + $: { exposed: { resetDirtyBaseline: () => void } } + }).$.exposed + exposed.resetDirtyBaseline() + await nextTick() + expect(wrapper.findAll('.a-nested-list-editor__row--unsaved').length).toBe(0) + }) + }) + + describe('maxDepth gates add-child button', () => { + it('hides add-child on nodes already at max allowed depth', () => { + // Fixture tree has 3 root nodes (Home, News, About) and 2 children (Sport, Weather) + // under News — 5 rows total, depth 0..1. With maxDepth=2 only depth-0 rows can still + // accept more children; the 2 depth-1 children are at max depth and must not show + // the add-child option. Add-child now lives in the overflow (⋮) menu; with add-after + // disabled, the menu button only renders when canAddChild is true, so we count those. + const { wrapper } = mountEditor(tree(), { showAddChildButton: true, maxDepth: 2 }) + const overflow = wrapper.findAll('.a-nested-list-editor__action--menu') + expect(overflow.length).toBe(3) + }) + }) + + describe('drag-enabled state in reorder mode', () => { + it('sets --drag-enabled root class on desktop-like environment in reorder mode', async () => { + const { wrapper } = mountEditor() + await clickReorder(wrapper) + await flushPromises() + // On a non-touch viewport the drag-enabled class is expected; on touch the arrows are used. + const rootHasDragClass = wrapper.find('.a-nested-list-editor--drag-enabled').exists() + const arrowsCount = wrapper.findAll('.a-nested-list-editor__action--up').length + expect(rootHasDragClass || arrowsCount > 0).toBe(true) + }) + + it('hides drag handle when disableDrag=true even in reorder mode', async () => { + const { wrapper } = mountEditor(tree(), { disableDrag: true }) + await clickReorder(wrapper) + await flushPromises() + expect(wrapper.find('.a-nested-list-editor__drag-handle').exists()).toBe(false) + expect(wrapper.find('.a-nested-list-editor--drag-enabled').exists()).toBe(false) + }) + }) +}) + +// Reach into the editor component's exposed imperative API for tests that +// need to invoke tree mutations directly (kebab-menu targets are inside a +// Vuetify VMenu popover and are not always stable in headless test DOM). +interface EditorApi { + indent: (id: number) => unknown + outdent: (id: number) => unknown + addAfterId: (targetId: number | null, data: MenuItem, childrenAllowed: boolean) => unknown + addChildToId: (targetId: number, data: MenuItem, childrenAllowed: boolean) => unknown + removeById: (id: number) => void + updateData: (id: number, data: MenuItem) => void + resetDirtyBaseline: () => void +} +function editorExposed(wrapper: VueWrapper): EditorApi { + const editor = findEditor(wrapper) + // In a + + diff --git a/src/labs/listEditor/internal/ALeDragHandle.vue b/src/labs/listEditor/internal/ALeDragHandle.vue new file mode 100644 index 00000000..40c0f598 --- /dev/null +++ b/src/labs/listEditor/internal/ALeDragHandle.vue @@ -0,0 +1,13 @@ + + + diff --git a/src/labs/listEditor/internal/ALeEmptyState.vue b/src/labs/listEditor/internal/ALeEmptyState.vue new file mode 100644 index 00000000..bf6fa892 --- /dev/null +++ b/src/labs/listEditor/internal/ALeEmptyState.vue @@ -0,0 +1,53 @@ + + + diff --git a/src/labs/listEditor/internal/ALeStatus.vue b/src/labs/listEditor/internal/ALeStatus.vue new file mode 100644 index 00000000..64a93340 --- /dev/null +++ b/src/labs/listEditor/internal/ALeStatus.vue @@ -0,0 +1,48 @@ + + + diff --git a/src/labs/listEditor/internal/ALeUnsavedLabel.vue b/src/labs/listEditor/internal/ALeUnsavedLabel.vue new file mode 100644 index 00000000..863174db --- /dev/null +++ b/src/labs/listEditor/internal/ALeUnsavedLabel.vue @@ -0,0 +1,28 @@ + + + diff --git a/src/labs/listEditor/styles/_shared.scss b/src/labs/listEditor/styles/_shared.scss new file mode 100644 index 00000000..894d1119 --- /dev/null +++ b/src/labs/listEditor/styles/_shared.scss @@ -0,0 +1,694 @@ +// Shared BEM rules for list-editor variants. Mixins take a `$block` selector +// string (e.g. `.a-list-editor`) and emit the suite of common rules using +// SCSS interpolation. Output matches the pre-refactor CSS byte-for-byte modulo +// the `--le-*` token renames. +// IMPORTANT: `&` inside a mixin keys off the mixin-call's parent selector, +// which is why every rule here uses full interpolated selectors +// (`#{$block}__row { ... }`) rather than ampersand descendants. +// The `$unsaved` parameter handles the naming split between AListEditor +// (`__row--dirty`) and the sortable / nested variants (`__row--unsaved`). +// The `$deep` parameter toggles between `:deep(*)` (scoped components) and +// bare `*` (unscoped nested variant). + +@use 'sass:list'; + +/* stylelint-disable color-function-alias-notation */ +/* stylelint-disable selector-pseudo-class-no-unknown -- + Vue SFC `:deep()` is valid inside consumer `.vue` files (Vue SFC stylelint + config extends `ignorePseudoClasses: ['deep']`), but a partial linted on + its own doesn't see that override. The rule generates warnings here that + resolve cleanly when the mixin output lands inside a `.vue` file. */ + +// --- Header, card, state, empty --------------------------------------------- + +@mixin le-card($block) { + #{$block}__card { + background: var(--le-surface); + border: 1px solid var(--le-border); + border-radius: var(--le-radius); + overflow: hidden; + } +} + +@mixin le-header-block($block) { + #{$block}__header { + display: flex; + justify-content: space-between; + align-items: center; + gap: 12px; + padding: 8px 20px; + height: 60px; + border-bottom: 1px solid var(--le-border); + background: var(--le-surface); + flex-shrink: 0; + } + + #{$block}__title-heading { + font-weight: 500; + font-size: 1rem; + line-height: 1.5; + letter-spacing: 0.009em; + color: var(--le-on-surface); + margin: 0; + } +} + +@mixin le-header-actions($block) { + #{$block}__header-actions { + display: inline-flex; + align-items: center; + gap: 8px; + flex-shrink: 0; + margin-left: auto; + } +} + +@mixin le-state-and-empty($block, $deep: true, $full-width-alert: true) { + #{$block}__state { + display: flex; + align-items: center; + justify-content: center; + padding: 32px 16px; + } + + #{$block}__state--error { + padding: 16px; + } + + // Full-width VAlert inside the error state. Only wrap `:deep()` when the + // component is scoped — otherwise `:deep()` leaks out as invalid CSS. The + // nested editor historically omits this; pass `$full-width-alert: false` + // there to preserve the pre-refactor output. + @if $full-width-alert { + @if $deep { + #{$block}__state--error :deep(.v-alert) { + width: 100%; + } + } @else { + #{$block}__state--error .v-alert { + width: 100%; + } + } + } + + #{$block}__empty { + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; + text-align: center; + padding: 16px; + } + + #{$block}__empty-title { + font-size: 1rem; + font-weight: 500; + margin: 0; + color: var(--le-on-surface); + } + + #{$block}__empty-text { + font-size: 0.875rem; + color: var(--le-on-surface-variant); + margin: 0 0 12px; + } +} + +// --- Row primitives ---------------------------------------------------------- +// `$sp` is an optional specificity-prefix injected before the two rules whose +// purpose is to beat consumer slot-content styles. Scoped components rely on +// the attribute-selector boost from `data-v-*`; unscoped variants (the nested +// editor) need an explicit `.a-... ` prefix to match the specificity a scoped +// build would get "for free". + +@mixin le-row-primitives($block, $unsaved: '--unsaved', $deep: true, $sp: '') { + #{$block}--disabled, + #{$block}--readonly { + opacity: 0.85; + } + + #{$block}--disabled { + pointer-events: none; + } + + #{$block}__row { + position: relative; + display: flex; + flex-direction: column; + background: var(--le-surface); + border-bottom: 1px solid var(--le-border); + transition: background-color 0.15s cubic-bezier(0.4, 0, 0.2, 1); + } + + #{$block}__row-header { + display: flex; + align-items: center; + gap: 10px; + padding: var(--le-row-pad-y) 12px var(--le-row-pad-y) 16px; + min-height: var(--le-row-min-height); + flex-shrink: 0; + position: relative; + transition: background-color 0.15s; + } + + #{$block}__row--clickable #{$block}__row-header { + cursor: pointer; + } + + /* Editing / readonly-expanded rows keep the overall row transparent — the + blue tint sits on the header only, and the form body gets its own soft + gradient (see `le-editing-body-rail` mixin and container-query rules). */ + #{$block}__row--editing #{$block}__row-header, + #{$block}__row--expanded #{$block}__row-header { + background: var(--le-primary-container); + } + + #{$block}__row--editing::before, + #{$block}__row--expanded::before { + content: ''; + position: absolute; + left: 0; + top: 0; + bottom: 0; + width: 4px; + background: var(--le-primary); + z-index: 1; + } + + /* Unsaved takes visual precedence over editing — swap the primary rail + + header tint for warning so the whole row reads as "dirty, not active". */ + #{$block}__row#{$unsaved} { + background: var(--le-warning-container); + } + + #{$block}__row#{$unsaved}::before { + content: ''; + position: absolute; + left: 0; + top: 0; + bottom: 0; + width: 4px; + background: var(--le-warning); + z-index: 2; + } + + #{$block}__row#{$unsaved} #{$block}__row-header { + background: var(--le-warning-container); + } + + #{$sp}#{$block}__row#{$unsaved}#{$block}__row--editing #{$block}__row-main, + #{$sp}#{$block}__row#{$unsaved}#{$block}__row--expanded #{$block}__row-main { + @if $deep { + color: var(--le-warning); + + :deep(*) { + color: var(--le-warning); + } + } @else { + &, + * { + color: var(--le-warning); + } + } + } + + #{$block}__row-main { + flex: 1 1 auto; + min-width: 0; + display: flex; + align-items: center; + gap: 8px; + } + + #{$block}__title { + flex: 1 1 auto; + font-size: var(--le-row-font); + font-weight: 400; + line-height: 1.43; + letter-spacing: 0.018em; + color: var(--le-on-surface); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + /* Active row — bold primary title. Reaches consumer-provided #item-compact + slot content via `:deep(*)` (scoped) or bare `*` (unscoped). */ + #{$sp}#{$block}__row--editing #{$block}__row-main, + #{$sp}#{$block}__row--expanded #{$block}__row-main { + @if $deep { + font-weight: 700; + color: var(--le-primary); + + :deep(*) { + font-weight: 700; + color: var(--le-primary); + } + } @else { + &, + * { + font-weight: 700; + color: var(--le-primary); + } + } + } + + #{$block}__row-body { + padding: 12px 16px; + transition: padding-left 0.2s ease; + } + + /* Form card — wraps consumer-provided #item / #item-readonly content so the + inline editor reads as a distinct surface against the tinted row-body + background. White fill, whisper-faint border, gentle radius. */ + #{$block}__form { + background: var(--le-surface); + border: 1px solid rgb(0 0 0 / 6%); + border-radius: var(--le-radius); + padding: 16px 16px 8px; + } + + #{$block}__row-footer { + display: flex; + justify-content: flex-end; + align-items: center; + gap: 8px; + padding: 4px 16px 16px; + } + + #{$block}__row-footer-spacer { + flex: 1 1 auto; + } + + #{$block}__unsaved-label { + display: inline-flex; + align-items: center; + gap: 3px; + font-size: 11px; + color: var(--le-warning); + font-weight: 500; + padding: 2px 4px; + white-space: nowrap; + letter-spacing: 0.02em; + flex-shrink: 0; + } + + #{$block}__status { + display: inline-flex; + align-items: center; + gap: 8px; + flex-shrink: 0; + margin-left: auto; + } + + #{$block}__status-badge { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 4px; + min-width: 56px; + padding: 4px 10px; + font: 500 11px/1 var(--v-font-body, inherit); + letter-spacing: 0.02em; + background: var(--le-success-container); + color: var(--le-success-fg); + border-radius: var(--le-radius-pill); + white-space: nowrap; + flex-shrink: 0; + + &--warning { + background: var(--le-warning-container); + color: var(--le-warning-fg); + } + + &--error { + background: var(--le-error-container); + color: var(--le-error-fg); + } + } + + #{$block}__actions { + display: inline-flex; + align-items: center; + gap: 2px; + flex-shrink: 0; + margin-left: 4px; + } +} + +// --- Clickable row hover/active states -------------------------------------- +// AListEditor and ASortableListEditor render both `:hover` and `:active`; +// ANestedSortableListEditor intentionally omits `:active` (the drag handle's +// press feedback is louder and the extra press state reads as noise there). + +@mixin le-row-hover($block) { + /* stylelint-disable selector-max-compound-selectors */ + #{$block}__row--clickable:not( + #{$block}__row--editing, + #{$block}__row--expanded + ):hover + #{$block}__row-header { + background: var(--le-primary-state); + } + /* stylelint-enable selector-max-compound-selectors */ +} + +@mixin le-row-active($block) { + /* stylelint-disable selector-max-compound-selectors */ + #{$block}__row--clickable:not( + #{$block}__row--editing, + #{$block}__row--expanded + ):active + #{$block}__row-header { + background: var(--le-primary-state-press); + } + /* stylelint-enable selector-max-compound-selectors */ +} + +// --- Container-query desktop form rail -------------------------------------- +// Adds the primary rail + soft gradient to the editing/readonly form area so +// the whole inline-edit block reads as a single continuous surface. The +// nested variant extends this with a depth-aware padding-left override +// (applied in-component after calling this mixin). + +@mixin le-editing-body-rail($block, $unsaved: '--unsaved') { + #{$block}__row--editing #{$block}__row-body, + #{$block}__row--expanded #{$block}__row-body, + #{$block}__row--editing #{$block}__row-footer, + #{$block}__row--expanded #{$block}__row-footer { + border-left: 2px solid rgba(var(--v-theme-primary, 63, 106, 216), 0.28); + background: linear-gradient( + to right, + rgba(var(--v-theme-primary, 63, 106, 216), 0.07), + rgba(var(--v-theme-primary, 63, 106, 216), 0.02) 50%, + transparent 85% + ); + } + + /* Unsaved + editing: swap the primary rail + gradient for warning so the + whole form surface matches the orange row accent. */ + #{$block}__row#{$unsaved}#{$block}__row--editing #{$block}__row-body, + #{$block}__row#{$unsaved}#{$block}__row--expanded #{$block}__row-body, + #{$block}__row#{$unsaved}#{$block}__row--editing #{$block}__row-footer, + #{$block}__row#{$unsaved}#{$block}__row--expanded #{$block}__row-footer { + border-left-color: rgb(251 140 0 / 35%); + background: linear-gradient( + to right, + rgb(251 140 0 / 7%), + rgb(251 140 0 / 2%) 50%, + transparent 85% + ); + } +} + +// --- Action column opacity (show on hover / focus / touch) ------------------ + +// Default hover-none set matches $all; caller can override to omit specific +// modifiers (ASortableListEditor excludes `--menu` there — its reorder +// dots-vertical stays reachable via the menu button always being visible in +// reorder mode anyway, so the coarse-pointer auto-reveal would be noise). +@mixin le-action-visibility($block, $extra-actions: (), $hover-none-actions: null) { + $base: ('--edit', '--delete', '--menu'); + $all: list.join($base, $extra-actions); + $hover-none: $all; + + @if $hover-none-actions { + $hover-none: $hover-none-actions; + } + + @each $mod in $all { + #{$block}__action#{$mod} { + opacity: 0; + transition: opacity 0.15s; + } + } + + /* stylelint-disable selector-max-compound-selectors */ + @each $mod in $all { + #{$block}__row:hover #{$block}__action#{$mod}, + #{$block}__row:focus-within #{$block}__action#{$mod} { + opacity: 1; + } + } + /* stylelint-enable selector-max-compound-selectors */ + + @each $mod in $all { + #{$block}--touch #{$block}__action#{$mod} { + opacity: 1; + } + } + + /* Active rows keep all affordances pinned open. */ + @each $mod in ('--edit', '--delete', '--menu') { + #{$block}__row--editing #{$block}__action#{$mod}, + #{$block}__row--expanded #{$block}__action#{$mod} { + opacity: 1; + } + } + + @media (hover: none) { + @each $mod in $hover-none { + #{$block}__action#{$mod} { + opacity: 1; + } + } + } +} + +// Disabled reorder arrows — muted but visible when the row is hovered / +// focused / on a touch device, matching the enabled arrow's reveal behaviour. +@mixin le-disabled-arrow-visibility($block) { + /* stylelint-disable selector-max-compound-selectors */ + #{$block}__row:hover #{$block}__action--up.v-btn--disabled, + #{$block}__row:hover #{$block}__action--down.v-btn--disabled, + #{$block}__row:focus-within #{$block}__action--up.v-btn--disabled, + #{$block}__row:focus-within #{$block}__action--down.v-btn--disabled, + #{$block}--touch #{$block}__action--up.v-btn--disabled, + #{$block}--touch #{$block}__action--down.v-btn--disabled { + opacity: 0.3; + } + /* stylelint-enable selector-max-compound-selectors */ +} + +// --- Row "+ Add" button at list end ----------------------------------------- + +@mixin le-row-add($block, $focus-outline: true) { + #{$block}__row-add { + width: 100%; + padding: 10px 16px; + display: flex; + align-items: center; + gap: 8px; + color: var(--le-primary); + font-size: 13px; + font-weight: 500; + line-height: 1; + cursor: pointer; + border: none; + border-top: 1px solid var(--le-border); + background: var(--le-surface-container); + letter-spacing: 0.02em; + transition: background-color 0.15s; + text-align: left; + font-family: inherit; + + &:hover { + background: var(--le-primary-container); + } + + // AListEditor + ASortableListEditor emit an explicit focus ring. The nested + // editor (set `$focus-outline: false` at the call site) opts out. + @if $focus-outline { + &:focus-visible { + outline: 2px solid var(--le-primary); + outline-offset: -2px; + } + } + } +} + +// --- Drag handle (sortable + nested) ---------------------------------------- + +@mixin le-drag-handle($block) { + #{$block}__drag-handle { + cursor: grab; + flex-shrink: 0; + padding: 4px 0; + + &:active { + cursor: grabbing; + } + } +} + +// --- Reorder-mode toolbar status pill --------------------------------------- + +@mixin le-toolbar-status($block) { + #{$block}__toolbar-status { + font-size: 0.85rem; + color: var(--le-on-surface); + font-weight: 500; + display: inline-flex; + align-items: center; + gap: 4px; + margin-right: 4px; + + &--pending { + color: var(--le-warning); + } + } +} + +// --- Two-rows layout (AListEditor + ASortableListEditor) -------------------- + +@mixin le-two-rows-layout($block) { + #{$block}__row--two-rows:not( + #{$block}__row--editing, + #{$block}__row--expanded + ) + #{$block}__row-header { + display: grid; + grid-template-columns: 1fr auto; + grid-template-areas: 'title title' 'status actions'; + align-items: center; + gap: 4px 8px; + padding: 10px 16px; + min-height: auto; + } + + #{$block}__row--two-rows #{$block}__row-main { + grid-area: title; + min-width: 0; + } + + #{$block}__row--two-rows #{$block}__row-main #{$block}__title { + white-space: normal; + overflow: hidden; + text-overflow: ellipsis; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + line-height: 1.35; + font-weight: 500; + } + + #{$block}__row--two-rows #{$block}__status { + grid-area: status; + } + + #{$block}__row--two-rows #{$block}__actions { + grid-area: actions; + margin-left: 0; + } +} + +// Mobile variant of the two-rows layout gated on `--two-rows-mobile` modifier. +@mixin le-two-rows-mobile-layout($block) { + #{$block}--two-rows-mobile + #{$block}__row:not( + #{$block}__row--editing, + #{$block}__row--expanded + ) + #{$block}__row-header { + display: grid; + grid-template-columns: 1fr auto; + grid-template-areas: 'title title' 'status actions'; + align-items: center; + gap: 4px 8px; + padding: 10px 16px; + min-height: auto; + } + + #{$block}--two-rows-mobile #{$block}__row-main { + grid-area: title; + min-width: 0; + } + + #{$block}--two-rows-mobile #{$block}__row-main #{$block}__title { + white-space: normal; + overflow: hidden; + text-overflow: ellipsis; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + line-height: 1.35; + font-weight: 500; + } + + #{$block}--two-rows-mobile #{$block}__status { + grid-area: status; + } + + #{$block}--two-rows-mobile #{$block}__actions { + grid-area: actions; + margin-left: 0; + } +} + +// --- Chips layout shared core (AListEditor + ASortableListEditor) ----------- +// Only emits the rules that are byte-identical between the two flat editors. +// The `__row-header` padding (12 px for AListEditor vs 8 px for sortable to +// leave room for the drag handle) and the sortable-only `__rows` / +// `__drag-handle` overrides stay inlined in each component. + +@mixin le-chips-shared($block) { + #{$block}--chips { + #{$block}__card { + display: flex; + flex-wrap: wrap; + gap: 6px; + padding: 8px; + border-radius: var(--le-radius); + box-shadow: none; + } + + #{$block}__header { + flex: 1 1 100%; + padding: 4px 8px 8px; + border-bottom: none; + min-height: auto; + } + + #{$block}__row { + border-bottom: none; + background: var(--le-primary-container); + border-radius: var(--le-radius-full); + flex: 0 0 auto; + max-width: 100%; + } + + #{$block}__row-main { + gap: 6px; + } + + #{$block}__title { + font-size: 0.82rem; + font-weight: 500; + color: var(--le-primary); + } + + #{$block}__action--chip-close { + opacity: 0.7; + transition: opacity 0.15s; + + &:hover { + opacity: 1; + } + } + + #{$block}__row-add { + flex: 0 0 auto; + width: auto; + border-top: none; + border: 1px dashed var(--le-border); + border-radius: var(--le-radius-full); + padding: 4px 12px; + font-size: 0.82rem; + background: transparent; + + &:hover { + background: var(--le-primary-state); + } + } + } +} diff --git a/src/labs/listEditor/styles/_tokens.scss b/src/labs/listEditor/styles/_tokens.scss new file mode 100644 index 00000000..f1257536 --- /dev/null +++ b/src/labs/listEditor/styles/_tokens.scss @@ -0,0 +1,49 @@ +// Shared token set for all list-editor variants. Each variant previously +// declared its own `--ale-*` / `--asle-*` / `--ansle-*` custom-property block +// with identical values — this mixin emits the unified `--le-*` set. +// Vuetify v4 exports theme colours as comma-separated "R, G, B" lists, which +// is why every primary/error/warning/surface var uses the explicit +// `rgba(..., A)` form or `rgb(var(--v-theme-*, R, G, B))`. The slash-alpha +// `rgb(R G B / A)` syntax produces invalid CSS when that var expands and +// silently falls back to transparent. +/* stylelint-disable color-function-alias-notation */ + +@mixin le-tokens { + --le-border: rgb(0 0 0 / 12%); + --le-surface: rgb(var(--v-theme-surface, 255, 255, 255)); + --le-surface-container: rgb(0 0 0 / 2.5%); + --le-primary: rgb(var(--v-theme-primary, 63, 106, 216)); + --le-primary-container: rgba(var(--v-theme-primary, 63, 106, 216), 0.12); + --le-primary-state: rgba(var(--v-theme-primary, 63, 106, 216), 0.04); + --le-primary-state-press: rgba(var(--v-theme-primary, 63, 106, 216), 0.12); + --le-success-container: rgb(76 175 80 / 18%); + --le-success-fg: #165634; + --le-warning-container: rgb(251 140 0 / 18%); + --le-warning-fg: #914000; + --le-warning: rgb(var(--v-theme-warning, 251, 140, 0)); + --le-error-container: rgba(var(--v-theme-error, 217, 37, 80), 0.18); + --le-error-fg: rgb(var(--v-theme-error, 217, 37, 80)); + --le-on-surface: rgb(var(--v-theme-on-surface, 51, 51, 51)); + --le-on-surface-variant: rgb(var(--v-theme-on-surface-variant, 102, 102, 102)); + --le-radius: 8px; + --le-radius-pill: 9999px; + --le-elev-1: 0 1px 2px rgb(0 0 0 / 12%), 0 1px 3px 1px rgb(0 0 0 / 6%); + --le-elev-3: 0 1px 3px rgb(0 0 0 / 16%), 0 4px 8px 3px rgb(0 0 0 / 10%); + + // Compact density — baked in, aligned across every list-editor variant. + --le-row-min-height: 48px; + --le-row-pad-y: 6px; + --le-row-font: 13px; + --le-indent: 24px; +} + +// Container-query setup — each editor declares a local `le-shell` container so +// rules inside `@container le-shell (...)` key off the component's own width, +// not the viewport. Multiple editors with the same container name don't +// collide: `@container` queries match the nearest ancestor by name, and each +// editor is its own ancestor tree. +@mixin le-shell-container { + position: relative; + container-type: inline-size; + container-name: le-shell; +} From 068955cb5f268bf4e5fd4625fedea8d7a46b0124 Mon Sep 17 00:00:00 2001 From: volar Date: Fri, 24 Apr 2026 20:56:59 +0200 Subject: [PATCH 13/98] Flatten list-editor styles to a shared .a-le-* class namespace MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 ` diff --git a/src/labs/listEditor/ANestedRow.vue b/src/labs/listEditor/ANestedRow.vue index 41652deb..351c4cd7 100644 --- a/src/labs/listEditor/ANestedRow.vue +++ b/src/labs/listEditor/ANestedRow.vue @@ -34,7 +34,7 @@ const ANestedRow = ANestedRowSelf as any const { t } = useI18n() const GROUP_CLASS = 'a-nested-list-editor__group' -const HANDLE_CLASS = 'a-nested-list-editor__drag-handle' +const HANDLE_CLASS = 'a-le-drag-handle' const anchorName = (key: ListEditorKey): string => `--row-${String(key).replace(/\W/g, '_')}` @@ -54,9 +54,9 @@ const directChildren = (): any[] =>