mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-09-20 18:51:26 -03:00
refactor(reorder): drop the Alt + Arrow shortcut, keep drag only
The reorder shortcut cannot be made reliable in this UI. `Alt + Arrow` is the browser's tab-history / back-forward gesture on several platforms, and the modal already binds bare `ArrowLeft`/`ArrowRight` to model navigation, so the binding either did nothing — a keypress with nothing focused never reaches a listener on the tag list — or fought the browser. An affordance that occasionally navigates the page away is worse than having no keyboard path at all, so drop it. Reordering is pointer-only again: drag the chip (tags) or its `⠿` grip (trigger words, whose chip body is click-to-edit). Everything that existed only to serve the shortcut goes with it — the keydown listener, the hover tracking used to resolve the target chip, the aria-live announcements, the per-grip position labels and `moveItemWithinContainer`. The grip becomes a decorative, non-focusable `<span>` (`aria-hidden`, behind a 5px drag threshold) instead of a `<button>`, so it no longer promises a keyboard action it cannot perform. The tooltip and hint drop the shortcut mention in all 10 locales (`common.reorder.dragHandle` = "Drag to reorder" and the localised equivalents); `common.reorder.ariaLabel` and `common.reorder.announcement` are pruned from every locale by the sync script. The i18n guidelines record the decision so no shortcut is re-added without re-adding the keys.
This commit is contained in:
@@ -51,25 +51,11 @@ Locales: `en`, `zh-CN`, `zh-TW`, `ja`, `ko`, `fr`, `de`, `es`, `ru`, `he` (RTL).
|
|||||||
> "Folder sidebar feature".
|
> "Folder sidebar feature".
|
||||||
>
|
>
|
||||||
> **Status (2026-09, chip reordering):** model tags and trigger words now share one drag/`⠿`
|
> **Status (2026-09, chip reordering):** model tags and trigger words now share one drag/`⠿`
|
||||||
> grip reorder affordance with `Alt + ↑/↓` keyboard support, which added the 3
|
> grip reorder affordance, which added the single `common.reorder.dragHandle` key (it lives
|
||||||
> `common.reorder.*` keys. They live under `common` (not a feature namespace) because both
|
> under `common` because both editors render it). All 9 locales are translated (renderings in
|
||||||
> editors render them; all 9 locales are translated (renderings in §2, "Chip reordering").
|
> §2, "Chip reordering"). Reordering is pointer-only by design: an `Alt + Arrow` shortcut was
|
||||||
> `Alt` and the `↑/↓` glyphs stay Latin/verbatim in every locale, the same precedent as
|
> prototyped and removed because it collided with the browser's Alt + Arrow handling and the
|
||||||
> `Shift+Enter` in `modals.model.metadata.notesHint`; zh-CN / zh-TW / ja use full-width
|
> modal's arrow-key navigation.
|
||||||
> parentheses and ko keeps this file's ASCII style.
|
|
||||||
>
|
|
||||||
> **Status (2026-09, download metadata phases):** the download progress UI now names the two
|
|
||||||
> post-transfer stages instead of sitting at 100% showing `0 B/s`, adding 4
|
|
||||||
> `modals.download.progress.*` keys (`metadata`, `indexingFile`, `fetchingSourceMetadata`,
|
|
||||||
> `fetchingMetadata`). Translated in all 9 locales in the same pass, so the "no remaining
|
|
||||||
> placeholders" claim holds again. No new terminology: `metadata` reuses the §5 row, and the
|
|
||||||
> fetching phrasing mirrors each locale's `download.fetchingRepoFiles` / `fetchingVersions`.
|
|
||||||
> `{name}` and `{source}` are verbatim §1-R2 placeholders — `{source}` is filled at runtime
|
|
||||||
> with the *untranslated* platform name, so its surrounding spacing follows each locale's
|
|
||||||
> `modelCard.actions.viewOnSource` precedent (ja `{source} から`, ko `{source}에서`,
|
|
||||||
> zh `从 {source}` / `從 {source}`, he `מ-{source}`) and no brand ever appears inside the
|
|
||||||
> translated text. ja / ko keep this block's ASCII `:` like their sibling progress keys;
|
|
||||||
> zh-CN / zh-TW use full-width `:`.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -378,25 +364,23 @@ are verbatim §1-R2 placeholders; `successWithFiles` is the only key carrying `{
|
|||||||
|
|
||||||
### Chip reordering (model tags / trigger words)
|
### Chip reordering (model tags / trigger words)
|
||||||
|
|
||||||
Model tags and trigger-word chips share a single reorder affordance (drag the chip or its
|
Model tags and trigger-word chips share a single reorder affordance (drag the chip, or its
|
||||||
`⠿` grip, or move it with `Alt + ↑/↓`), so the copy sits in `common.reorder.*` instead of a
|
`⠿` grip where the chip body is click-to-edit), so the copy sits in `common.reorder.dragHandle`
|
||||||
feature namespace. `dragHandle` is both the grip tooltip and the hint shown in the edit
|
instead of a feature namespace. It is used twice per editor: as the grip tooltip and as the
|
||||||
controls row; `ariaLabel` is the per-grip screen-reader label (`{item}` is the tag/word text);
|
hint shown in the edit controls row. There is deliberately **no keyboard shortcut** — an
|
||||||
`announcement` is the aria-live message after a keyboard move and deliberately has no
|
`Alt + Arrow` binding fought the browser's own Alt + Arrow handling and the modal's arrow-key
|
||||||
`{item}`. Keep `{item}` / `{position}` / `{total}` verbatim (§1-R2) — the caller supplies
|
navigation, so reordering is pointer-only and the grip is a decorative, non-focusable
|
||||||
exactly those.
|
affordance. Do not reintroduce a shortcut or a "position X of Y" screen-reader string without
|
||||||
|
re-adding the corresponding keys.
|
||||||
|
|
||||||
`Alt` and the `↑/↓` glyphs stay Latin/verbatim in every locale (same precedent as
|
`dragHandle` is a fragment, not a sentence: it labels both the grip and the hint, so keep it
|
||||||
`Shift+Enter`), and `position X of Y` reuses each locale's established ordering phrasing
|
short and imperative and do not append a keyboard hint in any locale.
|
||||||
(ja `{total} 件中 … 番目`, ko `총 {total}개 중 …번째`, fr `sur {total}`, ru `из {total}`, …).
|
|
||||||
|
|
||||||
| Term | Rendering |
|
| Term | Rendering |
|
||||||
|---|---|
|
|---|---|
|
||||||
| drag to reorder | zh-CN 拖拽以调整顺序 · zh-TW 拖曳以調整順序 · ja ドラッグして並べ替え · ko 드래그하여 순서 변경 · fr Glisser pour réordonner · de Zum Neuordnen ziehen · es Arrastra para reordenar · ru Перетащите, чтобы изменить порядок · he גרור כדי לשנות סדר |
|
| drag to reorder | zh-CN 拖拽以调整顺序 · zh-TW 拖曳以調整順序 · ja ドラッグして並べ替え · ko 드래그하여 순서 변경 · fr Glisser pour réordonner · de Zum Neuordnen ziehen · es Arrastra para reordenar · ru Перетащите, чтобы изменить порядок · he גרור כדי לשנות סדר |
|
||||||
| position {position} of {total} | zh-CN 第 {position} 个,共 {total} 个 · zh-TW 第 {position} 個,共 {total} 個 · ja {total} 件中 {position} 番目 · ko 총 {total}개 중 {position}번째 · fr position {position} sur {total} · de Position {position} von {total} · es posición {position} de {total} · ru позиция {position} из {total} · he מיקום {position} מתוך {total} |
|
|
||||||
|
|
||||||
The grip/handle noun itself is never translated (it is an icon); the hint carries the whole
|
The grip itself is an icon and is never translated.
|
||||||
instruction, so no locale needs a separate "grip" term.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+1
-3
@@ -3,9 +3,7 @@
|
|||||||
"cancel": "Abbrechen",
|
"cancel": "Abbrechen",
|
||||||
"confirm": "Bestätigen",
|
"confirm": "Bestätigen",
|
||||||
"reorder": {
|
"reorder": {
|
||||||
"dragHandle": "Zum Neuordnen ziehen (Alt + ↑/↓)",
|
"dragHandle": "Zum Neuordnen ziehen"
|
||||||
"ariaLabel": "{item} neu anordnen, Position {position} von {total}",
|
|
||||||
"announcement": "An Position {position} von {total} verschoben"
|
|
||||||
},
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"save": "Speichern",
|
"save": "Speichern",
|
||||||
|
|||||||
+1
-3
@@ -3,9 +3,7 @@
|
|||||||
"cancel": "Cancel",
|
"cancel": "Cancel",
|
||||||
"confirm": "Confirm",
|
"confirm": "Confirm",
|
||||||
"reorder": {
|
"reorder": {
|
||||||
"dragHandle": "Drag to reorder (Alt + ↑/↓)",
|
"dragHandle": "Drag to reorder"
|
||||||
"ariaLabel": "Reorder {item}, position {position} of {total}",
|
|
||||||
"announcement": "Moved to position {position} of {total}"
|
|
||||||
},
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"save": "Save",
|
"save": "Save",
|
||||||
|
|||||||
+1
-3
@@ -3,9 +3,7 @@
|
|||||||
"cancel": "Cancelar",
|
"cancel": "Cancelar",
|
||||||
"confirm": "Confirmar",
|
"confirm": "Confirmar",
|
||||||
"reorder": {
|
"reorder": {
|
||||||
"dragHandle": "Arrastra para reordenar (Alt + ↑/↓)",
|
"dragHandle": "Arrastra para reordenar"
|
||||||
"ariaLabel": "Reordenar {item}, posición {position} de {total}",
|
|
||||||
"announcement": "Movido a la posición {position} de {total}"
|
|
||||||
},
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"save": "Guardar",
|
"save": "Guardar",
|
||||||
|
|||||||
+1
-3
@@ -3,9 +3,7 @@
|
|||||||
"cancel": "Annuler",
|
"cancel": "Annuler",
|
||||||
"confirm": "Confirmer",
|
"confirm": "Confirmer",
|
||||||
"reorder": {
|
"reorder": {
|
||||||
"dragHandle": "Glisser pour réordonner (Alt + ↑/↓)",
|
"dragHandle": "Glisser pour réordonner"
|
||||||
"ariaLabel": "Réordonner {item}, position {position} sur {total}",
|
|
||||||
"announcement": "Déplacé en position {position} sur {total}"
|
|
||||||
},
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"save": "Enregistrer",
|
"save": "Enregistrer",
|
||||||
|
|||||||
+1
-3
@@ -3,9 +3,7 @@
|
|||||||
"cancel": "ביטול",
|
"cancel": "ביטול",
|
||||||
"confirm": "אישור",
|
"confirm": "אישור",
|
||||||
"reorder": {
|
"reorder": {
|
||||||
"dragHandle": "גרור כדי לשנות סדר (Alt + ↑/↓)",
|
"dragHandle": "גרור כדי לשנות סדר"
|
||||||
"ariaLabel": "סדר מחדש את {item}, מיקום {position} מתוך {total}",
|
|
||||||
"announcement": "הועבר למיקום {position} מתוך {total}"
|
|
||||||
},
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"save": "שמירה",
|
"save": "שמירה",
|
||||||
|
|||||||
+1
-3
@@ -3,9 +3,7 @@
|
|||||||
"cancel": "キャンセル",
|
"cancel": "キャンセル",
|
||||||
"confirm": "確認",
|
"confirm": "確認",
|
||||||
"reorder": {
|
"reorder": {
|
||||||
"dragHandle": "ドラッグして並べ替え(Alt + ↑/↓)",
|
"dragHandle": "ドラッグして並べ替え"
|
||||||
"ariaLabel": "{item} を並べ替え、{total} 件中 {position} 番目",
|
|
||||||
"announcement": "{total} 件中 {position} 番目に移動しました"
|
|
||||||
},
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"save": "保存",
|
"save": "保存",
|
||||||
|
|||||||
+1
-3
@@ -3,9 +3,7 @@
|
|||||||
"cancel": "취소",
|
"cancel": "취소",
|
||||||
"confirm": "확인",
|
"confirm": "확인",
|
||||||
"reorder": {
|
"reorder": {
|
||||||
"dragHandle": "드래그하여 순서 변경 (Alt + ↑/↓)",
|
"dragHandle": "드래그하여 순서 변경"
|
||||||
"ariaLabel": "{item} 순서 변경, 총 {total}개 중 {position}번째",
|
|
||||||
"announcement": "총 {total}개 중 {position}번째로 이동했습니다"
|
|
||||||
},
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"save": "저장",
|
"save": "저장",
|
||||||
|
|||||||
+1
-3
@@ -3,9 +3,7 @@
|
|||||||
"cancel": "Отмена",
|
"cancel": "Отмена",
|
||||||
"confirm": "Подтвердить",
|
"confirm": "Подтвердить",
|
||||||
"reorder": {
|
"reorder": {
|
||||||
"dragHandle": "Перетащите, чтобы изменить порядок (Alt + ↑/↓)",
|
"dragHandle": "Перетащите, чтобы изменить порядок"
|
||||||
"ariaLabel": "Изменить порядок {item}, позиция {position} из {total}",
|
|
||||||
"announcement": "Перемещено на позицию {position} из {total}"
|
|
||||||
},
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"save": "Сохранить",
|
"save": "Сохранить",
|
||||||
|
|||||||
+1
-3
@@ -3,9 +3,7 @@
|
|||||||
"cancel": "取消",
|
"cancel": "取消",
|
||||||
"confirm": "确认",
|
"confirm": "确认",
|
||||||
"reorder": {
|
"reorder": {
|
||||||
"dragHandle": "拖拽以调整顺序(Alt + ↑/↓)",
|
"dragHandle": "拖拽以调整顺序"
|
||||||
"ariaLabel": "调整 {item} 的顺序,第 {position} 个,共 {total} 个",
|
|
||||||
"announcement": "已移动到第 {position} 个,共 {total} 个"
|
|
||||||
},
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"save": "保存",
|
"save": "保存",
|
||||||
|
|||||||
+1
-3
@@ -3,9 +3,7 @@
|
|||||||
"cancel": "取消",
|
"cancel": "取消",
|
||||||
"confirm": "確認",
|
"confirm": "確認",
|
||||||
"reorder": {
|
"reorder": {
|
||||||
"dragHandle": "拖曳以調整順序(Alt + ↑/↓)",
|
"dragHandle": "拖曳以調整順序"
|
||||||
"ariaLabel": "調整 {item} 的順序,第 {position} 個,共 {total} 個",
|
|
||||||
"announcement": "已移動到第 {position} 個,共 {total} 個"
|
|
||||||
},
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"save": "儲存",
|
"save": "儲存",
|
||||||
|
|||||||
@@ -124,6 +124,7 @@
|
|||||||
cursor: grab;
|
cursor: grab;
|
||||||
/* Keep a touch drag on the handle from scrolling the surrounding panel */
|
/* Keep a touch drag on the handle from scrolling the surrounding panel */
|
||||||
touch-action: none;
|
touch-action: none;
|
||||||
|
user-select: none;
|
||||||
transition: opacity 0.2s ease, color 0.2s ease;
|
transition: opacity 0.2s ease, color 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -137,18 +138,11 @@
|
|||||||
margin-right: 4px;
|
margin-right: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.reorder-handle:hover,
|
.reorder-handle:hover {
|
||||||
.reorder-handle:focus-visible {
|
|
||||||
opacity: 0.9;
|
opacity: 0.9;
|
||||||
color: var(--lora-accent);
|
color: var(--lora-accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.reorder-handle:focus-visible {
|
|
||||||
outline: 2px solid var(--lora-accent);
|
|
||||||
outline-offset: 1px;
|
|
||||||
border-radius: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.reorder-handle:active {
|
.reorder-handle:active {
|
||||||
cursor: grabbing;
|
cursor: grabbing;
|
||||||
}
|
}
|
||||||
@@ -199,19 +193,6 @@ body.reorder-drag-active * {
|
|||||||
cursor: grabbing !important;
|
cursor: grabbing !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Screen-reader-only live region announcing reorder moves */
|
|
||||||
.reorder-sr-only {
|
|
||||||
position: absolute;
|
|
||||||
width: 1px;
|
|
||||||
height: 1px;
|
|
||||||
padding: 0;
|
|
||||||
margin: -1px;
|
|
||||||
overflow: hidden;
|
|
||||||
clip: rect(0, 0, 0, 0);
|
|
||||||
white-space: nowrap;
|
|
||||||
border: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.metadata-item-content {
|
.metadata-item-content {
|
||||||
color: var(--lora-accent) !important;
|
color: var(--lora-accent) !important;
|
||||||
font-size: 0.85em;
|
font-size: 0.85em;
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { getPriorityTagSuggestions } from '../../utils/priorityTagHelpers.js';
|
|||||||
import { state } from '../../state/index.js';
|
import { state } from '../../state/index.js';
|
||||||
import { enablePointerSort } from './pointerSort.js';
|
import { enablePointerSort } from './pointerSort.js';
|
||||||
import {
|
import {
|
||||||
createReorderSupport,
|
refreshReorderState,
|
||||||
renderReorderHandle,
|
renderReorderHandle,
|
||||||
renderReorderHint,
|
renderReorderHint,
|
||||||
} from './reorderSupport.js';
|
} from './reorderSupport.js';
|
||||||
@@ -24,7 +24,6 @@ const MODEL_TYPE_SUGGESTION_KEY_MAP = {
|
|||||||
};
|
};
|
||||||
const METADATA_ITEM_SELECTOR = '.metadata-item';
|
const METADATA_ITEM_SELECTOR = '.metadata-item';
|
||||||
const METADATA_ITEMS_CONTAINER_SELECTOR = '.metadata-items';
|
const METADATA_ITEMS_CONTAINER_SELECTOR = '.metadata-items';
|
||||||
const METADATA_DRAG_HANDLE_SELECTOR = '.reorder-handle';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Tag items have no click action of their own, so the whole chip stays
|
* Tag items have no click action of their own, so the whole chip stays
|
||||||
@@ -436,7 +435,7 @@ function createTagEditUI(currentTags, editBtnHTML = '') {
|
|||||||
<div class="metadata-items">
|
<div class="metadata-items">
|
||||||
${currentTags.map(tag => `
|
${currentTags.map(tag => `
|
||||||
<div class="metadata-item" data-tag="${tag}">
|
<div class="metadata-item" data-tag="${tag}">
|
||||||
${renderReorderHandle(translate('common.reorder.dragHandle'))}
|
${renderReorderHandle(translate('common.reorder.dragHandle', {}, 'Drag to reorder'))}
|
||||||
<span class="metadata-item-content">${tag}</span>
|
<span class="metadata-item-content">${tag}</span>
|
||||||
<button class="metadata-delete-btn">
|
<button class="metadata-delete-btn">
|
||||||
<i class="fas fa-times"></i>
|
<i class="fas fa-times"></i>
|
||||||
@@ -445,7 +444,7 @@ function createTagEditUI(currentTags, editBtnHTML = '') {
|
|||||||
`).join('')}
|
`).join('')}
|
||||||
</div>
|
</div>
|
||||||
<div class="metadata-edit-controls">
|
<div class="metadata-edit-controls">
|
||||||
${renderReorderHint(translate('common.reorder.dragHandle'))}
|
${renderReorderHint(translate('common.reorder.dragHandle', {}, 'Drag to reorder'))}
|
||||||
<button class="save-tags-btn" title="Save changes">
|
<button class="save-tags-btn" title="Save changes">
|
||||||
<i class="fas fa-save"></i> Save
|
<i class="fas fa-save"></i> Save
|
||||||
</button>
|
</button>
|
||||||
@@ -561,7 +560,7 @@ function setupDeleteButtons() {
|
|||||||
const scope = tag?.closest('.model-tags-container');
|
const scope = tag?.closest('.model-tags-container');
|
||||||
tag.remove();
|
tag.remove();
|
||||||
|
|
||||||
scope?._tagReorderSupport?.refresh();
|
refreshTagReorderState(scope);
|
||||||
|
|
||||||
// Update status of items in the suggestion dropdown
|
// Update status of items in the suggestion dropdown
|
||||||
updateSuggestionsDropdown();
|
updateSuggestionsDropdown();
|
||||||
@@ -582,28 +581,28 @@ function setupTagDragAndDrop(scopeContainer) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const scope = container.closest('.model-tags-container') || container;
|
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
enablePointerSort(container, {
|
enablePointerSort(container, {
|
||||||
...TAG_SORT_CONFIG,
|
...TAG_SORT_CONFIG,
|
||||||
onSorted: (item) => {
|
onSorted: () => {
|
||||||
updateSuggestionsDropdown();
|
updateSuggestionsDropdown();
|
||||||
support.refresh();
|
refreshTagReorderState(scope);
|
||||||
support.announce(item);
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
support.refresh();
|
refreshTagReorderState(scope);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Refresh the "sortable" flag (and therefore the grip + hint) of a tags section
|
||||||
|
* @param {Element} [tagsSection] - The .model-tags-container element
|
||||||
|
*/
|
||||||
|
function refreshTagReorderState(tagsSection) {
|
||||||
|
refreshReorderState({
|
||||||
|
container: tagsSection?.querySelector(METADATA_ITEMS_CONTAINER_SELECTOR),
|
||||||
|
scope: tagsSection || undefined,
|
||||||
|
itemSelector: METADATA_ITEM_SELECTOR,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -644,7 +643,7 @@ function addNewTag(tag, scopeElement = null) {
|
|||||||
newTag.className = 'metadata-item';
|
newTag.className = 'metadata-item';
|
||||||
newTag.dataset.tag = tag;
|
newTag.dataset.tag = tag;
|
||||||
newTag.innerHTML = `
|
newTag.innerHTML = `
|
||||||
${renderReorderHandle(translate('common.reorder.dragHandle'))}
|
${renderReorderHandle(translate('common.reorder.dragHandle', {}, 'Drag to reorder'))}
|
||||||
<span class="metadata-item-content">${tag}</span>
|
<span class="metadata-item-content">${tag}</span>
|
||||||
<button class="metadata-delete-btn">
|
<button class="metadata-delete-btn">
|
||||||
<i class="fas fa-times"></i>
|
<i class="fas fa-times"></i>
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import {
|
|||||||
disablePointerSort,
|
disablePointerSort,
|
||||||
} from './pointerSort.js';
|
} from './pointerSort.js';
|
||||||
import {
|
import {
|
||||||
createReorderSupport,
|
refreshReorderState,
|
||||||
renderReorderHandle,
|
renderReorderHandle,
|
||||||
renderReorderHint,
|
renderReorderHint,
|
||||||
} from './reorderSupport.js';
|
} from './reorderSupport.js';
|
||||||
@@ -26,13 +26,15 @@ const TRIGGER_WORD_DRAG_HANDLE_SELECTOR = '.reorder-handle';
|
|||||||
* Drag-to-reorder configuration for trigger word tags.
|
* Drag-to-reorder configuration for trigger word tags.
|
||||||
* Handlers are installed when entering edit mode and removed again on exit, so
|
* 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.
|
* 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.
|
* The item body is click-to-edit here, so only the grip starts a drag, and the
|
||||||
|
* small threshold keeps a click on the grip from lifting the tag.
|
||||||
*/
|
*/
|
||||||
const TRIGGER_WORD_DRAG_CONFIG = {
|
const TRIGGER_WORD_DRAG_CONFIG = {
|
||||||
itemSelector: '.trigger-word-tag',
|
itemSelector: '.trigger-word-tag',
|
||||||
handleSelector: TRIGGER_WORD_DRAG_HANDLE_SELECTOR,
|
handleSelector: TRIGGER_WORD_DRAG_HANDLE_SELECTOR,
|
||||||
ignoreSelector: '.metadata-delete-btn, .trigger-word-edit-input',
|
ignoreSelector: '.metadata-delete-btn, .trigger-word-edit-input',
|
||||||
blockedItemSelector: '.is-editing',
|
blockedItemSelector: '.is-editing',
|
||||||
|
dragThreshold: 5,
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -212,7 +214,7 @@ function createSuggestionDropdown(trainedWords, classTokens, existingWords = [])
|
|||||||
* @returns {string} Handle markup
|
* @returns {string} Handle markup
|
||||||
*/
|
*/
|
||||||
function renderTriggerWordDragHandle() {
|
function renderTriggerWordDragHandle() {
|
||||||
return renderReorderHandle(translate('common.reorder.dragHandle'));
|
return renderReorderHandle(translate('common.reorder.dragHandle', {}, 'Drag to reorder'));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -236,7 +238,7 @@ export function renderTriggerWords(words, filePath) {
|
|||||||
<div class="trigger-words-tags" style="display:none;"></div>
|
<div class="trigger-words-tags" style="display:none;"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="metadata-edit-controls" style="display:none;">
|
<div class="metadata-edit-controls" style="display:none;">
|
||||||
${renderReorderHint(translate('common.reorder.dragHandle'))}
|
${renderReorderHint(translate('common.reorder.dragHandle', {}, 'Drag to reorder'))}
|
||||||
<button class="metadata-save-btn" title="${translate('modals.model.triggerWords.save')}">
|
<button class="metadata-save-btn" title="${translate('modals.model.triggerWords.save')}">
|
||||||
<i class="fas fa-save"></i> ${translate('common.actions.save')}
|
<i class="fas fa-save"></i> ${translate('common.actions.save')}
|
||||||
</button>
|
</button>
|
||||||
@@ -275,7 +277,7 @@ export function renderTriggerWords(words, filePath) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="metadata-edit-controls" style="display:none;">
|
<div class="metadata-edit-controls" style="display:none;">
|
||||||
${renderReorderHint(translate('common.reorder.dragHandle'))}
|
${renderReorderHint(translate('common.reorder.dragHandle', {}, 'Drag to reorder'))}
|
||||||
<button class="metadata-save-btn" title="${translate('modals.model.triggerWords.save')}">
|
<button class="metadata-save-btn" title="${translate('modals.model.triggerWords.save')}">
|
||||||
<i class="fas fa-save"></i> ${translate('common.actions.save')}
|
<i class="fas fa-save"></i> ${translate('common.actions.save')}
|
||||||
</button>
|
</button>
|
||||||
@@ -543,38 +545,18 @@ function restoreOriginalTriggerWords(section, originalWords) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get (or lazily create) the reorder support of a section.
|
* Refresh the "sortable" flag (and therefore the grip + hint) of a section.
|
||||||
* Reordering is only allowed while the section is in edit mode, because the tag
|
* Reordering is drag-only and only offered while editing: the tag body itself
|
||||||
* body itself is click-to-edit and the grip must not appear in display mode.
|
* is click-to-edit, so 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
|
* @param {HTMLElement} section - The .trigger-words section
|
||||||
*/
|
*/
|
||||||
function refreshTriggerWordHandleLabels(section) {
|
function refreshTriggerWordHandleLabels(section) {
|
||||||
getTriggerWordReorder(section)?.refresh();
|
refreshReorderState({
|
||||||
|
container: section.querySelector('.trigger-words-tags'),
|
||||||
|
scope: section,
|
||||||
|
itemSelector: TRIGGER_WORD_DRAG_CONFIG.itemSelector,
|
||||||
|
isActive: () => section.classList.contains('edit-mode'),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -585,14 +567,9 @@ function enableTriggerWordSort(section) {
|
|||||||
const tagsContainer = section.querySelector('.trigger-words-tags');
|
const tagsContainer = section.querySelector('.trigger-words-tags');
|
||||||
if (!tagsContainer) return;
|
if (!tagsContainer) return;
|
||||||
|
|
||||||
const support = getTriggerWordReorder(section);
|
|
||||||
|
|
||||||
enablePointerSort(tagsContainer, {
|
enablePointerSort(tagsContainer, {
|
||||||
...TRIGGER_WORD_DRAG_CONFIG,
|
...TRIGGER_WORD_DRAG_CONFIG,
|
||||||
onSorted: (item) => {
|
onSorted: () => refreshTriggerWordHandleLabels(section),
|
||||||
support?.refresh();
|
|
||||||
support?.announce(item);
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -112,37 +112,6 @@ export function disablePointerSort(container, options = {}) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 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) {
|
function handlePointerDown(event, item, container, config) {
|
||||||
if (activeDragState || pendingDragState) return;
|
if (activeDragState || pendingDragState) return;
|
||||||
if (typeof event.button === 'number' && event.button !== 0) return;
|
if (typeof event.button === 'number' && event.button !== 0) return;
|
||||||
|
|||||||
@@ -1,38 +1,35 @@
|
|||||||
/**
|
/**
|
||||||
* reorderSupport.js
|
* reorderSupport.js
|
||||||
* Shared keyboard + screen-reader layer for chip lists sorted with pointerSort.
|
* Shared drag affordance for chip lists sorted with pointerSort.
|
||||||
*
|
*
|
||||||
* Drag gestures are handled by pointerSort.js; this module adds the parts every
|
* The drag gesture itself lives in pointerSort.js; this module owns the parts
|
||||||
* sortable list needs on top of it:
|
* every sortable list needs on top of it:
|
||||||
* - the `⠿` grip affordance (markup + labels),
|
* - the `⠿` grip markup,
|
||||||
* - the "sortable" flag that reveals the grip only when reordering is possible,
|
* - 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
|
* 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:
|
* 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 has no click action (model/recipe tags) -> whole item is draggable,
|
||||||
* - body is click-to-edit (trigger words) -> only the grip starts a drag.
|
* - body is click-to-edit (trigger words) -> only the grip starts a drag.
|
||||||
|
*
|
||||||
|
* Reordering is deliberately pointer-only: a keyboard shortcut would have to
|
||||||
|
* fight the browser's own Alt + Arrow handling and the modal's arrow-key
|
||||||
|
* navigation, so the grip is a plain decorative affordance rather than a
|
||||||
|
* focusable control.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { translate } from '../../utils/i18nHelpers.js';
|
|
||||||
import { escapeAttribute, escapeHtml } from './utils.js';
|
import { escapeAttribute, escapeHtml } from './utils.js';
|
||||||
import { moveItemWithinContainer } from './pointerSort.js';
|
|
||||||
|
|
||||||
const SORTABLE_CLASS = 'has-sortable-words';
|
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
|
* Render the shared reorder grip
|
||||||
* @param {string} label - Tooltip / accessible label
|
* @param {string} label - Tooltip text
|
||||||
* @returns {string} Handle markup
|
* @returns {string} Handle markup
|
||||||
*/
|
*/
|
||||||
export function renderReorderHandle(label) {
|
export function renderReorderHandle(label) {
|
||||||
const safeLabel = escapeAttribute(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>`;
|
return `<span class="reorder-handle" aria-hidden="true" title="${safeLabel}"><i class="fas fa-grip-vertical"></i></span>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -45,115 +42,23 @@ export function renderReorderHint(label) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Map a keydown event to a reorder offset
|
* Show or hide the grip and hint of a list.
|
||||||
* @param {KeyboardEvent} event - Keydown event
|
* They are only offered while the list is editable and holds more than one
|
||||||
* @returns {number} -1 (earlier), 1 (later) or 0 when it is not a reorder shortcut
|
* item, so the UI never shows an affordance that cannot do anything.
|
||||||
*/
|
|
||||||
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 {Object} options - Options
|
||||||
* @param {HTMLElement} options.container - Element holding the items
|
* @param {HTMLElement} options.container - Element holding the sortable items
|
||||||
* @param {HTMLElement} [options.scope] - Element that receives the sortable flag
|
* @param {HTMLElement} [options.scope] - Element that receives the sortable flag
|
||||||
* @param {string} [options.handleSelector] - Grip selector inside an item
|
* @param {string} options.itemSelector - Selector of the sortable items
|
||||||
* @param {Object} options.sortConfig - Same config passed to enablePointerSort()
|
|
||||||
* @param {Function} [options.isActive] - Whether reordering is currently allowed
|
* @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({
|
export function refreshReorderState({
|
||||||
container,
|
container,
|
||||||
scope = container,
|
scope = container,
|
||||||
handleSelector = DEFAULT_HANDLE_SELECTOR,
|
itemSelector,
|
||||||
sortConfig,
|
|
||||||
isActive = () => true,
|
isActive = () => true,
|
||||||
getItemLabel = (item) => item.dataset.word || item.dataset.tag || item.textContent.trim(),
|
|
||||||
i18n = {},
|
|
||||||
}) {
|
}) {
|
||||||
const itemSelector = sortConfig.itemSelector;
|
if (!container) return;
|
||||||
const ariaLabelKey = i18n.ariaLabel || DEFAULT_ARIA_LABEL_KEY;
|
|
||||||
const announcementKey = i18n.announcement || DEFAULT_ANNOUNCEMENT_KEY;
|
|
||||||
|
|
||||||
const getItems = () => Array.from(container.querySelectorAll(itemSelector));
|
const items = container.querySelectorAll(itemSelector);
|
||||||
|
scope.classList.toggle(SORTABLE_CLASS, isActive() && items.length > 1);
|
||||||
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 };
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -101,17 +101,6 @@ describe("ModelTags reordering", () => {
|
|||||||
firePointer('pointerup', target, { clientY: 999 });
|
firePointer('pointerup', target, { clientY: 999 });
|
||||||
}
|
}
|
||||||
|
|
||||||
function pressKey(target, key, init = {}) {
|
|
||||||
const event = new KeyboardEvent('keydown', {
|
|
||||||
key,
|
|
||||||
bubbles: true,
|
|
||||||
cancelable: true,
|
|
||||||
...init,
|
|
||||||
});
|
|
||||||
target.dispatchEvent(event);
|
|
||||||
return event;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function enterEditMode(tags = ['alpha', 'beta', 'gamma']) {
|
async function enterEditMode(tags = ['alpha', 'beta', 'gamma']) {
|
||||||
document.body.innerHTML = TAG_SECTION_HTML(tags);
|
document.body.innerHTML = TAG_SECTION_HTML(tags);
|
||||||
setupTagEditMode('loras');
|
setupTagEditMode('loras');
|
||||||
@@ -170,18 +159,11 @@ describe("ModelTags reordering", () => {
|
|||||||
expect(order()).toEqual(['alpha', 'beta', 'gamma']);
|
expect(order()).toEqual(['alpha', 'beta', 'gamma']);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("saves the new order after a keyboard reorder", async () => {
|
it("saves the new order after a drag", async () => {
|
||||||
await enterEditMode();
|
await enterEditMode();
|
||||||
|
|
||||||
pressKey(handles()[0], 'ArrowRight', { altKey: true });
|
dragToEnd(handles()[0]);
|
||||||
expect(order()).toEqual(['beta', 'alpha', 'gamma']);
|
expect(order()).toEqual(['beta', 'gamma', 'alpha']);
|
||||||
|
|
||||||
const liveRegion = section().querySelector('.reorder-live-region');
|
|
||||||
expect(liveRegion.getAttribute('aria-live')).toBe('polite');
|
|
||||||
expect(liveRegion.textContent).toBe('Moved to position 2 of 3');
|
|
||||||
|
|
||||||
expect(handles()[0].getAttribute('aria-label'))
|
|
||||||
.toBe('Reorder beta, position 1 of 3');
|
|
||||||
|
|
||||||
document.querySelector('.save-tags-btn')
|
document.querySelector('.save-tags-btn')
|
||||||
.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
|
.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
|
||||||
@@ -191,19 +173,10 @@ describe("ModelTags reordering", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(saveModelMetadataMock).toHaveBeenCalledWith('test.safetensors', {
|
expect(saveModelMetadataMock).toHaveBeenCalledWith('test.safetensors', {
|
||||||
tags: ['beta', 'alpha', 'gamma'],
|
tags: ['beta', 'gamma', 'alpha'],
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("swallows the reorder shortcut at the ends of the list", async () => {
|
|
||||||
await enterEditMode();
|
|
||||||
|
|
||||||
const event = pressKey(handles()[0], 'ArrowLeft', { altKey: true });
|
|
||||||
|
|
||||||
expect(event.defaultPrevented).toBe(true);
|
|
||||||
expect(order()).toEqual(['alpha', 'beta', 'gamma']);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("updates the sortable flag when tags are deleted", async () => {
|
it("updates the sortable flag when tags are deleted", async () => {
|
||||||
await enterEditMode(['alpha', 'beta']);
|
await enterEditMode(['alpha', 'beta']);
|
||||||
|
|
||||||
|
|||||||
@@ -8,14 +8,12 @@ const POINTER_SORT_MODULE = new URL(
|
|||||||
describe("pointerSort", () => {
|
describe("pointerSort", () => {
|
||||||
let enablePointerSort;
|
let enablePointerSort;
|
||||||
let disablePointerSort;
|
let disablePointerSort;
|
||||||
let moveItemWithinContainer;
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
document.body.innerHTML = '';
|
document.body.innerHTML = '';
|
||||||
const module = await import(POINTER_SORT_MODULE);
|
const module = await import(POINTER_SORT_MODULE);
|
||||||
enablePointerSort = module.enablePointerSort;
|
enablePointerSort = module.enablePointerSort;
|
||||||
disablePointerSort = module.disablePointerSort;
|
disablePointerSort = module.disablePointerSort;
|
||||||
moveItemWithinContainer = module.moveItemWithinContainer;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
function buildList(words) {
|
function buildList(words) {
|
||||||
@@ -172,25 +170,4 @@ describe("pointerSort", () => {
|
|||||||
expect(order()).toEqual(['a', 'b']);
|
expect(order()).toEqual(['a', 'b']);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("moveItemWithinContainer moves items within bounds only", () => {
|
|
||||||
const { container } = buildList(['a', 'b', 'c']);
|
|
||||||
const items = Array.from(document.querySelectorAll('.item'));
|
|
||||||
|
|
||||||
expect(moveItemWithinContainer(items[0], 1, sortOptions()))
|
|
||||||
.toEqual({ index: 1, total: 3 });
|
|
||||||
expect(order()).toEqual(['b', 'a', 'c']);
|
|
||||||
|
|
||||||
expect(moveItemWithinContainer(items[2], -1, sortOptions()))
|
|
||||||
.toEqual({ index: 1, total: 3 });
|
|
||||||
expect(order()).toEqual(['b', 'c', 'a']);
|
|
||||||
|
|
||||||
// Out of range / unknown item moves are refused
|
|
||||||
const currentFirst = document.querySelector('.item');
|
|
||||||
expect(moveItemWithinContainer(currentFirst, -1, sortOptions())).toBeNull();
|
|
||||||
expect(moveItemWithinContainer(currentFirst, 3, sortOptions())).toBeNull();
|
|
||||||
expect(moveItemWithinContainer(document.createElement('div'), 1, {
|
|
||||||
...sortOptions(),
|
|
||||||
container,
|
|
||||||
})).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -77,17 +77,6 @@ describe("TriggerWords reordering", () => {
|
|||||||
firePointer('pointerup', handle, { clientY: 999 });
|
firePointer('pointerup', handle, { clientY: 999 });
|
||||||
}
|
}
|
||||||
|
|
||||||
function pressKey(handle, key, init = {}) {
|
|
||||||
const event = new KeyboardEvent('keydown', {
|
|
||||||
key,
|
|
||||||
bubbles: true,
|
|
||||||
cancelable: true,
|
|
||||||
...init,
|
|
||||||
});
|
|
||||||
handle.dispatchEvent(event);
|
|
||||||
return event;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function enterEditMode(words = ["alpha", "beta", "gamma"]) {
|
async function enterEditMode(words = ["alpha", "beta", "gamma"]) {
|
||||||
document.body.innerHTML = renderTriggerWords(words, "test.safetensors");
|
document.body.innerHTML = renderTriggerWords(words, "test.safetensors");
|
||||||
setupTriggerWordsEditMode();
|
setupTriggerWordsEditMode();
|
||||||
@@ -114,6 +103,16 @@ describe("TriggerWords reordering", () => {
|
|||||||
expect(section().classList.contains('has-sortable-words')).toBe(false);
|
expect(section().classList.contains('has-sortable-words')).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps the grip a decorative drag affordance, not a keyboard control", async () => {
|
||||||
|
await enterEditMode();
|
||||||
|
|
||||||
|
const grip = handles()[0];
|
||||||
|
expect(grip.tagName).toBe('SPAN');
|
||||||
|
expect(grip.getAttribute('aria-hidden')).toBe('true');
|
||||||
|
expect(grip.hasAttribute('tabindex')).toBe(false);
|
||||||
|
expect(grip.getAttribute('title')).toBe('Drag to reorder');
|
||||||
|
});
|
||||||
|
|
||||||
it("reorders a word by dragging its handle and swallows the follow-up click", async () => {
|
it("reorders a word by dragging its handle and swallows the follow-up click", async () => {
|
||||||
await enterEditMode();
|
await enterEditMode();
|
||||||
|
|
||||||
@@ -144,13 +143,23 @@ describe("TriggerWords reordering", () => {
|
|||||||
expect(document.querySelector('.reorder-dragging')).toBeNull();
|
expect(document.querySelector('.reorder-dragging')).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("reorders with the keyboard and saves the new order", async () => {
|
it("treats a click on the grip without movement as a click, not a drag", async () => {
|
||||||
await enterEditMode();
|
await enterEditMode();
|
||||||
|
|
||||||
pressKey(handles()[0], 'ArrowRight', { altKey: true });
|
const grip = handles()[0];
|
||||||
expect(order()).toEqual(["beta", "alpha", "gamma"]);
|
firePointer('pointerdown', grip, { clientY: 10 });
|
||||||
|
firePointer('pointermove', grip, { clientY: 12 });
|
||||||
|
firePointer('pointerup', grip, { clientY: 12 });
|
||||||
|
grip.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
|
||||||
|
|
||||||
pressKey(handles()[2], 'ArrowUp', { altKey: true });
|
expect(order()).toEqual(["alpha", "beta", "gamma"]);
|
||||||
|
expect(document.querySelector('.trigger-word-edit-input')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("saves the new order after a drag", async () => {
|
||||||
|
await enterEditMode();
|
||||||
|
|
||||||
|
dragToEnd(handles()[0]);
|
||||||
expect(order()).toEqual(["beta", "gamma", "alpha"]);
|
expect(order()).toEqual(["beta", "gamma", "alpha"]);
|
||||||
|
|
||||||
document.querySelector('.metadata-save-btn')
|
document.querySelector('.metadata-save-btn')
|
||||||
@@ -165,49 +174,6 @@ describe("TriggerWords reordering", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("swallows the reorder shortcut at the ends of the list", async () => {
|
|
||||||
await enterEditMode();
|
|
||||||
|
|
||||||
const event = pressKey(handles()[0], 'ArrowLeft', { altKey: true });
|
|
||||||
|
|
||||||
expect(event.defaultPrevented).toBe(true);
|
|
||||||
expect(order()).toEqual(["alpha", "beta", "gamma"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("ignores the reorder shortcut without the Alt modifier", async () => {
|
|
||||||
await enterEditMode();
|
|
||||||
|
|
||||||
pressKey(handles()[0], 'ArrowRight');
|
|
||||||
|
|
||||||
expect(order()).toEqual(["alpha", "beta", "gamma"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("announces moved words for screen readers", async () => {
|
|
||||||
await enterEditMode();
|
|
||||||
|
|
||||||
pressKey(handles()[0], 'ArrowRight', { altKey: true });
|
|
||||||
|
|
||||||
const liveRegion = section().querySelector('.reorder-live-region');
|
|
||||||
expect(liveRegion.getAttribute('aria-live')).toBe('polite');
|
|
||||||
expect(liveRegion.textContent).toBe(
|
|
||||||
'Moved to position 2 of 3',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("updates handle labels with the current position", async () => {
|
|
||||||
await enterEditMode();
|
|
||||||
|
|
||||||
expect(handles()[0].getAttribute('aria-label'))
|
|
||||||
.toBe('Reorder alpha, position 1 of 3');
|
|
||||||
|
|
||||||
pressKey(handles()[0], 'ArrowRight', { altKey: true });
|
|
||||||
|
|
||||||
expect(handles()[0].getAttribute('aria-label'))
|
|
||||||
.toBe('Reorder beta, position 1 of 3');
|
|
||||||
expect(handles()[1].getAttribute('aria-label'))
|
|
||||||
.toBe('Reorder alpha, position 2 of 3');
|
|
||||||
});
|
|
||||||
|
|
||||||
it("restores the original order when edit mode is canceled", async () => {
|
it("restores the original order when edit mode is canceled", async () => {
|
||||||
await enterEditMode();
|
await enterEditMode();
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user