diff --git a/packages/calcite-components/src/components/combobox/assets/combobox/t9n/messages.json b/packages/calcite-components/src/components/combobox/assets/combobox/t9n/messages.json
index f22a4b202dd..c55e973fae1 100644
--- a/packages/calcite-components/src/components/combobox/assets/combobox/t9n/messages.json
+++ b/packages/calcite-components/src/components/combobox/assets/combobox/t9n/messages.json
@@ -1,4 +1,7 @@
{
+ "all": "All",
+ "allSelected": "All selected",
"clear": "Clear value",
- "removeTag": "Remove tag"
+ "removeTag": "Remove tag",
+ "selected": "selected"
}
diff --git a/packages/calcite-components/src/components/combobox/assets/combobox/t9n/messages_en.json b/packages/calcite-components/src/components/combobox/assets/combobox/t9n/messages_en.json
index f22a4b202dd..c55e973fae1 100644
--- a/packages/calcite-components/src/components/combobox/assets/combobox/t9n/messages_en.json
+++ b/packages/calcite-components/src/components/combobox/assets/combobox/t9n/messages_en.json
@@ -1,4 +1,7 @@
{
+ "all": "All",
+ "allSelected": "All selected",
"clear": "Clear value",
- "removeTag": "Remove tag"
+ "removeTag": "Remove tag",
+ "selected": "selected"
}
diff --git a/packages/calcite-components/src/components/combobox/combobox.scss b/packages/calcite-components/src/components/combobox/combobox.scss
index 15c8177f42e..72c01847b71 100644
--- a/packages/calcite-components/src/components/combobox/combobox.scss
+++ b/packages/calcite-components/src/components/combobox/combobox.scss
@@ -21,7 +21,7 @@
--calcite-internal-combobox-input-margin-block: calc(theme("spacing.1") - theme("borderWidth.DEFAULT"));
.x-button {
- margin-inline-end: theme("spacing.2");
+ margin-inline: theme("spacing.2");
}
}
@@ -77,26 +77,39 @@
flex-grow
flex-wrap
items-center
+ relative
truncate
p-0;
+
+ gap: var(--calcite-combobox-item-spacing-unit-s);
+
+ &.selection-display-fit,
+ &.selection-display-single {
+ @apply flex-nowrap overflow-hidden;
+ }
}
.input {
- @apply font-inherit
- text-color-1
- flex-grow
- appearance-none
- border-none
+ @apply appearance-none
bg-transparent
+ border-none
+ flex-grow
+ font-inherit
+ text-color-1
+ text-ellipsis
p-0;
font-size: inherit;
block-size: var(--calcite-combobox-input-height);
line-height: var(--calcite-combobox-input-height);
- min-inline-size: 120px;
+ inline-size: 100%;
margin-block-end: var(--calcite-combobox-item-spacing-unit-s);
+ min-inline-size: 4.8125rem;
&:focus {
@apply outline-none;
}
+ &:placeholder-shown {
+ @apply text-ellipsis;
+ }
}
.input--transparent {
@@ -121,7 +134,12 @@
.input--icon {
padding-block: 0;
- padding-inline: var(--calcite-combobox-item-spacing-unit-l);
+ padding-inline: var(--calcite-combobox-item-spacing-unit-s);
+}
+
+:host([scale="m"]) .input--icon,
+:host([scale="l"]) .input--icon {
+ padding-inline: 0.25rem;
}
.input-wrap {
@@ -195,9 +213,12 @@
@apply h-0 overflow-hidden;
}
+calcite-chip {
+ --calcite-animation-timing: 0;
+}
+
.chip {
margin-block: calc(var(--calcite-combobox-item-spacing-unit-s) / 4);
- margin-inline: 0 var(--calcite-combobox-item-spacing-unit-s);
max-inline-size: 100%;
}
@@ -205,6 +226,10 @@
@apply bg-foreground-3;
}
+.chip--invisible {
+ @apply absolute invisible;
+}
+
.item {
@apply block;
}
diff --git a/packages/calcite-components/src/components/combobox/combobox.stories.ts b/packages/calcite-components/src/components/combobox/combobox.stories.ts
index ab7675b0d14..36ff8ec8f67 100644
--- a/packages/calcite-components/src/components/combobox/combobox.stories.ts
+++ b/packages/calcite-components/src/components/combobox/combobox.stories.ts
@@ -13,51 +13,16 @@ export default {
...storyFilters(),
};
-export const simple = (): string => html`
+export const single = (): string => html`
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-`;
-
-export const single = (): string => html`
-
-
@@ -78,28 +43,244 @@ export const single = (): string => html`
export const multiple = (): string => html`
-
-
-
-
-
-
-
-
-
+
selection-display="all" (default)
+
+ Some selected
+
+
+
+
+
+
+
+
+
+
+
+ All selected
+
+
+
+
+
+
+
+
+
+
+
+ selection-display="fit"
+
+ Some selected with multiple visible chips
+
+
+
+
+
+
+
+
+
+
+
+ Some selected with multiple visible chips and overflow chip
+
+
+
+
+
+
+
+
+
+
+
+ All selected with multiple visible chips and overflow chip
+
+
+
+
+
+
+
+
+
+
+
+ Some selected as a condensed indicator chip
+
+
+
+
+
+
+
+
+
+
+
+ All selected as a condensed indicator chip
+
+
+
+
+
+
+
+
+
+
+
+ Some selected as a compact indicator chip
+
+
+
+
+
+
+
+
+
+
+
+ All selected as a compact indicator chip
+
+
+
+
+
+
+
+
+
+
+
+ selection-display="single"
+
+ Some selected
+
+
+
+
+
+
+
+
+
+
+
+ All selected
+
+
+
+
+
+
+
+
+
+
+
+ Some selected with compact indicator chip
+
+
+
+
+
+
+
+
+
+
+
+ All selected with compact indicator chip
+
+
+
+
+
+
+
+
+
+
`;
diff --git a/packages/calcite-components/src/components/combobox/combobox.tsx b/packages/calcite-components/src/components/combobox/combobox.tsx
index 5edc5beb2e4..7d6f14c8fd9 100644
--- a/packages/calcite-components/src/components/combobox/combobox.tsx
+++ b/packages/calcite-components/src/components/combobox/combobox.tsx
@@ -15,7 +15,12 @@ import {
import { debounce } from "lodash-es";
import { filter } from "../../utils/filter";
-import { isPrimaryPointerButton, toAriaBoolean } from "../../utils/dom";
+import {
+ getElementWidth,
+ getTextWidth,
+ isPrimaryPointerButton,
+ toAriaBoolean,
+} from "../../utils/dom";
import {
connectFloatingUI,
defaultMenuPlacement,
@@ -46,6 +51,7 @@ import {
import { connectLabel, disconnectLabel, getLabelText, LabelableComponent } from "../../utils/label";
import {
componentFocusable,
+ componentLoaded,
LoadableComponent,
setComponentLoaded,
setUpLoadableComponent,
@@ -62,11 +68,12 @@ import {
} from "../../utils/t9n";
import { Scale, SelectionMode } from "../interfaces";
import { ComboboxMessages } from "./assets/combobox/t9n";
-import { ComboboxChildElement } from "./interfaces";
+import { ComboboxChildElement, SelectionDisplay } from "./interfaces";
import { ComboboxChildSelector, ComboboxItem, ComboboxItemGroup, CSS } from "./resources";
import { getItemAncestors, getItemChildren, hasActiveChildren, isSingleLike } from "./utils";
import { XButton, CSS as XButtonCSS } from "../functional/XButton";
import { getIconScale } from "../../utils/component";
+import { CoreSizing15 } from "@esri/calcite-design-tokens/dist/es6/calcite-headless";
interface ItemData {
label: string;
@@ -112,6 +119,12 @@ export class Combobox
*/
@Prop({ reflect: true }) clearDisabled = false;
+ /**
+ * When `selectionMode` is `"ancestors"` or `"multiple"`, specifies the display of multiple `calcite-combobox-item` selections
+ * - `"all"` (displays all selections with individual `calcite-chip`s), `"fit"` (displays individual `calcite-chip`s that scale to the component's size, including a non-closable `calcite-chip`, which provides the number of additional `calcite-combobox-item` selections not visually displayed), or `"single"` (display one `calcite-chip` with the total number of selections).
+ */
+ @Prop({ reflect: true }) selectionDisplay: SelectionDisplay = "all";
+
/**When `true`, displays and positions the component. */
@Prop({ reflect: true, mutable: true }) open = false;
@@ -142,8 +155,7 @@ export class Combobox
*
* When not set, the component will be associated with its ancestor form element, if any.
*/
- @Prop({ reflect: true })
- form: string;
+ @Prop({ reflect: true }) form: string;
/** Accessible name for the component. */
@Prop() label!: string;
@@ -339,7 +351,8 @@ export class Combobox
/**
* Updates the position of the component.
*
- * @param delayed
+ * @param delayed Reposition the component after a delay
+ * @returns Promise
*/
@Method()
async reposition(delayed = false): Promise {
@@ -449,6 +462,10 @@ export class Combobox
updateHostInteraction(this);
}
+ componentDidUpdate(): void {
+ this.refreshSelectionDisplay();
+ }
+
disconnectedCallback(): void {
this.mutationObserver?.disconnect();
this.resizeObserver?.disconnect();
@@ -466,6 +483,8 @@ export class Combobox
//
//--------------------------------------------------------------------------
+ private allSelectedIndicatorChipEl: HTMLCalciteChipElement;
+
@Element() el: HTMLCalciteComboboxElement;
placement: LogicalPlacement = defaultMenuPlacement;
@@ -492,6 +511,12 @@ export class Combobox
@State() activeDescendant = "";
+ @State() compactSelectionDisplay = false;
+
+ @State() selectedHiddenChipsCount = 0;
+
+ @State() selectedVisibleChipsCount = 0;
+
@State() text = "";
/** when search text is cleared, reset active to */
@@ -515,7 +540,10 @@ export class Combobox
mutationObserver = createObserver("mutation", () => this.updateItems());
- resizeObserver = createObserver("resize", () => this.setMaxScrollerHeight());
+ private resizeObserver = createObserver("resize", () => {
+ this.setMaxScrollerHeight();
+ this.refreshSelectionDisplay();
+ });
private guid = guid();
@@ -525,12 +553,18 @@ export class Combobox
private referenceEl: HTMLDivElement;
+ private chipContainerEl: HTMLDivElement;
+
private listContainerEl: HTMLDivElement;
private ignoreSelectedEventsFlag = false;
+ private maxCompactBreakpoint: number;
+
openTransitionProp = "opacity";
+ private selectedIndicatorChipEl: HTMLCalciteChipElement;
+
transitionEl: HTMLDivElement;
// --------------------------------------------------------------------------
@@ -782,22 +816,152 @@ export class Combobox
this.updateActiveItemIndex(targetIndex);
}
+ private hideChip(chipEl: HTMLCalciteChipElement): void {
+ chipEl.classList.add(CSS.chipInvisible);
+ }
+
+ private showChip(chipEl: HTMLCalciteChipElement): void {
+ chipEl.classList.remove(CSS.chipInvisible);
+ }
+
+ private refreshChipDisplay({
+ chipEls,
+ availableHorizontalChipElSpace,
+ chipContainerElGap,
+ }): void {
+ chipEls.forEach((chipEl: HTMLCalciteChipElement) => {
+ if (!chipEl.selected) {
+ this.hideChip(chipEl);
+ } else {
+ const chipElWidth = getElementWidth(chipEl);
+ if (chipElWidth && chipElWidth < availableHorizontalChipElSpace) {
+ availableHorizontalChipElSpace -= chipElWidth + chipContainerElGap;
+ this.showChip(chipEl);
+ return;
+ }
+ }
+ this.hideChip(chipEl);
+ });
+ }
+
+ private refreshSelectionDisplay = async () => {
+ await componentLoaded(this);
+
+ if (isSingleLike(this.selectionMode)) {
+ return;
+ }
+
+ if (!this.textInput) {
+ return;
+ }
+
+ const {
+ allSelectedIndicatorChipEl,
+ chipContainerEl,
+ selectionDisplay,
+ placeholder,
+ selectedIndicatorChipEl,
+ textInput,
+ } = this;
+
+ const chipContainerElGap = parseInt(getComputedStyle(chipContainerEl).gap.replace("px", ""));
+ const chipContainerElWidth = getElementWidth(chipContainerEl);
+ const { fontSize, fontFamily } = getComputedStyle(textInput);
+ const inputTextWidth = getTextWidth(placeholder, `${fontSize} ${fontFamily}`);
+ const inputWidth = (inputTextWidth || parseInt(CoreSizing15)) + chipContainerElGap;
+ const allSelectedIndicatorChipElWidth = getElementWidth(allSelectedIndicatorChipEl);
+ const selectedIndicatorChipElWidth = getElementWidth(selectedIndicatorChipEl);
+ const largestSelectedIndicatorChipWidth = Math.max(
+ allSelectedIndicatorChipElWidth,
+ selectedIndicatorChipElWidth
+ );
+
+ this.setCompactSelectionDisplay({
+ chipContainerElGap,
+ chipContainerElWidth,
+ inputWidth,
+ largestSelectedIndicatorChipWidth,
+ });
+
+ if (selectionDisplay === "fit") {
+ const chipEls = Array.from(this.el.shadowRoot.querySelectorAll("calcite-chip")).filter(
+ (chipEl) => chipEl.closable
+ );
+
+ let availableHorizontalChipElSpace = Math.round(
+ chipContainerElWidth -
+ ((this.selectedHiddenChipsCount > 0 ? selectedIndicatorChipElWidth : 0) +
+ chipContainerElGap +
+ inputWidth +
+ chipContainerElGap)
+ );
+
+ this.refreshChipDisplay({ availableHorizontalChipElSpace, chipContainerElGap, chipEls });
+ this.setVisibleAndHiddenChips(chipEls);
+ }
+ };
+
setFloatingEl = (el: HTMLDivElement): void => {
this.floatingEl = el;
connectFloatingUI(this, this.referenceEl, this.floatingEl);
};
+ private setCompactSelectionDisplay({
+ chipContainerElGap,
+ chipContainerElWidth,
+ inputWidth,
+ largestSelectedIndicatorChipWidth,
+ }): void {
+ const newCompactBreakpoint = Math.round(
+ largestSelectedIndicatorChipWidth + chipContainerElGap + inputWidth
+ );
+ if (!this.maxCompactBreakpoint || this.maxCompactBreakpoint < newCompactBreakpoint) {
+ this.maxCompactBreakpoint = newCompactBreakpoint;
+ }
+ this.compactSelectionDisplay = chipContainerElWidth < this.maxCompactBreakpoint;
+ }
+
setContainerEl = (el: HTMLDivElement): void => {
this.resizeObserver.observe(el);
this.listContainerEl = el;
this.transitionEl = el;
};
+ setChipContainerEl = (el: HTMLDivElement): void => {
+ this.resizeObserver.observe(el);
+ this.chipContainerEl = el;
+ };
+
setReferenceEl = (el: HTMLDivElement): void => {
this.referenceEl = el;
connectFloatingUI(this, this.referenceEl, this.floatingEl);
};
+ setAllSelectedIndicatorChipEl = (el: HTMLCalciteChipElement): void => {
+ this.allSelectedIndicatorChipEl = el;
+ };
+
+ setSelectedIndicatorChipEl = (el: HTMLCalciteChipElement): void => {
+ this.selectedIndicatorChipEl = el;
+ };
+
+ private setVisibleAndHiddenChips(chipEls: HTMLCalciteChipElement[]): void {
+ let newSelectedVisibleChipsCount = 0;
+ chipEls.forEach((chipEl) => {
+ if (chipEl.selected && !chipEl.classList.contains(CSS.chipInvisible)) {
+ newSelectedVisibleChipsCount++;
+ }
+ });
+ if (newSelectedVisibleChipsCount !== this.selectedVisibleChipsCount) {
+ this.selectedVisibleChipsCount = newSelectedVisibleChipsCount;
+ }
+ const newSelectedHiddenChipsCount =
+ this.getSelectedItems().length - newSelectedVisibleChipsCount;
+ if (newSelectedHiddenChipsCount !== this.selectedHiddenChipsCount) {
+ this.selectedHiddenChipsCount = newSelectedHiddenChipsCount;
+ }
+ }
+
private getMaxScrollerHeight(): number {
const items = this.getItemsAndGroups().filter((item) => !item.hidden);
@@ -942,7 +1106,7 @@ export class Combobox
return this.items.filter((item) => !item.hidden);
}
- getSelectedItems(): HTMLCalciteComboboxItemElement[] {
+ private getSelectedItems = (): HTMLCalciteComboboxItemElement[] => {
if (!this.isMulti()) {
const match = this.items.find(({ selected }) => selected);
return match ? [match] : [];
@@ -964,7 +1128,7 @@ export class Combobox
return bIdx - aIdx;
})
);
- }
+ };
private updateItems = (): void => {
this.items = this.getItems();
@@ -1135,6 +1299,10 @@ export class Combobox
}
}
+ private isAllSelected(): boolean {
+ return this.getItems().length === this.getSelectedItems().length;
+ }
+
isMulti(): boolean {
return !isSingleLike(this.selectionMode);
}
@@ -1174,6 +1342,7 @@ export class Combobox
messageOverrides={{ dismissLabel: messages.removeTag }}
onCalciteChipClose={() => this.calciteChipCloseHandler(item)}
scale={scale}
+ selected={item.selected}
title={label}
value={item.value}
>
@@ -1183,6 +1352,149 @@ export class Combobox
});
}
+ renderAllSelectedIndicatorChip(): VNode {
+ const {
+ compactSelectionDisplay,
+ scale,
+ selectedVisibleChipsCount,
+ setAllSelectedIndicatorChipEl,
+ } = this;
+ const label = this.messages.allSelected;
+ return (
+
+ {label}
+
+ );
+ }
+
+ renderAllSelectedIndicatorChipCompact(): VNode {
+ const { compactSelectionDisplay, scale, selectedVisibleChipsCount } = this;
+ const label = this.messages.all || "All";
+ return (
+
+ {label}
+
+ );
+ }
+
+ renderSelectedIndicatorChip(): VNode {
+ const {
+ compactSelectionDisplay,
+ selectionDisplay,
+ getSelectedItems,
+ scale,
+ selectedHiddenChipsCount,
+ selectedVisibleChipsCount,
+ setSelectedIndicatorChipEl,
+ } = this;
+ let chipInvisible, label;
+ if (compactSelectionDisplay) {
+ chipInvisible = true;
+ } else {
+ if (selectionDisplay === "single") {
+ const selectedItemsCount = getSelectedItems().length;
+ if (this.isAllSelected()) {
+ chipInvisible = true;
+ } else if (selectedItemsCount > 0) {
+ chipInvisible = false;
+ } else {
+ chipInvisible = true;
+ }
+ label = `${selectedItemsCount} ${this.messages.selected}`;
+ } else if (selectionDisplay === "fit") {
+ if (
+ (this.isAllSelected() && selectedVisibleChipsCount === 0) ||
+ selectedHiddenChipsCount === 0
+ ) {
+ chipInvisible = true;
+ } else {
+ chipInvisible = false;
+ }
+ label =
+ selectedVisibleChipsCount > 0
+ ? `+${selectedHiddenChipsCount}`
+ : `${selectedHiddenChipsCount} ${this.messages.selected}`;
+ }
+ }
+ return (
+
+ {label}
+
+ );
+ }
+
+ renderSelectedIndicatorChipCompact(): VNode {
+ const {
+ compactSelectionDisplay,
+ selectionDisplay,
+ getSelectedItems,
+ scale,
+ selectedHiddenChipsCount,
+ } = this;
+ let chipInvisible, label;
+ if (compactSelectionDisplay) {
+ const selectedItemsCount = getSelectedItems().length;
+ if (this.isAllSelected()) {
+ chipInvisible = true;
+ } else if (selectionDisplay === "fit") {
+ chipInvisible = selectedHiddenChipsCount > 0 ? false : true;
+ label = `${selectedHiddenChipsCount || 0}`;
+ } else if (selectionDisplay === "single") {
+ chipInvisible = selectedItemsCount > 0 ? false : true;
+ label = `${selectedItemsCount}`;
+ }
+ } else {
+ chipInvisible = true;
+ }
+ return (
+
+ {label}
+
+ );
+ }
+
renderInput(): VNode {
const { guid, disabled, placeholder, selectionMode, selectedItems, open } = this;
const single = isSingleLike(selectionMode);
@@ -1315,10 +1627,12 @@ export class Combobox
}
render(): VNode {
- const { guid, label, open } = this;
- const single = isSingleLike(this.selectionMode);
+ const { selectionDisplay, guid, label, open } = this;
+ const singleSelectionMode = isSingleLike(this.selectionMode);
+ const allSelectionDisplay = selectionDisplay === "all";
+ const singleSelectionDisplay = selectionDisplay === "single";
+ const fitSelectionDisplay = !singleSelectionMode && selectionDisplay === "fit";
const isClearable = !this.clearDisabled && this.value?.length > 0;
-
return (