Skip to content

Commit 0795617

Browse files
dkariosnowystingerDylan Kario
authored
Implement help text for TextField, TextArea, SearchField, and Picker (#1846)
* Update useControlledState for StrictMode The current useControlledState has side effects, this causes an issue StrictMode * remove out of date comment * Add help text interfaces Adapted from GH issue description * Basic setup for useField * Add HelpText to Field when description or errorMessage exist * Start adding desc/err msg to TextField and Combobox * Fix render and aria-describedby logic * More improvements to rendering logic * Only return props for desc/errMsg if they're passed in * Refactor TextFieldBase to use Field * Preserve aria-describedby prop * Remove useField.mdx placeholder doc * Props syntax refactor * Linting and type fixes * Create HelpText stories and chromatic Also rearrange order of aria-describedby elements. Move props['aria-describedby'] from beginning to end, so AT announces help text description first, which makes more sense. * Add Textfield stories * Add styles Used the spectrum-css helptext component styles for reference. https://github.com/adobe/spectrum-css/blob/main/components/vars/css/components/spectrum-helptext.css * Update styles with latest from spectrum-css and support showIcon Updated spectrum-css helptext component: https://github.com/adobe/spectrum-css/tree/DNA-7.0-beta/components/helptext * Support fields with no visible label Refactored return logic in TextFieldBase: always return a `<Field>` and delegate rendering a simple `React.cloneElement` to Field.tsx. In Field.tsx, only return a simple `React.cloneElement` if both a label and help text are missing. Otherwise, conditionally render a `<Label>` and `<HelpText>`. * Make 'description and error message' story interactive * Pass temp Field props in SearchWithin to fix lint * Fix field styles when labelPosition: side Form field and help text components (but not label) are wrapped in a vertical flex container, so that the help text renders below the field while the label is to the side. This flex container shouldn't render when no help text is provided. Modified CSS so that this new wrapper's width defaults to the default form field width, but still respects a user override. * Remove unused styleProps reference from TextFieldBase These styleProps are now always processed in `<Field>`. Follow up fix to a4d479a. * Support labelAlign=end * Add help text to Form stories and fix labelPosition=side styles * Remove unused style * Remove 2nd margin between icon and help text when labelAlign=end * Add more chromatic stories * Refactor labelAlign=side style * Add help text to MobileComboBox * Add help text to Picker * WIP: Add help text to CheckboxGroup Pushing for visibility in the PR and then reverting. Outstanding questions and issues: - Adds .spectrum-Field-field to role="presentation" div: good or bad? - Should help text live outside of role="group"? - Workaround of hardcoding wrapper width to `--spectrum-field-default-width` breaks CheckboxGroup since normally its width expands/shrinks to fit longest checkbox label * Revert "WIP: Add help text to CheckboxGroup" This reverts commit 1bc5a73. * Temporarily remove help text from Combobox Will bring it back once we find a workaround for hardcoding `.spectrum-Field-wrapper` width to 192px. Note that there's a bug in prod where comboboxes with `.spectrum-Field--positionTop` are already hardcoded to 192px width instead of 224px to account for the button. * Make descriptionProps, errorMessageProps opt. on SpectrumFieldProps Not every component that uses <Field> supports help text. * Add help text to SearchField and TextArea * PR comments: Add doc comment block & validationState=valid story * PR comments: rename showIcon to showErrorIcon * Skip failing NumberField test This is related to some breaking behavior for controlled components that use Field. Need more investigation on why calling `useSlotId()` in `useField.ts` interferes with `useControlledState`. * Don't let form fields accept showErrorIcon prop Make SpectrumTextFieldProps and SpectrumPickerProps extend from HelpTextProps instead of SpectrumHelpTextProps. Now only HelpText and Field can accept `showErrorIcon`. When CheckboxGroup and RadioGroup are supported, they'll set it to true while all other components that use Field should set it to false. I'll also add back tests that verify the icon shows. * Remove useField from monorepo for now Co-authored-by: Rob Snow <[email protected]> Co-authored-by: Dylan Kario <[email protected]>
1 parent 5cb4ecf commit 0795617

File tree

41 files changed

+997
-180
lines changed

Some content is hidden

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

41 files changed

+997
-180
lines changed

packages/@adobe/spectrum-css-temp/components/fieldlabel/index.css

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ governing permissions and limitations under the License.
5858
text-align: end; /* labelPosition=side case */
5959
}
6060

