From cb6df91f534b11b772721bdc16d6d4a036394be3 Mon Sep 17 00:00:00 2001 From: Bernardo Sunderhus Date: Mon, 12 Aug 2024 14:35:53 +0200 Subject: [PATCH] feature(react-tag-picker): support TagPicker usage without TagPickerList (#32158) --- ...-f6c70a8c-6231-41fe-baa5-ce7d1e65417a.json | 7 ++ .../library/etc/react-tag-picker.api.md | 5 +- .../src/components/TagPicker/TagPicker.cy.tsx | 59 +++++++++++------ .../components/TagPicker/TagPicker.types.ts | 18 +++++- .../src/components/TagPicker/useTagPicker.ts | 17 +++-- .../TagPicker/useTagPickerContextValues.ts | 2 + .../TagPickerControl/useTagPickerControl.tsx | 5 +- .../TagPickerInput/useTagPickerInput.tsx | 2 +- .../library/src/contexts/TagPickerContext.ts | 1 + .../TagPicker/TagPickerNoPopover.stories.tsx | 64 +++++++++++++++++++ .../stories/src/TagPicker/index.stories.tsx | 1 + 11 files changed, 148 insertions(+), 33 deletions(-) create mode 100644 change/@fluentui-react-tag-picker-f6c70a8c-6231-41fe-baa5-ce7d1e65417a.json create mode 100644 packages/react-components/react-tag-picker/stories/src/TagPicker/TagPickerNoPopover.stories.tsx diff --git a/change/@fluentui-react-tag-picker-f6c70a8c-6231-41fe-baa5-ce7d1e65417a.json b/change/@fluentui-react-tag-picker-f6c70a8c-6231-41fe-baa5-ce7d1e65417a.json new file mode 100644 index 0000000000000..efd2c4f2d387b --- /dev/null +++ b/change/@fluentui-react-tag-picker-f6c70a8c-6231-41fe-baa5-ce7d1e65417a.json @@ -0,0 +1,7 @@ +{ + "type": "minor", + "comment": "feature: introduces noPopover property to TagPicker", + "packageName": "@fluentui/react-tag-picker", + "email": "bernardo.sunderhus@gmail.com", + "dependentChangeType": "patch" +} diff --git a/packages/react-components/react-tag-picker/library/etc/react-tag-picker.api.md b/packages/react-components/react-tag-picker/library/etc/react-tag-picker.api.md index bcaae6880404a..ad2eb52a97aab 100644 --- a/packages/react-components/react-tag-picker/library/etc/react-tag-picker.api.md +++ b/packages/react-components/react-tag-picker/library/etc/react-tag-picker.api.md @@ -217,9 +217,10 @@ export type TagPickerOptionState = ComponentState & Pick & Pick & Pick, 'size' | 'appearance'> & { + noPopover?: boolean; onOpenChange?: EventHandler; onOptionSelect?: EventHandler; - children: [JSX.Element, JSX.Element] | JSX.Element; + children: [JSX.Element, JSX.Element | undefined | false] | JSX.Element; inline?: boolean; }; @@ -230,7 +231,7 @@ export type TagPickerSize = 'medium' | 'large' | 'extra-large'; export type TagPickerSlots = {}; // @public -export type TagPickerState = ComponentState & Pick & Pick & { +export type TagPickerState = ComponentState & Pick & Pick & { trigger: React_2.ReactNode; popover?: React_2.ReactNode; inline: boolean; diff --git a/packages/react-components/react-tag-picker/library/src/components/TagPicker/TagPicker.cy.tsx b/packages/react-components/react-tag-picker/library/src/components/TagPicker/TagPicker.cy.tsx index cbf6a728f949c..54f065f6d7e0c 100644 --- a/packages/react-components/react-tag-picker/library/src/components/TagPicker/TagPicker.cy.tsx +++ b/packages/react-components/react-tag-picker/library/src/components/TagPicker/TagPicker.cy.tsx @@ -13,6 +13,8 @@ import { TagPickerOption } from '../TagPickerOption/TagPickerOption'; import { Avatar } from '@fluentui/react-avatar'; import { Button } from '@fluentui/react-button'; +import 'cypress-real-events'; +import { tagPickerControlClassNames } from '../TagPickerControl/useTagPickerControlStyles.styles'; /** * This error means that ResizeObserver * was not able to deliver all observations within a single animation frame. @@ -40,9 +42,14 @@ const options = [ 'Maria Rossi', ]; -type TagPickerControlledProps = Pick; +type TagPickerControlledProps = Pick; -const TagPickerControlled = ({ open, defaultOpen, defaultSelectedOptions = [] }: TagPickerControlledProps) => { +const TagPickerControlled = ({ + open, + defaultOpen, + defaultSelectedOptions = [], + noPopover = false, +}: TagPickerControlledProps) => { const [selectedOptions, setSelectedOptions] = React.useState(defaultSelectedOptions); const onOptionSelect: TagPickerProps['onOptionSelect'] = (e, data) => { setSelectedOptions(data.selectedOptions); @@ -54,13 +61,13 @@ const TagPickerControlled = ({ open, defaultOpen, defaultSelectedOptions = [] }: return (
- - {options - .filter(option => !selectedOptions.includes(option)) - .map((option, index) => ( - } - value={option} - key={option} - > - {option} - - ))} - + {noPopover ? undefined : ( + + {options + .filter(option => !selectedOptions.includes(option)) + .map((option, index) => ( + } + value={option} + key={option} + > + {option} + + ))} + + )}
); @@ -134,10 +143,10 @@ describe('TagPicker', () => { it('should open/close a listbox once expandIcon is clicked', () => { mount(); cy.get('[data-testid="tag-picker-list"]').should('not.exist'); - cy.get('[data-testid="tag-picker-control__expandIcon"]').realClick(); + cy.get(`.${tagPickerControlClassNames.expandIcon}`).realClick(); cy.get('[data-testid="tag-picker-list"]').should('be.visible'); cy.get('[data-testid="tag-picker-input"]').should('be.focused'); - cy.get('[data-testid="tag-picker-control__expandIcon"]').realClick(); + cy.get(`.${tagPickerControlClassNames.expandIcon}`).realClick(); cy.get('[data-testid="tag-picker-list"]').should('not.be.visible'); }); it('should open/close a listbox once surface (control) is clicked', () => { @@ -305,4 +314,12 @@ describe('TagPicker', () => { }); }); }); + it('should not render popover when "noPopover"', () => { + mount(); + cy.get('[data-testid="tag-picker-control"]').should('exist'); + cy.get(`.${tagPickerControlClassNames.expandIcon}`).should('not.exist'); + cy.get('[data-testid="tag-picker-list"]').should('not.exist'); + cy.get('[data-testid="tag-picker-input"]').realClick(); + cy.get('[data-testid="tag-picker-list"]').should('not.exist'); + }); }); diff --git a/packages/react-components/react-tag-picker/library/src/components/TagPicker/TagPicker.types.ts b/packages/react-components/react-tag-picker/library/src/components/TagPicker/TagPicker.types.ts index 1f2c9f3a075ca..00969493e3b4e 100644 --- a/packages/react-components/react-tag-picker/library/src/components/TagPicker/TagPicker.types.ts +++ b/packages/react-components/react-tag-picker/library/src/components/TagPicker/TagPicker.types.ts @@ -33,13 +33,20 @@ export type TagPickerProps = ComponentProps & 'positioning' | 'disabled' | 'defaultOpen' | 'selectedOptions' | 'defaultSelectedOptions' | 'open' > & Pick, 'size' | 'appearance'> & { + /** + * By default, when a single children is provided, the TagPicker will assume that the children + * is a popover. By setting this prop to true, the children will be treated as a trigger instead. + * + * @default false + */ + noPopover?: boolean; onOpenChange?: EventHandler; onOptionSelect?: EventHandler; /** * Can contain two children including a trigger and a popover */ - children: [JSX.Element, JSX.Element] | JSX.Element; + children: [JSX.Element, JSX.Element | undefined | false] | JSX.Element; /** * TagPickers are rendered out of DOM order on `document.body` by default, * use this to render the popover in DOM order @@ -74,7 +81,14 @@ export type TagPickerState = ComponentState & > & Pick< TagPickerContextValue, - 'triggerRef' | 'secondaryActionRef' | 'popoverId' | 'popoverRef' | 'targetRef' | 'tagPickerGroupRef' | 'size' + | 'triggerRef' + | 'secondaryActionRef' + | 'popoverId' + | 'popoverRef' + | 'targetRef' + | 'tagPickerGroupRef' + | 'size' + | 'noPopover' > & { trigger: React.ReactNode; popover?: React.ReactNode; diff --git a/packages/react-components/react-tag-picker/library/src/components/TagPicker/useTagPicker.ts b/packages/react-components/react-tag-picker/library/src/components/TagPicker/useTagPicker.ts index c68d4060ea2e1..6c658638188a4 100644 --- a/packages/react-components/react-tag-picker/library/src/components/TagPicker/useTagPicker.ts +++ b/packages/react-components/react-tag-picker/library/src/components/TagPicker/useTagPicker.ts @@ -27,7 +27,7 @@ export const useTagPicker_unstable = (props: TagPickerProps): TagPickerState => const triggerInnerRef = React.useRef(null); const secondaryActionRef = React.useRef(null); const tagPickerGroupRef = React.useRef(null); - const { positioning, size = 'medium', inline = false } = props; + const { positioning, size = 'medium', inline = false, noPopover = false } = props; const { targetRef, containerRef } = usePositioning({ position: 'below' as const, @@ -69,7 +69,7 @@ export const useTagPicker_unstable = (props: TagPickerProps): TagPickerState => size: 'medium', }); - const { trigger, popover } = childrenToTriggerAndPopover(props.children); + const { trigger, popover } = childrenToTriggerAndPopover(props.children, noPopover); return { activeDescendantController, @@ -77,6 +77,7 @@ export const useTagPicker_unstable = (props: TagPickerProps): TagPickerState => trigger, popover: comboboxState.open || comboboxState.hasFocus ? popover : undefined, popoverId, + noPopover, disabled: comboboxState.disabled, triggerRef: useMergedRefs(triggerInnerRef, activeParentRef), popoverRef: useMergedRefs(listboxRef, containerRef), @@ -105,28 +106,34 @@ export const useTagPicker_unstable = (props: TagPickerProps): TagPickerState => }; }; -const childrenToTriggerAndPopover = (children?: React.ReactNode) => { +const childrenToTriggerAndPopover = (children: React.ReactNode, noPopover: boolean) => { const childrenArray = React.Children.toArray(children) as React.ReactElement[]; if (process.env.NODE_ENV !== 'production') { if (childrenArray.length === 0) { // eslint-disable-next-line no-console - console.warn('Picker must contain at least one child'); + console.warn('TagPicker must contain at least one child'); } if (childrenArray.length > 2) { // eslint-disable-next-line no-console - console.warn('Picker must contain at most two children'); + console.warn('TagPicker must contain at most two children'); } } + if (noPopover) { + return { trigger: childrenArray[0] }; + } + let trigger: React.ReactElement | undefined = undefined; let popover: React.ReactElement | undefined = undefined; + if (childrenArray.length === 2) { trigger = childrenArray[0]; popover = childrenArray[1]; } else if (childrenArray.length === 1) { popover = childrenArray[0]; } + return { trigger, popover }; }; diff --git a/packages/react-components/react-tag-picker/library/src/components/TagPicker/useTagPickerContextValues.ts b/packages/react-components/react-tag-picker/library/src/components/TagPicker/useTagPickerContextValues.ts index 167e876ffc171..e4c20f182b9c6 100644 --- a/packages/react-components/react-tag-picker/library/src/components/TagPicker/useTagPickerContextValues.ts +++ b/packages/react-components/react-tag-picker/library/src/components/TagPicker/useTagPickerContextValues.ts @@ -24,6 +24,7 @@ export function useTagPickerContextValues(state: TagPickerState): TagPickerConte open, popoverId, disabled, + noPopover, } = state; return { activeDescendant: React.useMemo( @@ -59,6 +60,7 @@ export function useTagPickerContextValues(state: TagPickerState): TagPickerConte open, popoverId, disabled, + noPopover, }, }; } diff --git a/packages/react-components/react-tag-picker/library/src/components/TagPickerControl/useTagPickerControl.tsx b/packages/react-components/react-tag-picker/library/src/components/TagPickerControl/useTagPickerControl.tsx index 273c2a09b6c08..46ade32dbad2d 100644 --- a/packages/react-components/react-tag-picker/library/src/components/TagPickerControl/useTagPickerControl.tsx +++ b/packages/react-components/react-tag-picker/library/src/components/TagPickerControl/useTagPickerControl.tsx @@ -39,6 +39,7 @@ export const useTagPickerControl_unstable = ( const appearance = useTagPickerContext_unstable(ctx => ctx.appearance); const disabled = useTagPickerContext_unstable(ctx => ctx.disabled); const invalid = useFieldContext_unstable()?.validationState === 'error'; + const noPopover = useTagPickerContext_unstable(ctx => ctx.noPopover ?? false); const innerRef = React.useRef(null); const expandIconRef = React.useRef(null); @@ -53,7 +54,7 @@ export const useTagPickerControl_unstable = ( } const expandIcon = slot.optional(props.expandIcon, { - renderByDefault: true, + renderByDefault: !noPopover, defaultProps: { 'aria-expanded': open, children: , @@ -107,7 +108,7 @@ export const useTagPickerControl_unstable = ( root: slot.always( getIntrinsicElementProps('div', { ref: useMergedRefs(ref, targetRef, innerRef), - 'aria-owns': open ? popoverId : undefined, + 'aria-owns': open && !noPopover ? popoverId : undefined, ...props, onMouseDown: handleMouseDown, }), diff --git a/packages/react-components/react-tag-picker/library/src/components/TagPickerInput/useTagPickerInput.tsx b/packages/react-components/react-tag-picker/library/src/components/TagPickerInput/useTagPickerInput.tsx index 16fe6132f8797..d103572eb8c91 100644 --- a/packages/react-components/react-tag-picker/library/src/components/TagPickerInput/useTagPickerInput.tsx +++ b/packages/react-components/react-tag-picker/library/src/components/TagPickerInput/useTagPickerInput.tsx @@ -45,7 +45,7 @@ export const useTagPickerInput_unstable = ( const setHasFocus = useTagPickerContext_unstable(ctx => ctx.setHasFocus); const clearSelection = useTagPickerContext_unstable(ctx => ctx.clearSelection); const open = useTagPickerContext_unstable(ctx => ctx.open); - const popoverId = useTagPickerContext_unstable(ctx => ctx.popoverId); + const popoverId = useTagPickerContext_unstable(ctx => (ctx.noPopover ? undefined : ctx.popoverId)); const selectOption = useTagPickerContext_unstable(ctx => ctx.selectOption); const getOptionById = useTagPickerContext_unstable(ctx => ctx.getOptionById); const contextValue = useTagPickerContext_unstable(ctx => ctx.value); diff --git a/packages/react-components/react-tag-picker/library/src/contexts/TagPickerContext.ts b/packages/react-components/react-tag-picker/library/src/contexts/TagPickerContext.ts index efd9c1a15ece7..c4f16ea6cff93 100644 --- a/packages/react-components/react-tag-picker/library/src/contexts/TagPickerContext.ts +++ b/packages/react-components/react-tag-picker/library/src/contexts/TagPickerContext.ts @@ -25,6 +25,7 @@ export interface TagPickerContextValue secondaryActionRef: React.RefObject; tagPickerGroupRef: React.RefObject; size: TagPickerSize; + noPopover?: boolean; } /** diff --git a/packages/react-components/react-tag-picker/stories/src/TagPicker/TagPickerNoPopover.stories.tsx b/packages/react-components/react-tag-picker/stories/src/TagPicker/TagPickerNoPopover.stories.tsx new file mode 100644 index 0000000000000..9ec1361476373 --- /dev/null +++ b/packages/react-components/react-tag-picker/stories/src/TagPicker/TagPickerNoPopover.stories.tsx @@ -0,0 +1,64 @@ +import * as React from 'react'; +import { + TagPicker, + TagPickerInput, + TagPickerControl, + TagPickerProps, + TagPickerGroup, +} from '@fluentui/react-components'; +import { Tag, Avatar, Field } from '@fluentui/react-components'; + +export const NoPopover = () => { + const [selectedOptions, setSelectedOptions] = React.useState([]); + const [inputValue, setInputValue] = React.useState(''); + + const onOptionSelect: TagPickerProps['onOptionSelect'] = (_, data) => { + setSelectedOptions(data.selectedOptions); + }; + const handleChange = (event: React.ChangeEvent) => { + setInputValue(event.currentTarget.value); + }; + const handleKeyDown = (event: React.KeyboardEvent) => { + if (event.key === 'Enter' && inputValue) { + setInputValue(''); + setSelectedOptions(curr => (curr.includes(inputValue) ? curr : [...curr, inputValue])); + } + }; + + return ( + + + + + {selectedOptions.map((option, index) => ( + } + value={option} + > + {option} + + ))} + + + + + + ); +}; + +NoPopover.parameters = { + docs: { + description: { + story: ` +You can use the \`TagPicker\` without the popover with the list of options by providing the \`noPopover\` property. This is useful when you want to allow users to input their own tags. All you have to do is control the \`TagPickerInput\` value and handle the \`onKeyDown\` event to add the tag to the \`TagPicker\` when the user presses the Enter key. + `, + }, + }, +}; diff --git a/packages/react-components/react-tag-picker/stories/src/TagPicker/index.stories.tsx b/packages/react-components/react-tag-picker/stories/src/TagPicker/index.stories.tsx index c7e391e95bc08..97b263c6324be 100644 --- a/packages/react-components/react-tag-picker/stories/src/TagPicker/index.stories.tsx +++ b/packages/react-components/react-tag-picker/stories/src/TagPicker/index.stories.tsx @@ -23,6 +23,7 @@ export { SecondaryAction } from './TagPickerSecondaryAction.stories'; export { Grouped } from './TagPickerGrouped.stories'; export { TruncatedText } from './TagPickerTruncatedText.stories'; export { SingleSelect } from './TagPickerSingleSelect.stories'; +export { NoPopover } from './TagPickerNoPopover.stories'; export default { title: 'Components/TagPicker',