diff --git a/packages/react-spinner/etc/react-spinner.api.md b/packages/react-spinner/etc/react-spinner.api.md index 3f75a72e264fea..cdf5eec39992da 100644 --- a/packages/react-spinner/etc/react-spinner.api.md +++ b/packages/react-spinner/etc/react-spinner.api.md @@ -7,9 +7,10 @@ import type { ComponentProps } from '@fluentui/react-utilities'; import type { ComponentState } from '@fluentui/react-utilities'; import type { ForwardRefComponent } from '@fluentui/react-utilities'; +import { Label } from '@fluentui/react-label'; import * as React_2 from 'react'; import type { Slot } from '@fluentui/react-utilities'; -import { SlotClassNames } from '@fluentui/react-utilities'; +import type { SlotClassNames } from '@fluentui/react-utilities'; // @public export const renderSpinner_unstable: (state: SpinnerState) => JSX.Element; @@ -17,20 +18,19 @@ export const renderSpinner_unstable: (state: SpinnerState) => JSX.Element; // @public export const Spinner: ForwardRefComponent; -// @public @deprecated (undocumented) -export const spinnerClassName = "fui-Spinner"; - // @public (undocumented) export const spinnerClassNames: SlotClassNames; // Warning: (ae-forgotten-export) The symbol "SpinnerCommons" needs to be exported by the entry point index.d.ts // // @public -export type SpinnerProps = ComponentProps & SpinnerCommons; +export type SpinnerProps = Omit, 'size'> & Partial; // @public (undocumented) export type SpinnerSlots = { - root: Slot<'div'>; + root: NonNullable>; + spinner?: Slot<'span'>; + label?: Slot; }; // @public diff --git a/packages/react-spinner/package.json b/packages/react-spinner/package.json index b91a8930e4b613..e320228f787084 100644 --- a/packages/react-spinner/package.json +++ b/packages/react-spinner/package.json @@ -34,6 +34,7 @@ }, "dependencies": { "@fluentui/react-theme": "9.0.0-rc.4", + "@fluentui/react-label": "9.0.0-beta.9", "@fluentui/react-utilities": "9.0.0-rc.5", "@griffel/react": "1.0.0", "tslib": "^2.1.0" diff --git a/packages/react-spinner/src/components/Spinner/DefaultSvg.tsx b/packages/react-spinner/src/components/Spinner/DefaultSvg.tsx new file mode 100644 index 00000000000000..4cab26d8432720 --- /dev/null +++ b/packages/react-spinner/src/components/Spinner/DefaultSvg.tsx @@ -0,0 +1,8 @@ +import * as React from 'react'; + +export const DefaultSvg = () => ( + + + + +); diff --git a/packages/react-spinner/src/components/Spinner/Spinner.test.tsx b/packages/react-spinner/src/components/Spinner/Spinner.test.tsx index 5ed2dc68b04ff2..2bbd2c6e7aebc3 100644 --- a/packages/react-spinner/src/components/Spinner/Spinner.test.tsx +++ b/packages/react-spinner/src/components/Spinner/Spinner.test.tsx @@ -7,6 +7,16 @@ describe('Spinner', () => { isConformant({ Component: Spinner, displayName: 'Spinner', + testOptions: { + 'has-static-classnames': [ + { + props: { + label: 'Test Label', + }, + }, + ], + }, + disabledTests: ['component-has-static-classname', 'component-has-static-classname-exported'], }); // TODO add more tests here, and create visual regression tests in /apps/vr-tests diff --git a/packages/react-spinner/src/components/Spinner/Spinner.types.ts b/packages/react-spinner/src/components/Spinner/Spinner.types.ts index 6d446f122c3bd0..684097560c9e4f 100644 --- a/packages/react-spinner/src/components/Spinner/Spinner.types.ts +++ b/packages/react-spinner/src/components/Spinner/Spinner.types.ts @@ -1,17 +1,55 @@ import type { ComponentProps, ComponentState, Slot } from '@fluentui/react-utilities'; +import { Label } from '@fluentui/react-label'; export type SpinnerSlots = { - root: Slot<'div'>; + /** + * The root of the Spinner. + * The root slot receives the `className` and `style` specified directly on the ``. + */ + root: NonNullable>; + /** + * The slot for the animated svg. + * The spinner slot receives the `className` and `style` that handles the spinning animation. + * An svg is also rendered as a child of this slot + */ + spinner?: Slot<'span'>; + /** + * The label of the Slider. + * The label slot receives the styling related to the text associated with the Spinner. + */ + label?: Slot; }; type SpinnerCommons = { - // TODO Add things shared between props and state here + /** + * The appearance of the Spinner. + * @default 'primary' + */ + appearance?: 'primary' | 'inverted'; + + /** + * Where the label is positioned relative to the Spinner + * @default 'after' + */ + labelPosition?: 'above' | 'below' | 'before' | 'after'; + + /** + * The size of the spinner. + * @default 'medium' + */ + size?: 'tiny' | 'extra-small' | 'small' | 'medium' | 'large' | 'extra-large' | 'huge'; + + /** + * The status of the Spinner. + * @default 'active' + */ + status?: 'active' | 'inactive'; }; /** * Spinner Props */ -export type SpinnerProps = ComponentProps & SpinnerCommons; +export type SpinnerProps = Omit, 'size'> & Partial; /** * State used in rendering Spinner diff --git a/packages/react-spinner/src/components/Spinner/__snapshots__/Spinner.test.tsx.snap b/packages/react-spinner/src/components/Spinner/__snapshots__/Spinner.test.tsx.snap index d24d2fc0ad0aad..0a20f860e92d01 100644 --- a/packages/react-spinner/src/components/Spinner/__snapshots__/Spinner.test.tsx.snap +++ b/packages/react-spinner/src/components/Spinner/__snapshots__/Spinner.test.tsx.snap @@ -5,7 +5,21 @@ exports[`Spinner renders a default state 1`] = `
- Default Spinner + + + + + +
`; diff --git a/packages/react-spinner/src/components/Spinner/renderSpinner.tsx b/packages/react-spinner/src/components/Spinner/renderSpinner.tsx index 2d4bcf8956567c..54800d88dcc088 100644 --- a/packages/react-spinner/src/components/Spinner/renderSpinner.tsx +++ b/packages/react-spinner/src/components/Spinner/renderSpinner.tsx @@ -7,7 +7,12 @@ import type { SpinnerState, SpinnerSlots } from './Spinner.types'; */ export const renderSpinner_unstable = (state: SpinnerState) => { const { slots, slotProps } = getSlots(state); - - // TODO Add additional slots in the appropriate place - return ; + const { labelPosition, status } = state; + return ( + + {slots.label && (labelPosition === 'above' || labelPosition === 'before') && } + {slots.spinner && status === 'active' && } + {slots.label && (labelPosition === 'below' || labelPosition === 'after') && } + + ); }; diff --git a/packages/react-spinner/src/components/Spinner/useSpinner.ts b/packages/react-spinner/src/components/Spinner/useSpinner.ts deleted file mode 100644 index 9b47a50f884a55..00000000000000 --- a/packages/react-spinner/src/components/Spinner/useSpinner.ts +++ /dev/null @@ -1,28 +0,0 @@ -import * as React from 'react'; -import { getNativeElementProps } from '@fluentui/react-utilities'; -import type { SpinnerProps, SpinnerState } from './Spinner.types'; - -/** - * Create the state required to render Spinner. - * - * The returned state can be modified with hooks such as useSpinnerStyles_unstable, - * before being passed to renderSpinner_unstable. - * - * @param props - props from this instance of Spinner - * @param ref - reference to root HTMLElement of Spinner - */ -export const useSpinner_unstable = (props: SpinnerProps, ref: React.Ref): SpinnerState => { - 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-spinner/src/components/Spinner/useSpinner.tsx b/packages/react-spinner/src/components/Spinner/useSpinner.tsx new file mode 100644 index 00000000000000..7c7f385b037494 --- /dev/null +++ b/packages/react-spinner/src/components/Spinner/useSpinner.tsx @@ -0,0 +1,42 @@ +import * as React from 'react'; +import { getNativeElementProps, resolveShorthand } from '@fluentui/react-utilities'; +import type { SpinnerProps, SpinnerState } from './Spinner.types'; +import { Label } from '@fluentui/react-label'; +import { DefaultSvg } from './DefaultSvg'; + +/** + * Create the state required to render Spinner. + * + * The returned state can be modified with hooks such as useSpinnerStyles_unstable, + * before being passed to renderSpinner_unstable. + * + * @param props - props from this instance of Spinner + * @param ref - reference to root HTMLElement of Spinner + */ +export const useSpinner_unstable = (props: SpinnerProps, ref: React.Ref): SpinnerState => { + // Props + const { appearance = 'primary', labelPosition = 'after', size = 'medium', status = 'active' } = props; + + const state: SpinnerState = { + appearance, + labelPosition, + size, + status, + components: { + root: 'div', + spinner: 'span', + label: Label, + }, + root: getNativeElementProps('div', { ref, ...props }, ['size']), + spinner: resolveShorthand(props.spinner, { + required: true, + defaultProps: { + children: , + }, + }), + label: resolveShorthand(props.label, { + required: false, + }), + }; + return state; +}; diff --git a/packages/react-spinner/src/components/Spinner/useSpinnerStyles.ts b/packages/react-spinner/src/components/Spinner/useSpinnerStyles.ts index 470ab188e041d4..a929acb590b370 100644 --- a/packages/react-spinner/src/components/Spinner/useSpinnerStyles.ts +++ b/packages/react-spinner/src/components/Spinner/useSpinnerStyles.ts @@ -1,34 +1,334 @@ -import { SlotClassNames } from '@fluentui/react-utilities'; -import { makeStyles, mergeClasses } from '@griffel/react'; -import type { SpinnerSlots, SpinnerState } from './Spinner.types'; +import { makeStyles, mergeClasses, shorthands } from '@griffel/react'; +import { tokens } from '@fluentui/react-theme'; +import type { SpinnerState, SpinnerSlots } from './Spinner.types'; +import type { SlotClassNames } from '@fluentui/react-utilities'; -/** - * @deprecated Use `spinnerClassNames.root` instead. - */ -export const spinnerClassName = 'fui-Spinner'; export const spinnerClassNames: SlotClassNames = { root: 'fui-Spinner', + spinner: 'fui-Spinner__spinner', + label: 'fui-Spinner__label', +}; + +/* + * TODO: Update with proper tokens when added + * Radii for the Spinner circles + */ +const rValues = { + tiny: '9', + extraSmall: '11', + small: '13', + medium: '14.5', + large: '16.5', + extraLarge: '18.5', + huge: '20', +}; + +/* + * TODO: Update with proper tokens when added + * Sizes for the Spinner + */ +const spinnnerSizes = { + tiny: '20px', + extraSmall: '24px', + small: '28px', + medium: '32px', + large: '36px', + extraLarge: '40px', + huge: '44px', +}; + +/* + * TODO: Update with proper tokens when added + * Stroke widths for the Spinner + */ +const spinnerStrokeWidth = { + sWidth: '2px', + mWidth: '3px', + lWidth: '4px', }; + +/* + * TODO: Update with proper tokens when added + * Animation for Spinner + */ +const spinnerAnimation = { + container: { + animationDuration: '3s', + animationIterationCount: 'infinite', + animationTimingFunction: 'linear', + backgroundColor: 'transparent', + }, +}; + +/* + * TODO: Update with proper tokens when added + * Label sizes for the Spinner + */ +const labelSizeTokens = { + body1: { + fontSize: tokens.fontSizeBase300, + lineHeight: tokens.lineHeightBase300, + }, + + subtitle2: { + fontSize: tokens.fontSizeBase400, + lineHeight: tokens.lineHeightBase400, + }, + + subtitle1: { + fontSize: tokens.fontSizeBase500, + lineHeight: tokens.lineHeightBase500, + }, +}; + /** * Styles for the root slot */ -const useStyles = makeStyles({ +const useRootStyles = makeStyles({ root: { - // TODO Add default styles for the root element + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + ...shorthands.gap('8px'), + }, + + horizontal: { + flexDirection: 'row', + }, + + vertical: { + flexDirection: 'column', + }, +}); + +const useLoaderStyles = makeStyles({ + // global SVG class + spinnerSVG: { + ['& > svg']: { + animationName: { + '0%': { transform: 'rotate(0deg)' }, + '100%': { transform: 'rotate(360deg)' }, + }, + ...spinnerAnimation.container, + }, + ['& > svg > circle']: { + cx: '50%', + cy: '50%', + fill: 'none', + }, + }, + + tiny: { + ['& > svg']: { + height: spinnnerSizes.tiny, + width: spinnnerSizes.tiny, + }, + ['& > svg > circle']: { + strokeWidth: spinnerStrokeWidth.sWidth, + r: rValues.tiny, + }, + }, + + extraSmall: { + ['& > svg']: { + height: spinnnerSizes.extraSmall, + width: spinnnerSizes.extraSmall, + }, + ['& > svg > circle']: { + strokeWidth: spinnerStrokeWidth.sWidth, + r: rValues.extraSmall, + }, }, - // TODO add additional classes for different states and/or slots + small: { + ['& > svg']: { + height: spinnnerSizes.small, + width: spinnnerSizes.small, + }, + ['& > svg > circle']: { + strokeWidth: spinnerStrokeWidth.sWidth, + r: rValues.small, + }, + }, + + medium: { + ['& > svg']: { + height: spinnnerSizes.medium, + width: spinnnerSizes.medium, + }, + ['& > svg > circle']: { + strokeWidth: spinnerStrokeWidth.mWidth, + r: rValues.medium, + }, + }, + + large: { + ['& > svg']: { + height: spinnnerSizes.large, + width: spinnnerSizes.large, + }, + ['& > svg > circle']: { + strokeWidth: spinnerStrokeWidth.mWidth, + r: rValues.large, + }, + }, + + extraLarge: { + ['& > svg']: { + height: spinnnerSizes.extraLarge, + width: spinnnerSizes.extraLarge, + }, + ['& > svg > circle']: { + strokeWidth: spinnerStrokeWidth.mWidth, + r: rValues.extraLarge, + }, + }, + + huge: { + ['& > svg']: { + height: spinnnerSizes.huge, + width: spinnnerSizes.huge, + }, + ['& > svg > circle']: { + strokeWidth: spinnerStrokeWidth.lWidth, + r: rValues.huge, + }, + }, +}); + +const useTrackStyles = makeStyles({ + inverted: { + ['& > svg > circle.fui-Spinner__Tail']: { + stroke: tokens.colorNeutralStrokeOnBrand2, + animationName: { + '0%': { + strokeDasharray: '1,150', + strokeDashoffset: '0', + }, + + '50%': { + strokeDasharray: '90,150', + strokeDashoffset: '-35', + }, + + '100%': { + strokeDasharray: '90,150', + strokeDashoffset: '-124', + }, + }, + animationDuration: '1.5s', + animationIterationCount: 'infinite', + animationTimingFunction: 'cubic-bezier(0.33,0,0.67,1)', + strokeLinecap: 'round', + transform: 'rotate(-90deg)', + transformOrigin: '50% 50%', + }, + + ['& > svg > circle.fui-Spinner__Track']: { + stroke: tokens.colorNeutralBackgroundInverted, + }, + }, + primary: { + ['& > svg > circle.fui-Spinner__Tail']: { + stroke: tokens.colorBrandStroke1, + animationName: { + '0%': { + strokeDasharray: '1,150', + strokeDashoffset: '0', + }, + + '50%': { + strokeDasharray: '90,150', + strokeDashoffset: '-35', + }, + + '100%': { + strokeDasharray: '90,150', + strokeDashoffset: '-124', + }, + }, + animationDuration: '1.5s', + animationIterationCount: 'infinite', + animationTimingFunction: 'cubic-bezier(0.33,0,0.67,1)', + strokeLinecap: 'round', + transform: 'rotate(-90deg)', + transformOrigin: '50% 50%', + }, + ['& > svg > circle.fui-Spinner__Track']: { + stroke: tokens.colorNeutralBackground4, + }, + }, +}); + +const useLabelStyles = makeStyles({ + // style for label + + tiny: { + ...labelSizeTokens.body1, + }, + + extraSmall: { + ...labelSizeTokens.body1, + }, + + small: { + ...labelSizeTokens.body1, + }, + + medium: { + ...labelSizeTokens.subtitle2, + }, + + large: { + ...labelSizeTokens.subtitle2, + }, + + extraLarge: { + ...labelSizeTokens.subtitle2, + }, + + huge: { + ...labelSizeTokens.subtitle1, + }, }); /** * Apply styling to the Spinner slots based on the state */ export const useSpinnerStyles_unstable = (state: SpinnerState): SpinnerState => { - const styles = useStyles(); - state.root.className = mergeClasses(spinnerClassNames.root, styles.root, state.root.className); + const { labelPosition, size = 'medium' } = state; + const rootStyles = useRootStyles(); + const spinnerStyles = useLoaderStyles(); + const labelStyles = useLabelStyles(); + const trackStyles = useTrackStyles(); - // TODO Add class names to slots, for example: - // state.mySlot.className = mergeClasses(styles.mySlot, state.mySlot.className); + state.root.className = mergeClasses( + spinnerClassNames.root, + rootStyles.root, + (labelPosition === 'above' || labelPosition === 'below') && rootStyles.vertical, + (labelPosition === 'before' || labelPosition === 'after') && rootStyles.horizontal, + state.root.className, + ); + if (state.spinner && state.appearance) { + state.spinner.className = mergeClasses( + spinnerClassNames.spinner, + spinnerStyles.spinnerSVG, + size === 'extra-small' && spinnerStyles.extraSmall, + size === 'extra-large' && spinnerStyles.extraLarge, + size !== 'extra-large' && size !== 'extra-small' && spinnerStyles[size], + trackStyles[state.appearance], + state.spinner.className, + ); + } + if (state.label) { + state.label.className = mergeClasses( + spinnerClassNames.label, + size === 'extra-small' && labelStyles.extraSmall, + size === 'extra-large' && labelStyles.extraLarge, + size !== 'extra-large' && size !== 'extra-small' && labelStyles[size], + state.label.className, + ); + } return state; }; diff --git a/packages/react-spinner/src/index.ts b/packages/react-spinner/src/index.ts index c86731e7b2b49d..9aa0dbaa3627cf 100644 --- a/packages/react-spinner/src/index.ts +++ b/packages/react-spinner/src/index.ts @@ -1,3 +1,8 @@ -// TODO: replace with real exports -export {}; -export * from './Spinner'; +export { + Spinner, + spinnerClassNames, + renderSpinner_unstable, + useSpinner_unstable, + useSpinnerStyles_unstable, +} from './Spinner'; +export type { SpinnerProps, SpinnerSlots, SpinnerState } from './Spinner'; diff --git a/packages/react-spinner/src/stories/Spinner.stories.tsx b/packages/react-spinner/src/stories/Spinner.stories.tsx index 89405f4c5ea137..e7372c5421c311 100644 --- a/packages/react-spinner/src/stories/Spinner.stories.tsx +++ b/packages/react-spinner/src/stories/Spinner.stories.tsx @@ -4,6 +4,9 @@ import descriptionMd from './SpinnerDescription.md'; import bestPracticesMd from './SpinnerBestPractices.md'; export { Default } from './SpinnerDefault.stories'; +export { Appearance } from './SpinnerAppearance.stories'; +export { Labels } from './SpinnerLabel.stories'; +export { Size } from './SpinnerSize.stories'; export default { title: 'Components/Spinner', diff --git a/packages/react-spinner/src/stories/SpinnerAppearance.stories.tsx b/packages/react-spinner/src/stories/SpinnerAppearance.stories.tsx new file mode 100644 index 00000000000000..771cf769777a6a --- /dev/null +++ b/packages/react-spinner/src/stories/SpinnerAppearance.stories.tsx @@ -0,0 +1,21 @@ +import * as React from 'react'; +import { makeStyles, shorthands } from '@griffel/react'; +import { Spinner } from '../index'; + +const useStyles = makeStyles({ + container: { + '> div': { ...shorthands.padding('20px') }, + }, +}); + +export const Appearance = () => { + const styles = useStyles(); + + return ( +
+ + + +
+ ); +}; diff --git a/packages/react-spinner/src/stories/SpinnerLabel.stories.tsx b/packages/react-spinner/src/stories/SpinnerLabel.stories.tsx new file mode 100644 index 00000000000000..443a137180ab6b --- /dev/null +++ b/packages/react-spinner/src/stories/SpinnerLabel.stories.tsx @@ -0,0 +1,24 @@ +import * as React from 'react'; +import { makeStyles, shorthands } from '@griffel/react'; +import { Spinner } from '../index'; + +const useStyles = makeStyles({ + container: { + '> div': { ...shorthands.padding('20px') }, + }, +}); + +export const Labels = () => { + const styles = useStyles(); + return ( +
+ + + + + + + +
+ ); +}; diff --git a/packages/react-spinner/src/stories/SpinnerSize.stories.tsx b/packages/react-spinner/src/stories/SpinnerSize.stories.tsx new file mode 100644 index 00000000000000..6269b1ba54e3bc --- /dev/null +++ b/packages/react-spinner/src/stories/SpinnerSize.stories.tsx @@ -0,0 +1,31 @@ +import * as React from 'react'; +import { makeStyles, shorthands } from '@griffel/react'; +import { Spinner } from '../index'; + +const useStyles = makeStyles({ + container: { + '> div': { ...shorthands.padding('20px') }, + }, +}); + +export const Size = () => { + const styles = useStyles(); + + return ( +
+ + + + + + + + + + + + + +
+ ); +};