Skip to content

Commit

Permalink
feature(react-tag-picker): support TagPicker usage without TagPickerL…
Browse files Browse the repository at this point in the history
  • Loading branch information
bsunderhus authored Aug 12, 2024
1 parent 8cdc1ca commit cb6df91
Show file tree
Hide file tree
Showing 11 changed files with 148 additions and 33 deletions.
Original file line number Diff line number Diff line change
@@ -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"
}
Original file line number Diff line number Diff line change
Expand Up @@ -217,9 +217,10 @@ export type TagPickerOptionState = ComponentState<TagPickerOptionSlots> & Pick<O

// @public
export type TagPickerProps = ComponentProps<TagPickerSlots> & Pick<ComboboxProps, 'positioning' | 'disabled' | 'defaultOpen' | 'selectedOptions' | 'defaultSelectedOptions' | 'open'> & Pick<Partial<TagPickerContextValue>, 'size' | 'appearance'> & {
noPopover?: boolean;
onOpenChange?: EventHandler<TagPickerOnOpenChangeData>;
onOptionSelect?: EventHandler<TagPickerOnOptionSelectData>;
children: [JSX.Element, JSX.Element] | JSX.Element;
children: [JSX.Element, JSX.Element | undefined | false] | JSX.Element;
inline?: boolean;
};

Expand All @@ -230,7 +231,7 @@ export type TagPickerSize = 'medium' | 'large' | 'extra-large';
export type TagPickerSlots = {};

