diff --git a/change/@fluentui-react-input-74cef3fa-d688-4ac5-ba13-593f2513aa21.json b/change/@fluentui-react-input-74cef3fa-d688-4ac5-ba13-593f2513aa21.json new file mode 100644 index 00000000000000..965327f781241a --- /dev/null +++ b/change/@fluentui-react-input-74cef3fa-d688-4ac5-ba13-593f2513aa21.json @@ -0,0 +1,7 @@ +{ + "type": "prerelease", + "comment": "Update onChange event type", + "packageName": "@fluentui/react-input", + "email": "seanmonahan@microsoft.com", + "dependentChangeType": "patch" +} diff --git a/packages/react-spinbutton/Spec.md b/packages/react-spinbutton/Spec.md index ace451598bdfee..0e6dfc2a4f603a 100644 --- a/packages/react-spinbutton/Spec.md +++ b/packages/react-spinbutton/Spec.md @@ -84,7 +84,7 @@ The [WAI-ARIA spec for SpinButton](https://www.w3.org/TR/wai-aria-practices/#spi Fluent UI v8 (Fabric) ships a `SpinButton` control. This control supports directly typing values into the input field, stepping via step buttons, clamping values in a min-max range and suffixes on the displayed value. The control also supports variants like including an icon in the label, label positioning and styling overrides. `SpinButton` has RTL support and implements the correct ARIA attributes for proper accessibility support. -One interesting aspect of `SpinButton` in v8 is that the `value` prop (the prop that dictates the actual current value of the control) is a string but `min`, `max` and `step` are all numbers. This is in keeping with `` where the `value` attribute is also a string but it feels odd for a React component that works with numeric values to take in a string `value` prop. +One interesting aspect of `SpinButton` in v8 is that the `value` prop (the prop that dictates the actual current value of the control) is a string but `min`, `max` and `step` are all numbers. This is in keeping with `` where the `value` attribute is also a string but it feels odd for a React component that works with numeric values to take in a string `value` prop. As an aside, `` has an additional property called [`valueAsNumber`](https://developer.mozilla.org/en-US/docs/Web/API/HTMLInputElement) that is meant for retrieving the value as a `Number`. v8 supports an optional icon that appears before the label. As none of the other v8 input controls support adding an icon next to the label as part of their component APIs and how [labeling will work for vNext inputs is still an open question](https://github.com/microsoft/fluentui/issues/19627#issuecomment-1022646775) this feature will be omitted from this spec. Having an icon by the control can be achieved by aligning an icon with the control or perhaps by updating the vNext `Label` component to support icons. @@ -169,7 +169,8 @@ Inspecting a native number input with devtools shows that it implements the [spi ```tsx type SpinButtonChangeData = { - value: number; + value?: number; + displayValue?: string; }; const [value, setValue] = useState(2); @@ -187,40 +188,40 @@ type SpinButtonChangeData = { value: number; }; -type SpinButtonFormatter = (value: number) => string; -type SpinButtonParser = (formattedValue: string) => number; - -const [value, setValue] = useState(3); -const onControlledExampleChange = (_event, data: SpinButtonChangeData) => { - setValue(data.value); -}; +type FormatterFn = (value: number) => string; +type ParserFn = (formattedValue: string) => number; // Takes a number in and returns a formatted string // Ex: 12 becomes "12 pt" -const fontFormatter: SpinButtonFormatter = value => { +const fontFormatter: FormatterFn = value => { return `${value} pt`; }; // Takes a formatted string in and returns a number // Ex: "12 pt" becomes 12 -const fontParser: SpinButtonParser = formattedValue => { +const fontParser: ParserFn = formattedValue => { return parseFloat(formattedValue); }; -// Controlled +const [value, setValue] = useState(3); +const [displayValue, setDisplayValue] = useState(formatter(3));) + +const onControlledExampleChange = (_event, data: SpinButtonChangeData) => { + if (data.value !== undefined) { + setValue(data.value); + setDisplayValue(fontFormatter(data.value)); + } else if (data.displayValue !== undefined) { + const nextValue = fontParser(data.displayValue); + setValue(nextValue); + setDisplayValue(fontFormatter(nextValue)); + } +}; + - -// Uncontrolled - ``` ### Basic Example Implementation @@ -238,11 +239,7 @@ A very basic example to demonstrate how formatting will work in practice. - _**Public**_ ```tsx -const formatter = (value: number): string => { - return `${$value.toFixed(2)}`; -}; - -; + ``` - _**Internal**_ @@ -250,14 +247,14 @@ const formatter = (value: number): string => { ```tsx - - + + ``` - _**DOM** - how the component will be rendered as HTML elements_ -Note that `aria-valuetext` is conditionally rendered. In this case it is rendered because formatting it applied in this example with the `formatter` prop in JSX. +Note that `aria-valuetext` is conditionally rendered. In this case it is rendered because formatting is applied in this example by the `displayValue` prop in JSX. ```html @@ -289,7 +286,7 @@ _Describe what will need to be done to upgrade from the existing implementations 1. Ensure `value` prop is a number, not a string 2. Replace `onIncrement` and `onDecrement` callbacks with `onChange`. - 1. Increment/decrement logic can be handled by comparing `data.value` and `data.prevValue` in the `onChange` callback. + 1. Increment/decrement logic can be handled by comparing `data.value` and the current React/Redux/etc state value in the `onChange` callback. 3. Update `onChange` callback to handle new signature. 4. Remove `onValidate` callback. 5. Change ARIA props. @@ -302,13 +299,20 @@ Not applicable as v0 does not implement this component or one like it. ## Behaviors -`SpinButton`'s `value` prop is always a number, in contrast to the v8 implementation that gave `value` a string type. `SpinButton`s manipulate numeric values and making `value` a number aligns it with the other related props: `min`, `max` and `step`. `SpinButton`'s `value` is always displayed as a string which is determined with the `formatter()` function. By default this function stringifies `value` but it can do more sophisticated formatting like apply currency formatting. The inverse of `formatter()` is `parser()` which takes the stringified display value and converts it back to a number. The default implementation calls `parseFloat()` on the display value. `parser()` is called when a user directly types a value into `SpinButton`'s `` element so this value can be converted to a number for computation. If `parser()` returns a non-number value (e.g., `NaN`, `undefined`, `null`) `value` will not be changed. +`SpinButton`'s `value` prop is always a number, in contrast to the v8 implementation that gave `value` a string type. `SpinButton`s manipulate numeric values and making `value` a number aligns it with the other related props: `min`, `max` and `step`. `SpinButton`'s `value` is always displayed as a string which is determined by the `displayValue` prop or by stringifying `value` when `displayValue` is not provided (for uncontrolled `SpinButton`s `defaultValue` is stringified rather than `value`). + +`SpinButton` users may apply custom formatting to the component by providing a value to the `displayValue` prop. + +Values outside of the min/max bounds can be provided to `SpinButton` and they will be displayed. When stepping the value with the step buttons or hotkeys the value will not be stepped outside of the min/max bounds. If the value starts outside of the min/max bounds and is stepped it will update to a value outside of the bounds. Once the value is stepped inside the min/max bounds it will be clamped to this range. + +For example, assume a `SpinButton` with min=5, max=10, value=1 and step=1. Incrementing `value` with the stepper will increase it to 2. -`formatter()` and `parser()` allow users to hook into `SpinButton`'s display behavior without having to reimplement features like clamping `value` between `min` and `max` which is required when adding [custom suffixes in v8](https://codepen.io/seanms/pen/QWqpQWp). +Any value may be typing into the `` element of `SpinButton`. When typing into the input `SpinButton` enters an intermediate state where changes to the input are not applied to `value`. Instead the user must "commit" their edits to trigger a `value` update. This can be done two ways: -`SpinButton` does not allow values less than `min` or greater than `max` when these props are specified. When using the step buttons/arrow keys the value will not step outside the min/max bounds. If a value is typed into `` that is outside the min/max range it will be clamped to the relevant bound, that is values less than `min` will be set to `min` and values greater than `max` will be set to `max`. +1. Blur the input field (i.e., tab or click away) +2. Use one of `SpinButton`'s hotkeys to modify the value. -Aside from min/max range clamping `SpinButton` does not currently implement any input validation. +Aside from min/max range clamping behavior described above `SpinButton` does not currently implement any input validation. No error states are currently implemented. diff --git a/packages/react-spinbutton/etc/react-spinbutton.api.md b/packages/react-spinbutton/etc/react-spinbutton.api.md index 933b6a3b885511..af56b8736b20e7 100644 --- a/packages/react-spinbutton/etc/react-spinbutton.api.md +++ b/packages/react-spinbutton/etc/react-spinbutton.api.md @@ -18,12 +18,10 @@ export const renderSpinButton_unstable: (state: SpinButtonState) => JSX.Element; export const SpinButton: ForwardRefComponent; // @public (undocumented) -export type SpinButtonChangeData = { - value: number; -}; +export type SpinButtonBounds = 'none' | 'min' | 'max'; -// @public @deprecated (undocumented) -export const spinButtonClassName = "fui-SpinButton"; +// @public (undocumented) +export type SpinButtonChangeEvent = React_2.MouseEvent | React_2.ChangeEvent | React_2.FocusEvent | React_2.KeyboardEvent | React_2.WheelEvent; // @public (undocumented) export const spinButtonClassNames: SlotClassNames; @@ -32,37 +30,46 @@ export const spinButtonClassNames: SlotClassNames; export type SpinButtonCommons = { defaultValue: number; value: number; + displayValue: string; min: number; max: number; step: number; - formatter: SpinButtonFormatter; - parser: SpinButtonParser; - onChange: (event: React_2.SyntheticEvent, data: SpinButtonChangeData) => void; + stepPage: number; + onChange: (event: SpinButtonChangeEvent, data: SpinButtonOnChangeData) => void; precision: number; + appearance: 'outline' | 'underline' | 'filledDarker' | 'filledLighter'; + size: 'small' | 'medium'; + inputType: 'all' | 'spinners-only'; }; // @public (undocumented) -export type SpinButtonFormatter = (value: number) => string; - -// @public (undocumented) -export type SpinButtonParser = (formattedValue: string) => number; +export type SpinButtonOnChangeData = { + value?: number; + displayValue?: string; +}; // @public -export type SpinButtonProps = ComponentProps, 'input'> & Partial; +export type SpinButtonProps = Omit, 'input'>, 'onChange' | 'size'> & Partial; // @public (undocumented) export type SpinButtonSlots = { - root: NonNullable>; + root: NonNullable>; input: NonNullable>; - incrementControl: NonNullable>; - decrementControl: NonNullable>; + incrementButton: NonNullable>; + decrementButton: NonNullable>; }; +// @public (undocumented) +export type SpinButtonSpinState = 'rest' | 'up' | 'down'; + // @public -export type SpinButtonState = ComponentState> & Partial; +export type SpinButtonState = ComponentState & Partial & Pick & { + spinState: SpinButtonSpinState; + atBound: SpinButtonBounds; +}; // @public -export const useSpinButton_unstable: (props: SpinButtonProps, ref: React_2.Ref) => SpinButtonState; +export const useSpinButton_unstable: (props: SpinButtonProps, ref: React_2.Ref) => SpinButtonState; // @public export const useSpinButtonStyles_unstable: (state: SpinButtonState) => SpinButtonState; diff --git a/packages/react-spinbutton/package.json b/packages/react-spinbutton/package.json index 78328c534d8f8e..bc1a29bc71a5a6 100644 --- a/packages/react-spinbutton/package.json +++ b/packages/react-spinbutton/package.json @@ -33,6 +33,10 @@ }, "dependencies": { "@griffel/react": "1.0.0", + "@fluentui/keyboard-keys": "9.0.0-rc.4", + "@fluentui/react-icons": "^2.0.159-beta.10", + "@fluentui/react-input": "9.0.0-beta.5", + "@fluentui/react-theme": "9.0.0-rc.4", "@fluentui/react-utilities": "9.0.0-rc.5", "tslib": "^2.1.0" }, diff --git a/packages/react-spinbutton/src/components/SpinButton/SpinButton.test.tsx b/packages/react-spinbutton/src/components/SpinButton/SpinButton.test.tsx index 5e86604ae1a010..2cdee070f5f271 100644 --- a/packages/react-spinbutton/src/components/SpinButton/SpinButton.test.tsx +++ b/packages/react-spinbutton/src/components/SpinButton/SpinButton.test.tsx @@ -7,7 +7,8 @@ describe('SpinButton', () => { isConformant({ Component: SpinButton, displayName: 'SpinButton', - disabledTests: ['component-has-static-classnames-object'], // Will be enabled when component is implemented + primarySlot: 'input', + 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-spinbutton/src/components/SpinButton/SpinButton.types.ts b/packages/react-spinbutton/src/components/SpinButton/SpinButton.types.ts index 3c2a8cbb3d15d1..1b69f4ac4f88eb 100644 --- a/packages/react-spinbutton/src/components/SpinButton/SpinButton.types.ts +++ b/packages/react-spinbutton/src/components/SpinButton/SpinButton.types.ts @@ -1,5 +1,6 @@ import type { ComponentProps, ComponentState, Slot } from '@fluentui/react-utilities'; -import type * as React from 'react'; +// import { Input } from '@fluentui/react-input'; +import * as React from 'react'; export type SpinButtonSlots = { /** @@ -7,7 +8,7 @@ export type SpinButtonSlots = { * The root slot receives the `className` and `style` specified on the ``. * All other native props are applied to the primary slot: `input`. */ - root: NonNullable>; + root: NonNullable>; /** * Input that displays the current value and accepts direct input from the user. @@ -20,34 +21,22 @@ export type SpinButtonSlots = { /** * Renders the increment control. */ - incrementControl: NonNullable>; + incrementButton: NonNullable>; /** * Renders the decrement control. */ - decrementControl: NonNullable>; + decrementButton: NonNullable>; }; -export type SpinButtonChangeData = { - /** - * New value after the change. - * E.g., `1` - */ - value: number; -}; - -export type SpinButtonFormatter = (value: number) => string; -export type SpinButtonParser = (formattedValue: string) => number; - export type SpinButtonCommons = { /** * Initial value of the control (assumed to be valid). Updates to this prop will not be respected. * * Use this if you intend for the SpinButton to be an uncontrolled component which maintains its * own value. For a controlled component, use `value` instead. (Mutually exclusive with `value`.) - * @defaultvalue 0 */ - defaultValue: number; // Initial value of the + defaultValue: number; /** * Current value of the control (assumed to be valid). @@ -58,6 +47,18 @@ export type SpinButtonCommons = { */ value: number; + /** + * String representation of `value`. + * + * Use this when displaying the value to users as something other than a plain number. + * For example, when displaying currency values this might be "$1.00" when value is `1`. + * + * Only provide this if the SpinButton is a controlled component where you are maintaining its + * current state and passing updates based on change events. When SpinButton is used as an + * uncontrolled component this prop is ignored. + */ + displayValue: string; + /** * Min value of the control. If not provided, the control has no minimum value. */ @@ -71,29 +72,17 @@ export type SpinButtonCommons = { /** * Difference between two adjacent values of the control. * This value is used to calculate the precision of the input if no `precision` is given. - * The precision calculated this way will always be \>= 0. - * @defaultvalue 1 + * The precision calculated this way will always be greater than or equal 0. + * @default 1 */ step: number; /** - * Function used to format the displayed value in the component. - * This allows for things like: - * - Displaying the value as a monetary value: $1.00 - * - Displaying the value with a suffix: 12pt - * - * If this function is not supplied the default converts `value` to a string. + * Large difference between two values. This should be greater than `step` and is used + * when users hit the Page Up or Page Down keys. + * @default 1 */ - formatter: SpinButtonFormatter; - - /** - * Function used to parse a formatted value back into a number. - * This works in conjunction with `formatter` and is its inverse (i.e., `formatter` turns - * 1 to $1.00 and `parser` turns $1.00 to 1). - * - * If this function is not supplied the default calls `parseFloat()` on the formatted value. - */ - parser: SpinButtonParser; + stepPage: number; /** * Callback for when the committed value changes. @@ -102,7 +91,7 @@ export type SpinButtonCommons = { * - User *commits* edits to the input text by focusing away (blurring) or pressing enter. * Note that this is NOT called for every key press while the user is editing. */ - onChange: (event: React.SyntheticEvent, data: SpinButtonChangeData) => void; + onChange: (event: SpinButtonChangeEvent, data: SpinButtonOnChangeData) => void; /** * How many decimal places the value should be rounded to. @@ -111,14 +100,65 @@ export type SpinButtonCommons = { * step = 0.0089, precision = 4. step = 300, precision = 2. step = 23.00, precision = 2. */ precision: number; + + /** + * Controls the colors and borders of the input. + * @default 'outline' + */ + appearance: 'outline' | 'underline' | 'filledDarker' | 'filledLighter'; + + /** + * Size of the input. + * @default 'medium' + */ + size: 'small' | 'medium'; + + /** + * Controls which input types update the value. + * + * - 'all': both the spinner buttons and input field are enabled. + * - 'spinners-only': only the spinner buttons are enabled. + * @default all + */ + inputType: 'all' | 'spinners-only'; }; /** * SpinButton Props */ -export type SpinButtonProps = ComponentProps, 'input'> & Partial; +export type SpinButtonProps = Omit, 'input'>, 'onChange' | 'size'> & + Partial; /** * State used in rendering SpinButton */ -export type SpinButtonState = ComponentState> & Partial; +export type SpinButtonState = ComponentState & + Partial & + Pick & { + /** + * State used to track which direction, if any, SpinButton is currently spinning. + * @default 'rest' + */ + spinState: SpinButtonSpinState; + + /** + * State used to track if the value is at the range bounds of [min-max]. + * @default 'none' + */ + atBound: SpinButtonBounds; + }; + +export type SpinButtonChangeEvent = + | React.MouseEvent + | React.ChangeEvent + | React.FocusEvent + | React.KeyboardEvent + | React.WheelEvent; + +export type SpinButtonOnChangeData = { + value?: number; + displayValue?: string; +}; + +export type SpinButtonSpinState = 'rest' | 'up' | 'down'; +export type SpinButtonBounds = 'none' | 'min' | 'max'; diff --git a/packages/react-spinbutton/src/components/SpinButton/__snapshots__/SpinButton.test.tsx.snap b/packages/react-spinbutton/src/components/SpinButton/__snapshots__/SpinButton.test.tsx.snap index b13d988f497fc9..fa9e7dec6c624f 100644 --- a/packages/react-spinbutton/src/components/SpinButton/__snapshots__/SpinButton.test.tsx.snap +++ b/packages/react-spinbutton/src/components/SpinButton/__snapshots__/SpinButton.test.tsx.snap @@ -2,8 +2,53 @@ exports[`SpinButton renders a default state 1`] = `
-
+ + + + +
`; diff --git a/packages/react-spinbutton/src/components/SpinButton/renderSpinButton.tsx b/packages/react-spinbutton/src/components/SpinButton/renderSpinButton.tsx index 1b1345cc92e096..7e3f6fe41d3afb 100644 --- a/packages/react-spinbutton/src/components/SpinButton/renderSpinButton.tsx +++ b/packages/react-spinbutton/src/components/SpinButton/renderSpinButton.tsx @@ -6,8 +6,35 @@ import type { SpinButtonState, SpinButtonSlots } from './SpinButton.types'; * Render the final JSX of SpinButton */ export const renderSpinButton_unstable = (state: SpinButtonState) => { + // Leaving this here for now. + // This is the approach using react-input's Input component. + // It has some Typescript problems and feels hacky. + // const { slots, slotProps } = getSlots(state); + + // const { contentAfter, ...otherInputSlotProps } = slotProps.input as SpinButtonSlots['input']; + // const inputContentAfter = { + // ...contentAfter, + // children: ( + // <> + // + // + // + // ), + // }; + + // return ( + // + // + // + // ); + const { slots, slotProps } = getSlots(state); - // TODO Add additional slots in the appropriate place - return ; + return ( + + + + + + ); }; diff --git a/packages/react-spinbutton/src/components/SpinButton/useSpinButton.ts b/packages/react-spinbutton/src/components/SpinButton/useSpinButton.ts deleted file mode 100644 index 993f12e85c52ab..00000000000000 --- a/packages/react-spinbutton/src/components/SpinButton/useSpinButton.ts +++ /dev/null @@ -1,34 +0,0 @@ -import * as React from 'react'; -import { getNativeElementProps } from '@fluentui/react-utilities'; -import type { SpinButtonProps, SpinButtonState } from './SpinButton.types'; - -/** - * Create the state required to render SpinButton. - * - * The returned state can be modified with hooks such as useSpinButtonStyles_unstable, - * before being passed to renderSpinButton_unstable. - * - * @param props - props from this instance of SpinButton - * @param ref - reference to root HTMLElement of SpinButton - */ -export const useSpinButton_unstable = (props: SpinButtonProps, ref: React.Ref): SpinButtonState => { - return { - // TODO add appropriate props/defaults - components: { - // TODO add slot types here if needed (div is the default) - root: 'div', - input: 'input', - incrementControl: 'button', - decrementControl: 'button', - }, - // TODO add appropriate slots, for example: - // mySlot: resolveShorthand(props.mySlot), - root: getNativeElementProps('div', { - ref, - ...props, - }), - input: getNativeElementProps('input', {}), - incrementControl: getNativeElementProps('button', {}), - decrementControl: getNativeElementProps('button', {}), - }; -}; diff --git a/packages/react-spinbutton/src/components/SpinButton/useSpinButton.tsx b/packages/react-spinbutton/src/components/SpinButton/useSpinButton.tsx new file mode 100644 index 00000000000000..354fc8ecd4040d --- /dev/null +++ b/packages/react-spinbutton/src/components/SpinButton/useSpinButton.tsx @@ -0,0 +1,318 @@ +import * as React from 'react'; +import { + getPartitionedNativeProps, + resolveShorthand, + useControllableState, + useMergedEventCallbacks, + useTimeout, +} from '@fluentui/react-utilities'; +import * as Keys from '@fluentui/keyboard-keys'; +import { + SpinButtonProps, + SpinButtonState, + SpinButtonSpinState, + SpinButtonChangeEvent, + SpinButtonBounds, +} from './SpinButton.types'; +import { calculatePrecision, precisionRound, getBound, clampWhenInRange } from '../../utils/index'; +import { ChevronUp16Regular, ChevronDown16Regular } from '@fluentui/react-icons'; + +type InternalState = { + value: number; + spinState: SpinButtonSpinState; + spinTime: number; + spinDelay: number; + previousTextValue?: string; +}; + +const DEFAULT_SPIN_DELAY_MS = 150; +const MIN_SPIN_DELAY_MS = 80; +const MAX_SPIN_TIME_MS = 1000; + +// This is here to give an ease the mouse held down case. +// Exact easing it to be defined. Once it is we'll likely +// pull this out into a util function in the SpinButton package. +const lerp = (start: number, end: number, percent: number): number => start + (end - start) * percent; + +/** + * Create the state required to render SpinButton. + * + * The returned state can be modified with hooks such as useSpinButtonStyles_unstable, + * before being passed to renderSpinButton_unstable. + * + * @param props - props from this instance of SpinButton + * @param ref - reference to root HTMLElement of SpinButton + */ +export const useSpinButton_unstable = (props: SpinButtonProps, ref: React.Ref): SpinButtonState => { + const nativeProps = getPartitionedNativeProps({ + props, + primarySlotTagName: 'input', + excludedPropNames: ['onChange', 'size'], + }); + + const { + value, + displayValue, + defaultValue, + min, + max, + step = 1, + stepPage = 1, + precision: precisionFromProps, + onChange, + size = 'medium', + appearance = 'outline', + root, + input, + incrementButton, + decrementButton, + inputType = 'all', + } = props; + + const precision = React.useMemo(() => { + return precisionFromProps ?? Math.max(calculatePrecision(step), 0); + }, [precisionFromProps, step]); + + const [currentValue, setCurrentValue] = useControllableState({ + state: value, + defaultState: defaultValue, + initialState: 0, + }); + const [textValue, setTextValue] = React.useState( + value !== undefined && displayValue !== undefined ? displayValue : String(currentValue), + ); + const [spinState, setSpinState] = React.useState('rest'); + const [atBound, setAtBound] = React.useState('none'); + + const internalState = React.useRef({ + value: currentValue, + spinState, + spinTime: 0, + spinDelay: DEFAULT_SPIN_DELAY_MS, + }); + + const state: SpinButtonState = { + size, + appearance, + spinState, + atBound, + + components: { + root: 'span', + input: 'input', + incrementButton: 'button', + decrementButton: 'button', + }, + root: resolveShorthand(root, { + required: true, + defaultProps: nativeProps.root, + }), + input: resolveShorthand(input, { + required: true, + defaultProps: { + ref, + autoComplete: 'off', + role: 'spinbutton', + appearance: appearance, + ...nativeProps.primary, + }, + }), + incrementButton: resolveShorthand(incrementButton, { + required: true, + defaultProps: { + tabIndex: -1, + children: , + disabled: nativeProps.primary.disabled, + }, + }), + decrementButton: resolveShorthand(decrementButton, { + required: true, + defaultProps: { + tabIndex: -1, + children: , + disabled: nativeProps.primary.disabled, + }, + }), + }; + + const [setWheelTimeout] = useTimeout(); + const [setStepTimeout, clearStepTimeout] = useTimeout(); + + React.useEffect(() => { + let newTextValue; + if (value !== undefined) { + const roundedValue = precisionRound(value, precision); + newTextValue = displayValue ?? String(roundedValue); + internalState.current.value = roundedValue; + setAtBound(getBound(roundedValue, min, max)); + } else { + newTextValue = String(precisionRound(currentValue, precision)); + internalState.current.value = currentValue; + } + setTextValue(newTextValue); + }, [value, displayValue, currentValue, precision, setAtBound, min, max]); + + const handleWheel = (e: React.WheelEvent) => { + if (props.disabled) { + return; + } + + const spinDir = e.deltaY > 0 ? 'down' : 'up'; + // TODO: Might want to debounce this + setSpinState(spinDir); + stepValue(e, spinDir); + + setWheelTimeout(() => { + setSpinState('rest'); + }, 250); + }; + + const handleInputChange = (e: React.ChangeEvent) => { + if (inputType === 'spinners-only') { + return; + } + + if (!internalState.current.previousTextValue) { + internalState.current.previousTextValue = textValue; + } + + const newValue = e.target.value; + setTextValue(newValue); + }; + + const stepValue = (e: SpinButtonChangeEvent, direction: 'up' | 'down' | 'upPage' | 'downPage') => { + const dir = direction === 'up' || direction === 'upPage' ? 1 : -1; + const stepSize = direction === 'upPage' || direction === 'downPage' ? stepPage : step; + const val = internalState.current.value; + + let newValue = val + stepSize * dir; + if (!Number.isNaN(newValue)) { + newValue = clampWhenInRange(val, newValue, min, max); + } + + commit(e, newValue); + + if (internalState.current.spinState !== 'rest') { + setStepTimeout(() => { + // Ease the step speed a bit + internalState.current.spinTime += internalState.current.spinDelay; + internalState.current.spinDelay = lerp( + DEFAULT_SPIN_DELAY_MS, + MIN_SPIN_DELAY_MS, + internalState.current.spinTime / MAX_SPIN_TIME_MS, + ); + stepValue(e, direction); + }, internalState.current.spinDelay); + } + }; + + const handleIncrementMouseDown = (e: React.MouseEvent) => { + internalState.current.spinState = 'up'; + stepValue(e, 'up'); + }; + + const handleDecrementMouseDown = (e: React.MouseEvent) => { + internalState.current.spinState = 'down'; + stepValue(e, 'down'); + }; + + const handleStepMouseUpOrLeave = (e: React.MouseEvent) => { + clearStepTimeout(); + internalState.current.spinState = 'rest'; + internalState.current.spinDelay = DEFAULT_SPIN_DELAY_MS; + internalState.current.spinTime = 0; + }; + + const handleBlur = (e: React.FocusEvent) => { + commit(e, currentValue, textValue); + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === Keys.ArrowUp) { + stepValue(e, 'up'); + setSpinState('up'); + } else if (e.key === Keys.ArrowDown) { + stepValue(e, 'down'); + setSpinState('down'); + } else if (e.key === Keys.PageUp) { + e.preventDefault(); + stepValue(e, 'upPage'); + setSpinState('up'); + } else if (e.key === Keys.PageDown) { + e.preventDefault(); + stepValue(e, 'downPage'); + setSpinState('down'); + } else if (!e.shiftKey && e.key === Keys.Home && min !== undefined) { + commit(e, min); + setSpinState('down'); + } else if (!e.shiftKey && e.key === Keys.End && max !== undefined) { + commit(e, max); + setSpinState('up'); + } else if (e.key === Keys.Enter) { + commit(e, currentValue, textValue); + setSpinState('rest'); + } else if (e.key === Keys.Escape) { + if (internalState.current.previousTextValue) { + setTextValue(internalState.current.previousTextValue); + internalState.current.previousTextValue = undefined; + } + setSpinState('rest'); + } else { + setSpinState('rest'); + } + }; + + const handleKeyUp = (e: React.KeyboardEvent) => { + setSpinState('rest'); + }; + + const commit = (e: SpinButtonChangeEvent, newValue?: number, newDisplayValue?: string) => { + const valueChanged = newValue !== undefined && currentValue !== newValue; + const displayValueChanged = newDisplayValue !== undefined; + + let roundedValue; + if (valueChanged) { + roundedValue = precisionRound(newValue!, precision); + setCurrentValue(roundedValue); + internalState.current.value = roundedValue; + setAtBound(getBound(roundedValue, min, max)); + } + + if (valueChanged || displayValueChanged) { + onChange?.(e, { value: roundedValue, displayValue: newDisplayValue }); + } + }; + state.root.onWheel = useMergedEventCallbacks(state.root.onWheel, handleWheel); + + state.input.value = textValue; + state.input['aria-valuemin'] = min; + state.input['aria-valuemax'] = max; + state.input['aria-valuenow'] = currentValue; + state.input['aria-valuetext'] = displayValue ?? undefined; + state.input.onChange = useMergedEventCallbacks(state.input.onChange, handleInputChange); + state.input.onBlur = useMergedEventCallbacks(state.input.onBlur, handleBlur); + state.input.onKeyDown = useMergedEventCallbacks(state.input.onKeyDown, handleKeyDown); + state.input.onKeyUp = useMergedEventCallbacks(state.input.onKeyUp, handleKeyUp); + + state.incrementButton.onMouseDown = useMergedEventCallbacks( + handleIncrementMouseDown, + state.incrementButton.onMouseDown, + ); + state.incrementButton.onMouseUp = useMergedEventCallbacks(state.incrementButton.onMouseUp, handleStepMouseUpOrLeave); + state.incrementButton.onMouseLeave = useMergedEventCallbacks( + state.incrementButton.onMouseLeave, + handleStepMouseUpOrLeave, + ); + + state.decrementButton.onMouseDown = useMergedEventCallbacks( + handleDecrementMouseDown, + state.decrementButton.onMouseDown, + ); + state.decrementButton.onMouseUp = useMergedEventCallbacks(state.decrementButton.onMouseUp, handleStepMouseUpOrLeave); + state.decrementButton.onMouseLeave = useMergedEventCallbacks( + state.decrementButton.onMouseLeave, + handleStepMouseUpOrLeave, + ); + + return state; +}; diff --git a/packages/react-spinbutton/src/components/SpinButton/useSpinButtonStyles.ts b/packages/react-spinbutton/src/components/SpinButton/useSpinButtonStyles.ts index 08b79e0646db95..421378923d2eaa 100644 --- a/packages/react-spinbutton/src/components/SpinButton/useSpinButtonStyles.ts +++ b/packages/react-spinbutton/src/components/SpinButton/useSpinButtonStyles.ts @@ -1,38 +1,441 @@ import { SlotClassNames } from '@fluentui/react-utilities'; -import { makeStyles, mergeClasses } from '@griffel/react'; +import { makeStyles, mergeClasses, shorthands } from '@griffel/react'; import type { SpinButtonSlots, SpinButtonState } from './SpinButton.types'; +import { tokens } from '@fluentui/react-theme'; +import { useInputStyles_unstable } from '@fluentui/react-input'; -/** - * @deprecated Use `spinButtonClassNames.root` instead. - */ -export const spinButtonClassName = 'fui-SpinButton'; export const spinButtonClassNames: SlotClassNames = { root: 'fui-SpinButton', input: 'fui-SpinButton__input', - incrementControl: 'fui-SpinButton__incrementControl', - decrementControl: 'fui-SpinButton__decrementControl', + incrementButton: 'fui-SpinButton__incrementButton', + decrementButton: 'fui-SpinButton__decrementButton', }; -/** - * Styles for the root slot - */ -const useStyles = makeStyles({ - root: { - // TODO Add default styles for the root element +const spinButtonExtraClassNames = { + buttonActive: 'fui-SpinButton__button_active', +}; + +// TODO(sharing) use theme values once available +const horizontalSpacing = { + xs: '4px', +}; + +const useRootStyles = makeStyles({ + base: { + display: 'inline-grid', + gridTemplateColumns: `1fr 24px`, + gridTemplateRows: '1fr 1fr', + columnGap: horizontalSpacing.xs, + rowGap: 0, + paddingRight: 0, + position: 'relative', + // Remove the border styles from react-input + ...shorthands.border('0'), + isolation: 'isolate', + + // Apply border styles on the ::before pseudo element. + // We cannot use ::after since react-input uses that + // for the selector styles. + // Using the pseudo element allows us to place the border + // above content in the component which ensures the buttons + // line up visually with the border as expected. Without this + // there is a bit of a gap which can become very noticeable + // at high zoom or when OS zoom levels are not divisible by 2 + // (e.g., 150% on Windows in Firefox) + // This is most noticeable on the "outline" appearance which is + // also the default so it feels worth the extra ceremony to get right. + '::before': { + content: '""', + boxSizing: 'border-box', + position: 'absolute', + top: '-1px', + right: '-1px', + bottom: '-1px', + left: '-1px', + ...shorthands.borderRadius(tokens.borderRadiusMedium), + pointerEvents: 'none', + zIndex: 10, + }, + + '::after': { + zIndex: 20, + }, + }, + + outline: { + '::before': { + ...shorthands.border('1px', 'solid', tokens.colorNeutralStroke1), + borderBottomColor: tokens.colorNeutralStrokeAccessible, + }, + }, + + outlineInteractive: { + ':hover': { + '::before': { + ...shorthands.borderColor(tokens.colorNeutralStroke1Hover), + borderBottomColor: tokens.colorNeutralStrokeAccessibleHover, + }, + }, + // DO NOT add a space between the selectors! It changes the behavior of make-styles. + ':active,:focus-within': { + '::before': { + ...shorthands.borderColor(tokens.colorNeutralStroke1Pressed), + borderBottomColor: tokens.colorNeutralStrokeAccessiblePressed, + }, + }, + }, + + underline: { + '::before': { + ...shorthands.borderRadius(0), // corners look strange if rounded + ...shorthands.borderBottom('1px', 'solid', tokens.colorNeutralStrokeAccessible), + }, + }, + + underlineInteractive: { + ':hover': { + '::before': { + borderBottomColor: tokens.colorNeutralStrokeAccessibleHover, + }, + }, + // DO NOT add a space between the selectors! It changes the behavior of make-styles. + ':active,:focus-within': { + '::before': { + borderBottomColor: tokens.colorNeutralStrokeAccessiblePressed, + }, + }, }, - // TODO add additional classes for different states and/or slots + filled: { + '::before': { + ...shorthands.border('1px', 'solid', tokens.colorTransparentStroke), + }, + }, + + filledInteractive: { + // DO NOT add a space between the selectors! It changes the behavior of make-styles. + ':hover,:focus-within': { + '::before': { + // also handles pressed border color (:active) + ...shorthands.borderColor(tokens.colorTransparentStrokeInteractive), + }, + }, + }, + + disabled: { + '::before': { + ...shorthands.border('1px', 'solid', tokens.colorNeutralStrokeDisabled), + ...shorthands.borderRadius(tokens.borderRadiusMedium), // because underline doesn't usually have a radius + '@media (forced-colors: active)': { + ...shorthands.borderColor('GrayText'), + }, + }, + }, +}); + +const useInputStyles = makeStyles({ + base: { + gridColumnStart: '1', + gridColumnEnd: '2', + gridRowStart: '1', + gridRowEnd: '3', + ...shorthands.padding(0), + }, +}); + +const useButtonStyles = makeStyles({ + base: { + display: 'inline-flex', + width: '24px', + alignItems: 'center', + justifyContent: 'center', + ...shorthands.border(0), + position: 'absolute', + + ':disabled': { + cursor: 'not-allowed', + }, + height: 'calc(100% + 1px)', // handle the 1px negative poisitioning + }, + + incrementButton: { + gridColumnStart: '2', + gridColumnEnd: '3', + gridRowStart: '1', + gridRowEnd: '2', + ...shorthands.borderRadius(0, tokens.borderRadiusMedium, 0, 0), + top: '-1px', + right: '-1px', + }, + + // TODO: revisit these padding numbers for aligning the icon. + // Padding values aren't perfect. + // The icon doesn't align perfectly with the Figma designs. + // It's set in a 16x16px square but the artwork is inset from that + // so I've had to compute the numbers by handle. + // Additionally the design uses fractional values so these are + // rounded to the nearest integer. + incrementButtonSmall: { + ...shorthands.padding('3px', '5px', '0px', '5px'), + }, + + incrementButtonMedium: { + ...shorthands.padding('4px', '5px', '1px', '5px'), + }, + + decrementButton: { + gridColumnStart: '2', + gridColumnEnd: '3', + gridRowStart: '2', + gridRowEnd: '3', + ...shorthands.borderRadius(0, 0, tokens.borderRadiusMedium, 0), + right: '-1px', + bottom: '-1px', + }, + + decrementButtonSmall: { + ...shorthands.padding('0px', '5px', '3px', '5px'), + }, + + decrementButtonMedium: { + ...shorthands.padding('1px', '5px', '4px', '5px'), + }, + + outline: { + backgroundColor: 'transparent', + color: tokens.colorNeutralForeground3, + ':enabled': { + ':hover': { + color: tokens.colorNeutralForeground3Hover, + backgroundColor: tokens.colorSubtleBackgroundHover, + }, + ':active': { + color: tokens.colorNeutralForeground3Pressed, + backgroundColor: tokens.colorSubtleBackgroundPressed, + }, + [`&.${spinButtonExtraClassNames.buttonActive}`]: { + color: tokens.colorNeutralForeground3Pressed, + backgroundColor: tokens.colorSubtleBackgroundPressed, + }, + }, + ':disabled': { + color: tokens.colorNeutralForegroundDisabled, + }, + }, + + // These designs are not yet finalized so this is copy-paste for the "outline" + // appearance. + underline: { + backgroundColor: 'transparent', + color: tokens.colorNeutralForeground3, + ...shorthands.borderRadius(0), + ':enabled': { + ':hover': { + color: tokens.colorNeutralForeground3Hover, + backgroundColor: tokens.colorSubtleBackgroundHover, + }, + [`:active,&.${spinButtonExtraClassNames.buttonActive}`]: { + color: tokens.colorNeutralForeground3Pressed, + backgroundColor: tokens.colorSubtleBackgroundPressed, + }, + }, + ':disabled': { + color: tokens.colorNeutralForegroundDisabled, + }, + }, + filledDarker: { + backgroundColor: 'transparent', + color: tokens.colorNeutralForeground3, + + ':enabled': { + ':hover': { + color: tokens.colorNeutralForeground3BrandHover, + backgroundColor: tokens.colorSubtleBackgroundHover, + }, + [`:active,&.${spinButtonExtraClassNames.buttonActive}`]: { + color: tokens.colorNeutralForeground3BrandPressed, + backgroundColor: tokens.colorSubtleBackgroundPressed, + }, + }, + ':disabled': { + color: tokens.colorNeutralForegroundDisabled, + }, + }, + filledLighter: { + color: tokens.colorNeutralForeground3, + backgroundColor: 'transparent', + + ':hover': { + color: tokens.colorNeutralForeground3BrandHover, + backgroundColor: tokens.colorSubtleBackgroundHover, + }, + [`:active,&.${spinButtonExtraClassNames.buttonActive}`]: { + color: tokens.colorNeutralForeground3BrandPressed, + backgroundColor: tokens.colorSubtleBackgroundPressed, + }, + ':disabled': { + color: tokens.colorNeutralForegroundDisabled, + }, + }, + + filledIncrement: { + clipPath: `inset(1px 1px 0 0 round 0 ${tokens.borderRadiusMedium} 0 0)`, + }, + + filledDecrement: { + clipPath: `inset(0 1px 1px 0 round 0 0 ${tokens.borderRadiusMedium} 0)`, + }, +}); + +// Cannot just disable button as they need to remain +// exposed to ATs like screen readers. +const useButtonDisabledStyles = makeStyles({ + base: { + cursor: 'not-allowed', + }, + + outline: { + color: tokens.colorNeutralForegroundDisabled, + ':enabled': { + ':hover': { + color: tokens.colorNeutralForegroundDisabled, + backgroundColor: 'transparent', + }, + ':active': { + color: tokens.colorNeutralForegroundDisabled, + backgroundColor: 'transparent', + }, + [`&.${spinButtonExtraClassNames.buttonActive}`]: { + color: tokens.colorNeutralForegroundDisabled, + backgroundColor: 'transparent', + }, + }, + }, + + underline: { + color: tokens.colorNeutralForegroundDisabled, + ':enabled': { + ':hover': { + color: tokens.colorNeutralForegroundDisabled, + backgroundColor: 'transparent', + }, + ':active': { + color: tokens.colorNeutralForegroundDisabled, + backgroundColor: 'transparent', + }, + [`&.${spinButtonExtraClassNames.buttonActive}`]: { + color: tokens.colorNeutralForegroundDisabled, + backgroundColor: 'transparent', + }, + }, + }, + + filledDarker: { + color: tokens.colorNeutralForegroundDisabled, + ':enabled': { + ':hover': { + color: tokens.colorNeutralForegroundDisabled, + backgroundColor: 'transparent', + }, + ':active': { + color: tokens.colorNeutralForegroundDisabled, + backgroundColor: 'transparent', + }, + [`&.${spinButtonExtraClassNames.buttonActive}`]: { + color: tokens.colorNeutralForegroundDisabled, + backgroundColor: 'transparent', + }, + }, + }, + + filledLighter: { + color: tokens.colorNeutralForegroundDisabled, + ':enabled': { + ':hover': { + color: tokens.colorNeutralForegroundDisabled, + backgroundColor: 'transparent', + }, + ':active': { + color: tokens.colorNeutralForegroundDisabled, + backgroundColor: 'transparent', + }, + [`&.${spinButtonExtraClassNames.buttonActive}`]: { + color: tokens.colorNeutralForegroundDisabled, + backgroundColor: 'transparent', + }, + }, + }, }); /** * Apply styling to the SpinButton slots based on the state */ export const useSpinButtonStyles_unstable = (state: SpinButtonState): SpinButtonState => { - const styles = useStyles(); - state.root.className = mergeClasses(spinButtonClassNames.root, styles.root, state.root.className); + const { appearance, atBound, size } = state; + const disabled = state.input.disabled; + const filled = appearance.startsWith('filled'); + + const rootStyles = useRootStyles(); + const buttonStyles = useButtonStyles(); + const buttonDisabledStyles = useButtonDisabledStyles(); + const inputStyles = useInputStyles(); + + // Grab the root className here so we can be sure to merge is last + const rootClassName = state.root.className; + state.root.className = undefined; + // Reuse react-input's styles without re-using the Input component. + useInputStyles_unstable({ + size, + appearance, + input: state.input, + root: state.root, + components: { + root: 'span', + input: 'input', + contentBefore: 'span', + contentAfter: 'span', + }, + }); + + state.root.className = mergeClasses( + state.root.className, + spinButtonClassNames.root, + rootStyles.base, + appearance === 'outline' && rootStyles.outline, + appearance === 'underline' && rootStyles.underline, + !disabled && appearance === 'outline' && rootStyles.outlineInteractive, + !disabled && appearance === 'underline' && rootStyles.underlineInteractive, + !disabled && filled && rootStyles.filledInteractive, + disabled && rootStyles.disabled, + rootClassName, + ); + + state.incrementButton.className = mergeClasses( + spinButtonClassNames.incrementButton, + state.spinState === 'up' && `${spinButtonExtraClassNames.buttonActive}`, + buttonStyles.base, + buttonStyles.incrementButton, + buttonStyles[appearance], + filled && buttonStyles.filledIncrement, + size === 'small' ? buttonStyles.incrementButtonSmall : buttonStyles.incrementButtonMedium, + atBound === 'max' && buttonDisabledStyles.base, + atBound === 'max' && buttonDisabledStyles[appearance], + state.incrementButton.className, + ); + state.decrementButton.className = mergeClasses( + spinButtonClassNames.decrementButton, + state.spinState === 'down' && `${spinButtonExtraClassNames.buttonActive}`, + buttonStyles.base, + buttonStyles.decrementButton, + buttonStyles[appearance], + filled && buttonStyles.filledDecrement, + size === 'small' ? buttonStyles.decrementButtonSmall : buttonStyles.decrementButtonMedium, + atBound === 'min' && buttonDisabledStyles.base, + atBound === 'min' && buttonDisabledStyles[appearance], + state.decrementButton.className, + ); - // TODO Add class names to slots, for example: - // state.mySlot.className = mergeClasses(styles.mySlot, state.mySlot.className); + state.input.className = mergeClasses(spinButtonClassNames.input, state.input.className, inputStyles.base); return state; }; diff --git a/packages/react-spinbutton/src/index.ts b/packages/react-spinbutton/src/index.ts index 281544ad42a61c..18c69f4b2af87a 100644 --- a/packages/react-spinbutton/src/index.ts +++ b/packages/react-spinbutton/src/index.ts @@ -1,18 +1,17 @@ export { SpinButton, renderSpinButton_unstable, - /* eslint-disable-next-line deprecation/deprecation */ - spinButtonClassName, spinButtonClassNames, useSpinButtonStyles_unstable, useSpinButton_unstable, } from './SpinButton'; export type { - SpinButtonChangeData, + SpinButtonOnChangeData, + SpinButtonChangeEvent, SpinButtonCommons, - SpinButtonFormatter, - SpinButtonParser, SpinButtonProps, SpinButtonSlots, SpinButtonState, + SpinButtonSpinState, + SpinButtonBounds, } from './SpinButton'; diff --git a/packages/react-spinbutton/src/stories/SpinButton.stories.tsx b/packages/react-spinbutton/src/stories/SpinButton.stories.tsx index adabd972d81dd9..7b5af123c33467 100644 --- a/packages/react-spinbutton/src/stories/SpinButton.stories.tsx +++ b/packages/react-spinbutton/src/stories/SpinButton.stories.tsx @@ -1,11 +1,36 @@ +import * as React from 'react'; +import { Meta } from '@storybook/react'; import { SpinButton } from '../index'; import descriptionMd from './SpinButtonDescription.md'; import bestPracticesMd from './SpinButtonBestPractices.md'; -export { Default } from './SpinButtonDefault.stories'; +export { Controlled } from './SpinButtonControlled.stories'; +export { Uncontrolled } from './SpinButtonUncontrolled.stories'; +export { Size } from './SpinButtonSize.stories'; +export { Appearance } from './SpinButtonAppearance.stories'; +export { RTL } from './SpinButtonRTL.stories'; +export { Disabled } from './SpinButtonDisabled.stories'; +export { DisplayValue } from './SpinButtonDisplayValue.stories'; +export { Step } from './SpinButtonStep.stories'; +export { InputType } from './SpinButtonInputType.stories'; +import { makeStyles, mergeClasses, shorthands } from '@griffel/react'; -export default { +const useDecoratorStyles = makeStyles({ + base: { + display: 'flex', + flexDirection: 'column', + backgroundColor: 'transparent', + rowGap: '2px', + }, + + docsViewMode: { + maxWidth: '500px', + ...shorthands.padding('24px'), + }, +}); + +const meta: Meta = { title: 'Components/SpinButton', component: SpinButton, parameters: { @@ -15,4 +40,19 @@ export default { }, }, }, + decorators: [ + (Story, context) => { + const decoratorStyles = useDecoratorStyles(); + + const className = mergeClasses(decoratorStyles.base, context.viewMode === 'docs' && decoratorStyles.docsViewMode); + + return ( +
+ +
+ ); + }, + ], }; + +export default meta; diff --git a/packages/react-spinbutton/src/stories/SpinButtonAppearance.stories.tsx b/packages/react-spinbutton/src/stories/SpinButtonAppearance.stories.tsx new file mode 100644 index 00000000000000..38967ea9590b03 --- /dev/null +++ b/packages/react-spinbutton/src/stories/SpinButtonAppearance.stories.tsx @@ -0,0 +1,67 @@ +import * as React from 'react'; +import { SpinButton } from '../index'; +import { Label } from '@fluentui/react-label'; +import { useId } from '@fluentui/react-utilities'; +import { makeStyles } from '@griffel/react'; +import { tokens } from '@fluentui/react-theme'; + +const useStyles = makeStyles({ + root: { + display: 'flex', + flexDirection: 'column', + + [`> * + *`]: { + marginTop: '10px', + }, + + [`> div`]: { + display: 'flex', + flexDirection: 'column', + rowGap: '2px', + }, + }, + // ideally should match doc site, #faf9f8 + filledLighter: { backgroundColor: tokens.colorNeutralBackground2 }, + filledDarker: { backgroundColor: tokens.colorNeutralBackground1 }, +}); + +export const Appearance = () => { + const styles = useStyles(); + + const outlineId = useId('outline-id'); + const underlineId = useId('underline-id'); + const filledLighterId = useId('filledLighter-id'); + const filledDarkerId = useId('filledDarker-id'); + + return ( +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+ ); +}; + +Appearance.parameters = { + docs: { + description: { + story: `SpinButton can have different appearances.`, + }, + }, +}; diff --git a/packages/react-spinbutton/src/stories/SpinButtonControlled.stories.tsx b/packages/react-spinbutton/src/stories/SpinButtonControlled.stories.tsx new file mode 100644 index 00000000000000..58cd58b57536e1 --- /dev/null +++ b/packages/react-spinbutton/src/stories/SpinButtonControlled.stories.tsx @@ -0,0 +1,43 @@ +import * as React from 'react'; +import { SpinButton, SpinButtonProps } from '../index'; +import { Label } from '@fluentui/react-label'; +import { useId } from '@fluentui/react-utilities'; + +export const Controlled = () => { + const id = useId(); + + const [spinButtonValue, setSpinButtonValue] = React.useState(10); + + const onSpinButtonChange: SpinButtonProps['onChange'] = React.useCallback( + (_ev, data) => { + console.log('onSpinButtonChange', data.value, data.displayValue); + if (data.value !== undefined) { + setSpinButtonValue(data.value); + } else if (data.displayValue !== undefined) { + const newValue = parseFloat(data.displayValue); + if (!Number.isNaN(newValue)) { + setSpinButtonValue(newValue); + } else { + console.error(`"${data.displayValue}" is not a valid value.`); + } + } + }, + [setSpinButtonValue], + ); + + return ( + <> + + + + ); +}; + +Controlled.parameters = { + docs: { + description: { + story: `SpinButton can be a controlled input where the value and, optionally, the display value + are stored in state and updated with \`onChange\`.`, + }, + }, +}; diff --git a/packages/react-spinbutton/src/stories/SpinButtonDefault.stories.tsx b/packages/react-spinbutton/src/stories/SpinButtonDefault.stories.tsx deleted file mode 100644 index cf15341c5b01ae..00000000000000 --- a/packages/react-spinbutton/src/stories/SpinButtonDefault.stories.tsx +++ /dev/null @@ -1,4 +0,0 @@ -import * as React from 'react'; -import { SpinButton, SpinButtonProps } from '../index'; - -export const Default = (props: Partial) => ; diff --git a/packages/react-spinbutton/src/stories/SpinButtonDisabled.stories.tsx b/packages/react-spinbutton/src/stories/SpinButtonDisabled.stories.tsx new file mode 100644 index 00000000000000..387b6c5cf7e43e --- /dev/null +++ b/packages/react-spinbutton/src/stories/SpinButtonDisabled.stories.tsx @@ -0,0 +1,23 @@ +import * as React from 'react'; +import { SpinButton } from '../index'; +import { Label } from '@fluentui/react-label'; +import { useId } from '@fluentui/react-utilities'; + +export const Disabled = () => { + const id = useId(); + + return ( + <> + + + + ); +}; + +Disabled.parameters = { + docs: { + description: { + story: `SpinButton can be disabled.`, + }, + }, +}; diff --git a/packages/react-spinbutton/src/stories/SpinButtonDisplayValue.stories.tsx b/packages/react-spinbutton/src/stories/SpinButtonDisplayValue.stories.tsx new file mode 100644 index 00000000000000..61dd6647109bfc --- /dev/null +++ b/packages/react-spinbutton/src/stories/SpinButtonDisplayValue.stories.tsx @@ -0,0 +1,53 @@ +import * as React from 'react'; +import { SpinButton, SpinButtonProps } from '../index'; +import { Label } from '@fluentui/react-label'; +import { useId } from '@fluentui/react-utilities'; + +type FormatterFn = (value: number) => string; +type ParserFn = (formattedValue: string) => number; + +export const DisplayValue = () => { + const formatter: FormatterFn = value => { + return `${value}"`; + }; + + const parser: ParserFn = formattedValue => { + if (formattedValue === null) { + return NaN; + } + + return parseFloat(formattedValue); + }; + + const onSpinButtonChange: SpinButtonProps['onChange'] = (_ev, data) => { + if (data.value !== undefined) { + setSpinButtonValue(data.value); + setSpinButtonDisplayValue(formatter(data.value)); + } else if (data.displayValue) { + const newValue = parser(data.displayValue); + if (!Number.isNaN(newValue)) { + setSpinButtonValue(newValue); + setSpinButtonDisplayValue(formatter(newValue)); + } + } + }; + + const id = useId(); + const [spinButtonValue, setSpinButtonValue] = React.useState(1); + const [spinButtonDisplayValue, setSpinButtonDisplayValue] = React.useState(formatter(1)); + + return ( + <> + + + + ); +}; + +DisplayValue.parameters = { + docs: { + description: { + story: `SpinButton supports formatted display values.`, + }, + }, +}; diff --git a/packages/react-spinbutton/src/stories/SpinButtonInputType.stories.tsx b/packages/react-spinbutton/src/stories/SpinButtonInputType.stories.tsx new file mode 100644 index 00000000000000..0fc2c34791b9c6 --- /dev/null +++ b/packages/react-spinbutton/src/stories/SpinButtonInputType.stories.tsx @@ -0,0 +1,34 @@ +import * as React from 'react'; +import { SpinButton, SpinButtonProps } from '../index'; +import { Label } from '@fluentui/react-label'; +import { useId } from '@fluentui/react-utilities'; + +export const InputType = () => { + const id = useId(); + const [spinButtonValue, setSpinButtonValue] = React.useState(10); + + const onSpinButtonChange: SpinButtonProps['onChange'] = React.useCallback( + (_ev, data) => { + console.log('onSpinButtonChange', data.value, data.displayValue); + if (data.value !== undefined) { + setSpinButtonValue(data.value); + } + }, + [setSpinButtonValue], + ); + + return ( + <> + + + + ); +}; + +InputType.parameters = { + docs: { + description: { + story: `SpinButton has different input types that allow free-form input to be disabled`, + }, + }, +}; diff --git a/packages/react-spinbutton/src/stories/SpinButtonRTL.stories.tsx b/packages/react-spinbutton/src/stories/SpinButtonRTL.stories.tsx new file mode 100644 index 00000000000000..938ad6d3238fce --- /dev/null +++ b/packages/react-spinbutton/src/stories/SpinButtonRTL.stories.tsx @@ -0,0 +1,34 @@ +import * as React from 'react'; +import { SpinButton } from '../index'; +import { Label } from '@fluentui/react-label'; +import { useId } from '@fluentui/react-utilities'; +import { FluentProvider } from '@fluentui/react-provider'; +import { makeStyles } from '@griffel/react'; + +const useLayout = makeStyles({ + root: { + display: 'flex', + flexDirection: 'column', + rowGap: '2px', + }, +}); + +export const RTL = () => { + const layoutStyles = useLayout(); + const id = useId(); + + return ( + + + + + ); +}; + +RTL.parameters = { + docs: { + description: { + story: `SpinButton supports right-to-left (RTL) layout.`, + }, + }, +}; diff --git a/packages/react-spinbutton/src/stories/SpinButtonSize.stories.tsx b/packages/react-spinbutton/src/stories/SpinButtonSize.stories.tsx new file mode 100644 index 00000000000000..ec6a9473ebdd50 --- /dev/null +++ b/packages/react-spinbutton/src/stories/SpinButtonSize.stories.tsx @@ -0,0 +1,40 @@ +import * as React from 'react'; +import { SpinButton } from '../index'; +import { Label } from '@fluentui/react-label'; +import { useId } from '@fluentui/react-utilities'; +import { makeStyles } from '@griffel/react'; + +const useLayout = makeStyles({ + root: { + display: 'flex', + flexDirection: 'column', + + [`> * + label`]: { + marginTop: '10px', + }, + }, +}); + +export const Size = () => { + const layoutStyles = useLayout(); + const smallId = useId('small-id'); + const mediumId = useId('medium-id'); + + return ( +
+ + + + + +
+ ); +}; + +Size.parameters = { + docs: { + description: { + story: `SpinButton can have different sizes.`, + }, + }, +}; diff --git a/packages/react-spinbutton/src/stories/SpinButtonStep.stories.tsx b/packages/react-spinbutton/src/stories/SpinButtonStep.stories.tsx new file mode 100644 index 00000000000000..eb1fddb5ae235e --- /dev/null +++ b/packages/react-spinbutton/src/stories/SpinButtonStep.stories.tsx @@ -0,0 +1,39 @@ +import * as React from 'react'; +import { SpinButton, SpinButtonProps } from '../index'; +import { Label } from '@fluentui/react-label'; +import { useId } from '@fluentui/react-utilities'; + +export const Step = () => { + const id = useId(); + const [spinButtonValue, setSpinButtonValue] = React.useState(10); + + const onSpinButtonChange: SpinButtonProps['onChange'] = React.useCallback( + (_ev, data) => { + if (data.value !== undefined) { + setSpinButtonValue(data.value); + } else if (data.displayValue !== undefined) { + const newValue = parseFloat(data.displayValue); + if (!Number.isNaN(newValue)) { + setSpinButtonValue(newValue); + } + } + }, + [setSpinButtonValue], + ); + + return ( + <> + + + + ); +}; + +Step.parameters = { + docs: { + description: { + story: `SpinButton step size can be set. Additionally \`stepPage\` can be + set to a large value to allow bulk steps via the \`Page Up\` and \`Page Down\` keys.`, + }, + }, +}; diff --git a/packages/react-spinbutton/src/stories/SpinButtonUncontrolled.stories.tsx b/packages/react-spinbutton/src/stories/SpinButtonUncontrolled.stories.tsx new file mode 100644 index 00000000000000..add86e5ce41748 --- /dev/null +++ b/packages/react-spinbutton/src/stories/SpinButtonUncontrolled.stories.tsx @@ -0,0 +1,23 @@ +import * as React from 'react'; +import { SpinButton } from '../index'; +import { Label } from '@fluentui/react-label'; +import { useId } from '@fluentui/react-utilities'; + +export const Uncontrolled = () => { + const id = useId(); + + return ( + <> + + + + ); +}; + +Uncontrolled.parameters = { + docs: { + description: { + story: `An uncontrolled SpinButton`, + }, + }, +}; diff --git a/packages/react-spinbutton/src/utils/clamp.test.ts b/packages/react-spinbutton/src/utils/clamp.test.ts new file mode 100644 index 00000000000000..8e2a0fce262888 --- /dev/null +++ b/packages/react-spinbutton/src/utils/clamp.test.ts @@ -0,0 +1,36 @@ +import { clampWhenInRange } from './clamp'; + +describe('SpinButton Clamp Util', () => { + describe('clampWhenInRange', () => { + it('should clamp the new value', () => { + expect(clampWhenInRange(5, 0, 1, 10)).toBe(1); + expect(clampWhenInRange(5, 11, 1, 10)).toBe(10); + expect(clampWhenInRange(1, 0, 1, 10)).toBe(1); + expect(clampWhenInRange(10, 11, 1, 10)).toBe(10); + + expect(clampWhenInRange(5, 0, 1, undefined)).toBe(1); + expect(clampWhenInRange(5, 11, undefined, 10)).toBe(10); + expect(clampWhenInRange(1, 0, 1, undefined)).toBe(1); + expect(clampWhenInRange(10, 11, undefined, 10)).toBe(10); + + expect(clampWhenInRange(5, 4, 5, 5)).toBe(5); + expect(clampWhenInRange(5, 6, 5, 5)).toBe(5); + expect(clampWhenInRange(4, 5, 5, 5)).toBe(5); + expect(clampWhenInRange(6, 5, 5, 5)).toBe(5); + }); + + it('should not clamp the new value', () => { + expect(clampWhenInRange(0, -1, 1, 10)).toBe(-1); + expect(clampWhenInRange(11, 12, 1, 10)).toBe(12); + + expect(clampWhenInRange(0, 1, undefined, undefined)).toBe(1); + expect(clampWhenInRange(0, -1, undefined, 10)).toBe(-1); + expect(clampWhenInRange(11, 12, 1, undefined)).toBe(12); + + expect(clampWhenInRange(4, 3, 5, 5)).toBe(3); + expect(clampWhenInRange(6, 7, 5, 5)).toBe(7); + + expect(clampWhenInRange(4, 5, 10, 7)).toBe(5); + }); + }); +}); diff --git a/packages/react-spinbutton/src/utils/clamp.ts b/packages/react-spinbutton/src/utils/clamp.ts new file mode 100644 index 00000000000000..a3fb4b5a86a77f --- /dev/null +++ b/packages/react-spinbutton/src/utils/clamp.ts @@ -0,0 +1,34 @@ +export const clampWhenInRange = (oldValue: number, newValue: number, min?: number, max?: number): number => { + // When oldValue is in the range of [min, max] clamp newValue. + // Don't clamp values outside this range so users get a + // more natural behavior. For example, if the range is [5, 15] + // and the user types 1 into the input we don't want to clamp + // the value when they next press the increment button because + // clamping would snap the value to 5 rather than increment to 2. + let nextValue = newValue; + if (min !== undefined && oldValue >= min) { + if (max !== undefined && min > max) { + const error = new Error(); + if (process.env.NODE_ENV !== 'production') { + // eslint-disable-next-line no-console + console.error( + [ + `"min" value "${min}" is greater than "max" value "${max}".`, + '"min" must be less than or equal to "max".', + `Returning value "${newValue}".`, + error.stack, + ].join(), + ); + } + return newValue; + } + + nextValue = Math.max(min, nextValue); + } + + if (max !== undefined && oldValue <= max) { + nextValue = Math.min(max, nextValue); + } + + return nextValue; +}; diff --git a/packages/react-spinbutton/src/utils/getBound.test.ts b/packages/react-spinbutton/src/utils/getBound.test.ts new file mode 100644 index 00000000000000..bc79d4946e5631 --- /dev/null +++ b/packages/react-spinbutton/src/utils/getBound.test.ts @@ -0,0 +1,23 @@ +import { getBound } from './getBound'; + +describe('SpinButton getBound Util', () => { + it('should return "min"', () => { + expect(getBound(1, 1, 0)).toBe('min'); + expect(getBound(1, 1, undefined)).toBe('min'); + }); + + it('should return "max"', () => { + expect(getBound(10, 0, 10)).toBe('max'); + expect(getBound(10, undefined, 10)).toBe('max'); + }); + + it('should return "none"', () => { + expect(getBound(5, 0, 10)).toBe('none'); + expect(getBound(5)).toBe('none'); + expect(getBound(5, undefined, 10)).toBe('none'); + expect(getBound(5, 0, undefined)).toBe('none'); + + expect(getBound(-10, 0, 10)).toBe('none'); + expect(getBound(20, 0, 10)).toBe('none'); + }); +}); diff --git a/packages/react-spinbutton/src/utils/getBound.ts b/packages/react-spinbutton/src/utils/getBound.ts new file mode 100644 index 00000000000000..06fe5ac81f59ad --- /dev/null +++ b/packages/react-spinbutton/src/utils/getBound.ts @@ -0,0 +1,11 @@ +import type { SpinButtonBounds } from '../SpinButton'; + +export const getBound = (value: number, min?: number, max?: number): SpinButtonBounds => { + if (min !== undefined && value === min) { + return 'min'; + } else if (max !== undefined && value === max) { + return 'max'; + } + + return 'none'; +}; diff --git a/packages/react-spinbutton/src/utils/index.ts b/packages/react-spinbutton/src/utils/index.ts new file mode 100644 index 00000000000000..f29509f39cf9e6 --- /dev/null +++ b/packages/react-spinbutton/src/utils/index.ts @@ -0,0 +1,3 @@ +export * from './clamp'; +export * from './getBound'; +export * from './precision'; diff --git a/packages/react-spinbutton/src/utils/precision.test.ts b/packages/react-spinbutton/src/utils/precision.test.ts new file mode 100644 index 00000000000000..69bd888449a55e --- /dev/null +++ b/packages/react-spinbutton/src/utils/precision.test.ts @@ -0,0 +1,45 @@ +import { calculatePrecision, precisionRound } from './precision'; + +describe('SpinButton Precision Util', () => { + describe('calculatePrecision', () => { + it('caluclatePrecision should work as intended', () => { + expect(calculatePrecision(0)).toEqual(0); + expect(calculatePrecision(1)).toEqual(0); + expect(calculatePrecision('1')).toEqual(0); + + expect(calculatePrecision(200)).toEqual(-2); + expect(calculatePrecision(32100012300000)).toEqual(-5); + + expect(calculatePrecision(231.0)).toEqual(0); + expect(calculatePrecision('231.00')).toEqual(2); + expect(calculatePrecision(321.00002)).toEqual(5); + expect(calculatePrecision('321.00002')).toEqual(5); + + expect(calculatePrecision(0.002)).toEqual(3); + expect(calculatePrecision('.002')).toEqual(3); + }); + }); + + describe('precisionRound', () => { + it('precisionRound should work as intended', () => { + expect(precisionRound(1234, 0)).toEqual(1234); + expect(precisionRound(1234, -1)).toEqual(1230); + expect(precisionRound(1234, -3)).toEqual(1000); + + expect(precisionRound(1234.5678, 0)).toEqual(1235); + expect(precisionRound(1234.5678, 1)).toEqual(1234.6); + expect(precisionRound(1234.5678, 5)).toEqual(1234.5678); + + expect(precisionRound(1234.555, 2)).toEqual(1234.56); + expect(precisionRound(1234.554, 2)).toEqual(1234.55); + expect(precisionRound(1250, -2)).toEqual(1300); + expect(precisionRound(1249, -2)).toEqual(1200); + + // Different bases + expect(precisionRound(1234.5, -2, 2)).toEqual(1236); + expect(precisionRound(1234.5, -2, 16)).toEqual(1280); + expect(precisionRound(1234.5, -2, 8)).toEqual(1216); + expect(precisionRound(1234.5, -2, 7)).toEqual(1225); + }); + }); +}); diff --git a/packages/react-spinbutton/src/utils/precision.ts b/packages/react-spinbutton/src/utils/precision.ts new file mode 100644 index 00000000000000..83549a0d48912b --- /dev/null +++ b/packages/react-spinbutton/src/utils/precision.ts @@ -0,0 +1,36 @@ +/** + * Calculates a number's precision based on the number of trailing + * zeros if the number does not have a decimal indicated by a negative + * precision. Otherwise, it calculates the number of digits after + * the decimal point indicated by a positive precision. + * @param value - the value to determine the precision of + */ +export function calculatePrecision(value: number | string): number { + /** + * Group 1: + * [1-9]([0]+$) matches trailing zeros + * Group 2: + * \.([0-9]*) matches all digits after a decimal point. + */ + const groups = /[1-9]([0]+$)|\.([0-9]*)/.exec(String(value)); + if (!groups) { + return 0; + } + if (groups[1]) { + return -groups[1].length; + } + if (groups[2]) { + return groups[2].length; + } + return 0; +} + +/** + * Rounds a number to a certain level of precision. Accepts negative precision. + * @param value - The value that is being rounded. + * @param precision - The number of decimal places to round the number to + */ +export function precisionRound(value: number, precision: number, base: number = 10): number { + const exp = Math.pow(base, precision); + return Math.round(value * exp) / exp; +}