@inkra/react 0.2.0 → 0.3.0

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.
Files changed (37) hide show
  1. package/README.md +86 -4
  2. package/dist/alert-dialog/index.d.ts +1 -1
  3. package/dist/alert-dialog/index.js +4 -2
  4. package/dist/command/index.d.ts +24 -2
  5. package/dist/command/index.js +2 -2
  6. package/dist/context-menu/index.d.ts +2 -2
  7. package/dist/context-menu/index.js +3 -2
  8. package/dist/drawer/index.d.ts +1 -1
  9. package/dist/drawer/index.js +6 -4
  10. package/dist/dropdown-menu/index.d.ts +1 -1
  11. package/dist/dropdown-menu/index.js +3 -2
  12. package/dist/form/EditablePencilText.js +34 -0
  13. package/dist/form/Input.js +1 -1
  14. package/dist/input-number/index.d.ts +6 -0
  15. package/dist/input-number/index.js +126 -15
  16. package/dist/input-number/model.d.ts +13 -0
  17. package/dist/input-number/model.js +50 -0
  18. package/dist/number-ticker/index.d.ts +11 -9
  19. package/dist/number-ticker/index.js +14 -11
  20. package/dist/number-ticker/model.d.ts +38 -5
  21. package/dist/number-ticker/model.js +139 -10
  22. package/dist/number-ticker/precision.d.ts +7 -0
  23. package/dist/number-ticker/precision.js +134 -0
  24. package/dist/overlay/Overlay.d.ts +1 -1
  25. package/dist/overlay/Overlay.js +4 -2
  26. package/dist/overlay/Popover.d.ts +1 -1
  27. package/dist/overlay/Popover.js +3 -2
  28. package/dist/overlay/themeBridge.d.ts +12 -1
  29. package/dist/overlay/themeBridge.js +27 -1
  30. package/dist/radar/index.d.ts +2 -0
  31. package/dist/radar/index.js +2 -2
  32. package/dist/sortable/index.js +9 -4
  33. package/dist/styles.css +147 -22
  34. package/dist/tooltip/Tooltip.d.ts +1 -1
  35. package/dist/tooltip/Tooltip.js +3 -2
  36. package/dist/virtual-list/index.js +3 -2
  37. package/package.json +4 -3
@@ -1,18 +1,19 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { forwardRef, useEffect, useImperativeHandle, useRef, useState } from 'react';
2
+ import { forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState } from 'react';
3
3
  import { useBrowserLayoutEffect } from '../internal/useBrowserLayoutEffect.js';
4
4
  import { Button } from '../Button.js';
5
5
  import { PencilIcon } from '../PencilIcon.js';
6
6
  import { Input } from '../form/Input.js';
7
7
  import { useResolvedControlProps } from '../form/controlProps.js';
8
8
  import { Tooltip } from '../tooltip/Tooltip.js';
9
+ import { normalizeInputNumberRepeat, resolveInputNumberStep, stepInputNumber, } from './model.js';
9
10
  // Lucide Plus / Minus ISC geometry uses the shared pencil renderer.
10
11
  const plusPaths = [{ d: 'M5 12h14' }, { d: 'M12 5v14' }];
11
12
  const minusPaths = [{ d: 'M5 12h14' }];
12
13
  const finiteValue = (value) => typeof value === 'number' && Number.isFinite(value) ? value : null;
13
14
  const format = (value) => String(finiteValue(value) ?? '');
14
15
  const numberFromDraft = (draft) => draft === '' ? null : finiteValue(Number(draft));