61-
/* A Field is a wrapper for a FieldLabel and a field component (e.g. textfield).
61+
/* A Field is a wrapper for a FieldLabel, a field component (e.g. textfield), and a HelpText.
6262
* By default, labels are placed above the field. Fields have a default width, and the
6363
* label will wrap within this width. The width of the whole field can be overridden by the user,
6464
* and this causes both the label and inner field to resize as well. */
@@ -86,10 +86,25 @@ governing permissions and limitations under the License.
8686
display: inline-flex;
8787
align-items: flex-start;
8888

89-
.spectrum-Field-field {
89+
/* Wraps the field & help text, but not the label */
90+
.spectrum-Field-wrapper {
9091
flex: 1;
9192
/* Setting `flex: 1;` is equivalent to `flex: 1 1 0;`, which means we expect to be able to shrink
92-
* To be able to shrink, we must have a min-width that isn't 'auto' */
93+
* To be able to shrink, we must have a min-width that isn't 'auto' */
94+
min-width: 0;
95+
/* TODO: By default, vertical flex wrapper for `labelPosition: side` should have default field width.
96+
* This is a workaround until we find a better way to set the width of the field & help text.
97+
* Should default to form field's default width and and allow users to override with custom width. */
98+
width: var(--spectrum-field-default-width);
99+
100+
.spectrum-Field-field {
101+
/* If the user overrides the width of the field, propagate to the inner component */
102+
width: 100%;
103+
}
104+
}
105+
106+
.spectrum-Field-field {
107+
flex: 1;
93108
min-width: 0;
94109
}
95110

@@ -122,6 +137,10 @@ governing permissions and limitations under the License.
122137
display: table-cell;
123138
}
124139

