Skip to content

Commit d0a7e70

Browse files
committed
Merge branch 'opt-group_fix'
2 parents 3839cec + 59f7d67 commit d0a7e70

23 files changed

+119
-85
lines changed

CHANGELOG.md

+2
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@
1212
* Fix regression "no choices to choose from"/"no results found" notice did not reliably trigger. [#1185](https://github.com/Choices-js/Choices/issues/1185) [#1191](https://github.com/Choices-js/Choices/issues/1191)
1313
* Fix regression of `UnhighlightItem` event not firing [#1173](https://github.com/Choices-js/Choices/issues/1173)
1414
* Fix `clearChoices()` would remove items, and clear the search flag.
15+
* Fixes for opt-group handling/rendering
16+
* Fix `removeChoice()` did not properly remove a choice which was part of a group
1517

1618
### Chore
1719
* Add e2e tests for "no choices" behavior to match v10

public/test/select-multiple/index.html

+3-1
Original file line numberDiff line numberDiff line change
@@ -914,7 +914,9 @@ <h2>Select multiple inputs</h2>
914914
multiple
915915
>
916916
<option value="Choice 1">Choice 1</option>
917-
<option value="Choice 2">Choice 2</option>
917+
<optgroup label="test">
918+
<option value="Choice 2">Choice 2</option>
919+
</optgroup>
918920
<option value="Choice 3">Choice 3</option>
919921
</select>
920922
<button class="destroy">Destroy</button>

public/test/select-one/index.html

+5-3
Original file line numberDiff line numberDiff line change
@@ -870,7 +870,9 @@ <h2>Select one inputs</h2>
870870
id="choices-new-destroy-init"
871871
>
872872
<option value="Choice 1">Choice 1</option>
873-
<option value="Choice 2">Choice 2</option>
873+
<optgroup label="test">
874+
<option value="Choice 2">Choice 2</option>
875+
</optgroup>
874876
<option value="Choice 3">Choice 3</option>
875877
</select>
876878
<button class="destroy">Destroy</button>
@@ -926,7 +928,7 @@ <h2>Select one inputs</h2>
926928
</script>
927929
</div>
928930

929-
<div data-test-hook="autocomplete">
931+
<div data-test-hook="autocomplete">
930932
<label for="choices-autocomplete">Autocomplete example</label>
931933
<select
932934
class="form-control"
@@ -966,7 +968,7 @@ <h2>Select one inputs</h2>
966968
</script>
967969
</div>
968970

969-
971+
</div>
970972
</div>
971973
</body>
972974
</html>

src/scripts/choices.ts

+27-24
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
/* eslint-disable @typescript-eslint/no-explicit-any */
21
import { activateChoices, addChoice, removeChoice, filterChoices } from './actions/choices';
32
import { addGroup } from './actions/groups';
43
import { addItem, highlightItem, removeItem } from './actions/items';
@@ -28,7 +27,7 @@ import Store from './store/store';
2827
import { coerceBool, mapInputToChoice } from './lib/choice-input';
2928
import { ChoiceFull } from './interfaces/choice-full';
3029
import { GroupFull } from './interfaces/group-full';
31-
import { EventType, KeyCodeMap, PassedElementType, PassedElementTypes } from './interfaces';
30+
import { EventChoiceValueType, EventType, KeyCodeMap, PassedElementType, PassedElementTypes } from './interfaces';
3231
import { EventChoice } from './interfaces/event-choice';
3332
import { NoticeType, NoticeTypes, Templates } from './interfaces/templates';
3433
import { isHtmlInputElement, isHtmlSelectElement } from './lib/html-guard-statements';
@@ -539,13 +538,10 @@ class Choices {
539538
return this;
540539
}
541540

542-
getValue(valueOnly = false): string[] | EventChoice[] | EventChoice | string {
543-
const values = this._store.items.reduce<any[]>((selectedItems, item) => {
544-
const itemValue = valueOnly ? item.value : this._getChoiceForOutput(item);
545-
selectedItems.push(itemValue);
546-
547-
return selectedItems;
548-
}, []);
541+
getValue<B extends boolean = false>(valueOnly?: B): EventChoiceValueType<B> | EventChoiceValueType<B>[] {
542+
const values = this._store.items.map((item) => {
543+
return (valueOnly ? item.value : this._getChoiceForOutput(item)) as EventChoiceValueType<B>;
544+
});
549545

550546
return this._isSelectOneElement || this.config.singleModeForMultiSelect ? values[0] : values;
551547
}
@@ -788,16 +784,21 @@ class Choices {
788784

789785
this.clearStore(false);
790786

791-
choicesFromOptions.forEach((groupOrChoice) => {
792-
if ('choices' in groupOrChoice) {
793-
return;
794-
}
795-
const choice = groupOrChoice;
787+
const updateChoice = (choice: ChoiceFull): void => {
796788
if (deselectAll) {
797789
this._store.dispatch(removeItem(choice));
798790
} else if (existingItems[choice.value]) {
799791
choice.selected = true;
800792
}
793+
};
794+
795+
choicesFromOptions.forEach((groupOrChoice) => {
796+
if ('choices' in groupOrChoice) {
797+
groupOrChoice.choices.forEach(updateChoice);
798+
799+
return;
800+
}
801+
updateChoice(groupOrChoice);
801802
});
802803

803804
/* @todo only generate add events for the added options instead of all
@@ -984,7 +985,7 @@ class Choices {
984985
if (!this._hasNonChoicePlaceholder && !isSearching && this._isSelectOneElement) {
985986
// If we have a placeholder choice along with groups
986987
renderChoices(
987-
activeChoices.filter((choice) => choice.placeholder && !choice.groupId),
988+
activeChoices.filter((choice) => choice.placeholder && !choice.group),
988989
false,
989990
undefined,
990991
);
@@ -995,6 +996,13 @@ class Choices {
995996
if (config.shouldSort) {
996997
activeGroups.sort(config.sorter);
997998
}
999+
// render Choices without group first, regardless of sort, otherwise they won't be distinguishable
1000+
// from the last group
1001+
renderChoices(
1002+
activeChoices.filter((choice) => !choice.placeholder && !choice.group),
1003+
false,
1004+
undefined,
1005+
);
9981006

9991007
activeGroups.forEach((group) => {
10001008
const groupChoices = renderableChoices(group.choices);
@@ -1160,13 +1168,8 @@ class Choices {
11601168
}
11611169
}
11621170

1163-
_getChoiceForOutput(choice?: ChoiceFull, keyCode?: number): EventChoice | undefined {
1164-
if (!choice) {
1165-
return undefined;
1166-
}
1167-
1168-
const group = choice.groupId ? this._store.getGroupById(choice.groupId) : null;
1169-
1171+
// eslint-disable-next-line class-methods-use-this
1172+
_getChoiceForOutput(choice: ChoiceFull, keyCode?: number): EventChoice {
11701173
return {
11711174
id: choice.id,
11721175
highlighted: choice.highlighted,
@@ -1178,7 +1181,7 @@ class Choices {
11781181
label: choice.label,
11791182
placeholder: choice.placeholder,
11801183
value: choice.value,
1181-
groupValue: group && group.label ? group.label : undefined,
1184+
groupValue: choice.group ? choice.group.label : undefined,
11821185
element: choice.element,
11831186
keyCode,
11841187
};
@@ -2131,7 +2134,7 @@ class Choices {
21312134
group.id = this._lastAddedGroupId;
21322135

21332136
group.choices.forEach((item: ChoiceFull) => {
2134-
item.groupId = group.id;
2137+
item.group = group;
21352138
if (group.disabled) {
21362139
item.disabled = true;
21372140
}

src/scripts/components/wrapped-select.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@ export default class WrappedSelect extends WrappedElement<HTMLSelectElement> {
7777

7878
return {
7979
id: 0,
80-
groupId: 0,
80+
group: null,
8181
score: 0,
8282
rank: 0,
8383
value: option.value,

src/scripts/interfaces/choice-full.ts

+5-5
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
1-
/* eslint-disable @typescript-eslint/no-explicit-any */
21
import { StringUntrusted } from './string-untrusted';
3-
4-
export type CustomProperties = Record<string, any> | string;
2+
import { Types } from './types';
3+
// eslint-disable-next-line import/no-cycle
4+
import { GroupFull } from './group-full';
55

66
/*
77
A disabled choice appears in the choice dropdown but cannot be selected
@@ -16,11 +16,11 @@ export interface ChoiceFull {
1616
choiceEl?: HTMLElement;
1717
labelClass?: Array<string>;
1818
labelDescription?: string;
19-
customProperties?: CustomProperties;
19+
customProperties?: Types.CustomProperties;
2020
disabled: boolean;
2121
active: boolean;
2222
elementId?: string;
23-
groupId: number;
23+
group: GroupFull | null;
2424
label: StringUntrusted | string;
2525
placeholder: boolean;
2626
selected: boolean;

src/scripts/interfaces/event-choice.ts

+2
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import { InputChoice } from './input-choice';
22

3+
export type EventChoiceValueType<B extends boolean> = B extends true ? string : EventChoice;
4+
35
export interface EventChoice extends InputChoice {
46
element?: HTMLOptionElement | HTMLOptGroupElement;
57
groupValue?: string;

src/scripts/interfaces/group-full.ts

+1-2
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
1-
/* eslint-disable @typescript-eslint/no-explicit-any */
2-
1+
// eslint-disable-next-line import/no-cycle
32
import { ChoiceFull } from './choice-full';
43

54
export interface GroupFull {
+3-3
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,17 @@
1-
/* eslint-disable @typescript-eslint/no-explicit-any */
2-
31
import { StringUntrusted } from './string-untrusted';
2+
import { Types } from './types';
43

54
export interface InputChoice {
65
id?: number;
76
highlighted?: boolean;
87
labelClass?: string | Array<string>;
98
labelDescription?: string;
10-
customProperties?: Record<string, any> | string;
9+
customProperties?: Types.CustomProperties;
1110
disabled?: boolean;
1211
active?: boolean;
1312
label: StringUntrusted | string;
1413
placeholder?: boolean;
1514
selected?: boolean;
15+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
1616
value: any;
1717
}

src/scripts/interfaces/input-group.ts

-2
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,3 @@
1-
/* eslint-disable @typescript-eslint/no-explicit-any */
2-
31
import { InputChoice } from './input-choice';
42
import { StringUntrusted } from './string-untrusted';
53

src/scripts/interfaces/types.ts

+2
Original file line numberDiff line numberDiff line change
@@ -16,4 +16,6 @@ export namespace Types {
1616
label?: StringUntrusted | string;
1717
}
1818
export type ValueOf<T extends object> = T[keyof T];
19+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
20+
export type CustomProperties = Record<string, any> | string;
1921
}

src/scripts/lib/choice-input.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ export const mapInputToChoice = <T extends string | InputChoice | InputGroup>(
6464

6565
const result: ChoiceFull = {
6666
id: 0, // actual ID will be assigned during _addChoice
67-
groupId: 0, // actual ID will be assigned during _addGroup but before _addChoice
67+
group: null, // actual group will be assigned during _addGroup but before _addChoice
6868
score: 0, // used in search
6969
rank: 0, // used in search, stable sort order
7070
value: choice.value,

src/scripts/lib/utils.ts

+1-2
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,3 @@
1-
/* eslint-disable @typescript-eslint/no-explicit-any */
2-
31
import { EventTypes } from '../interfaces/event-type';
42
import { StringUntrusted } from '../interfaces/string-untrusted';
53
import { StringPreEscaped } from '../interfaces/string-pre-escaped';
@@ -179,6 +177,7 @@ export const cloneObject = <T>(obj: T): T => (obj !== undefined ? JSON.parse(JSO
179177
/**
180178
* Returns an array of keys present on the first but missing on the second object
181179
*/
180+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
182181
export const diff = (a: Record<string, any>, b: Record<string, any>): string[] => {
183182
const aKeys = Object.keys(a).sort();
184183
const bKeys = Object.keys(b).sort();

src/scripts/reducers/choices.ts

+3
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,9 @@ export default function choices(s: StateType, action: ActionTypes, context?: Opt
2222
case ActionType.REMOVE_CHOICE: {
2323
action.choice.choiceEl = undefined;
2424

25+
if (action.choice.group) {
26+
action.choice.group.choices = action.choice.group.choices.filter((obj) => obj.id !== action.choice.id);
27+
}
2528
state = state.filter((obj) => obj.id !== action.choice.id);
2629
break;
2730
}

src/scripts/reducers/items.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -54,8 +54,8 @@ export default function items(s: StateType, action: ActionTypes, context?: Optio
5454
}
5555

5656
case ActionType.REMOVE_CHOICE: {
57-
state = state.filter((item) => item.id !== action.choice.id);
5857
removeItem(action.choice);
58+
state = state.filter((item) => item.id !== action.choice.id);
5959
break;
6060
}
6161

src/scripts/templates.ts

+4-2
Original file line numberDiff line numberDiff line change
@@ -268,7 +268,6 @@ const templates: TemplatesInterface = {
268268
label = escapeForTemplate(allowHTML, label);
269269
label += ` (${groupName})`;
270270
label = { trusted: label };
271-
div.dataset.groupId = `${choice.groupId}`;
272271
}
273272

274273
let describedBy: HTMLElement = div;
@@ -300,14 +299,17 @@ const templates: TemplatesInterface = {
300299
addClassesToElement(div, placeholder);
301300
}
302301

303-
div.setAttribute('role', choice.groupId ? 'treeitem' : 'option');
302+
div.setAttribute('role', choice.group ? 'treeitem' : 'option');
304303

305304
div.dataset.choice = '';
306305
div.dataset.id = choice.id as unknown as string;
307306
div.dataset.value = rawValue;
308307
if (selectText) {
309308
div.dataset.selectText = selectText;
310309
}
310+
if (choice.group) {
311+
div.dataset.groupId = `${choice.group.id}`;
312+
}
311313

312314
assignCustomProperties(div, choice, false);
313315

test-e2e/tests/select-multiple.spec.ts

+6-4
Original file line numberDiff line numberDiff line change
@@ -683,9 +683,10 @@ describe(`Choices - select multiple`, () => {
683683
await suite.startWithClick();
684684

685685
await expect(suite.dropdown.locator('.choices__group[data-group]')).toHaveCount(1);
686-
expect(
687-
await suite.selectableChoices.filter({ hasNot: page.locator('[data-group-id]') }).count(),
688-
).toBeGreaterThan(0);
686+
expect(await suite.dropdown.locator('.choices__item--choice[data-group-id]').count()).toEqual(1);
687+
expect(await suite.dropdown.locator('.choices__item--choice:not([data-group-id])').count()).toBeGreaterThan(
688+
1,
689+
);
689690
});
690691
});
691692
});
@@ -960,7 +961,8 @@ describe(`Choices - select multiple`, () => {
960961
await suite.group.locator('.destroy').click({ force: true });
961962
await suite.advanceClock();
962963

963-
await expect(suite.group.locator('select > option')).toHaveCount(3);
964+
await expect(suite.group.locator('select > optgroup > option')).toHaveCount(1);
965+
await expect(suite.group.locator('select > option')).toHaveCount(2);
964966

965967
await suite.group.locator('.init').click({ force: true });
966968
await suite.advanceClock();

test-e2e/tests/select-one.spec.ts

+6-4
Original file line numberDiff line numberDiff line change
@@ -528,9 +528,10 @@ describe(`Choices - select one`, () => {
528528
await suite.startWithClick();
529529

530530
await expect(suite.dropdown.locator('.choices__group[data-group]')).toHaveCount(1);
531-
expect(
532-
await suite.selectableChoices.filter({ hasNot: page.locator('[data-group-id]') }).count(),
533-
).toBeGreaterThan(0);
531+
expect(await suite.dropdown.locator('.choices__item--choice[data-group-id]').count()).toEqual(1);
532+
expect(await suite.dropdown.locator('.choices__item--choice:not([data-group-id])').count()).toBeGreaterThan(
533+
1,
534+
);
534535
});
535536
});
536537
});
@@ -834,7 +835,8 @@ describe(`Choices - select one`, () => {
834835
await suite.group.locator('.destroy').click({ force: true });
835836
await suite.advanceClock();
836837

837-
await expect(suite.group.locator('select > option')).toHaveCount(3);
838+
await expect(suite.group.locator('select > optgroup > option')).toHaveCount(1);
839+
await expect(suite.group.locator('select > option')).toHaveCount(2);
838840

839841
await suite.group.locator('.init').click({ force: true });
840842
await suite.advanceClock();

0 commit comments

Comments
 (0)