Skip to content

feat(combobox): make combobox clearable #6972

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 13 commits into from
May 18, 2023
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 54 additions & 0 deletions src/assets/styles/includes.scss
Original file line number Diff line number Diff line change
Expand Up @@ -74,3 +74,57 @@
pointer-events: none;
}
}

@mixin xButton() {
:host([scale="s"]) {
.x-button {
inline-size: theme("spacing.4");
block-size: theme("spacing.4");
}
}

:host([scale="m"]) {
.x-button {
inline-size: theme("spacing.6");
block-size: theme("spacing.6");
}
}

:host([scale="l"]) {
.x-button {
inline-size: theme("spacing.8");
block-size: theme("spacing.8");
}
}

.x-button {
@apply flex
self-center
focus-base
transition-default
text-color-1
m-0
cursor-pointer
items-center
border-none
bg-transparent;
-webkit-appearance: none;
display: flex;
border-radius: 50%;
align-content: center;
justify-content: center;
&:hover {
background-color: var(--calcite-button-transparent-hover);
}
&:focus {
background-color: var(--calcite-button-transparent-press);
@apply focus-inset;
}
&:active {
background-color: var(--calcite-button-transparent-press);
}
& calcite-icon {
color: inherit;
}
}
}
9 changes: 6 additions & 3 deletions src/components/combobox-item/combobox-item.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -127,11 +127,14 @@ export class ComboboxItem implements ConditionalSlotComponent, InteractiveCompon
//
// --------------------------------------------------------------------------