140+
.spectrum-Field-wrapper {
141+
width: 100%;
142+
}
143+
125144
.spectrum-Field-field {
126145
display: table-cell;
127146
width: auto;
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
/*
2+
* Copyright 2021 Adobe. All rights reserved.
3+
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
4+
* you may not use this file except in compliance with the License. You may obtain a copy
5+
* of the License at http://www.apache.org/licenses/LICENSE-2.0
6+
*
7+
* Unless required by applicable law or agreed to in writing, software distributed under
8+
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
9+
* OF ANY KIND, either express or implied. See the License for the specific language
10+
* governing permissions and limitations under the License.
11+
*/
12+
13+
@import '../commons/index.css';
14+
15+
:root {
16+
--spectrum-helptext-neutral-texticon-text-size: var(--spectrum-global-dimension-font-size-75);
17+
--spectrum-helptext-neutral-texticon-icon-gap: var(--spectrum-global-dimension-size-100);
18+
19+
--spectrum-helptext-negative-texticon-icon-padding-top: var(--spectrum-global-dimension-size-40);
20+
--spectrum-helptext-negative-texticon-icon-padding-bottom: var(--spectrum-global-dimension-size-40);
21+
22+
--spectrum-helptext-neutral-textonly-text-padding-top: var(--spectrum-global-dimension-static-size-50);
23+
--spectrum-helptext-neutral-textonly-text-transform: none;
24+
--spectrum-helptext-neutral-textonly-text-letter-spacing: var(--spectrum-global-font-letter-spacing-none);
25+
26+
/* Uses value for DNA variable --spectrum-helptext-l-neutral-textonly-text-padding-bottom, since m variant doesn't exist */
27+
--spectrum-helptext-neutral-textonly-text-padding-bottom: var(--spectrum-global-dimension-size-115);
28+
/* Override: DNA uses --spectrum-alias-component-text-line-height */
29+
--spectrum-helptext-neutral-textonly-text-line-height: var(--spectrum-global-font-line-height-small);
30+
}
31+
32+
.spectrum-HelpText {
33+
display: flex;
34+
font-size: var(--spectrum-helptext-neutral-texticon-text-size);
35+
.spectrum-HelpText-validationIcon {
36+
margin-inline-end: var(--spectrum-helptext-neutral-texticon-icon-gap);
37+
padding-block: var(--spectrum-helptext-negative-texticon-icon-padding-top) var(--spectrum-helptext-negative-texticon-icon-padding-bottom);
38+
flex-shrink: 0;
39+
}
40+
.spectrum-HelpText-text {
41+
/* Not in DNA: make text fill up all horizontal space. */
42+
flex: 1;
43+
44+
margin-inline-end: var(--spectrum-helptext-neutral-texticon-icon-gap);
45+
padding-block: var(--spectrum-helptext-neutral-textonly-text-padding-top) var(--spectrum-helptext-neutral-textonly-text-padding-bottom);
46+
line-height: var(--spectrum-helptext-neutral-textonly-text-line-height);
47+
text-transform: var(--spectrum-helptext-neutral-textonly-text-transform);
48+
letter-spacing: var(--spectrum-helptext-neutral-textonly-text-letter-spacing);
49+
}
50+
/* Not in DNA */
51+
&.spectrum-HelpText--alignEnd {
52+
text-align: end; /* Works with labelPosition=top and labelPosition=side */
53+
.spectrum-HelpText-text {
54+
margin-inline-end: 0;
55+
margin-inline-start: var(--spectrum-helptext-neutral-texticon-icon-gap);
56+
}
57+
.spectrum-HelpText-validationIcon {
58+
margin-inline-end: 0;
59+
}
60+
}
61+
}
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
/*
2+
* Copyright 2021 Adobe. All rights reserved.
3+
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
4+
* you may not use this file except in compliance with the License. You may obtain a copy
5+
* of the License at http://www.apache.org/licenses/LICENSE-2.0
6+
*
7+
* Unless required by applicable law or agreed to in writing, software distributed under
8+
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
9+
* OF ANY KIND, either express or implied. See the License for the specific language
10+
* governing permissions and limitations under the License.
11+
*/
12+
13+
:root {
14+
--spectrum-helptext-neutral-texticon-text-color: var(--spectrum-alias-label-text-color);
15+
--spectrum-helptext-neutral-texticon-text-color-disabled: var(--spectrum-alias-text-color-disabled);
16+
17+
/* Override: DNA uses --spectrum-semantic-negative-text-color-small */
18+
--spectrum-helptext-negative-texticon-text-color: var(--spectrum-semantic-negative-color-text-small);
19+
/* Override: DNA uses --spectrum-semantic-negative-icon-color */
20+
--spectrum-helptext-negative-texticon-icon-color: var(--spectrum-semantic-negative-color-text-small);
21+
}
22+
23+
.spectrum-HelpText--neutral {
24+
.spectrum-HelpText-text {
25+
color: var(--spectrum-helptext-neutral-texticon-text-color);
26+
}
27+
&.is-disabled {
28+
.spectrum-HelpText-text {
29+
color: var(--spectrum-helptext-neutral-texticon-text-color-disabled);
30+
}
31+
}
32+
}
33+
34+
.spectrum-HelpText--negative {
35+
.spectrum-HelpText-validationIcon {
36+
color: var(--spectrum-helptext-negative-texticon-icon-color);
37+
}
38+
.spectrum-HelpText-text {
39+
color: var(--spectrum-helptext-negative-texticon-text-color);
40+
}
41+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
/*
2+
* Copyright 2021 Adobe. All rights reserved.
3+
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
4+
* you may not use this file except in compliance with the License. You may obtain a copy
5+
* of the License at http://www.apache.org/licenses/LICENSE-2.0
6+
*
7+
* Unless required by applicable law or agreed to in writing, software distributed under
8+
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
9+
* OF ANY KIND, either express or implied. See the License for the specific language
10+
* governing permissions and limitations under the License.
11+
*/
12+
13+
@import './index.css';
14+
@import './skin.css';

packages/@react-aria/combobox/src/useComboBox.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,11 @@ interface ComboBoxAria<T> {
4848
/** Props for the list box, to be passed to [useListBox](useListBox.html). */
4949
listBoxProps: AriaListBoxOptions<T>,
5050
/** Props for the optional trigger button, to be passed to [useButton](useButton.html). */
51-
buttonProps: AriaButtonProps
51+
buttonProps: AriaButtonProps,
52+
/** Props for the combo box description element, if any. */
53+
descriptionProps: HTMLAttributes<HTMLElement>,
54+
/** Props for the combo box error message element, if any. */
55+
errorMessageProps: HTMLAttributes<HTMLElement>
5256
}
5357

5458
/**
@@ -152,7 +156,7 @@ export function useComboBox<T>(props: AriaComboBoxProps<T>, state: ComboBoxState
152156
state.setFocused(true);
153157
};
154158

155-
let {labelProps, inputProps} = useTextField({
159+
let {labelProps, inputProps, descriptionProps, errorMessageProps} = useTextField({
156160
...props,
157161
onChange: state.setInputValue,
158162
onKeyDown: !isReadOnly && chain(state.isOpen && collectionProps.onKeyDown, onKeyDown),
@@ -316,6 +320,8 @@ export function useComboBox<T>(props: AriaComboBoxProps<T>, state: ComboBoxState
316320
shouldUseVirtualFocus: true,
317321
shouldSelectOnPressUp: true,
318322
shouldFocusOnHover: true
319-
})
323+
}),
324+
descriptionProps,
325+
errorMessageProps
320326
};
321327
}

packages/@react-aria/label/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,4 +10,5 @@
1010
* governing permissions and limitations under the License.
1111
*/
1212

13+
export * from './useField';
1314
export * from './useLabel';
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
/*
2+
* Copyright 2021 Adobe. All rights reserved.
3+
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
4+
* you may not use this file except in compliance with the License. You may obtain a copy
5+
* of the License at http://www.apache.org/licenses/LICENSE-2.0
6+
*
7+
* Unless required by applicable law or agreed to in writing, software distributed under
8+
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
9+
* OF ANY KIND, either express or implied. See the License for the specific language
10+
* governing permissions and limitations under the License.
11+
*/
12+
13+
import {HelpTextProps} from '@react-types/shared';
14+
import {HTMLAttributes} from 'react';
15+
import {LabelAria, LabelAriaProps, useLabel} from './useLabel';
16+
import {mergeProps, useSlotId} from '@react-aria/utils';
17+
18+
interface AriaFieldProps extends LabelAriaProps, HelpTextProps {}
19+
20+
export interface FieldAria extends LabelAria {
21+
/** Props for the description element, if any. */
22+
descriptionProps: HTMLAttributes<HTMLElement>,
23+
/** Props for the error message element, if any. */
24+
errorMessageProps: HTMLAttributes<HTMLElement>
25+
}
26+
27+
/**
28+
* Provides the accessibility implementation for input fields.
29+
* Fields accept user input, gain context from their label, and may display a description or error message.
30+
* @param props - Props for the Field.
31+
*/
32+
export function useField(props: AriaFieldProps): FieldAria {
33+
let {description, errorMessage} = props;
34+
let {labelProps, fieldProps} = useLabel(props);
35+
36+
let descriptionId = useSlotId();
37+
let errorMessageId = useSlotId();
38+
39+
fieldProps = mergeProps(fieldProps, {
40+
'aria-describedby': [
41+
descriptionId,
42+
// Use aria-describedby for error message because aria-errormessage is unsupported using VoiceOver or NVDA. See https://github.com/adobe/react-spectrum/issues/1346#issuecomment-740136268
43+
errorMessageId,
44+
props['aria-describedby']
45+
].filter(Boolean).join(' ') || undefined
46+
});
47+
48+
let descriptionProps: HTMLAttributes<HTMLElement> = {};
49+
let errorMessageProps: HTMLAttributes<HTMLElement> = {};
50+
if (description) {
51+
descriptionProps.id = descriptionId;
52+
}
53+
if (errorMessage) {
54+
errorMessageProps.id = errorMessageId;
55+
}
56+
57+
return {
58+
labelProps,
59+
fieldProps,
60+
descriptionProps,
61+
errorMessageProps
62+
};
63+
}

packages/@react-aria/label/src/useLabel.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,15 +14,15 @@ import {AriaLabelingProps, DOMProps, LabelableProps} from '@react-types/shared';
1414
import {ElementType, LabelHTMLAttributes} from 'react';
1515
import {useId, useLabels} from '@react-aria/utils';
1616

17-
interface LabelAriaProps extends LabelableProps, DOMProps, AriaLabelingProps {
17+
export interface LabelAriaProps extends LabelableProps, DOMProps, AriaLabelingProps {
1818
/**
1919
* The HTML element used to render the label, e.g. 'label', or 'span'.
2020
* @default 'label'
2121
*/
2222
labelElementType?: ElementType
2323
}
2424

25-
interface LabelAria {
25+
export interface LabelAria {
2626
/** Props to apply to the label container element. */
2727
labelProps: LabelHTMLAttributes<HTMLLabelElement>,
2828
/** Props to apply to the field container element being labeled. */
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
/*
2+
* Copyright 2020 Adobe. All rights reserved.
3+
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
4+
* you may not use this file except in compliance with the License. You may obtain a copy
5+
* of the License at http://www.apache.org/licenses/LICENSE-2.0
6+
*
7+
* Unless required by applicable law or agreed to in writing, software distributed under
8+
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
9+
* OF ANY KIND, either express or implied. See the License for the specific language
10+
* governing permissions and limitations under the License.
11+
*/
12+
13+
import {renderHook} from '@testing-library/react-hooks';
14+
import {useField} from '../';
15+
16+
describe('useField', function () {
17+
let renderFieldHook = (fieldProps) => {
18+
let {result} = renderHook(() => useField(fieldProps));
19+
return result.current;
20+
};
21+
22+
it('should return label props', function () {
23+
let {labelProps, fieldProps} = renderFieldHook({label: 'Test'});
24+
expect(labelProps.id).toBeDefined();
25+
expect(fieldProps.id).toBeDefined();
26+
});
27+
28+
it('should return props for description and error message if they are passed in', function () {
29+
let {descriptionProps, errorMessageProps} = renderFieldHook({label: 'Test', description: 'Description', errorMessage: 'Error'});
30+
expect(descriptionProps.id).toBeDefined();
31+
expect(errorMessageProps.id).toBeDefined();
32+
});
33+
34+
it('should not return props for description and error message if they are not passed in', function () {
35+
let {descriptionProps, errorMessageProps} = renderFieldHook({label: 'Test'});
36+
expect(descriptionProps).toEqual({});
37+
expect(errorMessageProps).toEqual({});
38+
});
39+
});

packages/@react-aria/searchfield/src/useSearchField.ts

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212

1313
import {AriaButtonProps} from '@react-types/button';
1414
import {AriaSearchFieldProps} from '@react-types/searchfield';
15-
import {InputHTMLAttributes, LabelHTMLAttributes, RefObject} from 'react';
15+
import {HTMLAttributes, InputHTMLAttributes, LabelHTMLAttributes, RefObject} from 'react';
1616
// @ts-ignore
1717
import intlMessages from '../intl/*.json';
1818
import {SearchFieldState} from '@react-stately/searchfield';
@@ -25,7 +25,11 @@ interface SearchFieldAria {
2525
/** Props for the input element. */
2626
inputProps: InputHTMLAttributes<HTMLInputElement | HTMLTextAreaElement>,
2727
/** Props for the clear button. */
28-
clearButtonProps: AriaButtonProps
28+
clearButtonProps: AriaButtonProps,
29+
/** Props for the description element. */
30+
descriptionProps: HTMLAttributes<HTMLElement>,
31+
/** Props for the error message element. */
32+
errorMessageProps: HTMLAttributes<HTMLElement>
2933
}
3034

3135
/**
@@ -84,7 +88,7 @@ export function useSearchField(
8488
inputRef.current.focus();
8589
};
8690

87-
let {labelProps, inputProps} = useTextField({
91+
let {labelProps, inputProps, descriptionProps, errorMessageProps} = useTextField({
8892
...props,
8993
value: state.value,
9094
onChange: state.setValue,
@@ -106,6 +110,8 @@ export function useSearchField(
106110
preventFocusOnPress: true,
107111
onPress: onClearButtonClick,
108112
onPressStart
109-
}
113+
},
114+
descriptionProps,
115+
errorMessageProps
110116
};
111117
}

0 commit comments

Comments
 (0)