From f920cfde7d1d5f1917a59859a91044e5b6c9a22f Mon Sep 17 00:00:00 2001 From: Masoud Amjadi Date: Tue, 31 Oct 2023 13:52:49 -0400 Subject: [PATCH] feat(multicolumnautocomplete): first implementation --- .../src/core/Autocomplete/index.stories.tsx | 63 ++-- .../src/core/Autocomplete/index.test.tsx | 4 +- .../src/core/Autocomplete/index.tsx | 339 +++--------------- .../components/src/core/Autocomplete/style.ts | 2 +- .../AutocompleteBase.namespace-test.tsx | 38 ++ .../__snapshots__/index.test.tsx.snap | 91 +++++ .../core/AutocompleteBase/index.stories.tsx | 285 +++++++++++++++ .../src/core/AutocompleteBase/index.test.tsx | 17 + .../src/core/AutocompleteBase/index.tsx | 334 +++++++++++++++++ .../src/core/AutocompleteBase/style.ts | 181 ++++++++++ ...AutocompleteMultiColumn.namespace-test.tsx | 38 ++ .../AutocompleteMultiColumn/GITHUB_LABELS.tsx | 113 ++++++ .../__snapshots__/index.test.tsx.snap | 91 +++++ .../AutocompleteMultiColumn/index.stories.tsx | 191 ++++++++++ .../AutocompleteMultiColumn/index.test.tsx | 17 + .../core/AutocompleteMultiColumn/index.tsx | 268 ++++++++++++++ .../src/core/AutocompleteMultiColumn/style.ts | 106 ++++++ .../src/core/DropdownMenu/GITHUB_LABELS.tsx | 3 + .../GITHUB_LABELS_MULTI_COLUMN.tsx | 80 +++++ .../src/core/DropdownMenu/index.tsx | 2 +- .../components/src/core/InputSearch/index.tsx | 2 +- packages/components/src/index.ts | 6 +- 22 files changed, 1947 insertions(+), 324 deletions(-) create mode 100644 packages/components/src/core/AutocompleteBase/AutocompleteBase.namespace-test.tsx create mode 100644 packages/components/src/core/AutocompleteBase/__snapshots__/index.test.tsx.snap create mode 100644 packages/components/src/core/AutocompleteBase/index.stories.tsx create mode 100644 packages/components/src/core/AutocompleteBase/index.test.tsx create mode 100644 packages/components/src/core/AutocompleteBase/index.tsx create mode 100644 packages/components/src/core/AutocompleteBase/style.ts create mode 100644 packages/components/src/core/AutocompleteMultiColumn/AutocompleteMultiColumn.namespace-test.tsx create mode 100644 packages/components/src/core/AutocompleteMultiColumn/GITHUB_LABELS.tsx create mode 100644 packages/components/src/core/AutocompleteMultiColumn/__snapshots__/index.test.tsx.snap create mode 100644 packages/components/src/core/AutocompleteMultiColumn/index.stories.tsx create mode 100644 packages/components/src/core/AutocompleteMultiColumn/index.test.tsx create mode 100644 packages/components/src/core/AutocompleteMultiColumn/index.tsx create mode 100644 packages/components/src/core/AutocompleteMultiColumn/style.ts create mode 100644 packages/components/src/core/DropdownMenu/GITHUB_LABELS_MULTI_COLUMN.tsx diff --git a/packages/components/src/core/Autocomplete/index.stories.tsx b/packages/components/src/core/Autocomplete/index.stories.tsx index 5813a90ab..c0c8ce532 100644 --- a/packages/components/src/core/Autocomplete/index.stories.tsx +++ b/packages/components/src/core/Autocomplete/index.stories.tsx @@ -1,28 +1,29 @@ +import { AutocompleteValue } from "@mui/base"; import { Args, Meta } from "@storybook/react"; import React, { SyntheticEvent, useEffect, useState } from "react"; -import { Value } from "../Dropdown"; +import { DefaultAutocompleteOption } from "../AutocompleteBase"; import { GITHUB_LABELS } from "../DropdownMenu/GITHUB_LABELS"; +import { GITHUB_LABELS_MULTI_COLUMN } from "../DropdownMenu/GITHUB_LABELS_MULTI_COLUMN"; import TagFilter from "../TagFilter"; -import RawAutocomplete, { DefaultAutocompleteOption } from "./index"; - -export type AutocompleteOptionValue = Multiple extends - | undefined - | false - ? T | undefined - : Array | undefined; +import RawAutocomplete from "./index"; const groupByOptions = [ undefined, (option: DefaultAutocompleteOption) => option.section as string, ]; -const Autocomplete = ( +const dataOptions = [GITHUB_LABELS, GITHUB_LABELS_MULTI_COLUMN]; + +const Autocomplete = < + T extends DefaultAutocompleteOption, + Multiple extends boolean | undefined = false +>( props: Args ): JSX.Element => { const { label, multiple, - options = GITHUB_LABELS, + options = GITHUB_LABELS_MULTI_COLUMN, search, value: propValue, keepSearchOnSelect, @@ -30,10 +31,10 @@ const Autocomplete = ( const isControlled = propValue !== undefined; const [value, setValue] = useState< - DefaultAutocompleteOption | DefaultAutocompleteOption[] | null + AutocompleteValue >(getInitialValue()); const [pendingValue, setPendingValue] = useState< - DefaultAutocompleteOption | DefaultAutocompleteOption[] | null + AutocompleteValue >(getInitialValue()); const [selection, setSelection] = useState([]); @@ -46,7 +47,11 @@ const Autocomplete = ( useEffect(() => { setSelection([]); - }, [multiple]); + setValue(null as AutocompleteValue); + setPendingValue( + [] as unknown as AutocompleteValue + ); + }, [multiple, options]); return (
@@ -83,17 +88,19 @@ const Autocomplete = ( function handleChange( _: SyntheticEvent, - newValue: DefaultAutocompleteOption | DefaultAutocompleteOption[] | null + newValue: AutocompleteValue ) { if (multiple) { const newSelection = Array.isArray(newValue) ? newValue?.map((item) => item.name) : []; setSelection(newSelection); - return setPendingValue(newValue); + return setPendingValue( + newValue as AutocompleteValue + ); } else { if (newValue && !Array.isArray(newValue) && newValue.name) { - setValue(newValue); + setValue(newValue as AutocompleteValue); setSelection([newValue.name]); } } @@ -104,26 +111,26 @@ const Autocomplete = ( const index = pendingValue?.findIndex((item) => item.name === tag); const newValue = [...pendingValue]; newValue.splice(index, 1); - setPendingValue(newValue); + setPendingValue(newValue as AutocompleteValue); const newSelection = [...selection]; const deleteIndex = newSelection.indexOf(tag); newSelection.splice(deleteIndex, 1); setSelection(newSelection); } else { - setValue(null); + setValue(null as AutocompleteValue); setSelection([]); } } - function getInitialValue(): Value { + function getInitialValue(): AutocompleteValue { if (isControlled) { return propValue; } return multiple - ? ([] as unknown as Value) - : null; + ? ([] as unknown as AutocompleteValue) + : (null as AutocompleteValue); } }; @@ -146,10 +153,18 @@ export default { multiple: { control: { type: "boolean" }, }, + options: { + control: { + labels: ["Single Column Autocomplete", "Multi Column Autocomplete"], + type: "select", + }, + mapping: dataOptions, + options: Object.keys(dataOptions), + }, }, component: Autocomplete, // (masoudmanson) For the purpose of storybook, the button is removed - // from the Autocomplete component which may cause some accessibility + // from the RawAutocomplete component which may cause some accessibility // violations related to ARIA roles and attributes. However, this // should not be a concern as the component is always used with a button // in real applications. To avoid false positive test failures, the following @@ -240,5 +255,7 @@ export const Test = { skip: true, }, }, - render: (args: Args) => , + render: (args: Args) => ( + + ), }; diff --git a/packages/components/src/core/Autocomplete/index.test.tsx b/packages/components/src/core/Autocomplete/index.test.tsx index 442ee4289..f747f44c3 100644 --- a/packages/components/src/core/Autocomplete/index.test.tsx +++ b/packages/components/src/core/Autocomplete/index.test.tsx @@ -9,9 +9,9 @@ const Test = composeStory(TestStory, Meta); describe("", () => { generateSnapshots(snapshotTestStoryFile); - it("renders Autocomplete component", () => { + it("renders AutocompleteBase component", () => { render(); - const AutocompleteElement = screen.getByTestId("autocomplete"); + const AutocompleteElement = screen.getByTestId("autocomplete-base"); expect(AutocompleteElement).not.toBeNull(); }); }); diff --git a/packages/components/src/core/Autocomplete/index.tsx b/packages/components/src/core/Autocomplete/index.tsx index 7fb0b33f1..d579bad8d 100644 --- a/packages/components/src/core/Autocomplete/index.tsx +++ b/packages/components/src/core/Autocomplete/index.tsx @@ -1,309 +1,60 @@ -import { - AutocompleteFreeSoloValueMapping, - AutocompleteInputChangeReason, - AutocompleteRenderInputParams, - AutocompleteRenderOptionState, - AutocompleteProps as MuiAutocompleteProps, - Popper, - PopperProps, -} from "@mui/material"; -import React, { ReactNode, SyntheticEvent, useCallback, useState } from "react"; -import { noop } from "src/common/utils"; -import ButtonIcon from "../ButtonIcon"; -import { IconProps } from "../Icon"; -import { InputSearchProps } from "../InputSearch"; -import { StyledInputAdornment } from "../InputSearch/style"; -import MenuItem, { IconNameToSmallSizes } from "../MenuItem"; -import { - InputBaseWrapper, - StyleProps, - StyledAutocomplete, - StyledMenuInputSearch, - StyledMenuItemDetails, - StyledMenuItemText, - StyledPaper, -} from "./style"; - -// (thuang): This requires option to have a `name` property. -interface AutocompleteOptionGeneral { - name: string; - section?: string; -} -export interface AutocompleteOptionBasic extends AutocompleteOptionGeneral { - count?: number; - details?: string; - sdsIcon?: keyof IconNameToSmallSizes; - sdsIconProps?: Partial>; -} - -export interface AutocompleteOptionComponent extends AutocompleteOptionGeneral { - component?: ReactNode; -} - -type Exclusive = T & { [K in Exclude]?: undefined }; - -export type DefaultAutocompleteOption = - | Exclusive - | Exclusive; - -// eslint-disable-next-line @typescript-eslint/no-explicit-any -type RenderFunctionType = (props: any) => JSX.Element; - -interface ExtraAutocompleteProps extends StyleProps { - keepSearchOnSelect?: boolean; - renderInput?: (params: AutocompleteRenderInputParams) => React.ReactNode; - onInputChange?: ( - event: SyntheticEvent, - value: string, - reason: AutocompleteInputChangeReason - ) => void; - InputBaseProps?: Partial; - label: string; - PaperComponent?: typeof StyledPaper | RenderFunctionType; -} - -type CustomAutocompleteProps< +import { AutocompleteValue } from "@mui/base"; +import React from "react"; +import AutocompleteBase, { + AutocompleteBaseProps, + DefaultAutocompleteOption, +} from "../AutocompleteBase"; +import AutocompleteMultiColumn from "../AutocompleteMultiColumn"; +import { StyleProps } from "./style"; + +export type AutocompleSingleColumnOption = T; + +export type AutocompleteMultiColumnOption< T, - Multiple extends boolean | undefined = undefined, - DisableClearable extends boolean | undefined = undefined, - FreeSolo extends boolean | undefined = undefined -> = Omit< - MuiAutocompleteProps, - "renderInput" | "nonce" | "rev" | "rel" | "autoFocus" | "content" ->; + Multiple extends boolean | undefined +> = { + options: T[]; + props?: Partial>; + style?: React.CSSProperties; + value?: AutocompleteValue; +}; +interface ExtraAutocompleteProps + extends StyleProps { + options: + | AutocompleSingleColumnOption[] + | AutocompleteMultiColumnOption[]; + columnWidth?: number; +} -export type AutocompleteProps< - T, - Multiple extends boolean | undefined = undefined, - DisableClearable extends boolean | undefined = undefined, - FreeSolo extends boolean | undefined = undefined -> = CustomAutocompleteProps & - ExtraAutocompleteProps; +export type AutocompleteProps = Omit< + AutocompleteBaseProps, + "options" +> & + ExtraAutocompleteProps; const Autocomplete = < T extends DefaultAutocompleteOption, - Multiple extends boolean | undefined = undefined, - DisableClearable extends boolean | undefined = undefined, - FreeSolo extends boolean | undefined = undefined + Multiple extends boolean | undefined >( - props: AutocompleteProps + props: AutocompleteProps ): JSX.Element => { - const { - multiple = false, - disableCloseOnSelect = multiple, - getOptionLabel = defaultGetOptionLabel, - InputBaseProps = {}, - isOptionEqualToValue = defaultIsOptionEqualToValue, - keepSearchOnSelect = false, - label, - loading = false, - loadingText = "", - noOptionsText = "No options", - onInputChange = noop, - PaperComponent = StyledPaper, - renderOption = defaultRenderOption, - renderTags = defaultRenderTags, - search = false, - } = props; - - const [inputValue, setInputValue] = useState(""); + const { options, ...rest } = props; - /** - * (masoudmanson): Using a custom Popper or Paper with the Autocomplete - * without a useCalback results in scroll jumps while selecting an option! - */ - const defaultPopperComponent = useCallback((popperProps: PopperProps) => { + // Multi-column options + if (Array.isArray(options) && options.length > 0 && "options" in options[0]) { return ( - []} + {...rest} + open /> ); - }, []); - - return ( - ( - - { - if (event.key === "Backspace") { - event.stopPropagation(); - } - }} - InputProps={{ - /** - * (thuang): Works with css caret-color: "transparent" to hide - * mobile keyboard - */ - inputMode: search ? "text" : "none", - /** - * (mmoore): passing only the ref along to InputProps to prevent - * default MUI arrow from rendering in search input. - * renderInput strips InputProps, so we explicitly pass end adornment here - */ - ...params.InputProps.ref, - endAdornment: ( - - {/** - * (masoudmansdon): Because the Autocomplete component overrides the - * InputSearch's endAdornment, we must also include the clear IconButton here. - */} - {inputValue && ( - - )} - - - ), - inputProps: params.inputProps, - }} - {...InputBaseProps} - /> - - )} - {...props} - onBlur={(event: React.FocusEvent) => { - setInputValue(""); - props.onBlur?.(event); - }} - onInputChange={( - event: SyntheticEvent, - value: string, - reason: AutocompleteInputChangeReason - ) => { - if (!multiple) { - if (reason === "input") { - setInputValue(value); - } else { - setInputValue(""); - } - } else { - if (reason === "clear") { - setInputValue(""); - } else if ( - reason === "input" || - (reason === "reset" && !keepSearchOnSelect) - ) { - setInputValue(value); - } - } - - if (onInputChange) onInputChange(event, value, reason); - }} - /> - ); - - function clearInput() { - setInputValue(""); - /** - * (masoudmanson): Because we are manually firing this event, - * we must build a onChange event to transmit the updated value to onChange listeners. - */ - if (onInputChange) - onInputChange( - { target: { value: "" } } as React.ChangeEvent, - "", - "clear" - ); - } - - function defaultGetOptionLabel( - option: T | AutocompleteFreeSoloValueMapping - ): string { - if (typeof option === "object" && "name" in option) return option.name; - return option.toString(); - } - - function defaultIsOptionEqualToValue(option: T, val: T): boolean { - return option.name === val.name; - } - - function defaultRenderTags() { - return null; - } - - function defaultRenderOption( - optionProps: React.HTMLAttributes, - option: T, - { selected }: AutocompleteRenderOptionState - ) { - let MenuItemContent; - - const { component, details, count, sdsIcon, sdsIconProps } = option; - const menuItemLabel = getOptionLabel(option); - - if (component) { - MenuItemContent = component; - } else { - MenuItemContent = ( - - {menuItemLabel} - {details && ( - - {details} - - )} - - ); - } - + } else { return ( - - {MenuItemContent} - + []} + {...rest} + /> ); } }; diff --git a/packages/components/src/core/Autocomplete/style.ts b/packages/components/src/core/Autocomplete/style.ts index 07f847693..f00fa0aa6 100644 --- a/packages/components/src/core/Autocomplete/style.ts +++ b/packages/components/src/core/Autocomplete/style.ts @@ -26,7 +26,7 @@ const doNotForwardProps = [ "InputBaseProps", ]; -export const StyledAutocomplete = styled(Autocomplete, { +export const StyledAutocompleteBase = styled(Autocomplete, { shouldForwardProp: (prop: string) => !doNotForwardProps.includes(prop), })` + .MuiAutocomplete-popper diff --git a/packages/components/src/core/AutocompleteBase/AutocompleteBase.namespace-test.tsx b/packages/components/src/core/AutocompleteBase/AutocompleteBase.namespace-test.tsx new file mode 100644 index 000000000..8e7291148 --- /dev/null +++ b/packages/components/src/core/AutocompleteBase/AutocompleteBase.namespace-test.tsx @@ -0,0 +1,38 @@ +import { + Autocomplete, + AutocompleteProps, + DefaultAutocompleteOption, +} from "@czi-sds/components"; +import { noop } from "src/common/utils"; + +const OPTIONS = [ + { + color: "#7057ff", + description: "Good for newcomers", + name: "good first issue", + }, + { + color: "#008672", + description: "Extra attention is needed", + name: "help wanted", + }, +]; + +const AutocompleteNameSpaceTest = ( + props: AutocompleteProps< + DefaultAutocompleteOption, + true, + undefined, + undefined + > +) => { + return ( + + ); +}; diff --git a/packages/components/src/core/AutocompleteBase/__snapshots__/index.test.tsx.snap b/packages/components/src/core/AutocompleteBase/__snapshots__/index.test.tsx.snap new file mode 100644 index 000000000..82768a997 --- /dev/null +++ b/packages/components/src/core/AutocompleteBase/__snapshots__/index.test.tsx.snap @@ -0,0 +1,91 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` Default story renders snapshot 1`] = ` +
+
+
+ +
+
+ +
+ + +
+ +
+
+
+
+
+
+`; diff --git a/packages/components/src/core/AutocompleteBase/index.stories.tsx b/packages/components/src/core/AutocompleteBase/index.stories.tsx new file mode 100644 index 000000000..2e88d25e5 --- /dev/null +++ b/packages/components/src/core/AutocompleteBase/index.stories.tsx @@ -0,0 +1,285 @@ +import { AutocompleteChangeReason, AutocompleteValue } from "@mui/base"; +import { Args, Meta } from "@storybook/react"; +import React, { useEffect, useState } from "react"; +import { GITHUB_LABELS } from "../DropdownMenu/GITHUB_LABELS"; +import TagFilter from "../TagFilter"; +import RawAutocompleteBase, { DefaultAutocompleteOption } from "./index"; + +const groupByOptions = [ + undefined, + (option: DefaultAutocompleteOption) => option.section as string, +]; + +const AutocompleteBase = < + T extends DefaultAutocompleteOption, + Multiple extends boolean | undefined +>( + props: Args +): JSX.Element => { + const { + label, + multiple, + options = GITHUB_LABELS, + search, + value: propValue, + keepSearchOnSelect, + } = props; + + const isControlled = propValue !== undefined; + const [value, setValue] = useState< + AutocompleteValue + >(getInitialValue()); + const [pendingValue, setPendingValue] = useState< + AutocompleteValue + >(getInitialValue()); + + const [selection, setSelection] = useState([]); + + useEffect(() => { + if (isControlled) { + setValue(propValue); + } + }, [isControlled, propValue]); + + useEffect(() => { + setSelection([]); + }, [multiple]); + + useEffect(() => { + console.log({ value }); + }, [value]); + + useEffect(() => { + console.log({ pendingValue }); + }, [pendingValue]); + + return ( +
+ { + return option.name === "Type: feature request"; + }} + {...props} + /> +
+ {selection.length + ? selection.map((item) => { + return ( + handleTagDelete(item)} + /> + ); + }) + : null} +
+
+ ); + + function handleChange( + _event: React.SyntheticEvent, + newValue: AutocompleteValue, + _reason: AutocompleteChangeReason + ) { + if (multiple) { + const newSelection = Array.isArray(newValue) + ? newValue?.map((item) => item.name) + : []; + setSelection(newSelection); + return setPendingValue(newValue); + } else { + if (newValue && !Array.isArray(newValue) && newValue.name) { + setValue(newValue); + setSelection([newValue.name]); + } + } + } + + function handleTagDelete(tag: string) { + if (multiple && Array.isArray(pendingValue)) { + const index = pendingValue?.findIndex((item) => item.name === tag); + const newValue = [...pendingValue]; + newValue.splice(index, 1); + setPendingValue(newValue as AutocompleteValue); + + const newSelection = [...selection]; + const deleteIndex = newSelection.indexOf(tag); + newSelection.splice(deleteIndex, 1); + setSelection(newSelection); + } else { + setValue(null as unknown as AutocompleteValue); + setSelection([]); + } + } + + function getInitialValue(): AutocompleteValue { + if (isControlled) { + return propValue; + } + + return multiple + ? ([] as unknown as AutocompleteValue) + : (null as unknown as AutocompleteValue); + } +}; + +export default { + argTypes: { + blurOnSelect: { + control: { + type: "boolean", + }, + }, + clearOnBlur: { + control: { + type: "boolean", + }, + }, + groupBy: { + control: { + labels: ["No group by", "Group by section names"], + type: "select", + }, + mapping: groupByOptions, + options: Object.keys(groupByOptions), + }, + keepSearchOnSelect: { + control: { type: "boolean" }, + }, + label: { + control: { type: "text" }, + }, + multiple: { + control: { type: "boolean" }, + }, + }, + component: AutocompleteBase, + // (masoudmanson) For the purpose of storybook, the button is removed + // from the RawAutocompleteBase component which may cause some accessibility + // violations related to ARIA roles and attributes. However, this + // should not be a concern as the component is always used with a button + // in real applications. To avoid false positive test failures, the following + // accessibility rules have been temporarily disabled in the tests + parameters: { + axe: { + disabledRules: [ + "aria-input-field-name", + "aria-required-children", + "aria-required-parent", + "button-name", + "list", + "listitem", + ], + }, + }, + title: "Dropdowns/AutocompleteBase", +} as Meta; + +// Default + +export const Default = { + args: { + groupBy: groupByOptions[1], + keepSearchOnSelect: true, + label: "Search by label", + multiple: true, + search: true, + }, + parameters: { + controls: { + exclude: ["search"], + }, + }, +}; + +// Test + +const TestDemo = < + T extends DefaultAutocompleteOption, + Multiple extends boolean | undefined +>( + props: Args +): JSX.Element => { + const { multiple, options = GITHUB_LABELS, search, value: propValue } = props; + + const isControlled = propValue !== undefined; + const [value, setValue] = useState< + AutocompleteValue + >(getInitialValue()); + const [pendingValue, setPendingValue] = useState< + AutocompleteValue + >(getInitialValue()); + + useEffect(() => { + if (isControlled) { + setValue(propValue); + } + }, [isControlled, propValue]); + + return ( + option.section as string} + {...props} + /> + ); + + function handleChange( + _event: React.SyntheticEvent, + newValue: AutocompleteValue, + _reason: AutocompleteChangeReason + ) { + if (!multiple) { + setValue(newValue); + } + + return setPendingValue(newValue); + } + + // eslint-disable-next-line sonarjs/no-identical-functions + function getInitialValue(): AutocompleteValue { + if (isControlled) { + return propValue; + } + + return multiple + ? ([] as unknown as AutocompleteValue) + : (null as unknown as AutocompleteValue); + } +}; + +export const Test = { + args: { + keepSearchOnSelect: false, + multiple: true, + search: true, + }, + parameters: { + controls: { + exclude: ["search"], + }, + snapshot: { + skip: true, + }, + }, + render: (args: Args) => ( + + ), +}; diff --git a/packages/components/src/core/AutocompleteBase/index.test.tsx b/packages/components/src/core/AutocompleteBase/index.test.tsx new file mode 100644 index 000000000..f747f44c3 --- /dev/null +++ b/packages/components/src/core/AutocompleteBase/index.test.tsx @@ -0,0 +1,17 @@ +import { generateSnapshots } from "@chanzuckerberg/story-utils"; +import { composeStory } from "@storybook/react"; +import { render, screen } from "@testing-library/react"; +import * as snapshotTestStoryFile from "./index.stories"; +import Meta, { Test as TestStory } from "./index.stories"; + +const Test = composeStory(TestStory, Meta); + +describe("", () => { + generateSnapshots(snapshotTestStoryFile); + + it("renders AutocompleteBase component", () => { + render(); + const AutocompleteElement = screen.getByTestId("autocomplete-base"); + expect(AutocompleteElement).not.toBeNull(); + }); +}); diff --git a/packages/components/src/core/AutocompleteBase/index.tsx b/packages/components/src/core/AutocompleteBase/index.tsx new file mode 100644 index 000000000..7f0800d3f --- /dev/null +++ b/packages/components/src/core/AutocompleteBase/index.tsx @@ -0,0 +1,334 @@ +import { + AutocompleteChangeDetails, + AutocompleteChangeReason, + AutocompleteFreeSoloValueMapping, + AutocompleteInputChangeReason, + AutocompleteRenderInputParams, + AutocompleteRenderOptionState, + AutocompleteValue, + AutocompleteProps as MuiAutocompleteProps, + Popper, + PopperProps, +} from "@mui/material"; +import React, { ReactNode, SyntheticEvent, useCallback, useState } from "react"; +import { noop } from "src/common/utils"; +import ButtonIcon from "../ButtonIcon"; +import { IconProps } from "../Icon"; +import { InputSearchProps } from "../InputSearch"; +import { StyledInputAdornment } from "../InputSearch/style"; +import MenuItem, { IconNameToSmallSizes } from "../MenuItem"; +import { + InputBaseWrapper, + StyleProps, + StyledAutocompleteBase, + StyledMenuInputSearch, + StyledMenuItemDetails, + StyledMenuItemText, + StyledPaper, +} from "./style"; + +// (thuang): This requires option to have a `name` property. +// (masoudmanson): Represents a generic autocomplete option with common properties. +interface AutocompleteOptionGeneral { + name: string; // The name of the autocomplete option. + section?: string; // An optional section for categorization. +} + +// (masoudmanson): Represents a basic autocomplete option. +export interface AutocompleteOptionBasic extends AutocompleteOptionGeneral { + count?: number; // An optional count associated with the option. + details?: string; // An optional string for additional details. + sdsIcon?: keyof IconNameToSmallSizes; // An optional icon key. + sdsIconProps?: Partial>; // Optional properties for the associated icon. +} + +// (masoudmanson): Represents an autocomplete option that includes a custom React component. +export interface AutocompleteOptionComponent extends AutocompleteOptionGeneral { + component?: ReactNode; // An optional custom React component. +} + +// (masoudmanson): Combines properties from two types while ensuring that overlapping properties are optional. +type Exclusive = T & { [K in Exclude]?: undefined }; + +// (masoudmanson): Represents a default autocomplete option that can be either basic or include a custom component. +export type DefaultAutocompleteOption = + | Exclusive // Represents a basic option with optional custom properties. + | Exclusive; // Represents an option with a custom component and optional basic properties. + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type RenderFunctionType = (props: any) => JSX.Element; + +interface ExtraAutocompleteProps extends StyleProps { + keepSearchOnSelect?: boolean; + renderInput?: (params: AutocompleteRenderInputParams) => React.ReactNode; + onInputChange?: ( + event: SyntheticEvent, + value: string, + reason: AutocompleteInputChangeReason + ) => void; + onChange?: ( + event: React.SyntheticEvent, + value: AutocompleteValue, + reason: AutocompleteChangeReason, + details?: AutocompleteChangeDetails + ) => void; + InputBaseProps?: Partial; + label: string; + PaperComponent?: typeof StyledPaper | RenderFunctionType; +} + +type CustomAutocompleteProps = Omit< + MuiAutocompleteProps, + "renderInput" | "nonce" | "rev" | "rel" | "autoFocus" | "content" +>; + +export type AutocompleteBaseProps< + T, + Multiple extends boolean | undefined +> = CustomAutocompleteProps & ExtraAutocompleteProps; + +const AutocompleteBase = < + T extends DefaultAutocompleteOption, + Multiple extends boolean | undefined +>( + props: AutocompleteBaseProps +): JSX.Element => { + const { + multiple, + disableCloseOnSelect = multiple, + getOptionLabel = defaultGetOptionLabel, + InputBaseProps = {}, + isOptionEqualToValue = defaultIsOptionEqualToValue, + keepSearchOnSelect = false, + label, + loading = false, + loadingText = "", + noOptionsText = "No options", + onInputChange = noop, + PaperComponent = StyledPaper, + renderOption = defaultRenderOption, + renderTags = defaultRenderTags, + search = false, + clearOnBlur = false, + blurOnSelect = !multiple, + } = props; + // console.log(props); + + const [inputValue, setInputValue] = useState(""); + + return ( + + ); + + /** + * (masoudmanson): Using a custom Popper or Paper with the Autocomplete + * without a useCalback results in scroll jumps while selecting an option! + */ + function useDefaultPopperComponent() { + return useCallback((popperProps: PopperProps) => { + return ( + + ); + }, []); + } + + function defaultRenderInput(params: AutocompleteRenderInputParams) { + return ( + + { + if (event.key === "Backspace") { + event.stopPropagation(); + } + }} + InputProps={{ + /** + * (thuang): Works with css caret-color: "transparent" to hide + * mobile keyboard + */ + inputMode: search ? "text" : "none", + /** + * (mmoore): passing only the ref along to InputProps to prevent + * default MUI arrow from rendering in search input. + * renderInput strips InputProps, so we explicitly pass end adornment here + */ + ...params.InputProps.ref, + endAdornment: ( + + {/** + * (masoudmansdon): Because the Autocomplete component overrides the + * InputSearch's endAdornment, we must also include the clear IconButton here. + */} + {inputValue && ( + + )} + + + ), + inputProps: params.inputProps, + }} + {...InputBaseProps} + /> + + ); + } + + function defaultOnBlur(event: React.FocusEvent) { + if (clearOnBlur) setInputValue(""); + props.onBlur?.(event); + } + + function defaultOnInputChange( + event: SyntheticEvent, + value: string, + reason: AutocompleteInputChangeReason + ) { + if (!multiple) { + if (reason === "input") { + setInputValue(value); + } else { + setInputValue(""); + } + } else { + if (reason === "clear") { + setInputValue(""); + } else if ( + reason === "input" || + (reason === "reset" && !keepSearchOnSelect) + ) { + setInputValue(value); + } + } + + if (onInputChange) onInputChange(event, value, reason); + } + + function clearInput() { + setInputValue(""); + /** + * (masoudmanson): Because we are manually firing this event, + * we must build a onChange event to transmit the updated value to onChange listeners. + */ + if (onInputChange) + onInputChange( + { target: { value: "" } } as React.ChangeEvent, + "", + "clear" + ); + } + + function defaultGetOptionLabel( + option: + | DefaultAutocompleteOption + | AutocompleteFreeSoloValueMapping + ): string { + if (typeof option === "object" && "name" in option) return option.name; + return option.toString(); + } + + function defaultIsOptionEqualToValue(option: T, val: T): boolean { + return option.name === val.name; + } + + function defaultRenderTags() { + return null; + } + + function defaultRenderOption( + optionProps: React.HTMLAttributes, + option: T, + { selected }: AutocompleteRenderOptionState + ) { + let MenuItemContent; + + const { component, details, count, sdsIcon, sdsIconProps } = option; + const menuItemLabel = getOptionLabel(option); + + if (component) { + MenuItemContent = component; + } else { + MenuItemContent = ( + + {menuItemLabel} + {details && ( + + {details} + + )} + + ); + } + + return ( + + {MenuItemContent} + + ); + } +}; + +export default AutocompleteBase; diff --git a/packages/components/src/core/AutocompleteBase/style.ts b/packages/components/src/core/AutocompleteBase/style.ts new file mode 100644 index 000000000..f00fa0aa6 --- /dev/null +++ b/packages/components/src/core/AutocompleteBase/style.ts @@ -0,0 +1,181 @@ +import { Autocomplete, Paper } from "@mui/material"; +import { styled } from "@mui/material/styles"; +import { ReactElement } from "react"; +import InputSearch from "../InputSearch"; +import { + CommonThemeProps, + fontBodyXxs, + fontCapsXxxxs, + getBorders, + getColors, + getCorners, + getShadows, + getSpaces, +} from "../styles"; + +export interface StyleProps extends CommonThemeProps { + count?: number; + icon?: ReactElement; + search?: boolean; +} + +const doNotForwardProps = [ + "count", + "keepSearchOnSelect", + "search", + "InputBaseProps", +]; + +export const StyledAutocompleteBase = styled(Autocomplete, { + shouldForwardProp: (prop: string) => !doNotForwardProps.includes(prop), +})` + + .MuiAutocomplete-popper + > .MuiAutocomplete-paper + .MuiAutocomplete-groupLabel { + ${fontCapsXxxxs} + } + + ${(props: StyleProps) => { + const { search } = props; + const spacings = getSpaces(props); + const colors = getColors(props); + const borders = getBorders(props); + + return ` + ${!search && `height: 0`}; + + .MuiOutlinedInput-root.MuiInputBase-formControl.MuiInputBase-adornedEnd { + padding: 0 ${spacings?.l}px 0 0; + + .MuiAutocomplete-input { + padding: ${spacings?.xs}px ${spacings?.l}px; + } + } + + & + .MuiAutocomplete-popper > .MuiAutocomplete-paper { + ${search ? `padding-left: ${spacings?.s}px !important;` : ""} + + .MuiAutocomplete-listbox { + max-height: 40vh; + padding-top: 0; + padding-bottom: 0; + padding-right: ${spacings?.s}px; + + .MuiAutocomplete-option { + min-height: unset; + } + + .MuiAutocomplete-option.Mui-focused { + background-color: ${colors?.gray[100]}; + } + + .MuiAutocomplete-option[aria-selected="true"] { + background-color: white; + } + + .MuiAutocomplete-option[aria-disabled="true"] { + opacity: 1; + } + + .MuiAutocomplete-option[aria-selected="true"].Mui-focused { + background-color: ${colors?.gray[100]}; + } + + & > li:last-child .MuiAutocomplete-groupUl { + border-bottom: none; + margin-bottom: 0; + } + } + + .MuiAutocomplete-groupLabel { + top: 0; + color: ${colors?.gray[500]}; + padding: ${spacings?.xxs}px 0 ${spacings?.xxs}px 0; + } + + .MuiAutocomplete-groupUl { + margin-bottom: ${spacings?.m}px; + position: relative; + padding: 0 0 ${spacings?.xs}px 0 0; + border-bottom: ${borders?.gray[200]}; + + & li:last-of-type { + position: relative; + margin-bottom: ${spacings?.xxs}px; + } + } + } + `; + }} +` as typeof Autocomplete; + +export const InputBaseWrapper = styled("div", { + shouldForwardProp: (prop: string) => !doNotForwardProps.includes(prop), +})` + ${(props: StyleProps) => { + const { search } = props; + + if (!search) { + // (thuang): We cannot use `display: none;` here, since + // the component needs to be in the DOM to handle backdrop + // click to close the menu + return ` + border: 0; + padding: 0; + + white-space: nowrap; + + clip-path: inset(100%); + clip: rect(0 0 0 0); + overflow: hidden; + margin: 0; + `; + } + }} +`; + +export const StyledMenuInputSearch = styled(InputSearch, { + shouldForwardProp: (prop: string) => !doNotForwardProps.includes(prop), +})<{ search: boolean }>` + margin: 0; + .MuiInputBase-root { + width: 100%; + } + /* (thuang): Works with attribute inputMode: "none" to hide mobile keyboard */ + caret-color: ${({ search }) => (search ? "auto" : "transparent")}; +`; + +export const StyledPaper = styled(Paper)` + ${(props) => { + const spacings = getSpaces(props); + const borders = getBorders(props); + const corners = getCorners(props); + const shadows = getShadows(props); + + return ` + padding: ${spacings?.s}px 0 0 ${spacings?.s}px; + background-color: white; + border: ${borders?.gray[100]}; + border-radius: ${corners?.m}px; + box-shadow: ${shadows?.m}; + box-sizing: border-box; + `; + }} +`; + +export const StyledMenuItemDetails = styled("div")` + ${fontBodyXxs} + ${(props) => { + const colors = getColors(props); + + return ` + color: ${colors?.gray[500]}; + white-space: pre-wrap; + `; + }} +`; + +export const StyledMenuItemText = styled("div")` + display: flex; + flex-direction: column; +`; diff --git a/packages/components/src/core/AutocompleteMultiColumn/AutocompleteMultiColumn.namespace-test.tsx b/packages/components/src/core/AutocompleteMultiColumn/AutocompleteMultiColumn.namespace-test.tsx new file mode 100644 index 000000000..8e7291148 --- /dev/null +++ b/packages/components/src/core/AutocompleteMultiColumn/AutocompleteMultiColumn.namespace-test.tsx @@ -0,0 +1,38 @@ +import { + Autocomplete, + AutocompleteProps, + DefaultAutocompleteOption, +} from "@czi-sds/components"; +import { noop } from "src/common/utils"; + +const OPTIONS = [ + { + color: "#7057ff", + description: "Good for newcomers", + name: "good first issue", + }, + { + color: "#008672", + description: "Extra attention is needed", + name: "help wanted", + }, +]; + +const AutocompleteNameSpaceTest = ( + props: AutocompleteProps< + DefaultAutocompleteOption, + true, + undefined, + undefined + > +) => { + return ( + + ); +}; diff --git a/packages/components/src/core/AutocompleteMultiColumn/GITHUB_LABELS.tsx b/packages/components/src/core/AutocompleteMultiColumn/GITHUB_LABELS.tsx new file mode 100644 index 000000000..790aa4c1b --- /dev/null +++ b/packages/components/src/core/AutocompleteMultiColumn/GITHUB_LABELS.tsx @@ -0,0 +1,113 @@ +// (masoudmanson): The unit tests rely on the content in this file; do not alter it! + +import { DefaultDropdownMenuOption } from "../DropdownMenu"; +import Tag from "../Tag"; + +export const data: Record = { + Custom: [ + { + component: ( +
+ Available Labels: +
+ + + +
+
+ ), + name: "Available labels", + section: "custom component", + }, + ], + Status: [ + { + name: "Status: can't reproduce", + section: "name only", + }, + { + name: "Status: confirmed", + section: "name only", + }, + { + count: 3, + name: "Status: duplicate", + section: "name with count", + }, + { + count: 5, + name: "Status: needs information", + section: "name with count", + }, + { + details: "This will not be worked on", + name: "Status: wont do/fix", + section: "name with details", + }, + { + details: "This is still in progress", + name: "Status: work in progress", + section: "name with details", + }, + ], + Type: [ + { + details: "This will not be worked on", + name: "Type: bug", + sdsIcon: "bacteria", + sdsIconProps: { + className: "custom-class-name", + }, + section: "With Icon", + }, + { + count: 4, + name: "Type: discussion", + sdsIcon: "puzzlePiece", + section: "With Icon", + }, + { + name: "Type: documentation", + sdsIcon: "copy", + section: "With Icon", + }, + { + name: "Type: enhancement", + sdsIcon: "lightBulb", + section: "With Icon", + }, + { + name: "Type: epic", + sdsIcon: "list", + section: "With Icon", + }, + { + name: "Type: feature request", + sdsIcon: "treeVertical", + section: "With Icon", + }, + { + name: "Type: question", + sdsIcon: "search", + section: "With Icon", + }, + ], +}; diff --git a/packages/components/src/core/AutocompleteMultiColumn/__snapshots__/index.test.tsx.snap b/packages/components/src/core/AutocompleteMultiColumn/__snapshots__/index.test.tsx.snap new file mode 100644 index 000000000..3c0a336ce --- /dev/null +++ b/packages/components/src/core/AutocompleteMultiColumn/__snapshots__/index.test.tsx.snap @@ -0,0 +1,91 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` Default story renders snapshot 1`] = ` +
+
+
+ +
+
+ +
+ + +
+ +
+
+
+
+
+
+`; diff --git a/packages/components/src/core/AutocompleteMultiColumn/index.stories.tsx b/packages/components/src/core/AutocompleteMultiColumn/index.stories.tsx new file mode 100644 index 000000000..287a376f5 --- /dev/null +++ b/packages/components/src/core/AutocompleteMultiColumn/index.stories.tsx @@ -0,0 +1,191 @@ +import { AutocompleteValue } from "@mui/base"; +import { Args, Meta } from "@storybook/react"; +import React, { useEffect, useState } from "react"; +import { DefaultDropdownMenuOption } from "../DropdownMenu"; +import { GITHUB_LABELS_MULTI_COLUMN } from "../DropdownMenu/GITHUB_LABELS_MULTI_COLUMN"; +import TagFilter from "../TagFilter"; +import RawAutocompleteMultiColumn from "./index"; + +const groupByOptions = [ + undefined, + (option: DefaultDropdownMenuOption) => option.section as string, +]; + +const AutocompleteMultiColumn = < + T extends DefaultDropdownMenuOption, + Multiple extends boolean | undefined +>( + props: Args +): JSX.Element => { + const { + multiple, + options = GITHUB_LABELS_MULTI_COLUMN, + search, + value: propValue, + } = props; + + const isControlled = propValue !== undefined; + const [value, setValue] = useState< + AutocompleteValue + >(getInitialValue()); + const [pendingValue, setPendingValue] = useState< + AutocompleteValue + >(getInitialValue()); + const [selection, setSelection] = useState([]); + + useEffect(() => { + setSelection([]); + }, [multiple]); + + useEffect(() => { + if (isControlled) { + if (multiple) setPendingValue(propValue); + else setValue(propValue); + } + }, [propValue, isControlled, multiple]); + + return ( +
+ +
+ {selection.length + ? selection.map((item) => { + return ( + handleTagDelete(item)} + /> + ); + }) + : null} +
+
+ ); + + function handleClickAway() { + if (multiple) { + setValue(pendingValue); + } + } + + function handleChange( + _event: React.SyntheticEvent, + newValue: AutocompleteValue + ) { + if (multiple) { + const newSelection = Array.isArray(newValue) + ? newValue?.map((item) => item.name) + : []; + setSelection(newSelection); + return setPendingValue( + newValue as AutocompleteValue + ); + } else { + if (newValue && !Array.isArray(newValue) && newValue.name) { + setValue(newValue as AutocompleteValue); + setSelection([newValue.name]); + } + } + } + + function handleTagDelete(tag: string) { + if (multiple && Array.isArray(pendingValue)) { + const index = pendingValue?.findIndex((item) => item.name === tag); + const newValue = [...pendingValue]; + newValue.splice(index, 1); + setPendingValue(newValue as AutocompleteValue); + + const newSelection = [...selection]; + const deleteIndex = newSelection.indexOf(tag); + newSelection.splice(deleteIndex, 1); + setSelection(newSelection); + } else { + setValue(null as AutocompleteValue); + setSelection([]); + } + } + + function getInitialValue(): AutocompleteValue { + if (isControlled) { + return propValue; + } + + return multiple + ? ([] as unknown as AutocompleteValue) + : (null as AutocompleteValue); + } +}; + +export default { + argTypes: { + ClickAwayListenerProps: { + control: { type: "object" }, + }, + columnWidth: { + control: { + type: "number", + }, + }, + groupBy: { + control: { + labels: ["No group by", "Group by section names"], + type: "select", + }, + mapping: groupByOptions, + options: Object.keys(groupByOptions), + }, + keepSearchOnSelect: { + control: { type: "boolean" }, + }, + label: { + control: { type: "text" }, + require: true, + }, + multiple: { + control: { type: "boolean" }, + }, + }, + component: AutocompleteMultiColumn, + // (masoudmanson) For the purpose of storybook, the button is removed + // from the dropdown menu component which may cause some accessibility + // violations related to ARIA roles and attributes. However, this + // should not be a concern as the component is always used with a button + // in real applications. To avoid false positive test failures, the following + // accessibility rules have been temporarily disabled in the tests + parameters: { + axe: { + disabledRules: [ + "aria-input-field-name", + "aria-required-children", + "aria-required-parent", + "button-name", + "list", + "listitem", + ], + }, + }, + title: "Dropdowns/AutocompleteMultiColumn", +} as Meta; + +// Default + +export const Default = { + args: { + groupBy: groupByOptions[1], + keepSearchOnSelect: true, + label: "Search by label", + multiple: true, + search: true, + }, +}; diff --git a/packages/components/src/core/AutocompleteMultiColumn/index.test.tsx b/packages/components/src/core/AutocompleteMultiColumn/index.test.tsx new file mode 100644 index 000000000..b5293d290 --- /dev/null +++ b/packages/components/src/core/AutocompleteMultiColumn/index.test.tsx @@ -0,0 +1,17 @@ +import { generateSnapshots } from "@chanzuckerberg/story-utils"; +import { composeStory } from "@storybook/react"; +import { render, screen } from "@testing-library/react"; +import * as snapshotTestStoryFile from "./index.stories"; +import Meta, { Test as TestStory } from "./index.stories"; + +const Test = composeStory(TestStory, Meta); + +describe("", () => { + generateSnapshots(snapshotTestStoryFile); + + it("renders AutocompleteMultiColumn component", () => { + render(); + const AutocompleteElement = screen.getByTestId("autocomplete-multi-column"); + expect(AutocompleteElement).not.toBeNull(); + }); +}); diff --git a/packages/components/src/core/AutocompleteMultiColumn/index.tsx b/packages/components/src/core/AutocompleteMultiColumn/index.tsx new file mode 100644 index 000000000..7661d7a44 --- /dev/null +++ b/packages/components/src/core/AutocompleteMultiColumn/index.tsx @@ -0,0 +1,268 @@ +import { + AutocompleteInputChangeReason, + AutocompleteRenderInputParams, + ClickAwayListener, + ClickAwayListenerProps as MUIClickAwayListenerProps, + PopperProps, +} from "@mui/material"; +import { DefaultAutocompleteOption } from "dist/index.cjs"; +import React, { + SyntheticEvent, + useCallback, + useEffect, + useRef, + useState, +} from "react"; +import { AutocompleteMultiColumnOption } from "../Autocomplete"; +import AutocompleteBase, { AutocompleteBaseProps } from "../AutocompleteBase"; +import ButtonIcon from "../ButtonIcon"; +import { InputSearchProps } from "../InputSearch"; +import { StyledInputAdornment } from "../InputSearch/style"; +import { + StyleProps, + StyledAutocomplesWrapper, + StyledAutocompleteInput, + StyledAutocompletePopper, + StyledColumn, + StyledPaper, + StyledPopper, +} from "./style"; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type RenderFunctionType = (props: any) => JSX.Element; + +interface ExtraAutocompleteMultiColumnProps< + T, + Multiple extends boolean | undefined +> extends StyleProps { + keepSearchOnSelect?: boolean; + renderInput?: (params: AutocompleteRenderInputParams) => React.ReactNode; + onInputChange?: ( + event: SyntheticEvent, + value: string, + reason: AutocompleteInputChangeReason + ) => void; + InputBaseProps?: Partial; + PopperBaseProps?: Partial; + label?: string; + PopperComponent?: typeof StyledPopper | RenderFunctionType; + PopperPlacement?: "bottom-start" | "top-start" | "bottom-end" | "top-end"; + PaperComponent?: typeof StyledPaper | RenderFunctionType; + children?: JSX.Element | null; + onClickAway: (event: MouseEvent | TouchEvent) => void; + ClickAwayListenerProps?: Partial; + options: AutocompleteMultiColumnOption[]; +} + +type CustomAutocompleteProps = Omit< + AutocompleteBaseProps, + "renderInput" | "options" | "nonce" | "rev" | "rel" | "autoFocus" | "content" +>; + +export type AutocompleteMultiColumnProps< + T, + Multiple extends boolean | undefined +> = CustomAutocompleteProps & + ExtraAutocompleteMultiColumnProps; + +const AutocompleteMultiColumn = < + T extends DefaultAutocompleteOption, + Multiple extends boolean | undefined +>( + props: AutocompleteMultiColumnProps +): JSX.Element => { + const { + id, + InputBaseProps, + open = false, + PaperComponent = StyledPaper, + PopperComponent = StyledPopper, + PopperPlacement = "bottom-start", + PopperBaseProps, + search = false, + label = "Search", + onClickAway = () => {}, + ClickAwayListenerProps, + options, + columnWidth, + onInputChange, + onBlur, + multiple, + ...rest + } = props; + + const [inputValue, setInputValue] = useState(""); + const [popperOpen, setPopperOpen] = useState(false); + + const anchorRef = useRef(null); + const [anchorEl, setAnchorEl] = React.useState(null); + + useEffect(() => { + setAnchorEl(anchorRef.current); + }, []); + + const defaultPopperComponent = useCallback((popperProps: PopperProps) => { + return ; + }, []); + + return ( + <> + + {/** + * (masoudmansdon): Because the Autocomplete component overrides the + * InputSearch's endAdornment, we must also include the clear IconButton here. + */} + {inputValue && ( + + )} + + + ), + /** + * (thuang): Works with css caret-color: "transparent" to hide + * mobile keyboard + */ + inputMode: "text", + }} + {...InputBaseProps} + /> + + + + {options.map((autocompleteOptions, index) => ( + + ))} + + + + + ); + + function RenderAutocompletes({ + autocompleteProps, + }: { + autocompleteProps: AutocompleteMultiColumnOption; + }) { + return ( + + + label={label} + InputBaseProps={InputBaseProps} + open={open} + multiple={multiple} + {...rest} + {...autocompleteProps.props} + search={false} + inputValue={inputValue} + options={autocompleteProps.options as T[]} + PaperComponent={PaperComponent} + PopperComponent={defaultPopperComponent} + /> + + ); + } + + function clearInput() { + setInputValue(""); + /** + * (masoudmanson): Because we are manually firing this event, + * we must build a onChange event to transmit the updated value to onChange listeners. + */ + onInputChange?.( + { target: { value: "" } } as React.ChangeEvent, + "", + "clear" + ); + } + + function handleInputClick(_: React.SyntheticEvent) { + if (popperOpen) { + setPopperOpen(false); + } else { + setPopperOpen(true); + } + } + + function handleInputChange(event: React.ChangeEvent) { + setInputValue(event.target.value); + if (onInputChange) { + if (event.target.value === "") { + onInputChange(event, "", "clear"); + } else { + onInputChange(event, event.target.value, "input"); + } + } + } + + function handleInputBlur(event: React.FocusEvent) { + if (props.clearOnBlur) setInputValue(""); + setPopperOpen(false); + onBlur?.(event); + } + + function handleInputKeyDown(event: React.KeyboardEvent) { + // (masoudmanson): This prevents Backspace from deselecting selected dropdown options. + if (event.key === "Backspace") { + event.stopPropagation(); + } + } +}; + +export default AutocompleteMultiColumn; diff --git a/packages/components/src/core/AutocompleteMultiColumn/style.ts b/packages/components/src/core/AutocompleteMultiColumn/style.ts new file mode 100644 index 000000000..e395c90ed --- /dev/null +++ b/packages/components/src/core/AutocompleteMultiColumn/style.ts @@ -0,0 +1,106 @@ +import { Paper, Popper } from "@mui/material"; +import { styled } from "@mui/material/styles"; +import { ReactElement } from "react"; +import InputSearch from "../InputSearch"; +import { + CommonThemeProps, + getBorders, + getCorners, + getShadows, + getSpaces, +} from "../styles"; + +export interface StyleProps extends CommonThemeProps { + count?: number; + icon?: ReactElement; + search?: boolean; + title?: string; + columnWidth?: number; +} + +const doNotForwardProps = [ + "anchorEl", + "count", + "keepSearchOnSelect", + "search", + "InputBaseProps", + "title", + "PopperBaseProps", + "onClickAway", + "ClickAwayListenerProps", +]; + +export const StyledPopper = styled(Popper, { + shouldForwardProp: (prop: string) => + !doNotForwardProps.includes(prop) || prop === "anchorEl", +})` + width: auto !important; + + .MuiAutocomplete-popperDisablePortal { + position: relative; + width: 100% !important; + box-shadow: none; + padding: 0; + border: none; + } + + ${(props) => { + const borders = getBorders(props); + const corners = getCorners(props); + const shadows = getShadows(props); + const spacings = getSpaces(props); + + return ` + background-color: white; + border: ${borders?.gray[100]}; + border-radius: ${corners?.m}px; + box-shadow: ${shadows?.m}; + padding: ${spacings?.xs}px; + box-sizing: border-box; + z-index: 1400; + `; + }} +`; + +export const StyledPaper = styled(Paper, { + shouldForwardProp: (prop: string) => !doNotForwardProps.includes(prop), +})` + box-shadow: none; + margin: 0; + border-radius: 0; + padding-top: 0; + padding-bottom: 0; +`; + +export const StyledAutocompletePopper = styled(Popper)` + position: relative !important; + transform: none !important; + width: 100% !important; + box-shadow: none; + padding: 0; + border: none; +`; + +export const StyledAutocomplesWrapper = styled("div")` + display: flex; +`; + +export const StyledColumn = styled("div")` + ${(props: StyleProps) => { + const { columnWidth = 260 } = props; + return ` + width: ${columnWidth}px; + `; + }} +`; + +export const StyledAutocompleteInput = styled(InputSearch, { + shouldForwardProp: (prop: string) => !doNotForwardProps.includes(prop), +})<{ search: boolean }>` + margin: 0; + .MuiInputBase-root { + width: 100%; + } + /* (thuang): Works with attribute inputMode: "none" to hide mobile keyboard */ + caret-color: ${({ search }) => (search ? "auto" : "transparent")}; +`; diff --git a/packages/components/src/core/DropdownMenu/GITHUB_LABELS.tsx b/packages/components/src/core/DropdownMenu/GITHUB_LABELS.tsx index ade053027..39182d6b9 100644 --- a/packages/components/src/core/DropdownMenu/GITHUB_LABELS.tsx +++ b/packages/components/src/core/DropdownMenu/GITHUB_LABELS.tsx @@ -82,18 +82,21 @@ export const GITHUB_LABELS: DefaultDropdownMenuOption[] = [ sdsStyle="rounded" sdsType="secondary" color="error" + hover={false} />
diff --git a/packages/components/src/core/DropdownMenu/GITHUB_LABELS_MULTI_COLUMN.tsx b/packages/components/src/core/DropdownMenu/GITHUB_LABELS_MULTI_COLUMN.tsx new file mode 100644 index 000000000..e3e992817 --- /dev/null +++ b/packages/components/src/core/DropdownMenu/GITHUB_LABELS_MULTI_COLUMN.tsx @@ -0,0 +1,80 @@ +// (masoudmanson): The unit tests rely on the content in this file; do not alter it! + +export const GITHUB_LABELS_MULTI_COLUMN = [ + { + options: [ + { + name: "Status: can't reproduce", + section: "name only", + }, + { + name: "Status: confirmed", + section: "name only", + }, + { + count: 3, + name: "Status: duplicate", + section: "name with count", + }, + { + count: 5, + name: "Status: needs information", + section: "name with count", + }, + { + details: "This will not be worked on", + name: "Status: wont do/fix", + section: "name with details", + }, + { + details: "This is still in progress", + name: "Status: work in progress", + section: "name with details", + }, + ], + }, + { + options: [ + { + details: "This will not be worked on", + name: "Type: bug", + sdsIcon: "bacteria", + sdsIconProps: { + className: "custom-class-name", + }, + section: "With Icon", + }, + { + count: 4, + name: "Type: discussion", + sdsIcon: "puzzlePiece", + section: "With Icon", + }, + { + name: "Type: documentation", + sdsIcon: "copy", + section: "With Icon", + }, + { + name: "Type: enhancement", + sdsIcon: "lightBulb", + section: "With Icon", + }, + { + name: "Type: epic", + sdsIcon: "list", + section: "With Icon", + }, + { + name: "Type: feature request", + sdsIcon: "treeVertical", + section: "With Icon", + }, + { + name: "Type: question", + sdsIcon: "search", + section: "With Icon", + }, + ], + }, +]; diff --git a/packages/components/src/core/DropdownMenu/index.tsx b/packages/components/src/core/DropdownMenu/index.tsx index c139e6540..4cd048474 100644 --- a/packages/components/src/core/DropdownMenu/index.tsx +++ b/packages/components/src/core/DropdownMenu/index.tsx @@ -8,7 +8,7 @@ import { PopperProps, } from "@mui/material"; import React, { SyntheticEvent, useCallback } from "react"; -import Autocomplete, { DefaultAutocompleteOption } from "../Autocomplete"; +import Autocomplete, { DefaultAutocompleteOption } from "../AutocompleteBase"; import { InputSearchProps } from "../InputSearch"; import { StyleProps, diff --git a/packages/components/src/core/InputSearch/index.tsx b/packages/components/src/core/InputSearch/index.tsx index 2a66f7cf6..8ebdee5ed 100644 --- a/packages/components/src/core/InputSearch/index.tsx +++ b/packages/components/src/core/InputSearch/index.tsx @@ -136,7 +136,7 @@ const InputSearch = forwardRef( * (masoudmanson): This prevents the browser's default auto completion * menu from being displayed for the InputSearch. */ - autoComplete="off" + autoComplete="one-time-code" {...rest} /> diff --git a/packages/components/src/index.ts b/packages/components/src/index.ts index 68275c9d6..47c83994f 100644 --- a/packages/components/src/index.ts +++ b/packages/components/src/index.ts @@ -6,8 +6,10 @@ export * from "./core/Accordion"; export { default as Accordion } from "./core/Accordion"; export * from "./core/Alert"; export { default as Alert } from "./core/Alert"; -export * from "./core/Autocomplete"; -export { default as Autocomplete } from "./core/Autocomplete"; +export * from "./core/AutocompleteBase"; +export { default as AutocompleteBase } from "./core/AutocompleteBase"; +export * from "./core/AutocompleteMultiColumn"; +export { default as AutocompleteMultiColumn } from "./core/AutocompleteMultiColumn"; export * from "./core/Banner"; export { default as Banner } from "./core/Banner"; export * from "./core/Button";