Skip to content

Commit

Permalink
add validation to picker + dropdown button
Browse files Browse the repository at this point in the history
  • Loading branch information
eunicekokor committed Apr 28, 2023
1 parent 2368562 commit 4b0930f
Show file tree
Hide file tree
Showing 4 changed files with 100 additions and 29 deletions.
56 changes: 52 additions & 4 deletions src/dropdown/DropdownButton.tsx
Original file line number Diff line number Diff line change
@@ -1,19 +1,23 @@
import React, { ReactNode, CSSProperties } from 'react';
import { css } from '@emotion/react';
import { css, keyframes } from '@emotion/react';
import { classNames } from '../utils/classNames';
import { mergeProps } from '@react-aria/utils';
import { useButton } from '@react-aria/button';
import { useHover } from '@react-aria/interactions';
import { FocusableRef } from '../types';
import { FocusableRef, Validation } from '../types';
import { useFocusableRef } from '../utils/useDOMRef';
import theme from '../theme';
import { AddonBefore } from '../field';
import { Icon, ArrowIosDownwardOutline } from '../icon';
import { Icon, ArrowIosDownwardOutline, AlertCircleOutline } from '../icon';
import { AddonableProps } from '../types';
import { FocusRing } from '@react-aria/focus';
import { textSizeCSS } from '../content/styles';