15
- export const InputNumber = forwardRef(function InputNumber({ classNames, controls = true, decrementButtonLabel = 'Decrease value', defaultValue, disabled, incrementButtonLabel = 'Increase value', inputMode = 'decimal', max, min, onChange, onValueChange, readOnly, size = 'md', step = 1, textAlign = 'center', value, ...props }, forwardedRef) {
16
+ export const InputNumber = forwardRef(function InputNumber({ classNames, controls = true, decrementButtonLabel = 'Decrease value', defaultValue, disabled, incrementButtonLabel = 'Increase value', inputMode = 'decimal', max, min, onChange, onBlur, onKeyDown, onValueChange, pressAndHold = true, readOnly, repeatDelay = 400, repeatInterval = 80, size = 'md', step = 1, stepModifiers, textAlign = 'center', value, ...props }, forwardedRef) {
16
17
  const inputRef = useRef(null);
17
18
  const isControlled = value !== undefined;
18
19
  const [draft, setDraft] = useState(() => format(isControlled ? value : defaultValue));
@@ -21,6 +22,50 @@ export const InputNumber = forwardRef(function InputNumber({ classNames, control
21
22
  const latest = useRef({ isControlled, value, defaultValue });
22
23
  latest.current = { isControlled, value, defaultValue };
23
24
  useImperativeHandle(forwardedRef, () => inputRef.current);
25
+ const holdRef = useRef(null);
26
+ const timerRef = useRef(undefined);
27
+ const suppressClickRef = useRef(false);
28
+ const stopHolding = useCallback(() => {
29
+ clearTimeout(timerRef.current);
30
+ timerRef.current = undefined;
31
+ const hold = holdRef.current;
32
+ holdRef.current = null;
33
+ if (hold?.button.hasPointerCapture?.(hold.pointerId)) {
34
+ try {
35
+ hold.button.releasePointerCapture(hold.pointerId);
36
+ }
37
+ catch { /* The pointer may already be inactive. */ }
38
+ }
39
+ }, []);
40
+ useEffect(() => {
41
+ const onVisibilityChange = () => { if (document.hidden)
42
+ stopHolding(); };
43
+ const onPointerEnd = (event) => {
44
+ if (event.pointerId === holdRef.current?.pointerId)
45
+ stopHolding();
46
+ };
47
+ const updateModifiers = (event) => {
48
+ if (holdRef.current)
49
+ holdRef.current.modifiers = {
50
+ shiftKey: event.shiftKey, altKey: event.altKey, ctrlKey: event.ctrlKey, metaKey: event.metaKey,
51
+ };
52
+ };
53
+ window.addEventListener('blur', stopHolding);
54
+ window.addEventListener('pointerup', onPointerEnd);
55
+ window.addEventListener('pointercancel', onPointerEnd);
56
+ window.addEventListener('keydown', updateModifiers);
57
+ window.addEventListener('keyup', updateModifiers);
58
+ document.addEventListener('visibilitychange', onVisibilityChange);
59
+ return () => {
60
+ stopHolding();
61
+ window.removeEventListener('blur', stopHolding);
62
+ window.removeEventListener('pointerup', onPointerEnd);
63
+ window.removeEventListener('pointercancel', onPointerEnd);
64
+ window.removeEventListener('keydown', updateModifiers);
65
+ window.removeEventListener('keyup', updateModifiers);
66
+ document.removeEventListener('visibilitychange', onVisibilityChange);
67
+ };
68
+ }, [stopHolding]);
24
69
  useBrowserLayoutEffect(() => {
25
70
  if (isControlled && currentValue !== finiteValue(value))
26
71
  setDraft(format(value));
@@ -32,6 +77,7 @@ export const InputNumber = forwardRef(function InputNumber({ classNames, control
32
77
  return;
33
78
  let resetTimer;
34
79
  const reset = (event) => {
80
+ stopHolding();
35
81
  clearTimeout(resetTimer);
36
82
  // Native reset runs after the event; restore the public default, not the latest draft.
37
83
  resetTimer = setTimeout(() => {
@@ -45,33 +91,98 @@ export const InputNumber = forwardRef(function InputNumber({ classNames, control
45
91
  };
46
92
  form.addEventListener('reset', reset);
47
93
  return () => { clearTimeout(resetTimer); form.removeEventListener('reset', reset); };
48
- }, [props.form]);
94
+ }, [props.form, stopHolding]);
49
95
  const cannotStep = state.disabled || state.readOnly || step === 'any' || (min !== undefined && max !== undefined && min > max);
50
96
  const decreaseDisabled = cannotStep || (currentValue !== null && min !== undefined && currentValue <= min);
51
97
  const increaseDisabled = cannotStep || (currentValue !== null && max !== undefined && currentValue >= max);
52
- function changeStep(direction) {
98
+ useBrowserLayoutEffect(() => {
99
+ const direction = holdRef.current?.direction;
100
+ if (cannotStep || !controls || !pressAndHold
101
+ || (direction === 'increment' && increaseDisabled)
102
+ || (direction === 'decrement' && decreaseDisabled))
103
+ stopHolding();
104
+ }, [cannotStep, controls, decreaseDisabled, increaseDisabled, pressAndHold, stopHolding]);
105
+ function changeStep(direction, modifiers) {
53
106
  const input = inputRef.current;
54
107
  if (!input || input.matches(':disabled') || input.readOnly || cannotStep)
55
- return;
56
- const before = input.value;
57
- // Native stepping applies the step base and decimal rounding without a custom parser.
58
- if (direction === 'increment')
59
- input.stepUp();
60
- else
61
- input.stepDown();
108
+ return false;
109
+ const changed = stepInputNumber(input, direction, resolveInputNumberStep(modifiers, stepModifiers));
62
110
  input.focus({ preventScroll: true });
63
- if (input.value === before)
64
- return;
111
+ if (!changed)
112
+ return false;
65
113
  setDraft(input.value);
66
114
  onValueChange?.(Number.isFinite(input.valueAsNumber) ? input.valueAsNumber : null, { reason: direction });
115
+ return true;
116
+ }
117
+ const stepRef = useRef(changeStep);
118
+ stepRef.current = changeStep;
119
+ const intervalRef = useRef(repeatInterval);
120
+ intervalRef.current = repeatInterval;
121
+ function startHolding(event, direction) {
122
+ if (event.button !== 0 || !event.isPrimary)
123
+ return;
124
+ event.preventDefault();
125
+ stopHolding();
126
+ suppressClickRef.current = false;
127
+ if (!pressAndHold)
128
+ return;
129
+ suppressClickRef.current = true;
130
+ const modifiers = {
131
+ shiftKey: event.shiftKey, altKey: event.altKey, ctrlKey: event.ctrlKey, metaKey: event.metaKey,
132
+ };
133
+ if (!changeStep(direction, modifiers))
134
+ return;
135
+ const button = event.currentTarget;
136
+ if (!button.isConnected || button.disabled)
137
+ return;
138
+ holdRef.current = { button, pointerId: event.pointerId, direction, modifiers };
139
+ try {
140
+ button.setPointerCapture(event.pointerId);
141
+ }
142
+ catch { /* Window listeners retain release handling. */ }
143
+ const repeat = () => {
144
+ const hold = holdRef.current;
145
+ if (!hold || document.hidden || !stepRef.current(hold.direction, hold.modifiers)) {
146
+ stopHolding();
147
+ return;
148
+ }
149
+ if (holdRef.current)
150
+ timerRef.current = setTimeout(repeat, normalizeInputNumberRepeat(intervalRef.current, 80, 32));
151
+ };
152
+ timerRef.current = setTimeout(repeat, normalizeInputNumberRepeat(repeatDelay, 400, 150));
153
+ }
154
+ function buttonEvents(direction) {
155
+ return {
156
+ onPointerDown: (event) => startHolding(event, direction),
157
+ onPointerUp: stopHolding,
158
+ onPointerCancel: stopHolding,
159
+ onLostPointerCapture: stopHolding,
160
+ onBlur: stopHolding,
161
+ onClick: (event) => {
162
+ if (event.detail > 0 && suppressClickRef.current) {
163
+ suppressClickRef.current = false;
164
+ return;
165
+ }
166
+ suppressClickRef.current = false;
167
+ changeStep(direction, event);
168
+ },
169
+ };
67
170
  }
68
171
  const iconSize = size === 'sm' ? 18 : size === 'lg' ? 26 : 22;
69
172
  const buttonSize = size === 'sm' ? 'icon-sm' : size === 'lg' ? 'icon-lg' : 'icon';
70
- return _jsx(Input, { ...props, ref: inputRef, type: "number", inputMode: inputMode, textAlign: textAlign, clearable: false, size: size, min: min, max: max, step: step, disabled: state.disabled, readOnly: state.readOnly, value: draft, classNames: { ...classNames, root: ['inkra-input-number', classNames?.root].filter(Boolean).join(' ') }, onChange: event => {
173
+ return _jsx(Input, { ...props, ref: inputRef, type: "number", inputMode: inputMode, textAlign: textAlign, clearable: false, size: size, min: min, max: max, step: step, disabled: state.disabled, readOnly: state.readOnly, value: draft, classNames: { ...classNames, root: ['inkra-input-number', classNames?.root].filter(Boolean).join(' ') }, onBlur: event => { stopHolding(); onBlur?.(event); }, onKeyDown: event => {
174
+ onKeyDown?.(event);
175
+ if (event.defaultPrevented || event.nativeEvent.isComposing || (event.key !== 'ArrowUp' && event.key !== 'ArrowDown'))
176
+ return;
177
+ if (resolveInputNumberStep(event, stepModifiers) === undefined)
178
+ return;
179
+ event.preventDefault();
180
+ changeStep(event.key === 'ArrowUp' ? 'increment' : 'decrement', event);
181
+ }, onChange: event => {
71
182
  const input = event.currentTarget;
72
183
  const nextValue = Number.isFinite(input.valueAsNumber) ? input.valueAsNumber : null;
73
184
  setDraft(input.value);
74
185
  onChange?.(event);
75
186
  onValueChange?.(nextValue, { reason: 'input' });
76
- }, startAction: controls ? _jsx(Tooltip.Provider, { children: _jsx("span", { className: "inkra-input-number__controls", children: _jsxs(Tooltip.Root, { children: [_jsx(Tooltip.Trigger, { asChild: true, children: _jsx(Button, { type: "button", size: buttonSize, variant: "ghost", disabled: decreaseDisabled, "aria-label": decrementButtonLabel, onPointerDown: event => event.preventDefault(), onClick: () => changeStep('decrement'), children: _jsx(PencilIcon, { paths: minusPaths, size: iconSize, sourceSize: 24 }) }) }), _jsx(Tooltip.Portal, { children: _jsx(Tooltip.Content, { children: decrementButtonLabel }) })] }) }) }) : undefined, endAction: controls ? _jsx(Tooltip.Provider, { children: _jsx("span", { className: "inkra-input-number__controls", children: _jsxs(Tooltip.Root, { children: [_jsx(Tooltip.Trigger, { asChild: true, children: _jsx(Button, { type: "button", size: buttonSize, variant: "ghost", disabled: increaseDisabled, "aria-label": incrementButtonLabel, onPointerDown: event => event.preventDefault(), onClick: () => changeStep('increment'), children: _jsx(PencilIcon, { paths: plusPaths, size: iconSize, sourceSize: 24 }) }) }), _jsx(Tooltip.Portal, { children: _jsx(Tooltip.Content, { children: incrementButtonLabel }) })] }) }) }) : undefined });
187
+ }, startAction: controls ? _jsx(Tooltip.Provider, { children: _jsx("span", { className: "inkra-input-number__controls", children: _jsxs(Tooltip.Root, { children: [_jsx(Tooltip.Trigger, { asChild: true, children: _jsx(Button, { type: "button", size: buttonSize, variant: "ghost", disabled: decreaseDisabled, "aria-label": decrementButtonLabel, ...buttonEvents('decrement'), children: _jsx(PencilIcon, { paths: minusPaths, size: iconSize, sourceSize: 24 }) }) }), _jsx(Tooltip.Portal, { children: _jsx(Tooltip.Content, { children: decrementButtonLabel }) })] }) }) }) : undefined, endAction: controls ? _jsx(Tooltip.Provider, { children: _jsx("span", { className: "inkra-input-number__controls", children: _jsxs(Tooltip.Root, { children: [_jsx(Tooltip.Trigger, { asChild: true, children: _jsx(Button, { type: "button", size: buttonSize, variant: "ghost", disabled: increaseDisabled, "aria-label": incrementButtonLabel, ...buttonEvents('increment'), children: _jsx(PencilIcon, { paths: plusPaths, size: iconSize, sourceSize: 24 }) }) }), _jsx(Tooltip.Portal, { children: _jsx(Tooltip.Content, { children: incrementButtonLabel }) })] }) }) }) : undefined });
77
188
  });
@@ -0,0 +1,13 @@
1
+ export interface InputNumberStepModifier {
2
+ shiftKey?: boolean;
3
+ altKey?: boolean;
4
+ ctrlKey?: boolean;
5
+ metaKey?: boolean;
6
+ step: number;
7
+ }
8
+ export type InputNumberModifierState = Pick<KeyboardEvent, 'shiftKey' | 'altKey' | 'ctrlKey' | 'metaKey'>;
9
+ export type InputNumberStepDirection = 'increment' | 'decrement';
10
+ export declare const defaultInputNumberStepModifiers: readonly InputNumberStepModifier[];
11
+ export declare function resolveInputNumberStep(modifiers: InputNumberModifierState, rules?: readonly InputNumberStepModifier[]): number | undefined;
12
+ export declare function normalizeInputNumberRepeat(value: number | undefined, fallback: number, minimum: number): number;
13
+ export declare function stepInputNumber(input: HTMLInputElement, direction: InputNumberStepDirection, modifierStep?: number): boolean;
@@ -0,0 +1,50 @@
1
+ export const defaultInputNumberStepModifiers = [
2
+ { shiftKey: true, step: 10 },
3
+ ];
4
+ export function resolveInputNumberStep(modifiers, rules = defaultInputNumberStepModifiers) {
5
+ return rules.find(rule => Number.isFinite(rule.step) && rule.step > 0
6
+ && Boolean(rule.shiftKey) === modifiers.shiftKey
7
+ && Boolean(rule.altKey) === modifiers.altKey
8
+ && Boolean(rule.ctrlKey) === modifiers.ctrlKey
9
+ && Boolean(rule.metaKey) === modifiers.metaKey)?.step;
10
+ }
11
+ export function normalizeInputNumberRepeat(value, fallback, minimum) {
12
+ return value === undefined || !Number.isFinite(value)
13
+ ? fallback
14
+ : Math.min(60000, Math.max(minimum, value));
15
+ }
16
+ export function stepInputNumber(input, direction, modifierStep) {
17
+ const before = input.value;
18
+ const min = input.min === '' ? -Infinity : Number(input.min);
19
+ const max = input.max === '' ? Infinity : Number(input.max);
20
+ if (input.disabled || input.readOnly || input.step === 'any' || min > max)
21
+ return false;
22
+ if ((direction === 'increment' && input.valueAsNumber >= max)
23
+ || (direction === 'decrement' && input.valueAsNumber <= min))
24
+ return false;
25
+ if (modifierStep === undefined) {
26
+ if (direction === 'increment')
27
+ input.stepUp();
28
+ else
29
+ input.stepDown();
30
+ }
31
+ else {
32
+ // A detached native input anchors the step base at the current value, so
33
+ // modifier steps are absolute deltas without floating-point arithmetic.
34
+ const probe = input.cloneNode(false);
35
+ probe.removeAttribute('min');
36
+ probe.removeAttribute('max');
37
+ probe.step = String(modifierStep);
38
+ probe.defaultValue = before || '0';
39
+ probe.value = before;
40
+ if (direction === 'increment')
41
+ probe.stepUp();
42
+ else
43
+ probe.stepDown();
44
+ const next = probe.valueAsNumber;
45
+ if (!Number.isFinite(next))
46
+ return false;
47
+ input.value = String(Math.min(Number.isFinite(max) ? max : Infinity, Math.max(Number.isFinite(min) ? min : -Infinity, next)));
48
+ }
49
+ return before !== input.value;
50
+ }
@@ -1,5 +1,5 @@
1
1
  import type { ComponentPropsWithoutRef, ReactNode } from 'react';
2
- import type { NumberTickerLocale } from './model.js';
2
+ import type { NumberTickerCompact, NumberTickerCompactOptions, NumberTickerFormatOptions, NumberTickerLocale, NumberTickerValue } from './model.js';
3
3
  export interface NumberTickerRenderDetails {
4
4
  animating: boolean;
5
5
  formattedValue: string;
@@ -9,21 +9,23 @@ export interface NumberTickerDeltaDetails {
9
9
  formattedDelta: string;
10
10
  }
11
11
  export interface NumberTickerProps extends Omit<ComponentPropsWithoutRef<'span'>, 'children' | 'prefix'> {
12
- value: number;
12
+ value: NumberTickerValue;
13
13
  locale?: NumberTickerLocale;
14
- formatOptions?: Intl.NumberFormatOptions;
14
+ formatOptions?: NumberTickerFormatOptions;
15
+ compact?: NumberTickerCompact;
16
+ compactOptions?: NumberTickerCompactOptions;
15
17
  prefix?: ReactNode;
16
18
  suffix?: ReactNode;
17
- renderValue?: (value: number, details: NumberTickerRenderDetails) => ReactNode;
18
- getValueLabel?: (value: number, formattedValue: string) => string;
19
+ renderValue?: (value: NumberTickerValue, details: NumberTickerRenderDetails) => ReactNode;
20
+ getValueLabel?: (value: NumberTickerValue, formattedValue: string) => string;
19
21
  animate?: boolean;
20
22
  duration?: number;
21
23
  easing?: (progress: number) => number;
22
24
  showDelta?: boolean;
23
25
  deltaDuration?: number;
24
- deltaFormatOptions?: Intl.NumberFormatOptions;
25
- renderDelta?: (delta: number, details: NumberTickerDeltaDetails) => ReactNode;
26
+ deltaFormatOptions?: NumberTickerFormatOptions;
27
+ renderDelta?: (delta: NumberTickerValue, details: NumberTickerDeltaDetails) => ReactNode;
26
28
  }
27
29
  export declare const NumberTicker: import("react").ForwardRefExoticComponent<NumberTickerProps & import("react").RefAttributes<HTMLSpanElement>>;
28
- export { createNumberFormatter, easeOutCubic, formatTickerDelta, formatTickerValue, interpolateTickerValue, } from './model.js';
29
- export type { NumberTickerLocale } from './model.js';
30
+ export { createNumberFormatter, easeOutCubic, formatTickerDelta, formatTickerValue, interpolateTickerValue, numberTickerUnits, } from './model.js';
31
+ export type { NumberTickerCompact, NumberTickerCompactOptions, NumberTickerFormatOptions, NumberTickerLocale, NumberTickerUnit, NumberTickerValue } from './model.js';
@@ -2,7 +2,8 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { forwardRef, useEffect, useImperativeHandle, useMemo, useRef, useState, } from 'react';
3
3
  import { PencilText } from '../PencilText.js';
4
4
  import { useBrowserLayoutEffect } from '../internal/useBrowserLayoutEffect.js';
5
- import { canAnimateTickerValue, createNumberFormatter, easeOutCubic, formatTickerDelta, formatTickerValue, interpolateTickerValue, tickerValueChanged, } from './model.js';
5
+ import { canAnimateTickerValue, createNumberFormatter, createTickerFormatter, easeOutCubic, formatDerivedTickerDelta, formatTickerValue, interpolateTickerValue, tickerValueChanged, subtractTickerValues, tickerValueKey, } from './model.js';
6
+ import { ExactDecimal } from './precision.js';
6
7
  function usePrefersReducedMotion() {
7
8
  const [reduced, setReduced] = useState(false);
8
9
  useBrowserLayoutEffect(() => {
@@ -22,7 +23,7 @@ function lettering(content, seed) {
22
23
  function accessibleAffix(content) {
23
24
  return typeof content === 'string' || typeof content === 'number' ? String(content) : '';
24
25
  }
25
- export const NumberTicker = forwardRef(function NumberTicker({ animate = true, className, deltaDuration = 1600, deltaFormatOptions, duration = 420, easing = easeOutCubic, formatOptions, getValueLabel, locale, prefix, renderDelta, renderValue, showDelta = false, suffix, value, ...props }, forwardedRef) {
26
+ export const NumberTicker = forwardRef(function NumberTicker({ animate = true, className, compact = false, compactOptions, deltaDuration = 1600, deltaFormatOptions, duration = 420, easing = easeOutCubic, formatOptions, getValueLabel, locale, prefix, renderDelta, renderValue, showDelta = false, suffix, value, ...props }, forwardedRef) {
26
27
  const rootRef = useRef(null);
27
28
  const frameRef = useRef(null);
28
29
  const deltaTimerRef = useRef(null);
@@ -35,15 +36,17 @@ export const NumberTicker = forwardRef(function NumberTicker({ animate = true, c
35
36
  const reducedMotion = usePrefersReducedMotion();
36
37
  const localeKey = Array.isArray(locale) ? locale.join('\u0000') : locale;
37
38
  const formatOptionsKey = JSON.stringify(formatOptions ?? {});
39
+ const compactOptionsKey = tickerValueKey(compactOptions ?? {});
38
40
  const deltaFormatOptionsKey = JSON.stringify(deltaFormatOptions ?? formatOptions ?? {});
39
- const formatter = useMemo(() => createNumberFormatter(locale, formatOptions), [localeKey, formatOptionsKey]);
40
- const deltaFormatter = useMemo(() => createNumberFormatter(locale, deltaFormatOptions ?? formatOptions), [localeKey, deltaFormatOptionsKey]);
41
+ const formatter = useMemo(() => createTickerFormatter(locale, formatOptions, compact, compactOptions), [localeKey, formatOptionsKey, compact, compactOptionsKey]);
42
+ const deltaFormatter = useMemo(() => createTickerFormatter(locale, deltaFormatOptions ?? formatOptions, compact, compactOptions), [localeKey, deltaFormatOptionsKey, compact, compactOptionsKey]);
43
+ const announcementFormatter = useMemo(() => createNumberFormatter(locale, compact ? { ...formatOptions, notation: 'standard' } : formatOptions), [localeKey, formatOptionsKey, compact]);
41
44
  useImperativeHandle(forwardedRef, () => rootRef.current);
42
45
  useBrowserLayoutEffect(() => {
43
46
  const previousTarget = targetRef.current;
44
47
  targetRef.current = value;
45
- if (showDelta && tickerValueChanged(previousTarget, value) && Number.isFinite(previousTarget) && Number.isFinite(value)) {
46
- const nextDelta = value - previousTarget;
48
+ if (showDelta && tickerValueChanged(previousTarget, value)) {
49
+ const nextDelta = subtractTickerValues(value, previousTarget);
47
50
  setDelta(nextDelta);
48
51
  if (deltaTimerRef.current !== null)
49
52
  clearTimeout(deltaTimerRef.current);
@@ -109,7 +112,7 @@ export const NumberTicker = forwardRef(function NumberTicker({ animate = true, c
109
112
  }, []);
110
113
  const formattedValue = formatTickerValue(formatter, displayedValue);
111
114
  const finalFormattedValue = formatTickerValue(formatter, value);
112
- const announcedFormattedValue = formatTickerValue(formatter, announcedValue);
115
+ const announcedFormattedValue = formatTickerValue(announcementFormatter, announcedValue);
113
116
  const defaultAccessibleLabel = [accessibleAffix(prefix), announcedFormattedValue, accessibleAffix(suffix)]
114
117
  .filter(Boolean).join(' ');
115
118
  const accessibleLabel = getValueLabel?.(announcedValue, announcedFormattedValue) ?? defaultAccessibleLabel;
@@ -118,15 +121,15 @@ export const NumberTicker = forwardRef(function NumberTicker({ animate = true, c
118
121
  ? _jsx("span", { className: "inkra-number-ticker__native-value", children: formattedValue })
119
122
  : _jsx(PencilText, { seed: `number-ticker-${finalFormattedValue}`, children: finalFormattedValue }));
120
123
  const deltaContent = delta === null ? null : (() => {
121
- const formattedDelta = formatTickerDelta(deltaFormatter, delta);
124
+ const formattedDelta = formatDerivedTickerDelta(deltaFormatter, delta);
122
125
  const details = {
123
- direction: delta < 0 ? 'decrease' : 'increase',
126
+ direction: new ExactDecimal(String(delta)).lt(0) ? 'decrease' : 'increase',
124
127
  formattedDelta,
125
128
  };
126
129
  return renderDelta?.(delta, details) ?? _jsx(PencilText, { seed: `number-ticker-delta-${formattedDelta}`, children: formattedDelta });
127
130
  })();
128
131
  const classes = ['inkra-number-ticker', animating && 'inkra-number-ticker--animating', className]
129
132
  .filter(Boolean).join(' ');
130
- return _jsxs("span", { ...props, className: classes, ref: rootRef, "data-animating": animating || undefined, children: [_jsxs("span", { "aria-hidden": "true", className: "inkra-number-ticker__visual", children: [prefix !== undefined && _jsx("span", { className: "inkra-number-ticker__prefix", children: lettering(prefix, 'number-ticker-prefix') }), _jsx("span", { className: "inkra-number-ticker__value", children: valueContent }), suffix !== undefined && _jsx("span", { className: "inkra-number-ticker__suffix", children: lettering(suffix, 'number-ticker-suffix') }), deltaContent !== null && _jsx("span", { className: "inkra-number-ticker__delta", "data-direction": delta < 0 ? 'decrease' : 'increase', children: deltaContent })] }), _jsx("span", { className: "inkra-number-ticker__announcement", "aria-atomic": "true", "aria-live": "polite", children: accessibleLabel })] });
133
+ return _jsxs("span", { ...props, className: classes, ref: rootRef, "data-animating": animating || undefined, children: [_jsxs("span", { "aria-hidden": "true", className: "inkra-number-ticker__visual", children: [prefix !== undefined && _jsx("span", { className: "inkra-number-ticker__prefix", children: lettering(prefix, 'number-ticker-prefix') }), _jsx("span", { className: "inkra-number-ticker__value", children: valueContent }), suffix !== undefined && _jsx("span", { className: "inkra-number-ticker__suffix", children: lettering(suffix, 'number-ticker-suffix') }), deltaContent !== null && _jsx("span", { className: "inkra-number-ticker__delta", "data-direction": new ExactDecimal(String(delta)).lt(0) ? 'decrease' : 'increase', children: deltaContent })] }), _jsx("span", { className: "inkra-number-ticker__announcement", "aria-atomic": "true", "aria-live": "polite", children: accessibleLabel })] });
131
134
  });
132
- export { createNumberFormatter, easeOutCubic, formatTickerDelta, formatTickerValue, interpolateTickerValue, } from './model.js';
135
+ export { createNumberFormatter, easeOutCubic, formatTickerDelta, formatTickerValue, interpolateTickerValue, numberTickerUnits, } from './model.js';
@@ -1,8 +1,41 @@
1
+ import { tickerDecimal } from './precision.js';
2
+ import type { NumberTickerValue } from './precision.js';
3
+ export { subtractTickerValues, tickerValueKey } from './precision.js';
4
+ export type { NumberTickerValue } from './precision.js';
1
5
  export type NumberTickerLocale = string | readonly string[];
2
- export declare function createNumberFormatter(locale?: NumberTickerLocale, options?: Intl.NumberFormatOptions): Intl.NumberFormat;
3
- export declare function formatTickerValue(formatter: Intl.NumberFormat, value: number): string;
4
- export declare function formatTickerDelta(formatter: Intl.NumberFormat, delta: number): string;
6
+ export type NumberTickerCompact = boolean | 'en' | 'zh';
7
+ export interface NumberTickerFormatOptions extends Intl.NumberFormatOptions {
8
+ roundingMode?: 'ceil' | 'floor' | 'expand' | 'trunc' | 'halfCeil' | 'halfFloor' | 'halfExpand' | 'halfTrunc' | 'halfEven';
9
+ }
10
+ export interface NumberTickerUnit {
11
+ factor: NumberTickerValue;
12
+ label: string;
13
+ /** Absolute display magnitude required to enter this unit. */
14
+ threshold?: NumberTickerValue;
15
+ }
16
+ export interface NumberTickerCompactOptions {
17
+ units?: readonly NumberTickerUnit[];
18
+ minUnit?: string;
19
+ maxUnit?: string;
20
+ separator?: string;
21
+ promotionThreshold?: NumberTickerValue;
22
+ promoteAfterRounding?: boolean;
23
+ overflow?: 'unit' | 'scientific';
24
+ }
25
+ export declare const numberTickerUnits: Readonly<Record<'en' | 'zh', readonly NumberTickerUnit[]>>;
26
+ type TickerFormatter = {
27
+ format(value: NumberTickerValue): string;
28
+ formatToParts(value: NumberTickerValue): Intl.NumberFormatPart[];
29
+ formatDecimal(value: ReturnType<typeof tickerDecimal>): Intl.NumberFormatPart[];
30
+ };
31
+ export declare function createNumberFormatter(locale?: NumberTickerLocale, options?: NumberTickerFormatOptions): Intl.NumberFormat;
32
+ export declare function createTickerFormatter(locale?: NumberTickerLocale, options?: NumberTickerFormatOptions, compact?: NumberTickerCompact, compactOptions?: NumberTickerCompactOptions): TickerFormatter;
33
+ export declare function formatTickerValue(formatter: TickerFormatter | Intl.NumberFormat, value: NumberTickerValue): string;
34
+ export declare function formatTickerDelta(formatter: TickerFormatter | Intl.NumberFormat, delta: NumberTickerValue): string;
35
+ export declare function formatDerivedTickerDelta(formatter: TickerFormatter, delta: NumberTickerValue): string;
5
36
  export declare function interpolateTickerValue(from: number, to: number, progress: number): number;
6
- export declare function tickerValueChanged(previous: number, next: number): boolean;
7
- export declare function canAnimateTickerValue(from: number, to: number, duration: number): boolean;
37
+ export declare function interpolateTickerValue(from: bigint, to: bigint, progress: number): bigint;
38
+ export declare function interpolateTickerValue(from: NumberTickerValue, to: NumberTickerValue, progress: number): NumberTickerValue;
39
+ export declare function tickerValueChanged(previous: NumberTickerValue, next: NumberTickerValue): boolean;
40
+ export declare function canAnimateTickerValue(from: NumberTickerValue, to: NumberTickerValue, duration: number): boolean;
8
41
  export declare function easeOutCubic(progress: number): number;
@@ -1,29 +1,158 @@
1
+ import { ExactDecimal, formatExactParts, tickerDecimal } from './precision.js';
2
+ export { subtractTickerValues, tickerValueKey } from './precision.js';
3
+ export const numberTickerUnits = {
4
+ en: ['K', 'M', 'B', 'T', 'Qa', 'Qi', 'Sx', 'Sp', 'Oc', 'No', 'Dc']
5
+ .map((label, index) => ({ label, factor: Number(`1e${(index + 1) * 3}`) })),
6
+ zh: [{ label: '千', factor: 1e3 }, ...['万', '亿', '兆', '京', '垓', '秭', '穰', '沟', '涧', '正', '载', '极', '恒河沙', '阿僧祇', '那由他', '不可思议', '无量大数']
7
+ .map((label, index) => ({ label, factor: Number(`1e${(index + 1) * 4}`) }))],
8
+ };
1
9
  export function createNumberFormatter(locale = 'en-US', options) {
2
- return new Intl.NumberFormat(locale, options);
10
+ const formatter = new Intl.NumberFormat(locale, options);
11
+ if (options?.roundingMode && options.roundingMode !== 'halfExpand' && !('roundingMode' in formatter.resolvedOptions())) {
12
+ throw new RangeError('NumberTicker roundingMode requires Intl.NumberFormat rounding support');
13
+ }
14
+ return formatter;
15
+ }
16
+ export function createTickerFormatter(locale = 'en-US', options, compact = false, compactOptions = {}) {
17
+ if (!compact || options?.notation === 'scientific' || options?.notation === 'engineering') {
18
+ const native = createNumberFormatter(locale, options);
19
+ const formatToParts = (value) => formatExactParts(native, value);
20
+ return { formatToParts, formatDecimal: value => formatExactParts(native, value), format: value => formatToParts(value).map(part => part.value).join('') };
21
+ }
22
+ const precision = {
23
+ minimumFractionDigits: 0,
24
+ maximumFractionDigits: Math.max(1, options?.minimumFractionDigits ?? 0),
25
+ ...options,
26
+ notation: 'standard',
27
+ };
28
+ const formatter = createNumberFormatter(locale, precision);
29
+ const language = compact === true
30
+ ? (formatter.resolvedOptions().locale.startsWith('zh') ? 'zh' : 'en')
31
+ : compact;
32
+ const multiplier = tickerDecimal(compactOptions.promotionThreshold ?? 1);
33
+ if (!multiplier.gt(0) || !multiplier.isFinite())
34
+ throw new RangeError('promotionThreshold must be positive and finite');
35
+ const supplied = [...(compactOptions.units ?? numberTickerUnits[language])]
36
+ .map(unit => ({ ...unit, factor: tickerDecimal(unit.factor), threshold: unit.threshold === undefined ? undefined : tickerDecimal(unit.threshold) }))
37
+ .sort((a, b) => a.factor.cmp(b.factor));
38
+ const labels = new Set();
39
+ let previousFactor = tickerDecimal(1);
40
+ let previousThreshold = tickerDecimal(0);
41
+ for (const unit of supplied) {
42
+ const threshold = unit.threshold ?? unit.factor.times(multiplier);
43
+ if (!unit.label.trim() || labels.has(unit.label) || !unit.factor.isFinite() || unit.factor.lte(previousFactor)
44
+ || !threshold.isFinite() || threshold.lte(previousThreshold)) {
45
+ throw new RangeError('Compact units need unique nonempty labels, increasing finite factors > 1 and positive increasing thresholds');
46
+ }
47
+ labels.add(unit.label);
48
+ previousFactor = unit.factor;
49
+ previousThreshold = threshold;
50
+ }
51
+ const units = [{ factor: tickerDecimal(1), label: '', threshold: undefined }, ...supplied];
52
+ const indexFor = (label, fallback) => {
53
+ if (label === undefined)
54
+ return fallback;
55
+ const index = units.findIndex(unit => unit.label === label);
56
+ if (index < 0)
57
+ throw new RangeError(`Unknown compact unit: ${label}`);
58
+ return index;
59
+ };
60
+ const minIndex = indexFor(compactOptions.minUnit, 0);
61
+ const maxIndex = indexFor(compactOptions.maxUnit, units.length - 1);
62
+ if (minIndex > maxIndex)
63
+ throw new RangeError('minUnit must not exceed maxUnit');
64
+ const thresholdFor = (index) => units[index].threshold ?? units[index].factor.times(multiplier);
65
+ const last = units[maxIndex];
66
+ const overflowThreshold = maxIndex + 1 < units.length ? thresholdFor(maxIndex + 1)
67
+ : thresholdFor(maxIndex).times(maxIndex > 0 ? last.factor.div(units[maxIndex - 1].factor) : 1000);
68
+ const overflow = compactOptions.overflow ?? (compactOptions.maxUnit !== undefined ? 'unit' : 'scientific');
69
+ const scientific = createNumberFormatter(locale, { ...precision, notation: 'scientific' });
70
+ const rounding = createNumberFormatter('en-US', {
71
+ ...precision, style: 'decimal', numberingSystem: 'latn', useGrouping: false,
72
+ });
73
+ const percentScale = options?.style === 'percent' ? 100 : 1;
74
+ const formatDecimal = (exact) => {
75
+ if (!exact.isFinite())
76
+ return formatExactParts(formatter, exact);
77
+ let index = minIndex;
78
+ const reaches = (threshold) => {
79
+ if (compactOptions.promoteAfterRounding === false)
80
+ return exact.abs().times(percentScale).gte(threshold);
81
+ const scaled = exact.div(units[index].factor).times(percentScale);
82
+ const rounded = formatExactParts(rounding, scaled).map(part => part.value).join('');
83
+ return new ExactDecimal(rounded).abs().times(units[index].factor).gte(threshold);
84
+ };
85
+ while (index < maxIndex) {
86
+ if (!reaches(thresholdFor(index + 1)))
87
+ break;
88
+ index++;
89
+ }
90
+ if (index === maxIndex && overflow === 'scientific' && reaches(overflowThreshold))
91
+ return formatExactParts(scientific, exact);
92
+ const { factor, label } = units[index];
93
+ const parts = formatExactParts(formatter, exact.div(factor));
94
+ if (label) {
95
+ let insertion = parts.length;
96
+ while (insertion > 0 && !['integer', 'fraction'].includes(parts[insertion - 1].type))
97
+ insertion--;
98
+ const separator = compactOptions.separator ?? ' ';
99
+ parts.splice(insertion, 0, ...(separator ? [{ type: 'literal', value: separator }] : []), { type: 'compact', value: label });
100
+ }
101
+ return parts;
102
+ };
103
+ const formatToParts = (value) => formatDecimal(tickerDecimal(value));
104
+ return { formatToParts, formatDecimal, format: value => formatToParts(value).map(part => part.value).join('') };
3
105
  }
4
106
  export function formatTickerValue(formatter, value) {
5
- return formatter.format(value);
107
+ return formatter instanceof Intl.NumberFormat ? formatExactParts(formatter, value).map(part => part.value).join('') : formatter.format(value);
6
108
  }
7
109
  export function formatTickerDelta(formatter, delta) {
8
- const sign = delta > 0 ? '+' : delta < 0 ? '\u2212' : '';
9
- const magnitude = formatter.formatToParts(Math.abs(delta))
110
+ const exact = tickerDecimal(delta);
111
+ return formatDecimalDelta(formatter, exact);
112
+ }
113
+ // Deltas are derived from validated inputs and can legitimately exceed input bounds.
114
+ export function formatDerivedTickerDelta(formatter, delta) {
115
+ return formatDecimalDelta(formatter, new ExactDecimal(String(delta)));
116
+ }
117
+ function formatDecimalDelta(formatter, exact) {
118
+ const sign = exact.gt(0) ? '+' : exact.lt(0) ? '\u2212' : '';
119
+ const magnitude = (formatter instanceof Intl.NumberFormat ? formatExactParts(formatter, exact) : formatter.formatDecimal(exact))
10
120
  .filter(part => part.type !== 'plusSign' && part.type !== 'minusSign')
121
+ .filter(part => part.type !== 'literal' || !/^[()]$/.test(part.value))
11
122
  .map(part => part.value)
12
123
  .join('');
13
124
  return `${sign}${magnitude}`;
14
125
  }
15
126
  export function interpolateTickerValue(from, to, progress) {
16
- if (!Number.isFinite(from) || !Number.isFinite(to))
127
+ if (!tickerDecimal(from).isFinite() || !tickerDecimal(to).isFinite())
17
128
  return to;
18
- const boundedProgress = Math.min(1, Math.max(0, progress));
19
- return from + (to - from) * boundedProgress;
129
+ const boundedProgress = Number.isFinite(progress) ? Math.min(1, Math.max(0, progress)) : 1;
130
+ if (boundedProgress === 0)
131
+ return from;
132
+ if (boundedProgress === 1)
133
+ return to;
134
+ if (typeof from === 'number' && typeof to === 'number') {
135
+ const distance = to - from;
136
+ return Number.isFinite(distance) ? from + distance * boundedProgress
137
+ : from * (1 - boundedProgress) + to * boundedProgress;
138
+ }
139
+ const start = tickerDecimal(from);
140
+ const next = start.plus(tickerDecimal(to).minus(start).times(String(boundedProgress)));
141
+ return typeof from === 'bigint' && typeof to === 'bigint' ? BigInt(next.trunc().toFixed(0)) : next.toString();
20
142
  }
21
143
  export function tickerValueChanged(previous, next) {
22
- return !Object.is(previous, next);
144
+ if (Object.is(previous, next))
145
+ return false;
146
+ const before = tickerDecimal(previous);
147
+ const after = tickerDecimal(next);
148
+ return !before.eq(after) || (before.isZero() && before.isNegative() !== after.isNegative());
23
149
  }
24
150
  export function canAnimateTickerValue(from, to, duration) {
25
- return Number.isFinite(from)
26
- && Number.isFinite(to)
151
+ const start = tickerDecimal(from);
152
+ const end = tickerDecimal(to);
153
+ return start.isFinite() && end.isFinite()
154
+ && typeof from === typeof to
155
+ && (typeof to === 'number' || (Math.max(start.sd(), end.sd(), Math.abs(start.e - end.e)) <= 512))
27
156
  && Number.isFinite(duration)
28
157
  && duration > 0
29
158
  && tickerValueChanged(from, to);
@@ -0,0 +1,7 @@
1
+ import { Decimal } from 'decimal.js';
2
+ export type NumberTickerValue = number | bigint | string;
3
+ export declare const ExactDecimal: typeof Decimal;
4
+ export declare function tickerDecimal(value: NumberTickerValue): Decimal;
5
+ export declare function tickerValueKey(value: unknown): string;
6
+ export declare function formatExactParts(formatter: Intl.NumberFormat, value: NumberTickerValue | Decimal): Intl.NumberFormatPart[];
7
+ export declare function subtractTickerValues(next: NumberTickerValue, previous: NumberTickerValue): NumberTickerValue | null;