toggleSelected(coerce?: boolean): Promise<void> {
if (this.disabled) {
toggleSelected(): Promise<void> {
const isSingleSelect = getElementProp(this.el, "selection-mode", "multiple") === "single";
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just a note that getElementProp is deprecated since it actually only gets the attribute and not the prop. I know we'll need to refactor this at some point.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, I remember. I'll submit a PR to rename it today, so it's not misleading when reading code.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

related to #6038


if (this.disabled || (isSingleSelect && this.selected)) {
return;
}
this.selected = typeof coerce === "boolean" ? coerce : !this.selected;

this.selected = !this.selected;
}

itemClickHandler = (event: MouseEvent): void => {
Expand Down
1 change: 1 addition & 0 deletions src/components/combobox/assets/combobox/t9n/messages.json
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
{
"clear": "Clear value",
"removeTag": "Remove tag"
}
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
{
"clear": "Clear value",
"removeTag": "Remove tag"
}
141 changes: 118 additions & 23 deletions src/components/combobox/combobox.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {

import { html } from "../../../support/formatting";
import { CSS } from "./resources";
import { CSS as XButtonCSS } from "../functional/XButton";

describe("calcite-combobox", () => {
describe("renders", () => {
Expand Down Expand Up @@ -302,33 +303,86 @@ describe("calcite-combobox", () => {
});

describe("item selection", () => {
it("should add/remove item to the selected items when an item is clicked", async () => {
const page = await newE2EPage();
await page.setContent(
html`
<calcite-combobox>
<calcite-combobox-item value="one" text-label="one"></calcite-combobox-item>
<calcite-combobox-item value="two" text-label="two"></calcite-combobox-item>
</calcite-combobox>
`
);
const cbox = await page.find("calcite-combobox");
const openEvent = page.waitForEvent("calciteComboboxOpen");
await cbox.click();
await openEvent;
describe("toggling items", () => {
it("single-selection mode does not allow toggling selection once the selected item is clicked", async () => {
const page = await newE2EPage();
await page.setContent(
html`
<calcite-combobox>
<calcite-combobox-item value="one" text-label="one"></calcite-combobox-item>
<calcite-combobox-item value="two" text-label="two"></calcite-combobox-item>
</calcite-combobox>
`
);
const cbox = await page.find("calcite-combobox");
const openEvent = page.waitForEvent("calciteComboboxOpen");
await cbox.click();
await openEvent;

const item1 = await cbox.find("calcite-combobox-item[value=one]");

let item1 = await cbox.find("calcite-combobox-item[value=one]");
await item1.click();
await item1.click();
expect(await page.find("calcite-combobox >>> calcite-chip")).toBeDefined();

let chip = await page.find("calcite-combobox >>> calcite-chip");
expect(chip).toBeDefined();
await item1.click();
expect(await page.find("calcite-combobox >>> calcite-chip")).toBeDefined();
});

item1 = await cbox.find("calcite-combobox-item[value=one]");
await item1.click();
await page.waitForChanges();
it("multiple-selection mode allows toggling selection once the selected item is clicked", async () => {
const page = await newE2EPage();
await page.setContent(
html`
<calcite-combobox selection-mode="multiple">
<calcite-combobox-item value="one" text-label="one"></calcite-combobox-item>
<calcite-combobox-item value="two" text-label="two"></calcite-combobox-item>
</calcite-combobox>
`
);
const cbox = await page.find("calcite-combobox");
const openEvent = page.waitForEvent("calciteComboboxOpen");
await cbox.click();
await openEvent;

const item1 = await cbox.find("calcite-combobox-item[value=one]");

chip = await page.find("calcite-combobox >>> calcite-chip");
expect(chip).toBeNull();
await item1.click();
expect(await page.find("calcite-combobox >>> calcite-chip")).toBeDefined();

await item1.click();
expect(await page.find("calcite-combobox >>> calcite-chip")).toBeNull();

await item1.click();
expect(await page.find("calcite-combobox >>> calcite-chip")).toBeDefined();
});

it("ancestors-selection mode allows toggling selection once the selected item is clicked", async () => {
const page = await newE2EPage();
await page.setContent(
html`
<calcite-combobox selection-mode="ancestors">
<calcite-combobox-item value="one" text-label="parent">
<calcite-combobox-item value="two" text-label="child1"></calcite-combobox-item>
<calcite-combobox-item value="three" text-label="child2"></calcite-combobox-item>
</calcite-combobox-item>
</calcite-combobox>
`
);
const cbox = await page.find("calcite-combobox");
const openEvent = page.waitForEvent("calciteComboboxOpen");
await cbox.click();
await openEvent;

const item1 = await cbox.find("calcite-combobox-item[value=one]");

await item1.click();
expect(await page.find("calcite-combobox >>> calcite-chip")).toBeDefined();

await item1.click();
expect(await page.find("calcite-combobox >>> calcite-chip")).toBeNull();

await item1.click();
expect(await page.find("calcite-combobox >>> calcite-chip")).toBeDefined();
});
});

it("should select parent in ancestor selection mode", async () => {
Expand Down Expand Up @@ -539,6 +593,47 @@ describe("calcite-combobox", () => {
});
});

describe("clearable", () => {
async function assertClearable(html: string): Promise<void> {
const page = await newE2EPage();
await page.setContent(html);
const combobox = await page.find("calcite-combobox");
const xButton = await page.find(`calcite-combobox >>> .${XButtonCSS.button}`);

await xButton.click();

expect(await combobox.getProperty("value")).toBe("");
}

it("clears the value in single-selection mode", async () =>
assertClearable(html`
<calcite-combobox clearable selection-mode="single">
<calcite-combobox-item selected id="one" value="one" text-label="one"></calcite-combobox-item>
<calcite-combobox-item id="two" value="two" text-label="two"></calcite-combobox-item>
<calcite-combobox-item id="three" value="three" text-label="three"></calcite-combobox-item>
</calcite-combobox>
`));

it("clears the value in multiple-selection mode", async () =>
assertClearable(html`
<calcite-combobox clearable selection-mode="multiple">
<calcite-combobox-item selected id="one" value="one" text-label="one"></calcite-combobox-item>
<calcite-combobox-item selected id="two" value="two" text-label="two"></calcite-combobox-item>
<calcite-combobox-item selected id="three" value="three" text-label="three"></calcite-combobox-item>
</calcite-combobox>
`));

it("clears the value in ancestors-selection mode", async () =>
assertClearable(html`
<calcite-combobox clearable selection-mode="ancestors">
<calcite-combobox-item value="parent" text-label="parent">
<calcite-combobox-item value="child1" text-label="child1"></calcite-combobox-item>
<calcite-combobox-item selected value="child2" text-label="child2"></calcite-combobox-item>
</calcite-combobox-item>
</calcite-combobox>
`));
});

describe("keyboard navigation", () => {
let page: E2EPage;
const scrollablePageSizeInPx = 2400;
Expand Down
1 change: 1 addition & 0 deletions src/components/combobox/combobox.scss
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
}

@include disabled();
@include xButton();

:host([scale="s"]) {
@apply text-n2;
Expand Down
41 changes: 39 additions & 2 deletions src/components/combobox/combobox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ import { ComboboxMessages } from "./assets/combobox/t9n";
import { ComboboxChildElement } from "./interfaces";
import { ComboboxChildSelector, ComboboxItem, ComboboxItemGroup } from "./resources";
import { getItemAncestors, getItemChildren, hasActiveChildren } from "./utils";
import { XButton, CSS as XButtonCSS } from "../functional/XButton";

interface ItemData {
label: string;
Expand Down Expand Up @@ -111,6 +112,11 @@ export class Combobox
//
//--------------------------------------------------------------------------

/**
* When `true`, a clear button is displayed when the component has a value.
*/
@Prop({ reflect: true }) clearable = false;

/**When `true`, displays and positions the component. */
@Prop({ reflect: true, mutable: true }) open = false;

Expand Down Expand Up @@ -506,6 +512,19 @@ export class Combobox
//
// --------------------------------------------------------------------------

private clearValue(): void {
this.ignoreSelectedEventsFlag = true;
this.items.forEach((el) => (el.selected = false));
this.ignoreSelectedEventsFlag = false;
this.selectedItems = [];
this.emitComboboxChange();
this.open = false;
this.updateActiveItemIndex(-1);
this.resetText();
this.filterItems("");
this.setFocus();
}

setFilteredPlacements = (): void => {
const { el, flipPlacements } = this;

Expand Down Expand Up @@ -688,11 +707,20 @@ export class Combobox
};

clickHandler = (event: MouseEvent): void => {
if (event.composedPath().some((node: HTMLElement) => node.tagName === "CALCITE-CHIP")) {
const composedPath = event.composedPath();

if (composedPath.some((node: HTMLElement) => node.tagName === "CALCITE-CHIP")) {
this.open = false;
event.stopPropagation();
event.preventDefault();
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@macandcheese Tests seem to pass, but do you recall why the original event needed to stop propagating?

Copy link
Contributor

@macandcheese macandcheese May 17, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It allowed focus to be set on the clicked Chip's close button, when user clicked the Chip, which was a change to support how Chips behaved post some Chip Group related changes.

Although looking at it in action (and compared to past behavior), that probably wasn't needed, so this should be fine to remove, and have focus instead shift to Combobox.

return;
}

if (composedPath.some((node: HTMLElement) => node.classList?.contains(XButtonCSS.button))) {
this.clearValue();
event.preventDefault();
return;
}

this.open = !this.open;
this.updateActiveItemIndex(0);
this.setFocus();
Expand Down Expand Up @@ -1242,6 +1270,7 @@ export class Combobox
render(): VNode {
const { guid, label, open } = this;
const single = this.selectionMode === "single";
const isClearable = this.clearable && this.value.length > 0;

return (
<Host onClick={this.comboboxFocusHandler}>
Expand Down Expand Up @@ -1276,6 +1305,14 @@ export class Combobox
</label>
{this.renderInput()}
</div>
{isClearable ? (
<XButton
disabled={this.disabled}
key="close-button"
label={this.messages.clear}
scale={this.scale}
/>
) : null}
{this.renderIconEnd()}
</div>
<ul
Expand Down
39 changes: 39 additions & 0 deletions src/components/functional/XButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { FunctionalComponent, h } from "@stencil/core";
import { JSXAttributes, JSXBase } from "@stencil/core/internal";
import { Scale } from "../interfaces";

export interface XButtonOptions extends JSXAttributes {
disabled: boolean;
label: string;
scale: Scale;
onClick?: JSXBase.DOMAttributes<HTMLElement>["onClick"];
}

export const CSS = {
button: "x-button"
};

export function XButton({
disabled,
key,
label,
onClick,
ref,
scale
}: XButtonOptions): FunctionalComponent {
return (
<button
aria-label={label}
class={CSS.button}
disabled={disabled}
key={key}
onClick={onClick}
tabIndex={-1}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just confirming that clearing here only happens via mouse. A keyboard user would need to select all text and delete.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great point. I think I had it this way because of how it works in input, but Escape doesn't clear the value here. @ashetland WDYT?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Synced up w/ @ashetland. Following input's example, the clear button won't be focusable and Escape will clear the value if the combobox is closed and focused.

type="button"
// eslint-disable-next-line react/jsx-sort-props
ref={ref}
>
<calcite-icon icon="x" scale={scale === "l" ? "m" : "s"} />
</button>
);
}
Loading