Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feat - Native Select / Select - Supporting additional content #1159

Merged
merged 11 commits into from
Nov 29, 2023
5 changes: 5 additions & 0 deletions .changeset/feat-selectChildren.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -433,7 +433,7 @@ function getIconButtonTransform(props) {
return position;
}

const IconButtonContainer = styled.span<{
export const IconButtonContainer = styled.span<{
iconPosition?: InputIconPosition;
inputSize?: InputSize;
theme: ThemeInterface;
Expand Down Expand Up @@ -623,7 +623,6 @@ export const InputBase = React.forwardRef<HTMLInputElement, InputBaseProps>(
const passwordBtnWidth = () => {
const btnWidth =
children?.props?.children?.[0]?.ref?.current?.offsetWidth;

if (typeof btnWidth === 'number') {
return btnWidth;
} else {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<NativeSelectProps> = args => (
<NativeSelect {...args}>
Expand Down Expand Up @@ -53,6 +57,39 @@ Disabled.args = {
disabled: true,
};

const WithContentTemplate: Story<NativeSelectProps> = args => {
const helpLinkLabel = 'Learn more';
const onHelpLinkClick = () => {
alert('Help link clicked!');
};
return (
<NativeSelect
{...args}
additionalContent={
<Tooltip content={helpLinkLabel}>
<IconButton
aria-label={helpLinkLabel}
icon={<HelpIcon />}
onClick={onHelpLinkClick}
type={ButtonType.button}
size={ButtonSize.small}
variant={ButtonVariant.link}
/>
</Tooltip>
}
>
<option>Red</option>
<option>Green</option>
<option>Blue</option>
</NativeSelect>
);
};

export const WithContent = WithContentTemplate.bind({});
WithContent.args = {
...Default.args,
};

export const HasError = Template.bind({});
HasError.args = {
...Default.args,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<NativeSelect testId={testId}></NativeSelect>
);
Expand All @@ -28,7 +33,6 @@ describe('NativeSelect', () => {
});

it('should render a disabled select', () => {
const testId = 'test-id';
const { getByTestId } = render(
<NativeSelect disabled testId={testId}></NativeSelect>
);
Expand All @@ -40,7 +44,6 @@ describe('NativeSelect', () => {
});

it('should render a disabled inverse select', () => {
const testId = 'test-id';
const { getByTestId } = render(
<NativeSelect disabled isInverse testId={testId}></NativeSelect>
);
Expand All @@ -52,7 +55,6 @@ describe('NativeSelect', () => {
});

it('should render a default border', () => {
const testId = 'test-id';
const { getByTestId } = render(
<NativeSelect testId={testId}></NativeSelect>
);
Expand All @@ -64,7 +66,6 @@ describe('NativeSelect', () => {
});

it('should render a default inverse border', () => {
const testId = 'test-id';
const { getByTestId } = render(
<NativeSelect isInverse testId={testId}></NativeSelect>
);
Expand All @@ -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(
<NativeSelect errorMessage={errorMessage} testId={testId}></NativeSelect>
Expand All @@ -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(
<NativeSelect
errorMessage={errorMessage}
isInverse
testId={testId}
></NativeSelect>
<NativeSelect errorMessage={errorMessage} isInverse testId={testId} />
);

expect(getByTestId(testId).parentElement).toHaveStyleRule(
Expand All @@ -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', () => {
silvalaura marked this conversation as resolved.
Show resolved Hide resolved
const { getByTestId } = render(
<NativeSelect
testId={testId}
additionalContent={
<Tooltip content={helpLinkLabel}>
<IconButton
aria-label={helpLinkLabel}
icon={<HelpIcon />}
onClick={onHelpLinkClick}
testId="Icon Button"
type={ButtonType.button}
size={ButtonSize.small}
variant={ButtonVariant.link}
/>
</Tooltip>
}
/>
);
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(
<NativeSelect
labelPosition="left"
testId={testId}
additionalContent={
<Tooltip content={helpLinkLabel}>
<IconButton
aria-label={helpLinkLabel}
icon={<HelpIcon />}
onClick={onHelpLinkClick}
testId="Icon Button"
type={ButtonType.button}
size={ButtonSize.small}
variant={ButtonVariant.link}
/>
</Tooltip>
}
/>
);
expect(getByTestId(`${testId}-form-field-container`)).toHaveStyleRule(
'display',
'flex'
);
});
});
});
131 changes: 103 additions & 28 deletions packages/react-magma-dom/src/components/NativeSelect/NativeSelect.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -13,13 +13,18 @@ import { useIsInverse } from '../../inverse';
import { useGenerateId } from '../../utils';
import { ThemeInterface } from '../../theme/magma';
import { transparentize } from 'polished';
import { LabelPosition } from '../Label';

/**
* @children required
*/
export interface NativeSelectProps
extends Omit<FormFieldContainerBaseProps, 'inputSize'>,
React.SelectHTMLAttributes<HTMLSelectElement> {
/**
* Content above the select. For use with Icon Buttons to relay information.
*/
additionalContent?: React.ReactNode;
/**
* @internal
*/
Expand Down Expand Up @@ -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<HTMLDivElement, NativeSelectProps>(
(props, ref) => {
const {
additionalContent,
children,
containerStyle,
disabled,
Expand All @@ -100,40 +137,78 @@ export const NativeSelect = React.forwardRef<HTMLDivElement, NativeSelectProps>(

const id = useGenerateId(defaultId);

const hasLabel = !!labelText;

// If the labelPosition is set to 'left' then a <div> wraps the FormFieldContainer, NativeSelectWrapper, and NativeSelect for proper styling alignment.
function AdditionalContentWrapper(props) {
if (
labelPosition === LabelPosition.left ||
(labelPosition === LabelPosition.top && !hasLabel)
) {
return (
<StyledAdditionalContentWrapper theme={theme}>
{props.children}
</StyledAdditionalContentWrapper>
);
}
return props.children;
}

function inlineContent() {
if (!labelText || labelPosition !== LabelPosition.top) {
return additionalContent;
}
}

return (
<FormFieldContainer
containerStyle={containerStyle}
errorMessage={errorMessage}
fieldId={id}
labelPosition={labelPosition}
labelStyle={labelStyle}
labelText={labelText}
labelWidth={labelWidth}
isInverse={isInverse}
helperMessage={helperMessage}
messageStyle={messageStyle}
ref={ref}
>
<StyledNativeSelectWrapper
disabled={disabled}
hasError={!!errorMessage}
<AdditionalContentWrapper labelPosition={labelPosition}>
<StyledFormFieldContainer
additionalContent={additionalContent}
containerStyle={containerStyle}
data-testId={testId && `${testId}-form-field-container`}
errorMessage={errorMessage}
fieldId={id}
hasLabel={!!labelText}
labelPosition={labelPosition}
labelStyle={labelStyle}
labelText={
labelPosition !== LabelPosition.left && additionalContent ? (
<>
{labelText}
{labelText && additionalContent}
</>
) : (
labelText
)
}
labelWidth={labelWidth}
isInverse={isInverse}
theme={theme}
helperMessage={helperMessage}
messageStyle={messageStyle}
ref={ref}
>
<StyledNativeSelect
data-testid={testId}
hasError={!!errorMessage}
<StyledNativeSelectWrapper
disabled={disabled}
id={id}
hasError={!!errorMessage}
isInverse={isInverse}
theme={theme}
{...other}
>
{children}
</StyledNativeSelect>
<DefaultDropdownIndicator disabled={disabled} />
</StyledNativeSelectWrapper>
</FormFieldContainer>
<StyledNativeSelect
data-testid={testId}
hasError={!!errorMessage}
disabled={disabled}
id={id}
isInverse={isInverse}
theme={theme}
{...other}
>
{children}
</StyledNativeSelect>
<DefaultDropdownIndicator disabled={disabled} />
</StyledNativeSelectWrapper>
</StyledFormFieldContainer>
{inlineContent()}
</AdditionalContentWrapper>
);
}
);
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { I18nContext } from '../../i18n';

export function MultiSelect<T>(props: MultiSelectProps<T>) {
const {
additionalContent,
ariaDescribedBy,
components: customComponents,
errorMessage,
Expand Down Expand Up @@ -160,6 +161,7 @@ export function MultiSelect<T>(props: MultiSelectProps<T>) {

return (
<SelectContainer
additionalContent={additionalContent}
descriptionId={ariaDescribedBy}
errorMessage={errorMessage}
getLabelProps={getLabelProps}
Expand Down
Loading
Loading