Skip to content

Commit 97a660d

Browse files
committed
c-select: assorted usability improvements
1 parent deed3c1 commit 97a660d

File tree

3 files changed

+192
-16
lines changed

3 files changed

+192
-16
lines changed

playground/Coalesce.Web.Vue3/src/examples/c-select/multiple-binding.vue

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,11 @@
7373
<h1>for non-many-to-many collection navigation</h1>
7474
<v-row v-if="person">
7575
<v-col>
76-
<c-select :model="person" for="casesAssigned" />
76+
<c-select :model="person" for="casesAssigned">
77+
<template #list-item="{ item }">
78+
{{ item.title }} ({{ item.caseKey }})
79+
</template>
80+
</c-select>
7781
Via c-input (this SHOULD NOT produce an input):
7882
<c-input :model="person" for="casesAssigned" />
7983
</v-col>

src/coalesce-vue-vuetify3/src/components/input/c-select.spec.tsx

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1110,6 +1110,86 @@ describe("CSelect", () => {
11101110
expect(wrapper.text()).not.toContain("Single Test is required");
11111111
});
11121112

1113+
test("left/right arrow navigation through selected items in multiple mode", async () => {
1114+
const model = new ComplexModelViewModel();
1115+
const wrapper = mountApp(() => (
1116+
<CSelect model={model} for="tests" multiple />
1117+
));
1118+
1119+
// Add multiple items to the selection
1120+
model.tests = [
1121+
new TestViewModel({ testId: 101, testName: "Test 1" }),
1122+
new TestViewModel({ testId: 202, testName: "Test 2" }),
1123+
new TestViewModel({ testId: 303, testName: "Test 3" }),
1124+
];
1125+
1126+
await nextTick();
1127+
1128+
// Find the text field input
1129+
const input = wrapper.find("input");
1130+
1131+
// Initially no item should be selected (selectionIndex = -1)
1132+
expect(wrapper.findAll(".v-select__selection--selected")).toHaveLength(0);
1133+
1134+
// Press ArrowLeft to select the last item
1135+
await input.trigger("keydown", { key: "ArrowLeft" });
1136+
await nextTick();
1137+
1138+
// Last item (index 2, Test 3) should be selected
1139+
const selectedItems = wrapper.findAll(".v-select__selection--selected");
1140+
expect(selectedItems).toHaveLength(1);
1141+
expect(selectedItems[0].text()).toContain("Test 3");
1142+
1143+
// Press ArrowLeft again to move to previous item
1144+
await input.trigger("keydown", { key: "ArrowLeft" });
1145+
await nextTick();
1146+
1147+
// Second item (index 1, Test 2) should now be selected
1148+
const selectedItems2 = wrapper.findAll(".v-select__selection--selected");
1149+
expect(selectedItems2).toHaveLength(1);
1150+
expect(selectedItems2[0].text()).toContain("Test 2");
1151+
1152+
// Press ArrowRight to move to next item
1153+
await input.trigger("keydown", { key: "ArrowRight" });
1154+
await nextTick();
1155+
1156+
// Third item (index 2, Test 3) should be selected again
1157+
const selectedItems3 = wrapper.findAll(".v-select__selection--selected");
1158+
expect(selectedItems3).toHaveLength(1);
1159+
expect(selectedItems3[0].text()).toContain("Test 3");
1160+
1161+
// Press ArrowRight again to deselect (should go to selectionIndex = -1)
1162+
await input.trigger("keydown", { key: "ArrowRight" });
1163+
await nextTick();
1164+
1165+
// No items should be selected
1166+
expect(wrapper.findAll(".v-select__selection--selected")).toHaveLength(0);
1167+
1168+
// Test deleting selected item with Delete key
1169+
// First select the last item (Test 3)
1170+
await input.trigger("keydown", { key: "ArrowLeft" });
1171+
await nextTick();
1172+
1173+
// Verify we have 3 items initially and Test 3 is selected
1174+
expect(model.tests).toHaveLength(3);
1175+
expect(model.tests[2].testName).toBe("Test 3");
1176+
const selectedBeforeDelete = wrapper.findAll(".v-select__selection--selected");
1177+
expect(selectedBeforeDelete).toHaveLength(1);
1178+
expect(selectedBeforeDelete[0].text()).toContain("Test 3");
1179+
1180+
// Press Delete to remove Test 3
1181+
await input.trigger("keydown", { key: "Delete" });
1182+
await nextTick();
1183+
1184+
// Should now have 2 items (Test 1 and Test 2) and Test 2 should be selected
1185+
expect(model.tests).toHaveLength(2);
1186+
expect(model.tests[0].testName).toBe("Test 1");
1187+
expect(model.tests[1].testName).toBe("Test 2");
1188+
const selectedAfterDelete = wrapper.findAll(".v-select__selection--selected");
1189+
expect(selectedAfterDelete).toHaveLength(1);
1190+
expect(selectedAfterDelete[0].text()).toContain("Test 2");
1191+
});
1192+
11131193
describe("vuetify props passthrough", () => {
11141194
beforeEach(() => {
11151195
mockEndpoint("/Person/list", () => ({

src/coalesce-vue-vuetify3/src/components/input/c-select.vue

Lines changed: 107 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222
append-inner-icon="$dropdown"
2323
@click:clear.stop.prevent="onInput(null, true)"
2424
@keydown="onInputKey($event)"
25-
@click.stop.prevent="openMenu()"
25+
@click:control.stop.prevent="openMenu()"
2626
v-intersect="onIntersect"
2727
>
2828
<template v-for="(_, slot) of passthroughSlots" v-slot:[slot]="scope">
@@ -35,6 +35,10 @@
3535
v-for="(item, index) in internalModelValue"
3636
:key="item[modelObjectMeta.keyProp.name]"
3737
class="v-select__selection"
38+
:class="{
39+
'v-select__selection--selected':
40+
index === selectionIndex && effectiveMultiple,
41+
}"
3842
>
3943
<slot
4044
name="selected-item"
@@ -44,12 +48,20 @@
4448
:remove="() => onInput(item)"
4549
>
4650
<slot name="item" :item="item" :search="search">
47-
<v-chip
48-
v-if="effectiveMultiple"
49-
size="small"
50-
:closable="!!canDeselect && isInteractive"
51-
@click:close="onInput(item)"
52-
>
51+
<v-chip v-if="effectiveMultiple" size="small">
52+
<template #append>
53+
<button
54+
v-if="!!canDeselect && isInteractive"
55+
class="v-chip__close"
56+
type="button"
57+
data-testid="close-chip"
58+
aria-label="Remove Item"
59+
tabindex="-1"
60+
@click.stop.prevent="onInput(item)"
61+
>
62+
<VIcon icon="$delete" size="x-small" />
63+
</button>
64+
</template>
5365
{{ itemTitle(item) }}
5466
</v-chip>
5567
<span v-else class="v-select__selection-text">
@@ -69,6 +81,7 @@
6981
:disabled="isInteractive"
7082
origin="top"
7183
location="bottom"
84+
v-bind="menuProps"
7285
>
7386
<v-sheet
7487
ref="menuContentRef"
@@ -159,7 +172,7 @@
159172
v-for="(item, i) in listItems"
160173
:key="item.key"
161174
@click="onInput(item.model)"
162-
:value="i"
175+
:value="item.key"
163176
:class="{
164177
'pending-selection': pendingSelection === i,
165178
}"
@@ -245,6 +258,15 @@
245258
&.c-select--is-menu-active .v-field__append-inner > .v-icon {
246259
transform: rotate(180deg);
247260
}
261+
262+
:has(.v-select__selection--selected) {
263+
.v-field__input {
264+
caret-color: transparent;
265+
}
266+
.v-select__selection:not(.v-select__selection--selected) {
267+
opacity: var(--v-medium-emphasis-opacity);
268+
}
269+
}
248270
}
249271
250272
.c-select__menu-content {
@@ -402,7 +424,7 @@ import {
402424
ViewModelCollection,
403425
ModelCollectionNavigationProperty,
404426
} from "coalesce-vue";
405-
import { VTextField } from "vuetify/components";
427+
import { VMenu, VTextField } from "vuetify/components";
406428
import { Intersect } from "vuetify/directives";
407429
408430
/* DEV NOTES:
@@ -507,6 +529,10 @@ const props = withDefaults(
507529
rules?: Array<TypedValidationRule<SelectedPkType>>;
508530
509531
itemTitle?: (item: SelectedModelTypeSingle) => string | null;
532+
533+
/** Props to pass to the underlying v-menu component */
534+
menuProps?: VMenu["$props"];
535+
510536
create?: {
511537
getLabel: (
512538
search: string,
@@ -595,6 +621,7 @@ const mainValue = ref("");
595621
const createItemLoading = ref(false);
596622
const createItemError = ref("" as string | null);
597623
const pendingSelection = ref(0);
624+
const selectionIndex = ref(-1);
598625
599626
/** The models representing the current selected item(s)
600627
* in the case that only the PK was provided to the component.
@@ -1113,15 +1140,32 @@ function onMenuContentBlur(event: FocusEvent): void {
11131140
function onInputKey(event: KeyboardEvent): void {
11141141
if (!isInteractive.value) return;
11151142
1143+
const input = mainInputRef.value;
1144+
const selectionStart = input?.selectionStart;
1145+
const value = internalModelValue.value;
1146+
const length = value.length;
1147+
11161148
switch (event.key.toLowerCase()) {
11171149
case "delete":
11181150
case "backspace":
11191151
if (!menuOpen.value) {
11201152
if (effectiveMultiple.value) {
1121-
// Delete only the last item when deleting items with multi-select
1122-
const lastItem = internalModelValue.value.at(-1);
1123-
if (lastItem) {
1124-
onInput(lastItem, true);
1153+
if (length == 1) {
1154+
onInput(value[0], true);
1155+
} else if (selectionIndex.value >= 0) {
1156+
// If we have a selection index, remove that specific item
1157+
const itemToRemove = value[selectionIndex.value];
1158+
if (itemToRemove) {
1159+
onInput(itemToRemove, true);
1160+
// Adjust selection index after removal
1161+
const originalSelectionIndex = selectionIndex.value;
1162+
selectionIndex.value =
1163+
originalSelectionIndex >= length - 1
1164+
? length - 2
1165+
: originalSelectionIndex;
1166+
}
1167+
} else if (event.key.toLowerCase() === "backspace") {
1168+
selectionIndex.value = length - 1;
11251169
}
11261170
} else {
11271171
onInput(null, true);
@@ -1130,11 +1174,57 @@ function onInputKey(event: KeyboardEvent): void {
11301174
event.preventDefault();
11311175
}
11321176
return;
1177+
case "arrowleft":
1178+
case "left":
1179+
if (!effectiveMultiple.value || menuOpen.value) return;
1180+
1181+
if (
1182+
selectionIndex.value < 0 &&
1183+
selectionStart != null &&
1184+
selectionStart > 0
1185+
)
1186+
return;
1187+
1188+
const prev =
1189+
selectionIndex.value > -1 ? selectionIndex.value - 1 : length - 1;
1190+
1191+
if (internalModelValue.value[prev]) {
1192+
selectionIndex.value = prev;
1193+
} else {
1194+
const searchLength = search.value?.length ?? 0;
1195+
selectionIndex.value = -1;
1196+
input?.setSelectionRange(searchLength, searchLength);
1197+
}
1198+
event.stopPropagation();
1199+
event.preventDefault();
1200+
return;
1201+
case "arrowright":
1202+
case "right":
1203+
if (!effectiveMultiple.value || menuOpen.value) return;
1204+
1205+
if (selectionIndex.value < 0) return;
1206+
1207+
const next = selectionIndex.value + 1;
1208+
1209+
if (internalModelValue.value[next]) {
1210+
selectionIndex.value = next;
1211+
} else {
1212+
selectionIndex.value = -1;
1213+
input?.setSelectionRange(0, 0);
1214+
}
1215+
event.stopPropagation();
1216+
event.preventDefault();
1217+
return;
11331218
case "esc":
11341219
case "escape":
1220+
if (!menuOpen.value && selectionIndex.value >= 0) {
1221+
selectionIndex.value = -1;
1222+
mainInputRef.value?.focus();
1223+
} else {
1224+
closeMenu(true);
1225+
}
11351226
event.stopPropagation();
11361227
event.preventDefault();
1137-
closeMenu(true);
11381228
return;
11391229
case " ":
11401230
case "enter":
@@ -1208,7 +1298,7 @@ function onIntersect(isIntersecting: boolean) {
12081298
// Doesn't work reliably without a small delay
12091299
setTimeout(() => {
12101300
mainInputRef.value?.focus();
1211-
}, 10);
1301+
}, 50);
12121302
}
12131303
12141304
async function openMenu(select?: boolean): Promise<void> {
@@ -1223,6 +1313,7 @@ async function openMenu(select?: boolean): Promise<void> {
12231313
12241314
if (menuOpen.value) return;
12251315
menuOpen.value = true;
1316+
selectionIndex.value = -1; // Reset selection index when menu opens
12261317
12271318
if (props.reloadOnOpen) listCaller();
12281319
@@ -1267,6 +1358,7 @@ function closeMenu(force = false): void {
12671358
12681359
menuOpenForced.value = false;
12691360
menuOpen.value = false;
1361+
selectionIndex.value = -1;
12701362
mainInputRef.value?.focus();
12711363
}
12721364

0 commit comments

Comments
 (0)