diff --git a/packages/react-components/react-tags/etc/react-tags.api.md b/packages/react-components/react-tags/etc/react-tags.api.md index 7b2fb641fecdd..d0a1cca902c48 100644 --- a/packages/react-components/react-tags/etc/react-tags.api.md +++ b/packages/react-components/react-tags/etc/react-tags.api.md @@ -22,6 +22,9 @@ export const renderTag_unstable: (state: TagState, contextValues: TagContextValu // @public export const renderTagButton_unstable: (state: TagButtonState, contextValues: TagButtonContextValues) => JSX.Element; +// @public +export const renderTagGroup_unstable: (state: TagGroupState, contextValue: TagGroupContextValues) => JSX.Element; + // @public export const Tag: ForwardRefComponent; @@ -47,13 +50,32 @@ export type TagButtonState = ComponentState & Omit; +// @public +export const TagGroup: ForwardRefComponent; + +// @public (undocumented) +export const tagGroupClassNames: SlotClassNames; + +// @public +export type TagGroupProps = ComponentProps & { + size?: TagSize; +}; + +// @public (undocumented) +export type TagGroupSlots = { + root: Slot<'div'>; +}; + +// @public +export type TagGroupState = ComponentState & Required>; + // @public export type TagProps = ComponentProps> & { appearance?: 'filled-darker' | 'filled-lighter' | 'tint' | 'outline'; disabled?: boolean; dismissible?: boolean; shape?: 'rounded' | 'circular'; - size?: 'extra-small' | 'small' | 'medium'; + size?: TagSize; }; // @public (undocumented) @@ -81,6 +103,12 @@ export const useTagButton_unstable: (props: TagButtonProps, ref: React_2.Ref TagButtonState; +// @public +export const useTagGroup_unstable: (props: TagGroupProps, ref: React_2.Ref) => TagGroupState; + +// @public +export const useTagGroupStyles_unstable: (state: TagGroupState) => TagGroupState; + // @public export const useTagStyles_unstable: (state: TagState) => TagState; diff --git a/packages/react-components/react-tags/src/TagGroup.ts b/packages/react-components/react-tags/src/TagGroup.ts new file mode 100644 index 0000000000000..bdda9a5083ef6 --- /dev/null +++ b/packages/react-components/react-tags/src/TagGroup.ts @@ -0,0 +1 @@ +export * from './components/TagGroup/index'; diff --git a/packages/react-components/react-tags/src/components/Tag/Tag.test.tsx b/packages/react-components/react-tags/src/components/Tag/Tag.test.tsx index df2e9e30a5fdf..174ddfaad88b7 100644 --- a/packages/react-components/react-tags/src/components/Tag/Tag.test.tsx +++ b/packages/react-components/react-tags/src/components/Tag/Tag.test.tsx @@ -1,6 +1,9 @@ +import * as React from 'react'; import { Tag } from './Tag'; import { isConformant } from '../../testing/isConformant'; import { TagProps } from './Tag.types'; +import { render } from '@testing-library/react'; +import { tagClassNames } from './useTagStyles.styles'; const requiredProps: TagProps = { dismissible: true, @@ -16,4 +19,9 @@ describe('Tag', () => { displayName: 'Tag', requiredProps, }); + + it('should render root as a button', () => { + const { getByRole } = render(Tag); + expect(getByRole('button').className.includes(tagClassNames.root)).toBe(true); + }); }); diff --git a/packages/react-components/react-tags/src/components/Tag/Tag.types.ts b/packages/react-components/react-tags/src/components/Tag/Tag.types.ts index 49c35e5fd3672..c22b1bfe4d09c 100644 --- a/packages/react-components/react-tags/src/components/Tag/Tag.types.ts +++ b/packages/react-components/react-tags/src/components/Tag/Tag.types.ts @@ -1,6 +1,8 @@ import { AvatarSize, AvatarShape } from '@fluentui/react-avatar'; import type { ComponentProps, ComponentState, Slot } from '@fluentui/react-utilities'; +export type TagSize = 'extra-small' | 'small' | 'medium'; + export type TagContextValues = { avatar: { size?: AvatarSize; @@ -36,12 +38,10 @@ export type TagSlots = { */ export type TagProps = ComponentProps> & { appearance?: 'filled-darker' | 'filled-lighter' | 'tint' | 'outline'; - // TODO implement tag checked state - // checked?: boolean; disabled?: boolean; dismissible?: boolean; shape?: 'rounded' | 'circular'; - size?: 'extra-small' | 'small' | 'medium'; + size?: TagSize; }; /** diff --git a/packages/react-components/react-tags/src/components/Tag/useTag.tsx b/packages/react-components/react-tags/src/components/Tag/useTag.tsx index 16397e53d9551..d899ebadb96e4 100644 --- a/packages/react-components/react-tags/src/components/Tag/useTag.tsx +++ b/packages/react-components/react-tags/src/components/Tag/useTag.tsx @@ -2,6 +2,7 @@ import * as React from 'react'; import { getNativeElementProps, resolveShorthand } from '@fluentui/react-utilities'; import { DismissRegular, bundleIcon, DismissFilled } from '@fluentui/react-icons'; import type { TagProps, TagState } from './Tag.types'; +import { useTagGroupContext_unstable } from '../../contexts/TagGroupContext'; const tagAvatarSizeMap = { medium: 28, @@ -26,12 +27,14 @@ const DismissIcon = bundleIcon(DismissFilled, DismissRegular); * @param ref - reference to root HTMLElement of Tag */ export const useTag_unstable = (props: TagProps, ref: React.Ref): TagState => { + const { size: contextSize } = useTagGroupContext_unstable(); + const { appearance = 'filled-lighter', disabled = false, dismissible = false, shape = 'rounded', - size = 'medium', + size = contextSize, } = props; return { diff --git a/packages/react-components/react-tags/src/components/Tag/useTagStyles.styles.ts b/packages/react-components/react-tags/src/components/Tag/useTagStyles.styles.ts index 731ee441aee7a..16f9f35174451 100644 --- a/packages/react-components/react-tags/src/components/Tag/useTagStyles.styles.ts +++ b/packages/react-components/react-tags/src/components/Tag/useTagStyles.styles.ts @@ -93,6 +93,10 @@ const useTagStyles = makeStyles({ }, { enableOutline: true }, ), + + ':hover': { + cursor: 'pointer', + }, }, rootCircular: { ...shorthands.borderRadius(tokens.borderRadiusCircular), @@ -118,6 +122,14 @@ const useTagStyles = makeStyles({ // TODO add additional classes for fill/outline appearance, different sizes, and state }); + +const useSmallTagStyles = makeStyles({ + root: { + height: '24px', + }, + // TODO add additional styles for sizes +}); + /** * Apply styling to the Tag slots based on the state */ @@ -125,6 +137,7 @@ export const useTagStyles_unstable = (state: TagState): TagState => { const baseStyles = useTagBaseStyles(); const resetButtonStyles = useResetButtonStyles(); const styles = useTagStyles(); + const smallStyles = useSmallTagStyles(); state.root.className = mergeClasses( tagClassNames.root, @@ -135,6 +148,8 @@ export const useTagStyles_unstable = (state: TagState): TagState => { !state.media && !state.icon && styles.rootWithoutMedia, !state.dismissIcon && styles.rootWithoutDismiss, + state.size === 'small' && smallStyles.root, + state.root.className, ); diff --git a/packages/react-components/react-tags/src/components/TagButton/useTagButton.tsx b/packages/react-components/react-tags/src/components/TagButton/useTagButton.tsx index dd4cf7b00bc7c..352b01ff6cec3 100644 --- a/packages/react-components/react-tags/src/components/TagButton/useTagButton.tsx +++ b/packages/react-components/react-tags/src/components/TagButton/useTagButton.tsx @@ -3,6 +3,7 @@ import { getNativeElementProps, resolveShorthand } from '@fluentui/react-utiliti import { DismissRegular, bundleIcon, DismissFilled } from '@fluentui/react-icons'; import type { TagButtonProps, TagButtonState } from './TagButton.types'; import { useARIAButtonShorthand } from '@fluentui/react-aria'; +import { useTagGroupContext_unstable } from '../../contexts/TagGroupContext'; const tagButtonAvatarSizeMap = { medium: 28, @@ -27,12 +28,14 @@ const DismissIcon = bundleIcon(DismissFilled, DismissRegular); * @param ref - reference to root HTMLElement of TagButton */ export const useTagButton_unstable = (props: TagButtonProps, ref: React.Ref): TagButtonState => { + const { size: contextSize } = useTagGroupContext_unstable(); + const { appearance = 'filled-lighter', disabled = false, dismissible = false, shape = 'rounded', - size = 'medium', + size = contextSize, } = props; return { diff --git a/packages/react-components/react-tags/src/components/TagButton/useTagButtonStyles.styles.ts b/packages/react-components/react-tags/src/components/TagButton/useTagButtonStyles.styles.ts index 19375c6b3c8a3..486c9bd294844 100644 --- a/packages/react-components/react-tags/src/components/TagButton/useTagButtonStyles.styles.ts +++ b/packages/react-components/react-tags/src/components/TagButton/useTagButtonStyles.styles.ts @@ -50,6 +50,10 @@ const useStyles = makeStyles({ }, { enableOutline: true }, ), + + ':hover': { + cursor: 'pointer', + }, }, circularContent: createCustomFocusIndicatorStyle(shorthands.borderRadius(tokens.borderRadiusCircular)), contentWithoutMedia: { @@ -78,6 +82,10 @@ const useStyles = makeStyles({ borderTopRightRadius: tokens.borderRadiusMedium, borderBottomRightRadius: tokens.borderRadiusMedium, }), + + ':hover': { + cursor: 'pointer', + }, }, dismissButtonCircular: createCustomFocusIndicatorStyle({ borderTopRightRadius: tokens.borderRadiusCircular, @@ -87,6 +95,13 @@ const useStyles = makeStyles({ // TODO add additional classes for fill/outline appearance, different sizes, and state }); +const useSmallTagButtonStyles = makeStyles({ + root: { + height: '24px', + }, + // TODO add additional styles for sizes +}); + /** * Apply styling to the TagButton slots based on the state */ @@ -94,11 +109,15 @@ export const useTagButtonStyles_unstable = (state: TagButtonState): TagButtonSta const baseStyles = useTagBaseStyles(); const resetButtonStyles = useResetButtonStyles(); const styles = useStyles(); + const smallStyles = useSmallTagButtonStyles(); state.root.className = mergeClasses( tagButtonClassNames.root, styles.root, state.shape === 'circular' && styles.rootCircular, + + state.size === 'small' && smallStyles.root, + state.root.className, ); if (state.content) { diff --git a/packages/react-components/react-tags/src/components/TagGroup/TagGroup.test.tsx b/packages/react-components/react-tags/src/components/TagGroup/TagGroup.test.tsx new file mode 100644 index 0000000000000..21d7525962a10 --- /dev/null +++ b/packages/react-components/react-tags/src/components/TagGroup/TagGroup.test.tsx @@ -0,0 +1,11 @@ +import { TagGroup } from './TagGroup'; +import { isConformant } from '../../testing/isConformant'; + +describe('TagGroup', () => { + isConformant({ + Component: TagGroup, + displayName: 'TagGroup', + }); + + // TODO add more tests here, and create visual regression tests in /apps/vr-tests +}); diff --git a/packages/react-components/react-tags/src/components/TagGroup/TagGroup.tsx b/packages/react-components/react-tags/src/components/TagGroup/TagGroup.tsx new file mode 100644 index 0000000000000..763c28eafad0c --- /dev/null +++ b/packages/react-components/react-tags/src/components/TagGroup/TagGroup.tsx @@ -0,0 +1,19 @@ +import * as React from 'react'; +import { useTagGroup_unstable } from './useTagGroup'; +import { renderTagGroup_unstable } from './renderTagGroup'; +import { useTagGroupStyles_unstable } from './useTagGroupStyles.styles'; +import type { TagGroupProps } from './TagGroup.types'; +import type { ForwardRefComponent } from '@fluentui/react-utilities'; +import { useTagGroupContextValues_unstable } from './useTagGroupContextValues'; + +/** + * TagGroup component - TODO: add more docs + */ +export const TagGroup: ForwardRefComponent = React.forwardRef((props, ref) => { + const state = useTagGroup_unstable(props, ref); + + useTagGroupStyles_unstable(state); + return renderTagGroup_unstable(state, useTagGroupContextValues_unstable(state)); +}); + +TagGroup.displayName = 'TagGroup'; diff --git a/packages/react-components/react-tags/src/components/TagGroup/TagGroup.types.ts b/packages/react-components/react-tags/src/components/TagGroup/TagGroup.types.ts new file mode 100644 index 0000000000000..5bcf24c00691a --- /dev/null +++ b/packages/react-components/react-tags/src/components/TagGroup/TagGroup.types.ts @@ -0,0 +1,23 @@ +import type { ComponentProps, ComponentState, Slot } from '@fluentui/react-utilities'; +import { TagSize } from '../Tag/Tag.types'; +import { TagGroupContextValue } from '../../contexts/TagGroupContext'; + +export type TagGroupContextValues = { + tagGroup: TagGroupContextValue; +}; + +export type TagGroupSlots = { + root: Slot<'div'>; +}; + +/** + * TagGroup Props + */ +export type TagGroupProps = ComponentProps & { + size?: TagSize; +}; + +/** + * State used in rendering TagGroup + */ +export type TagGroupState = ComponentState & Required>; diff --git a/packages/react-components/react-tags/src/components/TagGroup/index.ts b/packages/react-components/react-tags/src/components/TagGroup/index.ts new file mode 100644 index 0000000000000..8ab5d0bbb3ceb --- /dev/null +++ b/packages/react-components/react-tags/src/components/TagGroup/index.ts @@ -0,0 +1,5 @@ +export * from './TagGroup'; +export * from './TagGroup.types'; +export * from './renderTagGroup'; +export * from './useTagGroup'; +export * from './useTagGroupStyles.styles'; diff --git a/packages/react-components/react-tags/src/components/TagGroup/renderTagGroup.tsx b/packages/react-components/react-tags/src/components/TagGroup/renderTagGroup.tsx new file mode 100644 index 0000000000000..c49c2e386c0d0 --- /dev/null +++ b/packages/react-components/react-tags/src/components/TagGroup/renderTagGroup.tsx @@ -0,0 +1,19 @@ +/** @jsxRuntime classic */ +/** @jsx createElement */ +import { createElement } from '@fluentui/react-jsx-runtime'; +import { getSlotsNext } from '@fluentui/react-utilities'; +import type { TagGroupState, TagGroupSlots, TagGroupContextValues } from './TagGroup.types'; +import { TagGroupContextProvider } from '../../contexts/TagGroupContext'; + +/** + * Render the final JSX of TagGroup + */ +export const renderTagGroup_unstable = (state: TagGroupState, contextValue: TagGroupContextValues) => { + const { slots, slotProps } = getSlotsNext(state); + + return ( + + + + ); +}; diff --git a/packages/react-components/react-tags/src/components/TagGroup/useTagGroup.ts b/packages/react-components/react-tags/src/components/TagGroup/useTagGroup.ts new file mode 100644 index 0000000000000..3666ddbe33f8d --- /dev/null +++ b/packages/react-components/react-tags/src/components/TagGroup/useTagGroup.ts @@ -0,0 +1,28 @@ +import * as React from 'react'; +import { getNativeElementProps } from '@fluentui/react-utilities'; +import type { TagGroupProps, TagGroupState } from './TagGroup.types'; + +/** + * Create the state required to render TagGroup. + * + * The returned state can be modified with hooks such as useTagGroupStyles_unstable, + * before being passed to renderTagGroup_unstable. + * + * @param props - props from this instance of TagGroup + * @param ref - reference to root HTMLElement of TagGroup + */ +export const useTagGroup_unstable = (props: TagGroupProps, ref: React.Ref): TagGroupState => { + const { size = 'medium' } = props; + + return { + size, + components: { + root: 'div', + }, + root: getNativeElementProps('div', { + ref, + ...props, + // TODO aria attributes + }), + }; +}; diff --git a/packages/react-components/react-tags/src/components/TagGroup/useTagGroupContextValues.ts b/packages/react-components/react-tags/src/components/TagGroup/useTagGroupContextValues.ts new file mode 100644 index 0000000000000..76e89d4bfc3f4 --- /dev/null +++ b/packages/react-components/react-tags/src/components/TagGroup/useTagGroupContextValues.ts @@ -0,0 +1,7 @@ +import * as React from 'react'; +import type { TagGroupContextValues, TagGroupState } from './TagGroup.types'; + +export function useTagGroupContextValues_unstable(state: TagGroupState): TagGroupContextValues { + const { size } = state; + return { tagGroup: React.useMemo(() => ({ size }), [size]) }; +} diff --git a/packages/react-components/react-tags/src/components/TagGroup/useTagGroupStyles.styles.ts b/packages/react-components/react-tags/src/components/TagGroup/useTagGroupStyles.styles.ts new file mode 100644 index 0000000000000..384730cbf6e5c --- /dev/null +++ b/packages/react-components/react-tags/src/components/TagGroup/useTagGroupStyles.styles.ts @@ -0,0 +1,36 @@ +import { makeStyles, mergeClasses } from '@griffel/react'; +import type { TagGroupSlots, TagGroupState } from './TagGroup.types'; +import type { SlotClassNames } from '@fluentui/react-utilities'; +import { tokens } from '@fluentui/react-theme'; + +export const tagGroupClassNames: SlotClassNames = { + root: 'fui-TagGroup', +}; + +/** + * Styles for the root slot + */ +const useStyles = makeStyles({ + root: { + display: 'inline-flex', + columnGap: tokens.spacingHorizontalS, + }, + rootSmall: { + columnGap: tokens.spacingHorizontalSNudge, + }, +}); + +/** + * Apply styling to the TagGroup slots based on the state + */ +export const useTagGroupStyles_unstable = (state: TagGroupState): TagGroupState => { + const styles = useStyles(); + state.root.className = mergeClasses( + tagGroupClassNames.root, + styles.root, + state.size === 'small' && styles.rootSmall, + state.root.className, + ); + + return state; +}; diff --git a/packages/react-components/react-tags/src/contexts/TagGroupContext.tsx b/packages/react-components/react-tags/src/contexts/TagGroupContext.tsx new file mode 100644 index 0000000000000..85a893fb4a9e3 --- /dev/null +++ b/packages/react-components/react-tags/src/contexts/TagGroupContext.tsx @@ -0,0 +1,17 @@ +import * as React from 'react'; +import { TagGroupState } from '../components/TagGroup/index'; + +export const TagGroupContext = React.createContext(undefined); + +const tagGroupContextDefaultValue: TagGroupContextValue = { + size: 'medium', +}; + +/** + * Context shared between TagGroup and its children components + */ +export type TagGroupContextValue = Required>; + +export const TagGroupContextProvider = TagGroupContext.Provider; + +export const useTagGroupContext_unstable = () => React.useContext(TagGroupContext) ?? tagGroupContextDefaultValue; diff --git a/packages/react-components/react-tags/src/index.ts b/packages/react-components/react-tags/src/index.ts index 4d1d802959899..d689581545f33 100644 --- a/packages/react-components/react-tags/src/index.ts +++ b/packages/react-components/react-tags/src/index.ts @@ -8,3 +8,11 @@ export { useTagButton_unstable, } from './TagButton'; export type { TagButtonProps, TagButtonSlots, TagButtonState } from './TagButton'; +export { + TagGroup, + renderTagGroup_unstable, + tagGroupClassNames, + useTagGroupStyles_unstable, + useTagGroup_unstable, +} from './TagGroup'; +export type { TagGroupProps, TagGroupSlots, TagGroupState } from './TagGroup'; diff --git a/packages/react-components/react-tags/stories/TagGroup/TagGroupBestPractices.md b/packages/react-components/react-tags/stories/TagGroup/TagGroupBestPractices.md new file mode 100644 index 0000000000000..08ff8ddeeb5f8 --- /dev/null +++ b/packages/react-components/react-tags/stories/TagGroup/TagGroupBestPractices.md @@ -0,0 +1,5 @@ +## Best practices + +### Do + +### Don't diff --git a/packages/react-components/react-tags/stories/TagGroup/TagGroupDefault.stories.tsx b/packages/react-components/react-tags/stories/TagGroup/TagGroupDefault.stories.tsx new file mode 100644 index 0000000000000..a3c0e41712622 --- /dev/null +++ b/packages/react-components/react-tags/stories/TagGroup/TagGroupDefault.stories.tsx @@ -0,0 +1,20 @@ +import * as React from 'react'; +import { TagGroup, Tag, TagButton, TagGroupProps } from '@fluentui/react-tags'; +import { Calendar3Day20Regular, Calendar3Day20Filled, bundleIcon } from '@fluentui/react-icons'; + +const Calendar3Day20Icon = bundleIcon(Calendar3Day20Filled, Calendar3Day20Regular); + +export const Default = (props: Partial) => ( + + }> + Tag 1 + + }> + Tag 2 + + }> + Tag 3 + + Tag 4 + +); diff --git a/packages/react-components/react-tags/stories/TagGroup/TagGroupDescription.md b/packages/react-components/react-tags/stories/TagGroup/TagGroupDescription.md new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/packages/react-components/react-tags/stories/TagGroup/TagGroupSizes.stories.tsx b/packages/react-components/react-tags/stories/TagGroup/TagGroupSizes.stories.tsx new file mode 100644 index 0000000000000..a1c66a444c89c --- /dev/null +++ b/packages/react-components/react-tags/stories/TagGroup/TagGroupSizes.stories.tsx @@ -0,0 +1,44 @@ +import * as React from 'react'; +import { TagGroup, Tag, TagButton } from '@fluentui/react-tags'; +import { Avatar, makeStyles } from '@fluentui/react-components'; +import { TagSize } from '../../src/Tag'; + +const useContainerStyles = makeStyles({ + root: { + display: 'flex', + flexDirection: 'column', + rowGap: '10px', + }, +}); + +export const Sizes = () => { + const containerStyles = useContainerStyles(); + // TODO add one more size + const sizes: TagSize[] = ['small', 'medium']; + return ( +
+ {sizes.map(size => ( +
+ {`${size}: `} + + } shape="circular"> + Tag 1 + + } shape="circular"> + Tag 2 + + +
+ ))} +
+ ); +}; + +Sizes.storyName = 'Sizes'; +Sizes.parameters = { + docs: { + description: { + story: 'A TagGroup supports different sizes', + }, + }, +}; diff --git a/packages/react-components/react-tags/stories/TagGroup/index.stories.tsx b/packages/react-components/react-tags/stories/TagGroup/index.stories.tsx new file mode 100644 index 0000000000000..58aa049b067af --- /dev/null +++ b/packages/react-components/react-tags/stories/TagGroup/index.stories.tsx @@ -0,0 +1,19 @@ +import { TagGroup } from '@fluentui/react-tags'; + +import descriptionMd from './TagGroupDescription.md'; +import bestPracticesMd from './TagGroupBestPractices.md'; + +export { Default } from './TagGroupDefault.stories'; +export { Sizes } from './TagGroupSizes.stories'; + +export default { + title: 'Preview Components/Tag/TagGroup', + component: TagGroup, + parameters: { + docs: { + description: { + component: [descriptionMd, bestPracticesMd].join('\n'), + }, + }, + }, +};