From 89f6b7c0835aca7ce29aff3c9a22ace4eb1b01d5 Mon Sep 17 00:00:00 2001 From: Emma Jiang <31319479+emmayjiang@users.noreply.github.com> Date: Thu, 15 Jun 2023 14:53:09 -0700 Subject: [PATCH] Structure and slots for SearchBox, using Input as a slot (#28090) --- .../react-search/etc/react-search.api.md | 14 ++- .../react-search/package.json | 2 + .../components/SearchBox/SearchBox.test.tsx | 3 +- .../components/SearchBox/SearchBox.types.ts | 18 ++- .../__snapshots__/SearchBox.test.tsx.snap | 54 +++++++- .../components/SearchBox/renderSearchBox.tsx | 16 ++- .../src/components/SearchBox/useSearchBox.ts | 28 ----- .../src/components/SearchBox/useSearchBox.tsx | 73 +++++++++++ .../SearchBox/useSearchBoxStyles.styles.ts | 116 ++++++++++++++++-- .../SearchBox/SearchBoxDefault.stories.tsx | 4 +- 10 files changed, 273 insertions(+), 55 deletions(-) delete mode 100644 packages/react-components/react-search/src/components/SearchBox/useSearchBox.ts create mode 100644 packages/react-components/react-search/src/components/SearchBox/useSearchBox.tsx diff --git a/packages/react-components/react-search/etc/react-search.api.md b/packages/react-components/react-search/etc/react-search.api.md index 64fce5ae0e171a..35e030a97c5b08 100644 --- a/packages/react-components/react-search/etc/react-search.api.md +++ b/packages/react-components/react-search/etc/react-search.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 { Input } from '@fluentui/react-input'; +import { InputState } from '@fluentui/react-input'; import * as React_2 from 'react'; import type { Slot } from '@fluentui/react-utilities'; import type { SlotClassNames } from '@fluentui/react-utilities'; @@ -21,18 +25,20 @@ export const SearchBox: ForwardRefComponent; export const searchBoxClassNames: SlotClassNames; // @public -export type SearchBoxProps = ComponentProps & {}; +export type SearchBoxProps = ComponentProps; // @public (undocumented) export type SearchBoxSlots = { - root: Slot<'div'>; + root: NonNullable>; + dismiss?: Slot<'span'>; + contentAfter?: Slot<'span'>; }; // @public -export type SearchBoxState = ComponentState; +export type SearchBoxState = ComponentState & Required> & Required>; // @public -export const useSearchBox_unstable: (props: SearchBoxProps, ref: React_2.Ref) => SearchBoxState; +export const useSearchBox_unstable: (props: SearchBoxProps, ref: React_2.Ref) => SearchBoxState; // @public export const useSearchBoxStyles_unstable: (state: SearchBoxState) => SearchBoxState; diff --git a/packages/react-components/react-search/package.json b/packages/react-components/react-search/package.json index dfd82bd9f7778e..2bc8da226d1d67 100644 --- a/packages/react-components/react-search/package.json +++ b/packages/react-components/react-search/package.json @@ -33,6 +33,8 @@ "@fluentui/scripts-tasks": "*" }, "dependencies": { + "@fluentui/react-icons": "^2.0.203", + "@fluentui/react-input": "^9.4.16", "@fluentui/react-jsx-runtime": "9.0.0-alpha.6", "@fluentui/react-theme": "^9.1.8", "@fluentui/react-utilities": "^9.9.2", diff --git a/packages/react-components/react-search/src/components/SearchBox/SearchBox.test.tsx b/packages/react-components/react-search/src/components/SearchBox/SearchBox.test.tsx index 02785e11cfd379..cd06bc56cd110a 100644 --- a/packages/react-components/react-search/src/components/SearchBox/SearchBox.test.tsx +++ b/packages/react-components/react-search/src/components/SearchBox/SearchBox.test.tsx @@ -7,12 +7,13 @@ describe('SearchBox', () => { isConformant({ Component: SearchBox, displayName: 'SearchBox', + primarySlot: 'input', }); // TODO add more tests here, and create visual regression tests in /apps/vr-tests it('renders a default state', () => { - const result = render(Default SearchBox); + const result = render(); expect(result.container).toMatchSnapshot(); }); }); diff --git a/packages/react-components/react-search/src/components/SearchBox/SearchBox.types.ts b/packages/react-components/react-search/src/components/SearchBox/SearchBox.types.ts index ede74dbf5447a1..9716052f4899cc 100644 --- a/packages/react-components/react-search/src/components/SearchBox/SearchBox.types.ts +++ b/packages/react-components/react-search/src/components/SearchBox/SearchBox.types.ts @@ -1,17 +1,25 @@ import type { ComponentProps, ComponentState, Slot } from '@fluentui/react-utilities'; +import { Input, InputState } from '@fluentui/react-input'; export type SearchBoxSlots = { - root: Slot<'div'>; + // Root of the component, wrapping the inputs + root: NonNullable>; + + // Last element in the input, within the input border + dismiss?: Slot<'span'>; + + // Element after the input text, within the input border + contentAfter?: Slot<'span'>; }; /** * SearchBox Props */ -export type SearchBoxProps = ComponentProps & {}; +export type SearchBoxProps = ComponentProps; /** * State used in rendering SearchBox */ -export type SearchBoxState = ComponentState; -// TODO: Remove semicolon from previous line, uncomment next line, and provide union of props to pick from SearchBoxProps. -// & Required> +export type SearchBoxState = ComponentState & + Required> & + Required>; diff --git a/packages/react-components/react-search/src/components/SearchBox/__snapshots__/SearchBox.test.tsx.snap b/packages/react-components/react-search/src/components/SearchBox/__snapshots__/SearchBox.test.tsx.snap index 50af2699391d50..ff04dd5806b3ff 100644 --- a/packages/react-components/react-search/src/components/SearchBox/__snapshots__/SearchBox.test.tsx.snap +++ b/packages/react-components/react-search/src/components/SearchBox/__snapshots__/SearchBox.test.tsx.snap @@ -2,10 +2,56 @@ exports[`SearchBox renders a default state 1`] = `
- + + + + + + + + + +
`; diff --git a/packages/react-components/react-search/src/components/SearchBox/renderSearchBox.tsx b/packages/react-components/react-search/src/components/SearchBox/renderSearchBox.tsx index 4979cf2c8e5340..1b4f7d4ee899fd 100644 --- a/packages/react-components/react-search/src/components/SearchBox/renderSearchBox.tsx +++ b/packages/react-components/react-search/src/components/SearchBox/renderSearchBox.tsx @@ -1,6 +1,8 @@ /** @jsxRuntime classic */ /** @jsx createElement */ +/** @jsxFrag React.Fragment */ +import * as React from 'react'; import { createElement } from '@fluentui/react-jsx-runtime'; import { getSlotsNext } from '@fluentui/react-utilities'; import type { SearchBoxState, SearchBoxSlots } from './SearchBox.types'; @@ -12,5 +14,17 @@ export const renderSearchBox_unstable = (state: SearchBoxState) => { const { slots, slotProps } = getSlotsNext(state); // TODO Add additional slots in the appropriate place - return ; + const rootSlots = { + contentAfter: slots.contentAfter && { + ...slotProps.contentAfter, + children: ( + <> + {slotProps.contentAfter.children} + {slots.dismiss && } + + ), + }, + }; + + return ; }; diff --git a/packages/react-components/react-search/src/components/SearchBox/useSearchBox.ts b/packages/react-components/react-search/src/components/SearchBox/useSearchBox.ts deleted file mode 100644 index 398df621098749..00000000000000 --- a/packages/react-components/react-search/src/components/SearchBox/useSearchBox.ts +++ /dev/null @@ -1,28 +0,0 @@ -import * as React from 'react'; -import { getNativeElementProps } from '@fluentui/react-utilities'; -import type { SearchBoxProps, SearchBoxState } from './SearchBox.types'; - -/** - * Create the state required to render SearchBox. - * - * The returned state can be modified with hooks such as useSearchBoxStyles_unstable, - * before being passed to renderSearchBox_unstable. - * - * @param props - props from this instance of SearchBox - * @param ref - reference to root HTMLElement of SearchBox - */ -export const useSearchBox_unstable = (props: SearchBoxProps, ref: React.Ref): SearchBoxState => { - 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-search/src/components/SearchBox/useSearchBox.tsx b/packages/react-components/react-search/src/components/SearchBox/useSearchBox.tsx new file mode 100644 index 00000000000000..788018286e9097 --- /dev/null +++ b/packages/react-components/react-search/src/components/SearchBox/useSearchBox.tsx @@ -0,0 +1,73 @@ +import * as React from 'react'; +import { mergeCallbacks, resolveShorthand, useControllableState, useEventCallback } from '@fluentui/react-utilities'; +import { Input } from '@fluentui/react-input'; +import type { SearchBoxProps, SearchBoxState } from './SearchBox.types'; +import { DismissRegular, SearchRegular } from '@fluentui/react-icons'; + +/** + * Create the state required to render SearchBox. + * + * The returned state can be modified with hooks such as useSearchBoxStyles_unstable, + * before being passed to renderSearchBox_unstable. + * + * @param props - props from this instance of SearchBox + * @param ref - reference to root HTMLElement of SearchBox + */ +export const useSearchBox_unstable = (props: SearchBoxProps, ref: React.Ref): SearchBoxState => { + const { size = 'medium', disabled = false, contentBefore, dismiss, contentAfter, ...inputProps } = props; + + const [value, setValue] = useControllableState({ + state: props.value, + defaultState: props.defaultValue, + initialState: '', + }); + + const state: SearchBoxState = { + components: { + root: Input, + dismiss: 'span', + contentAfter: 'span', + }, + + root: { + ref, + type: 'search', + input: {}, // defining here to have access in styles hook + value, + + contentBefore: resolveShorthand(contentBefore, { + defaultProps: { + children: , + }, + required: true, // TODO need to allow users to remove + }), + + ...inputProps, + + onChange: useEventCallback(ev => { + const newValue = ev.target.value; + props.onChange?.(ev, { value: newValue }); + setValue(newValue); + }), + }, + dismiss: resolveShorthand(dismiss, { + defaultProps: { + children: , + role: 'button', + 'aria-label': 'clear', + }, + required: true, + }), + contentAfter: resolveShorthand(contentAfter, { required: true }), + + disabled, + size, + }; + + const onDismissClick = useEventCallback(mergeCallbacks(state.dismiss?.onClick, () => setValue(''))); + if (state.dismiss) { + state.dismiss.onClick = onDismissClick; + } + + return state; +}; diff --git a/packages/react-components/react-search/src/components/SearchBox/useSearchBoxStyles.styles.ts b/packages/react-components/react-search/src/components/SearchBox/useSearchBoxStyles.styles.ts index 21fd6fbea8f5d9..e7817f6ee7610c 100644 --- a/packages/react-components/react-search/src/components/SearchBox/useSearchBoxStyles.styles.ts +++ b/packages/react-components/react-search/src/components/SearchBox/useSearchBoxStyles.styles.ts @@ -1,33 +1,127 @@ -import { makeStyles, mergeClasses } from '@griffel/react'; +import { makeResetStyles, makeStyles, mergeClasses } from '@griffel/react'; import type { SearchBoxSlots, SearchBoxState } from './SearchBox.types'; import type { SlotClassNames } from '@fluentui/react-utilities'; +import { tokens } from '@fluentui/react-theme'; export const searchBoxClassNames: SlotClassNames = { root: 'fui-SearchBox', - // TODO: add class names for all slots on SearchBoxSlots. - // Should be of the form `: 'fui-SearchBox__` + dismiss: 'fui-SearchBox__dismiss', + contentAfter: 'fui-SearchBox__contentAfter', }; /** * Styles for the root slot */ -const useStyles = makeStyles({ - root: { - // TODO Add default styles for the root element +const useRootStyles = makeStyles({ + small: { + columnGap: 0, + width: '468px', + + paddingLeft: tokens.spacingHorizontalSNudge, + paddingRight: tokens.spacingHorizontalSNudge, + }, + medium: { + columnGap: 0, + width: '468px', + + paddingLeft: tokens.spacingHorizontalS, + paddingRight: tokens.spacingHorizontalS, + }, + large: { + columnGap: 0, + width: '468px', + + paddingLeft: tokens.spacingHorizontalMNudge, + paddingRight: tokens.spacingHorizontalMNudge, + }, + + input: { + paddingLeft: tokens.spacingHorizontalSNudge, + paddingRight: 0, + + // dismiss + contentAfter appear on focus + '& + span': { + display: 'none', + }, + '&:focus + span': { + display: 'flex', + }, + + // removes the WebKit pseudoelement styling + '::-webkit-search-decoration': { + display: 'none', + }, + '::-webkit-search-cancel-button': { + display: 'none', + }, + }, +}); + +const useContentAfterStyles = makeStyles({ + contentAfter: { + paddingLeft: tokens.spacingHorizontalM, + columnGap: tokens.spacingHorizontalXS, }, +}); - // TODO add additional classes for different states and/or slots +const useDismissClassName = makeResetStyles({ + boxSizing: 'border-box', + color: tokens.colorNeutralForeground3, // "icon color" in design spec + display: 'flex', + // special case styling for icons (most common case) to ensure they're centered vertically + // size: medium (default) + '> svg': { fontSize: '20px' }, +}); + +const useDismissStyles = makeStyles({ + disabled: { + color: tokens.colorNeutralForegroundDisabled, + }, + // Ensure resizable icons show up with the proper font size + small: { + '> svg': { fontSize: '16px' }, + }, + medium: { + // included in useDismissClassName + }, + large: { + '> svg': { fontSize: '24px' }, + }, }); /** * Apply styling to the SearchBox slots based on the state */ export const useSearchBoxStyles_unstable = (state: SearchBoxState): SearchBoxState => { - const styles = useStyles(); - state.root.className = mergeClasses(searchBoxClassNames.root, styles.root, state.root.className); + const { disabled, size } = state; + + const rootStyles = useRootStyles(); + const contentAfterStyles = useContentAfterStyles(); + const dismissClassName = useDismissClassName(); + const dismissStyles = useDismissStyles(); + + state.root.className = mergeClasses(searchBoxClassNames.root, rootStyles[size], state.root.className); + state.root.input!.className = rootStyles.input; + + if (state.dismiss) { + state.dismiss.className = mergeClasses( + searchBoxClassNames.dismiss, + dismissClassName, + disabled && dismissStyles.disabled, + dismissStyles[size], + state.dismiss.className, + ); + } - // TODO Add class names to slots, for example: - // state.mySlot.className = mergeClasses(styles.mySlot, state.mySlot.className); + if (state.contentAfter) { + state.contentAfter!.className = mergeClasses( + searchBoxClassNames.contentAfter, + contentAfterStyles.contentAfter, + state.contentAfter.className, + ); + } else if (state.dismiss) { + state.dismiss.className = mergeClasses(state.dismiss.className, contentAfterStyles.contentAfter); + } return state; }; diff --git a/packages/react-components/react-search/stories/SearchBox/SearchBoxDefault.stories.tsx b/packages/react-components/react-search/stories/SearchBox/SearchBoxDefault.stories.tsx index 08a6b5018fd0c9..fe7ab84e788d32 100644 --- a/packages/react-components/react-search/stories/SearchBox/SearchBoxDefault.stories.tsx +++ b/packages/react-components/react-search/stories/SearchBox/SearchBoxDefault.stories.tsx @@ -1,4 +1,6 @@ import * as React from 'react'; import { SearchBox, SearchBoxProps } from '@fluentui/react-search'; -export const Default = (props: Partial) => ; +import { FilterRegular } from '@fluentui/react-icons'; + +export const Default = (props: Partial) => } />;