Skip to content

Commit f23d22f

Browse files
devongovettLFDanLu
andauthored
feat: Support help text in Checkbox, Radio, and Switch (#9877)
* feat: Support help text in Checkbox, Radio, and Switch * fix * Fix checkbox styling when nested in collections * A couple more tests * fix aria-describedby behavior * Add tests for CheckboxField in collections * Add help text support in S2 * updates from testing * lint * make radiogroup rac example also grey out help text when disabled * whoops forgot it for switch too --------- Co-authored-by: Daniel Lu <dl1644@gmail.com>
1 parent e05b106 commit f23d22f

61 files changed

Lines changed: 1835 additions & 583 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

packages/@adobe/react-spectrum/src/checkbox/Checkbox.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ export const Checkbox = forwardRef(function Checkbox(props: SpectrumCheckboxProp
6868
// This is a bit unorthodox. Typically, hooks cannot be called in a conditional,
6969
// but since the checkbox won't move in and out of a group, it should be safe.
7070
let groupState = useContext(CheckboxGroupContext);
71-
let {inputProps, isInvalid, isDisabled} = groupState
71+
let {labelProps, inputProps, isInvalid, isDisabled} = groupState
7272
// eslint-disable-next-line react-hooks/rules-of-hooks
7373
? useCheckboxGroupItem({
7474
...props,
@@ -104,6 +104,7 @@ export const Checkbox = forwardRef(function Checkbox(props: SpectrumCheckboxProp
104104

105105
return (
106106
<label
107+
{...labelProps}
107108
{...styleProps}
108109
{...hoverProps}
109110
ref={domRef}

packages/@adobe/react-spectrum/src/radio/Radio.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,14 +47,15 @@ export const Radio = forwardRef(function Radio(props: SpectrumRadioProps, ref: F
4747
state
4848
} = radioGroupProps;
4949

50-
let {inputProps} = useRadio({
50+
let {labelProps, inputProps} = useRadio({
5151
...props,
5252
...radioGroupProps,
5353
isDisabled
5454
}, state, inputRef);
5555

5656
return (
5757
<label
58+
{...labelProps}
5859
{...styleProps}
5960
{...hoverProps}
6061
ref={domRef}

packages/@adobe/react-spectrum/src/switch/Switch.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,11 +49,12 @@ export const Switch = forwardRef(function Switch(props: SpectrumSwitchProps, ref
4949
let inputRef = useRef<HTMLInputElement>(null);
5050
let domRef = useFocusableRef(ref, inputRef);
5151
let state = useToggleState(props);
52-
let {inputProps} = useSwitch(props, state, inputRef);
52+
let {labelProps, inputProps} = useSwitch(props, state, inputRef);
5353

5454

5555
return (
5656
<label
57+
{...labelProps}
5758
{...styleProps}
5859
{...hoverProps}
5960
ref={domRef}

packages/@react-spectrum/s2/src/Checkbox.tsx

Lines changed: 101 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -10,21 +10,23 @@
1010
* governing permissions and limitations under the License.
1111
*/
1212

13+
import {baseColor, focusRing, space, style} from '../style' with {type: 'macro'};
14+
import {CenterBaseline} from './CenterBaseline';
1315
import {
14-
Checkbox as AriaCheckbox,
15-
CheckboxProps as AriaCheckboxProps,
16+
CheckboxButton,
17+
CheckboxField,
18+
CheckboxFieldProps,
1619
CheckboxRenderProps
1720
} from 'react-aria-components/Checkbox';
18-
import {baseColor, focusRing, space, style} from '../style' with {type: 'macro'};
19-
import {CenterBaseline} from './CenterBaseline';
2021
import {CheckboxGroupStateContext} from 'react-aria-components/CheckboxGroup';
2122
import CheckmarkIcon from '../ui-icons/Checkmark';
2223
import {ContextValue, useSlottedContext} from 'react-aria-components/slots';
2324
import {controlBorderRadius, controlFont, controlSize, getAllowedOverrides, StyleProps} from './style-utils' with {type: 'macro'};
2425
import {createContext, forwardRef, ReactNode, useContext, useRef} from 'react';
2526
import DashIcon from '../ui-icons/Dash';
26-
import {FocusableRef, FocusableRefValue, GlobalDOMAttributes} from '@react-types/shared';
27+
import {FocusableRef, FocusableRefValue, GlobalDOMAttributes, HelpTextProps} from '@react-types/shared';
2728
import {FormContext, useFormProps} from './Form';
29+
import {HelpText} from './Field';
2830
import {pressScale} from './pressScale';
2931
import {useFocusableRef} from './useDOMRef';
3032
import {useSpectrumContextProps} from './useSpectrumContextProps';
@@ -42,20 +44,50 @@ interface CheckboxStyleProps {
4244

4345
interface RenderProps extends CheckboxRenderProps, CheckboxStyleProps {}
4446

45-
export interface CheckboxProps extends Omit<AriaCheckboxProps, 'className' | 'style' | 'render' | 'children' | 'onHover' | 'onHoverStart' | 'onHoverEnd' | 'onHoverChange' | 'onClick' | keyof GlobalDOMAttributes>, StyleProps, CheckboxStyleProps {
47+
export interface CheckboxProps extends Omit<CheckboxFieldProps, 'className' | 'style' | 'render' | 'children' | 'onHover' | 'onHoverStart' | 'onHoverEnd' | 'onHoverChange' | 'onClick' | keyof GlobalDOMAttributes>, HelpTextProps, StyleProps, CheckboxStyleProps {
4648
/** The label for the element. */
4749
children?: ReactNode
4850
}
4951

50-
export const CheckboxContext = createContext<ContextValue<Partial<CheckboxProps>, FocusableRefValue<HTMLLabelElement>>>(null);
52+
export const CheckboxContext = createContext<ContextValue<Partial<CheckboxProps>, FocusableRefValue<HTMLInputElement, HTMLDivElement>>>(null);
53+
54+
const field = style({
55+
display: 'grid',
56+
gridTemplateColumns: ['max-content', '1fr'],
57+
columnGap: 'text-to-control',
58+
alignContent: 'start',
59+
width: {
60+
default: 'fit',
61+
isInCheckboxGroup: 'auto'
62+
},
63+
font: controlFont(),
64+
'--field-height': {
65+
type: 'height',
66+
value: controlSize()
67+
},
68+
rowGap: {
69+
default: 'calc(var(--field-height) - 1lh)',
70+
isInCheckboxGroup: {
71+
size: {
72+
S: space(1),
73+
M: space(1),
74+
L: 2,
75+
XL: 2
76+
}
77+
}
78+
},
79+
gridColumnStart: {
80+
isInForm: 'field'
81+
}
82+
}, getAllowedOverrides());
5183

5284
const wrapper = style({
53-
display: 'flex',
85+
display: 'grid',
86+
gridTemplateColumns: 'subgrid',
87+
gridColumnStart: 1,
88+
gridColumnEnd: -1,
5489
position: 'relative',
55-
columnGap: 'text-to-control',
5690
alignItems: 'baseline',
57-
width: 'fit',
58-
font: controlFont(),
5991
transition: 'colors',
6092
color: {
6193
default: baseColor('neutral'),
@@ -64,11 +96,8 @@ const wrapper = style({
6496
forcedColors: 'GrayText'
6597
}
6698
},
67-
gridColumnStart: {
68-
isInForm: 'field'
69-
},
7099
disableTapHighlight: true
71-
}, getAllowedOverrides());
100+
});
72101

73102
export const box = style<RenderProps>({
74103
...focusRing(),
@@ -126,7 +155,7 @@ export const iconStyles = style({
126155
}
127156
});
128157

129-
const iconSize = {
158+
const smallerSize = {
130159
S: 'XS',
131160
M: 'S',
132161
L: 'M',
@@ -137,7 +166,7 @@ const iconSize = {
137166
* Checkboxes allow users to select multiple items from a list of individual items,
138167
* or to mark one individual item as selected.
139168
*/
140-
export const Checkbox = forwardRef(function Checkbox({children, ...props}: CheckboxProps, ref: FocusableRef<HTMLLabelElement>) {
169+
export const Checkbox = forwardRef(function Checkbox({children, ...props}: CheckboxProps, ref: FocusableRef<HTMLInputElement, HTMLDivElement>) {
141170
[props, ref] = useSpectrumContextProps(props, ref, CheckboxContext);
142171
let boxRef = useRef(null);
143172
let inputRef = useRef<HTMLInputElement | null>(null);
@@ -148,47 +177,66 @@ export const Checkbox = forwardRef(function Checkbox({children, ...props}: Check
148177
let ctx = useSlottedContext(CheckboxContext, props.slot);
149178

150179
return (
151-
<AriaCheckbox
180+
<CheckboxField
152181
{...props}
153182
ref={domRef}
154183
inputRef={inputRef}
155184
style={props.UNSAFE_style}
156-
className={renderProps => (props.UNSAFE_className || '') + wrapper({...renderProps, isInForm, size: props.size || 'M'}, props.styles)}>
157-
{renderProps => {
158-
let checkbox = (
159-
<div
160-
ref={boxRef}
161-
style={pressScale(boxRef)(renderProps)}
162-
className={box({
163-
...renderProps,
164-
isSelected: renderProps.isSelected || renderProps.isIndeterminate,
165-
size: props.size || 'M',
166-
isEmphasized: isInCheckboxGroup ? ctx?.isEmphasized : props.isEmphasized
167-
})}>
168-
{renderProps.isIndeterminate &&
169-
<DashIcon size={iconSize[props.size || 'M']} className={iconStyles} />
170-
}
171-
{renderProps.isSelected && !renderProps.isIndeterminate &&
172-
<CheckmarkIcon size={iconSize[props.size || 'M']} className={iconStyles} />
173-
}
174-
</div>
175-
);
185+
className={(props.UNSAFE_className || '') + field({size: props.size || 'M', isInCheckboxGroup}, props.styles)}>
186+
{({isDisabled, isInvalid}) => (<>
187+
<CheckboxButton className={renderProps => wrapper({...renderProps, isInForm, size: props.size || 'M'})}>
188+
{renderProps => {
189+
let checkbox = (
190+
<div
191+
ref={boxRef}
192+
style={pressScale(boxRef)(renderProps)}
193+
className={box({
194+
...renderProps,
195+
isSelected: renderProps.isSelected || renderProps.isIndeterminate,
196+
size: props.size || 'M',
197+
isEmphasized: isInCheckboxGroup ? ctx?.isEmphasized : props.isEmphasized
198+
})}>
199+
{renderProps.isIndeterminate &&
200+
<DashIcon size={smallerSize[props.size || 'M']} className={iconStyles} />
201+
}
202+
{renderProps.isSelected && !renderProps.isIndeterminate &&
203+
<CheckmarkIcon size={smallerSize[props.size || 'M']} className={iconStyles} />
204+
}
205+
</div>
206+
);
176207

177-
// Only render checkbox without center baseline if no label.
178-
// This avoids expanding the checkbox height to the font's line height.
179-
if (!children) {
180-
return checkbox;
181-
}
208+
// Only render checkbox without center baseline if no label.
209+
// This avoids expanding the checkbox height to the font's line height.
210+
if (!children) {
211+
return checkbox;
212+
}
182213

183-
return (
184-
<>
185-
<CenterBaseline>
186-
{checkbox}
187-
</CenterBaseline>
188-
{children}
189-
</>
190-
);
191-
}}
192-
</AriaCheckbox>
214+
return (
215+
<>
216+
<CenterBaseline>
217+
{checkbox}
218+
</CenterBaseline>
219+
<span className={style({gridColumnStart: 2})}>{children}</span>
220+
</>
221+
);
222+
}}
223+
</CheckboxButton>
224+
<HelpText
225+
size={isInCheckboxGroup ? smallerSize[props.size || 'M'] : props.size || 'M'}
226+
styles={style({
227+
gridColumnStart: {
228+
default: 1,
229+
isInCheckboxGroup: 2
230+
},
231+
paddingTop: 0
232+
})({isInCheckboxGroup})}
233+
isDisabled={isDisabled}
234+
isInvalid={isInCheckboxGroup ? false : isInvalid}
235+
description={props.description}
236+
showErrorIcon>
237+
{props.errorMessage}
238+
</HelpText>
239+
</>)}
240+
</CheckboxField>
193241
);
194242
});

packages/@react-spectrum/s2/src/CheckboxGroup.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,7 @@ export const CheckboxGroup = forwardRef(function CheckboxGroup(props: CheckboxGr
100100
size={size}
101101
labelPosition={labelPosition}
102102
labelAlign={labelAlign}
103+
isQuiet // Make the label affect the width of the group
103104
necessityIndicator={necessityIndicator}
104105
contextualHelp={props.contextualHelp}>
105106
{label}
@@ -119,7 +120,11 @@ export const CheckboxGroup = forwardRef(function CheckboxGroup(props: CheckboxGr
119120
// Spectrum uses a fixed spacing value for horizontal,
120121
// but the gap changes depending on t-shirt size in vertical.
121122
columnGap: 16,
122-
flexWrap: 'wrap'
123+
flexWrap: {
124+
orientation: {
125+
horizontal: 'wrap'
126+
}
127+
}
123128
})({orientation})}>
124129
<FormContext.Provider value={{...formContext, size, isRequired: undefined}}>
125130
<CheckboxContext.Provider value={{isEmphasized}}>

packages/@react-spectrum/s2/src/Field.tsx

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -273,11 +273,12 @@ export const Input = forwardRef(function Input(props: InputProps, ref: Forwarded
273273
});
274274

275275
interface HelpTextProps extends FieldErrorProps {
276-
size?: 'S' | 'M' | 'L' | 'XL',
276+
size?: 'XS' | 'S' | 'M' | 'L' | 'XL',
277277
isDisabled?: boolean,
278278
isInvalid?: boolean, // TODO: export FieldErrorContext from RAC to get this.
279279
description?: ReactNode,
280-
showErrorIcon?: boolean
280+
showErrorIcon?: boolean,
281+
styles?: StyleString
281282
}
282283

283284
export const helpTextStyles = style({
@@ -318,21 +319,37 @@ export function HelpText(props: HelpTextProps & {descriptionRef?: DOMRef<HTMLDiv
318319
<Text
319320
slot="description"
320321
ref={domDescriptionRef}
321-
className={helpTextStyles({size: props.size || 'M', isDisabled: props.isDisabled})}>
322+
className={mergeStyles(helpTextStyles({size: props.size || 'M', isDisabled: props.isDisabled}), props.styles)}>
322323
{props.description}
323324
</Text>
324325
);
325326
}
326327

328+
if (!props.isInvalid) {
329+
return null;
330+
}
331+
327332
return (
328333
<FieldError
329334
{...props}
330335
ref={domErrorRef}
331-
className={renderProps => helpTextStyles({...renderProps, size: props.size || 'M', isDisabled: props.isDisabled})}>
336+
className={renderProps => mergeStyles(helpTextStyles({...renderProps, size: props.size || 'M', isDisabled: props.isDisabled}), props.styles)}>
332337
{composeRenderProps(props.children, (children, {validationErrors}) => (<>
333338
{props.showErrorIcon &&
334339
<CenterBaseline>
335-
<AlertIcon />
340+
<AlertIcon
341+
// @ts-ignore - ts doesn't know that AlertIcon is an icon and not a raw SVG
342+
styles={style({
343+
size: {
344+
size: {
345+
XS: 14,
346+
S: 16,
347+
M: 20,
348+
L: 22,
349+
XL: 26
350+
}
351+
}
352+
})({size: props.size || 'M'})} />
336353
</CenterBaseline>
337354
}
338355
<span>{children || validationErrors.join(' ')}</span>

0 commit comments

Comments
 (0)