From c47fc18b47ac5bd4e67055cd7726a5a08daf4cb8 Mon Sep 17 00:00:00 2001 From: ccedrone <77400920+ccedrone@users.noreply.github.com> Date: Wed, 29 Nov 2023 11:31:02 -0500 Subject: [PATCH] Feat - Native Select / Select - Supporting additional content (#1159) --- .changeset/feat-selectChildren.md | 5 + .../src/components/InputBase/index.tsx | 3 +- .../NativeSelect/NativeSelect.stories.tsx | 37 +++++ .../NativeSelect/NativeSelect.test.js | 76 ++++++++-- .../components/NativeSelect/NativeSelect.tsx | 131 ++++++++++++++---- .../src/components/Select/MultiSelect.tsx | 2 + .../src/components/Select/Select.stories.tsx | 43 ++++++ .../src/components/Select/Select.test.js | 131 +++++++++++++++++- .../src/components/Select/Select.tsx | 2 + .../src/components/Select/SelectContainer.tsx | 89 ++++++++++-- .../src/components/Select/index.tsx | 4 + .../src/pages/api/native-select.mdx | 70 ++++++++++ .../react-magma-docs/src/pages/api/select.mdx | 74 ++++++++++ 13 files changed, 610 insertions(+), 57 deletions(-) create mode 100644 .changeset/feat-selectChildren.md diff --git a/.changeset/feat-selectChildren.md b/.changeset/feat-selectChildren.md new file mode 100644 index 000000000..946b064ec --- /dev/null +++ b/.changeset/feat-selectChildren.md @@ -0,0 +1,5 @@ +--- +'react-magma-dom': minor +--- + +feat(Select/NativeSelect): New `additionalContent` prop to provide the ability to add extra content inline with the label similar to the Input component. diff --git a/packages/react-magma-dom/src/components/InputBase/index.tsx b/packages/react-magma-dom/src/components/InputBase/index.tsx index d18c8b2bc..29cfa2f56 100644 --- a/packages/react-magma-dom/src/components/InputBase/index.tsx +++ b/packages/react-magma-dom/src/components/InputBase/index.tsx @@ -433,7 +433,7 @@ function getIconButtonTransform(props) { return position; } -const IconButtonContainer = styled.span<{ +export const IconButtonContainer = styled.span<{ iconPosition?: InputIconPosition; inputSize?: InputSize; theme: ThemeInterface; @@ -623,7 +623,6 @@ export const InputBase = React.forwardRef( const passwordBtnWidth = () => { const btnWidth = children?.props?.children?.[0]?.ref?.current?.offsetWidth; - if (typeof btnWidth === 'number') { return btnWidth; } else { diff --git a/packages/react-magma-dom/src/components/NativeSelect/NativeSelect.stories.tsx b/packages/react-magma-dom/src/components/NativeSelect/NativeSelect.stories.tsx index 39f909720..d4111b771 100644 --- a/packages/react-magma-dom/src/components/NativeSelect/NativeSelect.stories.tsx +++ b/packages/react-magma-dom/src/components/NativeSelect/NativeSelect.stories.tsx @@ -4,6 +4,10 @@ import { CardBody } from '../Card/CardBody'; import { NativeSelect, NativeSelectProps } from '.'; import { Story, Meta } from '@storybook/react/types-6-0'; import { LabelPosition } from '../Label'; +import { Tooltip } from '../Tooltip'; +import { IconButton } from '../IconButton'; +import { HelpIcon } from 'react-magma-icons'; +import { ButtonSize, ButtonType, ButtonVariant } from '../Button'; const Template: Story = args => ( @@ -53,6 +57,39 @@ Disabled.args = { disabled: true, }; +const WithContentTemplate: Story = args => { + const helpLinkLabel = 'Learn more'; + const onHelpLinkClick = () => { + alert('Help link clicked!'); + }; + return ( + + } + onClick={onHelpLinkClick} + type={ButtonType.button} + size={ButtonSize.small} + variant={ButtonVariant.link} + /> + + } + > + + + + + ); +}; + +export const WithContent = WithContentTemplate.bind({}); +WithContent.args = { + ...Default.args, +}; + export const HasError = Template.bind({}); HasError.args = { ...Default.args, diff --git a/packages/react-magma-dom/src/components/NativeSelect/NativeSelect.test.js b/packages/react-magma-dom/src/components/NativeSelect/NativeSelect.test.js index a82468215..093819070 100644 --- a/packages/react-magma-dom/src/components/NativeSelect/NativeSelect.test.js +++ b/packages/react-magma-dom/src/components/NativeSelect/NativeSelect.test.js @@ -4,10 +4,15 @@ import { magma } from '../../theme/magma'; import { NativeSelect } from '.'; import { render } from '@testing-library/react'; import { transparentize } from 'polished'; +import { Tooltip } from '../Tooltip'; +import { IconButton } from '../IconButton'; +import { HelpIcon } from 'react-magma-icons'; +import { ButtonSize, ButtonType, ButtonVariant } from '../Button'; describe('NativeSelect', () => { + const testId = 'test-id'; + it('should find element by testId', () => { - const testId = 'test-id'; const { getByTestId } = render( ); @@ -28,7 +33,6 @@ describe('NativeSelect', () => { }); it('should render a disabled select', () => { - const testId = 'test-id'; const { getByTestId } = render( ); @@ -40,7 +44,6 @@ describe('NativeSelect', () => { }); it('should render a disabled inverse select', () => { - const testId = 'test-id'; const { getByTestId } = render( ); @@ -52,7 +55,6 @@ describe('NativeSelect', () => { }); it('should render a default border', () => { - const testId = 'test-id'; const { getByTestId } = render( ); @@ -64,7 +66,6 @@ describe('NativeSelect', () => { }); it('should render a default inverse border', () => { - const testId = 'test-id'; const { getByTestId } = render( ); @@ -76,7 +77,6 @@ describe('NativeSelect', () => { }); it('should render an error state', () => { - const testId = 'test-id'; const errorMessage = 'This is an error'; const { getByTestId, getByText } = render( @@ -91,14 +91,9 @@ describe('NativeSelect', () => { }); it('should render an inverse error state', () => { - const testId = 'test-id'; const errorMessage = 'This is an error'; const { getByTestId, getByText } = render( - + ); expect(getByTestId(testId).parentElement).toHaveStyleRule( @@ -108,4 +103,61 @@ describe('NativeSelect', () => { expect(getByText(errorMessage)).toBeInTheDocument(); }); + + describe('additional content', () => { + const helpLinkLabel = 'Learn more'; + + const onHelpLinkClick = () => { + alert('Help link clicked!'); + }; + + it('Should accept additional content to the right of the native select label', () => { + const { getByTestId } = render( + + } + onClick={onHelpLinkClick} + testId="Icon Button" + type={ButtonType.button} + size={ButtonSize.small} + variant={ButtonVariant.link} + /> + + } + /> + ); + expect(getByTestId(testId)).toBeInTheDocument(); + expect(getByTestId('Icon Button')).toBeInTheDocument(); + }); + + it(`Should display additional content inline with the native select label when labelPosition is set to 'left'`, () => { + const { getByTestId } = render( + + } + onClick={onHelpLinkClick} + testId="Icon Button" + type={ButtonType.button} + size={ButtonSize.small} + variant={ButtonVariant.link} + /> + + } + /> + ); + expect(getByTestId(`${testId}-form-field-container`)).toHaveStyleRule( + 'display', + 'flex' + ); + }); + }); }); diff --git a/packages/react-magma-dom/src/components/NativeSelect/NativeSelect.tsx b/packages/react-magma-dom/src/components/NativeSelect/NativeSelect.tsx index 93b00d60d..f658eb5a4 100644 --- a/packages/react-magma-dom/src/components/NativeSelect/NativeSelect.tsx +++ b/packages/react-magma-dom/src/components/NativeSelect/NativeSelect.tsx @@ -1,6 +1,6 @@ import * as React from 'react'; import styled from '../../theme/styled'; - +import { css } from '@emotion/core'; import { inputBaseStyles, inputWrapperStyles } from '../InputBase'; import { FormFieldContainer, @@ -13,6 +13,7 @@ import { useIsInverse } from '../../inverse'; import { useGenerateId } from '../../utils'; import { ThemeInterface } from '../../theme/magma'; import { transparentize } from 'polished'; +import { LabelPosition } from '../Label'; /** * @children required @@ -20,6 +21,10 @@ import { transparentize } from 'polished'; export interface NativeSelectProps extends Omit, React.SelectHTMLAttributes { + /** + * Content above the select. For use with Icon Buttons to relay information. + */ + additionalContent?: React.ReactNode; /** * @internal */ @@ -75,9 +80,41 @@ const StyledNativeSelect = styled.select<{ } `; +const StyledFormFieldContainer = styled(FormFieldContainer)<{ + additionalContent?: React.ReactNode; + hasLabel?: boolean; + labelPosition?: LabelPosition; +}>` + display: ${props => + props.labelPosition === LabelPosition.left ? 'flex' : ''}; + + ${props => + props.additionalContent && + css` + flex: 1; + label { + display: flex; + justify-content: space-between; + align-items: center; + } + `} +`; + +const StyledAdditionalContentWrapper = styled.div` + align-items: center; + display: flex; + label { + margin: 0 ${props => props.theme.spaceScale.spacing03} 0 0; + } + button { + margin: 0 0 0 ${props => props.theme.spaceScale.spacing03}; + } +`; + export const NativeSelect = React.forwardRef( (props, ref) => { const { + additionalContent, children, containerStyle, disabled, @@ -100,40 +137,78 @@ export const NativeSelect = React.forwardRef( const id = useGenerateId(defaultId); + const hasLabel = !!labelText; + + // If the labelPosition is set to 'left' then a
wraps the FormFieldContainer, NativeSelectWrapper, and NativeSelect for proper styling alignment. + function AdditionalContentWrapper(props) { + if ( + labelPosition === LabelPosition.left || + (labelPosition === LabelPosition.top && !hasLabel) + ) { + return ( + + {props.children} + + ); + } + return props.children; + } + + function inlineContent() { + if (!labelText || labelPosition !== LabelPosition.top) { + return additionalContent; + } + } + return ( - - + + {labelText} + {labelText && additionalContent} + + ) : ( + labelText + ) + } + labelWidth={labelWidth} isInverse={isInverse} - theme={theme} + helperMessage={helperMessage} + messageStyle={messageStyle} + ref={ref} > - - {children} - - - - + + {children} + + + + + {inlineContent()} + ); } ); diff --git a/packages/react-magma-dom/src/components/Select/MultiSelect.tsx b/packages/react-magma-dom/src/components/Select/MultiSelect.tsx index a98e9499f..677b44644 100644 --- a/packages/react-magma-dom/src/components/Select/MultiSelect.tsx +++ b/packages/react-magma-dom/src/components/Select/MultiSelect.tsx @@ -12,6 +12,7 @@ import { I18nContext } from '../../i18n'; export function MultiSelect(props: MultiSelectProps) { const { + additionalContent, ariaDescribedBy, components: customComponents, errorMessage, @@ -160,6 +161,7 @@ export function MultiSelect(props: MultiSelectProps) { return ( > = args => ( + } + onClick={onHelpLinkClick} + type={ButtonType.button} + size={ButtonSize.small} + variant={ButtonVariant.link} + /> + + } + labelText="Helper icon" + {...args} + items={[ + { label: 'Red', value: 'red' }, + { label: 'Blue', value: 'blue' }, + { label: 'Green', value: 'green' }, + ]} + /> +); + +export const WithContent = WithContentTemplate.bind({}); +WithContent.args = { + isMulti: false, +}; + export const ErrorMessage = Template.bind({}); ErrorMessage.args = { ...Default.args, diff --git a/packages/react-magma-dom/src/components/Select/Select.test.js b/packages/react-magma-dom/src/components/Select/Select.test.js index 53ae186cf..0d7109c5a 100644 --- a/packages/react-magma-dom/src/components/Select/Select.test.js +++ b/packages/react-magma-dom/src/components/Select/Select.test.js @@ -4,6 +4,11 @@ import { Select } from '.'; import { defaultI18n } from '../../i18n/default'; import { magma } from '../../theme/magma'; import { Modal } from '../Modal'; +import { Tooltip } from '../Tooltip'; +import { IconButton } from '../IconButton'; +import { HelpIcon } from 'react-magma-icons'; +import { ButtonSize, ButtonType, ButtonVariant } from '../Button'; +import { LabelPosition } from '../Label'; describe('Select', () => { const labelText = 'Label'; @@ -459,7 +464,11 @@ describe('Select', () => { it('should show a left aligned label', () => { const { getByTestId } = render( - ); expect(getByTestId('selectContainerElement')).toHaveStyleRule( @@ -472,7 +481,7 @@ describe('Select', () => { const { getByText } = render( + } + onClick={onHelpLinkClick} + testId={'Icon Button'} + type={ButtonType.button} + size={ButtonSize.small} + variant={ButtonVariant.link} + /> + + } + labelText={labelText} + items={items} + /> + ); + + expect(getByTestId('Icon Button')).toBeInTheDocument(); + }); + + it('Should accept additional content to the right of the multi-select label', () => { + const { getByTestId } = render( + + } + onClick={onHelpLinkClick} + type={ButtonType.button} + size={ButtonSize.small} + variant={ButtonVariant.link} + /> + + } + labelPosition={LabelPosition.left} + labelText={labelText} + items={items} + data-testid="selectContainerElement" + /> + ); + + expect(getByTestId('selectContainerElement')).toBeInTheDocument(); + expect(getByTestId('selectContainerElement')).toHaveStyleRule( + 'display', + 'flex' + ); + }); + + it('When label position is left and isLabelVisuallyHidden is true, should accept additional content to display along select with a visually hidden label', () => { + const { getByTestId, getByText } = render( + + } + onClick={onHelpLinkClick} + type={ButtonType.button} + size={ButtonSize.small} + variant={ButtonVariant.link} + /> + + } + labelText="Default positioning" + items={[ + { label: 'Red', value: 'red' }, + { label: 'Blue', value: 'blue' }, + { label: 'Green', value: 'green' }, + ]} + /> +
+