From bbc4ccb1afe1b773b4bfe4c20b2b8c3ec1bdf7a9 Mon Sep 17 00:00:00 2001 From: Fernanda Castillo Date: Mon, 15 Jan 2024 14:17:25 +0000 Subject: [PATCH] Tokenized input next (#2798) Co-authored-by: lilyvc <45168453+lilyvc@users.noreply.github.com> --- .changeset/lucky-worms-punch.md | 11 + .../form-field-context/FormFieldContext.ts | 2 +- .../TokenizedInputNext.cy.tsx | 179 +++++ packages/lab/src/index.ts | 1 + .../TokenizedInputNext.css | 225 ++++++ .../TokenizedInputNext.tsx | 376 ++++++++++ .../lab/src/tokenized-input-next/index.ts | 2 + .../internal/InputPill.css | 30 + .../internal/InputPill.tsx | 116 +++ .../internal/calcFirstHiddenIndex.ts | 18 + .../internal/useResizeObserver.ts | 46 ++ .../tokenized-input-next/internal/useWidth.ts | 56 ++ .../useTokenizedInputNext.tsx | 677 ++++++++++++++++++ .../tokenized-input-next.qa.stories.tsx | 37 + .../tokenized-input-next.stories.mdx | 108 +++ .../tokenized-input-next.stories.tsx | 336 +++++++++ .../tokenized-input-next/accessibility.mdx | 103 +++ .../tokenized-input-next/examples.mdx | 34 + .../components/tokenized-input-next/index.mdx | 19 + .../components/tokenized-input-next/usage.mdx | 31 + .../examples/tokenized-input-next/Default.tsx | 10 + .../tokenized-input-next/Disabled.tsx | 10 + .../WithCustomizedDelimiter.tsx | 6 + .../examples/tokenized-input-next/index.ts | 3 + 24 files changed, 2435 insertions(+), 1 deletion(-) create mode 100644 .changeset/lucky-worms-punch.md create mode 100644 packages/lab/src/__tests__/__e2e__/tokenized-input-next/TokenizedInputNext.cy.tsx create mode 100644 packages/lab/src/tokenized-input-next/TokenizedInputNext.css create mode 100644 packages/lab/src/tokenized-input-next/TokenizedInputNext.tsx create mode 100644 packages/lab/src/tokenized-input-next/index.ts create mode 100644 packages/lab/src/tokenized-input-next/internal/InputPill.css create mode 100644 packages/lab/src/tokenized-input-next/internal/InputPill.tsx create mode 100644 packages/lab/src/tokenized-input-next/internal/calcFirstHiddenIndex.ts create mode 100644 packages/lab/src/tokenized-input-next/internal/useResizeObserver.ts create mode 100644 packages/lab/src/tokenized-input-next/internal/useWidth.ts create mode 100644 packages/lab/src/tokenized-input-next/useTokenizedInputNext.tsx create mode 100644 packages/lab/stories/tokenized-input-next/tokenized-input-next.qa.stories.tsx create mode 100644 packages/lab/stories/tokenized-input-next/tokenized-input-next.stories.mdx create mode 100644 packages/lab/stories/tokenized-input-next/tokenized-input-next.stories.tsx create mode 100644 site/docs/components/tokenized-input-next/accessibility.mdx create mode 100644 site/docs/components/tokenized-input-next/examples.mdx create mode 100644 site/docs/components/tokenized-input-next/index.mdx create mode 100644 site/docs/components/tokenized-input-next/usage.mdx create mode 100644 site/src/examples/tokenized-input-next/Default.tsx create mode 100644 site/src/examples/tokenized-input-next/Disabled.tsx create mode 100644 site/src/examples/tokenized-input-next/WithCustomizedDelimiter.tsx create mode 100644 site/src/examples/tokenized-input-next/index.ts diff --git a/.changeset/lucky-worms-punch.md b/.changeset/lucky-worms-punch.md new file mode 100644 index 00000000000..e8661cb249b --- /dev/null +++ b/.changeset/lucky-worms-punch.md @@ -0,0 +1,11 @@ +--- +"@salt-ds/lab": patch +--- + +Added TokenizedInputNext to lab. + +Tokenized input provides an input field for text that’s converted into a pill within the field, or tokenized, when the user enters a delimiting character. + +```tsx + +``` diff --git a/packages/core/src/form-field-context/FormFieldContext.ts b/packages/core/src/form-field-context/FormFieldContext.ts index 9c3d2222b82..617b9045ef4 100644 --- a/packages/core/src/form-field-context/FormFieldContext.ts +++ b/packages/core/src/form-field-context/FormFieldContext.ts @@ -11,7 +11,7 @@ export interface A11yValueProps { labelId?: string; } -type NecessityType = "required" | "optional" | "asterisk"; +export type NecessityType = "required" | "optional" | "asterisk"; export interface a11yValueAriaProps { "aria-labelledby": A11yValueProps["labelId"]; diff --git a/packages/lab/src/__tests__/__e2e__/tokenized-input-next/TokenizedInputNext.cy.tsx b/packages/lab/src/__tests__/__e2e__/tokenized-input-next/TokenizedInputNext.cy.tsx new file mode 100644 index 00000000000..99510df17d5 --- /dev/null +++ b/packages/lab/src/__tests__/__e2e__/tokenized-input-next/TokenizedInputNext.cy.tsx @@ -0,0 +1,179 @@ +import { composeStories } from "@storybook/react"; +import * as tokenizedInputNextStories from "@stories/tokenized-input-next/tokenized-input-next.stories"; +import { checkAccessibility } from "../../../../../../cypress/tests/checkAccessibility"; + +const composedStories = composeStories(tokenizedInputNextStories); + +const { Default, WithCollapsedButton } = composedStories; + +describe("GIVEN a Tokenized Input", () => { + checkAccessibility(composedStories); + describe("WHEN disabled", () => { + it("SHOULD mount as disabled", () => { + cy.mount(); + cy.findByRole("textbox").should("be.disabled"); + }); + }); + describe("WHEN readonly", () => { + it("SHOULD mount as readonly", () => { + cy.mount(); + cy.findByRole("textbox").should("have.attr", "readonly"); + }); + it("should not allow to remove or add items", () => { + cy.mount(); + cy.findByRole("option").should("exist"); + // Remove the item + cy.realPress("ArrowLeft"); + cy.realPress("Backspace"); + + // pill should not have been removed + cy.findByRole("option").should("exist"); + }); + }); + describe("WHEN mounted as an uncontrolled component", () => { + it("should render the Tokenized Input with pre selected items", () => { + cy.mount(); + cy.findByRole("textbox").should("exist"); + cy.findByTestId("expand-button").should("exist"); + }); + it("should allow adding items by typing and pressing the delimiter", () => { + cy.mount(); + cy.findByRole("textbox").focus(); + cy.findByRole("textbox").type("Tokio,"); + cy.findByRole("option").should("exist"); + }); + it.skip("should highlihht pills if navigating with arrows", () => { + // FIXME: option and testid are in the button, not pill wrapper + cy.mount(); + cy.findByRole("textbox").focus(); + + // navigating trough pills with arrows + cy.realPress("ArrowLeft"); + cy.findAllByRole("option") + .eq(2) + .should("have.class", "saltInputPill-pillHighlighted"); + cy.realPress("ArrowLeft"); + cy.realPress("ArrowRight"); + cy.findAllByRole("option") + .eq(1) + .should("have.class", "saltInputPill-pillHighlighted"); + }); + it("should be able to change delimiter", () => { + cy.mount(); + cy.findByRole("textbox").focus(); + cy.findByRole("textbox").type("Tokio, Delhi, Shanghai"); + cy.findAllByRole("option").should("have.length", 0); + cy.findByRole("textbox").type("{selectall}{backspace}"); + cy.findByRole("textbox").type("Tokio; Delhi; Shanghai;"); + cy.findAllByRole("option").should("have.length", 3); + }); + it("should be able to take an array of delimiters", () => { + cy.mount(); + cy.findByRole("textbox").focus(); + cy.findByRole("textbox").type("Tokio, Delhi, Shanghai"); + cy.findAllByRole("option").should("have.length", 0); + cy.findByRole("textbox").type("{selectall}{backspace}"); + cy.findByRole("textbox").type("Tokio; Delhi/ Shanghai."); + cy.findAllByRole("option").should("have.length", 3); + }); + it("should allow removing items by clicking on the close button", () => { + cy.mount(); + cy.findByRole("textbox").should("exist"); + cy.findByRole("textbox").focus(); + // pill should exist + cy.findByRole("option").should("exist"); + + // Remove the item + cy.realPress("ArrowLeft"); + cy.realPress("Backspace"); + + // pill should not exist after removal + cy.findByRole("option").should("not.exist"); + }); + + it("should clear input on clicking the clear button", () => { + cy.mount(); + cy.findByRole("textbox").focus(); + cy.findByTestId("clear-button").realClick(); + cy.findByRole("textbox").should("have.value", ""); + }); + it("should expand on clicking the expand button and collapse when blur", () => { + cy.mount(); + cy.findByRole("textbox").focus(); + cy.findAllByRole("option").should("have.length", 50); + cy.get('[data-testid="pill"]').eq(49).should("be.visible"); + // Move focus out of Tokenized input + cy.realPress("Tab"); + cy.realPress("Tab"); + + cy.findByRole("textbox").should("not.be.focused"); + cy.get('[data-testid="pill"]').should("have.length", 50); + cy.findAllByTestId("pill").eq(49).should("not.be.visible"); + }); + it("should not display the clear button if there is no selection", () => { + cy.mount(); + cy.findByTestId("clear-button").should("not.exist"); + }); + it("should return focus to input if an item is closed", () => { + cy.mount(); + cy.findByRole("textbox").focus(); + // clear + cy.findByTestId("clear-button").realClick(); + cy.findByRole("textbox").should("have.focus"); + }); + it("should trigger event callbacks when actions are prompted", () => { + const onChangeSpy = cy.spy().as("onChange"); + const onClearSpy = cy.spy().as("onClear"); + const onExpandSpy = cy.spy().as("onExpand"); + const onCollapseSpy = cy.spy().as("onCollapse"); + cy.mount( + + ); + // expand + cy.findByTestId("expand-button").should("exist"); + cy.findByTestId("expand-button").realClick(); + // Move focus out of Tokenized input + cy.realPress("Tab"); + cy.realPress("Tab"); + //clear input + cy.findByRole("textbox").focus(); + cy.findByTestId("clear-button").realClick(); + cy.findByRole("textbox").should("have.focus"); + // type and add an item + cy.findByRole("textbox").type("Tokio,"); + cy.findByRole("option").should("exist"); + + cy.get("@onClear").should("have.been.called"); + cy.get("@onExpand").should("have.been.called"); + cy.get("@onChange").should("have.been.called"); + }); + }); + + describe("WHEN mounted as a controlled component", () => { + it("THEN have the specified value", () => { + cy.mount(); + cy.findByRole("textbox").should("have.value", "Tokio"); + }); + + describe("THEN the user input is updated", () => { + it("SHOULD call onChange with the new value", () => { + const changeSpy = cy.stub().as("changeSpy"); + cy.mount( + + ); + cy.findByRole("textbox").focus(); + cy.findByRole("textbox").click().clear().type("Mexico City,"); + cy.get("@changeSpy").should("have.been.called"); + }); + }); + }); +}); diff --git a/packages/lab/src/index.ts b/packages/lab/src/index.ts index 1dc8d5ea054..508555cad57 100644 --- a/packages/lab/src/index.ts +++ b/packages/lab/src/index.ts @@ -74,6 +74,7 @@ export * from "./tabs"; export * from "./tabs-next"; export * from "./toast-group"; export * from "./tokenized-input"; +export * from "./tokenized-input-next"; export * from "./toolbar"; export * from "./tree"; export * from "./utils"; diff --git a/packages/lab/src/tokenized-input-next/TokenizedInputNext.css b/packages/lab/src/tokenized-input-next/TokenizedInputNext.css new file mode 100644 index 00000000000..90d3e0e9af6 --- /dev/null +++ b/packages/lab/src/tokenized-input-next/TokenizedInputNext.css @@ -0,0 +1,225 @@ +/* Style applied to the root element */ +.saltTokenizedInputNext-container { + width: 100%; +} +.saltTokenizedInputNext { + --tokenizedInput-borderColor: var(--salt-editable-borderColor); + --tokenizedInput-borderStyle: var(--salt-editable-borderStyle); + --tokenizedInput-outlineColor: var(--salt-focused-outlineColor); + --tokenizedInput-border: none; + --tokenizedInput-activationIndicator-borderWidth: var(--salt-size-border); + + align-items: center; + background: var(--tokenizedInput-background); + border: var(--tokenizedInput-border); + color: var(--salt-content-primary-foreground); + display: inline-flex; + flex-wrap: wrap; + font-family: var(--salt-text-fontFamily); + font-size: var(--salt-text-fontSize); + height: 100%; + line-height: var(--salt-text-lineHeight); + min-height: var(--salt-size-base); + padding: 0 var(--salt-spacing-100); + position: relative; + width: 100%; + overflow: hidden; +} + +/* Style applied on hover */ +.saltTokenizedInputNext:hover { + --tokenizedInput-borderStyle: var(--salt-editable-borderStyle-hover); + --tokenizedInput-borderColor: var(--salt-editable-borderColor-hover); + + background: var(--tokenizedInput-background-hover); + cursor: var(--salt-editable-cursor-hover); +} + +/* Style applied when active */ +.saltTokenizedInputNext:active { + --tokenizedInput-borderColor: var(--salt-editable-borderColor-active); + --tokenizedInput-borderStyle: var(--salt-editable-borderStyle-active); + --tokenizedInput-activationIndicator-borderWidth: var(--salt-editable-borderWidth-active); + + background: var(--tokenizedInput-background-active); + cursor: var(--salt-editable-cursor-active); +} + +/* Class applied if `variant="primary"` */ +.saltTokenizedInputNext-primary { + --tokenizedInput-background: var(--salt-editable-primary-background); + --tokenizedInput-background-active: var(--salt-editable-primary-background-active); + --tokenizedInput-background-hover: var(--salt-editable-primary-background-hover); + --tokenizedInput-background-disabled: var(--salt-editable-primary-background-disabled); + --tokenizedInput-background-readonly: var(--salt-editable-primary-background-readonly); +} + +/* Class applied if `variant="secondary"` */ +.saltTokenizedInputNext-secondary { + --tokenizedInput-background: var(--salt-editable-secondary-background); + --tokenizedInput-background-active: var(--salt-editable-secondary-background-active); + --tokenizedInput-background-hover: var(--salt-editable-secondary-background-active); + --tokenizedInput-background-disabled: var(--salt-editable-secondary-background-disabled); + --tokenizedInput-background-readonly: var(--salt-editable-secondary-background-readonly); +} + +/* Style applied to input if `validationState="error"` */ +.saltTokenizedInputNext-error, +.saltTokenizedInputNext-error:hover { + --tokenizedInput-background: var(--salt-status-error-background); + --tokenizedInput-background-active: var(--salt-status-error-background); + --tokenizedInput-background-hover: var(--salt-status-error-background); + --tokenizedInput-borderColor: var(--salt-status-error-borderColor); + --tokenizedInput-outlineColor: var(--salt-status-error-borderColor); +} + +/* Style applied to input if `validationState="warning"` */ +.saltTokenizedInputNext-warning, +.saltTokenizedInputNext-warning:hover { + --tokenizedInput-background: var(--salt-status-warning-background); + --tokenizedInput-background-active: var(--salt-status-warning-background); + --tokenizedInput-background-hover: var(--salt-status-warning-background); + --tokenizedInput-borderColor: var(--salt-status-warning-borderColor); + --tokenizedInput-outlineColor: var(--salt-status-warning-borderColor); +} + +/* Style applied to input if `validationState="success"` */ +.saltTokenizedInputNext-success, +.saltTokenizedInputNext-success:hover { + --tokenizedInput-background: var(--salt-status-success-background); + --tokenizedInput-background-active: var(--salt-status-success-background); + --tokenizedInput-background-hover: var(--salt-status-success-background); + --tokenizedInput-borderColor: var(--salt-status-success-borderColor); + --tokenizedInput-outlineColor: var(--salt-status-success-borderColor); +} + +/* Style applied to inner textarea element */ +.saltTokenizedInputNext-textarea { + background: none; + border: none; + box-sizing: content-box; + color: inherit; + cursor: inherit; + display: inline-flex; + flex-basis: 0; + height: var(--salt-text-lineHeight); + font: inherit; + letter-spacing: 0; + overflow: hidden; + resize: none; + padding: 0; + min-width: 1px; /* requires a min width to be visible */ +} +.saltTokenizedInputNext-expanded .saltTokenizedInputNext-textarea { + flex-grow: 1; + min-width: 4em; /* on expanded, use the same min-width as input*/ +} + +/* Style applied to placeholder */ +.saltTokenizedInputNext-textarea::placeholder { + font-weight: var(--salt-text-fontWeight-small); +} + +/* Reset in the class */ +.saltTokenizedInputNext-textarea:focus { + outline: none; +} + +/* Style applied to selected input */ +.saltTokenizedInputNext-textarea::selection { + background: var(--salt-content-foreground-highlight); +} + +/* Styling when focused */ +.saltTokenizedInputNext-focused { + --tokenizedInput-borderColor: var(--tokenizedInput-outlineColor); + --tokenizedInput-activationIndicator-borderWidth: var(--salt-editable-borderWidth-active); + + outline: var(--salt-focused-outlineWidth) var(--salt-focused-outlineStyle) var(--tokenizedInput-outlineColor); +} + +/* Style applied if `readOnly={true}` */ +.saltTokenizedInputNext-readOnly, +.saltTokenizedInputNext-readOnly:active, +.saltTokenizedInputNext-readOnly:hover { + --tokenizedInput-borderColor: var(--salt-editable-borderColor-readonly); + --tokenizedInput-borderStyle: var(--salt-editable-borderStyle-readonly); + --tokenizedInput-activationIndicator-borderWidth: var(--salt-size-border); + + background: var(--tokenizedInput-background-readonly); + cursor: var(--salt-editable-cursor-readonly); +} + +/* Style applied to selected text if `disabled={true}` */ +.saltTokenizedInputNext-disabled .saltTokenizedInputNext-textarea::selection { + background: none; +} + +/* Style applied when `disabled={true}` */ +.saltTokenizedInputNext-disabled, +.saltTokenizedInputNext-disabled:hover, +.saltTokenizedInputNext-disabled:active { + --tokenizedInput-borderColor: var(--salt-editable-borderColor-disabled); + --tokenizedInput-borderStyle: var(--salt-editable-borderStyle-disabled); + --tokenizedInput-activationIndicator-borderWidth: var(--salt-size-border); + + background: var(--tokenizedInput-background-disabled); + cursor: var(--salt-editable-cursor-disabled); + color: var(--salt-content-primary-foreground-disabled); +} + +/* Style for activation indicator */ +.saltTokenizedInputNext-activationIndicator { + left: 0; + bottom: 0; + width: 100%; + position: absolute; + border-bottom: var(--tokenizedInput-activationIndicator-borderWidth) var(--tokenizedInput-borderStyle) var(--tokenizedInput-borderColor); +} + +/* Style applied if `bordered={true}` */ +.saltTokenizedInputNext.saltTokenizedInputNext-bordered { + --tokenizedInput-border: var(--salt-size-border) var(--salt-container-borderStyle) var(--tokenizedInput-borderColor); + --tokenizedInput-activationIndicator-borderWidth: 0; +} + +/* Style applied if active or focused when `bordered={true}` */ +.saltTokenizedInputNext-bordered:active, +.saltTokenizedInputNext-bordered.saltTokenizedInputNext-focused { + --tokenizedInput-activationIndicator-borderWidth: var(--salt-editable-borderWidth-active); +} + +/* Styling when focused if `disabled={true}` or `readOnly={true}` when `bordered={true}` */ +.saltTokenizedInputNext-bordered.saltTokenizedInputNext-readOnly:hover, +.saltTokenizedInputNext-bordered.saltTokenizedInputNext-disabled:hover { + --tokenizedInput-activationIndicator-borderWidth: 0; +} + +.saltTokenizedInputNext-statusAdornment { + margin-left: auto; + margin-right: var(--salt-spacing-100); +} + +.saltTokenizedInputNext-endAdornmentContainer { + margin-left: auto; + align-self: self-end; + display: inline-flex; + min-height: var(--salt-size-base); +} + +.saltTokenizedInputNext-statusAdornment ~ .saltTokenizedInputNext-endAdornmentContainer { + margin-left: 0; +} + +.saltTokenizedInputNext .saltButton { + --saltButton-padding: 0; + --saltButton-height: calc(var(--salt-size-base) - var(--salt-spacing-100)); + --saltButton-width: calc(var(--salt-size-base) - var(--salt-spacing-100)); +} + +.saltTokenizedInputNext .saltButton.saltTokenizedInputNext-endAdornment { + --saltButton-margin: auto calc(var(--salt-spacing-50) * -1) auto auto; +} +.saltTokenizedInputNext-hidden { + display: none; +} diff --git a/packages/lab/src/tokenized-input-next/TokenizedInputNext.tsx b/packages/lab/src/tokenized-input-next/TokenizedInputNext.tsx new file mode 100644 index 00000000000..bc509be13b2 --- /dev/null +++ b/packages/lab/src/tokenized-input-next/TokenizedInputNext.tsx @@ -0,0 +1,376 @@ +import { + AdornmentValidationStatus, + Button, + ButtonProps, + makePrefixer, + NecessityType, + StatusAdornment, + useForkRef, + useId, + ValidationStatus, +} from "@salt-ds/core"; +import { + ChangeEventHandler, + FocusEvent, + FocusEventHandler, + ForwardedRef, + forwardRef, + HTMLAttributes, + KeyboardEvent, + KeyboardEventHandler, + ReactEventHandler, + Ref, + SyntheticEvent, + TextareaHTMLAttributes, + useRef, +} from "react"; +import { + TokenizedInputNextHelpers, + TokenizedInputNextState, + useTokenizedInputNext, +} from "./useTokenizedInputNext"; +import { clsx } from "clsx"; +import { InputPill } from "./internal/InputPill"; +import { CloseIcon, OverflowMenuIcon } from "@salt-ds/icons"; +import { useWindow } from "@salt-ds/window"; +import { useComponentCssInjection } from "@salt-ds/styles"; +import tokenizedInputCss from "./TokenizedInputNext.css"; + +type ChangeHandler = ( + event: SyntheticEvent, + selectedItems: Item[] | undefined +) => void; + +type ExpandButtonProps = Pick< + ButtonProps, + "role" | "aria-roledescription" | "aria-describedby" +> & { "aria-label"?: string }; + +export interface TokenizedInputNextProps + extends Partial>, + Omit< + HTMLAttributes, + "onFocus" | "onBlur" | "onChange" | "onKeyUp" | "onKeyDown" + > { + ExpandButtonProps?: ExpandButtonProps; + disabled?: boolean; + focused?: boolean; + expandButtonRef?: Ref; + onBlur?: FocusEventHandler; + onKeyUp?: KeyboardEventHandler; + // Can key down on either input or expand button + onKeyDown?: KeyboardEventHandler; + onRemoveItem?: (event: SyntheticEvent, index: number) => void; + onInputBlur?: FocusEventHandler; + onInputFocus?: FocusEventHandler; + onInputChange?: ChangeEventHandler; + onClick?: ReactEventHandler; + onClear?: ReactEventHandler; + delimiters?: string[]; + disableAddOnBlur?: boolean; + defaultSelected?: Item[]; + onChange?: ChangeHandler; + onCollapse?: ReactEventHandler; + onExpand?: ReactEventHandler; + + /// from input + /** + * Validation status. + */ + validationStatus?: Omit; + /** + * If `true`, the component is read only. + */ + readOnly?: boolean; + /** + * [Attributes](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/textarea#Attributes) applied to the `textarea` element. + */ + textAreaProps?: TextareaHTMLAttributes; + /** + * Optional ref for the textarea component + */ + textAreaRef?: Ref; + necessity?: NecessityType; + /** + * Styling variant. Defaults to "primary". + */ + variant?: "primary" | "secondary"; +} + +const withBaseName = makePrefixer("saltTokenizedInputNext"); + +const getItemsAriaLabel = (itemCount: number) => + itemCount === 0 + ? "no item selected" + : `${itemCount} ${itemCount > 1 ? "items" : "item"}`; + +export const TokenizedInputNext = forwardRef(function TokenizedInputNext( + { + textAreaRef: textAreaRefProp, + textAreaProps = {}, + variant = "primary", + ...rest + }: TokenizedInputNextProps, + ref: ForwardedRef +) { + const targetWindow = useWindow(); + useComponentCssInjection({ + testId: "salt-tokenized-input-next", + css: tokenizedInputCss, + window: targetWindow, + }); + const { + "aria-describedby": textAreaDescribedBy, + "aria-labelledby": textAreaLabelledBy, + required: textAreaRequired, + ...restTextAreaProps + } = textAreaProps; + + const { refs, helpers, inputProps, firstHiddenIndex } = + useTokenizedInputNext(rest); + + const { + textAreaRef: textAreaHookRef, + pillsRef, + clearButtonRef, + expandButtonRef, + statusAdornmentRef, + containerRef: containerHookRef, + } = refs; + + const { + ExpandButtonProps = { + "aria-label": "expand edit", + }, + className, + activeIndices = [], + selectedItems = [], + highlightedIndex, + value, + expanded, + disabled, + onBlur, + onKeyDown, + onRemoveItem, + onInputChange, + focused, + validationStatus, + readOnly, + onInputFocus, + onInputBlur, + onClear, + onClick, + onKeyUp, + id: idProp, + "aria-label": ariaLabel, + "aria-labelledby": ariaLabelledBy, + "aria-describedby": ariaDescribedBy, + ...restProps + } = inputProps; + + const id = useId(idProp); + const inputId = `${id}-input`; + const expandButtonId = `${id}-expand-button`; + const clearButtonId = `${id}-clear-button`; + + const keydownExpandButton = useRef(false); + const containerRef = useForkRef(ref, containerHookRef); + const textAreaRef = useForkRef(textAreaHookRef, textAreaRefProp); + const showExpandButton = !expanded && firstHiddenIndex != null; + + const hasHelpers = (helpers: TokenizedInputNextHelpers) => { + if (process.env.NODE_ENV !== "production") { + if (helpers == null) { + console.warn( + 'TokenizedInputNext is used without helpers. You should pass in "helpers" from "useTokenizedInputNext".' + ); + } + } + return helpers != null; + }; + + const handleExpandButtonKeyDown = ( + event: KeyboardEvent + ) => { + const singleChar = event.key.length === 1; + const triggerExpand = + [ + "CONTROL", + "META", + "ENTER", + "BACKSPACE", + "ARROWDOWN", + "ARROWLEFT", + "ARROWRIGHT", + ].indexOf(event.key.toUpperCase()) !== -1; + + if ((singleChar || triggerExpand) && hasHelpers(helpers)) { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + event.stopPropagation(); + } + helpers.updateExpanded(event, true); + keydownExpandButton.current = true; + } + }; + + const handleInputKeyUp = ( + event: KeyboardEvent + ) => { + // Call keydown again if the initial event has been used to expand the input + if (keydownExpandButton.current && "Enter" !== event.key) { + keydownExpandButton.current = false; + onKeyDown?.(event); + } + onKeyUp?.(event); + }; + + const handleExpand = (event: SyntheticEvent) => { + event.stopPropagation(); + + if (hasHelpers(helpers)) { + helpers.updateExpanded(event, true); + } + }; + + const handleClearButtonFocus = (event: FocusEvent) => { + event.stopPropagation(); + }; + + const selectedItemIds = selectedItems.map( + (_, index) => `${id}-pill-${index}` + ); + + const inputAriaLabelledBy = disabled + ? [ariaLabelledBy, inputId, ...selectedItemIds] + : [ariaLabelledBy, inputId]; + + const expandedWithItems = + expanded && !showExpandButton && selectedItems.length > 0; + const { "aria-label": expandButtonAccessibleText, ...restExpandButtonProps } = + ExpandButtonProps; + + return ( +
+ +
+ {selectedItems.length > 0 && + selectedItems.map((item, index) => { + const label = String(item); + return ( +