refactor(ui): unify confirm-dropdown Enter behavior via useAcceptOnEnter
Operator-flagged (again) on the tag-merge picker: Enter on the dropdown re-opens it instead of accepting the selection. I'd already patched this twice (fandom picker + fandom set dialog) with copy-pasted capture-phase handlers, so DRY it. New composable useAcceptOnEnter(accept): tracks the menu state and, on a capture-phase Enter, lets Vuetify pick when the menu is open but calls accept() (and blocks the re-open) when it's closed. Applied to every confirm-style picker: - TagsView merge-into picker (the reported one) - AliasPickerDialog - PostSeriesMenu add-to-existing - FandomPicker + FandomSetDialog (refactored off their bespoke handlers) One behavior, one place to change it.
This commit is contained in:
@@ -8,6 +8,7 @@
|
|||||||
</p>
|
</p>
|
||||||
<v-autocomplete
|
<v-autocomplete
|
||||||
v-model="selectedId"
|
v-model="selectedId"
|
||||||
|
v-model:menu="menuOpen"
|
||||||
:items="results"
|
:items="results"
|
||||||
:item-title="(t) => t.fandom_name ? `${t.name} — ${t.fandom_name}` : t.name"
|
:item-title="(t) => t.fandom_name ? `${t.name} — ${t.fandom_name}` : t.name"
|
||||||
:item-value="(t) => t.id"
|
:item-value="(t) => t.id"
|
||||||
@@ -15,6 +16,7 @@
|
|||||||
label="Canonical tag"
|
label="Canonical tag"
|
||||||
no-filter clearable density="compact"
|
no-filter clearable density="compact"
|
||||||
@update:search="onSearch"
|
@update:search="onSearch"
|
||||||
|
@keydown.enter.capture="onEnter"
|
||||||
/>
|
/>
|
||||||
</v-card-text>
|
</v-card-text>
|
||||||
<v-card-actions>
|
<v-card-actions>
|
||||||
@@ -31,9 +33,10 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import { useApi } from '../../composables/useApi.js'
|
import { useApi } from '../../composables/useApi.js'
|
||||||
|
import { useAcceptOnEnter } from '../../composables/useAcceptOnEnter.js'
|
||||||
|
|
||||||
const props = defineProps({ category: { type: String, required: true } })
|
const props = defineProps({ category: { type: String, required: true } })
|
||||||
defineEmits(['confirm', 'cancel'])
|
const emit = defineEmits(['confirm', 'cancel'])
|
||||||
|
|
||||||
const api = useApi()
|
const api = useApi()
|
||||||
const results = ref([])
|
const results = ref([])
|
||||||
@@ -41,6 +44,11 @@ const loading = ref(false)
|
|||||||
const selectedId = ref(null)
|
const selectedId = ref(null)
|
||||||
let debounce = null
|
let debounce = null
|
||||||
|
|
||||||
|
// Enter on the closed dropdown confirms the selection instead of re-opening it.
|
||||||
|
const { menuOpen, onEnter } = useAcceptOnEnter(() => {
|
||||||
|
if (selectedId.value != null) emit('confirm', selectedId.value)
|
||||||
|
})
|
||||||
|
|
||||||
function onSearch(q) {
|
function onSearch(q) {
|
||||||
if (debounce) clearTimeout(debounce)
|
if (debounce) clearTimeout(debounce)
|
||||||
debounce = setTimeout(async () => {
|
debounce = setTimeout(async () => {
|
||||||
|
|||||||
@@ -51,16 +51,19 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { nextTick, onMounted, ref } from 'vue'
|
import { nextTick, onMounted, ref } from 'vue'
|
||||||
import { useTagStore } from '../../stores/tags.js'
|
import { useTagStore } from '../../stores/tags.js'
|
||||||
|
import { useAcceptOnEnter } from '../../composables/useAcceptOnEnter.js'
|
||||||
|
|
||||||
const emit = defineEmits(['confirm', 'cancel'])
|
const emit = defineEmits(['confirm', 'cancel'])
|
||||||
const store = useTagStore()
|
const store = useTagStore()
|
||||||
|
|
||||||
const selectedId = ref(null)
|
const selectedId = ref(null)
|
||||||
const newName = ref('')
|
const newName = ref('')
|
||||||
const menuOpen = ref(false)
|
|
||||||
const fandomRef = ref(null)
|
const fandomRef = ref(null)
|
||||||
const newNameRef = ref(null)
|
const newNameRef = ref(null)
|
||||||
|
|
||||||
|
// Enter on the closed dropdown accepts the chosen fandom instead of re-opening it.
|
||||||
|
const { menuOpen, onEnter: onSearchEnter } = useAcceptOnEnter(() => onConfirm())
|
||||||
|
|
||||||
// Always refresh on open so the list reflects fandoms created elsewhere (#712).
|
// Always refresh on open so the list reflects fandoms created elsewhere (#712).
|
||||||
onMounted(() => store.loadFandoms())
|
onMounted(() => store.loadFandoms())
|
||||||
|
|
||||||
@@ -92,22 +95,6 @@ function onConfirm () {
|
|||||||
if (f) emit('confirm', f)
|
if (f) emit('confirm', f)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Enter on the search field — bound in the CAPTURE phase so this runs BEFORE
|
|
||||||
// Vuetify's own input keydown handler (which opens the menu on Enter). When the
|
|
||||||
// menu is open, let Vuetify pick the highlighted item (it sets selectedId +
|
|
||||||
// closes the menu). When it's closed and a fandom is already chosen, accept it
|
|
||||||
// and stop the event so Vuetify never (re)opens the menu — that re-open was the
|
|
||||||
// bug: Enter popped the dropdown instead of submitting (operator-flagged
|
|
||||||
// 2026-06-07).
|
|
||||||
function onSearchEnter (e) {
|
|
||||||
if (menuOpen.value) return
|
|
||||||
if (selectedId.value != null) {
|
|
||||||
e.preventDefault()
|
|
||||||
e.stopPropagation()
|
|
||||||
onConfirm()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Tab from the search field goes to the "new fandom" text field (operator-
|
// Tab from the search field goes to the "new fandom" text field (operator-
|
||||||
// specified). Shift+Tab keeps normal reverse traversal.
|
// specified). Shift+Tab keeps normal reverse traversal.
|
||||||
function onSearchTab (e) {
|
function onSearchTab (e) {
|
||||||
|
|||||||
@@ -82,6 +82,7 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { nextTick, onMounted, ref } from 'vue'
|
import { nextTick, onMounted, ref } from 'vue'
|
||||||
import { useTagStore } from '../../stores/tags.js'
|
import { useTagStore } from '../../stores/tags.js'
|
||||||
|
import { useAcceptOnEnter } from '../../composables/useAcceptOnEnter.js'
|
||||||
|
|
||||||
const props = defineProps({ tag: { type: Object, required: true } })
|
const props = defineProps({ tag: { type: Object, required: true } })
|
||||||
const emit = defineEmits(['updated', 'cancel'])
|
const emit = defineEmits(['updated', 'cancel'])
|
||||||
@@ -93,10 +94,15 @@ const newName = ref('')
|
|||||||
const busy = ref(false)
|
const busy = ref(false)
|
||||||
const error = ref(null)
|
const error = ref(null)
|
||||||
const collision = ref(null)
|
const collision = ref(null)
|
||||||
const menuOpen = ref(false)
|
|
||||||
const fandomRef = ref(null)
|
const fandomRef = ref(null)
|
||||||
const newNameRef = ref(null)
|
const newNameRef = ref(null)
|
||||||
|
|
||||||
|
// Enter on the closed dropdown Saves the changed fandom instead of re-opening it.
|
||||||
|
const { menuOpen, onEnter: onSearchEnter } = useAcceptOnEnter(() => {
|
||||||
|
if (busy.value) return
|
||||||
|
if (selectedId.value !== (props.tag.fandom_id ?? null)) onSave()
|
||||||
|
})
|
||||||
|
|
||||||
// Always refresh on open so the list reflects fandoms created elsewhere (#712).
|
// Always refresh on open so the list reflects fandoms created elsewhere (#712).
|
||||||
onMounted(() => store.loadFandoms())
|
onMounted(() => store.loadFandoms())
|
||||||
|
|
||||||
@@ -131,20 +137,6 @@ async function onCreate() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Enter on the search field — bound in the CAPTURE phase so it runs BEFORE
|
|
||||||
// Vuetify's own handler (which opens the menu on Enter). Menu open → let Vuetify
|
|
||||||
// pick the highlighted item. Menu closed with a changed selection → Save and stop
|
|
||||||
// the event so Vuetify never (re)opens the dropdown (the bug this fixes).
|
|
||||||
function onSearchEnter(e) {
|
|
||||||
if (menuOpen.value) return
|
|
||||||
if (busy.value) return
|
|
||||||
if (selectedId.value !== (props.tag.fandom_id ?? null)) {
|
|
||||||
e.preventDefault()
|
|
||||||
e.stopPropagation()
|
|
||||||
onSave()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Tab from the search field goes to the "new fandom" text field (operator-
|
// Tab from the search field goes to the "new fandom" text field (operator-
|
||||||
// specified). Shift+Tab keeps normal reverse traversal.
|
// specified). Shift+Tab keeps normal reverse traversal.
|
||||||
function onSearchTab(e) {
|
function onSearchTab(e) {
|
||||||
|
|||||||
@@ -32,11 +32,13 @@
|
|||||||
</p>
|
</p>
|
||||||
<v-autocomplete
|
<v-autocomplete
|
||||||
v-model="picked"
|
v-model="picked"
|
||||||
|
v-model:menu="pickerMenuOpen"
|
||||||
:items="hits"
|
:items="hits"
|
||||||
:item-title="(s) => s.name"
|
:item-title="(s) => s.name"
|
||||||
:item-value="(s) => s.id"
|
:item-value="(s) => s.id"
|
||||||
:loading="searching"
|
:loading="searching"
|
||||||
label="Series" density="compact" autofocus clearable
|
label="Series" density="compact" autofocus clearable
|
||||||
|
@keydown.enter.capture="onEnter"
|
||||||
/>
|
/>
|
||||||
</v-card-text>
|
</v-card-text>
|
||||||
<v-card-actions>
|
<v-card-actions>
|
||||||
@@ -56,6 +58,7 @@
|
|||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { useApi } from '../../composables/useApi.js'
|
import { useApi } from '../../composables/useApi.js'
|
||||||
|
import { useAcceptOnEnter } from '../../composables/useAcceptOnEnter.js'
|
||||||
import { toast } from '../../utils/toast.js'
|
import { toast } from '../../utils/toast.js'
|
||||||
|
|
||||||
const props = defineProps({ post: { type: Object, required: true } })
|
const props = defineProps({ post: { type: Object, required: true } })
|
||||||
@@ -70,6 +73,12 @@ const picked = ref(null)
|
|||||||
const hits = ref([])
|
const hits = ref([])
|
||||||
const searching = ref(false)
|
const searching = ref(false)
|
||||||
|
|
||||||
|
// Enter on the closed dropdown adds the chosen series instead of re-opening it.
|
||||||
|
// (aliased — `menuOpen` above is the outer "Series ▾" v-menu's state.)
|
||||||
|
const { menuOpen: pickerMenuOpen, onEnter } = useAcceptOnEnter(() => {
|
||||||
|
if (picked.value != null && !busy.value) onAddExisting()
|
||||||
|
})
|
||||||
|
|
||||||
async function onPromote() {
|
async function onPromote() {
|
||||||
menuOpen.value = false
|
menuOpen.value = false
|
||||||
busy.value = true
|
busy.value = true
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { ref } from 'vue'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unifies keyboard behavior for confirm-style `v-autocomplete` / `v-select`
|
||||||
|
* dropdowns — a single picker whose Enter should trigger the surrounding
|
||||||
|
* modal's primary action (Merge, Add, Save, Use…).
|
||||||
|
*
|
||||||
|
* The recurring bug (operator-flagged across the merge / fandom / series / alias
|
||||||
|
* dialogs): pressing Enter with the menu CLOSED re-opens the dropdown instead of
|
||||||
|
* accepting the chosen value, because Vuetify's own Enter handler opens the menu.
|
||||||
|
* We bind in the CAPTURE phase so this runs BEFORE Vuetify's handler:
|
||||||
|
* - menu OPEN → return; let Vuetify pick the highlighted item (it sets the
|
||||||
|
* value and closes the menu).
|
||||||
|
* - menu CLOSED → preventDefault + stopPropagation so Vuetify can't re-open the
|
||||||
|
* menu, then call `accept()` — the modal's confirm. `accept`
|
||||||
|
* should itself no-op when nothing valid is selected.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* const { menuOpen, onEnter } = useAcceptOnEnter(onConfirm)
|
||||||
|
* <v-autocomplete v-model:menu="menuOpen" @keydown.enter.capture="onEnter" … />
|
||||||
|
*
|
||||||
|
* `menuOpen` is exposed so a component that programmatically focuses the field
|
||||||
|
* can force the menu shut (so the very next Enter accepts rather than being
|
||||||
|
* swallowed as a menu interaction).
|
||||||
|
*/
|
||||||
|
export function useAcceptOnEnter(accept) {
|
||||||
|
const menuOpen = ref(false)
|
||||||
|
function onEnter(e) {
|
||||||
|
if (menuOpen.value) return
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
accept()
|
||||||
|
}
|
||||||
|
return { menuOpen, onEnter }
|
||||||
|
}
|
||||||
@@ -55,11 +55,13 @@
|
|||||||
</p>
|
</p>
|
||||||
<v-autocomplete
|
<v-autocomplete
|
||||||
v-model="mergeTargetId"
|
v-model="mergeTargetId"
|
||||||
|
v-model:menu="mergeMenuOpen"
|
||||||
:items="mergeHits" item-title="name" item-value="id"
|
:items="mergeHits" item-title="name" item-value="id"
|
||||||
:loading="mergeLoading"
|
:loading="mergeLoading"
|
||||||
density="compact" variant="outlined" hide-details
|
density="compact" variant="outlined" hide-details
|
||||||
placeholder="Search target tag…" no-filter
|
placeholder="Search target tag…" no-filter
|
||||||
@update:search="onMergeSearch"
|
@update:search="onMergeSearch"
|
||||||
|
@keydown.enter.capture="onMergeEnter"
|
||||||
/>
|
/>
|
||||||
</v-card-text>
|
</v-card-text>
|
||||||
<v-card-actions>
|
<v-card-actions>
|
||||||
@@ -107,6 +109,7 @@ import { useTagDirectoryStore } from '../stores/tagDirectory.js'
|
|||||||
import { useAdminStore } from '../stores/admin.js'
|
import { useAdminStore } from '../stores/admin.js'
|
||||||
import { useApi } from '../composables/useApi.js'
|
import { useApi } from '../composables/useApi.js'
|
||||||
import { useInfiniteScroll } from '../composables/useInfiniteScroll.js'
|
import { useInfiniteScroll } from '../composables/useInfiniteScroll.js'
|
||||||
|
import { useAcceptOnEnter } from '../composables/useAcceptOnEnter.js'
|
||||||
import TagCard from '../components/discovery/TagCard.vue'
|
import TagCard from '../components/discovery/TagCard.vue'
|
||||||
import MergeConfirmDialog from '../components/discovery/MergeConfirmDialog.vue'
|
import MergeConfirmDialog from '../components/discovery/MergeConfirmDialog.vue'
|
||||||
import DestructiveConfirmModal from '../components/modal/DestructiveConfirmModal.vue'
|
import DestructiveConfirmModal from '../components/modal/DestructiveConfirmModal.vue'
|
||||||
@@ -181,6 +184,10 @@ const api = useApi()
|
|||||||
|
|
||||||
// Tag merge via dots-menu (separate from inline-rename collision flow above)
|
// Tag merge via dots-menu (separate from inline-rename collision flow above)
|
||||||
const mergePickerOpen = ref(false)
|
const mergePickerOpen = ref(false)
|
||||||
|
// Enter on the closed dropdown accepts the target instead of re-opening it.
|
||||||
|
const { menuOpen: mergeMenuOpen, onEnter: onMergeEnter } = useAcceptOnEnter(
|
||||||
|
() => onMergeConfirm()
|
||||||
|
)
|
||||||
const mergeSource = ref(null)
|
const mergeSource = ref(null)
|
||||||
const mergeTargetId = ref(null)
|
const mergeTargetId = ref(null)
|
||||||
const mergeHits = ref([])
|
const mergeHits = ref([])
|
||||||
|
|||||||
Reference in New Issue
Block a user