export interface DropdownButtonProps extends AddonableProps {
const appearKeyframes = keyframes`
0% { opacity: 0; }
100% { opacity: 1; }
`;
export interface DropdownButtonProps extends AddonableProps, Validation {
/**
* Whether the button should be displayed with a quiet style.
* @default false
Expand Down Expand Up @@ -59,6 +63,15 @@ const buttonBaseCSS = css`
&[disabled] {
opacity: ${theme.opacity.disabled};
}
.ac-dropdown-button__validation-icon {
/* Animate in the icon */
animation: ${appearKeyframes} ${0.2}s forwards ease-in-out;
top: ${theme.spacing.padding8}px;
right: 0;
&.ac-dropdown-button__validation-icon--invalid {
color: ${theme.colors.statusDanger};
}
}
`;

/**
Expand Down Expand Up @@ -86,6 +99,17 @@ const quietButtonCSS = css`
cursor: default;
border-bottom: 1px solid ${theme.components.dropdown.borderColor};
}
&.ac-dropdown-button--invalid {
border-bottom: 1px solid ${theme.colors.statusDanger};
div.ac-dropdown__content {
color: ${theme.colors.statusDanger};
}
}
// Make room for the invalid icon
&.ac-dropdown-button--invalid > div.ac-dropdown__content {
padding-right: ${theme.spacing.padding24}px;
}
`;

/**
Expand Down Expand Up @@ -119,6 +143,18 @@ const nonQuietButtonCSS = css`
margin: ${theme.spacing.margin8}px ${theme.spacing.margin8}px
${theme.spacing.margin8}px ${theme.spacing.margin16}px;
}
&.ac-dropdown-button--invalid {
border: 1px solid ${theme.colors.statusDanger};
div.ac-dropdown__content {
color: ${theme.colors.statusDanger};
}
}
// Make room for the invalid icon (outer padding + icon width + inner padding)
&.ac-dropdown-button--invalid > div.ac-dropdown__content {
padding-right: ${theme.spacing.padding8 + 24 + theme.spacing.padding4}px;
}
`;

/**
Expand All @@ -139,13 +175,22 @@ function DropdownButton(
children,
style,
addonBefore,
validationState,
// TODO: add support for autoFocus
// autoFocus,
...otherProps
} = props;
const { buttonProps, isPressed } = useButton(props, domRef);
const { hoverProps, isHovered } = useHover({ isDisabled });
const isInvalid = validationState === 'invalid';

const validation = (
<Icon
key="validation-icon"
className={`ac-dropdown-button__validation-icon ac-dropdown-button__validation-icon--${validationState}`}
svg={<AlertCircleOutline />}
/>
);
return (
<FocusRing focusRingClass="focus-ring">
<button
Expand All @@ -156,6 +201,8 @@ function DropdownButton(
'is-active': isActive || isPressed,
'is-disabled': isDisabled,
'is-hovered': isHovered,
'ac-dropdown-button--invalid': isInvalid,
'ac-dropdown-button--valid': validationState === 'valid',
})}
style={style}
css={css(
Expand All @@ -176,6 +223,7 @@ function DropdownButton(
>
{children}
</span>
{validationState && validationState === 'invalid' ? validation : null}
<Icon svg={<ArrowIosDownwardOutline />} />
</button>
</FocusRing>
Expand Down
18 changes: 2 additions & 16 deletions src/picker/Picker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,6 @@ import theme from '../theme';
import { useProviderProps } from '../provider';
import { Field } from '../field';
import { dimensionValue } from '../utils/styleProps';
import { AlertCircleOutline, Icon } from '../icon';

function Picker<T extends object>(
props: PickerProps<T>,
Expand Down Expand Up @@ -156,16 +155,6 @@ function Picker<T extends object>(
minWidth: isQuiet ? '200px' : buttonWidth,
};

let isInvalid = validationState === 'invalid';

const validation = (
<Icon
key="validation-icon"
className={`ac-dropdown__validation-icon ac-dropdown__validation-icon--${validationState}`}
svg={<AlertCircleOutline />}
/>
);

const overlay = (
<Popover
isOpen={state.isOpen}
Expand All @@ -191,6 +180,7 @@ function Picker<T extends object>(
<span
className={classNames({
'is-placeholder': state.selectedItem == null,
'ac-dropdown__content': true,
})}
>
{contents}
Expand All @@ -205,8 +195,6 @@ function Picker<T extends object>(
{
'is-disabled': isDisabled,
'ac-dropdown--quiet': isQuiet,
'ac-dropdown--invalid': isInvalid,
'ac-dropdown--valid': validationState === 'valid',
},
props.className
)}
Expand Down Expand Up @@ -239,13 +227,11 @@ function Picker<T extends object>(
className={classNames('ac-dropdown-trigger', {
'is-hovered': isHovered,
})}
validationState={validationState}
{...{ addonBefore }}
>
<div>{contents}</div>
{isLoadingInitial && 'Loading...'}
{validationState && validationState === 'invalid' && !isLoadingInitial
? validation
: null}
</DropdownButton>
</PressResponder>
{state.collection.size === 0 ? null : overlay}
Expand Down
5 changes: 3 additions & 2 deletions src/types/select.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { LabelableProps, Alignment, AddonableProps } from './labelable';
import { CollectionBase } from './collection';
import { InputBase, TextInputBase } from './input';
import { HelpTextProps, InputBase, TextInputBase } from './input';
import { AriaLabelingProps, DOMProps, FocusableDOMProps } from './dom';
import { SingleSelection } from './selection';
import { FocusableProps } from './events';
Expand Down Expand Up @@ -48,7 +48,8 @@ export interface PickerProps<T>
extends AriaSelectProps<T>,
AddonableProps,
StyleProps,
Validation {
Validation,
HelpTextProps {
/**
* Whether the picker should be displayed with a quiet style.
* @default false
Expand Down
50 changes: 43 additions & 7 deletions stories/Form.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ const meta: Meta = {

export default meta;

const Template: Story<FormProps> = args => {
const Template: Story<FormProps> = (args) => {
const { control, handleSubmit } = useForm();
const onSubmit = (d: any) => {
alert(JSON.stringify(d));
Expand Down Expand Up @@ -100,12 +100,19 @@ const Template: Story<FormProps> = args => {
<Controller
name={'tier'}
control={control}
render={({ field: { onChange, value } }) => (
rules={{ required: 'This field is required' }}
render={({
field: { onChange, value },
fieldState: { invalid, error },
}) => (
<Picker
addonBefore="$"
onSelectionChange={onChange}
selectedKey={value}
label={'Charge Amount'}
validationState={invalid ? 'invalid' : undefined}
errorMessage={error?.message}
aria-errormessage={error?.message}
>
<Item>Free</Item>
<Item>Paid</Item>
Expand Down Expand Up @@ -203,11 +210,19 @@ export const QuietForm: Story<FormProps> = () => {
<Controller
name={'tier'}
control={control}
render={({ field: { onChange, value } }) => (
rules={{ required: 'This field is required' }}
render={({
field: { onChange, value },
fieldState: { invalid, error },
}) => (
<Picker
addonBefore="$"
onSelectionChange={onChange}
selectedKey={value}
label={'Charge Amount'}
validationState={invalid ? 'invalid' : undefined}
errorMessage={error?.message}
aria-errormessage={error?.message}
>
<Item>Free</Item>
<Item>Paid</Item>
Expand Down Expand Up @@ -248,7 +263,7 @@ const Break = () => (
/>
);

export const InlineForm: Story<FormProps> = props => {
export const InlineForm: Story<FormProps> = (props) => {
const { control, handleSubmit } = useForm();
const onSubmit = (d: any) => {
alert(JSON.stringify(d));
Expand All @@ -267,13 +282,18 @@ export const InlineForm: Story<FormProps> = props => {
name={'name'}
control={control}
rules={{ required: 'This field is required', min: 10, max: 20 }}
render={({ field: { onChange, value }, fieldState: { invalid } }) => (
render={({
field: { onChange, value },
fieldState: { invalid, error },
}) => (
<TextField
onChange={onChange}
value={value}
validationState={invalid ? 'invalid' : undefined}
aria-label={'Name'}
placeholder={'e.g. drift monitor'}
errorMessage={error?.message}
aria-errormessage={error?.message}
/>
)}
/>
Expand Down Expand Up @@ -302,8 +322,22 @@ export const InlineForm: Story<FormProps> = props => {
<Controller
name={'type'}
control={control}
render={({ field: { onChange, value } }) => (
<Picker aria-label="Picker" defaultSelectedKey={'psi'}>
rules={{
required: 'This field is required',
validate: (value) => value !== 'psi' || 'Psi is not supported',
}}
render={({
field: { onChange, value },
fieldState: { invalid, error },
}) => (
<Picker
aria-label="Picker"
defaultSelectedKey={'psi'}
selectedKey={value}
onSelectionChange={onChange}
validationState={invalid ? 'invalid' : undefined}
aria-errormessage={error?.message}
>
<Item key="psi">PSI</Item>
<Item key="kl">KL Divergence</Item>
</Picker>
Expand All @@ -326,6 +360,8 @@ export const InlineForm: Story<FormProps> = props => {
aria-label={'time'}
type="number"
width={'static-size-900'}
errorMessage={error?.message}
aria-errormessage={error?.message}
/>
)}
/>
Expand Down

0 comments on commit 4b0930f

Please sign in to comment.