diff --git a/packages/lab/src/__tests__/__e2e__/combo-box-next/ComboBoxNext.cy.tsx b/packages/lab/src/__tests__/__e2e__/combo-box-next/ComboBoxNext.cy.tsx index aec6c4a1d7e..e1f8667432b 100644 --- a/packages/lab/src/__tests__/__e2e__/combo-box-next/ComboBoxNext.cy.tsx +++ b/packages/lab/src/__tests__/__e2e__/combo-box-next/ComboBoxNext.cy.tsx @@ -16,6 +16,8 @@ const { EmptyMessage, ComplexOption, ObjectValue, + MultiplePills, + MultiplePillsTruncated, } = composeStories(comboBoxNextStories); describe("Given a ComboBox", () => { @@ -299,6 +301,7 @@ describe("Given a ComboBox", () => { "aria-selected", "true" ); + cy.findByRole("button", { name: /^Alabama/ }).should("be.visible"); cy.findByRole("option", { name: "Alaska" }).realClick(); cy.get("@selectionChange").should( "have.been.calledWith", @@ -310,6 +313,8 @@ describe("Given a ComboBox", () => { "aria-selected", "true" ); + cy.findByRole("button", { name: /^Alabama/ }).should("be.visible"); + cy.findByRole("button", { name: /^Alaska/ }).should("be.visible"); cy.findByRole("listbox").should("exist"); cy.findByRole("combobox").should("have.value", ""); }); @@ -330,6 +335,7 @@ describe("Given a ComboBox", () => { "aria-selected", "true" ); + cy.findByRole("button", { name: /^Alabama/ }).should("be.visible"); cy.realPress("ArrowDown"); cy.realPress("Enter"); cy.get("@selectionChange").should( @@ -342,6 +348,8 @@ describe("Given a ComboBox", () => { "aria-selected", "true" ); + cy.findByRole("button", { name: /^Alabama/ }).should("be.visible"); + cy.findByRole("button", { name: /^Alaska/ }).should("be.visible"); cy.findByRole("listbox").should("exist"); cy.findByRole("combobox").should("have.value", ""); }); @@ -474,6 +482,46 @@ describe("Given a ComboBox", () => { cy.findAllByRole("option").should("not.be.activeDescendant"); }); + it("should wrap pills by default", () => { + cy.mount(); + cy.findAllByRole("button").should("have.length", 4).should("be.visible"); + }); + + it("should truncate pills when `truncate=true` and expand them on focus", () => { + cy.mount(); + cy.findAllByRole("button").should("have.length", 2).should("be.visible"); + cy.findByTestId(/OverflowMenuIcon/i).should("be.visible"); + cy.findByRole("combobox").realClick(); + cy.findAllByRole("button").should("have.length", 4).should("be.visible"); + }); + + it("should focus the pills first and on tab focus the input", () => { + cy.mount(); + cy.realPress("Tab"); + cy.findByRole("button", { name: /^Alabama/ }).should("be.focused"); + cy.realPress("Tab"); + cy.findByRole("combobox").should("be.focused"); + }); + + it("should support arrow key navigation between pills and input", () => { + cy.mount(); + cy.realPress("Tab"); + cy.findByRole("button", { name: /^Alabama/ }).should("be.focused"); + cy.realPress("ArrowRight"); + cy.findByRole("button", { name: /^Alaska/ }).should("be.focused"); + cy.realPress("ArrowRight"); + cy.findByRole("button", { name: /^Arizona/ }).should("be.focused"); + cy.realPress("ArrowRight"); + cy.findByRole("combobox").should("be.focused"); + + cy.realPress("ArrowLeft"); + cy.findByRole("button", { name: /^Arizona/ }).should("be.focused"); + cy.realPress("ArrowLeft"); + cy.findByRole("button", { name: /^Alaska/ }).should("be.focused"); + cy.realPress("ArrowLeft"); + cy.findByRole("button", { name: /^Alabama/ }).should("be.focused"); + }); + it("should render the custom floating component", () => { cy.mount( diff --git a/packages/lab/src/combo-box-next/ComboBoxNext.css b/packages/lab/src/combo-box-next/ComboBoxNext.css new file mode 100644 index 00000000000..cee5cbf1300 --- /dev/null +++ b/packages/lab/src/combo-box-next/ComboBoxNext.css @@ -0,0 +1,3 @@ +.saltComboBoxNext-focused { + outline: var(--salt-focused-outline); +} diff --git a/packages/lab/src/combo-box-next/ComboBoxNext.tsx b/packages/lab/src/combo-box-next/ComboBoxNext.tsx index b7a4545b712..a20f81dc61c 100644 --- a/packages/lab/src/combo-box-next/ComboBoxNext.tsx +++ b/packages/lab/src/combo-box-next/ComboBoxNext.tsx @@ -7,13 +7,12 @@ import { MouseEvent, ReactNode, Ref, + SyntheticEvent, useEffect, useRef, } from "react"; import { Button, - Input, - InputProps, makePrefixer, useFloatingComponent, useFloatingUI, @@ -36,6 +35,10 @@ import { import { ChevronDownIcon, ChevronUpIcon } from "@salt-ds/icons"; import { useComboBoxNext, UseComboBoxNextProps } from "./useComboBoxNext"; import { OptionList } from "../option/OptionList"; +import { PillInput, PillInputProps } from "../pill-input"; +import { useWindow } from "@salt-ds/window"; +import { useComponentCssInjection } from "@salt-ds/styles"; +import comboBoxNextCss from "./ComboBoxNext.css"; export type ComboBoxNextProps = { /** @@ -43,7 +46,7 @@ export type ComboBoxNextProps = { */ children?: ReactNode; } & UseComboBoxNextProps & - InputProps; + PillInputProps; const withBaseName = makePrefixer("saltComboBoxNext"); @@ -74,9 +77,17 @@ export const ComboBoxNext = forwardRef(function ComboBox( value, defaultValue, valueToString = defaultValueToString, + truncate, ...rest } = props; + const targetWindow = useWindow(); + useComponentCssInjection({ + testId: "salt-combo-box-next", + css: comboBoxNextCss, + window: targetWindow, + }); + const { a11yProps: { "aria-labelledby": formFieldLabelledBy } = {}, disabled: formFieldDisabled, @@ -287,6 +298,12 @@ export const ComboBoxNext = forwardRef(function ComboBox( onChange?.(event); }; + const handlePillRemove = (event: SyntheticEvent, index: number) => { + event.stopPropagation(); + const removed = selectedState[index]; + select(event, getOptionsMatching((option) => option.value === removed)[0]); + }; + const handleListMouseOver = () => { setFocusVisibleState(false); }; @@ -346,8 +363,15 @@ export const ComboBoxNext = forwardRef(function ComboBox( return ( - {endAdornment} @@ -395,6 +419,15 @@ export const ComboBoxNext = forwardRef(function ComboBox( onFocus: handleFocus, ...rest, })} + pills={ + multiselect ? selectedState.map((item) => valueToString(item)) : [] + } + truncate={truncate && !focusedState && !openState} + onPillRemove={handlePillRemove} + hidePillClose={!focusedState || readOnly} + emptyReadOnlyMarker={ + readOnly && selectedState.length > 0 ? "" : undefined + } /> ( const targetWindow = useWindow(); useComponentCssInjection({ - testId: "salt-DropdownNext", - css: dropdownCss, + testId: "salt-dropdown-next", + css: dropdownNextCss, window: targetWindow, }); diff --git a/packages/lab/src/option/Option.css b/packages/lab/src/option/Option.css index 878e8fdf496..e089a6563c6 100644 --- a/packages/lab/src/option/Option.css +++ b/packages/lab/src/option/Option.css @@ -10,10 +10,6 @@ gap: var(--salt-spacing-100); position: relative; align-items: center; - margin-top: var(--salt-size-border); - margin-bottom: var(--salt-size-border); - border-top: var(--salt-size-border) var(--salt-container-borderStyle) transparent; - border-bottom: var(--salt-size-border) var(--salt-container-borderStyle) transparent; cursor: var(--salt-selectable-cursor-hover); box-sizing: border-box; } @@ -33,7 +29,30 @@ .saltOption[aria-selected="true"] { background: var(--salt-selectable-background-selected); - border-color: var(--salt-selectable-borderColor-selected); +} + +.saltOption[aria-selected="true"]::before { + content: ""; + display: block; + position: absolute; + top: -1px; + bottom: -1px; + left: 0; + width: 100%; +} + +.saltOption[aria-selected="true"]::before { + border-top: var(--salt-size-border) var(--salt-selectable-borderStyle-selected) var(--salt-selectable-borderColor-selected); + border-bottom: var(--salt-size-border) var(--salt-selectable-borderStyle-selected) var(--salt-selectable-borderColor-selected); +} + +.saltOption[aria-selected="true"]:first-of-type::before { + border-top: unset; + top: 0; +} +.saltOption[aria-selected="true"]:last-of-type::before { + border-bottom: unset; + bottom: 0; } .saltOption[aria-disabled="true"] { diff --git a/packages/lab/src/option/OptionList.css b/packages/lab/src/option/OptionList.css index 3f647f91acc..f0be3d26ac5 100644 --- a/packages/lab/src/option/OptionList.css +++ b/packages/lab/src/option/OptionList.css @@ -9,6 +9,9 @@ max-height: inherit; min-height: inherit; box-sizing: border-box; + display: flex; + flex-direction: column; + gap: var(--salt-size-border); } .saltOptionList-collapsed { diff --git a/packages/lab/src/pill-input/PillInput.css b/packages/lab/src/pill-input/PillInput.css new file mode 100644 index 00000000000..ab3c93b9573 --- /dev/null +++ b/packages/lab/src/pill-input/PillInput.css @@ -0,0 +1,272 @@ +/* Style applied to the root element */ +.saltPillInput { + --input-borderColor: var(--salt-editable-borderColor); + --input-borderStyle: var(--salt-editable-borderStyle); + --input-outlineColor: var(--salt-focused-outlineColor); + --input-borderWidth: var(--salt-size-border); + + align-items: center; + background: var(--saltInput-background, var(--input-background)); + color: var(--saltInput-color, var(--salt-content-primary-foreground)); + display: inline-flex; + font-family: var(--salt-text-fontFamily); + font-size: var(--saltInput-fontSize, var(--salt-text-fontSize)); + line-height: var(--saltInput-lineHeight, var(--salt-text-lineHeight)); + min-height: var(--saltInput-minHeight, var(--salt-size-base)); + min-width: var(--saltInput-minWidth, 4em); + padding-left: var(--saltInput-paddingLeft, var(--salt-spacing-50)); + padding-right: var(--saltInput-paddingRight, var(--salt-spacing-100)); + position: relative; + width: 100%; + box-sizing: border-box; +} + +.saltPillInput-truncate { + height: var(--salt-size-base); +} + +.saltPillInput-truncate .saltPillInput-inputWrapper { + flex-wrap: nowrap; +} + +.saltPillInput:hover { + --input-borderStyle: var(--salt-editable-borderStyle-hover); + --input-borderColor: var(--salt-editable-borderColor-hover); + + background: var(--saltInput-background-hover, var(--input-background-hover)); + cursor: var(--salt-editable-cursor-hover); +} + +.saltPillInput:active { + --input-borderColor: var(--salt-editable-borderColor-active); + --input-borderStyle: var(--salt-editable-borderStyle-active); + --input-borderWidth: var(--salt-editable-borderWidth-active); + + background: var(--saltInput-background-active, var(--input-background-active)); + cursor: var(--salt-editable-cursor-active); +} + +/* Class applied if `variant="primary"` */ +.saltPillInput-primary { + --input-background: var(--salt-editable-primary-background); + --input-background-active: var(--salt-editable-primary-background-active); + --input-background-hover: var(--salt-editable-primary-background-hover); + --input-background-disabled: var(--salt-editable-primary-background-disabled); + --input-background-readonly: var(--salt-editable-primary-background-readonly); +} + +/* Class applied if `variant="secondary"` */ +.saltPillInput-secondary { + --input-background: var(--salt-editable-secondary-background); + --input-background-active: var(--salt-editable-secondary-background-active); + --input-background-hover: var(--salt-editable-secondary-background-active); + --input-background-disabled: var(--salt-editable-secondary-background-disabled); + --input-background-readonly: var(--salt-editable-secondary-background-readonly); +} + +/* Style applied to input if `validationState="error"` */ +.saltPillInput-error, +.saltPillInput-error:hover { + --input-background: var(--salt-status-error-background); + --input-background-active: var(--salt-status-error-background); + --input-background-hover: var(--salt-status-error-background); + --input-borderColor: var(--salt-status-error-borderColor); + --input-outlineColor: var(--salt-status-error-borderColor); +} + +/* Style applied to input if `validationState="warning"` */ +.saltPillInput-warning, +.saltPillInput-warning:hover { + --input-background: var(--salt-status-warning-background); + --input-background-active: var(--salt-status-warning-background); + --input-background-hover: var(--salt-status-warning-background); + --input-borderColor: var(--salt-status-warning-borderColor); + --input-outlineColor: var(--salt-status-warning-borderColor); +} + +/* Style applied to input if `validationState="success"` */ +.saltPillInput-success, +.saltPillInput-success:hover { + --input-background: var(--salt-status-success-background); + --input-background-active: var(--salt-status-success-background); + --input-background-hover: var(--salt-status-success-background); + --input-borderColor: var(--salt-status-success-borderColor); + --input-outlineColor: var(--salt-status-success-borderColor); +} + +/* Style applied to inner input component */ +.saltPillInput-input { + background: none; + border: none; + box-sizing: content-box; + color: inherit; + cursor: inherit; + display: block; + flex: 1; + font: inherit; + height: 100%; + letter-spacing: var(--saltInput-letterSpacing, 0); + margin: 0; + min-width: 0; + overflow: hidden; + padding: 0; + text-align: var(--input-textAlign); + width: 100%; +} + +/* Reset in the class */ +.saltPillInput-input:focus { + outline: none; +} + +/* Style applied to selected input */ +.saltPillInput-input::selection { + background: var(--salt-content-foreground-highlight); +} + +/* Style applied to placeholder text */ +.saltPillInput-input::placeholder { + color: var(--salt-content-secondary-foreground); + font-weight: var(--salt-text-fontWeight-small); +} + +/* Styling when focused */ +.saltPillInput-focused { + --input-borderColor: var(--input-outlineColor); + --input-borderWidth: var(--salt-editable-borderWidth-active); + + outline: var(--saltInput-outline, var(--salt-focused-outlineWidth) var(--salt-focused-outlineStyle) var(--input-outlineColor)); +} + +/* Style applied if `readOnly={true}` */ +.saltPillInput.saltPillInput-readOnly { + --input-borderColor: var(--salt-editable-borderColor-readonly); + --input-borderStyle: var(--salt-editable-borderStyle-readonly); + --input-borderWidth: var(--salt-size-border); + + background: var(--input-background-readonly); + cursor: var(--salt-editable-cursor-readonly); +} + +/* Styling when focused if `disabled={true}` */ +.saltPillInput-focused.saltPillInput-disabled { + --input-borderWidth: var(--salt-size-border); + outline: none; +} + +/* Styling when focused if `readOnly={true}` */ +.saltPillInput-focused.saltPillInput-readOnly { + --input-borderWidth: var(--salt-size-border); +} + +/* Style applied to selected input if `disabled={true}` */ +.saltPillInput-disabled .saltPillInput-input::selection { + background: none; +} + +/* Style applied to input if `disabled={true}` */ +.saltPillInput.saltPillInput-disabled, +.saltPillInput.saltPillInput-disabled:hover, +.saltPillInput.saltPillInput-disabled:active { + --input-borderColor: var(--salt-editable-borderColor-disabled); + --input-borderStyle: var(--salt-editable-borderStyle-disabled); + --input-borderWidth: var(--salt-size-border); + + background: var(--input-background-disabled); + cursor: var(--salt-editable-cursor-disabled); + color: var(--saltInput-color-disabled, var(--salt-content-primary-foreground-disabled)); +} + +.saltPillInput-activationIndicator { + left: 0; + bottom: 0; + width: 100%; + position: absolute; + border-bottom: var(--input-borderWidth) var(--input-borderStyle) var(--input-borderColor); +} + +/* Style applied to start adornments */ +.saltPillInput-startAdornmentContainer { + align-items: center; + display: inline-flex; + padding-right: var(--salt-spacing-100); + column-gap: var(--salt-spacing-100); +} + +/* Style applied to end adornments */ +.saltPillInput-endAdornmentContainer { + align-items: center; + display: inline-flex; + padding-left: var(--salt-spacing-100); + padding-top: var(--salt-spacing-50); + column-gap: var(--salt-spacing-100); + align-self: flex-start; +} + +.saltPillInput-readOnly .saltPillInput-startAdornmentContainer { + margin-left: var(--salt-spacing-50); +} + +.saltPillInput-startAdornmentContainer .saltButton ~ .saltButton { + margin-left: calc(-1 * var(--salt-spacing-50)); +} +.saltPillInput-endAdornmentContainer .saltButton ~ .saltButton { + margin-left: calc(-1 * var(--salt-spacing-50)); +} + +.saltPillInput-startAdornmentContainer .saltButton:first-child { + margin-left: calc(var(--salt-spacing-50) * -1); +} +.saltPillInput-endAdornmentContainer .saltButton:last-child { + margin-right: calc(var(--salt-spacing-50) * -1); +} + +.saltPillInput-startAdornmentContainer > .saltButton, +.saltPillInput-endAdornmentContainer > .saltButton { + --saltButton-padding: var(--salt-spacing-50); + --saltButton-height: calc(var(--salt-size-base) - var(--salt-spacing-100)); +} + +.saltPillInput-inputWrapper { + display: flex; + gap: var(--salt-spacing-50); + align-items: center; + flex: 1; + padding: var(--salt-spacing-50) 0; + flex-wrap: wrap; + max-height: inherit; + height: inherit; + overflow-y: auto; + box-sizing: border-box; +} + +.saltPillInput-pillList { + display: contents; +} + +.saltPillInput .saltPill:focus-visible { + background: var(--salt-selectable-background-hover); + outline: none; +} + +.saltPillInput .saltPill:hover { + background: var(--salt-selectable-background-hover); +} + +.saltPillInput .saltPill:active { + background: var(--salt-actionable-primary-background-active); + --saltPill-color: var(--salt-actionable-primary-foreground-active); + --saltIcon-color: var(--salt-actionable-primary-foreground-active); +} + +div[role="listitem"] { + display: inline; +} + +.saltPillInput-overflowIndicator { + width: calc(var(--salt-size-base) - var(--salt-spacing-100)); + height: calc(var(--salt-size-base) - var(--salt-spacing-100)); + display: flex; + align-items: center; + justify-content: center; +} diff --git a/packages/lab/src/pill-input/PillInput.tsx b/packages/lab/src/pill-input/PillInput.tsx new file mode 100644 index 00000000000..199a63d8f25 --- /dev/null +++ b/packages/lab/src/pill-input/PillInput.tsx @@ -0,0 +1,344 @@ +import { clsx } from "clsx"; +import { + ChangeEvent, + KeyboardEvent, + SyntheticEvent, + ComponentPropsWithoutRef, + MouseEvent, + ForwardedRef, + forwardRef, + InputHTMLAttributes, + ReactNode, + Ref, + useState, + useRef, +} from "react"; +import { useComponentCssInjection } from "@salt-ds/styles"; +import { useWindow } from "@salt-ds/window"; +import { + makePrefixer, + useControlled, + useFormFieldProps, + StatusAdornment, + useId, + Pill, + useForkRef, +} from "@salt-ds/core"; + +import pillInputCss from "./PillInput.css"; +import { CloseIcon, OverflowMenuIcon } from "@salt-ds/icons"; +import { useTruncatePills } from "./useTruncatePills"; + +const withBaseName = makePrefixer("saltPillInput"); + +export interface PillInputProps + extends Omit, "defaultValue">, + Pick< + ComponentPropsWithoutRef<"input">, + "disabled" | "value" | "defaultValue" | "placeholder" + > { + /** + * The marker to use in an empty read only Input. + * Use `''` to disable this feature. Defaults to '—'. + */ + emptyReadOnlyMarker?: string; + /** + * End adornment component + */ + endAdornment?: ReactNode; + /** + * [Attributes](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input#Attributes) applied to the `input` element. + */ + inputProps?: InputHTMLAttributes; + /** + * Optional ref for the input component + */ + inputRef?: Ref; + /** + * If `true`, the component is read only. + */ + readOnly?: boolean; + /** + * The tokens to display in the input. + */ + pills?: any[]; + onPillRemove?: (event: SyntheticEvent, index: number) => void; + /** + * Start adornment component + */ + startAdornment?: ReactNode; + /** + * Alignment of text within container. Defaults to "left" + */ + textAlign?: "left" | "center" | "right"; + /** + * Validation status. + */ + validationStatus?: "error" | "warning" | "success"; + /** + * Styling variant. Defaults to "primary". + */ + variant?: "primary" | "secondary"; + hidePillClose?: boolean; + truncate?: boolean; +} + +export const PillInput = forwardRef(function PillInput( + { + "aria-activedescendant": ariaActiveDescendant, + "aria-expanded": ariaExpanded, + "aria-owns": ariaOwns, + className: classNameProp, + disabled, + emptyReadOnlyMarker = "—", + endAdornment, + hidePillClose, + id: idProp, + inputProps = {}, + inputRef: inputRefProp, + placeholder, + pills = [], + onPillRemove, + readOnly: readOnlyProp, + role, + startAdornment, + style, + textAlign = "left", + value: valueProp, + defaultValue: defaultValueProp = valueProp === undefined ? "" : undefined, + validationStatus: validationStatusProp, + variant = "primary", + truncate, + ...other + }: PillInputProps, + ref: ForwardedRef +) { + const targetWindow = useWindow(); + useComponentCssInjection({ + testId: "salt-pill-input", + css: pillInputCss, + window: targetWindow, + }); + + const { + a11yProps: { + "aria-describedby": formFieldDescribedBy, + "aria-labelledby": formFieldLabelledBy, + } = {}, + disabled: formFieldDisabled, + readOnly: formFieldReadOnly, + necessity: formFieldRequired, + validationStatus: formFieldValidationStatus, + } = useFormFieldProps(); + + const restA11yProps = { + "aria-activedescendant": ariaActiveDescendant, + "aria-expanded": ariaExpanded, + "aria-owns": ariaOwns, + }; + + const isDisabled = disabled || formFieldDisabled; + const isReadOnly = readOnlyProp || formFieldReadOnly; + const validationStatus = formFieldValidationStatus ?? validationStatusProp; + + const [focusedPillIndex, setFocusedPillIndex] = useState(-1); + + const isEmptyReadOnly = isReadOnly && !defaultValueProp && !valueProp; + const defaultValue = isEmptyReadOnly ? emptyReadOnlyMarker : defaultValueProp; + + const { + "aria-describedby": inputDescribedBy, + "aria-labelledby": inputLabelledBy, + onChange, + required: inputPropsRequired, + onKeyDown: inputOnKeyDown, + ...restInputProps + } = inputProps; + + const isRequired = formFieldRequired + ? ["required", "asterisk"].includes(formFieldRequired) + : inputPropsRequired; + + const [value, setValue] = useControlled({ + controlled: valueProp, + default: defaultValue, + name: "Input", + state: "value", + }); + + const { visiblePills, pillListRef } = useTruncatePills({ + pills, + enable: truncate && pills.length > 0, + }); + + const id = useId(idProp); + const pillListId = `${id}-optionsList`; + + const pillElementsRef = useRef([]); + const inputRef = useRef(null); + + const handleInputRef = useForkRef(inputRef, inputRefProp); + + const handleChange = (event: ChangeEvent) => { + const value = event.target.value; + setValue(value); + onChange?.(event); + }; + + const handleKeyDown = (event: KeyboardEvent) => { + const target = event.currentTarget; + if (target.selectionStart === 0 && target.selectionEnd == 0) { + const lastPillIndex = pills.length - 1; + const lastPill = pills[lastPillIndex]; + if (event.key === "Backspace" && lastPill) { + event.preventDefault(); + onPillRemove?.(event, lastPillIndex); + } else if (event.key === "ArrowLeft") { + event.preventDefault(); + // Move focus to last pill + pillElementsRef.current[lastPillIndex]?.focus(); + } + } + + inputOnKeyDown?.(event); + }; + + const handlePillKeyDown = (event: KeyboardEvent) => { + const target = event.currentTarget; + const index = Number(target.dataset.index); + if (event.key === "ArrowLeft") { + event.preventDefault(); + // Move focus to previous pill + pillElementsRef.current[index - 1]?.focus(); + } else if (event.key === "ArrowRight") { + event.preventDefault(); + // Move focus to next pill or input + if (index === pills.length - 1) { + inputRef?.current?.focus(); + } else { + pillElementsRef.current[index + 1]?.focus(); + } + } else if (event.key == "Delete" || event.key === "Backspace") { + event.preventDefault(); + onPillRemove?.(event, index); + + if (pills.length === 1) { + inputRef.current?.focus(); + } else if (index === pills.length - 1) { + pillElementsRef.current[pills.length - 2]?.focus(); + } else { + pillElementsRef.current[index]?.focus(); + } + } + }; + + const handlePillClick = (event: MouseEvent) => { + const target = event.currentTarget; + const index = Number(target.dataset.index); + onPillRemove?.(event, index); + inputRef.current?.focus(); + }; + + const inputStyle = { + "--input-textAlign": textAlign, + ...style, + }; + + return ( +
+ {startAdornment && ( +
+ {startAdornment} +
+ )} +
+
+ {visiblePills?.map((pill, index) => ( +
+ { + if (element) { + pillElementsRef.current[index] = element; + } else { + pillElementsRef.current = pillElementsRef.current.filter( + (pillEl) => pillEl !== element + ); + } + }} + onFocus={() => setFocusedPillIndex(index)} + onKeyDown={handlePillKeyDown} + onClick={handlePillClick} + tabIndex={ + focusedPillIndex === -1 || focusedPillIndex === index ? 0 : -1 + } + > + {pill} + {!hidePillClose && } + +
+ ))} + {visiblePills.length < pills.length && ( +
+
+ +
+
+ )} +
+ +
+ {!isDisabled && !isReadOnly && validationStatus && ( + + )} + {endAdornment && ( +
+ {endAdornment} +
+ )} +
+
+ ); +}); diff --git a/packages/lab/src/pill-input/index.ts b/packages/lab/src/pill-input/index.ts new file mode 100644 index 00000000000..e57eb7755d2 --- /dev/null +++ b/packages/lab/src/pill-input/index.ts @@ -0,0 +1 @@ +export * from "./PillInput"; diff --git a/packages/lab/src/pill-input/useTruncatePills.ts b/packages/lab/src/pill-input/useTruncatePills.ts new file mode 100644 index 00000000000..d2e4adb2b83 --- /dev/null +++ b/packages/lab/src/pill-input/useTruncatePills.ts @@ -0,0 +1,95 @@ +import { useCallback, useRef } from "react"; +import { useIsomorphicLayoutEffect } from "@salt-ds/core"; +import { useValueEffect } from "../utils/useValueEffect"; +import { useWindow } from "@salt-ds/window"; +import { useResizeObserver } from "../responsive"; + +export function useTruncatePills({ + pills, + enable, +}: { + pills: string[]; + enable?: boolean; +}) { + const pillListRef = useRef(null); + const [{ visibleCount }, setVisibleItems] = useValueEffect({ + visibleCount: pills.length, + }); + const targetWindow = useWindow(); + + const updateOverflow = useCallback(() => { + if (!enable) { + return; + } + + const computeVisible = (visibleCount: number) => { + const pillList = pillListRef.current; + + if (pillList && targetWindow) { + let pillElements = Array.from( + pillList.querySelectorAll('[role="listitem"]') + ) as HTMLLIElement[]; + const maxWidth = pillList.getBoundingClientRect().width; + const listGap = parseInt(targetWindow.getComputedStyle(pillList).gap); + let isShowingOverflow = pillList.querySelector( + "[data-overflowindicator]" + ); + + let currentSize = 0; + let newVisibleCount = 0; + + if (isShowingOverflow) { + let pill = pillElements.pop(); + if (pill) { + let pillWidth = pill.getBoundingClientRect().width; + currentSize += pillWidth + listGap; + } + } + + for (let pill of pillElements) { + let pillWidth = pill.getBoundingClientRect().width; + currentSize += pillWidth + listGap; + + if (Math.round(currentSize) <= Math.round(maxWidth)) { + newVisibleCount++; + } else { + break; + } + } + return newVisibleCount; + } + return visibleCount; + }; + + setVisibleItems(function* () { + // Show all + yield { + visibleCount: pills.length, + }; + + // Measure the visible count + let newVisibleCount = computeVisible(pills.length); + let isMeasuring = newVisibleCount < pills.length && newVisibleCount > 0; + yield { + visibleCount: newVisibleCount, + }; + + // ensure the visible count is correct + if (isMeasuring) { + newVisibleCount = computeVisible(visibleCount); + yield { + visibleCount: newVisibleCount, + }; + } + }); + }, [pills, setVisibleItems, enable, targetWindow]); + + useIsomorphicLayoutEffect(updateOverflow, [updateOverflow, pills]); + useResizeObserver(pillListRef, ["width"], updateOverflow, true); + + return { + pillListRef, + visibleCount, + visiblePills: enable ? pills.slice(0, visibleCount) : pills, + }; +} diff --git a/packages/lab/src/utils/useValueEffect.ts b/packages/lab/src/utils/useValueEffect.ts new file mode 100644 index 00000000000..85baa1bd392 --- /dev/null +++ b/packages/lab/src/utils/useValueEffect.ts @@ -0,0 +1,68 @@ +/* + * Copyright 2020 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import { Dispatch, MutableRefObject, useRef, useState } from "react"; +import { useIsomorphicLayoutEffect } from "@salt-ds/core"; +import { useEventCallback } from "./useEventCallback"; + +type SetValueAction = (prev: S) => Generator; + +// This hook works like `useState`, but when setting the value, you pass a generator function +// that can yield multiple values. Each yielded value updates the state and waits for the next +// layout effect, then continues the generator. This allows sequential updates to state to be +// written linearly. +export function useValueEffect( + defaultValue: S | (() => S) +): [S, Dispatch>] { + let [value, setValue] = useState(defaultValue); + let effect: MutableRefObject | null> = + useRef | null>(null); + + // Store the function in a ref so we can always access the current version + // which has the proper `value` in scope. + let nextRef = useEventCallback(() => { + if (!effect.current) { + return; + } + // Run the generator to the next yield. + let newValue = effect.current.next(); + + // If the generator is done, reset the effect. + if (newValue.done) { + effect.current = null; + return; + } + + // If the value is the same as the current value, + // then continue to the next yield. Otherwise, + // set the value in state and wait for the next layout effect. + if (value === newValue.value) { + nextRef(); + } else { + setValue(newValue.value); + } + }); + + useIsomorphicLayoutEffect(() => { + // If there is an effect currently running, continue to the next yield. + if (effect.current) { + nextRef(); + } + }); + + let queue: Dispatch> = useEventCallback((fn) => { + effect.current = fn(value); + nextRef(); + }); + + return [value, queue]; +} diff --git a/packages/lab/stories/combo-box-next/combo-box-next.qa.stories.tsx b/packages/lab/stories/combo-box-next/combo-box-next.qa.stories.tsx index 8d146272a29..af79da322ea 100644 --- a/packages/lab/stories/combo-box-next/combo-box-next.qa.stories.tsx +++ b/packages/lab/stories/combo-box-next/combo-box-next.qa.stories.tsx @@ -1,6 +1,7 @@ import { Meta, StoryFn } from "@storybook/react"; import { QAContainer, QAContainerProps } from "docs/components"; -import { ComboBoxNext, FormField, Option, OptionGroup } from "@salt-ds/lab"; +import { FormField, FormFieldLabel, FormFieldHelperText } from "@salt-ds/core"; +import { ComboBoxNext, Option, OptionGroup } from "@salt-ds/lab"; import { usStateExampleData } from "../assets/exampleData"; @@ -18,9 +19,12 @@ const groupedOptions = usStateExampleData.slice(0, 5).reduce((acc, option) => { return acc; }, {} as Record); +const first5States = usStateExampleData.slice(0, 5); + export const OpenExamples: StoryFn = () => ( - + + Default example {Object.entries(groupedOptions).map(([firstLetter, options]) => ( @@ -32,6 +36,7 @@ export const OpenExamples: StoryFn = () => ( ))} + This is some help text ); @@ -40,25 +45,120 @@ OpenExamples.parameters = { chromatic: { disableSnapshot: false }, }; +export const OpenMultiselectExamples: StoryFn = () => ( + + + Multi-select example + + {first5States.map((state) => ( + + ))} + + This is some help text + + +); + +OpenMultiselectExamples.parameters = { + chromatic: { disableSnapshot: false }, +}; + export const ClosedExamples: StoryFn = () => ( - + + Default example + + {first5States.map((state) => ( + + ))} + + This is some help text + + + Read-only example - {usStateExampleData.map((state) => ( + {first5States.map((state) => ( ))} + This is some help text - + + Disabled example - {usStateExampleData.map((state) => ( + {first5States.map((state) => ( + + ))} + + This is some help text + + + Disabled multi-select example + + {first5States.map((state) => ( + + ))} + + This is some help text + + + Read-only multi-select example + + {first5States.map((state) => ( + + ))} + + This is some help text + + + Read-only truncated multi-select example + + {first5States.map((state) => ( + + ))} + + This is some help text + + + Multi-select example + + {first5States.map((state) => ( + + ))} + + This is some help text + + + Truncated multi-select example + + {first5States.map((state) => ( ))} + This is some help text ); diff --git a/packages/lab/stories/combo-box-next/combo-box-next.stories.tsx b/packages/lab/stories/combo-box-next/combo-box-next.stories.tsx index 5b9cc471fd6..165c3047021 100644 --- a/packages/lab/stories/combo-box-next/combo-box-next.stories.tsx +++ b/packages/lab/stories/combo-box-next/combo-box-next.stories.tsx @@ -36,10 +36,27 @@ export default { const usStates = usStateExampleData.slice(0, 10); +function getTemplateDefaultValue({ + defaultValue, + defaultSelected, + multiselect, +}: Pick< + ComboBoxNextProps, + "defaultValue" | "defaultSelected" | "multiselect" +>): string { + if (multiselect) { + return ""; + } + + if (defaultValue) { + return String(defaultValue); + } + + return defaultSelected?.[0] ?? ""; +} + const Template: StoryFn = (args) => { - const [value, setValue] = useState( - args.defaultValue?.toString() ?? args.defaultSelected?.[0] ?? "" - ); + const [value, setValue] = useState(getTemplateDefaultValue(args)); const handleChange = (event: ChangeEvent) => { // React 16 backwards compatibility @@ -625,3 +642,16 @@ export const ObjectValue: StoryFn> = (args) => { ); }; + +export const MultiplePills = Template.bind({}); +MultiplePills.args = { + defaultSelected: ["Alabama", "Alaska", "Arizona"], + multiselect: true, +}; + +export const MultiplePillsTruncated = Template.bind({}); +MultiplePillsTruncated.args = { + defaultSelected: ["Alabama", "Alaska", "Arizona"], + multiselect: true, + truncate: true, +}; diff --git a/site/docs/components/combo-box/examples.mdx b/site/docs/components/combo-box/examples.mdx index 1f5c37cba0f..ad603d056bc 100644 --- a/site/docs/components/combo-box/examples.mdx +++ b/site/docs/components/combo-box/examples.mdx @@ -27,7 +27,7 @@ A single item can be selected from the combo box options. ## Multi-select -When `multiSelect={true}`, users can select multiple items from the combo box options. +When `multiSelect={true}`, users can select multiple items from the combo box options. Selected options are displayed as pills in the input field. @@ -168,5 +168,13 @@ Note that the data fetching in this example has been made intentionally slow to In order to support advanced use-cases, like where the options are fetched from a server, the `value` prop passed to `Option` can be an object. When the `value` prop is an object, the `valueToString` prop must be provided to `ComboBoxNext` to specify how to display the option in the input field. + + + + +## Truncation + +Use the `truncate` prop when you are limited on space and want to prevent the multi-select combobox from spanning multiple lines. + diff --git a/site/src/examples/combo-box/Multiselect.tsx b/site/src/examples/combo-box/Multiselect.tsx index 12abeee6858..322a154f986 100644 --- a/site/src/examples/combo-box/Multiselect.tsx +++ b/site/src/examples/combo-box/Multiselect.tsx @@ -1,47 +1,33 @@ import { ChangeEvent, ReactElement, useState } from "react"; import { ComboBoxNext, ComboBoxNextProps, Option } from "@salt-ds/lab"; import { shortColorData } from "./exampleData"; -import { FlowLayout, StackLayout, Pill } from "@salt-ds/core"; export const Multiselect = (): ReactElement => { const [value, setValue] = useState(""); - const [selected, setSelected] = useState([]); - const handleChange = (event: ChangeEvent) => { const value = event.target.value; setValue(value); }; - const handleSelectionChange: ComboBoxNextProps["onSelectionChange"] = ( - event, - newSelected - ) => { + const handleSelectionChange: ComboBoxNextProps["onSelectionChange"] = () => { setValue(""); - setSelected(newSelected); }; return ( - - - {selected.map((color) => ( - {color} + + {shortColorData + .filter((color) => + color.toLowerCase().includes(value.trim().toLowerCase()) + ) + .map((color) => ( + - - {shortColorData - .filter((color) => - color.toLowerCase().includes(value.trim().toLowerCase()) - ) - .map((color) => ( - - + ); }; diff --git a/site/src/examples/combo-box/Truncation.tsx b/site/src/examples/combo-box/Truncation.tsx new file mode 100644 index 00000000000..c9d086a39d5 --- /dev/null +++ b/site/src/examples/combo-box/Truncation.tsx @@ -0,0 +1,35 @@ +import { ChangeEvent, ReactElement, useState } from "react"; +import { ComboBoxNext, ComboBoxNextProps, Option } from "@salt-ds/lab"; +import { shortColorData } from "./exampleData"; + +export const Truncation = (): ReactElement => { + const [value, setValue] = useState(""); + const handleChange = (event: ChangeEvent) => { + const value = event.target.value; + setValue(value); + }; + + const handleSelectionChange: ComboBoxNextProps["onSelectionChange"] = () => { + setValue(""); + }; + + return ( + + {shortColorData + .filter((color) => + color.toLowerCase().includes(value.trim().toLowerCase()) + ) + .map((color) => ( + + ); +}; diff --git a/site/src/examples/combo-box/index.ts b/site/src/examples/combo-box/index.ts index ec4bfe2435d..ab5451bae24 100644 --- a/site/src/examples/combo-box/index.ts +++ b/site/src/examples/combo-box/index.ts @@ -13,3 +13,4 @@ export * from "./Validation"; export * from "./CustomFiltering"; export * from "./ServerSideData"; export * from "./ObjectValues"; +export * from "./Truncation";