From 58317ae6803a3a1716cabeaeac2061fa533a6bad Mon Sep 17 00:00:00 2001 From: Erik Harper Date: Tue, 21 Nov 2023 11:58:24 -0800 Subject: [PATCH] feat(combobox): limit display of selected items with new selection-display prop (#7912) **Related Issue:** #4326 ## Summary This PR introduces a new `selection-display` property to Combobox to control the chip display with the available values `all`(default), `fit` and `single`. This new property only takes effect when `selection-mode` is `multiple` or `ancestors`. --------- Co-authored-by: Erik Harper --- .../assets/combobox/t9n/messages.json | 5 +- .../assets/combobox/t9n/messages_en.json | 5 +- .../src/components/combobox/combobox.scss | 43 ++- .../components/combobox/combobox.stories.ts | 303 ++++++++++++--- .../src/components/combobox/combobox.tsx | 356 +++++++++++++++++- .../src/components/combobox/interfaces.ts | 1 + .../src/components/combobox/resources.ts | 3 + .../src/demos/combobox.html | 210 ++++++++++- packages/calcite-components/src/utils/dom.ts | 30 ++ 9 files changed, 869 insertions(+), 87 deletions(-) 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 (
-
+
{this.renderIconStart()} - {!single && this.renderChips()} + {!singleSelectionMode && !singleSelectionDisplay && this.renderChips()} + {!singleSelectionMode && + !allSelectionDisplay && [ + this.renderSelectedIndicatorChip(), + this.renderSelectedIndicatorChipCompact(), + this.renderAllSelectedIndicatorChip(), + this.renderAllSelectedIndicatorChipCompact(), + ]}
+ +
+
Multi select (single selection-display)
+ +
+ label + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ label + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ label + + + + + + + + + + + + + + + + + + + + + + +
+
+ + +
+
Multi select (fit selection-display)
+ +
+ label + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ label + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ label + + + + + + + + + + + + + + + + + + + + + + +
+
+
Multi select with groups
diff --git a/packages/calcite-components/src/utils/dom.ts b/packages/calcite-components/src/utils/dom.ts index e4399a1d61a..c6e6e987eba 100644 --- a/packages/calcite-components/src/utils/dom.ts +++ b/packages/calcite-components/src/utils/dom.ts @@ -81,6 +81,19 @@ export function getElementProp(el: Element, attribute: string, fallbackValue: an return closest ? closest.getAttribute(attribute) : fallbackValue; } +/** + * This helper returns the computed width in pixels of a rendered HTMLElement. + * + * @param {HTMLElement} el An element. + * @returns {number} The element's width. + */ +export function getElementWidth(el: HTMLElement): number { + if (!el) { + return 0; + } + return parseFloat(getComputedStyle(el).inlineSize); +} + /** * This helper returns the rootNode of an element. * @@ -102,6 +115,23 @@ export function getShadowRootNode(el: Element): ShadowRoot | null { return "host" in rootNode ? rootNode : null; } +/** + * This helper returns the computed width in pixels a given text string takes up on screen. + * + * See https://stackoverflow.com/questions/118241/calculate-text-width-with-javascript + * + * @param {string} text The string of text to measure. + * @param {string} font The CSS font attribute's value, which should include size and face, e.g. "12px Arial". + */ +export function getTextWidth(text: string, font: string): number { + if (!text) { + return 0; + } + const canvas = document.createElement("canvas"); + const context = canvas.getContext("2d"); + context.font = font; + return context.measureText(text).width; +} /** * This helper returns the host of a ShadowRoot. *