diff --git a/change/@fluentui-react-fd94592f-6c11-434f-a340-fce340db9a34.json b/change/@fluentui-react-fd94592f-6c11-434f-a340-fce340db9a34.json new file mode 100644 index 0000000000000..6135caf03e623 --- /dev/null +++ b/change/@fluentui-react-fd94592f-6c11-434f-a340-fce340db9a34.json @@ -0,0 +1,7 @@ +{ + "type": "minor", + "comment": "fix: Added label, required, and error properties to BasePicker.", + "packageName": "@fluentui/react", + "email": "tpalacino@users.noreply.github.com", + "dependentChangeType": "patch" +} diff --git a/packages/react-examples/src/react/PeoplePicker/PeoplePicker.List.Example.tsx b/packages/react-examples/src/react/PeoplePicker/PeoplePicker.List.Example.tsx index 480611b64991b..ee575b0a6fcff 100644 --- a/packages/react-examples/src/react/PeoplePicker/PeoplePicker.List.Example.tsx +++ b/packages/react-examples/src/react/PeoplePicker/PeoplePicker.List.Example.tsx @@ -1,4 +1,5 @@ import * as React from 'react'; +import { TextField } from '@fluentui/react/lib/TextField'; import { Checkbox } from '@fluentui/react/lib/Checkbox'; import { IPersonaProps, IPersonaStyles } from '@fluentui/react/lib/Persona'; import { @@ -42,6 +43,9 @@ const personaStyles: Partial = { export const PeoplePickerListExample: React.FunctionComponent = () => { const [delayResults, setDelayResults] = React.useState(false); const [isPickerDisabled, setIsPickerDisabled] = React.useState(false); + const [pickerLabel, setPickerLabel] = React.useState('Choose People'); + const [showPickerLabel, setShowPickerLabel] = React.useState(false); + const [isPickerRequired, setIsPickerRequired] = React.useState(false); const [mostRecentlyUsed, setMostRecentlyUsed] = React.useState(mru); const [peopleList, setPeopleList] = React.useState(people); @@ -102,6 +106,18 @@ export const PeoplePickerListExample: React.FunctionComponent = () => { setIsPickerDisabled(!isPickerDisabled); }; + const onShowLabelButtonClick = (): void => { + setShowPickerLabel(!showPickerLabel); + }; + + const onPickerLabelChange = (_: React.FormEvent, newValue?: string): void => { + setPickerLabel(newValue ?? ''); + }; + + const onRequiredButtonClick = (): void => { + setIsPickerRequired(!isPickerRequired); + }; + const onToggleDelayResultsChange = (): void => { setDelayResults(!delayResults); }; @@ -115,9 +131,17 @@ export const PeoplePickerListExample: React.FunctionComponent = () => { ); }; + const onGetErrorMessage = React.useCallback( + (items: IPersonaProps[]): string | JSX.Element | PromiseLike | undefined => { + return isPickerRequired && (items || []).length === 0 ? 'Please fill out this field.' : undefined; + }, + [isPickerRequired], + ); + return (
{ componentRef={picker} resolveDelay={300} disabled={isPickerDisabled} + required={isPickerRequired} + onGetErrorMessage={onGetErrorMessage} /> { onChange={onToggleDelayResultsChange} styles={checkboxStyles} /> + + + {showPickerLabel && ( + + )}
); }; diff --git a/packages/react-examples/src/react/PeoplePicker/PeoplePicker.Normal.Example.tsx b/packages/react-examples/src/react/PeoplePicker/PeoplePicker.Normal.Example.tsx index 410efaa4a7145..f12398e2db581 100644 --- a/packages/react-examples/src/react/PeoplePicker/PeoplePicker.Normal.Example.tsx +++ b/packages/react-examples/src/react/PeoplePicker/PeoplePicker.Normal.Example.tsx @@ -1,4 +1,5 @@ import * as React from 'react'; +import { TextField } from '@fluentui/react/lib/TextField'; import { Checkbox } from '@fluentui/react/lib/Checkbox'; import { IPersonaProps } from '@fluentui/react/lib/Persona'; import { @@ -29,6 +30,9 @@ const checkboxStyles = { export const PeoplePickerNormalExample: React.FunctionComponent = () => { const [delayResults, setDelayResults] = React.useState(false); const [isPickerDisabled, setIsPickerDisabled] = React.useState(false); + const [pickerLabel, setPickerLabel] = React.useState('Choose People'); + const [showPickerLabel, setShowPickerLabel] = React.useState(false); + const [isPickerRequired, setIsPickerRequired] = React.useState(false); const [showSecondaryText, setShowSecondaryText] = React.useState(false); const [mostRecentlyUsed, setMostRecentlyUsed] = React.useState(mru); const [peopleList, setPeopleList] = React.useState(people); @@ -103,6 +107,18 @@ export const PeoplePickerNormalExample: React.FunctionComponent = () => { setIsPickerDisabled(!isPickerDisabled); }; + const onShowLabelButtonClick = (): void => { + setShowPickerLabel(!showPickerLabel); + }; + + const onPickerLabelChange = (_: React.FormEvent, newValue?: string): void => { + setPickerLabel(newValue ?? ''); + }; + + const onRequiredButtonClick = (): void => { + setIsPickerRequired(!isPickerRequired); + }; + const onToggleDelayResultsChange = (): void => { setDelayResults(!delayResults); }; @@ -111,9 +127,17 @@ export const PeoplePickerNormalExample: React.FunctionComponent = () => { setShowSecondaryText(!showSecondaryText); }; + const onGetErrorMessage = React.useCallback( + (items: IPersonaProps[]): string | JSX.Element | PromiseLike | undefined => { + return isPickerRequired && (items || []).length === 0 ? 'Please fill out this field.' : undefined; + }, + [isPickerRequired], + ); + return (
{ onInputChange={onInputChange} resolveDelay={300} disabled={isPickerDisabled} + required={isPickerRequired} + onGetErrorMessage={onGetErrorMessage} /> { onChange={onToggleShowSecondaryText} styles={checkboxStyles} /> + + + {showPickerLabel && ( + + )}
); }; diff --git a/packages/react/src/components/pickers/BasePicker.styles.ts b/packages/react/src/components/pickers/BasePicker.styles.ts index b6965fe710a14..e8d0d7d392155 100644 --- a/packages/react/src/components/pickers/BasePicker.styles.ts +++ b/packages/react/src/components/pickers/BasePicker.styles.ts @@ -10,13 +10,15 @@ import type { IStyle } from '../../Styling'; const GlobalClassNames = { root: 'ms-BasePicker', + label: 'ms-BasePicker-label', text: 'ms-BasePicker-text', itemsWrapper: 'ms-BasePicker-itemsWrapper', input: 'ms-BasePicker-input', + error: 'ms-BasePicker-error', }; export function getStyles(props: IBasePickerStyleProps): IBasePickerStyles { - const { className, theme, isFocused, inputClassName, disabled } = props; + const { className, theme, isFocused, inputClassName, disabled, hasErrorMessage } = props; if (!theme) { throw new Error('theme is undefined or null in base BasePicker getStyles function.'); @@ -56,8 +58,22 @@ export function getStyles(props: IBasePickerStyleProps): IBasePickerStyles { // const disabledOverlayColor = rgbColor ? `rgba(${rgbColor.r}, ${rgbColor.g}, ${rgbColor.b}, 0.29)` : 'transparent'; const disabledOverlayColor = 'rgba(218, 218, 218, 0.29)'; + const focusColor = isFocused && !disabled && (hasErrorMessage ? semanticColors.errorText : inputFocusBorderAlt); + return { root: [classNames.root, className, { position: 'relative' }], + error: [ + classNames.error, + { + fontSize: 12, + fontWeight: 400, + color: semanticColors.errorText, + margin: 0, + paddingTop: 5, + display: hasErrorMessage ? 'flex' : 'none', + alignItems: 'center', + }, + ], text: [ classNames.text, { @@ -79,7 +95,7 @@ export function getStyles(props: IBasePickerStyleProps): IBasePickerStyles { }, }, }, - isFocused && !disabled && getInputFocusStyle(inputFocusBorderAlt, effects.roundedCorner2), + focusColor && getInputFocusStyle(focusColor, effects.roundedCorner2), disabled && { borderColor: disabledOverlayColor, selectors: { @@ -102,6 +118,14 @@ export function getStyles(props: IBasePickerStyleProps): IBasePickerStyles { }, }, }, + hasErrorMessage && { + borderColor: semanticColors.errorText, + selectors: { + ':hover': { + borderColor: semanticColors.errorText, + }, + }, + }, ], itemsWrapper: [ classNames.itemsWrapper, diff --git a/packages/react/src/components/pickers/BasePicker.tsx b/packages/react/src/components/pickers/BasePicker.tsx index acf894a7ca8e5..0a22b22e261ca 100644 --- a/packages/react/src/components/pickers/BasePicker.tsx +++ b/packages/react/src/components/pickers/BasePicker.tsx @@ -19,6 +19,7 @@ import { SuggestionsController } from './Suggestions/SuggestionsController'; import { ValidationState } from './BasePicker.types'; import { Autofill } from '../Autofill/index'; import * as stylesImport from './BasePicker.scss'; +import { Label } from '../../Label'; import type { IProcessedStyleSet } from '../../Styling'; import type { ISuggestions, @@ -49,6 +50,7 @@ export interface IBasePickerState { isResultsFooterVisible?: boolean; selectedIndices?: number[]; selectionRemoved?: T; + errorMessage?: string | JSX.Element; } /** @@ -72,6 +74,10 @@ export type IPickerAriaIds = { * Aria id for element with role=combobox */ combobox: string; + /** + * Aria id for error message component + */ + error: string; }; const getClassNames = classNamesFunction(); @@ -115,6 +121,7 @@ export class BasePicker> private _styledSuggestions = getStyledSuggestions(this.SuggestionOfProperType); private _id: string; private _async: Async; + private _isMounted: boolean = false; private _onResolveSuggestionsDebounced: (updatedValue: string) => void; private _overrideScrollDismiss = false; private _overrideScrollDimissTimeout: number; @@ -139,6 +146,7 @@ export class BasePicker> selectedSuggestionAlert: `selected-suggestion-alert-${this._id}`, suggestionList: `suggestion-list-${this._id}`, combobox: `combobox-${this._id}`, + error: `error-${this._id}`, }; this.suggestionStore = new SuggestionsController(); this.selection = new Selection({ onSelectionChanged: () => this.onSelectionChange() }); @@ -160,7 +168,9 @@ export class BasePicker> } public componentDidMount(): void { + this._isMounted = true; this._async = new Async(this); + this._updateErrorMessage(this.state.items); this.selection.setItems(this.state.items); this._onResolveSuggestionsDebounced = this._async.debounce(this._onResolveSuggestions, this.props.resolveDelay); } @@ -183,6 +193,8 @@ export class BasePicker> } } + this._updateErrorMessage(this.state.items); + // handle dismiss buffer after suggestions are opened if (this.state.suggestionsVisible && !oldState.suggestionsVisible) { this._overrideScrollDismiss = true; @@ -194,6 +206,7 @@ export class BasePicker> } public componentWillUnmount(): void { + this._isMounted = false; if (this.currentPromise) { this.currentPromise = undefined; } @@ -269,6 +282,7 @@ export class BasePicker> const suggestionsVisible = !!this.state.suggestionsVisible; const suggestionsAvailable = suggestionsVisible ? this._ariaMap.suggestionList : undefined; + const hasError = !!(this.state.errorMessage ?? this.props.errorMessage); // TODO // Clean this up by leaving only the first part after removing support for SASS. // Currently we can not remove the SASS styles from BasePicker class because it @@ -284,10 +298,13 @@ export class BasePicker> className, isFocused, disabled, + hasErrorMessage: hasError, inputClassName: inputProps && inputProps.className, }) : { root: css('ms-BasePicker', className ? className : ''), + error: 'ms-BasePicker-error', + label: 'ms-BasePicker-label', text: css('ms-BasePicker-text', legacyStyles.pickerText, this.state.isFocused && legacyStyles.inputFocused), itemsWrapper: legacyStyles.pickerItems, input: css('ms-BasePicker-input', legacyStyles.pickerInput, inputProps && inputProps.className), @@ -295,6 +312,7 @@ export class BasePicker> }; const comboLabel = this.props['aria-label'] || inputProps?.['aria-label']; + const inputId = inputProps?.id ?? this._ariaMap.combobox; // selectionAriaLabel is contained in a separate rather than an aria-label on the items list // because if the items list has an aria-label, the aria-describedby on the input will only read @@ -309,6 +327,7 @@ export class BasePicker> onBlur={this.onBlur} onClick={this.onWrapperClick} > + {this.renderLabel(inputId, classNames.label)} {this.renderCustomAlert(classNames.screenReaderText)}