@celestia-island/hikari 0.43.0 → 0.43.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/DemoApp.tsx +22 -0
- package/src/components/HkSelect.scss +34 -6
- package/src/components/HkSelectPanel.test.tsx +88 -0
- package/src/components/HkSelectPanel.tsx +65 -10
- package/src/components/HkTagInput.scss +84 -1
- package/src/components/HkTagInput.singleScroll.contract.test.ts +78 -5
- package/src/components/HkTagInput.test.tsx +813 -9
- package/src/components/HkTagInput.tsx +459 -109
- package/src/composables/usePointerReorder.test.ts +651 -0
- package/src/composables/usePointerReorder.ts +432 -0
- package/src/i18n/locales/ar/components.json +2 -1
- package/src/i18n/locales/de/components.json +2 -1
- package/src/i18n/locales/en/components.json +2 -1
- package/src/i18n/locales/es/components.json +2 -1
- package/src/i18n/locales/fr/components.json +2 -1
- package/src/i18n/locales/ja/components.json +2 -1
- package/src/i18n/locales/ko/components.json +2 -1
- package/src/i18n/locales/pt/components.json +2 -1
- package/src/i18n/locales/ru/components.json +2 -1
- package/src/i18n/locales/zh-Hans/components.json +2 -1
- package/src/i18n/locales/zh-Hant/components.json +2 -1
|
@@ -2,6 +2,7 @@ import { afterEach, describe, expect, it } from "vitest";
|
|
|
2
2
|
import { createApp, defineComponent, h, nextTick, ref } from "vue";
|
|
3
3
|
|
|
4
4
|
import HkModal from "./HkModal";
|
|
5
|
+
import HkSelectPanel from "./HkSelectPanel";
|
|
5
6
|
import HkTagInput, { type HkTagOption } from "./HkTagInput";
|
|
6
7
|
|
|
7
8
|
/** Regional-indicator flag pair for an ISO 3166-1 alpha-2 code, built
|
|
@@ -114,8 +115,19 @@ function inlineInput(container: HTMLElement): HTMLInputElement | null {
|
|
|
114
115
|
return container.querySelector<HTMLInputElement>(".hk-tag-input-element");
|
|
115
116
|
}
|
|
116
117
|
|
|
118
|
+
/** HkListTransition keeps a leaving element mounted until its squeeze-out
|
|
119
|
+
* ends (`variant="reveal"`), and happy-dom never signals that end — so a
|
|
120
|
+
* node on its way out is still in the DOM. It is no longer part of the
|
|
121
|
+
* list (the CSS even takes its pointer events away), so every list query
|
|
122
|
+
* here reads the LIVING elements only. */
|
|
123
|
+
function isLeaving(el: Element): boolean {
|
|
124
|
+
return el.classList.contains("hk-list-reveal-leave-active");
|
|
125
|
+
}
|
|
126
|
+
|
|
117
127
|
function tags(container: HTMLElement): HTMLElement[] {
|
|
118
|
-
return [...container.querySelectorAll<HTMLElement>(".hk-tag")]
|
|
128
|
+
return [...container.querySelectorAll<HTMLElement>(".hk-tag")].filter(
|
|
129
|
+
(tag) => !isLeaving(tag),
|
|
130
|
+
);
|
|
119
131
|
}
|
|
120
132
|
|
|
121
133
|
function tagTexts(container: HTMLElement): string[] {
|
|
@@ -125,12 +137,22 @@ function tagTexts(container: HTMLElement): string[] {
|
|
|
125
137
|
}
|
|
126
138
|
|
|
127
139
|
function closeButtons(container: HTMLElement): HTMLButtonElement[] {
|
|
128
|
-
return
|
|
140
|
+
return tags(container)
|
|
141
|
+
.map((tag) => tag.querySelector<HTMLButtonElement>(".hk-tag-close"))
|
|
142
|
+
.filter((button): button is HTMLButtonElement => button != null);
|
|
129
143
|
}
|
|
130
144
|
|
|
131
145
|
/** Rows live in the teleported panel, so they are queried on body. */
|
|
132
146
|
function rows(): HTMLElement[] {
|
|
133
|
-
return [...document.querySelectorAll<HTMLElement>(".hk-tag-input-row")]
|
|
147
|
+
return [...document.querySelectorAll<HTMLElement>(".hk-tag-input-row")].filter(
|
|
148
|
+
(row) => !isLeaving(row),
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** The rows a panel drag may move: the selected ones, which carry both the
|
|
153
|
+
* strip marker and their grip handle. */
|
|
154
|
+
function reorderableRows(): HTMLElement[] {
|
|
155
|
+
return rows().filter((row) => row.hasAttribute("data-reorder"));
|
|
134
156
|
}
|
|
135
157
|
|
|
136
158
|
function rowLabels(): string[] {
|
|
@@ -149,11 +171,11 @@ function rowByLabel(label: string): HTMLElement {
|
|
|
149
171
|
|
|
150
172
|
/** The row the keyboard cursor currently sits on (none = null). */
|
|
151
173
|
function activeRow(): HTMLElement | null {
|
|
152
|
-
return
|
|
174
|
+
return rows().find((row) => row.hasAttribute("data-active")) ?? null;
|
|
153
175
|
}
|
|
154
176
|
|
|
155
177
|
function customRow(): HTMLElement | null {
|
|
156
|
-
return
|
|
178
|
+
return rows().find((row) => row.getAttribute("data-custom") === "true") ?? null;
|
|
157
179
|
}
|
|
158
180
|
|
|
159
181
|
function searchInput(): HTMLInputElement {
|
|
@@ -183,8 +205,10 @@ async function typeSearch(value: string): Promise<void> {
|
|
|
183
205
|
await typeInto(searchInput(), value);
|
|
184
206
|
}
|
|
185
207
|
|
|
186
|
-
function pressKey(el: Element, key: string): void {
|
|
187
|
-
el.dispatchEvent(
|
|
208
|
+
function pressKey(el: Element, key: string, init: KeyboardEventInit = {}): void {
|
|
209
|
+
el.dispatchEvent(
|
|
210
|
+
new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true, ...init }),
|
|
211
|
+
);
|
|
188
212
|
}
|
|
189
213
|
|
|
190
214
|
afterEach(async () => {
|
|
@@ -301,7 +325,7 @@ describe("HkTagInput", () => {
|
|
|
301
325
|
const list = document.getElementById(id!);
|
|
302
326
|
expect(list, "the referenced element exists").toBeTruthy();
|
|
303
327
|
expect(list!.getAttribute("role")).toBe("listbox");
|
|
304
|
-
expect(list!.children).toHaveLength(0);
|
|
328
|
+
expect([...list!.children].filter((child) => !isLeaving(child))).toHaveLength(0);
|
|
305
329
|
|
|
306
330
|
// …and the message itself is NOT owned by the listbox: a listbox may
|
|
307
331
|
// only own options/groups (aria-required-children), so it sits beside.
|
|
@@ -314,10 +338,13 @@ describe("HkTagInput", () => {
|
|
|
314
338
|
it("lists EVERY option — selected ones stay visible with a check glyph", async () => {
|
|
315
339
|
const { container } = mountTagInput({ modelValue: ["news", "tech"] });
|
|
316
340
|
await openPanel(container);
|
|
341
|
+
// Group-strict: the two selected rows come first, in the HOST's order
|
|
342
|
+
// ("news" before "tech" — not the catalog's), then the unselected ones
|
|
343
|
+
// in catalog order.
|
|
317
344
|
expect(rowLabels()).toEqual([
|
|
318
345
|
"News",
|
|
319
|
-
"中华人民共和国",
|
|
320
346
|
"Technology",
|
|
347
|
+
"中华人民共和国",
|
|
321
348
|
"Germany",
|
|
322
349
|
"Retired",
|
|
323
350
|
]);
|
|
@@ -1120,3 +1147,780 @@ describe("HkTagInput", () => {
|
|
|
1120
1147
|
).toEqual(["News", "Technology"]);
|
|
1121
1148
|
});
|
|
1122
1149
|
});
|
|
1150
|
+
|
|
1151
|
+
/** Pin an item strip's rects: happy-dom ships no layout engine, so the
|
|
1152
|
+
* items a drag measures are placed by hand (the HkSlider pinRect
|
|
1153
|
+
* pattern). `size` is the item's extent along the axis. */
|
|
1154
|
+
function pinStrip(items: HTMLElement[], size: number, axis: "x" | "y"): void {
|
|
1155
|
+
items.forEach((el, index) => {
|
|
1156
|
+
const start = index * size;
|
|
1157
|
+
const box =
|
|
1158
|
+
axis === "x"
|
|
1159
|
+
? { left: start, right: start + size, top: 0, bottom: size, width: size, height: size, x: start, y: 0 }
|
|
1160
|
+
: { left: 0, right: size, top: start, bottom: start + size, width: size, height: size, x: 0, y: start };
|
|
1161
|
+
Object.defineProperty(el, "getBoundingClientRect", {
|
|
1162
|
+
configurable: true,
|
|
1163
|
+
value: () => box as DOMRect,
|
|
1164
|
+
});
|
|
1165
|
+
});
|
|
1166
|
+
}
|
|
1167
|
+
|
|
1168
|
+
/** Press `on` and move to `to`, WITHOUT releasing — for observing a held
|
|
1169
|
+
* drag (auto-scroll, drop markers) before the drop. Moves and releases are
|
|
1170
|
+
* window events: the drag listens at the window, because a real pointer
|
|
1171
|
+
* leaves the item it started on. `buttons` models the held button (1 for
|
|
1172
|
+
* the whole press, 0 on release), which is how the drag tells a live
|
|
1173
|
+
* gesture from one whose release the page never saw. */
|
|
1174
|
+
function holdPointer(
|
|
1175
|
+
from: { x: number; y: number },
|
|
1176
|
+
to: { x: number; y: number },
|
|
1177
|
+
on: HTMLElement,
|
|
1178
|
+
pointerType: "mouse" | "touch" = "mouse",
|
|
1179
|
+
): void {
|
|
1180
|
+
on.dispatchEvent(
|
|
1181
|
+
new PointerEvent("pointerdown", {
|
|
1182
|
+
bubbles: true,
|
|
1183
|
+
cancelable: true,
|
|
1184
|
+
pointerId: 1,
|
|
1185
|
+
pointerType,
|
|
1186
|
+
button: 0,
|
|
1187
|
+
buttons: 1,
|
|
1188
|
+
clientX: from.x,
|
|
1189
|
+
clientY: from.y,
|
|
1190
|
+
}),
|
|
1191
|
+
);
|
|
1192
|
+
window.dispatchEvent(
|
|
1193
|
+
new PointerEvent("pointermove", {
|
|
1194
|
+
bubbles: true,
|
|
1195
|
+
pointerId: 1,
|
|
1196
|
+
pointerType,
|
|
1197
|
+
buttons: 1,
|
|
1198
|
+
clientX: to.x,
|
|
1199
|
+
clientY: to.y,
|
|
1200
|
+
}),
|
|
1201
|
+
);
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1204
|
+
function releasePointer(
|
|
1205
|
+
to: { x: number; y: number },
|
|
1206
|
+
pointerType: "mouse" | "touch" = "mouse",
|
|
1207
|
+
): void {
|
|
1208
|
+
window.dispatchEvent(
|
|
1209
|
+
new PointerEvent("pointerup", {
|
|
1210
|
+
bubbles: true,
|
|
1211
|
+
pointerId: 1,
|
|
1212
|
+
pointerType,
|
|
1213
|
+
buttons: 0,
|
|
1214
|
+
clientX: to.x,
|
|
1215
|
+
clientY: to.y,
|
|
1216
|
+
}),
|
|
1217
|
+
);
|
|
1218
|
+
}
|
|
1219
|
+
|
|
1220
|
+
/** A full gesture: press, move, release. */
|
|
1221
|
+
function dragPointer(
|
|
1222
|
+
from: { x: number; y: number },
|
|
1223
|
+
to: { x: number; y: number },
|
|
1224
|
+
on: HTMLElement,
|
|
1225
|
+
pointerType: "mouse" | "touch" = "mouse",
|
|
1226
|
+
): void {
|
|
1227
|
+
holdPointer(from, to, on, pointerType);
|
|
1228
|
+
releasePointer(to, pointerType);
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1231
|
+
/** Wait out `count` animation frames — auto-scroll steps one per frame. */
|
|
1232
|
+
async function frames(count: number): Promise<void> {
|
|
1233
|
+
for (let i = 0; i < count; i += 1) {
|
|
1234
|
+
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
|
|
1235
|
+
}
|
|
1236
|
+
}
|
|
1237
|
+
|
|
1238
|
+
/** Pin a scrollable band and place its drag rows relative to the band's own
|
|
1239
|
+
* scroll offset — what a browser does to the content a scroll moves under
|
|
1240
|
+
* the pointer (happy-dom has no layout, so the geometry is stated). */
|
|
1241
|
+
function pinScrollBand(
|
|
1242
|
+
surface: HTMLElement,
|
|
1243
|
+
height: number,
|
|
1244
|
+
rows: HTMLElement[],
|
|
1245
|
+
rowSize = 40,
|
|
1246
|
+
): void {
|
|
1247
|
+
Object.defineProperty(surface, "getBoundingClientRect", {
|
|
1248
|
+
configurable: true,
|
|
1249
|
+
value: () =>
|
|
1250
|
+
({ left: 0, right: 40, top: 0, bottom: height, width: 40, height, x: 0, y: 0 }) as DOMRect,
|
|
1251
|
+
});
|
|
1252
|
+
rows.forEach((row, index) => {
|
|
1253
|
+
Object.defineProperty(row, "getBoundingClientRect", {
|
|
1254
|
+
configurable: true,
|
|
1255
|
+
value: () => {
|
|
1256
|
+
const start = index * rowSize - surface.scrollTop;
|
|
1257
|
+
return {
|
|
1258
|
+
left: 0,
|
|
1259
|
+
right: 40,
|
|
1260
|
+
top: start,
|
|
1261
|
+
bottom: start + rowSize,
|
|
1262
|
+
width: 40,
|
|
1263
|
+
height: rowSize,
|
|
1264
|
+
x: 0,
|
|
1265
|
+
y: start,
|
|
1266
|
+
} as DOMRect;
|
|
1267
|
+
},
|
|
1268
|
+
});
|
|
1269
|
+
});
|
|
1270
|
+
}
|
|
1271
|
+
|
|
1272
|
+
/** The row the live drag would land on (the drop marker), or null. */
|
|
1273
|
+
function dropRow(): HTMLElement | null {
|
|
1274
|
+
return rows().find((row) => row.hasAttribute("data-drop")) ?? null;
|
|
1275
|
+
}
|
|
1276
|
+
|
|
1277
|
+
function grip(row: HTMLElement): HTMLElement {
|
|
1278
|
+
const handle = row.querySelector<HTMLElement>(".hk-tag-input-grip");
|
|
1279
|
+
expect(handle, "the selected row carries a drag handle").toBeTruthy();
|
|
1280
|
+
return handle!;
|
|
1281
|
+
}
|
|
1282
|
+
|
|
1283
|
+
function rowText(row: HTMLElement): string {
|
|
1284
|
+
return row.querySelector(".hk-tag-input-row-label")?.textContent ?? "";
|
|
1285
|
+
}
|
|
1286
|
+
|
|
1287
|
+
describe("HkTagInput panel geometry and drag reordering", () => {
|
|
1288
|
+
it("hugs its own measure instead of the field's width, capped by maxHeight", async () => {
|
|
1289
|
+
const { container } = mountTagInput({ modelValue: ["news"] });
|
|
1290
|
+
// A full-width settings column (880px): far wider than the panel's
|
|
1291
|
+
// designed 13–19rem measure, which is exactly the reported defect.
|
|
1292
|
+
Object.defineProperty(field(container), "getBoundingClientRect", {
|
|
1293
|
+
configurable: true,
|
|
1294
|
+
value: () =>
|
|
1295
|
+
({ left: 0, right: 880, top: 0, bottom: 40, width: 880, height: 40, x: 0, y: 0 }) as DOMRect,
|
|
1296
|
+
});
|
|
1297
|
+
await openPanel(container);
|
|
1298
|
+
|
|
1299
|
+
const host = document.querySelector<HTMLElement>(".hk-select-popout-host")!;
|
|
1300
|
+
expect(host, "the popout host renders").toBeTruthy();
|
|
1301
|
+
// No anchor-width match: nothing pins the popout to the 880px field.
|
|
1302
|
+
expect(host.style.minWidth).toBe("");
|
|
1303
|
+
|
|
1304
|
+
// The surface cap rides the panel through the SCSS hook, so a long
|
|
1305
|
+
// catalog scrolls inside 18rem/45dvh instead of growing to 36rem.
|
|
1306
|
+
const popout = document.querySelector<HTMLElement>(".hk-select-popout")!;
|
|
1307
|
+
expect(popout.style.getPropertyValue("--hk-select-panel-max-height")).toBe(
|
|
1308
|
+
"min(18rem, 45dvh)",
|
|
1309
|
+
);
|
|
1310
|
+
});
|
|
1311
|
+
|
|
1312
|
+
it("leaves the shared panel exactly as it was when the cap is not passed", async () => {
|
|
1313
|
+
// The `maxHeight` prop is ADDITIVE, pinned at the DOM level rather than
|
|
1314
|
+
// in source text: a panel mounted with no cap renders no hook and no
|
|
1315
|
+
// inline max-height, i.e. the historic popout geometry.
|
|
1316
|
+
const anchor = document.createElement("div");
|
|
1317
|
+
document.body.appendChild(anchor);
|
|
1318
|
+
const container = document.createElement("div");
|
|
1319
|
+
document.body.appendChild(container);
|
|
1320
|
+
const app = createApp({
|
|
1321
|
+
render: () =>
|
|
1322
|
+
h(
|
|
1323
|
+
HkSelectPanel,
|
|
1324
|
+
{ open: true, anchorRef: anchor, title: "Pick" },
|
|
1325
|
+
{ default: () => h("div", { class: "probe-row" }, "row") },
|
|
1326
|
+
),
|
|
1327
|
+
});
|
|
1328
|
+
app.mount(container);
|
|
1329
|
+
mounts.push({ app, container });
|
|
1330
|
+
await settle();
|
|
1331
|
+
|
|
1332
|
+
const popout = document.querySelector<HTMLElement>(".hk-select-popout");
|
|
1333
|
+
expect(popout, "the popout renders").toBeTruthy();
|
|
1334
|
+
expect(popout!.style.getPropertyValue("--hk-select-panel-max-height")).toBe("");
|
|
1335
|
+
expect(popout!.style.maxHeight).toBe("");
|
|
1336
|
+
expect(popout!.hasAttribute("style"), "no inline style at all").toBe(false);
|
|
1337
|
+
});
|
|
1338
|
+
|
|
1339
|
+
it("regroups the rows: selected first in host order, then catalog order", async () => {
|
|
1340
|
+
// The host order is "de" then "news" — deliberately NOT the catalog's
|
|
1341
|
+
// (news precedes de there), so the assertion pins the host's order.
|
|
1342
|
+
const { container } = mountTagInput({ modelValue: ["de", "news"] });
|
|
1343
|
+
await openPanel(container);
|
|
1344
|
+
expect(rowLabels()).toEqual([
|
|
1345
|
+
"Germany",
|
|
1346
|
+
"News",
|
|
1347
|
+
"中华人民共和国",
|
|
1348
|
+
"Technology",
|
|
1349
|
+
"Retired",
|
|
1350
|
+
]);
|
|
1351
|
+
// Only the selected rows are drag items — they carry the marker.
|
|
1352
|
+
expect(reorderableRows().map(rowText)).toEqual(["Germany", "News"]);
|
|
1353
|
+
});
|
|
1354
|
+
|
|
1355
|
+
it("filters WITHIN each group and never merges them", async () => {
|
|
1356
|
+
const { container } = mountTagInput({
|
|
1357
|
+
modelValue: ["tech", "news"],
|
|
1358
|
+
allowCustom: true,
|
|
1359
|
+
});
|
|
1360
|
+
await openPanel(container);
|
|
1361
|
+
// "e" matches News, Technology, Germany and Retired: the two selected
|
|
1362
|
+
// rows keep the host order and stay ahead of the unselected ones.
|
|
1363
|
+
await typeSearch("e");
|
|
1364
|
+
expect(rowLabels()).toEqual([
|
|
1365
|
+
"Technology",
|
|
1366
|
+
"News",
|
|
1367
|
+
"Germany",
|
|
1368
|
+
"Retired",
|
|
1369
|
+
"Use “e”",
|
|
1370
|
+
]);
|
|
1371
|
+
|
|
1372
|
+
// A query matching only unselected rows leaves the selected group
|
|
1373
|
+
// empty — the groups do not collapse into one catalog order.
|
|
1374
|
+
await typeSearch("retir");
|
|
1375
|
+
expect(rowLabels()).toEqual(["Retired", "Use “retir”"]);
|
|
1376
|
+
});
|
|
1377
|
+
|
|
1378
|
+
it("walks the keyboard stops in the RENDERED order", async () => {
|
|
1379
|
+
const { container } = mountTagInput({ modelValue: ["de", "news"], allowCustom: true });
|
|
1380
|
+
await openPanel(container);
|
|
1381
|
+
const search = searchInput();
|
|
1382
|
+
search.focus();
|
|
1383
|
+
await typeSearch("e");
|
|
1384
|
+
|
|
1385
|
+
const walked: string[] = [];
|
|
1386
|
+
for (let i = 0; i < 5; i += 1) {
|
|
1387
|
+
pressKey(search, "ArrowDown");
|
|
1388
|
+
await settle();
|
|
1389
|
+
walked.push(rowText(activeRow()!));
|
|
1390
|
+
}
|
|
1391
|
+
// The cursor follows the regrouped rows, custom row last…
|
|
1392
|
+
expect(walked).toEqual(["Germany", "News", "Technology", "Retired", "Use “e”"]);
|
|
1393
|
+
// …and wraps back to the first rendered row.
|
|
1394
|
+
pressKey(search, "ArrowDown");
|
|
1395
|
+
await settle();
|
|
1396
|
+
expect(activeRow()).toBe(rowByLabel("Germany"));
|
|
1397
|
+
});
|
|
1398
|
+
|
|
1399
|
+
it("drags a chip by its body and emits the host's new order", async () => {
|
|
1400
|
+
const { events, container } = mountTagInput({ modelValue: ["news", "tech", "de"] });
|
|
1401
|
+
const strip = tags(container);
|
|
1402
|
+
expect(strip).toHaveLength(3);
|
|
1403
|
+
pinStrip(strip, 60, "x");
|
|
1404
|
+
|
|
1405
|
+
// Press the first chip and pull the pointer past every midpoint: it
|
|
1406
|
+
// lands in the last slot.
|
|
1407
|
+
dragPointer({ x: 10, y: 10 }, { x: 200, y: 10 }, strip[0]);
|
|
1408
|
+
await settle();
|
|
1409
|
+
|
|
1410
|
+
expect(events.modelValue.at(-1)).toEqual(["tech", "de", "news"]);
|
|
1411
|
+
expect(tagTexts(container)).toEqual(["Technology", "Germany", "News"]);
|
|
1412
|
+
// A reorder is not an edit: nothing was added or removed.
|
|
1413
|
+
expect(events.add).toEqual([]);
|
|
1414
|
+
expect(events.remove).toEqual([]);
|
|
1415
|
+
});
|
|
1416
|
+
|
|
1417
|
+
it("drags a selected row by its grip and emits the host's new order", async () => {
|
|
1418
|
+
const { events, container } = mountTagInput({ modelValue: ["news", "tech", "de"] });
|
|
1419
|
+
await openPanel(container);
|
|
1420
|
+
const selected = reorderableRows();
|
|
1421
|
+
expect(selected.map(rowText)).toEqual(["News", "Technology", "Germany"]);
|
|
1422
|
+
pinStrip(selected, 40, "y");
|
|
1423
|
+
|
|
1424
|
+
dragPointer({ x: 10, y: 10 }, { x: 10, y: 130 }, grip(selected[0]));
|
|
1425
|
+
await settle();
|
|
1426
|
+
|
|
1427
|
+
expect(events.modelValue.at(-1)).toEqual(["tech", "de", "news"]);
|
|
1428
|
+
expect(events.add).toEqual([]);
|
|
1429
|
+
expect(events.remove).toEqual([]);
|
|
1430
|
+
// The panel and the field agree on the new order.
|
|
1431
|
+
expect(rowLabels().slice(0, 3)).toEqual(["Technology", "Germany", "News"]);
|
|
1432
|
+
});
|
|
1433
|
+
|
|
1434
|
+
it("clamps a row drag to the selected group", async () => {
|
|
1435
|
+
const { events, container } = mountTagInput({ modelValue: ["news", "tech"] });
|
|
1436
|
+
await openPanel(container);
|
|
1437
|
+
const selected = reorderableRows();
|
|
1438
|
+
expect(selected.map(rowText)).toEqual(["News", "Technology"]);
|
|
1439
|
+
// EVERY row is measured, not just the drag items: a strip that wrongly
|
|
1440
|
+
// included the unselected rows would otherwise resolve against
|
|
1441
|
+
// unmeasured (all-zero) rects and slip through this test.
|
|
1442
|
+
pinStrip(rows(), 40, "y");
|
|
1443
|
+
|
|
1444
|
+
// Aim far BELOW the group, deep into the unselected rows: the drop
|
|
1445
|
+
// still lands on the last SELECTED slot, never inside that region.
|
|
1446
|
+
dragPointer({ x: 10, y: 10 }, { x: 10, y: 600 }, grip(selected[0]));
|
|
1447
|
+
await settle();
|
|
1448
|
+
|
|
1449
|
+
expect(events.modelValue.at(-1)).toEqual(["tech", "news"]);
|
|
1450
|
+
expect(rowLabels()).toEqual([
|
|
1451
|
+
"Technology",
|
|
1452
|
+
"News",
|
|
1453
|
+
"中华人民共和国",
|
|
1454
|
+
"Germany",
|
|
1455
|
+
"Retired",
|
|
1456
|
+
]);
|
|
1457
|
+
});
|
|
1458
|
+
|
|
1459
|
+
it("keeps unselected rows out of the drag space entirely", async () => {
|
|
1460
|
+
const { events, container } = mountTagInput({ modelValue: ["news"] });
|
|
1461
|
+
await openPanel(container);
|
|
1462
|
+
const selected = reorderableRows();
|
|
1463
|
+
expect(selected).toHaveLength(1);
|
|
1464
|
+
expect(grip(selected[0])).toBeTruthy();
|
|
1465
|
+
|
|
1466
|
+
// An unselected row carries neither the drag marker nor a handle…
|
|
1467
|
+
const unselected = rowByLabel("Germany");
|
|
1468
|
+
expect(unselected.hasAttribute("data-reorder")).toBe(false);
|
|
1469
|
+
expect(unselected.querySelector(".hk-tag-input-grip")).toBeNull();
|
|
1470
|
+
|
|
1471
|
+
// …and a press on it starts nothing, however far the pointer travels.
|
|
1472
|
+
pinStrip([selected[0], unselected], 40, "y");
|
|
1473
|
+
dragPointer({ x: 10, y: 70 }, { x: 10, y: 600 }, unselected);
|
|
1474
|
+
await settle();
|
|
1475
|
+
expect(events.modelValue).toEqual([]);
|
|
1476
|
+
});
|
|
1477
|
+
|
|
1478
|
+
it("moves a panel row by key, so a custom tag in the array survives", async () => {
|
|
1479
|
+
const { events, container } = mountTagInput({
|
|
1480
|
+
modelValue: ["news", "gitee.com", "tech"],
|
|
1481
|
+
});
|
|
1482
|
+
await openPanel(container);
|
|
1483
|
+
const selected = reorderableRows();
|
|
1484
|
+
// Only the two catalog entries have rows; the custom key does not.
|
|
1485
|
+
expect(selected.map(rowText)).toEqual(["News", "Technology"]);
|
|
1486
|
+
pinStrip(selected, 40, "y");
|
|
1487
|
+
|
|
1488
|
+
dragPointer({ x: 10, y: 10 }, { x: 10, y: 70 }, grip(selected[0]));
|
|
1489
|
+
await settle();
|
|
1490
|
+
|
|
1491
|
+
// "news" lands on "tech"'s host position; the custom key was neither
|
|
1492
|
+
// dropped nor duplicated by a row drag it had no row in.
|
|
1493
|
+
expect(events.modelValue.at(-1)).toEqual(["gitee.com", "tech", "news"]);
|
|
1494
|
+
expect(tagTexts(container)).toEqual(["gitee.com", "Technology", "News"]);
|
|
1495
|
+
});
|
|
1496
|
+
|
|
1497
|
+
it("keeps a sub-threshold press a click — and never drags from the ×", async () => {
|
|
1498
|
+
const { events, container } = mountTagInput({ modelValue: ["news", "tech"] });
|
|
1499
|
+
pinStrip(tags(container), 60, "x");
|
|
1500
|
+
|
|
1501
|
+
// The × is a control: its press starts no drag at all, however far the
|
|
1502
|
+
// pointer then travels — on touch just as on a mouse.
|
|
1503
|
+
dragPointer({ x: 10, y: 10 }, { x: 200, y: 10 }, closeButtons(container)[0], "touch");
|
|
1504
|
+
await settle();
|
|
1505
|
+
expect(events.modelValue).toEqual([]);
|
|
1506
|
+
|
|
1507
|
+
// …and the click it does own still removes its tag.
|
|
1508
|
+
closeButtons(container)[0].click();
|
|
1509
|
+
await settle();
|
|
1510
|
+
expect(events.remove).toEqual(["news"]);
|
|
1511
|
+
expect(events.modelValue.at(-1)).toEqual(["tech"]);
|
|
1512
|
+
|
|
1513
|
+
// A press on the chip body that stays under the ~6px threshold is a
|
|
1514
|
+
// click too — no reorder, no swallowed click.
|
|
1515
|
+
const remaining = tags(container);
|
|
1516
|
+
pinStrip(remaining, 60, "x");
|
|
1517
|
+
dragPointer({ x: 10, y: 10 }, { x: 13, y: 10 }, remaining[0]);
|
|
1518
|
+
await settle();
|
|
1519
|
+
expect(events.modelValue.at(-1)).toEqual(["tech"]);
|
|
1520
|
+
expect(tagTexts(container)).toEqual(["Technology"]);
|
|
1521
|
+
});
|
|
1522
|
+
|
|
1523
|
+
it("swallows the click a real chip drag would deliver to the field", async () => {
|
|
1524
|
+
const { events, container } = mountTagInput({ modelValue: ["news", "tech"] });
|
|
1525
|
+
const strip = tags(container);
|
|
1526
|
+
pinStrip(strip, 60, "x");
|
|
1527
|
+
|
|
1528
|
+
dragPointer({ x: 10, y: 10 }, { x: 200, y: 10 }, strip[0]);
|
|
1529
|
+
// The gesture ended inside the field, so the browser delivers its click
|
|
1530
|
+
// to the box: the drag owns it — the panel must not pop open behind
|
|
1531
|
+
// the drop.
|
|
1532
|
+
field(container).dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
1533
|
+
await settle();
|
|
1534
|
+
|
|
1535
|
+
expect(events.modelValue.at(-1)).toEqual(["tech", "news"]);
|
|
1536
|
+
expect(events.open).toEqual([]);
|
|
1537
|
+
});
|
|
1538
|
+
|
|
1539
|
+
it("does not let a row drag's trailing click toggle the dragged row", async () => {
|
|
1540
|
+
const { events, container } = mountTagInput({ modelValue: ["news", "tech"] });
|
|
1541
|
+
await openPanel(container);
|
|
1542
|
+
const selected = reorderableRows();
|
|
1543
|
+
pinStrip(selected, 40, "y");
|
|
1544
|
+
dragPointer({ x: 10, y: 10 }, { x: 10, y: 70 }, grip(selected[0]));
|
|
1545
|
+
// The pointer started and ended on the SAME row (a short drag), so the
|
|
1546
|
+
// browser delivers its click there right after the release: the drag
|
|
1547
|
+
// owns that click — otherwise the row the user just moved would toggle
|
|
1548
|
+
// itself back OFF against the model it was moved in.
|
|
1549
|
+
grip(selected[0]).dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
1550
|
+
await settle();
|
|
1551
|
+
|
|
1552
|
+
expect(events.remove).toEqual([]);
|
|
1553
|
+
expect(events.modelValue.at(-1)).toEqual(["tech", "news"]);
|
|
1554
|
+
});
|
|
1555
|
+
|
|
1556
|
+
it("never lets a blind Enter remove a tag the query matches", async () => {
|
|
1557
|
+
const { events, container } = mountTagInput({ modelValue: ["tech"], allowCustom: true });
|
|
1558
|
+
await openPanel(container);
|
|
1559
|
+
const search = searchInput();
|
|
1560
|
+
search.focus();
|
|
1561
|
+
|
|
1562
|
+
// "e" matches the SELECTED Technology first (group-strict order), then
|
|
1563
|
+
// News / Germany / Retired. Enter with no cursor is the "type to add"
|
|
1564
|
+
// gesture, so it takes the first row the field can still take instead of
|
|
1565
|
+
// toggling the tag the user is looking at back off.
|
|
1566
|
+
await typeSearch("e");
|
|
1567
|
+
expect(rowLabels()[0]).toBe("Technology");
|
|
1568
|
+
pressKey(search, "Enter");
|
|
1569
|
+
await settle();
|
|
1570
|
+
expect(events.add).toEqual(["news"]);
|
|
1571
|
+
expect(events.remove).toEqual([]);
|
|
1572
|
+
expect(events.modelValue.at(-1)).toEqual(["tech", "news"]);
|
|
1573
|
+
|
|
1574
|
+
// A query naming ONLY what the field already carries has nothing to add
|
|
1575
|
+
// and nothing to remove — the same no-op rule a selected custom key
|
|
1576
|
+
// already had; the row stays for a deliberate toggle.
|
|
1577
|
+
await typeSearch("Technology");
|
|
1578
|
+
expect(rowLabels()).toEqual(["Technology"]);
|
|
1579
|
+
pressKey(search, "Enter");
|
|
1580
|
+
await settle();
|
|
1581
|
+
expect(events.modelValue.at(-1)).toEqual(["tech", "news"]);
|
|
1582
|
+
expect(events.remove).toEqual([]);
|
|
1583
|
+
expect(rowByLabel("Technology").getAttribute("aria-selected")).toBe("true");
|
|
1584
|
+
});
|
|
1585
|
+
|
|
1586
|
+
it("keeps a tap on the grip from toggling its row", async () => {
|
|
1587
|
+
const { events, container } = mountTagInput({ modelValue: ["news"] });
|
|
1588
|
+
await openPanel(container);
|
|
1589
|
+
const row = reorderableRows()[0];
|
|
1590
|
+
expect(rowText(row)).toBe("News");
|
|
1591
|
+
|
|
1592
|
+
// No drag happened (no movement): the press is the handle's, not the
|
|
1593
|
+
// row's — a handle that erased its tag on a tap would be a trap.
|
|
1594
|
+
grip(row).dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
1595
|
+
await settle();
|
|
1596
|
+
expect(events.remove).toEqual([]);
|
|
1597
|
+
expect(events.modelValue).toEqual([]);
|
|
1598
|
+
expect(rowByLabel("News").getAttribute("aria-selected")).toBe("true");
|
|
1599
|
+
});
|
|
1600
|
+
|
|
1601
|
+
it("names the grip handle after the entry it moves", async () => {
|
|
1602
|
+
const { container } = mountTagInput({ modelValue: ["news", "tech"] });
|
|
1603
|
+
await openPanel(container);
|
|
1604
|
+
// The handle's name is the localized `hikari::tagInput.reorder`
|
|
1605
|
+
// template with the row's own label interpolated — the i18n key and
|
|
1606
|
+
// its `{label}` placeholder are part of the contract.
|
|
1607
|
+
const handle = grip(reorderableRows()[0]);
|
|
1608
|
+
expect(rowText(reorderableRows()[0])).toBe("News");
|
|
1609
|
+
expect(handle.getAttribute("aria-label")).toBe("Reorder News");
|
|
1610
|
+
expect(handle.getAttribute("title")).toBe("Reorder News");
|
|
1611
|
+
expect(handle.getAttribute("role")).toBe("button");
|
|
1612
|
+
expect(handle.getAttribute("tabindex"), "pointer-only affordance").toBe("-1");
|
|
1613
|
+
});
|
|
1614
|
+
|
|
1615
|
+
it("never lets a leaving row keep the id the field publishes", async () => {
|
|
1616
|
+
// A catalog edit (an async reload) drops the row the cursor sits on: the
|
|
1617
|
+
// row leaves through the list transition while the cursor stays on its
|
|
1618
|
+
// index, where a DIFFERENT option now lives. An index-derived id would
|
|
1619
|
+
// be worn by two nodes at once, so `aria-activedescendant` could resolve
|
|
1620
|
+
// to the ghost; the ids are keyed, so the published one belongs to
|
|
1621
|
+
// exactly one LIVING row.
|
|
1622
|
+
const container = document.createElement("div");
|
|
1623
|
+
document.body.appendChild(container);
|
|
1624
|
+
const value = ref<readonly string[]>(["news", "tech", "de"]);
|
|
1625
|
+
const options = ref<readonly HkTagOption[]>(OPTIONS);
|
|
1626
|
+
const Host = defineComponent({
|
|
1627
|
+
name: "HkTagInputCatalogShrinkHost",
|
|
1628
|
+
setup() {
|
|
1629
|
+
return () =>
|
|
1630
|
+
h(HkTagInput, {
|
|
1631
|
+
modelValue: value.value,
|
|
1632
|
+
options: options.value,
|
|
1633
|
+
"onUpdate:modelValue": (keys: string[]) => {
|
|
1634
|
+
value.value = keys;
|
|
1635
|
+
},
|
|
1636
|
+
});
|
|
1637
|
+
},
|
|
1638
|
+
});
|
|
1639
|
+
const app = createApp(Host);
|
|
1640
|
+
app.mount(container);
|
|
1641
|
+
mounts.push({ app, container });
|
|
1642
|
+
await settle();
|
|
1643
|
+
|
|
1644
|
+
chevron(container).click();
|
|
1645
|
+
await settle();
|
|
1646
|
+
pressKey(inlineInput(container)!, "ArrowDown");
|
|
1647
|
+
await settle();
|
|
1648
|
+
expect(activeRow()).toBe(rowByLabel("News"));
|
|
1649
|
+
|
|
1650
|
+
options.value = OPTIONS.filter((option) => option.key !== "news");
|
|
1651
|
+
await nextTick();
|
|
1652
|
+
|
|
1653
|
+
const published = inlineInput(container)!.getAttribute("aria-activedescendant");
|
|
1654
|
+
expect(published, "the field still publishes a row").toBeTruthy();
|
|
1655
|
+
const matches = document.querySelectorAll(`[id="${published}"]`);
|
|
1656
|
+
expect(matches, "the published id is worn by one row only").toHaveLength(1);
|
|
1657
|
+
const live = matches[0] as HTMLElement;
|
|
1658
|
+
expect(live.classList.contains("hk-list-reveal-leave-active")).toBe(false);
|
|
1659
|
+
expect(live.getAttribute("role")).toBe("option");
|
|
1660
|
+
expect(rowText(live)).toBe("Technology");
|
|
1661
|
+
});
|
|
1662
|
+
|
|
1663
|
+
it("carries a panel drag past the visible band by scrolling the popout", async () => {
|
|
1664
|
+
const { events, container } = mountTagInput({
|
|
1665
|
+
modelValue: ["news", "cn-main", "tech", "de", "retired"],
|
|
1666
|
+
});
|
|
1667
|
+
await openPanel(container);
|
|
1668
|
+
const selected = reorderableRows();
|
|
1669
|
+
expect(selected.map(rowText)).toEqual([
|
|
1670
|
+
"News",
|
|
1671
|
+
"中华人民共和国",
|
|
1672
|
+
"Technology",
|
|
1673
|
+
"Germany",
|
|
1674
|
+
"Retired",
|
|
1675
|
+
]);
|
|
1676
|
+
|
|
1677
|
+
// A two-row visible band (80px) over the five-row strip: the last row is
|
|
1678
|
+
// only reachable by scrolling the surface — no new scroll region, the
|
|
1679
|
+
// popout that already owns the one scrollbar.
|
|
1680
|
+
const popout = document.querySelector<HTMLElement>(".hk-select-popout")!;
|
|
1681
|
+
expect(popout).toBeTruthy();
|
|
1682
|
+
pinScrollBand(popout, 80, selected);
|
|
1683
|
+
|
|
1684
|
+
// Press the first row and hold near the bottom edge.
|
|
1685
|
+
holdPointer({ x: 10, y: 10 }, { x: 10, y: 78 }, grip(selected[0]));
|
|
1686
|
+
await nextTick();
|
|
1687
|
+
expect(dropRow(), "before any scrolling the slot is inside the band").toBe(
|
|
1688
|
+
rowByLabel("中华人民共和国"),
|
|
1689
|
+
);
|
|
1690
|
+
|
|
1691
|
+
for (let i = 0; i < 40 && popout.scrollTop < 120; i += 1) await frames(1);
|
|
1692
|
+
expect(popout.scrollTop, "the surface is pulled along").toBeGreaterThanOrEqual(120);
|
|
1693
|
+
expect(dropRow(), "the slot follows the scrolled content").toBe(
|
|
1694
|
+
rowByLabel("Retired"),
|
|
1695
|
+
);
|
|
1696
|
+
|
|
1697
|
+
releasePointer({ x: 10, y: 78 });
|
|
1698
|
+
await settle();
|
|
1699
|
+
// The dragged key landed last, which is what the scroll bought.
|
|
1700
|
+
expect(events.modelValue.at(-1)).toEqual(["cn-main", "tech", "de", "retired", "news"]);
|
|
1701
|
+
expect(tagTexts(container).at(-1)).toBe("News");
|
|
1702
|
+
|
|
1703
|
+
// Nothing scrolls without a live drag.
|
|
1704
|
+
const settled = popout.scrollTop;
|
|
1705
|
+
await frames(3);
|
|
1706
|
+
expect(popout.scrollTop).toBe(settled);
|
|
1707
|
+
});
|
|
1708
|
+
|
|
1709
|
+
it("scrolls the mobile sheet's own list band, not the popout", async () => {
|
|
1710
|
+
const original = window.innerWidth;
|
|
1711
|
+
Object.defineProperty(window, "innerWidth", {
|
|
1712
|
+
configurable: true,
|
|
1713
|
+
writable: true,
|
|
1714
|
+
value: 375,
|
|
1715
|
+
});
|
|
1716
|
+
try {
|
|
1717
|
+
const { container } = mountTagInput({ modelValue: ["news", "tech", "de"] });
|
|
1718
|
+
await openPanel(container);
|
|
1719
|
+
// Phone width: the sheet is the surface, and the band that scrolls is
|
|
1720
|
+
// its list — resolved at drag time, not assumed.
|
|
1721
|
+
expect(document.querySelector(".hk-select-popout")).toBeNull();
|
|
1722
|
+
const band = document.querySelector<HTMLElement>(".hk-select-sheet-list");
|
|
1723
|
+
expect(band, "the sheet list renders").toBeTruthy();
|
|
1724
|
+
const selected = reorderableRows();
|
|
1725
|
+
pinScrollBand(band!, 80, selected);
|
|
1726
|
+
|
|
1727
|
+
holdPointer({ x: 10, y: 10 }, { x: 10, y: 78 }, grip(selected[0]));
|
|
1728
|
+
await nextTick();
|
|
1729
|
+
for (let i = 0; i < 40 && band!.scrollTop < 80; i += 1) await frames(1);
|
|
1730
|
+
expect(band!.scrollTop, "the sheet band is pulled along").toBeGreaterThanOrEqual(80);
|
|
1731
|
+
releasePointer({ x: 10, y: 78 });
|
|
1732
|
+
await settle();
|
|
1733
|
+
} finally {
|
|
1734
|
+
Object.defineProperty(window, "innerWidth", {
|
|
1735
|
+
configurable: true,
|
|
1736
|
+
writable: true,
|
|
1737
|
+
value: original,
|
|
1738
|
+
});
|
|
1739
|
+
}
|
|
1740
|
+
});
|
|
1741
|
+
|
|
1742
|
+
it("keeps a leaving chip out of the drag strip", async () => {
|
|
1743
|
+
const { events, container } = mountTagInput({ modelValue: ["news", "tech", "de"] });
|
|
1744
|
+
// Measure the whole strip BEFORE the removal: the removed chip keeps
|
|
1745
|
+
// its rect while it squeezes out, so a drag that counted it would
|
|
1746
|
+
// resolve a different slot.
|
|
1747
|
+
pinStrip(tags(container), 60, "x");
|
|
1748
|
+
closeButtons(container)[0].click();
|
|
1749
|
+
await nextTick();
|
|
1750
|
+
|
|
1751
|
+
const live = tags(container);
|
|
1752
|
+
expect(live.map((tag) => tag.textContent)).toEqual(["Technology", "Germany"]);
|
|
1753
|
+
expect(
|
|
1754
|
+
container.querySelectorAll(".hk-tag").length,
|
|
1755
|
+
"the removed chip is still mounted (its leave)",
|
|
1756
|
+
).toBe(3);
|
|
1757
|
+
|
|
1758
|
+
// Drag the FIRST LIVING chip to the end. Counting the ghost would take
|
|
1759
|
+
// the press for index 1 and emit the unchanged order instead.
|
|
1760
|
+
dragPointer({ x: 70, y: 10 }, { x: 300, y: 10 }, live[0]);
|
|
1761
|
+
await settle();
|
|
1762
|
+
expect(events.modelValue.at(-1)).toEqual(["de", "tech"]);
|
|
1763
|
+
expect(tagTexts(container)).toEqual(["Germany", "Technology"]);
|
|
1764
|
+
});
|
|
1765
|
+
|
|
1766
|
+
it("keeps a leaving row out of the panel drag strip", async () => {
|
|
1767
|
+
const { events, container } = mountTagInput({
|
|
1768
|
+
modelValue: ["news", "tech", "de", "cn-main"],
|
|
1769
|
+
});
|
|
1770
|
+
await openPanel(container);
|
|
1771
|
+
expect(reorderableRows().map(rowText)).toEqual([
|
|
1772
|
+
"News",
|
|
1773
|
+
"Technology",
|
|
1774
|
+
"Germany",
|
|
1775
|
+
"中华人民共和国",
|
|
1776
|
+
]);
|
|
1777
|
+
// Measure every row up front — the row about to leave keeps its rect.
|
|
1778
|
+
pinStrip(rows(), 40, "y");
|
|
1779
|
+
|
|
1780
|
+
// "e" hides the selected 中华人民共和国: its row starts leaving and is
|
|
1781
|
+
// still in the DOM for the leave window, while the OTHER three remain
|
|
1782
|
+
// the drag strip. (One tick, not `settle()`: the ghost is guaranteed to
|
|
1783
|
+
// be there the moment the patch lands — which is when a real drag would
|
|
1784
|
+
// start — while later timers may already have swept it away.)
|
|
1785
|
+
const search = searchInput();
|
|
1786
|
+
search.value = "e";
|
|
1787
|
+
search.dispatchEvent(new Event("input", { bubbles: true }));
|
|
1788
|
+
await nextTick();
|
|
1789
|
+
expect(reorderableRows().map(rowText)).toEqual(["News", "Technology", "Germany"]);
|
|
1790
|
+
expect(
|
|
1791
|
+
document.querySelectorAll('.hk-tag-input-row[data-reorder="true"]').length,
|
|
1792
|
+
"the hidden row is still mounted (its leave)",
|
|
1793
|
+
).toBe(4);
|
|
1794
|
+
|
|
1795
|
+
dragPointer({ x: 10, y: 10 }, { x: 10, y: 200 }, grip(reorderableRows()[0]));
|
|
1796
|
+
await settle();
|
|
1797
|
+
expect(events.modelValue.at(-1)).toEqual(["tech", "de", "news", "cn-main"]);
|
|
1798
|
+
expect(rowLabels().slice(0, 3)).toEqual(["Technology", "Germany", "News"]);
|
|
1799
|
+
});
|
|
1800
|
+
|
|
1801
|
+
it("moves the ACTIVE selected row with Alt+Arrow, and stops at the edges", async () => {
|
|
1802
|
+
const { events, container } = mountTagInput({ modelValue: ["news", "tech", "de"] });
|
|
1803
|
+
await openPanel(container);
|
|
1804
|
+
const search = searchInput();
|
|
1805
|
+
search.focus();
|
|
1806
|
+
const edits = () => events.modelValue.length;
|
|
1807
|
+
|
|
1808
|
+
// Park the cursor on the middle selected row.
|
|
1809
|
+
pressKey(search, "ArrowDown");
|
|
1810
|
+
await settle();
|
|
1811
|
+
pressKey(search, "ArrowDown");
|
|
1812
|
+
await settle();
|
|
1813
|
+
expect(activeRow()).toBe(rowByLabel("Technology"));
|
|
1814
|
+
|
|
1815
|
+
// Alt+ArrowUp moves it one slot up inside the selected group.
|
|
1816
|
+
pressKey(search, "ArrowUp", { altKey: true });
|
|
1817
|
+
await settle();
|
|
1818
|
+
expect(events.modelValue.at(-1)).toEqual(["tech", "news", "de"]);
|
|
1819
|
+
expect(events.add).toEqual([]);
|
|
1820
|
+
expect(events.remove).toEqual([]);
|
|
1821
|
+
expect(rowLabels().slice(0, 3)).toEqual(["Technology", "News", "Germany"]);
|
|
1822
|
+
// The cursor followed the row it moved, it was not left behind.
|
|
1823
|
+
expect(activeRow()).toBe(rowByLabel("Technology"));
|
|
1824
|
+
|
|
1825
|
+
// It is the group's first row now: another Alt+ArrowUp is a hard stop.
|
|
1826
|
+
pressKey(search, "ArrowUp", { altKey: true });
|
|
1827
|
+
await settle();
|
|
1828
|
+
expect(edits()).toBe(1);
|
|
1829
|
+
|
|
1830
|
+
// An UNSELECTED row has no place in the order — the chord does nothing.
|
|
1831
|
+
// Rows are [Technology, News, Germany, 中华人民共和国, Retired] now, and
|
|
1832
|
+
// the third of those is the first the field does not carry.
|
|
1833
|
+
pressKey(search, "ArrowDown");
|
|
1834
|
+
await settle();
|
|
1835
|
+
pressKey(search, "ArrowDown");
|
|
1836
|
+
await settle();
|
|
1837
|
+
pressKey(search, "ArrowDown");
|
|
1838
|
+
await settle();
|
|
1839
|
+
expect(activeRow()).toBe(rowByLabel("中华人民共和国"));
|
|
1840
|
+
expect(rowByLabel("中华人民共和国").hasAttribute("data-selected")).toBe(false);
|
|
1841
|
+
pressKey(search, "ArrowDown", { altKey: true });
|
|
1842
|
+
await settle();
|
|
1843
|
+
expect(edits()).toBe(1);
|
|
1844
|
+
|
|
1845
|
+
// …and with no row active at all there is nothing to move.
|
|
1846
|
+
pressKey(search, "Escape");
|
|
1847
|
+
await settle();
|
|
1848
|
+
await untilSettled(() => rows().length);
|
|
1849
|
+
await openPanel(container);
|
|
1850
|
+
expect(activeRow()).toBeNull();
|
|
1851
|
+
pressKey(searchInput(), "ArrowDown", { altKey: true });
|
|
1852
|
+
await settle();
|
|
1853
|
+
expect(edits()).toBe(1);
|
|
1854
|
+
});
|
|
1855
|
+
|
|
1856
|
+
it("reorders at the maxTags cap — the cap freezes adds, not the order", async () => {
|
|
1857
|
+
const { events, container } = mountTagInput({ modelValue: ["news", "tech"], maxTags: 2 });
|
|
1858
|
+
await openPanel(container);
|
|
1859
|
+
const search = searchInput();
|
|
1860
|
+
search.focus();
|
|
1861
|
+
pressKey(search, "ArrowDown");
|
|
1862
|
+
await settle();
|
|
1863
|
+
pressKey(search, "ArrowDown");
|
|
1864
|
+
await settle();
|
|
1865
|
+
expect(activeRow()).toBe(rowByLabel("Technology"));
|
|
1866
|
+
|
|
1867
|
+
pressKey(search, "ArrowUp", { altKey: true });
|
|
1868
|
+
await settle();
|
|
1869
|
+
expect(events.modelValue.at(-1)).toEqual(["tech", "news"]);
|
|
1870
|
+
expect(events.add).toEqual([]);
|
|
1871
|
+
expect(tagTexts(container)).toEqual(["Technology", "News"]);
|
|
1872
|
+
});
|
|
1873
|
+
|
|
1874
|
+
it("keeps every chip and row a DIRECT child of its container", async () => {
|
|
1875
|
+
// The FLIP groups render as fragments: no wrapper element may appear
|
|
1876
|
+
// between the box and its chips, or between the listbox and its rows.
|
|
1877
|
+
const { container } = mountTagInput({ modelValue: ["news", "tech"] });
|
|
1878
|
+
const box = field(container);
|
|
1879
|
+
for (const chip of tags(container)) expect(chip.parentElement).toBe(box);
|
|
1880
|
+
expect(inlineInput(container)!.parentElement).toBe(box);
|
|
1881
|
+
|
|
1882
|
+
await openPanel(container);
|
|
1883
|
+
const list = document.querySelector<HTMLElement>(".hk-tag-input-list")!;
|
|
1884
|
+
expect(list.getAttribute("role")).toBe("listbox");
|
|
1885
|
+
expect(rows()).toHaveLength(OPTIONS.length);
|
|
1886
|
+
for (const row of rows()) expect(row.parentElement).toBe(list);
|
|
1887
|
+
});
|
|
1888
|
+
|
|
1889
|
+
it("caps the mobile sheet too — on the band that scrolls", async () => {
|
|
1890
|
+
const original = window.innerWidth;
|
|
1891
|
+
Object.defineProperty(window, "innerWidth", {
|
|
1892
|
+
configurable: true,
|
|
1893
|
+
writable: true,
|
|
1894
|
+
value: 375,
|
|
1895
|
+
});
|
|
1896
|
+
try {
|
|
1897
|
+
const { container } = mountTagInput({ modelValue: ["news"] });
|
|
1898
|
+
await openPanel(container);
|
|
1899
|
+
// Phone width: the sheet replaces the popout, and the same cap lands
|
|
1900
|
+
// on the sheet's scrolling list (the surface the user actually drags).
|
|
1901
|
+
expect(document.querySelector(".hk-select-popout-host")).toBeNull();
|
|
1902
|
+
const list = document.querySelector<HTMLElement>(".hk-select-sheet-list");
|
|
1903
|
+
expect(list, "the sheet list renders").toBeTruthy();
|
|
1904
|
+
expect(list!.style.getPropertyValue("--hk-select-panel-max-height")).toBe(
|
|
1905
|
+
"min(18rem, 45dvh)",
|
|
1906
|
+
);
|
|
1907
|
+
} finally {
|
|
1908
|
+
Object.defineProperty(window, "innerWidth", {
|
|
1909
|
+
configurable: true,
|
|
1910
|
+
writable: true,
|
|
1911
|
+
value: original,
|
|
1912
|
+
});
|
|
1913
|
+
}
|
|
1914
|
+
});
|
|
1915
|
+
|
|
1916
|
+
it("leaves a removed chip through the list-transition leave", async () => {
|
|
1917
|
+
// Both lists are HkListTransition groups (FLIP `move` + the reveal
|
|
1918
|
+
// squeeze): a removal squeezes the chip out instead of vanishing, and
|
|
1919
|
+
// the logical list is short immediately.
|
|
1920
|
+
const { container } = mountTagInput({ modelValue: ["news", "tech"] });
|
|
1921
|
+
closeButtons(container)[0].click();
|
|
1922
|
+
await nextTick();
|
|
1923
|
+
expect(container.querySelectorAll(".hk-tag.hk-list-reveal-leave-active")).toHaveLength(1);
|
|
1924
|
+
expect(tagTexts(container)).toEqual(["Technology"]);
|
|
1925
|
+
});
|
|
1926
|
+
});
|