diff --git a/packages/react-components/react-infobutton/etc/react-infobutton.api.md b/packages/react-components/react-infobutton/etc/react-infobutton.api.md index 3d74b54b50698b..080037287f1be2 100644 --- a/packages/react-components/react-infobutton/etc/react-infobutton.api.md +++ b/packages/react-components/react-infobutton/etc/react-infobutton.api.md @@ -4,9 +4,13 @@ ```ts +/// + import type { ComponentProps } from '@fluentui/react-utilities'; import type { ComponentState } from '@fluentui/react-utilities'; -import type { ForwardRefComponent } from '@fluentui/react-utilities'; +import { ForwardRefComponent } from '@fluentui/react-utilities'; +import type { PopoverProps } from '@fluentui/react-popover'; +import type { PopoverSurface } from '@fluentui/react-popover'; import * as React_2 from 'react'; import type { Slot } from '@fluentui/react-utilities'; import type { SlotClassNames } from '@fluentui/react-utilities'; @@ -18,11 +22,13 @@ export const InfoButton: ForwardRefComponent; export const infoButtonClassNames: SlotClassNames; // @public -export type InfoButtonProps = ComponentProps & {}; +export type InfoButtonProps = ComponentProps>; // @public (undocumented) export type InfoButtonSlots = { - root: Slot<'div'>; + root: NonNullable>; + popover: NonNullable>; + content: NonNullable>; }; // @public diff --git a/packages/react-components/react-infobutton/package.json b/packages/react-components/react-infobutton/package.json index ef46a6847f730f..94bab915c948af 100644 --- a/packages/react-components/react-infobutton/package.json +++ b/packages/react-components/react-infobutton/package.json @@ -32,6 +32,9 @@ "@fluentui/scripts": "^1.0.0" }, "dependencies": { + "@fluentui/react-icons": "^2.0.175", + "@fluentui/react-popover": "^9.3.0", + "@fluentui/react-tabster": "^9.2.1", "@fluentui/react-theme": "^9.1.1", "@fluentui/react-utilities": "^9.1.2", "@griffel/react": "^1.4.1", diff --git a/packages/react-components/react-infobutton/src/components/InfoButton/DefaultInfoButtonIcon.tsx b/packages/react-components/react-infobutton/src/components/InfoButton/DefaultInfoButtonIcon.tsx new file mode 100644 index 00000000000000..336ae52755c871 --- /dev/null +++ b/packages/react-components/react-infobutton/src/components/InfoButton/DefaultInfoButtonIcon.tsx @@ -0,0 +1,3 @@ +import { Info12Regular, Info12Filled, bundleIcon } from '@fluentui/react-icons'; + +export const DefaultInfoButtonIcon = bundleIcon(Info12Filled, Info12Regular); diff --git a/packages/react-components/react-infobutton/src/components/InfoButton/InfoButton.test.tsx b/packages/react-components/react-infobutton/src/components/InfoButton/InfoButton.test.tsx index 3ef2254124d51d..a22e9cc47c03b6 100644 --- a/packages/react-components/react-infobutton/src/components/InfoButton/InfoButton.test.tsx +++ b/packages/react-components/react-infobutton/src/components/InfoButton/InfoButton.test.tsx @@ -1,18 +1,47 @@ -import * as React from 'react'; -import { render } from '@testing-library/react'; import { InfoButton } from './InfoButton'; import { isConformant } from '../../common/isConformant'; +import { infoButtonClassNames } from './useInfoButtonStyles'; +import type { RenderResult } from '@testing-library/react'; + +// testing-library's queryByRole function doesn't look inside portals +function queryByRoleDialog(result: RenderResult) { + const dialogs = result.baseElement.querySelectorAll('[role="dialog"]'); + if (!dialogs?.length) { + return null; + } else { + expect(dialogs.length).toBe(1); + return dialogs.item(0) as HTMLElement; + } +} + +const getPopoverSurfaceElement = (result: RenderResult) => { + // button needs to be clicked otherwise content won't be rendered. + result.getByRole('button').click(); + const dialog = queryByRoleDialog(result); + expect(dialog).not.toBeNull(); + return dialog!; +}; describe('InfoButton', () => { isConformant({ Component: InfoButton, displayName: 'InfoButton', - }); - - // TODO add more tests here, and create visual regression tests in /apps/vr-tests - - it('renders a default state', () => { - const result = render(Default InfoButton); - expect(result.container).toMatchSnapshot(); + requiredProps: { + content: 'Popover content', + }, + testOptions: { + 'has-static-classnames': [ + { + props: { + content: 'Popover content', + }, + expectedClassNames: { + root: infoButtonClassNames.root, + content: infoButtonClassNames.content, + }, + getPortalElement: getPopoverSurfaceElement, + }, + ], + }, }); }); diff --git a/packages/react-components/react-infobutton/src/components/InfoButton/InfoButton.tsx b/packages/react-components/react-infobutton/src/components/InfoButton/InfoButton.tsx index 2284305794c455..867bc036864b26 100644 --- a/packages/react-components/react-infobutton/src/components/InfoButton/InfoButton.tsx +++ b/packages/react-components/react-infobutton/src/components/InfoButton/InfoButton.tsx @@ -1,12 +1,12 @@ import * as React from 'react'; -import { useInfoButton_unstable } from './useInfoButton'; +import { ForwardRefComponent } from '@fluentui/react-utilities'; import { renderInfoButton_unstable } from './renderInfoButton'; +import { useInfoButton_unstable } from './useInfoButton'; import { useInfoButtonStyles_unstable } from './useInfoButtonStyles'; import type { InfoButtonProps } from './InfoButton.types'; -import type { ForwardRefComponent } from '@fluentui/react-utilities'; /** - * InfoButton component - TODO: add more docs + * InfoButtons provide a way to display additional information about a form field or an area in the UI. */ export const InfoButton: ForwardRefComponent = React.forwardRef((props, ref) => { const state = useInfoButton_unstable(props, ref); diff --git a/packages/react-components/react-infobutton/src/components/InfoButton/InfoButton.types.ts b/packages/react-components/react-infobutton/src/components/InfoButton/InfoButton.types.ts index ff95c26471146e..97083e38847291 100644 --- a/packages/react-components/react-infobutton/src/components/InfoButton/InfoButton.types.ts +++ b/packages/react-components/react-infobutton/src/components/InfoButton/InfoButton.types.ts @@ -1,13 +1,24 @@ import type { ComponentProps, ComponentState, Slot } from '@fluentui/react-utilities'; +import type { PopoverProps, PopoverSurface } from '@fluentui/react-popover'; export type InfoButtonSlots = { - root: Slot<'div'>; + root: NonNullable>; + + /** + * The Popover element that wraps the content and root. Use this slot to pass props to the Popover. + */ + popover: NonNullable>; + + /** + * The content to be displayed in the PopoverSurface when the button is pressed. + */ + content: NonNullable>; }; /** * InfoButton Props */ -export type InfoButtonProps = ComponentProps & {}; +export type InfoButtonProps = ComponentProps>; /** * State used in rendering InfoButton diff --git a/packages/react-components/react-infobutton/src/components/InfoButton/__snapshots__/InfoButton.test.tsx.snap b/packages/react-components/react-infobutton/src/components/InfoButton/__snapshots__/InfoButton.test.tsx.snap deleted file mode 100644 index 0b1dbff1b7af5f..00000000000000 --- a/packages/react-components/react-infobutton/src/components/InfoButton/__snapshots__/InfoButton.test.tsx.snap +++ /dev/null @@ -1,11 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`InfoButton renders a default state 1`] = ` -
-
- Default InfoButton -
-
-`; diff --git a/packages/react-components/react-infobutton/src/components/InfoButton/renderInfoButton.tsx b/packages/react-components/react-infobutton/src/components/InfoButton/renderInfoButton.tsx index 0082ea998e1f43..2b2884a7965eb3 100644 --- a/packages/react-components/react-infobutton/src/components/InfoButton/renderInfoButton.tsx +++ b/packages/react-components/react-infobutton/src/components/InfoButton/renderInfoButton.tsx @@ -1,5 +1,7 @@ import * as React from 'react'; import { getSlots } from '@fluentui/react-utilities'; +import { PopoverTrigger } from '@fluentui/react-popover'; +import type { PopoverProps } from '@fluentui/react-popover'; import type { InfoButtonState, InfoButtonSlots } from './InfoButton.types'; /** @@ -8,6 +10,12 @@ import type { InfoButtonState, InfoButtonSlots } from './InfoButton.types'; export const renderInfoButton_unstable = (state: InfoButtonState) => { const { slots, slotProps } = getSlots(state); - // TODO Add additional slots in the appropriate place - return ; + return ( + + + + + + + ); }; diff --git a/packages/react-components/react-infobutton/src/components/InfoButton/useInfoButton.ts b/packages/react-components/react-infobutton/src/components/InfoButton/useInfoButton.ts deleted file mode 100644 index 56c30895b815ae..00000000000000 --- a/packages/react-components/react-infobutton/src/components/InfoButton/useInfoButton.ts +++ /dev/null @@ -1,28 +0,0 @@ -import * as React from 'react'; -import { getNativeElementProps } from '@fluentui/react-utilities'; -import type { InfoButtonProps, InfoButtonState } from './InfoButton.types'; - -/** - * Create the state required to render InfoButton. - * - * The returned state can be modified with hooks such as useInfoButtonStyles_unstable, - * before being passed to renderInfoButton_unstable. - * - * @param props - props from this instance of InfoButton - * @param ref - reference to root HTMLElement of InfoButton - */ -export const useInfoButton_unstable = (props: InfoButtonProps, ref: React.Ref): InfoButtonState => { - return { - // TODO add appropriate props/defaults - components: { - // TODO add each slot's element type or component - root: 'div', - }, - // TODO add appropriate slots, for example: - // mySlot: resolveShorthand(props.mySlot), - root: getNativeElementProps('div', { - ref, - ...props, - }), - }; -}; diff --git a/packages/react-components/react-infobutton/src/components/InfoButton/useInfoButton.tsx b/packages/react-components/react-infobutton/src/components/InfoButton/useInfoButton.tsx new file mode 100644 index 00000000000000..a998066b66d83a --- /dev/null +++ b/packages/react-components/react-infobutton/src/components/InfoButton/useInfoButton.tsx @@ -0,0 +1,58 @@ +import * as React from 'react'; +import { DefaultInfoButtonIcon } from './DefaultInfoButtonIcon'; +import { getNativeElementProps, mergeCallbacks, resolveShorthand } from '@fluentui/react-utilities'; +import { Popover, PopoverSurface } from '@fluentui/react-popover'; +import { useControllableState } from '@fluentui/react-utilities'; +import type { InfoButtonProps, InfoButtonState } from './InfoButton.types'; + +/** + * Create the state required to render InfoButton. + * + * The returned state can be modified with hooks such as useInfoButtonStyles_unstable, + * before being passed to renderInfoButton_unstable. + * + * @param props - props from this instance of InfoButton + * @param ref - reference to root HTMLElement of InfoButton + */ +export const useInfoButton_unstable = (props: InfoButtonProps, ref: React.Ref): InfoButtonState => { + const state: InfoButtonState = { + components: { + root: 'button', + popover: Popover, + content: PopoverSurface, + }, + + root: getNativeElementProps('button', { + children: , + type: 'button', + ...props, + ref, + }), + popover: resolveShorthand(props.popover, { + required: true, + defaultProps: { + children: <>, + positioning: 'above-start', + size: 'small', + withArrow: true, + }, + }), + content: resolveShorthand(props.content, { + required: true, + defaultProps: { + role: 'dialog', + }, + }), + }; + + const [popoverOpen, setPopoverOpen] = useControllableState({ + state: state.popover.open, + defaultState: state.popover.defaultOpen, + initialState: false, + }); + + state.popover.open = popoverOpen; + state.popover.onOpenChange = mergeCallbacks(state.popover.onOpenChange, (e, data) => setPopoverOpen(data.open)); + + return state; +}; diff --git a/packages/react-components/react-infobutton/src/components/InfoButton/useInfoButtonStyles.ts b/packages/react-components/react-infobutton/src/components/InfoButton/useInfoButtonStyles.ts index c1f5c927ea9a3e..4d3ed6908f9cb1 100644 --- a/packages/react-components/react-infobutton/src/components/InfoButton/useInfoButtonStyles.ts +++ b/packages/react-components/react-infobutton/src/components/InfoButton/useInfoButtonStyles.ts @@ -1,33 +1,107 @@ -import { makeStyles, mergeClasses } from '@griffel/react'; +import { createCustomFocusIndicatorStyle } from '@fluentui/react-tabster'; +import { iconFilledClassName, iconRegularClassName } from '@fluentui/react-icons'; +import { makeStyles, mergeClasses, shorthands } from '@griffel/react'; +import { tokens } from '@fluentui/react-theme'; import type { InfoButtonSlots, InfoButtonState } from './InfoButton.types'; import type { SlotClassNames } from '@fluentui/react-utilities'; export const infoButtonClassNames: SlotClassNames = { root: 'fui-InfoButton', - // TODO: add class names for all slots on InfoButtonSlots. - // Should be of the form `: 'fui-InfoButton__` + // this className won't be used, but it's needed to satisfy the type checker + popover: 'fui-InfoButton__popover', + content: 'fui-InfoButton__content', }; /** * Styles for the root slot */ -const useStyles = makeStyles({ - root: { - // TODO Add default styles for the root element +const useButtonStyles = makeStyles({ + base: { + alignItems: 'center', + boxSizing: 'border-box', + display: 'inline-flex', + justifyContent: 'center', + textDecorationLine: 'none', + verticalAlign: 'middle', + + backgroundColor: tokens.colorTransparentBackground, + color: tokens.colorNeutralForeground2, + fontFamily: tokens.fontFamilyBase, + + ...shorthands.overflow('hidden'), + ...shorthands.border(tokens.strokeWidthThin, 'solid', tokens.colorTransparentStroke), + ...shorthands.padding(tokens.spacingVerticalXS, tokens.spacingHorizontalXS), + ...shorthands.margin(0), + + [`& .${iconFilledClassName}`]: { + display: 'none', + }, + [`& .${iconRegularClassName}`]: { + display: 'inline-flex', + }, + + ':enabled:hover': { + backgroundColor: tokens.colorTransparentBackgroundHover, + color: tokens.colorNeutralForeground2BrandHover, + + [`& .${iconFilledClassName}`]: { + display: 'inline-flex', + }, + [`& .${iconRegularClassName}`]: { + display: 'none', + }, + }, + ':enabled:hover:active': { + backgroundColor: tokens.colorTransparentBackgroundPressed, + color: tokens.colorNeutralForeground2BrandPressed, + }, + ':disabled': { + cursor: 'not-allowed', + color: tokens.colorNeutralForegroundDisabled, + }, }, - // TODO add additional classes for different states and/or slots + focusIndicator: createCustomFocusIndicatorStyle({ + ...shorthands.borderRadius(tokens.borderRadiusSmall), + ...shorthands.borderColor(tokens.colorTransparentStroke), + outlineColor: tokens.colorTransparentStroke, + outlineWidth: tokens.strokeWidthThick, + outlineStyle: 'solid', + boxShadow: ` + ${tokens.shadow4}, + 0 0 0 ${tokens.borderRadiusSmall} ${tokens.colorStrokeFocus2} + `, + zIndex: 1, + }), + + selected: { + backgroundColor: tokens.colorTransparentBackgroundSelected, + color: tokens.colorNeutralForeground2BrandSelected, + + [`& .${iconFilledClassName}`]: { + display: 'inline-flex', + }, + [`& .${iconRegularClassName}`]: { + display: 'none', + }, + }, }); /** * Apply styling to the InfoButton slots based on the state */ export const useInfoButtonStyles_unstable = (state: InfoButtonState): InfoButtonState => { - const styles = useStyles(); - state.root.className = mergeClasses(infoButtonClassNames.root, styles.root, state.root.className); + const { open } = state.popover; + const buttonStyles = useButtonStyles(); - // TODO Add class names to slots, for example: - // state.mySlot.className = mergeClasses(styles.mySlot, state.mySlot.className); + state.content.className = mergeClasses(infoButtonClassNames.content, state.content.className); + state.root.className = mergeClasses( + infoButtonClassNames.root, + buttonStyles.base, + buttonStyles.focusIndicator, + open && buttonStyles.selected, + state.root.className, + ); return state; }; diff --git a/packages/react-components/react-infobutton/src/stories/Infobutton/InfoButtonBestPractices.md b/packages/react-components/react-infobutton/src/stories/Infobutton/InfoButtonBestPractices.md index 08ff8ddeeb5f86..865548d4e1bcad 100644 --- a/packages/react-components/react-infobutton/src/stories/Infobutton/InfoButtonBestPractices.md +++ b/packages/react-components/react-infobutton/src/stories/Infobutton/InfoButtonBestPractices.md @@ -1,5 +1,10 @@ -## Best practices - -### Do +
+ +Best Practices + ### Don't + +- Because the Popover isn't always visible, don't include information that people must know in order to complete the field. + +
diff --git a/packages/react-components/react-infobutton/src/stories/Infobutton/InfoButtonDefault.stories.tsx b/packages/react-components/react-infobutton/src/stories/Infobutton/InfoButtonDefault.stories.tsx index 95c4860b164699..63dd8419d45426 100644 --- a/packages/react-components/react-infobutton/src/stories/Infobutton/InfoButtonDefault.stories.tsx +++ b/packages/react-components/react-infobutton/src/stories/Infobutton/InfoButtonDefault.stories.tsx @@ -1,4 +1,14 @@ import * as React from 'react'; import { InfoButton, InfoButtonProps } from '@fluentui/react-infobutton'; +import { Link } from '@fluentui/react-components'; -export const Default = (props: Partial) => ; +export const Default = (props: Partial) => ( + + This is example content for an InfoButton. Learn more + + } + /> +); diff --git a/packages/react-components/react-infobutton/src/stories/Infobutton/InfoButtonDescription.md b/packages/react-components/react-infobutton/src/stories/Infobutton/InfoButtonDescription.md index e69de29bb2d1d6..71e51a19a3d6ad 100644 --- a/packages/react-components/react-infobutton/src/stories/Infobutton/InfoButtonDescription.md +++ b/packages/react-components/react-infobutton/src/stories/Infobutton/InfoButtonDescription.md @@ -0,0 +1 @@ +InfoButtons provide a way to display additional information about a form field or an area in the UI.