// @public
export type TagPickerState = ComponentState<TagPickerSlots> & Pick<ComboboxState, 'open' | 'activeDescendantController' | 'mountNode' | 'onOptionClick' | 'registerOption' | 'selectedOptions' | 'selectOption' | 'value' | 'setValue' | 'setOpen' | 'setHasFocus' | 'appearance' | 'clearSelection' | 'getOptionById' | 'getOptionsMatchingValue' | 'disabled'> & Pick<TagPickerContextValue, 'triggerRef' | 'secondaryActionRef' | 'popoverId' | 'popoverRef' | 'targetRef' | 'tagPickerGroupRef' | 'size'> & {
export type TagPickerState = ComponentState<TagPickerSlots> & Pick<ComboboxState, 'open' | 'activeDescendantController' | 'mountNode' | 'onOptionClick' | 'registerOption' | 'selectedOptions' | 'selectOption' | 'value' | 'setValue' | 'setOpen' | 'setHasFocus' | 'appearance' | 'clearSelection' | 'getOptionById' | 'getOptionsMatchingValue' | 'disabled'> & Pick<TagPickerContextValue, 'triggerRef' | 'secondaryActionRef' | 'popoverId' | 'popoverRef' | 'targetRef' | 'tagPickerGroupRef' | 'size' | 'noPopover'> & {
trigger: React_2.ReactNode;
popover?: React_2.ReactNode;
inline: boolean;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -40,9 +42,14 @@ const options = [
'Maria Rossi',
];

type TagPickerControlledProps = Pick<TagPickerProps, 'open' | 'defaultOpen' | 'defaultSelectedOptions'>;
type TagPickerControlledProps = Pick<TagPickerProps, 'open' | 'defaultOpen' | 'defaultSelectedOptions' | 'noPopover'>;

const TagPickerControlled = ({ open, defaultOpen, defaultSelectedOptions = [] }: TagPickerControlledProps) => {
const TagPickerControlled = ({
open,
defaultOpen,
defaultSelectedOptions = [],
noPopover = false,
}: TagPickerControlledProps) => {
const [selectedOptions, setSelectedOptions] = React.useState<string[]>(defaultSelectedOptions);
const onOptionSelect: TagPickerProps['onOptionSelect'] = (e, data) => {
setSelectedOptions(data.selectedOptions);
Expand All @@ -54,13 +61,13 @@ const TagPickerControlled = ({ open, defaultOpen, defaultSelectedOptions = [] }:
return (
<div style={{ maxWidth: 400 }}>
<TagPicker
noPopover={noPopover}
onOptionSelect={onOptionSelect}
selectedOptions={selectedOptions}
open={open}
defaultOpen={defaultOpen}
>
<TagPickerControl
expandIcon={{ 'data-testid': 'tag-picker-control__expandIcon' } as {}}
data-testid="tag-picker-control"
secondaryAction={
<Button
Expand Down Expand Up @@ -89,22 +96,24 @@ const TagPickerControlled = ({ open, defaultOpen, defaultSelectedOptions = [] }:
</TagPickerGroup>
<TagPickerInput data-testid="tag-picker-input" />
</TagPickerControl>
<TagPickerList data-testid="tag-picker-list">
{options
.filter(option => !selectedOptions.includes(option))
.map((option, index) => (
<TagPickerOption
id={`tag-picker-option--${index}`}
data-testid={`tag-picker-option--${option}`}
secondaryContent="Microsoft FTE"
media={<Avatar name={option} color="colorful" />}
value={option}
key={option}
>
{option}
</TagPickerOption>
))}
</TagPickerList>
{noPopover ? undefined : (
<TagPickerList data-testid="tag-picker-list">
{options
.filter(option => !selectedOptions.includes(option))
.map((option, index) => (
<TagPickerOption
id={`tag-picker-option--${index}`}
data-testid={`tag-picker-option--${option}`}
secondaryContent="Microsoft FTE"
media={<Avatar name={option} color="colorful" />}
value={option}
key={option}
>
{option}
</TagPickerOption>
))}
</TagPickerList>
)}
</TagPicker>
</div>
);
Expand Down Expand Up @@ -134,10 +143,10 @@ describe('TagPicker', () => {
it('should open/close a listbox once expandIcon is clicked', () => {
mount(<TagPickerControlled />);
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', () => {
Expand Down Expand Up @@ -305,4 +314,12 @@ describe('TagPicker', () => {
});
});
});
it('should not render popover when "noPopover"', () => {
mount(<TagPickerControlled noPopover />);
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');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -33,13 +33,20 @@ export type TagPickerProps = ComponentProps<TagPickerSlots> &
'positioning' | 'disabled' | 'defaultOpen' | 'selectedOptions' | 'defaultSelectedOptions' | 'open'
> &
Pick<Partial<TagPickerContextValue>, '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<TagPickerOnOpenChangeData>;
onOptionSelect?: EventHandler<TagPickerOnOptionSelectData>;

/**
* 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
Expand Down Expand Up @@ -74,7 +81,14 @@ export type TagPickerState = ComponentState<TagPickerSlots> &
> &
Pick<
TagPickerContextValue,
'triggerRef' | 'secondaryActionRef' | 'popoverId' | 'popoverRef' | 'targetRef' | 'tagPickerGroupRef' | 'size'
| 'triggerRef'
| 'secondaryActionRef'
| 'popoverId'
| 'popoverRef'
| 'targetRef'
| 'tagPickerGroupRef'
| 'size'
| 'noPopover'
> & {
trigger: React.ReactNode;
popover?: React.ReactNode;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ export const useTagPicker_unstable = (props: TagPickerProps): TagPickerState =>
const triggerInnerRef = React.useRef<HTMLInputElement | HTMLButtonElement>(null);
const secondaryActionRef = React.useRef<HTMLSpanElement>(null);
const tagPickerGroupRef = React.useRef<HTMLDivElement>(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,
Expand Down Expand Up @@ -69,14 +69,15 @@ export const useTagPicker_unstable = (props: TagPickerProps): TagPickerState =>
size: 'medium',
});

const { trigger, popover } = childrenToTriggerAndPopover(props.children);
const { trigger, popover } = childrenToTriggerAndPopover(props.children, noPopover);

return {
activeDescendantController,
components: {},
trigger,
popover: comboboxState.open || comboboxState.hasFocus ? popover : undefined,
popoverId,
noPopover,
disabled: comboboxState.disabled,
triggerRef: useMergedRefs(triggerInnerRef, activeParentRef),
popoverRef: useMergedRefs(listboxRef, containerRef),
Expand Down Expand Up @@ -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 };
};
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ export function useTagPickerContextValues(state: TagPickerState): TagPickerConte
open,
popoverId,
disabled,
noPopover,
} = state;
return {
activeDescendant: React.useMemo(
Expand Down Expand Up @@ -59,6 +60,7 @@ export function useTagPickerContextValues(state: TagPickerState): TagPickerConte
open,
popoverId,
disabled,
noPopover,
},
};
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<HTMLDivElement>(null);
const expandIconRef = React.useRef<HTMLSpanElement>(null);
Expand All @@ -53,7 +54,7 @@ export const useTagPickerControl_unstable = (
}

const expandIcon = slot.optional(props.expandIcon, {
renderByDefault: true,
renderByDefault: !noPopover,
defaultProps: {
'aria-expanded': open,
children: <ChevronDownRegular />,
Expand Down Expand Up @@ -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,
}),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ export interface TagPickerContextValue
secondaryActionRef: React.RefObject<HTMLSpanElement>;
tagPickerGroupRef: React.RefObject<HTMLDivElement>;
size: TagPickerSize;
noPopover?: boolean;
}

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -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<string[]>([]);
const [inputValue, setInputValue] = React.useState('');

const onOptionSelect: TagPickerProps['onOptionSelect'] = (_, data) => {
setSelectedOptions(data.selectedOptions);
};
const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
setInputValue(event.currentTarget.value);
};
const handleKeyDown = (event: React.KeyboardEvent) => {
if (event.key === 'Enter' && inputValue) {
setInputValue('');
setSelectedOptions(curr => (curr.includes(inputValue) ? curr : [...curr, inputValue]));
}
};

return (
<Field label="Add Employees" style={{ maxWidth: 400 }}>
<TagPicker noPopover onOptionSelect={onOptionSelect} selectedOptions={selectedOptions}>
<TagPickerControl>
<TagPickerGroup>
{selectedOptions.map((option, index) => (
<Tag
key={index}
shape="rounded"
media={<Avatar aria-hidden name={option} color="colorful" />}
value={option}
>
{option}
</Tag>
))}
</TagPickerGroup>
<TagPickerInput
value={inputValue}
onChange={handleChange}
onKeyDown={handleKeyDown}
aria-label="Add Employees"
/>
</TagPickerControl>
</TagPicker>
</Field>
);
};

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.
`,
},
},
};
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down

0 comments on commit cb6df91

Please sign in to comment.