mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-09-21 03:01:27 -03:00
feat(frontend): one grip reorder affordance for tags and trigger words
Model tags could already be reordered by dragging a chip, but the only hint was a `cursor: grab` on `.metadata-item` — a hover-only, mouse-only signal that also leaked into the bulk add-tags modal, where the chips are not sortable at all. Trigger words could not be reordered, and their order matters: "Copy Trigger Words" and the insert-into-node action join the array as-is to build a prompt. Both editors now share one vocabulary: a `⠿` grip that appears whenever the list has something to order, plus `Alt + arrow` keyboard moves with an aria-live announcement. Whether the chip body is draggable is a property of the item rather than of the feature: - tags have no click action of their own, so the whole chip stays draggable (`handleSelector: null`), with a 5px threshold so a click on the grip only focuses it - trigger words keep click-to-edit on the body, so a drag starts from the grip only - the grip is the element that opts out of touch scrolling (`touch-action: none`), so touch users drag by the grip in both editors - reordering is offered only while editing: trigger words reveal the grip from `.edit-mode`, tags from the edit container, which stays hidden outside edit mode The drag engine moves out of ModelTags.js into shared/pointerSort.js, which now marks sortable containers with `pointer-sort-enabled` so only lists that really sort show the grab cursor. Labels, the sortable flag, the keyboard handler and the live region live in shared/reorderSupport.js, and both editors render the same three `common.reorder.*` keys (translations follow in the next commit). Two inherited engine bugs are fixed on the way: the drop position was only settled when an animation frame was still pending, so a fast drag (or one that started by crossing the threshold) fell back into its original slot; and the guard that stops a drop from triggering the chip's own click handler was removed on a timer, swallowing unrelated clicks until the next task.
This commit is contained in:
@@ -7,6 +7,12 @@ import { getModelApiClient } from '../../api/modelApiFactory.js';
|
||||
import { translate } from '../../utils/i18nHelpers.js';
|
||||
import { getPriorityTagSuggestions } from '../../utils/priorityTagHelpers.js';
|
||||
import { state } from '../../state/index.js';
|
||||
import { enablePointerSort } from './pointerSort.js';
|
||||
import {
|
||||
createReorderSupport,
|
||||
renderReorderHandle,
|
||||
renderReorderHint,
|
||||
} from './reorderSupport.js';
|
||||
|
||||
const MODEL_TYPE_SUGGESTION_KEY_MAP = {
|
||||
loras: 'lora',
|
||||
@@ -18,16 +24,23 @@ const MODEL_TYPE_SUGGESTION_KEY_MAP = {
|
||||
};
|
||||
const METADATA_ITEM_SELECTOR = '.metadata-item';
|
||||
const METADATA_ITEMS_CONTAINER_SELECTOR = '.metadata-items';
|
||||
const METADATA_ITEM_DRAGGING_CLASS = 'metadata-item-dragging';
|
||||
const METADATA_ITEM_PLACEHOLDER_CLASS = 'metadata-item-placeholder';
|
||||
const METADATA_ITEMS_SORTING_CLASS = 'metadata-items-sorting';
|
||||
const BODY_DRAGGING_CLASS = 'metadata-drag-active';
|
||||
const METADATA_DRAG_HANDLE_SELECTOR = '.reorder-handle';
|
||||
|
||||
/**
|
||||
* Tag items have no click action of their own, so the whole chip stays
|
||||
* draggable (handleSelector is null); the small threshold keeps a click on the
|
||||
* grip from starting a drag (it just focuses the grip). Touch users drag by the
|
||||
* grip, which is the element that opts out of scrolling via touch-action.
|
||||
*/
|
||||
const TAG_SORT_CONFIG = {
|
||||
itemSelector: METADATA_ITEM_SELECTOR,
|
||||
dragThreshold: 5,
|
||||
};
|
||||
|
||||
let activeModelTypeKey = '';
|
||||
let priorityTagSuggestions = [];
|
||||
let priorityTagSuggestionsLoaded = false;
|
||||
let priorityTagSuggestionsPromise = null;
|
||||
let activeTagDragState = null;
|
||||
|
||||
// Configurable options for tag editing (set by setupTagEditMode)
|
||||
let tagEditOptions = {
|
||||
@@ -423,6 +436,7 @@ function createTagEditUI(currentTags, editBtnHTML = '') {
|
||||
<div class="metadata-items">
|
||||
${currentTags.map(tag => `
|
||||
<div class="metadata-item" data-tag="${tag}">
|
||||
${renderReorderHandle(translate('common.reorder.dragHandle'))}
|
||||
<span class="metadata-item-content">${tag}</span>
|
||||
<button class="metadata-delete-btn">
|
||||
<i class="fas fa-times"></i>
|
||||
@@ -431,6 +445,7 @@ function createTagEditUI(currentTags, editBtnHTML = '') {
|
||||
`).join('')}
|
||||
</div>
|
||||
<div class="metadata-edit-controls">
|
||||
${renderReorderHint(translate('common.reorder.dragHandle'))}
|
||||
<button class="save-tags-btn" title="Save changes">
|
||||
<i class="fas fa-save"></i> Save
|
||||
</button>
|
||||
@@ -543,8 +558,11 @@ function setupDeleteButtons() {
|
||||
btn.addEventListener('click', function(e) {
|
||||
e.stopPropagation();
|
||||
const tag = this.closest('.metadata-item');
|
||||
const scope = tag?.closest('.model-tags-container');
|
||||
tag.remove();
|
||||
|
||||
|
||||
scope?._tagReorderSupport?.refresh();
|
||||
|
||||
// Update status of items in the suggestion dropdown
|
||||
updateSuggestionsDropdown();
|
||||
});
|
||||
@@ -563,202 +581,29 @@ function setupTagDragAndDrop(scopeContainer) {
|
||||
return;
|
||||
}
|
||||
|
||||
container.querySelectorAll(METADATA_ITEM_SELECTOR).forEach((item) => {
|
||||
item.removeAttribute('draggable');
|
||||
if (item.classList.contains(METADATA_ITEM_PLACEHOLDER_CLASS)) {
|
||||
return;
|
||||
}
|
||||
if (item.dataset.pointerDragInit === 'true') {
|
||||
return;
|
||||
}
|
||||
const scope = container.closest('.model-tags-container') || container;
|
||||
let support = scope._tagReorderSupport;
|
||||
if (!support || scope._tagReorderContainer !== container) {
|
||||
support = createReorderSupport({
|
||||
container,
|
||||
scope,
|
||||
handleSelector: METADATA_DRAG_HANDLE_SELECTOR,
|
||||
sortConfig: TAG_SORT_CONFIG,
|
||||
});
|
||||
scope._tagReorderSupport = support;
|
||||
scope._tagReorderContainer = container;
|
||||
}
|
||||
|
||||
item.addEventListener('pointerdown', handleTagPointerDown);
|
||||
item.dataset.pointerDragInit = 'true';
|
||||
enablePointerSort(container, {
|
||||
...TAG_SORT_CONFIG,
|
||||
onSorted: (item) => {
|
||||
updateSuggestionsDropdown();
|
||||
support.refresh();
|
||||
support.announce(item);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function handleTagPointerDown(event) {
|
||||
if (event.button !== 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.target.closest('.metadata-delete-btn')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const item = event.currentTarget;
|
||||
const container = item?.closest(METADATA_ITEMS_CONTAINER_SELECTOR);
|
||||
if (!item || !container) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
startPointerDrag({ item, container, startEvent: event });
|
||||
}
|
||||
|
||||
function startPointerDrag({ item, container, startEvent }) {
|
||||
if (activeTagDragState) {
|
||||
finishPointerDrag();
|
||||
}
|
||||
|
||||
const itemRect = item.getBoundingClientRect();
|
||||
const placeholder = document.createElement('div');
|
||||
placeholder.className = `metadata-item ${METADATA_ITEM_PLACEHOLDER_CLASS}`;
|
||||
placeholder.style.width = `${itemRect.width}px`;
|
||||
placeholder.style.height = `${itemRect.height}px`;
|
||||
|
||||
container.insertBefore(placeholder, item);
|
||||
|
||||
item.classList.add(METADATA_ITEM_DRAGGING_CLASS);
|
||||
item.style.width = `${itemRect.width}px`;
|
||||
item.style.height = `${itemRect.height}px`;
|
||||
item.style.position = 'fixed';
|
||||
item.style.left = `${itemRect.left}px`;
|
||||
item.style.top = `${itemRect.top}px`;
|
||||
item.style.pointerEvents = 'none';
|
||||
item.style.zIndex = '1000';
|
||||
|
||||
container.classList.add(METADATA_ITEMS_SORTING_CLASS);
|
||||
if (document.body) {
|
||||
document.body.classList.add(BODY_DRAGGING_CLASS);
|
||||
}
|
||||
|
||||
const dragState = {
|
||||
container,
|
||||
item,
|
||||
placeholder,
|
||||
offsetX: startEvent.clientX - itemRect.left,
|
||||
offsetY: startEvent.clientY - itemRect.top,
|
||||
lastKnownPointer: { x: startEvent.clientX, y: startEvent.clientY },
|
||||
rafId: null,
|
||||
};
|
||||
|
||||
activeTagDragState = dragState;
|
||||
|
||||
document.addEventListener('pointermove', handlePointerMove);
|
||||
document.addEventListener('pointerup', handlePointerUp);
|
||||
document.addEventListener('pointercancel', handlePointerUp);
|
||||
}
|
||||
|
||||
function handlePointerMove(event) {
|
||||
if (!activeTagDragState) {
|
||||
return;
|
||||
}
|
||||
|
||||
activeTagDragState.lastKnownPointer = { x: event.clientX, y: event.clientY };
|
||||
|
||||
if (activeTagDragState.rafId !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
activeTagDragState.rafId = requestAnimationFrame(() => {
|
||||
if (!activeTagDragState) {
|
||||
return;
|
||||
}
|
||||
activeTagDragState.rafId = null;
|
||||
updateDraggingItemPosition();
|
||||
updatePlaceholderPosition();
|
||||
});
|
||||
}
|
||||
|
||||
function handlePointerUp() {
|
||||
finishPointerDrag();
|
||||
}
|
||||
|
||||
function updateDraggingItemPosition() {
|
||||
if (!activeTagDragState) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { item, offsetX, offsetY, lastKnownPointer } = activeTagDragState;
|
||||
const left = lastKnownPointer.x - offsetX;
|
||||
const top = lastKnownPointer.y - offsetY;
|
||||
item.style.left = `${left}px`;
|
||||
item.style.top = `${top}px`;
|
||||
}
|
||||
|
||||
function updatePlaceholderPosition() {
|
||||
if (!activeTagDragState) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { container, placeholder, item, lastKnownPointer } = activeTagDragState;
|
||||
const siblings = Array.from(
|
||||
container.querySelectorAll(
|
||||
`${METADATA_ITEM_SELECTOR}:not(.${METADATA_ITEM_PLACEHOLDER_CLASS})`
|
||||
)
|
||||
).filter((element) => element !== item);
|
||||
|
||||
let insertAfter = null;
|
||||
|
||||
for (const sibling of siblings) {
|
||||
const rect = sibling.getBoundingClientRect();
|
||||
|
||||
if (lastKnownPointer.y < rect.top) {
|
||||
container.insertBefore(placeholder, sibling);
|
||||
return;
|
||||
}
|
||||
|
||||
if (lastKnownPointer.y <= rect.bottom) {
|
||||
if (lastKnownPointer.x < rect.left + rect.width / 2) {
|
||||
container.insertBefore(placeholder, sibling);
|
||||
return;
|
||||
}
|
||||
insertAfter = sibling;
|
||||
continue;
|
||||
}
|
||||
|
||||
insertAfter = sibling;
|
||||
}
|
||||
|
||||
if (!insertAfter) {
|
||||
container.insertBefore(placeholder, container.firstElementChild);
|
||||
return;
|
||||
}
|
||||
|
||||
container.insertBefore(placeholder, insertAfter.nextSibling);
|
||||
}
|
||||
|
||||
function finishPointerDrag() {
|
||||
if (!activeTagDragState) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { container, item, placeholder, rafId } = activeTagDragState;
|
||||
|
||||
document.removeEventListener('pointermove', handlePointerMove);
|
||||
document.removeEventListener('pointerup', handlePointerUp);
|
||||
document.removeEventListener('pointercancel', handlePointerUp);
|
||||
|
||||
container.classList.remove(METADATA_ITEMS_SORTING_CLASS);
|
||||
if (document.body) {
|
||||
document.body.classList.remove(BODY_DRAGGING_CLASS);
|
||||
}
|
||||
|
||||
if (rafId !== null) {
|
||||
cancelAnimationFrame(rafId);
|
||||
activeTagDragState.rafId = null;
|
||||
updateDraggingItemPosition();
|
||||
updatePlaceholderPosition();
|
||||
}
|
||||
|
||||
if (placeholder && placeholder.parentNode === container) {
|
||||
container.insertBefore(item, placeholder);
|
||||
container.removeChild(placeholder);
|
||||
}
|
||||
|
||||
item.classList.remove(METADATA_ITEM_DRAGGING_CLASS);
|
||||
item.style.position = '';
|
||||
item.style.width = '';
|
||||
item.style.height = '';
|
||||
item.style.left = '';
|
||||
item.style.top = '';
|
||||
item.style.pointerEvents = '';
|
||||
item.style.zIndex = '';
|
||||
|
||||
activeTagDragState = null;
|
||||
|
||||
updateSuggestionsDropdown();
|
||||
support.refresh();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -799,6 +644,7 @@ function addNewTag(tag, scopeElement = null) {
|
||||
newTag.className = 'metadata-item';
|
||||
newTag.dataset.tag = tag;
|
||||
newTag.innerHTML = `
|
||||
${renderReorderHandle(translate('common.reorder.dragHandle'))}
|
||||
<span class="metadata-item-content">${tag}</span>
|
||||
<button class="metadata-delete-btn">
|
||||
<i class="fas fa-times"></i>
|
||||
|
||||
@@ -7,10 +7,33 @@ import { showToast, copyToClipboard } from '../../utils/uiHelpers.js';
|
||||
import { translate } from '../../utils/i18nHelpers.js';
|
||||
import { getModelApiClient } from '../../api/modelApiFactory.js';
|
||||
import { escapeAttribute, escapeHtml } from './utils.js';
|
||||
import {
|
||||
enablePointerSort,
|
||||
disablePointerSort,
|
||||
} from './pointerSort.js';
|
||||
import {
|
||||
createReorderSupport,
|
||||
renderReorderHandle,
|
||||
renderReorderHint,
|
||||
} from './reorderSupport.js';
|
||||
|
||||
const MAX_WORDS_PER_TRIGGER_GROUP = 500;
|
||||
const MAX_TRIGGER_WORD_GROUPS = 100;
|
||||
const TRIGGER_WORD_CLICK_DELAY_MS = 220;
|
||||
const TRIGGER_WORD_DRAG_HANDLE_SELECTOR = '.reorder-handle';
|
||||
|
||||
/**
|
||||
* Drag-to-reorder configuration for trigger word tags.
|
||||
* Handlers are installed when entering edit mode and removed again on exit, so
|
||||
* display mode keeps its click-to-copy / double-click-to-edit behaviour.
|
||||
* The item body is click-to-edit here, so only the grip starts a drag.
|
||||
*/
|
||||
const TRIGGER_WORD_DRAG_CONFIG = {
|
||||
itemSelector: '.trigger-word-tag',
|
||||
handleSelector: TRIGGER_WORD_DRAG_HANDLE_SELECTOR,
|
||||
ignoreSelector: '.metadata-delete-btn, .trigger-word-edit-input',
|
||||
blockedItemSelector: '.is-editing',
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetch trained words for a model
|
||||
@@ -182,6 +205,16 @@ function createSuggestionDropdown(trainedWords, classTokens, existingWords = [])
|
||||
return dropdown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the drag handle of a trigger word tag.
|
||||
* The handle is always in the DOM but only visible (and clickable) in edit mode,
|
||||
* so switching modes never has to rebuild the tag markup.
|
||||
* @returns {string} Handle markup
|
||||
*/
|
||||
function renderTriggerWordDragHandle() {
|
||||
return renderReorderHandle(translate('common.reorder.dragHandle'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Render trigger words
|
||||
* @param {Array} words - Array of trigger words
|
||||
@@ -203,6 +236,7 @@ export function renderTriggerWords(words, filePath) {
|
||||
<div class="trigger-words-tags" style="display:none;"></div>
|
||||
</div>
|
||||
<div class="metadata-edit-controls" style="display:none;">
|
||||
${renderReorderHint(translate('common.reorder.dragHandle'))}
|
||||
<button class="metadata-save-btn" title="${translate('modals.model.triggerWords.save')}">
|
||||
<i class="fas fa-save"></i> ${translate('common.actions.save')}
|
||||
</button>
|
||||
@@ -228,6 +262,7 @@ export function renderTriggerWords(words, filePath) {
|
||||
const escapedAttr = escapeAttribute(word);
|
||||
return `
|
||||
<div class="trigger-word-tag" data-word="${escapedAttr}" title="${translate('modals.model.triggerWords.copyOrEditWord')}">
|
||||
${renderTriggerWordDragHandle()}
|
||||
<span class="trigger-word-content">${escapedWord}</span>
|
||||
<span class="trigger-word-copy">
|
||||
<i class="fas fa-copy"></i>
|
||||
@@ -240,6 +275,7 @@ export function renderTriggerWords(words, filePath) {
|
||||
</div>
|
||||
</div>
|
||||
<div class="metadata-edit-controls" style="display:none;">
|
||||
${renderReorderHint(translate('common.reorder.dragHandle'))}
|
||||
<button class="metadata-save-btn" title="${translate('modals.model.triggerWords.save')}">
|
||||
<i class="fas fa-save"></i> ${translate('common.actions.save')}
|
||||
</button>
|
||||
@@ -316,6 +352,10 @@ export function setupTriggerWordsEditMode() {
|
||||
}
|
||||
});
|
||||
|
||||
// Enable drag-to-reorder (grip handle) for the current words
|
||||
enableTriggerWordSort(triggerWordsSection);
|
||||
refreshTriggerWordHandleLabels(triggerWordsSection);
|
||||
|
||||
// Load trained words and display dropdown when entering edit mode
|
||||
// Add loading indicator
|
||||
const loadingIndicator = document.createElement('div');
|
||||
@@ -379,6 +419,10 @@ export function setupTriggerWordsEditMode() {
|
||||
if (tagsContainer) tagsContainer.style.display = 'none';
|
||||
}
|
||||
|
||||
// Leaving edit mode: tags are no longer reorderable
|
||||
disableTriggerWordSort(triggerWordsSection);
|
||||
refreshTriggerWordHandleLabels(triggerWordsSection);
|
||||
|
||||
// Remove dropdown if present
|
||||
const dropdown = triggerWordsSection.querySelector('.metadata-suggestions-dropdown');
|
||||
if (dropdown) dropdown.remove();
|
||||
@@ -433,8 +477,13 @@ export function setupTriggerWordsEditMode() {
|
||||
function deleteTriggerWord(e) {
|
||||
e.stopPropagation();
|
||||
const tag = this.closest('.trigger-word-tag');
|
||||
const section = tag?.closest('.trigger-words');
|
||||
tag.remove();
|
||||
|
||||
if (section) {
|
||||
refreshTriggerWordHandleLabels(section);
|
||||
}
|
||||
|
||||
// Update status of items in the trained words dropdown
|
||||
updateTrainedWordsDropdown();
|
||||
}
|
||||
@@ -493,6 +542,72 @@ function restoreOriginalTriggerWords(section, originalWords) {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get (or lazily create) the reorder support of a section.
|
||||
* Reordering is only allowed while the section is in edit mode, because the tag
|
||||
* body itself is click-to-edit and the grip must not appear in display mode.
|
||||
* @param {HTMLElement} section - The .trigger-words section
|
||||
* @returns {{refresh: Function, announce: Function}|null} Reorder support
|
||||
*/
|
||||
function getTriggerWordReorder(section) {
|
||||
const tagsContainer = section.querySelector('.trigger-words-tags');
|
||||
if (!tagsContainer) return null;
|
||||
|
||||
let support = section._triggerWordReorderSupport;
|
||||
if (!support || section._triggerWordReorderContainer !== tagsContainer) {
|
||||
support = createReorderSupport({
|
||||
container: tagsContainer,
|
||||
scope: section,
|
||||
handleSelector: TRIGGER_WORD_DRAG_HANDLE_SELECTOR,
|
||||
sortConfig: TRIGGER_WORD_DRAG_CONFIG,
|
||||
isActive: () => section.classList.contains('edit-mode'),
|
||||
});
|
||||
section._triggerWordReorderSupport = support;
|
||||
section._triggerWordReorderContainer = tagsContainer;
|
||||
}
|
||||
|
||||
return support;
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh the handle labels and the "sortable" flag of a section
|
||||
* @param {HTMLElement} section - The .trigger-words section
|
||||
*/
|
||||
function refreshTriggerWordHandleLabels(section) {
|
||||
getTriggerWordReorder(section)?.refresh();
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable drag-to-reorder for the tags of a section (edit mode only)
|
||||
* @param {HTMLElement} section - The .trigger-words section
|
||||
*/
|
||||
function enableTriggerWordSort(section) {
|
||||
const tagsContainer = section.querySelector('.trigger-words-tags');
|
||||
if (!tagsContainer) return;
|
||||
|
||||
const support = getTriggerWordReorder(section);
|
||||
|
||||
enablePointerSort(tagsContainer, {
|
||||
...TRIGGER_WORD_DRAG_CONFIG,
|
||||
onSorted: (item) => {
|
||||
support?.refresh();
|
||||
support?.announce(item);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove drag-to-reorder handlers when leaving edit mode
|
||||
* @param {HTMLElement} section - The .trigger-words section
|
||||
*/
|
||||
function disableTriggerWordSort(section) {
|
||||
const tagsContainer = section.querySelector('.trigger-words-tags');
|
||||
if (!tagsContainer) return;
|
||||
|
||||
disablePointerSort(tagsContainer, TRIGGER_WORD_DRAG_CONFIG);
|
||||
refreshTriggerWordHandleLabels(section);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a trigger word tag element
|
||||
* @param {string} word - Trigger word
|
||||
@@ -507,6 +622,7 @@ function createTriggerWordTag(word, isEditMode = false) {
|
||||
|
||||
const escapedWord = escapeHtml(word);
|
||||
tag.innerHTML = `
|
||||
${renderTriggerWordDragHandle()}
|
||||
<span class="trigger-word-content">${escapedWord}</span>
|
||||
<span class="trigger-word-copy" style="${isEditMode ? 'display:none;' : ''}">
|
||||
<i class="fas fa-copy"></i>
|
||||
@@ -637,7 +753,7 @@ function validateTriggerWord(word, tagsContainer, currentTag = null) {
|
||||
* @param {Event} e - Click event
|
||||
*/
|
||||
function startEditTriggerWord(e) {
|
||||
if (e.target.closest('.metadata-delete-btn') || e.target.closest('.trigger-word-edit-input')) return;
|
||||
if (e.target.closest('.metadata-delete-btn') || e.target.closest('.trigger-word-edit-input') || e.target.closest(TRIGGER_WORD_DRAG_HANDLE_SELECTOR)) return;
|
||||
|
||||
const tag = this.closest('.trigger-word-tag');
|
||||
const section = tag?.closest('.trigger-words');
|
||||
@@ -684,6 +800,11 @@ function startEditTriggerWord(e) {
|
||||
tag.classList.remove('is-editing');
|
||||
tag.style.removeProperty('--trigger-word-edit-width');
|
||||
tag.style.removeProperty('--trigger-word-edit-height');
|
||||
|
||||
if (section) {
|
||||
refreshTriggerWordHandleLabels(section);
|
||||
}
|
||||
|
||||
updateTrainedWordsDropdown();
|
||||
};
|
||||
|
||||
@@ -763,6 +884,12 @@ function addNewTriggerWord(word) {
|
||||
const newTag = createTriggerWordTag(word, triggerWordsSection.classList.contains('edit-mode'));
|
||||
tagsContainer.appendChild(newTag);
|
||||
|
||||
if (triggerWordsSection.classList.contains('edit-mode')) {
|
||||
// Wire the freshly added tag for reordering too
|
||||
enableTriggerWordSort(triggerWordsSection);
|
||||
refreshTriggerWordHandleLabels(triggerWordsSection);
|
||||
}
|
||||
|
||||
// Update status of items in the trained words dropdown
|
||||
updateTrainedWordsDropdown();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,398 @@
|
||||
/**
|
||||
* pointerSort.js
|
||||
* Shared pointer-based drag-and-drop sorting for wrapped item lists
|
||||
* (model tags, trigger words, ...).
|
||||
*
|
||||
* The engine lifts the dragged item into a fixed-position "ghost", leaves a
|
||||
* correctly sized placeholder behind, and moves that placeholder around based
|
||||
* on the pointer position. Because the dragged node is re-inserted where the
|
||||
* placeholder ended up, the resulting DOM order *is* the new sort order — the
|
||||
* save path simply reads the items in DOM order.
|
||||
*/
|
||||
|
||||
const DEFAULT_OPTIONS = {
|
||||
// Selector of the sortable items inside the container.
|
||||
itemSelector: '.metadata-item',
|
||||
// When set, a drag can only start from inside this element (a handle).
|
||||
// When null the whole item is draggable.
|
||||
handleSelector: null,
|
||||
// Elements inside an item that must never start a drag.
|
||||
ignoreSelector: '.metadata-delete-btn',
|
||||
// Items matching this selector cannot be dragged (e.g. while being edited).
|
||||
blockedItemSelector: null,
|
||||
draggingClass: 'reorder-dragging',
|
||||
placeholderClass: 'reorder-placeholder',
|
||||
containerSortingClass: 'reorder-sorting',
|
||||
// Added to <body> while dragging to disable text selection globally.
|
||||
bodySortingClass: 'reorder-drag-active',
|
||||
// Pointer travel (px) required before a drag starts. 0 = start on pointerdown.
|
||||
dragThreshold: 0,
|
||||
// Called after a successful drop with (item, container).
|
||||
onSorted: null,
|
||||
};
|
||||
|
||||
// Marks a container whose items are actually sortable, so styles can offer the
|
||||
// grab affordance only where dragging really works.
|
||||
const CONTAINER_ENABLED_CLASS = 'pointer-sort-enabled';
|
||||
|
||||
let activeDragState = null;
|
||||
let pendingDragState = null;
|
||||
|
||||
function resolveConfig(options = {}) {
|
||||
return { ...DEFAULT_OPTIONS, ...options };
|
||||
}
|
||||
|
||||
function itemInitKey(config) {
|
||||
// Any option that changes how a pointerdown is interpreted is part of the
|
||||
// key, so re-enabling a container with new options replaces the handler
|
||||
// instead of silently keeping the old one.
|
||||
return [
|
||||
config.itemSelector,
|
||||
config.handleSelector || '',
|
||||
config.ignoreSelector || '',
|
||||
config.blockedItemSelector || '',
|
||||
config.dragThreshold,
|
||||
].join('|');
|
||||
}
|
||||
|
||||
/**
|
||||
* Make the items of a container draggable within it.
|
||||
* Safe to call repeatedly (e.g. after adding an item): already-configured items
|
||||
* are skipped, and newly added items get wired up.
|
||||
* @param {HTMLElement} container - Element holding the sortable items
|
||||
* @param {Object} [options] - See DEFAULT_OPTIONS
|
||||
*/
|
||||
export function enablePointerSort(container, options = {}) {
|
||||
if (!container) return;
|
||||
|
||||
const config = resolveConfig(options);
|
||||
const initKey = itemInitKey(config);
|
||||
container.__pointerSortConfig = config;
|
||||
container.classList.add(CONTAINER_ENABLED_CLASS);
|
||||
|
||||
container.querySelectorAll(config.itemSelector).forEach((item) => {
|
||||
item.removeAttribute('draggable');
|
||||
if (item.classList.contains(config.placeholderClass)) return;
|
||||
if (item.__pointerSortKey === initKey) return;
|
||||
|
||||
if (item.__pointerSortHandler) {
|
||||
item.removeEventListener('pointerdown', item.__pointerSortHandler);
|
||||
}
|
||||
|
||||
const handler = (event) => handlePointerDown(event, item, container, config);
|
||||
item.addEventListener('pointerdown', handler);
|
||||
item.__pointerSortKey = initKey;
|
||||
item.__pointerSortHandler = handler;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove drag handlers previously installed by enablePointerSort().
|
||||
* @param {HTMLElement} container - Element holding the sortable items
|
||||
* @param {Object} [options] - Used when the container has no stored config
|
||||
*/
|
||||
export function disablePointerSort(container, options = {}) {
|
||||
if (!container) return;
|
||||
|
||||
const config = resolveConfig(container.__pointerSortConfig || options);
|
||||
container.querySelectorAll(config.itemSelector).forEach((item) => {
|
||||
if (item.__pointerSortHandler) {
|
||||
item.removeEventListener('pointerdown', item.__pointerSortHandler);
|
||||
}
|
||||
delete item.__pointerSortHandler;
|
||||
delete item.__pointerSortKey;
|
||||
});
|
||||
|
||||
delete container.__pointerSortConfig;
|
||||
container.classList.remove(CONTAINER_ENABLED_CLASS);
|
||||
cancelPendingDrag(container);
|
||||
|
||||
if (activeDragState && activeDragState.container === container) {
|
||||
finishPointerDrag();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Move an item by `offset` positions inside its container.
|
||||
* Shared by the keyboard interaction so it matches drag ordering exactly.
|
||||
* @param {HTMLElement} item - Item to move
|
||||
* @param {number} offset - Negative moves earlier, positive moves later
|
||||
* @param {Object} [options] - Same options as enablePointerSort(); use
|
||||
* `options.container` when the item is not attached to its list yet
|
||||
* @returns {{index: number, total: number}|null} New position, or null when out of range
|
||||
*/
|
||||
export function moveItemWithinContainer(item, offset, options = {}) {
|
||||
if (!item || !offset) return null;
|
||||
|
||||
const config = resolveConfig(options);
|
||||
const container = options.container || item.parentElement;
|
||||
if (!container) return null;
|
||||
|
||||
const items = Array.from(container.querySelectorAll(config.itemSelector)).filter(
|
||||
(element) => !element.classList.contains(config.placeholderClass),
|
||||
);
|
||||
const index = items.indexOf(item);
|
||||
if (index === -1) return null;
|
||||
|
||||
const target = index + offset;
|
||||
if (target < 0 || target >= items.length) return null;
|
||||
|
||||
const reference = offset < 0 ? items[target] : items[target].nextSibling;
|
||||
container.insertBefore(item, reference);
|
||||
|
||||
return { index: target, total: items.length };
|
||||
}
|
||||
|
||||
function handlePointerDown(event, item, container, config) {
|
||||
if (activeDragState || pendingDragState) return;
|
||||
if (typeof event.button === 'number' && event.button !== 0) return;
|
||||
if (config.ignoreSelector && event.target.closest(config.ignoreSelector)) return;
|
||||
if (config.handleSelector && !event.target.closest(config.handleSelector)) return;
|
||||
if (config.blockedItemSelector && item.matches(config.blockedItemSelector)) return;
|
||||
if (item.classList.contains(config.placeholderClass)) return;
|
||||
|
||||
if (config.dragThreshold > 0) {
|
||||
startPendingDrag({ item, container, config, startEvent: event });
|
||||
return;
|
||||
}
|
||||
|
||||
// Prevent the browser's native text selection / image drag from kicking in.
|
||||
event.preventDefault();
|
||||
startPointerDrag({ item, container, config, startEvent: event });
|
||||
}
|
||||
|
||||
function startPendingDrag({ item, container, config, startEvent }) {
|
||||
const state = {
|
||||
item,
|
||||
container,
|
||||
config,
|
||||
startX: startEvent.clientX,
|
||||
startY: startEvent.clientY,
|
||||
};
|
||||
|
||||
state.onMove = (event) => {
|
||||
const dx = event.clientX - state.startX;
|
||||
const dy = event.clientY - state.startY;
|
||||
if (Math.hypot(dx, dy) < config.dragThreshold) return;
|
||||
|
||||
cleanupPendingDrag();
|
||||
event.preventDefault();
|
||||
clearTextSelection();
|
||||
startPointerDrag({ item, container, config, startEvent: event });
|
||||
};
|
||||
state.onUp = () => cleanupPendingDrag();
|
||||
|
||||
pendingDragState = state;
|
||||
|
||||
document.addEventListener('pointermove', state.onMove);
|
||||
document.addEventListener('pointerup', state.onUp);
|
||||
document.addEventListener('pointercancel', state.onUp);
|
||||
}
|
||||
|
||||
function cleanupPendingDrag() {
|
||||
if (!pendingDragState) return;
|
||||
|
||||
const { onMove, onUp } = pendingDragState;
|
||||
document.removeEventListener('pointermove', onMove);
|
||||
document.removeEventListener('pointerup', onUp);
|
||||
document.removeEventListener('pointercancel', onUp);
|
||||
pendingDragState = null;
|
||||
}
|
||||
|
||||
function cancelPendingDrag(container) {
|
||||
if (pendingDragState && (!container || pendingDragState.container === container)) {
|
||||
cleanupPendingDrag();
|
||||
}
|
||||
}
|
||||
|
||||
function clearTextSelection() {
|
||||
if (typeof window === 'undefined' || !window.getSelection) return;
|
||||
const selection = window.getSelection();
|
||||
if (selection && selection.removeAllRanges) selection.removeAllRanges();
|
||||
}
|
||||
|
||||
function startPointerDrag({ item, container, config, startEvent }) {
|
||||
if (activeDragState) finishPointerDrag();
|
||||
|
||||
const itemRect = item.getBoundingClientRect();
|
||||
const placeholder = document.createElement('div');
|
||||
const placeholderClasses = Array.from(item.classList).filter(
|
||||
(name) => name !== config.draggingClass && name !== config.placeholderClass,
|
||||
);
|
||||
placeholderClasses.push(config.placeholderClass);
|
||||
placeholder.className = placeholderClasses.join(' ');
|
||||
placeholder.style.width = `${itemRect.width}px`;
|
||||
placeholder.style.height = `${itemRect.height}px`;
|
||||
|
||||
container.insertBefore(placeholder, item);
|
||||
|
||||
item.classList.add(config.draggingClass);
|
||||
item.style.width = `${itemRect.width}px`;
|
||||
item.style.height = `${itemRect.height}px`;
|
||||
item.style.position = 'fixed';
|
||||
item.style.left = `${itemRect.left}px`;
|
||||
item.style.top = `${itemRect.top}px`;
|
||||
item.style.pointerEvents = 'none';
|
||||
item.style.zIndex = '1000';
|
||||
|
||||
container.classList.add(config.containerSortingClass);
|
||||
if (config.bodySortingClass && document.body) {
|
||||
document.body.classList.add(config.bodySortingClass);
|
||||
}
|
||||
|
||||
// Swallow the click generated by this pointer sequence so dropping an item
|
||||
// never triggers its own click handler (copy-to-clipboard, inline editing).
|
||||
// Scoped to the dragged container so unrelated clicks are never affected.
|
||||
const swallowClick = (event) => {
|
||||
if (event.target !== container && !container.contains(event.target)) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
document.removeEventListener('click', swallowClick, true);
|
||||
};
|
||||
document.addEventListener('click', swallowClick, true);
|
||||
|
||||
activeDragState = {
|
||||
container,
|
||||
item,
|
||||
placeholder,
|
||||
config,
|
||||
offsetX: startEvent.clientX - itemRect.left,
|
||||
offsetY: startEvent.clientY - itemRect.top,
|
||||
lastKnownPointer: { x: startEvent.clientX, y: startEvent.clientY },
|
||||
rafId: null,
|
||||
swallowClick,
|
||||
};
|
||||
|
||||
document.addEventListener('pointermove', handlePointerMove);
|
||||
document.addEventListener('pointerup', handlePointerUp);
|
||||
document.addEventListener('pointercancel', handlePointerUp);
|
||||
}
|
||||
|
||||
function handlePointerMove(event) {
|
||||
if (!activeDragState) return;
|
||||
|
||||
activeDragState.lastKnownPointer = { x: event.clientX, y: event.clientY };
|
||||
|
||||
if (activeDragState.rafId !== null) return;
|
||||
|
||||
activeDragState.rafId = requestAnimationFrame(() => {
|
||||
if (!activeDragState) return;
|
||||
activeDragState.rafId = null;
|
||||
updateDraggingItemPosition();
|
||||
updatePlaceholderPosition();
|
||||
});
|
||||
}
|
||||
|
||||
function handlePointerUp() {
|
||||
finishPointerDrag();
|
||||
}
|
||||
|
||||
function updateDraggingItemPosition() {
|
||||
if (!activeDragState) return;
|
||||
|
||||
const { item, offsetX, offsetY, lastKnownPointer } = activeDragState;
|
||||
const left = lastKnownPointer.x - offsetX;
|
||||
const top = lastKnownPointer.y - offsetY;
|
||||
item.style.left = `${left}px`;
|
||||
item.style.top = `${top}px`;
|
||||
}
|
||||
|
||||
function updatePlaceholderPosition() {
|
||||
if (!activeDragState) return;
|
||||
|
||||
const { container, placeholder, item, config, lastKnownPointer } = activeDragState;
|
||||
const siblings = Array.from(
|
||||
container.querySelectorAll(
|
||||
`${config.itemSelector}:not(.${config.placeholderClass})`,
|
||||
),
|
||||
).filter((element) => element !== item);
|
||||
|
||||
let insertAfter = null;
|
||||
|
||||
for (const sibling of siblings) {
|
||||
const rect = sibling.getBoundingClientRect();
|
||||
|
||||
if (lastKnownPointer.y < rect.top) {
|
||||
container.insertBefore(placeholder, sibling);
|
||||
return;
|
||||
}
|
||||
|
||||
if (lastKnownPointer.y <= rect.bottom) {
|
||||
if (lastKnownPointer.x < rect.left + rect.width / 2) {
|
||||
container.insertBefore(placeholder, sibling);
|
||||
return;
|
||||
}
|
||||
insertAfter = sibling;
|
||||
continue;
|
||||
}
|
||||
|
||||
insertAfter = sibling;
|
||||
}
|
||||
|
||||
if (!insertAfter) {
|
||||
container.insertBefore(placeholder, container.firstElementChild);
|
||||
return;
|
||||
}
|
||||
|
||||
container.insertBefore(placeholder, insertAfter.nextSibling);
|
||||
}
|
||||
|
||||
function finishPointerDrag() {
|
||||
if (!activeDragState) return;
|
||||
|
||||
const { container, item, placeholder, config, rafId, swallowClick } = activeDragState;
|
||||
|
||||
document.removeEventListener('pointermove', handlePointerMove);
|
||||
document.removeEventListener('pointerup', handlePointerUp);
|
||||
document.removeEventListener('pointercancel', handlePointerUp);
|
||||
|
||||
container.classList.remove(config.containerSortingClass);
|
||||
if (config.bodySortingClass && document.body) {
|
||||
document.body.classList.remove(config.bodySortingClass);
|
||||
}
|
||||
|
||||
if (rafId !== null) {
|
||||
cancelAnimationFrame(rafId);
|
||||
activeDragState.rafId = null;
|
||||
}
|
||||
|
||||
// Always settle the placeholder from the last known pointer: the drop must
|
||||
// reflect the final pointer position even when no animation frame ran
|
||||
// (fast drags, or drags that started from the threshold-crossing move).
|
||||
updateDraggingItemPosition();
|
||||
updatePlaceholderPosition();
|
||||
|
||||
if (placeholder && placeholder.parentNode === container) {
|
||||
container.insertBefore(item, placeholder);
|
||||
container.removeChild(placeholder);
|
||||
}
|
||||
|
||||
item.classList.remove(config.draggingClass);
|
||||
item.style.position = '';
|
||||
item.style.width = '';
|
||||
item.style.height = '';
|
||||
item.style.left = '';
|
||||
item.style.top = '';
|
||||
item.style.pointerEvents = '';
|
||||
item.style.zIndex = '';
|
||||
|
||||
activeDragState = null;
|
||||
|
||||
if (typeof config.onSorted === 'function') {
|
||||
config.onSorted(item, container);
|
||||
}
|
||||
|
||||
cleanupSwallowClick(swallowClick);
|
||||
}
|
||||
|
||||
/**
|
||||
* The click that follows a drop is dispatched right after pointerup, so the
|
||||
* guard has to survive until the next macrotask.
|
||||
* @param {Function} handler - Capture-phase click handler to remove
|
||||
*/
|
||||
function cleanupSwallowClick(handler) {
|
||||
if (!handler) return;
|
||||
setTimeout(() => document.removeEventListener('click', handler, true), 0);
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
/**
|
||||
* reorderSupport.js
|
||||
* Shared keyboard + screen-reader layer for chip lists sorted with pointerSort.
|
||||
*
|
||||
* Drag gestures are handled by pointerSort.js; this module adds the parts every
|
||||
* sortable list needs on top of it:
|
||||
* - the `⠿` grip affordance (markup + labels),
|
||||
* - the "sortable" flag that reveals the grip only when reordering is possible,
|
||||
* - Alt + Arrow keyboard reordering with aria-live announcements.
|
||||
*
|
||||
* Convention used by both callers: a list always shows the grip while it is
|
||||
* sortable. Whether the item *body* is draggable as well depends on the item:
|
||||
* - body has no click action (model/recipe tags) -> whole item is draggable,
|
||||
* - body is click-to-edit (trigger words) -> only the grip starts a drag.
|
||||
*/
|
||||
|
||||
import { translate } from '../../utils/i18nHelpers.js';
|
||||
import { escapeAttribute, escapeHtml } from './utils.js';
|
||||
import { moveItemWithinContainer } from './pointerSort.js';
|
||||
|
||||
const SORTABLE_CLASS = 'has-sortable-words';
|
||||
const LIVE_REGION_CLASS = 'reorder-live-region';
|
||||
const SR_ONLY_CLASS = 'reorder-sr-only';
|
||||
const DEFAULT_HANDLE_SELECTOR = '.reorder-handle';
|
||||
const DEFAULT_ARIA_LABEL_KEY = 'common.reorder.ariaLabel';
|
||||
const DEFAULT_ANNOUNCEMENT_KEY = 'common.reorder.announcement';
|
||||
|
||||
/**
|
||||
* Render the shared reorder grip button
|
||||
* @param {string} label - Tooltip / accessible label
|
||||
* @returns {string} Handle markup
|
||||
*/
|
||||
export function renderReorderHandle(label) {
|
||||
const safeLabel = escapeAttribute(label || '');
|
||||
return `<button type="button" class="reorder-handle" title="${safeLabel}" aria-label="${safeLabel}"><i class="fas fa-grip-vertical"></i></button>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the shared reorder hint shown in an edit controls row
|
||||
* @param {string} label - Hint text
|
||||
* @returns {string} Hint markup
|
||||
*/
|
||||
export function renderReorderHint(label) {
|
||||
return `<span class="reorder-hint"><i class="fas fa-grip-vertical"></i> ${escapeHtml(label || '')}</span>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a keydown event to a reorder offset
|
||||
* @param {KeyboardEvent} event - Keydown event
|
||||
* @returns {number} -1 (earlier), 1 (later) or 0 when it is not a reorder shortcut
|
||||
*/
|
||||
function getReorderOffset(event) {
|
||||
if (!event.altKey || event.ctrlKey || event.metaKey) return 0;
|
||||
if (event.key === 'ArrowLeft' || event.key === 'ArrowUp') return -1;
|
||||
if (event.key === 'ArrowRight' || event.key === 'ArrowDown') return 1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the keyboard / label support of a sortable list
|
||||
* @param {Object} options - Options
|
||||
* @param {HTMLElement} options.container - Element holding the items
|
||||
* @param {HTMLElement} [options.scope] - Element that receives the sortable flag
|
||||
* @param {string} [options.handleSelector] - Grip selector inside an item
|
||||
* @param {Object} options.sortConfig - Same config passed to enablePointerSort()
|
||||
* @param {Function} [options.isActive] - Whether reordering is currently allowed
|
||||
* @param {Function} [options.getItemLabel] - (item) => label used in messages
|
||||
* @param {Object} [options.i18n] - { ariaLabel, announcement } translation keys
|
||||
* @returns {{refresh: Function, announce: Function, handleKeydown: Function}}
|
||||
*/
|
||||
export function createReorderSupport({
|
||||
container,
|
||||
scope = container,
|
||||
handleSelector = DEFAULT_HANDLE_SELECTOR,
|
||||
sortConfig,
|
||||
isActive = () => true,
|
||||
getItemLabel = (item) => item.dataset.word || item.dataset.tag || item.textContent.trim(),
|
||||
i18n = {},
|
||||
}) {
|
||||
const itemSelector = sortConfig.itemSelector;
|
||||
const ariaLabelKey = i18n.ariaLabel || DEFAULT_ARIA_LABEL_KEY;
|
||||
const announcementKey = i18n.announcement || DEFAULT_ANNOUNCEMENT_KEY;
|
||||
|
||||
const getItems = () => Array.from(container.querySelectorAll(itemSelector));
|
||||
|
||||
function refresh() {
|
||||
const items = getItems();
|
||||
scope.classList.toggle(SORTABLE_CLASS, isActive() && items.length > 1);
|
||||
|
||||
items.forEach((item, index) => {
|
||||
const handle = item.querySelector(handleSelector);
|
||||
if (!handle) return;
|
||||
|
||||
const label = getItemLabel(item);
|
||||
handle.setAttribute('aria-label', translate(
|
||||
ariaLabelKey,
|
||||
{ item: label, position: index + 1, total: items.length },
|
||||
`Reorder ${label}, position ${index + 1} of ${items.length}`,
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
function ensureLiveRegion() {
|
||||
let liveRegion = scope.querySelector(`.${LIVE_REGION_CLASS}`);
|
||||
if (liveRegion) return liveRegion;
|
||||
|
||||
liveRegion = document.createElement('div');
|
||||
liveRegion.className = `${LIVE_REGION_CLASS} ${SR_ONLY_CLASS}`;
|
||||
liveRegion.setAttribute('role', 'status');
|
||||
liveRegion.setAttribute('aria-live', 'polite');
|
||||
scope.appendChild(liveRegion);
|
||||
|
||||
return liveRegion;
|
||||
}
|
||||
|
||||
function announce(item) {
|
||||
const items = getItems();
|
||||
const index = items.indexOf(item);
|
||||
if (index === -1) return;
|
||||
|
||||
ensureLiveRegion().textContent = translate(
|
||||
announcementKey,
|
||||
{ position: index + 1, total: items.length },
|
||||
`Moved to position ${index + 1} of ${items.length}`,
|
||||
);
|
||||
}
|
||||
|
||||
function handleKeydown(event) {
|
||||
const handle = event.target.closest(handleSelector);
|
||||
if (!handle || !isActive()) return;
|
||||
|
||||
const offset = getReorderOffset(event);
|
||||
if (!offset) return;
|
||||
|
||||
// Swallow the shortcut even at the ends of the list: Alt + Left/Right
|
||||
// would otherwise trigger the browser's back/forward navigation.
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
const item = handle.closest(itemSelector);
|
||||
if (!item) return;
|
||||
|
||||
const result = moveItemWithinContainer(item, offset, sortConfig);
|
||||
if (!result) return;
|
||||
|
||||
refresh();
|
||||
announce(item);
|
||||
|
||||
const nextHandle = item.querySelector(handleSelector);
|
||||
if (nextHandle) nextHandle.focus();
|
||||
}
|
||||
|
||||
if (!container.__reorderKeyboardAttached) {
|
||||
container.__reorderKeyboardAttached = true;
|
||||
container.addEventListener('keydown', handleKeydown);
|
||||
}
|
||||
|
||||
return { refresh, announce, handleKeydown };
|
||||
}
|
||||
Reference in New Issue
Block a user