From 31f6ba5367bfc60bef1111ce3b877ec5e83c9f69 Mon Sep 17 00:00:00 2001 From: Eunice Kokor Date: Wed, 26 Apr 2023 16:53:16 -0700 Subject: [PATCH] saving validation picker add validation to picker + dropdown button PR comments --- src/dropdown/DropdownButton.tsx | 58 +++++++++++++++++++++--- src/field/HelpText.tsx | 2 +- src/picker/Picker.tsx | 3 ++ src/provider/GlobalStyles.tsx | 79 +++++++++++++++++++++++++++++++++ src/textfield/TextFieldBase.tsx | 26 ++++++----- src/types/select.ts | 10 +++-- stories/Form.stories.tsx | 50 ++++++++++++++++++--- 7 files changed, 200 insertions(+), 28 deletions(-) diff --git a/src/dropdown/DropdownButton.tsx b/src/dropdown/DropdownButton.tsx index 9a1f12d2..3cb7819c 100644 --- a/src/dropdown/DropdownButton.tsx +++ b/src/dropdown/DropdownButton.tsx @@ -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 @@ -49,7 +53,7 @@ const buttonBaseCSS = css` overflow: hidden; color: var(--ac-field-text-color-override, ${theme.textColors.white90}); } - .ac-icon-wrap { + .ac-icon-wrap:not(.ac-dropdown-button__validation-icon) { margin: 10px 0 10px 10px; flex: fixed; width: 16px; @@ -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: var(--ac-global-color-danger); + } + } `; /** @@ -86,6 +99,17 @@ const quietButtonCSS = css` cursor: default; border-bottom: 1px solid ${theme.components.dropdown.borderColor}; } + + &.ac-dropdown-button--invalid { + border-color: var(--ac-global-color-danger); + div.ac-dropdown__content { + color: var(--ac-global-color-danger); + } + } + // Make room for the invalid icon + &.ac-dropdown-button--invalid > div.ac-dropdown__content { + padding-right: ${theme.spacing.padding24}px; + } `; /** @@ -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 var(--ac-global-color-danger); + div.ac-dropdown__content { + color: var(--ac-global-color-danger); + } + } + + // 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; + } `; /** @@ -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 = ( + } + /> + ); return ( diff --git a/src/field/HelpText.tsx b/src/field/HelpText.tsx index 3b0c4832..398e6de0 100644 --- a/src/field/HelpText.tsx +++ b/src/field/HelpText.tsx @@ -42,7 +42,7 @@ function HelpText(props: HelpTextComponentProps, ref: DOMRef) { padding: ${theme.spacing.padding4}px 0 0; color: ${theme.textColors.white50}; &.ac-help-text--danger { - color: ${theme.colors.statusDanger}; + color: var(--ac-global-color-danger); } .ac-help-text__text { font-size: ${theme.typography.sizes.small.fontSize}px; diff --git a/src/picker/Picker.tsx b/src/picker/Picker.tsx index 3718ee97..76718f48 100644 --- a/src/picker/Picker.tsx +++ b/src/picker/Picker.tsx @@ -47,6 +47,7 @@ function Picker( autoFocus, addonBefore, menuWidth, + validationState, } = props; let state = useSelectState(props); @@ -179,6 +180,7 @@ function Picker( {contents} @@ -225,6 +227,7 @@ function Picker( className={classNames('ac-dropdown-trigger', { 'is-hovered': isHovered, })} + validationState={validationState} {...{ addonBefore }} >
{contents}
diff --git a/src/provider/GlobalStyles.tsx b/src/provider/GlobalStyles.tsx index 51b5d19f..3ef53f0a 100644 --- a/src/provider/GlobalStyles.tsx +++ b/src/provider/GlobalStyles.tsx @@ -1,6 +1,65 @@ import { Global, css } from '@emotion/react'; export const globalCSS = css` + :root { + --ac-global-dimension-static-size-0: 0px; + --ac-global-dimension-static-size-10: 1px; + --ac-global-dimension-static-size-25: 2px; + --ac-global-dimension-static-size-50: 4px; + --ac-global-dimension-static-size-40: 3px; + --ac-global-dimension-static-size-65: 5px; + --ac-global-dimension-static-size-100: 8px; + --ac-global-dimension-static-size-115: 9px; + --ac-global-dimension-static-size-125: 10px; + --ac-global-dimension-static-size-130: 11px; + --ac-global-dimension-static-size-150: 12px; + --ac-global-dimension-static-size-160: 13px; + --ac-global-dimension-static-size-175: 14px; + --ac-global-dimension-static-size-200: 16px; + --ac-global-dimension-static-size-225: 18px; + --ac-global-dimension-static-size-250: 20px; + --ac-global-dimension-static-size-300: 24px; + --ac-global-dimension-static-size-400: 32px; + --ac-global-dimension-static-size-450: 36px; + --ac-global-dimension-static-size-500: 40px; + --ac-global-dimension-static-size-550: 44px; + --ac-global-dimension-static-size-600: 48px; + --ac-global-dimension-static-size-700: 56px; + --ac-global-dimension-static-size-800: 64px; + --ac-global-dimension-static-size-900: 72px; + --ac-global-dimension-static-size-1000: 80px; + --ac-global-dimension-static-size-1200: 96px; + --ac-global-dimension-static-size-1700: 136px; + --ac-global-dimension-static-size-2400: 192px; + --ac-global-dimension-static-size-2600: 208px; + --ac-global-dimension-static-size-3400: 272px; + --ac-global-dimension-static-size-3600: 288px; + --ac-global-dimension-static-size-4600: 368px; + --ac-global-dimension-static-size-5000: 400px; + --ac-global-dimension-static-size-6000: 480px; + --ac-global-dimension-static-font-size-50: 11px; + --ac-global-dimension-static-font-size-75: 12px; + --ac-global-dimension-static-font-size-100: 14px; + --ac-global-dimension-static-font-size-150: 15px; + --ac-global-dimension-static-font-size-200: 16px; + --ac-global-dimension-static-font-size-300: 18px; + --ac-global-dimension-static-font-size-400: 20px; + --ac-global-dimension-static-font-size-500: 22px; + --ac-global-dimension-static-font-size-600: 25px; + --ac-global-dimension-static-font-size-700: 28px; + --ac-global-dimension-static-font-size-800: 32px; + --ac-global-dimension-static-font-size-900: 36px; + --ac-global-dimension-static-font-size-1000: 40px; + --ac-global-dimension-static-percent-50: 50%; + --ac-global-dimension-static-percent-100: 100%; + --ac-global-dimension-static-breakpoint-xsmall: 304px; + --ac-global-dimension-static-breakpoint-small: 768px; + --ac-global-dimension-static-breakpoint-medium: 1280px; + --ac-global-dimension-static-breakpoint-large: 1768px; + --ac-global-dimension-static-breakpoint-xlarge: 2160px; + --ac-global-dimension-static-grid-columns: 12; + --ac-global-dimension-static-grid-fluid-width: 100%; + --ac-global-dimension-static-grid-fixed-max-width: 1280px; :root { --ac-global-dimension-static-size-0: 0px; --ac-global-dimension-static-size-10: 1px; @@ -80,6 +139,26 @@ export const globalCSS = css` --ac-global-rounding-small: var(--ac-global-dimension-static-size-50); --ac-global-rounding-medium: var(--ac-global-dimension-static-size-100); + + --ac-global-color-danger: #f85149; + } + + --ac-global-color-gray-900: #181b1f; + --ac-global-color-gray-800: #1d2126; + --ac-global-color-gray-700: #23282e; + --ac-global-color-gray-600: #282e35; + --ac-global-color-gray-500: #2f353d; + --ac-global-color-gray-400: #3d434a; + --ac-global-color-gray-300: #4a5057; + --ac-global-color-gray-200: #585d64; + --ac-global-color-gray-100: #666b71; + --ac-global-background-color-light: var(--ac-global-color-gray-500); + --ac-global-background-color-dark: var(--ac-global-color-gray-900); + --ac-global-border-color-light: var(--ac-global-color-gray-100); + --ac-global-border-color-dark: var(--ac-global-color-gray-400); + --ac-global-rounding-small: var(--ac-global-dimension-static-size-50); + --ac-global-rounding-medium: var(--ac-global-dimension-static-size-100); + --ac-global-color-danger: #f85149; } `; diff --git a/src/textfield/TextFieldBase.tsx b/src/textfield/TextFieldBase.tsx index fcfb6c30..474d12df 100644 --- a/src/textfield/TextFieldBase.tsx +++ b/src/textfield/TextFieldBase.tsx @@ -117,9 +117,11 @@ const textFieldBaseCSS = (styleProps: StyleProps) => css` flex-direction: row; position: relative; align-items: center; - min-width: ${styleProps.width - ? dimensionValue(styleProps.width) - : dimensionValue('static-size-3400')}; + min-width: ${ + styleProps.width + ? dimensionValue(styleProps.width) + : dimensionValue('static-size-3400') + }; width: ${styleProps.width ? dimensionValue(styleProps.width) : '100%'}; transition: all 0.2s ease-in-out; @@ -185,12 +187,12 @@ const quietTextfieldBaseCSS = css` opacity: ${theme.opacity.disabled}; } &.ac-textfield--invalid:not(.is-disabled) { - border-bottom: 1px solid ${theme.colors.statusDanger}; + border-bottom: 1px solid var(--ac-global-color-danger); } &.ac-textfield--invalid.ac-textfield__input { // Make room for the invalid icon padding-right: 24px; - color: ${theme.colors.statusDanger}; + color: var(--ac-global-color-danger); } .ac-textfield__validation-icon { @@ -200,7 +202,7 @@ const quietTextfieldBaseCSS = css` right: 0; position: absolute; &.ac-textfield__validation-icon--invalid { - color: ${theme.colors.statusDanger}; + color: var(--ac-global-color-danger); } } `; @@ -222,9 +224,9 @@ const standardTextfieldBaseCSS = css` border: 1px solid ${theme.components.textField.activeBorderColor}; background-color: ${theme.components.textField.activeBackgroundColor}; &.ac-textfield--invalid { - border: 1px solid ${theme.colors.statusDanger}; + border: 1px solid var(--ac-global-color-danger); .ac-textfield__input { - color: ${theme.colors.statusDanger}; + color: var(--ac-global-color-danger); } } } @@ -241,16 +243,16 @@ const standardTextfieldBaseCSS = css` } &.ac-textfield--invalid:not(.is-disabled) { - border: 1px solid ${theme.colors.statusDanger}; + border: 1px solid var(--ac-global-color-danger); .ac-textfield__input { - color: ${theme.colors.statusDanger}; + color: var(--ac-global-color-danger); } } &.ac-textfield--invalid .ac-textfield__input { // Make room for the invalid icon (outer padding + icon width + inner padding) padding-right: ${theme.spacing.padding8 + 24 + theme.spacing.padding4}px; - color: ${theme.colors.statusDanger}; + color: var(--ac-global-color-danger); } .ac-textfield__validation-icon { @@ -260,7 +262,7 @@ const standardTextfieldBaseCSS = css` right: ${theme.spacing.padding8}px; position: absolute; &.ac-textfield__validation-icon--invalid { - color: ${theme.colors.statusDanger}; + color: var(--ac-global-color-danger); } } `; diff --git a/src/types/select.ts b/src/types/select.ts index d230272d..c6ca98c1 100644 --- a/src/types/select.ts +++ b/src/types/select.ts @@ -1,11 +1,12 @@ 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'; import { StyleProps } from './style'; import { DimensionValue } from './core'; +import { Validation, AriaValidationProps } from '../types'; export interface SelectProps extends CollectionBase, @@ -31,7 +32,8 @@ export interface AriaSelectProps extends SelectProps, DOMProps, AriaLabelingProps, - FocusableDOMProps { + FocusableDOMProps, + AriaValidationProps { /** * Describes the type of autocomplete functionality the input should provide if any. See [MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input#htmlattrdefautocomplete). */ @@ -45,7 +47,9 @@ export interface AriaSelectProps export interface PickerProps extends AriaSelectProps, AddonableProps, - StyleProps { + StyleProps, + Validation, + HelpTextProps { /** * Whether the picker should be displayed with a quiet style. * @default false diff --git a/stories/Form.stories.tsx b/stories/Form.stories.tsx index b3481473..ec51d700 100644 --- a/stories/Form.stories.tsx +++ b/stories/Form.stories.tsx @@ -32,7 +32,7 @@ const meta: Meta = { export default meta; -const Template: Story = args => { +const Template: Story = (args) => { const { control, handleSubmit } = useForm(); const onSubmit = (d: any) => { alert(JSON.stringify(d)); @@ -100,12 +100,19 @@ const Template: Story = args => { ( + rules={{ required: 'This field is required' }} + render={({ + field: { onChange, value }, + fieldState: { invalid, error }, + }) => ( Free Paid @@ -203,11 +210,19 @@ export const QuietForm: Story = () => { ( + rules={{ required: 'This field is required' }} + render={({ + field: { onChange, value }, + fieldState: { invalid, error }, + }) => ( Free Paid @@ -248,7 +263,7 @@ const Break = () => ( /> ); -export const InlineForm: Story = props => { +export const InlineForm: Story = (props) => { const { control, handleSubmit } = useForm(); const onSubmit = (d: any) => { alert(JSON.stringify(d)); @@ -267,13 +282,18 @@ export const InlineForm: Story = 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 }, + }) => ( )} /> @@ -302,8 +322,22 @@ export const InlineForm: Story = props => { ( - + rules={{ + required: 'This field is required', + validate: (value) => value !== 'psi' || 'Psi is not supported', + }} + render={({ + field: { onChange, value }, + fieldState: { invalid, error }, + }) => ( + PSI KL Divergence @@ -326,6 +360,8 @@ export const InlineForm: Story = props => { aria-label={'time'} type="number" width={'static-size-900'} + errorMessage={error?.message} + aria-errormessage={error?.message} /> )} />