@box/blueprint-web 16.20.0 → 16.20.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -39,6 +39,7 @@ export * from './list-item/list-item';
39
39
  export * from './loading-indicator/loading-indicator';
40
40
  export * from './modal';
41
41
  export * from './navigation-menu';
42
+ export * from './number-field';
42
43
  export * from './page';
43
44
  export * from './page-section';
44
45
  export * from './password-input';
@@ -50,6 +50,7 @@ export { LoadingIndicator } from './loading-indicator/loading-indicator.js';
50
50
  export { AlertModal } from './modal/alert-modal.js';
51
51
  export { Modal } from './modal/modal.js';
52
52
  export { NavigationMenu } from './navigation-menu/index.js';
53
+ export { NumberField } from './number-field/number-field.js';
53
54
  export { Page } from './page/index.js';
54
55
  export { PageSection } from './page-section/index.js';
55
56
  export { PasswordInput } from './password-input/password-input.js';
@@ -0,0 +1,5 @@
1
+ /** Three digits, the width the design spec draws the control at. */
2
+ declare const NUMBER_FIELD_DEFAULT_MAX = 999;
3
+ /** Non-negative integers only. Also governs what react-aria lets through, keeping the field digits-only. */
4
+ declare const NUMBER_FIELD_FORMAT_OPTIONS: Intl.NumberFormatOptions;
5
+ export { NUMBER_FIELD_DEFAULT_MAX, NUMBER_FIELD_FORMAT_OPTIONS };
@@ -0,0 +1,9 @@
1
+ /** Three digits, the width the design spec draws the control at. */
2
+ const NUMBER_FIELD_DEFAULT_MAX = 999;
3
+ /** Non-negative integers only. Also governs what react-aria lets through, keeping the field digits-only. */
4
+ const NUMBER_FIELD_FORMAT_OPTIONS = {
5
+ maximumFractionDigits: 0,
6
+ useGrouping: false
7
+ };
8
+
9
+ export { NUMBER_FIELD_DEFAULT_MAX, NUMBER_FIELD_FORMAT_OPTIONS };
@@ -0,0 +1,2 @@
1
+ export { NumberField } from './number-field';
2
+ export { type NumberFieldProps } from './types';
@@ -0,0 +1,24 @@
1
+ import { type ComponentPropsWithoutRef, type ForwardedRef } from 'react';
2
+ type NumberFieldControlProps = {
3
+ ariaValueMax: number;
4
+ ariaValueMin: number;
5
+ decrementAriaLabel: string;
6
+ hasError: boolean;
7
+ incrementAriaLabel: string;
8
+ inputLength: number;
9
+ inputProps: ComponentPropsWithoutRef<'input'>;
10
+ inputRef: ForwardedRef<HTMLInputElement>;
11
+ isAnimationEnabled: boolean;
12
+ isDecrementDisabled: boolean;
13
+ isDecrementLoading: boolean;
14
+ isIncrementDisabled: boolean;
15
+ isIncrementLoading: boolean;
16
+ labelId: string;
17
+ loadingAriaLabel?: string;
18
+ maxReachedTooltipContent?: string;
19
+ minReachedTooltipContent?: string;
20
+ showDecrementTooltip: boolean;
21
+ showIncrementTooltip: boolean;
22
+ };
23
+ export declare const NumberFieldControl: ({ ariaValueMax, ariaValueMin, decrementAriaLabel, hasError, incrementAriaLabel, inputLength, inputProps, inputRef, isAnimationEnabled, isDecrementDisabled, isDecrementLoading, isIncrementDisabled, isIncrementLoading, labelId, loadingAriaLabel, maxReachedTooltipContent, minReachedTooltipContent, showDecrementTooltip, showIncrementTooltip, }: NumberFieldControlProps) => React.ReactElement;
24
+ export {};
@@ -0,0 +1,82 @@
1
+ import { jsxs, jsx } from 'react/jsx-runtime';
2
+ import { Minus, Plus } from '@box/blueprint-web-assets/icons/Medium';
3
+ import { useContext } from 'react';
4
+ import { NumberFieldStateContext, useContextProps, InputContext, Group } from 'react-aria-components';
5
+ import { BaseTextInput } from '../primitives/base-text-input/base-text-input.js';
6
+ import { StepperButton } from './stepper-button.js';
7
+ import styles from './number-field.module.js';
8
+
9
+ const NumberFieldControl = ({
10
+ ariaValueMax,
11
+ ariaValueMin,
12
+ decrementAriaLabel,
13
+ hasError,
14
+ incrementAriaLabel,
15
+ inputLength,
16
+ inputProps,
17
+ inputRef,
18
+ isAnimationEnabled,
19
+ isDecrementDisabled,
20
+ isDecrementLoading,
21
+ isIncrementDisabled,
22
+ isIncrementLoading,
23
+ labelId,
24
+ loadingAriaLabel,
25
+ maxReachedTooltipContent,
26
+ minReachedTooltipContent,
27
+ showDecrementTooltip,
28
+ showIncrementTooltip
29
+ }) => {
30
+ const state = useContext(NumberFieldStateContext);
31
+ const [racInputProps, racInputRef] = useContextProps({
32
+ maxLength: inputLength
33
+ }, inputRef, InputContext);
34
+ if (!state) {
35
+ throw new Error('NumberFieldControl must be rendered inside NumberField');
36
+ }
37
+ const decrementLoading = loadingAriaLabel && isDecrementLoading ? {
38
+ loading: true,
39
+ loadingAriaLabel
40
+ } : {};
41
+ const incrementLoading = loadingAriaLabel && isIncrementLoading ? {
42
+ loading: true,
43
+ loadingAriaLabel
44
+ } : {};
45
+ return jsxs(Group, {
46
+ "aria-labelledby": labelId,
47
+ className: styles.control,
48
+ children: [jsx(StepperButton, {
49
+ ariaLabel: decrementAriaLabel,
50
+ className: styles.stepperLeft,
51
+ disabled: isDecrementDisabled,
52
+ icon: Minus,
53
+ onClick: state.decrement,
54
+ showTooltip: showDecrementTooltip,
55
+ tooltipContent: minReachedTooltipContent,
56
+ ...decrementLoading
57
+ }), jsx(BaseTextInput, {
58
+ ...inputProps,
59
+ ...racInputProps,
60
+ ref: racInputRef,
61
+ "aria-valuemax": ariaValueMax,
62
+ "aria-valuemin": ariaValueMin,
63
+ className: styles.input,
64
+ "data-bp-animated": isAnimationEnabled ? 'true' : 'false',
65
+ invalid: hasError,
66
+ label: "",
67
+ // Pinned to the `text` react-aria already sets, since it types this as any input type.
68
+ type: "text"
69
+ }), jsx(StepperButton, {
70
+ ariaLabel: incrementAriaLabel,
71
+ className: styles.stepperRight,
72
+ disabled: isIncrementDisabled,
73
+ icon: Plus,
74
+ onClick: state.increment,
75
+ showTooltip: showIncrementTooltip,
76
+ tooltipContent: maxReachedTooltipContent,
77
+ ...incrementLoading
78
+ })]
79
+ });
80
+ };
81
+
82
+ export { NumberFieldControl };
@@ -0,0 +1,9 @@
1
+ import { type NumberFieldProps } from './types';
2
+ /**
3
+ * Numeric form field with a label, a decrement/increment stepper, and optional subtext or error.
4
+ *
5
+ * Formats non-negative whole numbers out of the box; `formatOptions` opens that up to decimals, currency
6
+ * and percent, and doubles as the rule for what may be typed. The control is sized to hold `maxLength`
7
+ * characters and does not grow or shrink with its container.
8
+ */
9
+ export declare const NumberField: import("react").ForwardRefExoticComponent<NumberFieldProps & import("react").RefAttributes<HTMLInputElement>>;
@@ -0,0 +1,140 @@
1
+ import { jsxs, jsx } from 'react/jsx-runtime';
2
+ import clsx from 'clsx';
3
+ import { forwardRef } from 'react';
4
+ import { NumberField as NumberField$1 } from 'react-aria-components';
5
+ import '../blueprint-configuration-context/blueprint-configuration-context.js';
6
+ import '../blueprint-configuration-context/consts.js';
7
+ import { useBlueprintConfiguration } from '../blueprint-configuration-context/useBlueprintConfiguration.js';
8
+ import { InlineError } from '../primitives/inline-error/inline-error.js';
9
+ import { Text } from '../text/text.js';
10
+ import { useLabelable } from '../util-components/labelable/useLabelable.js';
11
+ import { useUniqueId } from '../utils/useUniqueId.js';
12
+ import { NUMBER_FIELD_FORMAT_OPTIONS, NUMBER_FIELD_DEFAULT_MAX } from './constants.js';
13
+ import { NumberFieldControl } from './number-field-control.js';
14
+ import styles from './number-field.module.js';
15
+
16
+ /**
17
+ * Numeric form field with a label, a decrement/increment stepper, and optional subtext or error.
18
+ *
19
+ * Formats non-negative whole numbers out of the box; `formatOptions` opens that up to decimals, currency
20
+ * and percent, and doubles as the rule for what may be typed. The control is sized to hold `maxLength`
21
+ * characters and does not grow or shrink with its container.
22
+ */
23
+ const NumberField = /*#__PURE__*/forwardRef((props, forwardedRef) => {
24
+ const {
25
+ className,
26
+ decrementAriaLabel,
27
+ disabled = false,
28
+ error,
29
+ form,
30
+ formatOptions = NUMBER_FIELD_FORMAT_OPTIONS,
31
+ hideLabel = false,
32
+ id,
33
+ incrementAriaLabel,
34
+ label,
35
+ loading = false,
36
+ loadingAriaLabel,
37
+ max = NUMBER_FIELD_DEFAULT_MAX,
38
+ maxLength,
39
+ maxReachedTooltipContent,
40
+ min = 0,
41
+ minReachedTooltipContent,
42
+ name,
43
+ onChange,
44
+ readOnly = false,
45
+ required = false,
46
+ step = 1,
47
+ subtext,
48
+ value,
49
+ 'aria-describedby': ariaDescribedByProp,
50
+ ...rest
51
+ } = props;
52
+ const uniqueId = useUniqueId('number-field-');
53
+ const inputId = id || uniqueId;
54
+ const labelId = useUniqueId('number-field-label-');
55
+ const subtextId = useUniqueId('number-field-subtext-');
56
+ const inlineErrorId = useUniqueId('number-field-error-');
57
+ const isDecrementLoading = !disabled && (loading === true || loading === 'decrement');
58
+ const isIncrementLoading = !disabled && (loading === true || loading === 'increment');
59
+ const isAnyLoading = isDecrementLoading || isIncrementLoading;
60
+ const isInteractive = !disabled && !readOnly;
61
+ // Hide the error while loading: BaseTextInput suppresses aria-invalid when disabled, so showing
62
+ // InlineError alone would leave an inconsistent a11y state.
63
+ const hasError = !!error && !disabled && !isAnyLoading;
64
+ const hasSubtext = !hasError && !!subtext;
65
+ // Also sizes the control, handed to the stylesheet as `--number-field-digits` below.
66
+ const inputLength = maxLength ?? String(max).length;
67
+ const Label = useLabelable(label, inputId, required);
68
+ // Mirrors TextInput
69
+ const {
70
+ componentsWithAnimationEnabled
71
+ } = useBlueprintConfiguration();
72
+ const isAnimationEnabled = componentsWithAnimationEnabled.includes('TextInput');
73
+ const ariaDescribedBy = clsx(ariaDescribedByProp, {
74
+ [inlineErrorId]: hasError,
75
+ [subtextId]: hasSubtext
76
+ }) || undefined;
77
+ return jsxs(NumberField$1, {
78
+ "aria-describedby": ariaDescribedBy,
79
+ "aria-labelledby": labelId,
80
+ className: clsx(styles.numberField, className),
81
+ formatOptions: formatOptions,
82
+ form: form,
83
+ id: inputId,
84
+ isDisabled: disabled || isAnyLoading,
85
+ isInvalid: hasError,
86
+ isReadOnly: readOnly,
87
+ isRequired: required,
88
+ maxValue: max,
89
+ minValue: min,
90
+ name: name,
91
+ onChange: onChange,
92
+ step: step,
93
+ style: {
94
+ '--number-field-digits': inputLength
95
+ },
96
+ validationBehavior: "native",
97
+ value: value,
98
+ children: [jsx(Label, {
99
+ className: clsx(styles.label, {
100
+ [styles.hidden]: hideLabel
101
+ }),
102
+ hideLabel: hideLabel,
103
+ id: labelId
104
+ }), jsx(NumberFieldControl, {
105
+ ariaValueMax: max,
106
+ ariaValueMin: min,
107
+ decrementAriaLabel: decrementAriaLabel,
108
+ hasError: hasError,
109
+ incrementAriaLabel: incrementAriaLabel,
110
+ inputLength: inputLength,
111
+ inputProps: rest,
112
+ inputRef: forwardedRef,
113
+ isAnimationEnabled: isAnimationEnabled,
114
+ isDecrementDisabled: !isInteractive || value <= min || isAnyLoading && !isDecrementLoading,
115
+ isDecrementLoading: isDecrementLoading,
116
+ isIncrementDisabled: !isInteractive || value >= max || isAnyLoading && !isIncrementLoading,
117
+ isIncrementLoading: isIncrementLoading,
118
+ labelId: labelId,
119
+ loadingAriaLabel: loadingAriaLabel,
120
+ maxReachedTooltipContent: maxReachedTooltipContent,
121
+ minReachedTooltipContent: minReachedTooltipContent,
122
+ showDecrementTooltip: isInteractive && !isAnyLoading && value <= min,
123
+ showIncrementTooltip: isInteractive && !isAnyLoading && value >= max
124
+ }), hasError ? jsx(InlineError, {
125
+ className: styles.inlineError,
126
+ id: inlineErrorId,
127
+ children: error
128
+ }) : hasSubtext && jsx(Text, {
129
+ as: "p",
130
+ className: styles.subtext,
131
+ color: "textOnLightSecondary",
132
+ id: subtextId,
133
+ variant: "caption",
134
+ children: subtext
135
+ })]
136
+ });
137
+ });
138
+ NumberField.displayName = 'NumberField';
139
+
140
+ export { NumberField };
@@ -0,0 +1,4 @@
1
+ import '../index.css';
2
+ var styles = {"numberField":"bp_number_field_module_numberField--e9b37","label":"bp_number_field_module_label--e9b37","hidden":"bp_number_field_module_hidden--e9b37","control":"bp_number_field_module_control--e9b37","stepperLeft":"bp_number_field_module_stepperLeft--e9b37","stepperRight":"bp_number_field_module_stepperRight--e9b37","input":"bp_number_field_module_input--e9b37","inlineError":"bp_number_field_module_inlineError--e9b37","subtext":"bp_number_field_module_subtext--e9b37"};
3
+
4
+ export { styles as default };
@@ -0,0 +1,21 @@
1
+ import { type RequireAllOrNone } from 'type-fest';
2
+ import { type SvgIconComponent } from '../button/types';
3
+ type StepperLoadingProps = RequireAllOrNone<{
4
+ loading: boolean;
5
+ loadingAriaLabel: string;
6
+ }, 'loading' | 'loadingAriaLabel'>;
7
+ type StepperButtonProps = {
8
+ ariaLabel: string;
9
+ className?: string;
10
+ disabled: boolean;
11
+ icon: SvgIconComponent;
12
+ onClick: () => void;
13
+ showTooltip: boolean;
14
+ tooltipContent?: string;
15
+ } & StepperLoadingProps;
16
+ /**
17
+ * With a boundary tooltip to show, `disabled` gives way to `aria-disabled` so the button stays focusable
18
+ * and hoverable enough to surface it.
19
+ */
20
+ export declare const StepperButton: ({ ariaLabel, className, disabled, icon, onClick, showTooltip, tooltipContent, ...loadingProps }: StepperButtonProps) => React.ReactElement;
21
+ export type { StepperLoadingProps };
@@ -0,0 +1,41 @@
1
+ import { jsx } from 'react/jsx-runtime';
2
+ import { Button } from '../button/button.js';
3
+ import { Tooltip } from '../tooltip/tooltip.js';
4
+
5
+ /**
6
+ * With a boundary tooltip to show, `disabled` gives way to `aria-disabled` so the button stays focusable
7
+ * and hoverable enough to surface it.
8
+ */
9
+ const StepperButton = ({
10
+ ariaLabel,
11
+ className,
12
+ disabled,
13
+ icon,
14
+ onClick,
15
+ showTooltip,
16
+ tooltipContent,
17
+ ...loadingProps
18
+ }) => {
19
+ const tooltip = showTooltip ? tooltipContent : undefined;
20
+ const button = jsx(Button, {
21
+ ...loadingProps,
22
+ accessibleWhenDisabled: !!tooltip,
23
+ "aria-label": ariaLabel,
24
+ className: className,
25
+ disabled: disabled,
26
+ onClick: onClick,
27
+ size: "large",
28
+ startIcon: icon,
29
+ type: "button",
30
+ variant: "secondary"
31
+ });
32
+ if (tooltip) {
33
+ return jsx(Tooltip, {
34
+ content: tooltip,
35
+ children: button
36
+ });
37
+ }
38
+ return button;
39
+ };
40
+
41
+ export { StepperButton };
@@ -0,0 +1,99 @@
1
+ import { type ComponentPropsWithoutRef, type ReactNode } from 'react';
2
+ import { type RequireAllOrNone } from 'type-fest';
3
+ import { type Labelable } from '../util-components/labelable';
4
+ export interface Loading {
5
+ /**
6
+ * Shows a spinner on both steppers, or on just one. The input and the other stepper are disabled while
7
+ * it spins. Requires `loadingAriaLabel`.
8
+ */
9
+ loading: boolean | 'increment' | 'decrement';
10
+ /** The aria-label for the stepper loading indicators. */
11
+ loadingAriaLabel: string;
12
+ }
13
+ /**
14
+ * Attributes NumberField sets from its own props, so they cannot be forwarded. `size` is included because
15
+ * the control takes its width from `maxLength`.
16
+ */
17
+ type ReservedInputAttributes = 'defaultValue' | 'disabled' | 'inputMode' | 'max' | 'min' | 'onBlur' | 'onChange' | 'onKeyDown' | 'pattern' | 'readOnly' | 'required' | 'size' | 'step' | 'type' | 'value';
18
+ /**
19
+ * Remaining props are forwarded to the `<input>`, so the field takes part in native form submission.
20
+ * `className` styles the wrapper holding the label, the control and the subtext.
21
+ */
22
+ interface NumberFieldBaseProps extends Omit<ComponentPropsWithoutRef<'input'>, ReservedInputAttributes>, Labelable {
23
+ /** Accessible label for the decrement button. */
24
+ decrementAriaLabel: string;
25
+ /**
26
+ * When true, prevents the user from interacting with the field.
27
+ *
28
+ * @default false
29
+ */
30
+ disabled?: boolean;
31
+ /** Error message rendered below the control, replacing the subtext and marking the control invalid. */
32
+ error?: ReactNode;
33
+ /**
34
+ * How the value is formatted and, in turn, what may be typed — react-aria parses input with the same
35
+ * options it formats with. Must be a stable reference, so hoist or memoize it.
36
+ *
37
+ * @default { maximumFractionDigits: 0, useGrouping: false }
38
+ */
39
+ formatOptions?: Intl.NumberFormatOptions;
40
+ /**
41
+ * When true, the label is visually hidden but remains available to assistive technology.
42
+ *
43
+ * @default false
44
+ */
45
+ hideLabel?: boolean;
46
+ /** Accessible label for the increment button. */
47
+ incrementAriaLabel: string;
48
+ /** The field label rendered above the control. */
49
+ label: Labelable['label'];
50
+ /**
51
+ * The upper bound. Disables the increment button once reached, shows `maxReachedTooltipContent`, and
52
+ * snaps typed commits down to this value.
53
+ *
54
+ * @default 999
55
+ */
56
+ max?: number;
57
+ /**
58
+ * How many characters the input accepts and is sized to hold. Defaults to the digits of `max`; set it
59
+ * when `formatOptions` needs room for a separator or a sign.
60
+ */
61
+ maxLength?: number;
62
+ /** Localized tooltip content shown when the increment button is disabled because the value is at max. */
63
+ maxReachedTooltipContent?: string;
64
+ /**
65
+ * The lower bound. Disables the decrement button once reached, shows `minReachedTooltipContent`, and
66
+ * snaps typed commits up to this value.
67
+ *
68
+ * @default 0
69
+ */
70
+ min?: number;
71
+ /** Localized tooltip content shown when the decrement button is disabled because the value is at min. */
72
+ minReachedTooltipContent?: string;
73
+ /** Called with the new value when a stepper is used, or when the typed draft is committed on blur or Enter. */
74
+ onChange: (value: number) => void;
75
+ /**
76
+ * When true, the value is displayed but cannot be edited and the stepper buttons are inactive.
77
+ *
78
+ * @default false
79
+ */
80
+ readOnly?: boolean;
81
+ /**
82
+ * When true, marks the field as required.
83
+ *
84
+ * @default false
85
+ */
86
+ required?: boolean;
87
+ /**
88
+ * The stepping interval used by the increment and decrement buttons.
89
+ *
90
+ * @default 1
91
+ */
92
+ step?: number;
93
+ /** Helper text rendered below the control. Not rendered while the field is in its error state. */
94
+ subtext?: string;
95
+ /** The controlled value of the field. Must be used in conjunction with `onChange`. */
96
+ value: number;
97
+ }
98
+ export type NumberFieldProps = NumberFieldBaseProps & RequireAllOrNone<Loading, keyof Loading>;
99
+ export {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@box/blueprint-web",
3
- "version": "16.20.0",
3
+ "version": "16.20.2",
4
4
  "type": "module",
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "publishConfig": {
@@ -50,7 +50,7 @@
50
50
  "dependencies": {
51
51
  "@ariakit/react": "0.4.21",
52
52
  "@ariakit/react-core": "0.4.21",
53
- "@box/blueprint-web-assets": "^5.7.7",
53
+ "@box/blueprint-web-assets": "^5.7.9",
54
54
  "@internationalized/date": "^3.12.0",
55
55
  "@radix-ui/react-accordion": "1.1.2",
56
56
  "@radix-ui/react-checkbox": "1.0.4",
@@ -79,7 +79,7 @@
79
79
  "type-fest": "^3.2.0"
80
80
  },
81
81
  "devDependencies": {
82
- "@box/storybook-utils": "^1.2.16",
82
+ "@box/storybook-utils": "^1.2.18",
83
83
  "@figma/code-connect": "1.4.4",
84
84
  "@types/react": "^18.0.0",
85
85
  "@types/react-dom": "^18.0.0",