From 5c4be73dde80b461cb3b462308433da707cab204 Mon Sep 17 00:00:00 2001 From: Eunice Kokor Date: Wed, 26 Apr 2023 16:53:16 -0700 Subject: [PATCH 1/3] 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 | 59 +++++++++++++++++++++++++++++++++ src/textfield/TextFieldBase.tsx | 26 ++++++++------- src/types/select.ts | 10 ++++-- stories/Form.stories.tsx | 50 ++++++++++++++++++++++++---- 7 files changed, 180 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 e63132e0..cb0ad99f 100644 --- a/src/field/HelpText.tsx +++ b/src/field/HelpText.tsx @@ -41,7 +41,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 108a7d01..efef3251 100644 --- a/src/provider/GlobalStyles.tsx +++ b/src/provider/GlobalStyles.tsx @@ -61,6 +61,65 @@ export const mediumRootCSS = css` `; 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; 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} /> )} /> From eee8380d8e5a98923a694aae67e07b65663202ae Mon Sep 17 00:00:00 2001 From: Eunice Kokor Date: Wed, 17 May 2023 11:57:31 -0400 Subject: [PATCH 2/3] move to css vars, update form stories --- src/dropdown/DropdownButton.tsx | 7 ++- src/textfield/TextFieldBase.tsx | 8 +++- src/tooltip/TooltipTrigger.tsx | 3 ++ stories/Form.stories.tsx | 80 ++++++++++++++++++++++----------- 4 files changed, 69 insertions(+), 29 deletions(-) diff --git a/src/dropdown/DropdownButton.tsx b/src/dropdown/DropdownButton.tsx index 3cb7819c..57921ec7 100644 --- a/src/dropdown/DropdownButton.tsx +++ b/src/dropdown/DropdownButton.tsx @@ -101,15 +101,18 @@ const quietButtonCSS = css` } &.ac-dropdown-button--invalid { - border-color: var(--ac-global-color-danger); + border-bottom-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 { + &.ac-dropdown-button > .ac-dropdown-button__text { padding-right: ${theme.spacing.padding24}px; } + &.ac-dropdown-button--invalid > .ac-dropdown-button__text { + padding-right: 0; + } `; /** diff --git a/src/textfield/TextFieldBase.tsx b/src/textfield/TextFieldBase.tsx index 474d12df..d8a34272 100644 --- a/src/textfield/TextFieldBase.tsx +++ b/src/textfield/TextFieldBase.tsx @@ -170,6 +170,8 @@ const textFieldBaseCSS = (styleProps: StyleProps) => css` &.ac-textfield--nested { border: none; } + /* Validation styles */ + --ac-validation-icon-width: var(--ac-global-dimension-size-300); `; const quietTextfieldBaseCSS = css` @@ -191,7 +193,7 @@ const quietTextfieldBaseCSS = css` } &.ac-textfield--invalid.ac-textfield__input { // Make room for the invalid icon - padding-right: 24px; + padding-right: var(--ac-validation-icon-width); color: var(--ac-global-color-danger); } @@ -251,7 +253,9 @@ const standardTextfieldBaseCSS = css` &.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; + padding-right: calc(${ + theme.spacing.padding8 + } + var(--ac-validation-icon-width) + ${theme.spacing.padding4}px); color: var(--ac-global-color-danger); } diff --git a/src/tooltip/TooltipTrigger.tsx b/src/tooltip/TooltipTrigger.tsx index f00a8da1..3374cab5 100644 --- a/src/tooltip/TooltipTrigger.tsx +++ b/src/tooltip/TooltipTrigger.tsx @@ -25,6 +25,9 @@ export interface TooltipTriggerProps * By default, opens for both focus and hover. Can be made to open only for focus. */ trigger?: 'focus'; + /** + * The first child is the element that triggers the tooltip while the second is the tooltip itself + */ children: [ReactElement, ReactElement]; } diff --git a/stories/Form.stories.tsx b/stories/Form.stories.tsx index ec51d700..e0f03240 100644 --- a/stories/Form.stories.tsx +++ b/stories/Form.stories.tsx @@ -11,6 +11,8 @@ import { Dropdown, Text, Provider, + Tooltip, + TooltipTrigger, } from '../src'; import { useForm, Controller } from 'react-hook-form'; import dedent from 'ts-dedent'; @@ -281,21 +283,39 @@ export const InlineForm: Story = (props) => { { + if (value.length > 10) { + return 'Monitor name too long'; + } + return true; + }, + }} render={({ field: { onChange, value }, fieldState: { invalid, error }, - }) => ( - - )} + }) => { + const monitorNameTextField = ( + + ); + return error?.message ? ( + + {monitorNameTextField} + {error.message} + + ) : ( + monitorNameTextField + ); + }} /> with a description = (props) => { render={({ field: { onChange, value }, fieldState: { invalid, error }, - }) => ( - - PSI - KL Divergence - - )} + }) => { + const metricPicker = ( + + PSI + KL Divergence + + ); + return error?.message ? ( + + {metricPicker} + {error.message} + + ) : ( + metricPicker + ); + }} /> evaluating every From 6df2dd50b6fd0336b37c49125dacd0cd43229b48 Mon Sep 17 00:00:00 2001 From: Eunice Kokor Date: Wed, 17 May 2023 12:14:15 -0400 Subject: [PATCH 3/3] add width to reduce layout shift --- src/dropdown/DropdownButton.tsx | 34 +++++++++++++++++++++++---------- 1 file changed, 24 insertions(+), 10 deletions(-) diff --git a/src/dropdown/DropdownButton.tsx b/src/dropdown/DropdownButton.tsx index 57921ec7..5f2af1fa 100644 --- a/src/dropdown/DropdownButton.tsx +++ b/src/dropdown/DropdownButton.tsx @@ -53,13 +53,21 @@ const buttonBaseCSS = css` overflow: hidden; color: var(--ac-field-text-color-override, ${theme.textColors.white90}); } - .ac-icon-wrap:not(.ac-dropdown-button__validation-icon) { + .ac-dropdown-button__dropdown-icon { margin: 10px 0 10px 10px; flex: fixed; width: 16px; height: 16px; font-size: 16px; } + + /* Validation styles */ + --ac-validation-icon-width: var(--ac-global-dimension-size-300); + + // prepend some space before the icon + .ac-dropdown-button__validation-icon { + margin: 0 0 0 10px; + } &[disabled] { opacity: ${theme.opacity.disabled}; } @@ -72,6 +80,17 @@ const buttonBaseCSS = css` color: var(--ac-global-color-danger); } } + + // Make room for the invalid icon + &.ac-dropdown-button > .ac-dropdown-button__text { + padding-right: calc(${ + theme.spacing.padding8 + }px + var(--ac-validation-icon-width)); + } + + &.ac-dropdown-button--invalid > .ac-dropdown-button__text { + padding-right: 0; + } `; /** @@ -99,20 +118,12 @@ const quietButtonCSS = css` cursor: default; border-bottom: 1px solid ${theme.components.dropdown.borderColor}; } - &.ac-dropdown-button--invalid { border-bottom-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 > .ac-dropdown-button__text { - padding-right: ${theme.spacing.padding24}px; - } - &.ac-dropdown-button--invalid > .ac-dropdown-button__text { - padding-right: 0; - } `; /** @@ -227,7 +238,10 @@ function DropdownButton( {children}
{validationState && validationState === 'invalid' ? validation : null} - } /> + } + /> );