From ac8715a63e3955ef1f0702fd60dbddfb29bad084 Mon Sep 17 00:00:00 2001 From: Meis Date: Tue, 27 Jun 2023 11:40:35 -0600 Subject: [PATCH 01/11] feat: Well - make headingLevel prop optional --- src/components/Well/Well.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/Well/Well.tsx b/src/components/Well/Well.tsx index 391053f0..97d95b94 100644 --- a/src/components/Well/Well.tsx +++ b/src/components/Well/Well.tsx @@ -4,9 +4,9 @@ import ListItem from '../List/ListItem'; interface WellProperties { heading: string; - text: JSX.Element | string; - links?: JSX.Element[]; headingLevel?: HeadingLevel; + links?: JSX.Element[]; + text: JSX.Element | string; } export default function Well({ From 1d04b02a7c35c828580463ef4463821eb8029395 Mon Sep 17 00:00:00 2001 From: Meis Date: Thu, 13 Jul 2023 14:33:25 -0600 Subject: [PATCH 02/11] feat: Icon - With/without background --- src/components/Icon/Icon.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/components/Icon/Icon.tsx b/src/components/Icon/Icon.tsx index 9ab247f3..ab6a2bfb 100644 --- a/src/components/Icon/Icon.tsx +++ b/src/components/Icon/Icon.tsx @@ -55,7 +55,8 @@ interface IconProperties { export const Icon = ({ name, alt, - withBg = false + withBg = false, + ...others }: IconProperties): JSX.Element | null => { const shapeModifier = getShapeModifier(name, withBg); const fileName = `${name}${shapeModifier}`; @@ -77,6 +78,7 @@ export const Icon = ({ ); }; From 0b5bb5d7b4424131435a2787169956881cf32668 Mon Sep 17 00:00:00 2001 From: Meis Date: Thu, 13 Jul 2023 14:33:57 -0600 Subject: [PATCH 03/11] feat: Notification - allow conditional rendering; allow conditional display of icon --- src/components/Notification/Notification.tsx | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/components/Notification/Notification.tsx b/src/components/Notification/Notification.tsx index 7406b838..dce40282 100644 --- a/src/components/Notification/Notification.tsx +++ b/src/components/Notification/Notification.tsx @@ -27,6 +27,8 @@ interface NotificationProperties { headingLevel?: HeadingLevel; children?: React.ReactNode; links?: NotificationLinkProperties[]; + isVisible?: boolean; + showIcon?: boolean; } /** @@ -40,6 +42,8 @@ interface NotificationProperties { * @param links Links * @param message Notification reason * @param type Type of notification + * @param isVisible Display/hide notification + * @param showIcon Display/hide notification icon * @returns ReactElement */ export const Notification = ({ @@ -49,9 +53,13 @@ export const Notification = ({ links, message, type = 'info', + isVisible = true, + showIcon = true, ...properties }: NotificationProperties & - React.HTMLAttributes): React.ReactElement => { + React.HTMLAttributes): React.ReactElement | null => { + if (!isVisible) return null; + const classes = classNames( 'm-notification', 'm-notification__visible', @@ -66,7 +74,7 @@ export const Notification = ({ return (
- + {showIcon ? : null}
{message ? (

Date: Thu, 13 Jul 2023 14:51:56 -0600 Subject: [PATCH 04/11] feat: Label - Hide if no label text provided --- src/components/Label/Label.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) 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 (

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.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' + } ]; From 9c0334e78458baf5234b0e5b9e547d972e0b191d Mon Sep 17 00:00:00 2001 From: Meis Date: Tue, 18 Jul 2023 10:47:27 -0600 Subject: [PATCH 06/11] Support passing additional HTML element props to Divider and Layout components --- src/components/Divider/Divider.tsx | 2 +- src/components/Layout/LayoutContent.tsx | 12 +++++++++--- src/components/Layout/LayoutSidebar.tsx | 12 +++++++++--- src/components/Layout/LayoutWrapper.tsx | 11 ++++++++--- 4 files changed, 27 insertions(+), 10 deletions(-) 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/Layout/LayoutContent.tsx b/src/components/Layout/LayoutContent.tsx index add966c4..07b1a495 100644 --- a/src/components/Layout/LayoutContent.tsx +++ b/src/components/Layout/LayoutContent.tsx @@ -12,13 +12,19 @@ export const LayoutContent = ({ flushBottom, flushTopOnSmall, flushAllOnSmall, - narrow -}: LayoutContentProperties): JSX.Element => { + narrow, + ...properties +}: LayoutContentProperties & + React.HTMLAttributes): JSX.Element => { const cnames = ['content_main']; if (flushBottom) cnames.push('content__flush-bottom'); if (flushTopOnSmall) cnames.push('content__flush-top-on-small-bottom'); if (flushAllOnSmall) cnames.push('content__flush-all-on-small'); if (narrow) cnames.push('content_main__narrow'); - return
{children}
; + return ( +
+ {children} +
+ ); }; diff --git a/src/components/Layout/LayoutSidebar.tsx b/src/components/Layout/LayoutSidebar.tsx index 1fb665e8..514a17dc 100644 --- a/src/components/Layout/LayoutSidebar.tsx +++ b/src/components/Layout/LayoutSidebar.tsx @@ -10,12 +10,18 @@ export const LayoutSidebar = ({ children, flushBottom, flushTopOnSmall, - flushAllOnSmall -}: LayoutSidebarProperties): JSX.Element => { + flushAllOnSmall, + ...properties +}: LayoutSidebarProperties & + React.HTMLAttributes): JSX.Element => { const cnames = ['sidebar', 'content_sidebar', 'o-sidebar-content']; if (flushBottom) cnames.push('content__flush-bottom'); if (flushTopOnSmall) cnames.push('content__flush-top-on-small-bottom'); if (flushAllOnSmall) cnames.push('content__flush-all-on-small'); - return ; + return ( + + ); }; diff --git a/src/components/Layout/LayoutWrapper.tsx b/src/components/Layout/LayoutWrapper.tsx index 403e8a86..401173ed 100644 --- a/src/components/Layout/LayoutWrapper.tsx +++ b/src/components/Layout/LayoutWrapper.tsx @@ -16,8 +16,13 @@ interface LayoutWrapperProperties { children: JSX.Element | JSX.Element[] | string; } + export const LayoutWrapper = ({ - children -}: LayoutWrapperProperties): JSX.Element => ( -
{children}
+ children, + ...properties +}: LayoutWrapperProperties & + React.HTMLAttributes): JSX.Element => ( +
+ {children} +
); From ecb22aeca8b86b0a7b5a4cf5afd7884fdf64e145 Mon Sep 17 00:00:00 2001 From: Meis Date: Wed, 19 Jul 2023 16:36:26 -0600 Subject: [PATCH 07/11] Test: Update interactions to account for Clear All button --- src/components/Dropdown/Dropdown.test.tsx | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) 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'); }); From 54ca9aba6ad71446ce4d70bf938b8aae7b638bb5 Mon Sep 17 00:00:00 2001 From: Meis Date: Mon, 24 Jul 2023 14:24:38 -0600 Subject: [PATCH 08/11] Header - remove bottom margin --- src/components/PageHeader/header.less | 1 - 1 file changed, 1 deletion(-) diff --git a/src/components/PageHeader/header.less b/src/components/PageHeader/header.less index 02127bdf..7c4c2d28 100644 --- a/src/components/PageHeader/header.less +++ b/src/components/PageHeader/header.less @@ -66,7 +66,6 @@ } @media only all and (min-width: 56.3125em) { .o-header_logo-img { - margin: 0 0 1.25em 0; height: 50px; } } From 341fb7c3fdca51d0c149cb2517a5a246f289b1d7 Mon Sep 17 00:00:00 2001 From: Meis Date: Tue, 25 Jul 2023 11:31:08 -0600 Subject: [PATCH 09/11] Well - Propagate supported properties to HTML element --- src/components/Well/Well.tsx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/components/Well/Well.tsx b/src/components/Well/Well.tsx index 97d95b94..5c15dd16 100644 --- a/src/components/Well/Well.tsx +++ b/src/components/Well/Well.tsx @@ -7,13 +7,16 @@ interface WellProperties { headingLevel?: HeadingLevel; links?: JSX.Element[]; text: JSX.Element | string; + className?: string | undefined; } export default function Well({ heading, headingLevel = 'h4', links, - text + text, + className = '', + ...properties }: WellProperties): JSX.Element { const callsToAction = []; if (links) @@ -22,7 +25,7 @@ export default function Well({ } return ( -
+

{heading}

{text}

{callsToAction.length > 0 ? {callsToAction} : null} From c1d63ace698dfb072f5d6ca504e68ed52757026b Mon Sep 17 00:00:00 2001 From: Meis Date: Tue, 25 Jul 2023 11:31:08 -0600 Subject: [PATCH 10/11] Well - Propagate supported properties to HTML element --- src/components/Well/Well.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/Well/Well.tsx b/src/components/Well/Well.tsx index 5c15dd16..034b8245 100644 --- a/src/components/Well/Well.tsx +++ b/src/components/Well/Well.tsx @@ -21,7 +21,7 @@ export default function Well({ const callsToAction = []; if (links) for (const link of links) { - callsToAction.push({link}); + callsToAction.push({link}); } return ( From 90c6fc701ce7a0c9c514f470eb7195488abf5552 Mon Sep 17 00:00:00 2001 From: Meis Date: Tue, 25 Jul 2023 13:42:27 -0600 Subject: [PATCH 11/11] Icon - Allow sizing of icon to match an element that is not it's parent --- src/components/Icon/Icon.tsx | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/src/components/Icon/Icon.tsx b/src/components/Icon/Icon.tsx index ab6a2bfb..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,14 @@ 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, + size = 'inherit', ...others }: IconProperties): JSX.Element | null => { const shapeModifier = getShapeModifier(name, withBg); @@ -69,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('