Skip to content

Commit 7dab04f

Browse files
Elijbetdriskull
andauthored
feat(combobox): add selectAll toggle property (#11721)
**Related Issue:** #2311 ## Summary Provides a `Select All` checkbox in the `calcite-combobox` by default for `selection-mode="multiple"` as a convenient shortcut for users to quickly select or deselect all items in the list. The `indeterminate` state for this checkbox is a visual and programmatic state that indicates a checkbox is neither fully checked nor unchecked. It represents a "partial selection" in scenario where a checkbox is associated with a group of child checkboxes, and some (but not all) of the child checkboxes are selected. --------- Co-authored-by: Matt Driscoll <[email protected]>
1 parent 7410426 commit 7dab04f

File tree

11 files changed

+631
-99
lines changed

11 files changed

+631
-99
lines changed

packages/calcite-components/src/components/combobox-item/combobox-item.scss

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,13 @@
33
*
44
* These properties can be overridden using the component's tag as selector.
55
*
6+
* @prop --calcite-combobox-item-border-color: Specifies the component's border color.
67
* @prop --calcite-combobox-text-color: Specifies the component's text and `icon` color.
78
* @prop --calcite-combobox-text-color-hover: Specifies the component's text and `icon` color when hovered.
89
* @prop --calcite-combobox-item-background-color-active: Specifies the component's background color when active.
910
* @prop --calcite-combobox-item-background-color-hover: Specifies the component's background color when hovered.
11+
* @prop --calcite-combobox-item-shadow: Specifies the component's shadow.
12+
1013
* @prop --calcite-combobox-selected-icon-color: Specifies the component's selected indicator icon color.
1114
* @prop --calcite-combobox-description-text-color: Specifies the component's `description` and `shortHeading` text color.
1215
* @prop --calcite-combobox-description-text-color-press: Specifies the component's `description` and `shortHeading` text color when hovered.
@@ -118,7 +121,8 @@ ul:focus {
118121
color: var(--calcite-color-border-input);
119122
}
120123

121-
:host([selected]) .icon {
124+
:host([selected]) .icon,
125+
:host([indeterminate]) .icon {
122126
color: var(--calcite-combobox-selected-icon-color, var(--calcite-color-brand));
123127
}
124128

packages/calcite-components/src/components/combobox-item/combobox-item.tsx

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import { getIconScale, warnIfMissingRequiredProp } from "../../utils/component";
1414
import { IconNameOrString } from "../icon/interfaces";
1515
import { slotChangeHasContent } from "../../utils/dom";
1616
import { highlightText } from "../../utils/text";
17-
import { CSS, SLOTS } from "./resources";
17+
import { CSS, ICONS, SLOTS } from "./resources";
1818
import { styles } from "./combobox-item.scss";
1919

2020
declare global {
@@ -158,6 +158,13 @@ export class ComboboxItem extends LitElement implements InteractiveComponent {
158158
* */
159159
@property({ reflect: true }) itemHidden = false;
160160

161+
/**
162+
* When `selectionMode` is `"multiple"` or `"ancestors"` and one or more, but not all `calcite-combobox-item`s are selected, displays an indeterminate "select all" checkbox.
163+
*
164+
* @private
165+
*/
166+
@property({ reflect: true }) indeterminate = false;
167+
161168
//#endregion
162169

163170
//#region Events
@@ -279,14 +286,16 @@ export class ComboboxItem extends LitElement implements InteractiveComponent {
279286
shortHeading,
280287
} = this;
281288
const isSingleSelect = isSingleLike(this.selectionMode);
282-
const icon = disabled || isSingleSelect ? undefined : "check-square-f";
289+
const icon = disabled || isSingleSelect ? undefined : ICONS.checked;
283290
const selectionIcon = isSingleSelect
284291
? this.selected
285-
? "circle-inset-large"
286-
: "circle"
287-
: this.selected
288-
? "check-square-f"
289-
: "square";
292+
? ICONS.selectedSingle
293+
: ICONS.circle
294+
: this.indeterminate
295+
? ICONS.indeterminate
296+
: this.selected
297+
? ICONS.checked
298+
: ICONS.unchecked;
290299
const headingText = heading || textLabel;
291300
const itemLabel = label || value;
292301

packages/calcite-components/src/components/combobox-item/resources.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,14 @@ export const CSS = {
1515
heading: "heading",
1616
};
1717

18+
export const ICONS = {
19+
checked: "check-square-f",
20+
circle: "circle",
21+
indeterminate: "minus-square-f",
22+
selectedSingle: "circle-inset-large",
23+
unchecked: "square",
24+
};
25+
1826
export const SLOTS = {
1927
contentEnd: "content-end",
2028
contentStart: "content-start",

packages/calcite-components/src/components/combobox/assets/t9n/messages.en.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
{
22
"all": "All",
33
"allSelected": "All selected",
4+
"selectAll": "Select All",
45
"clear": "Clear value",
56
"removeTag": "Remove tag",
67
"selected": "selected"

packages/calcite-components/src/components/combobox/assets/t9n/messages.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
{
22
"all": "All",
33
"allSelected": "All selected",
4+
"selectAll": "Select All",
45
"clear": "Clear value",
56
"removeTag": "Remove tag",
67
"selected": "selected"

packages/calcite-components/src/components/combobox/combobox.e2e.ts

Lines changed: 229 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -752,7 +752,13 @@ describe("calcite-combobox", () => {
752752
</calcite-combobox>`,
753753
);
754754

755-
const item = await page.find("calcite-combobox-item");
755+
await page.waitForChanges();
756+
757+
const combobox = await page.find("calcite-combobox");
758+
await combobox.callMethod("componentOnReady");
759+
expect(combobox).not.toBeNull();
760+
761+
const item = await page.find("calcite-combobox-item#item-0");
756762
let a11yItem = await page.find(`calcite-combobox >>> ul.${CSS.screenReadersOnly} li`);
757763

758764
expect(a11yItem).not.toBeNull();
@@ -794,7 +800,7 @@ describe("calcite-combobox", () => {
794800
item.setProperty("disabled", true);
795801
await page.waitForChanges();
796802
await page.waitForTimeout(DEBOUNCE.nextTick);
797-
a11yItem = await page.find(`calcite-combobox >>> ul.${CSS.screenReadersOnly} li`);
803+
a11yItem = await page.find(`calcite-combobox >>> ul.${CSS.screenReadersOnly} li:nth-of-type(2)`);
798804

799805
expect(a11yItem).toBeNull();
800806
});
@@ -2975,6 +2981,227 @@ describe("calcite-combobox", () => {
29752981
expect((await combobox.getProperty("selectedItems")).length).toBe(1);
29762982
});
29772983

2984+
describe("selectAllEnabled", async () => {
2985+
let page: E2EPage;
2986+
2987+
beforeEach(async () => {
2988+
page = await newE2EPage();
2989+
await page.setContent(
2990+
html`<calcite-combobox selection-mode="multiple" select-all-enabled>
2991+
<calcite-combobox-item value="Trees" text-label="Trees">
2992+
<calcite-combobox-item value="Pine" text-label="Pine">
2993+
<calcite-combobox-item value="Pine Nested" text-label="Pine Nested"></calcite-combobox-item>
2994+
</calcite-combobox-item>
2995+
<calcite-combobox-item value="Sequoia" text-label="Sequoia"></calcite-combobox-item>
2996+
</calcite-combobox-item>
2997+
<calcite-combobox-item value="Flowers" text-label="Flowers">
2998+
<calcite-combobox-item value="Daffodil" text-label="Daffodil"></calcite-combobox-item>
2999+
<calcite-combobox-item value="Nasturtium" text-label="Nasturtium"></calcite-combobox-item>
3000+
</calcite-combobox-item>
3001+
</calcite-combobox>`,
3002+
);
3003+
await page.waitForChanges();
3004+
});
3005+
3006+
async function testToggleAllItems(
3007+
page: E2EPage,
3008+
toggleAction: ([selectAll, combobox]: [E2EElement, E2EElement]) => Promise<void>,
3009+
): Promise<void> {
3010+
const combobox = await page.find("calcite-combobox");
3011+
await combobox.click();
3012+
expect(await combobox.getProperty("open")).toBe(true);
3013+
3014+
const selectAll = await page.find(`calcite-combobox >>> .${CSS.selectAll}`);
3015+
await toggleAction([selectAll, combobox]);
3016+
3017+
let allComboboxItems = await findAll(page, "calcite-combobox-item");
3018+
for (const item of allComboboxItems) {
3019+
expect(await item.getProperty("selected")).toBe(true);
3020+
}
3021+
expect(await page.find(`calcite-combobox >>> calcite-chip.${CSS.allSelected}`)).toBeDefined();
3022+
3023+
await toggleAction([selectAll, combobox]);
3024+
3025+
allComboboxItems = await findAll(page, "calcite-combobox-item");
3026+
for (const item of allComboboxItems) {
3027+
expect(await item.getProperty("selected")).toBe(false);
3028+
}
3029+
3030+
const chip = await page.find(`calcite-combobox >>> calcite-chip.${CSS.allSelected}`);
3031+
expect(chip.classList.contains(`${CSS.chipInvisible}`)).toBe(true);
3032+
}
3033+
3034+
it("should toggle all items on and off with a click", async () => {
3035+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
3036+
await testToggleAllItems(page, async ([selectAll, _combobox]) => {
3037+
await selectAll.click();
3038+
});
3039+
});
3040+
3041+
it("should toggle all items on and off with KeyDown press `enter`", async () => {
3042+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
3043+
await testToggleAllItems(page, async ([_selectAll, combobox]) => {
3044+
await combobox.press("Enter");
3045+
});
3046+
});
3047+
3048+
it("indeterminate state", async () => {
3049+
const combobox = await page.find("calcite-combobox");
3050+
await combobox.click();
3051+
expect(await combobox.getProperty("open")).toBe(true);
3052+
3053+
await (await combobox.find("calcite-combobox-item[value=Sequoia]")).click();
3054+
3055+
const selectAll = await page.find(`calcite-combobox >>> calcite-combobox-item.${CSS.selectAll}`);
3056+
expect(await selectAll.getProperty("indeterminate")).toBe(true);
3057+
expect(await page.find(`calcite-combobox >>> calcite-chip[value=Sequoia]`)).toBeDefined();
3058+
3059+
await (await combobox.find("calcite-combobox-item[value=Flowers]")).click();
3060+
3061+
expect(await selectAll.getProperty("indeterminate")).toBe(true);
3062+
expect(await page.find(`calcite-combobox >>> calcite-chip[value=Flowers]`)).toBeDefined();
3063+
3064+
const chip = await page.find(`calcite-combobox >>> calcite-chip.${CSS.allSelected}`);
3065+
expect(chip.classList.contains(`${CSS.chipInvisible}`)).toBe(true);
3066+
3067+
await selectAll.click();
3068+
expect(await selectAll.getProperty("indeterminate")).toBe(false);
3069+
expect(await selectAll.getProperty("selected")).toBe(true);
3070+
3071+
expect(await page.find(`calcite-combobox >>> calcite-chip.${CSS.allSelected}`)).toBeDefined();
3072+
expect(await page.find(`calcite-combobox >>> calcite-chip[value=Sequoia]`)).toBeNull();
3073+
expect(await page.find(`calcite-combobox >>> calcite-chip[value=Flowers]`)).toBeNull();
3074+
3075+
const allComboboxItems = await findAll(page, "calcite-combobox-item");
3076+
for (const item of allComboboxItems) {
3077+
expect(await item.getProperty("selected")).toBe(true);
3078+
}
3079+
});
3080+
3081+
async function testToggleListItems(
3082+
page: E2EPage,
3083+
toggleAction: ([listItem, combobox]: [E2EElement, E2EElement]) => Promise<void>,
3084+
): Promise<void> {
3085+
const messages = await import("./assets/t9n/messages.json");
3086+
const combobox = await page.find("calcite-combobox");
3087+
await combobox.click();
3088+
expect(await combobox.getProperty("open")).toBe(true);
3089+
3090+
const allComboboxItems = await findAll(page, "calcite-combobox-item");
3091+
for (const item of allComboboxItems) {
3092+
item.setProperty("selected", true);
3093+
}
3094+
await page.waitForChanges();
3095+
expect(await page.find(`calcite-combobox >>> calcite-chip[value="${messages.allSelected}"]`)).toBeDefined();
3096+
3097+
const listItem = await combobox.find("calcite-combobox-item[value=Sequoia]");
3098+
await toggleAction([listItem, combobox]);
3099+
3100+
const selectAll = await page.find(`calcite-combobox >>> calcite-combobox-item.${CSS.selectAll}`);
3101+
expect(await selectAll.getProperty("indeterminate")).toBe(true);
3102+
expect(await page.find(`calcite-combobox >>> calcite-chip[value=Sequoia]`)).toBeDefined();
3103+
3104+
await toggleAction([listItem, combobox]);
3105+
3106+
expect(await selectAll.getProperty("indeterminate")).toBe(false);
3107+
expect(await selectAll.getProperty("selected")).toBe(true);
3108+
expect(await page.find(`calcite-combobox >>> calcite-chip[value=Sequoia]`)).toBeNull();
3109+
3110+
await toggleAction([listItem, combobox]);
3111+
3112+
expect(await selectAll.getProperty("indeterminate")).toBe(true);
3113+
expect(await page.find(`calcite-combobox >>> calcite-chip[value=Sequoia]`)).toBeDefined();
3114+
}
3115+
3116+
it("should toggle indeterminate state to `All Selected` when list items are toggled with a click", async () => {
3117+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
3118+
await testToggleListItems(page, async ([listItem, _combobox]) => {
3119+
await listItem.click();
3120+
});
3121+
});
3122+
3123+
it("should toggle indeterminate state to `All Selected` when list items are toggled with a keydown `Enter`", async () => {
3124+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
3125+
await testToggleAllItems(page, async ([_listItem, combobox]) => {
3126+
await combobox.press("Enter");
3127+
});
3128+
});
3129+
3130+
it("should have indeterminate state when some items are initialized selected", async () => {
3131+
page = await newE2EPage();
3132+
await page.setContent(
3133+
html`<calcite-combobox selection-mode="multiple" select-all-enabled>
3134+
<calcite-combobox-item value="Trees" text-label="Trees" selected>
3135+
<calcite-combobox-item value="Pine" text-label="Pine" />
3136+
</calcite-combobox-item>
3137+
</calcite-combobox>`,
3138+
);
3139+
await page.waitForChanges();
3140+
const selectAll = await page.find(`calcite-combobox >>> calcite-combobox-item.${CSS.selectAll}`);
3141+
expect(await selectAll.getProperty("indeterminate")).toBe(true);
3142+
});
3143+
3144+
it("should have selectAll state true when all items are initialized selected", async () => {
3145+
page = await newE2EPage();
3146+
await page.setContent(
3147+
html`<calcite-combobox selection-mode="multiple" select-all-enabled>
3148+
<calcite-combobox-item value="Trees" text-label="Trees" selected>
3149+
<calcite-combobox-item value="Pine" text-label="Pine" selected />
3150+
</calcite-combobox-item>
3151+
</calcite-combobox>`,
3152+
);
3153+
await page.waitForChanges();
3154+
const selectAll = await page.find(`calcite-combobox >>> calcite-combobox-item.${CSS.selectAll}`);
3155+
expect(await selectAll.getProperty("selected")).toBe(true);
3156+
});
3157+
3158+
it("should bring back all the chips except `All Selected` when one item is deselected", async () => {
3159+
page = await newE2EPage();
3160+
await page.setContent(
3161+
html`<calcite-combobox selection-mode="multiple" select-all-enabled>
3162+
<calcite-combobox-item value="Trees" text-label="Trees" selected>
3163+
<calcite-combobox-item value="Pine" text-label="Maple" selected />
3164+
<calcite-combobox-item value="Pine" text-label="Pine" selected />
3165+
</calcite-combobox-item>
3166+
</calcite-combobox>`,
3167+
);
3168+
await page.waitForChanges();
3169+
const messages = await import("./assets/t9n/messages.json");
3170+
3171+
const combobox = await page.find("calcite-combobox");
3172+
await combobox.click();
3173+
await page.waitForChanges();
3174+
3175+
const listItem = await combobox.find("calcite-combobox-item[value=Pine]");
3176+
await listItem.click();
3177+
3178+
expect(await page.find(`calcite-combobox >>> calcite-chip[value="Trees"]`)).toBeDefined();
3179+
expect(await page.find(`calcite-combobox >>> calcite-chip[value="Maple"]`)).toBeDefined();
3180+
expect(await page.find(`calcite-combobox >>> calcite-chip[value="${messages.allSelected}"]`)).toBeNull();
3181+
});
3182+
3183+
it("should update aria-selected on items when toggling 'Select All'", async () => {
3184+
const combobox = await page.find("calcite-combobox");
3185+
await combobox.click();
3186+
3187+
const selectAll = await page.find(`calcite-combobox >>> calcite-combobox-item.${CSS.selectAll}`);
3188+
await selectAll.click();
3189+
await page.waitForChanges();
3190+
3191+
let a11yItem = await page.find(`calcite-combobox >>> ul.${CSS.screenReadersOnly} li:nth-of-type(2)`);
3192+
expect(await a11yItem.getProperty("ariaSelected")).toBe("true");
3193+
3194+
a11yItem = await page.find(`calcite-combobox >>> ul.${CSS.screenReadersOnly} li:nth-of-type(3)`);
3195+
expect(await a11yItem.getProperty("ariaSelected")).toBe("true");
3196+
3197+
await selectAll.click();
3198+
await page.waitForChanges();
3199+
3200+
a11yItem = await page.find(`calcite-combobox >>> ul.${CSS.screenReadersOnly} li:nth-of-type(2)`);
3201+
expect(await a11yItem.getProperty("ariaSelected")).toBe("false");
3202+
});
3203+
});
3204+
29783205
describe("theme", () => {
29793206
describe("default", () => {
29803207
const comboboxHTML = html`<calcite-combobox label="test" max-items="6" open>

packages/calcite-components/src/components/combobox/combobox.scss

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -110,8 +110,8 @@
110110
gap: var(--calcite-internal-combobox-spacing-unit-s);
111111
margin-inline-end: var(--calcite-internal-combobox-spacing-unit-s);
112112

113-
&.selection-display-fit,
114-
&.selection-display-single {
113+
&.selection-display--fit,
114+
&.selection-display--single {
115115
@apply flex-nowrap overflow-hidden;
116116
}
117117
}
@@ -236,6 +236,16 @@ calcite-chip {
236236
@apply block;
237237
}
238238

239+
.select-all {
240+
background-color: var(--calcite-combobox-item-background-color-active, var(--calcite-color-foreground-1));
241+
border-block-end-color: var(--calcite-combobox-item-border-color, var(--calcite-color-border-3));
242+
border-block-end-style: solid;
243+
border-block-end-width: var(--calcite-border-width-sm);
244+
inset-block-start: 0;
245+
position: sticky;
246+
z-index: var(--calcite-z-index-sticky);
247+
}
248+
239249
@include disabled();
240250
@include x-button(
241251
$background-color: "var(--calcite-close-background-color, var(--calcite-color-foreground-2))",

0 commit comments

Comments
 (0)