--- phase: 10-ux-interaction plan: 11 type: execute wave: 4 depends_on: [10-06, 10-09, 10-10, 10-05] files_modified: - frontend/src/components/storage/StorageBrowser.vue - frontend/src/components/documents/DocumentCard.vue - frontend/src/components/folders/FolderRow.vue - frontend/src/components/storage/__tests__/StorageBrowser.dragmove.test.js - frontend/src/components/ui/__tests__/dropdown.test.js autonomous: true requirements: [UX-11, UX-13] must_haves: truths: - "Drag-to-move document onto a folder applies the ring-2 ring-inset ring-amber-300 highlight while dragging (already wired in StorageBrowser - this plan verifies + adds click guard)" - "Dropping a document on a folder emits file-move which fires a success toast via the FileManagerView.doMove chain (toast already added in 10-06)" - "File-row click is suppressed when draggingFile is non-null (Pitfall 2 - prevents click-after-drag navigation)" - "StorageBrowser folder picker dropdown is teleported to body with getBoundingClientRect positioning" - "DocumentCard folder picker dropdown is teleported to body with getBoundingClientRect positioning" - "FolderRow three-dot menu is teleported to body with getBoundingClientRect positioning" - "All three teleported dropdowns reposition on window scroll" artifacts: - path: "frontend/src/components/storage/StorageBrowser.vue" provides: "Updated with click-guard for drag + Teleport-based folder picker" - path: "frontend/src/components/documents/DocumentCard.vue" provides: "Folder picker teleported" - path: "frontend/src/components/folders/FolderRow.vue" provides: "Three-dot menu teleported" key_links: - from: "StorageBrowser.vue file row @click" to: "draggingFile guard" via: "v-if/v-else or inline check" pattern: "draggingFile.*null" --- Complete the drag-to-move flow (UX-11) and fix the three dropdown clipping risks (UX-13). The drag-to-move highlight + drop handlers are already in StorageBrowser.vue; the missing pieces are the click-after-drag guard (Pitfall 2) and verification of the toast emit (toast was wired in 10-06). The dropdown fix uses the SearchableModelSelect.vue pattern: `` + `getBoundingClientRect()` to compute fixed position, with a `window.addEventListener('scroll', updatePosition, true)` listener to reposition on scroll. Apply to: 1. StorageBrowser folder picker (per-file move-to-folder dropdown) 2. DocumentCard folder picker (move-to-folder dropdown on the card) 3. FolderRow three-dot menu (rename/delete actions) Output: - StorageBrowser file-row click guard + folder picker teleport - DocumentCard folder picker teleport - FolderRow three-dot menu teleport **D-18 scope note (locked decision from CONTEXT.md):** D-18 specifies "dedicated drag handle element in DocumentCard.vue to prevent dragend-then-click navigation." DocumentCard.vue does NOT have `draggable="true"` set in Phase 10 — it is not a drag source in this phase. The dragend-then-click guard (D-18's protection goal) is implemented on StorageBrowser file rows, which ARE the drag sources. D-18 is fully satisfied for the elements that are actually draggable. DocumentCard drag capability, if added in a future phase, would need its own drag handle at that time. Document this conclusion in the SUMMARY.md. - Two stub test files (StorageBrowser.dragmove + dropdown) promoted to real tests @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md @CLAUDE.md @.planning/phases/10-ux-interaction/10-CONTEXT.md @.planning/phases/10-ux-interaction/10-RESEARCH.md @.planning/phases/10-ux-interaction/10-PATTERNS.md @frontend/src/components/storage/StorageBrowser.vue @frontend/src/components/documents/DocumentCard.vue @frontend/src/components/folders/FolderRow.vue @frontend/src/components/ui/SearchableModelSelect.vue @frontend/src/views/FileManagerView.vue **Drag-to-move click guard (Pitfall 2):** File rows in StorageBrowser.vue have `@click="$emit('file-open', file)"` (current line ~107). After a drag (even one shorter than 4px) the browser fires `dragend` followed by `click` - causing the document to open accidentally after drag. Fix: 1. The existing onDropDocOnFolder already sets `draggingFile.value = null` inside its body, but the `dragend` event handler still needs to clear it for the case where the user releases on a non-folder area. 2. Add an `@dragend="draggingFile = null"` handler to file rows. 3. Guard the click: change `@click="$emit('file-open', file)"` to `@click="draggingFile ? null : $emit('file-open', file)"`. Per RESEARCH.md, the existing onDropDocOnFolder already resets draggingFile -> null synchronously after emitting file-move; that means click WILL still fire with draggingFile=null. To reliably block click-after-drag, defer the reset using nextTick: ```js function onDropDocOnFolder(folderId) { if (!draggingFile.value) return const fileId = draggingFile.value.id const f = draggingFile.value dragOverFolderId.value = null emit('file-move', { fileId, folderId }) nextTick(() => { draggingFile.value = null }) } ``` And on file row dragend: ```html @dragend="onFileDragEnd" ``` with ```js function onFileDragEnd() { nextTick(() => { draggingFile.value = null }) } ``` **Dropdown teleport pattern (per SearchableModelSelect.vue):** ```js import { ref, onMounted, onUnmounted } from 'vue' const triggerEl = ref(null) const dropdownStyle = ref({}) const isOpen = ref(false) function updatePosition() { if (!triggerEl.value) return const rect = triggerEl.value.getBoundingClientRect() const spaceBelow = window.innerHeight - rect.bottom const dropH = 240 if (spaceBelow >= dropH || spaceBelow > 120) { dropdownStyle.value = { top: `${rect.bottom + 4}px`, left: `${rect.left}px`, width: '192px', } } else { dropdownStyle.value = { bottom: `${window.innerHeight - rect.top + 4}px`, left: `${rect.left}px`, width: '192px', } } } function openDropdown() { updatePosition() isOpen.value = true } function onScroll() { if (isOpen.value) updatePosition() } onMounted(() => { window.addEventListener('scroll', onScroll, true) window.addEventListener('resize', onScroll) }) onUnmounted(() => { window.removeEventListener('scroll', onScroll, true) window.removeEventListener('resize', onScroll) }) ``` Template: ```html
...
``` Three targets: - StorageBrowser folder picker (lines ~196-213 of current file): the per-file `
`. Replace with: 1. Add per-file refs is impractical (folders are iterated). Use a single shared trigger ref + a single Teleport. 2. Add new refs: `const pickerTriggerEl = ref(null)`, `const pickerStyle = ref({})`. 3. Add a function `function openFolderPicker(fileId, ev) { folderPickerFileId.value = fileId; const trigger = ev.currentTarget; const rect = trigger.getBoundingClientRect(); updatePickerPosition(rect) }`. 4. Add `function updatePickerPosition(rect) { const spaceBelow = window.innerHeight - rect.bottom; const dropH = 240; if (spaceBelow >= dropH || spaceBelow > 120) { pickerStyle.value = { top: `${rect.bottom + 4}px`, left: `${rect.left}px`, width: '192px' } } else { pickerStyle.value = { bottom: `${window.innerHeight - rect.top + 4}px`, left: `${rect.left}px`, width: '192px' } } }`. 5. Update the move-button to use `@click.stop="openFolderPicker(file.id, $event)"` (replacing the inline state setter). 6. Store the trigger button in a Map keyed by fileId (`const pickerTriggerMap = new Map()`) so reposition-on-scroll knows which trigger to recompute. On `openFolderPicker`, set `pickerTriggerMap.set(fileId, ev.currentTarget)`. 7. Move the dropdown `
` OUT of the inline `
` and into a single `` block at the end of the template: ```html
``` Reuse the existing list-of-folders markup verbatim - only the wrapping changes. 8. Add a window scroll listener to keep the picker positioned: ```js function onWindowScroll() { if (!folderPickerFileId.value) return const trig = pickerTriggerMap.get(folderPickerFileId.value) if (trig) updatePickerPosition(trig.getBoundingClientRect()) } onMounted(() => { window.addEventListener('scroll', onWindowScroll, true); window.addEventListener('resize', onWindowScroll) }) onUnmounted(() => { window.removeEventListener('scroll', onWindowScroll, true); window.removeEventListener('resize', onWindowScroll) }) ``` 9. Keep the existing outside-click handler (`onOutsideClick`) but update it to also close the picker when clicking outside both the trigger AND the teleported dropdown. Simplest: in onOutsideClick, check `if (!e.target.closest('[data-test="folder-picker"]') && !e.target.closest('.relative')) folderPickerFileId.value = null`. Or rely on the existing `.relative` check since the trigger button is still wrapped in `.relative` even if the dropdown is teleported. Preserve all other StorageBrowser functionality untouched. cd frontend && npm run test -- --run dragmove; cd frontend && npm run test -- --run StorageBrowser Expected: 4 UX-11 dragmove tests pass; existing StorageBrowser suite unaffected. - `grep -E "draggingFile\\s*\\?\\s*null\\s*:\\s*\\$emit\\('file-open'" frontend/src/components/storage/StorageBrowser.vue` returns 1 (click guard in place) - `grep -E "function onFileDragEnd" frontend/src/components/storage/StorageBrowser.vue` returns 1 - `grep -E "@dragend=\"onFileDragEnd\"" frontend/src/components/storage/StorageBrowser.vue` returns 1 - `grep -E "await nextTick\\(\\)" frontend/src/components/storage/StorageBrowser.vue` returns >= 1 (in onDropDocOnFolder) - `grep -E "" frontend/src/components/storage/StorageBrowser.vue` returns >= 1 - `grep -E "data-test=\"folder-picker\"" frontend/src/components/storage/StorageBrowser.vue` returns 1 - `grep -E "getBoundingClientRect" frontend/src/components/storage/StorageBrowser.vue` returns >= 1 - `grep -E "addEventListener\\('scroll'" frontend/src/components/storage/StorageBrowser.vue` returns 1 - 4 UX-11 dragmove tests pass - Existing StorageBrowser test suite passes Click guard + teleport in place; UX-11 dragmove tests green. Task 3: Teleport DocumentCard folder picker frontend/src/components/documents/DocumentCard.vue - frontend/src/components/documents/DocumentCard.vue (current state - has folder picker absolute-positioned) - frontend/src/components/ui/SearchableModelSelect.vue (Teleport reference) - .planning/phases/10-ux-interaction/10-PATTERNS.md §"DocumentCard.vue + FolderRow.vue" Apply the same Teleport pattern to the DocumentCard folder picker: 1. Add refs: `pickerTriggerEl`, `pickerStyle`. Add `updatePosition()` helper using getBoundingClientRect. 2. Change `toggleFolderPicker()` to capture the trigger element and recompute position before setting `showFolderPicker = true`. 3. Wrap the picker dropdown in `` with `class="fixed z-[9999]"` and `:style="pickerStyle"`. Add `data-test="folder-picker"`. 4. Add window scroll + resize listeners that call updatePosition when open. 5. Add window scroll + resize listener cleanup in onUnmounted. 6. Ensure outside-click closes the picker (existing logic should still work if it checks for the trigger button's wrapper class). Reuse the exact dropdown markup; only the wrapping changes. cd frontend && npm run test -- --run dropdown Expected: 2 DocumentCard-related tests pass (Teleport + position). - `grep -E "" frontend/src/components/documents/DocumentCard.vue` returns 1 - `grep -E "getBoundingClientRect" frontend/src/components/documents/DocumentCard.vue` returns >= 1 - `grep -E "data-test=\"folder-picker\"" frontend/src/components/documents/DocumentCard.vue` returns 1 - `grep -E "addEventListener\\('scroll'" frontend/src/components/documents/DocumentCard.vue` returns 1 - 2 DocumentCard-related dropdown tests pass DocumentCard folder picker teleported. Task 4: Teleport FolderRow three-dot menu frontend/src/components/folders/FolderRow.vue - frontend/src/components/folders/FolderRow.vue (current state - has three-dot menu absolute-positioned, lines ~37-66) - frontend/src/components/ui/SearchableModelSelect.vue (Teleport reference) - .planning/phases/10-ux-interaction/10-PATTERNS.md §"DocumentCard.vue + FolderRow.vue" Apply the same Teleport pattern to FolderRow's three-dot menu: 1. Add refs: `menuTriggerEl`, `menuStyle`. Add `updatePosition()` helper using getBoundingClientRect. 2. Change `toggleMenu()` to capture the trigger element and recompute position before flipping `menuOpen`. 3. Wrap the menu `
` in `` with `class="fixed z-[9999]"` and `:style="menuStyle"`. Add `data-test="folder-row-menu"`. 4. Add window scroll + resize listeners. 5. Outside-click logic preserved or updated. cd frontend && npm run test -- --run dropdown; cd frontend && npm run test -- --run Expected: all 4 dropdown tests pass + full suite green. - `grep -E "" frontend/src/components/folders/FolderRow.vue` returns 1 - `grep -E "getBoundingClientRect" frontend/src/components/folders/FolderRow.vue` returns >= 1 - `grep -E "data-test=\"folder-row-menu\"" frontend/src/components/folders/FolderRow.vue` returns 1 - `grep -E "addEventListener\\('scroll'" frontend/src/components/folders/FolderRow.vue` returns 1 - All 4 dropdown tests pass - Full test suite passes FolderRow three-dot menu teleported; UX-13 fully closed. - `cd frontend && npm run test -- --run dragmove` exits 0 - `cd frontend && npm run test -- --run dropdown` exits 0 - `cd frontend && npm run test -- --run` (full suite) exits 0 - StorageBrowser, DocumentCard, FolderRow each contain `` - StorageBrowser file-row click guard present (grep on draggingFile + file-open) Drag a document onto a folder row -> folder row gets the amber ring -> release -> document moves and a "Document moved" toast appears -> clicking the same file row does NOT navigate (the drag suppressed the click). Opening the move-to-folder dropdown on any file (in StorageBrowser or DocumentCard) shows a properly positioned dropdown that does not clip at the viewport edge and follows the trigger when the page scrolls. Same for FolderRow three-dot menu. Create `.planning/phases/10-ux-interaction/10-11-SUMMARY.md` when done.