diff --git a/src/components/Divider/Divider.tsx b/src/components/Divider/Divider.tsx index ca5089f8..63a827f9 100644 --- a/src/components/Divider/Divider.tsx +++ b/src/components/Divider/Divider.tsx @@ -5,6 +5,6 @@ interface DividerProperties { export default function Divider({ className = '', ...properties -}: DividerProperties): JSX.Element { +}: DividerProperties & React.HTMLProps): JSX.Element { return
; } diff --git a/src/components/Dropdown/Dropdown.stories.tsx b/src/components/Dropdown/Dropdown.stories.tsx index e738ed3a..dbe64377 100644 --- a/src/components/Dropdown/Dropdown.stories.tsx +++ b/src/components/Dropdown/Dropdown.stories.tsx @@ -2,6 +2,7 @@ import type { Meta, StoryObj } from '@storybook/react'; import { useState } from 'react'; import { Button } from '../Button/Button'; +import type { SelectOption } from './Dropdown'; import { Dropdown } from './Dropdown'; import { MockOptions } from './utils'; @@ -55,14 +56,7 @@ export const Disabled: Story = { export const MultiSelect: Story = { args: { ...DefaultDropdown.args, - options: [ - ...MockOptions, - { - value: 'long', - label: - 'Multiselect options can also contain long words that will be wrapped like supercalifragilisticexpialidocious' - } - ], + options: [...MockOptions], id: 'multi', isMulti: true, label: 'Multi-select' @@ -72,14 +66,7 @@ export const MultiSelect: Story = { export const MultiSelectWithDefaultValue: Story = { args: { ...DefaultDropdown.args, - options: [ - ...MockOptions, - { - value: 'long', - label: - 'Multiselect options can also contain long words that will be wrapped like supercalifragilisticexpialidocious' - } - ], + options: [...MockOptions], defaultValue: [MockOptions[0]], id: 'multi', isMulti: true, @@ -87,17 +74,23 @@ export const MultiSelectWithDefaultValue: Story = { } }; +export const MultiSelectWithCheckboxes: Story = { + args: { + ...DefaultDropdown.args, + options: [...MockOptions], + defaultValue: [MockOptions[0]], + id: 'multi', + isMulti: true, + label: 'Multi-select', + pillAlign: 'bottom', + withCheckbox: true + } +}; + export const MultiSelectWithPillsAlignedBottom: Story = { args: { ...DefaultDropdown.args, - options: [ - ...MockOptions, - { - value: 'long', - label: - 'Multiselect options can also contain long words that will be wrapped like supercalifragilisticexpialidocious' - } - ], + options: [...MockOptions], defaultValue: [MockOptions[0]], id: 'multi', isMulti: true, @@ -106,14 +99,48 @@ export const MultiSelectWithPillsAlignedBottom: Story = { } }; -const ControlledDropdown = arguments_ => { - const [selected, setSelected] = useState([arguments_.options[0]]); +export const MultiSelectWithoutPills: Story = { + args: { + ...DefaultDropdown.args, + options: [...MockOptions], + defaultValue: [MockOptions[0]], + id: 'multi', + isMulti: true, + label: 'Multi-select', + pillAlign: 'hide', + withCheckbox: true + } +}; + +export const MultiSelectWithoutClearAllButton: Story = { + args: { + ...DefaultDropdown.args, + options: [...MockOptions], + defaultValue: [MockOptions[0]], + id: 'multi', + isMulti: true, + label: 'Multi-select', + pillAlign: 'bottom', + withCheckbox: true, + showClearAllSelectedButton: false + } +}; + +interface ControlledArguments { + options: SelectOption[]; +} +const ControlledDropdown = ({ + options, + ...arguments_ +}: ControlledArguments): JSX.Element => { + const [selected, setSelected] = useState([options[0]]); + return ( <>
setSelected(newValue)} value={selected} + {...arguments_} /> ); @@ -134,14 +164,7 @@ export const AsAControlledComponent: Story = { render: arguments_ => , args: { ...DefaultDropdown.args, - options: [ - ...MockOptions, - { - value: 'long', - label: - 'Multiselect options can also contain long words that will be wrapped like supercalifragilisticexpialidocious' - } - ], + options: [...MockOptions], defaultValue: [MockOptions[0]], id: 'multi', isMulti: true, diff --git a/src/components/Dropdown/Dropdown.test.tsx b/src/components/Dropdown/Dropdown.test.tsx index 1c0757c8..be16d800 100644 --- a/src/components/Dropdown/Dropdown.test.tsx +++ b/src/components/Dropdown/Dropdown.test.tsx @@ -92,7 +92,7 @@ describe('Default Dropdown', () => { options: MockOptions, onSelect, placeholder, - defaultValue: MockOptions.at(-1) + defaultValue: MockOptions.at(2) }} /> ); @@ -136,7 +136,7 @@ describe('Multi-select Dropdown', () => { }); const pills = screen.queryAllByRole('listitem'); - expect(pills.length).toBe(1); + expect(pills.length).toBe(2); const selectedOption = pills[0]; expect(selectedOption).toHaveClass('pill'); @@ -167,7 +167,7 @@ describe('Multi-select Dropdown', () => { // Verify pill displayed expect(screen.getByText(optionLabel)).toBeInTheDocument(); const afterSelection = screen.queryAllByRole('listitem'); - expect(afterSelection.length).toBe(1); + expect(afterSelection.length).toBe(2); expect(afterSelection[0]).toHaveClass('pill'); expect(afterSelection[0]).toHaveTextContent(optionLabel); @@ -190,7 +190,8 @@ describe('Multi-select Dropdown', () => { // Verify pills displayed const afterSelection = screen.queryAllByRole('listitem'); - expect(afterSelection.length).toBe(3); + // All options + Clear All button + expect(afterSelection.length).toBe(5); expect(afterSelection[2]).toHaveClass('pill'); expect(afterSelection[2]).toHaveTextContent(optionLabel); @@ -202,7 +203,7 @@ describe('Multi-select Dropdown', () => { // Verify correct option's pill was removed, while others remain const afterDelete = screen.queryAllByRole('listitem'); - expect(afterDelete.length).toBe(2); + expect(afterDelete.length).toBe(4); expect(afterDelete[0]).toHaveTextContent('Option B'); expect(afterDelete[1]).toHaveTextContent('Option C'); }); diff --git a/src/components/Dropdown/Dropdown.tsx b/src/components/Dropdown/Dropdown.tsx index 78c4190b..f04d73aa 100644 --- a/src/components/Dropdown/Dropdown.tsx +++ b/src/components/Dropdown/Dropdown.tsx @@ -4,13 +4,16 @@ import type { CSSObjectWithLabel, ControlProps, GroupBase, + InputActionMeta, OnChangeValue, OptionsOrGroups, PropsValue, SelectInstance } from 'react-select'; -import Select, { createFilter } from 'react-select'; +import Select, { components, createFilter } from 'react-select'; +import type { StateManagerProps } from 'react-select/dist/declarations/src/useStateManager'; import { Label } from '../Label/Label'; +import CheckboxInputOption from './DropdownInputWithCheckbox'; import { DropdownPills } from './DropdownPills'; export interface SelectOption { @@ -44,14 +47,16 @@ const extendedSelectStyles = { * @param options Available options * @param selected Selected options * @param isMulti Is a multi-select component? + * @param showAllOptions Force all options to be displayed for selection * @returns A list of selectable options */ const filterOptions = ( options: PropsValue, selected: PropsValue, - isMulti: boolean + isMulti: boolean, + showAllOptions: boolean ): OptionsOrGroups> => { - if (!selected || !isMulti) + if (showAllOptions || !selected || !isMulti) return options as OptionsOrGroups>; return (options as SelectOption[]).filter( @@ -67,10 +72,19 @@ interface DropdownProperties { label?: string; onSelect: (event: OnChangeValue) => void; options: SelectOption[]; - pillAlign?: 'bottom' | 'top'; + pillAlign?: 'bottom' | 'hide' | 'top'; // Display pills below/above the select input or hide them + showClearAllSelectedButton?: boolean; // Show/Hide our custom 'Clear All...' button value?: PropsValue; + withCheckbox?: boolean; // Show/Hide checkbox next to optios } +// Make it easier for the user to delete/edit search text by highlighting all input text onFocus +const onSelectInputFocus = ( + event: React.ChangeEvent +): void => { + event.target.select(); +}; + /** * A dropdown input component that supports multi-select. * @@ -86,21 +100,31 @@ export function Dropdown({ options, pillAlign = 'top', value, - ...rest -}: DropdownProperties): JSX.Element { + withCheckbox = false, + isClearable = true, // Show/Hide react-select X in select input that clears all selections + showClearAllSelectedButton = true, + ...properties +}: DropdownProperties & StateManagerProps): JSX.Element { + const [searchString, setSearchString] = useState(''); const [selected, setSelected] = useState>( defaultValue ?? [] ); + // Retain user search input between interactions + const onInputChange = (inputValue: string, event: InputActionMeta): void => { + if (event.action !== 'input-change') return; + setSearchString(inputValue); + }; + + // Support acting as controlled component useEffect(() => { - // Support acting as controlled component if (value) setSelected(value); }, [value]); const selectReference = useRef(null); // Store updated list of selected items - const onChange = useCallback( + const onSelectionChange = useCallback( (option: PropsValue) => { onSelect(option); setSelected(option); @@ -124,42 +148,58 @@ export function Dropdown({ return (
- {!!label && ( - - )} + {pillAlign === 'top' && ( )} + {children} + + ); +}; + +export default CheckboxInputOption; diff --git a/src/components/Dropdown/DropdownPills.less b/src/components/Dropdown/DropdownPills.less index 94d604d8..d36800cf 100644 --- a/src/components/Dropdown/DropdownPills.less +++ b/src/components/Dropdown/DropdownPills.less @@ -1,12 +1,44 @@ @import (reference) '/src/assets/styles/_shared.less'; +.o-multiselect_choices figcaption, +.o-multiselect_choices ul { + display: inline-block; + vertical-align: top; + margin-top: 1em; +} + +.o-multiselect_choices .pill { + display: block; + + .a-label { + border: 1px solid @teal; + background-color: @teal-20; + color: black; + padding: 2px 25px 2px 10px; + + .cf-icon-svg { + fill: @black; + } + } + + &.clear-selected .a-btn { + background-color: @red-dark; + color: @white; + border-radius: 4px; + padding: 5px 8px; + margin-top: inherit; + display: block; + margin-top: 20px; + } +} + .o-multiselect_choices__bottom { li { margin-top: unit((8px / @base-font-size-px), em); margin-bottom: 0; &:first-child { - margin-top: unit((10px / @base-font-size-px), em); + margin-top: 0; } } } diff --git a/src/components/Dropdown/DropdownPills.tsx b/src/components/Dropdown/DropdownPills.tsx index 276d2a4a..18a95d56 100644 --- a/src/components/Dropdown/DropdownPills.tsx +++ b/src/components/Dropdown/DropdownPills.tsx @@ -1,9 +1,9 @@ -import type { ReactEventHandler } from 'react'; +import type { ReactEventHandler, Ref } from 'react'; import type { PropsValue } from 'react-select'; +import { Button } from '../Button/Button'; import { Icon } from '../Icon/Icon'; import { Label } from '../Label/Label'; import type { SelectOption } from './Dropdown'; - import './DropdownPills.less'; /** @@ -59,17 +59,23 @@ export const DropdownPill = ({ ); interface DropdownPillsProperties { + isMulti?: boolean; + labelClearAll?: string; onChange: (event: PropsValue) => void; + pillAlign?: 'bottom' | 'top'; selected: PropsValue; - isMulti?: boolean; - pillAlign?: 'top' | 'bottom'; + selectRef: Ref; + showClearAllSelectedButton?: boolean; } export const DropdownPills = ({ - selected, isMulti, + labelClearAll = 'Clear All Selected Institutions', onChange, - pillAlign = 'top' + pillAlign = 'top', + selected, + selectRef, + showClearAllSelectedButton }: DropdownPillsProperties): JSX.Element | null => { if ( !isMulti || @@ -79,19 +85,31 @@ export const DropdownPills = ({ ) return null; + const onClearAllSelected = (): void => { + selectRef?.current?.clearValue(); + }; + return ( -
    - {selected.map(({ value, label }: SelectOption, index: number) => ( - - ))} -
+
Selected:
+
    + {selected.map(({ value, label }: SelectOption, index: number) => ( + + ))} + {showClearAllSelectedButton ? ( +
  • +
  • + ) : null} +
+ ); }; diff --git a/src/components/Dropdown/utils.tsx b/src/components/Dropdown/utils.tsx index 9b896cf7..b6217a30 100644 --- a/src/components/Dropdown/utils.tsx +++ b/src/components/Dropdown/utils.tsx @@ -1,5 +1,10 @@ export const MockOptions = [ { value: 'value1', label: 'Option A' }, { value: 'value2', label: 'Option B' }, - { value: 'value3', label: 'Option C' } + { value: 'value3', label: 'Option C' }, + { + value: 'long', + label: + 'Options can also contain long words that will be wrapped like supercalifragilisticexpialidocious' + } ]; diff --git a/src/components/Icon/Icon.tsx b/src/components/Icon/Icon.tsx index 9ab247f3..5384a6a4 100644 --- a/src/components/Icon/Icon.tsx +++ b/src/components/Icon/Icon.tsx @@ -2,6 +2,18 @@ import classNames from 'classnames'; import { useIconSvg } from '../../hooks/useIconSvg'; import { numberIcons } from './iconLists'; +// Design System font sizes for HTML elements +const sizeMap: Record = { + h1: '34px', + h2: '26px', + h3: '22px', + h4: '18px', + h5: '14px', + p: '16px', + sub: '12px' +}; + +// Icons who's background is square as opposed to round const isSquare = new Set([ 'email', 'facebook', @@ -13,6 +25,7 @@ const isSquare = new Set([ 'youtube' ]); +// Is this an number icon, based on the icon name? const isNumber = new Set(numberIcons); /** @@ -40,6 +53,7 @@ interface IconProperties { name: string; alt?: string; withBg?: boolean; + size?: string; } /** @@ -50,12 +64,15 @@ interface IconProperties { * @param name Canonical icon name * @param alt Alt text for image * @param withBg With background? + * @param size Match the icon size to a specified HTML element or provide a custom size. By default the icon size is determined by it's parent element's font-size. * @returns ReactElement | null */ export const Icon = ({ name, alt, - withBg = false + withBg = false, + size = 'inherit', + ...others }: IconProperties): JSX.Element | null => { const shapeModifier = getShapeModifier(name, withBg); const fileName = `${name}${shapeModifier}`; @@ -68,7 +85,8 @@ export const Icon = ({ const iconAttributes = [ `class="${classNames(classes)}"`, 'role="img"', - `alt="${alt ?? name}"` + `alt="${alt ?? name}"`, + `style="font-size: ${sizeMap[size] || size}"` ].join(' '); const iconHtml = `${icon}`.replace(' ); }; diff --git a/src/components/Label/Label.tsx b/src/components/Label/Label.tsx index 388e54fc..bf454109 100644 --- a/src/components/Label/Label.tsx +++ b/src/components/Label/Label.tsx @@ -15,10 +15,13 @@ export const Label = ({ htmlFor, className, ...other -}: JSX.IntrinsicElements['label'] & LabelProperties): React.ReactElement => { +}: JSX.IntrinsicElements['label'] & + LabelProperties): React.ReactElement | null => { const styles = [...baseStyles, inline ? '' : 'a-label__heading']; const classes = [className, ...styles]; + if (!children) return null; + return (