diff --git a/apps/pr-deploy-site/just.config.ts b/apps/pr-deploy-site/just.config.ts index 948068bc0dabbd..da5d3b6ef3cbb7 100644 --- a/apps/pr-deploy-site/just.config.ts +++ b/apps/pr-deploy-site/just.config.ts @@ -26,6 +26,7 @@ const dependencies = [ '@fluentui/react-charting', '@fluentui/react-components', '@fluentui/react-experiments', + '@fluentui/react-input', '@fluentui/web-components', 'perf-test', 'theming-designer', diff --git a/apps/pr-deploy-site/pr-deploy-site.js b/apps/pr-deploy-site/pr-deploy-site.js index 190021829addd8..31881bf2f5d68f 100644 --- a/apps/pr-deploy-site/pr-deploy-site.js +++ b/apps/pr-deploy-site/pr-deploy-site.js @@ -54,6 +54,12 @@ var siteInfo = [ icon: 'BarChart4', title: 'Charting', }, + { + package: '@fluentui/react-input', + link: './react-input/storybook/index.html', + icon: 'TextField', + title: 'Input', + }, { package: 'theming-designer', link: './theming-designer/index.html', diff --git a/packages/react-input/Spec-styling.md b/packages/react-input/Spec-styling.md new file mode 100644 index 00000000000000..cb303430f13950 --- /dev/null +++ b/packages/react-input/Spec-styling.md @@ -0,0 +1,119 @@ +# `@fluentui/react-input`: Styling implementation notes + +Input has multiple size and appearance variants. These tables were created based on the design spec to assist with initial implementation and help ensure everything is handled. + +General abbreviations used: + +- " = inherit from left +- ^ = inherit from above +- ^^ = inherit from 2 lines above + +## Sizes + +- padding and gap values are from (theoretical) `spacing.horizontal` unless otherwise specified +- bookend-related sizes are from separate bookends page (everything except L/R padding and spacing within inherits from default) +- interpretation: + - "spacing between icon before and content"/"spacing between content and icon after 1" => "spacing start/end to content" + - "spacing between icon after 1 and icon after 2" => "spacing within insideEnd" because we're not going to have two icon slots + - bookend "spacing between content and icon" => "spacing within bookend" + - omitted "focus indicator" b/c that's handled elsewhere + +| Style | All | +| ------------- | ------------------- | +| v-align | vertically centered | +| border radius | medium | + +| Style | medium | small | large | +| ----------------------------- | ---------------- | ------------------- | --------- | +| height | 32px | 24px | 40px | +| left/right padding | mNudge | sNudge | m | +| left/right padding in content | xxs | " | sNudge | +| bookends left/right padding | s | sNudge | m | +| content size | body1 (base.300) | caption1 (base.200) | base.400 | +| "icon" size | 20Regular | 16Regular | 24Regular | +| spacing start/end to content | xxs | " | sNudge | +| spacing within insideEnd | xs | " | " | +| spacing within bookend | xs | " | " | + +### Sizes application + +| Style | Slot | Notes | +| ----------------------------- | ---------------------------- | ---------------------------------------------------------------- | +| v-align | root, inputWrapper | ??? | +| height | root | ? as minHeight or height ? | +| border radius | bookends, inputWrapper, root | set where borders or shadow are defined; don't use if underlined | +| left/right padding | inputWrapper | padding | +| left/right padding in content | input | padding | +| bookends left/right padding | bookends | padding | +| content size | root, input | fontSize; doesn't inherit to input | +| "icon" size | n/a | no icons built in | +| spacing start/end to content | inputWrapper | display: flex (also to grow input), flex gap | +| spacing within insideEnd | insideEnd | display: flex, flex gap | +| spacing within bookends | bookends | display: flex, flex gap | + +## Appearance colors and strokes + +- italics = thick border +- interpreting "compound brand stroke 1 pressed" as compoundBrandStrokePressed +- appears that focus and keyboard focus styles are the same + +| Style | All | +| ------------------------------------------ | ------------------------- | +| content | neutralForeground1 | +| content disabled | neutralForegroundDisabled | +| placeholder | neutralForeground4 | +| placeholder disabled | neutralForegroundDisabled | +| "icon" color | neutralForeground3 | +| "icon" color disabled | neutralForegroundDisabled | +| background disabled | transparentBackground | +| border disabled | neutralStrokeDisabled | +| in focus indicator (bottom border) | _compoundBrandStroke_ | +| in focus indicator (bottom border) pressed | _^Pressed_ | +| cursor disabled | not-allowed | + +| Style | filledDarker | filledLighter | underline | outline | +| -------------------- | ------------------ | ------------------ | ------------------------ | -------------------- | +| shadow | shadow2 | " | none | " | +| background | neutralBackground3 | neutralBackground1 | transparentBackground | neutralBackground1 | +| border | transparentStroke | " | none | neutralStroke1 | +| border hover | ^Interactive | " | n/a | ^Hover | +| border pressed | ^ | " | n/a | ^^Pressed | +| border focused | ^ | " | n/a | n/a (neutralStroke1) | +| borderBottom | n/a | n/a | neutralStrokeAccessible | " | +| borderBottom hover | n/a | n/a | ^Hover | " | +| borderBottom pressed | n/a | n/a | _^^Pressed_ | " | +| borderBottom focused | n/a | n/a | n/a (in focus indicator) | " | + +### Appearance application + +| Style | Slot | Notes | +| -------------------------- | ------------------- | -------------------------------------------------------- | +| content color | input | other things have their own colors | +| placeholder color | input | `::placeholder` | +| "icon" color | insideStart/End | | +| shadow | root | encompasses bookends; requires rounding root corners | +| background | inputWrapper, input | bookends have separate background; input doesn't inherit | +| border | inputWrapper | | +| border hover | TODO inputWrapper | `:hover` | +| border pressed | TODO | | +| border focused | TODO inputWrapper | `:focus-within` | +| borderBottom | inputWrapper | | +| borderBottom hover | TODO inputWrapper | `:hover` | +| borderBottom pressed | TODO | | +| borderBottom focused | n/a | handled by focus indicator | +| in focus indicator | TODO | | +| in focus indicator pressed | TODO | | +| cursor | root, input | | + +## Bookend appearance (TODO) + +| Style | filled | brand | transparent | +| --------------- | ------------------ | ------------------------ | --------------------- | +| background | neutralBackground6 | brandBackground | transparentBackground | +| content (+icon) | neutralForeground2 | neutralForegroundOnBrand | neutralForeground2 | +| border | transparentStroke | none | transparentStroke | +| inner border | n/a | n/a | neutralStroke3 | + +- Inner border ("border right") color is applied separately to before/after bookends. +- Others are applied in obvious way to both bookends. +- All borders are thin (1px). diff --git a/packages/react-input/bundle-size/Input.fixture.js b/packages/react-input/bundle-size/Input.fixture.js new file mode 100644 index 00000000000000..10f93b389190b7 --- /dev/null +++ b/packages/react-input/bundle-size/Input.fixture.js @@ -0,0 +1,7 @@ +import { Input } from '@fluentui/react-input'; + +console.log(Input); + +export default { + name: 'Input', +}; diff --git a/packages/react-input/etc/react-input.api.md b/packages/react-input/etc/react-input.api.md index d7c40747dbb690..72657533ef9092 100644 --- a/packages/react-input/etc/react-input.api.md +++ b/packages/react-input/etc/react-input.api.md @@ -10,10 +10,18 @@ import type { ObjectShorthandProps } from '@fluentui/react-utilities'; import * as React_2 from 'react'; // @public -export const Input: React_2.ForwardRefExoticComponent & React_2.RefAttributes>; +export const Input: React_2.FunctionComponent; + +// @public (undocumented) +export interface InputCommons { + appearance?: 'outline' | 'underline' | 'filledDarker' | 'filledLighter'; + // (undocumented) + inline?: boolean; + size?: 'small' | 'medium' | 'large'; +} // @public -export interface InputProps extends ComponentProps> { +export interface InputProps extends InputCommons, Omit>, 'children'> { } // @public @@ -31,7 +39,7 @@ export type InputSlots = { }; // @public -export interface InputState extends ComponentState { +export interface InputState extends InputCommons, ComponentState { } // @public diff --git a/packages/react-input/package.json b/packages/react-input/package.json index 28980820fb36cc..f2223a70ee355a 100644 --- a/packages/react-input/package.json +++ b/packages/react-input/package.json @@ -14,6 +14,7 @@ "license": "MIT", "scripts": { "build": "just-scripts build", + "bundle-size": "bundle-size measure", "clean": "just-scripts clean", "code-style": "just-scripts code-style", "just": "just-scripts", @@ -22,6 +23,7 @@ "test": "jest", "docs": "api-extractor run --config=config/api-extractor.local.json --local", "build:local": "tsc -p . --module esnext --emitDeclarationOnly && node ../../scripts/typescript/normalize-import --output dist/react-input/src && yarn docs", + "bundle:storybook": "just-scripts storybook:build", "storybook": "start-storybook" }, "devDependencies": { diff --git a/packages/react-input/src/components/Input/Input.stories.tsx b/packages/react-input/src/components/Input/Input.stories.tsx index 5fd8aa8cafe905..c39e1616bdb6e8 100644 --- a/packages/react-input/src/components/Input/Input.stories.tsx +++ b/packages/react-input/src/components/Input/Input.stories.tsx @@ -1,32 +1,98 @@ import * as React from 'react'; -import { makeStyles } from '@fluentui/react-make-styles'; +import { makeStyles, mergeClasses } from '@fluentui/react-make-styles'; import { Input } from './Input'; -import { useId } from '@fluentui/react-utilities'; +import { getNativeElementProps, useId } from '@fluentui/react-utilities'; +import { InputProps } from './Input.types'; +import { ArgTypes } from '@storybook/react'; +// prevent terrible reload times by using deep imports :( +import Search16Regular from '@fluentui/react-icons/lib/esm/components/Search16Regular'; +import Search20Regular from '@fluentui/react-icons/lib/esm/components/Search20Regular'; +import Search24Regular from '@fluentui/react-icons/lib/esm/components/Search24Regular'; +import Dismiss16Regular from '@fluentui/react-icons/lib/esm/components/Dismiss16Regular'; +import Dismiss20Regular from '@fluentui/react-icons/lib/esm/components/Dismiss20Regular'; +import Dismiss24Regular from '@fluentui/react-icons/lib/esm/components/Dismiss24Regular'; const useStyles = makeStyles({ container: { display: 'flex', flexDirection: 'column', gap: '20px', - width: '300px', + padding: '20px', }, + storyFilledBackground: theme => ({ background: theme.alias.color.neutral.neutralBackground3 }), }); -export const InputExamples = () => { +const icons = { + search: { small: Search16Regular, medium: Search20Regular, large: Search24Regular }, + dismiss: { small: Dismiss16Regular, medium: Dismiss20Regular, large: Dismiss24Regular }, +}; + +export const InputExamples = ( + args: Partial & React.InputHTMLAttributes & { storyFilledBackground: boolean }, +) => { const styles = useStyles(); const inputId1 = useId(); + // pass native input props to the internal input element and custom props to the root + const { storyFilledBackground, ...rest } = args; + const inputProps = getNativeElementProps('input', rest, ['size']); + const props: Partial = { input: inputProps }; + for (const prop of Object.keys(rest) as (keyof InputProps)[]) { + if (!(inputProps as Partial)[prop]) { + props[prop] = rest[prop]; + } + } + const SearchIcon = icons.search[props.size!]; + const DismissIcon = icons.dismiss[props.size!]; + return ( -
- +
+
- +
+ } insideEnd={} /> +

+ Some text with inline input +

+ ), + disabled: true, + placeholder: 'disabled', + }} + insideStart={} + insideEnd={} + /> + ), + placeholder: '300px width', + }} + />
); }; +const argTypes: ArgTypes = { + size: { defaultValue: 'medium', control: { type: 'radio', options: ['small', 'medium', 'large'] } }, + appearance: { + defaultValue: 'outline', + control: { type: 'radio', options: ['filledDarker', 'filledLighter', 'underline', 'outline'] }, + }, + // this one is for the example + storyFilledBackground: { defaultValue: false, control: { type: 'boolean' } }, + // NOTE: these are not actually top-level props right now until RFC is resolved, + // so they get passed through in the example via the input slot + placeholder: { defaultValue: 'placeholder', control: { type: 'text' } }, + value: { control: { type: 'text' } }, +}; + export default { title: 'Components/Input', component: Input, + argTypes, }; diff --git a/packages/react-input/src/components/Input/Input.tsx b/packages/react-input/src/components/Input/Input.tsx index 3c2d9d49a51eab..a320b7189bee40 100644 --- a/packages/react-input/src/components/Input/Input.tsx +++ b/packages/react-input/src/components/Input/Input.tsx @@ -7,7 +7,7 @@ import type { InputProps } from './Input.types'; /** * Input component */ -export const Input = React.forwardRef((props, ref) => { +export const Input: React.FunctionComponent = React.forwardRef((props, ref) => { const state = useInput(props, ref); useInputStyles(state); diff --git a/packages/react-input/src/components/Input/Input.types.ts b/packages/react-input/src/components/Input/Input.types.ts index 9d3f7622c15858..60ce04c3c259ce 100644 --- a/packages/react-input/src/components/Input/Input.types.ts +++ b/packages/react-input/src/components/Input/Input.types.ts @@ -20,12 +20,21 @@ export type InputSlots = { insideEnd?: ObjectShorthandProps, HTMLElement>; }; +export interface InputCommons { + /** @default 'medium' */ + // TODO this overlaps with a native input prop + size?: 'small' | 'medium' | 'large'; + inline?: boolean; + /** @default 'outline' */ + appearance?: 'outline' | 'underline' | 'filledDarker' | 'filledLighter'; +} + /** * Input Props */ -export interface InputProps extends ComponentProps> {} +export interface InputProps extends InputCommons, Omit>, 'children'> {} /** * State used in rendering Input */ -export interface InputState extends ComponentState {} +export interface InputState extends InputCommons, ComponentState {} diff --git a/packages/react-input/src/components/Input/__snapshots__/Input.test.tsx.snap b/packages/react-input/src/components/Input/__snapshots__/Input.test.tsx.snap index 83e70cc633a1d3..46fbd4c3f3dd6c 100644 --- a/packages/react-input/src/components/Input/__snapshots__/Input.test.tsx.snap +++ b/packages/react-input/src/components/Input/__snapshots__/Input.test.tsx.snap @@ -2,12 +2,16 @@ exports[`Input renders a default state 1`] = `
-
-
- -
-
+ + + +
`; diff --git a/packages/react-input/src/components/Input/useInput.ts b/packages/react-input/src/components/Input/useInput.ts index fcea4bfa509df8..f7b0727ad03bc3 100644 --- a/packages/react-input/src/components/Input/useInput.ts +++ b/packages/react-input/src/components/Input/useInput.ts @@ -25,19 +25,28 @@ export const inputShorthandProps: (keyof InputSlots)[] = [ * @param ref - reference to root HTMLInputElement of Input */ export const useInput = (props: InputProps, ref: React.Ref): InputState => { - const { input, inputWrapper, bookendAfter, bookendBefore, insideEnd, insideStart } = props; + const { input, inputWrapper, bookendAfter, bookendBefore, insideEnd, insideStart, size, appearance, inline } = props; + return { + size, + appearance, + inline, components: { + root: 'span', input: 'input', + inputWrapper: 'span', + bookendBefore: 'span', + bookendAfter: 'span', + insideStart: 'span', + insideEnd: 'span', }, - // temporarily must add fake children to prevent getSlots from substituting nullRender input: resolveShorthand(input, { required: true }), inputWrapper: resolveShorthand(inputWrapper, { required: true }), bookendAfter: resolveShorthand(bookendAfter), bookendBefore: resolveShorthand(bookendBefore), insideEnd: resolveShorthand(insideEnd), insideStart: resolveShorthand(insideStart), - root: getNativeElementProps('div', { + root: getNativeElementProps('span', { ref, ...props, }), diff --git a/packages/react-input/src/components/Input/useInputStyles.ts b/packages/react-input/src/components/Input/useInputStyles.ts index d375e7e81950d5..87a529421e9415 100644 --- a/packages/react-input/src/components/Input/useInputStyles.ts +++ b/packages/react-input/src/components/Input/useInputStyles.ts @@ -1,28 +1,233 @@ import { makeStyles, mergeClasses } from '@fluentui/react-make-styles'; -import type { InputSlots, InputState } from './Input.types'; +import type { InputState } from './Input.types'; +import type { Theme } from '@fluentui/react-theme'; -/** - * Styles for the root slot - */ -const useStyles = makeStyles({ - root: theme => ({}), - input: theme => ({}), - inputWrapper: theme => ({}), - bookendBefore: theme => ({}), - bookendAfter: theme => ({}), - insideStart: theme => ({}), - insideEnd: theme => ({}), +// TODO(sharing) use theme values once available +const horizontalSpacing = { + xxs: '2px', + xs: '4px', + sNudge: '6px', + s: '8px', + mNudge: '10px', + m: '12px', +}; +const contentSizes = { + // TODO(sharing) shouldn't these be in the theme? + body1: (theme: Theme) => ({ + fontSize: theme.global.type.fontSizes.base[300], + lineHeight: theme.global.type.lineHeights.base[300], + }), + caption1: (theme: Theme) => ({ + fontSize: theme.global.type.fontSizes.base[200], + lineHeight: theme.global.type.lineHeights.base[200], + }), + // eslint-disable-next-line @typescript-eslint/naming-convention + 400: (theme: Theme) => ({ + fontSize: theme.global.type.fontSizes.base[400], + lineHeight: theme.global.type.lineHeights.base[400], + }), +}; +// TODO(sharing) should these be shared somewhere? +const fieldHeights = { + small: '24px', + medium: '32px', + large: '40px', +}; +const borderRadius = (theme: Theme) => theme.global.borderRadius.medium; +const backgroundColors = { + /** for outline/filledLighter */ + filledLighter: (theme: Theme) => theme.alias.color.neutral.neutralBackground1, + filledDarker: (theme: Theme) => theme.alias.color.neutral.neutralBackground3, + /** for underline */ + transparent: (theme: Theme) => theme.alias.color.neutral.transparentBackground, +}; + +const useRootStyles = makeStyles({ + base: theme => ({ + display: 'flex', + alignItems: 'stretch', + flexWrap: 'no-wrap', + fontFamily: theme.global.type.fontFamilies.base, + boxSizing: 'border-box', + '*, *:before, *:after': { + boxSizing: 'border-box', + }, + }), + small: theme => ({ + minHeight: fieldHeights.small, + ...contentSizes.caption1(theme), + }), + medium: theme => ({ + minHeight: fieldHeights.medium, // minHeight not height in case of tall bookends + ...contentSizes.body1(theme), + }), + large: theme => ({ + minHeight: fieldHeights.large, + ...contentSizes[400](theme), + }), + inline: { + display: 'inline-flex', + }, + filled: theme => ({ + // optional shadow for filled appearances + boxShadow: theme.alias.shadow.shadow2, + borderRadius: borderRadius(theme), // needed for shadow + }), + disabled: { + cursor: 'not-allowed', + }, +}); + +const useInputElementStyles = makeStyles({ + base: theme => ({ + flexGrow: 1, + border: 'none', // input itself never has a border (this is handled by inputWrapper) + padding: `0 ${horizontalSpacing.xxs}`, + color: theme.alias.color.neutral.neutralForeground1, + + '::placeholder': { + color: theme.alias.color.neutral.neutralForeground4, + opacity: 1, // browser style override + }, + ':focus-visible': { + outline: 'none', // disable default browser outline + }, + }), + small: theme => ({ + // This is set on root but doesn't inherit + ...contentSizes.caption1(theme), + }), + medium: theme => ({ + ...contentSizes.body1(theme), + }), + large: theme => ({ + ...contentSizes[400](theme), + padding: `0 ${horizontalSpacing.sNudge}`, + }), + outline: theme => ({ + // This is set on inputWrapper but doesn't inherit + background: backgroundColors.filledLighter(theme), + }), + underline: theme => ({ + background: backgroundColors.transparent(theme), + }), + filledLighter: theme => ({ + background: backgroundColors.filledLighter(theme), + }), + filledDarker: theme => ({ + background: backgroundColors.filledDarker(theme), + }), + disabled: theme => ({ + color: theme.alias.color.neutral.neutralForegroundDisabled, + background: theme.alias.color.neutral.transparentBackground, + cursor: 'not-allowed', + '::placeholder': { + color: theme.alias.color.neutral.neutralForegroundDisabled, + }, + }), +}); + +const useInputWrapperStyles = makeStyles({ + base: theme => ({ + flexGrow: 1, + display: 'flex', + alignItems: 'center', + flexWrap: 'no-wrap', + gap: horizontalSpacing.xxs, + + // This may need to be conditionally applied to start/end corners when bookend styling is added + borderRadius: borderRadius(theme), + }), + small: { + padding: `0 ${horizontalSpacing.sNudge}`, + }, + medium: { + padding: `0 ${horizontalSpacing.mNudge}`, + }, + large: { + padding: `0 ${horizontalSpacing.m}`, + }, + outline: theme => ({ + // Set this on inputWrapper rather than root since bookends will have separate background colors and borders + background: backgroundColors.filledLighter(theme), + border: `1px solid ${theme.alias.color.neutral.neutralStroke1}`, + borderBottom: `1px solid ${theme.alias.color.neutral.neutralStrokeAccessible}`, + }), + underline: theme => ({ + backgroundColor: backgroundColors.transparent(theme), + borderRadius: 0, // corners look strange if rounded + borderBottom: `1px solid ${theme.alias.color.neutral.neutralStrokeAccessible}`, + }), + filledDarker: theme => ({ + background: backgroundColors.filledDarker(theme), + border: `1px solid ${theme.alias.color.neutral.transparentStroke}`, + }), + filledLighter: theme => ({ + background: backgroundColors.filledLighter(theme), + border: `1px solid ${theme.alias.color.neutral.transparentStroke}`, + }), + disabled: theme => ({ + border: `1px solid ${theme.alias.color.neutral.neutralStrokeDisabled}`, + borderRadius: borderRadius(theme), // because underline doesn't usually have a radius + }), +}); + +const useInsideStyles = makeStyles({ + base: theme => ({ + color: theme.alias.color.neutral.neutralForeground3, // "icon color" in design spec + // special case styling for icons (most common case) to ensure they're centered vertically + '> span > svg': { display: 'block' }, + }), + disabled: theme => ({ + color: theme.alias.color.neutral.neutralForegroundDisabled, + }), }); /** * Apply styling to the Input slots based on the state */ export const useInputStyles = (state: InputState): InputState => { - const styles = useStyles(); - state.root.className = mergeClasses(styles.root, state.root.className); + const { size = 'medium', appearance = 'outline' } = state; + const disabled = state.input.disabled; + const rootStyles = useRootStyles(); + const inputStyles = useInputElementStyles(); + const inputWrapperStyles = useInputWrapperStyles(); + const insideStyles = useInsideStyles(); + + const filled = appearance.startsWith('filled'); + + state.root.className = mergeClasses( + rootStyles.base, + rootStyles[size], + state.inline && rootStyles.inline, + filled && rootStyles.filled, + disabled && rootStyles.disabled, + state.root.className, + ); + + state.input.className = mergeClasses( + inputStyles.base, + inputStyles[size], + inputStyles[appearance], + disabled && inputStyles.disabled, + state.input.className, + ); + + state.inputWrapper.className = mergeClasses( + inputWrapperStyles.base, + inputWrapperStyles[size], + inputWrapperStyles[appearance], + disabled && inputWrapperStyles.disabled, + state.inputWrapper.className, + ); - // TODO Add class names to slots, for example: - // state.mySlot.className = mergeClasses(styles.mySlot, state.mySlot.className); + const insideClasses = [insideStyles.base, disabled && insideStyles.disabled]; + if (state.insideStart) { + state.insideStart.className = mergeClasses(...insideClasses, state.insideStart.className); + } + if (state.insideEnd) { + state.insideEnd.className = mergeClasses(...insideClasses, state.insideEnd.className); + } return state; };