@inkra/react 0.6.0 → 0.9.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 (72) hide show
  1. package/README.md +166 -1
  2. package/dist/Button.js +1 -1
  3. package/dist/PencilIcon.d.ts +14 -1
  4. package/dist/PencilIcon.js +88 -52
  5. package/dist/PencilText.js +8 -0
  6. package/dist/form/Textarea.d.ts +4 -0
  7. package/dist/form/Textarea.js +14 -8
  8. package/dist/form/index.d.ts +1 -1
  9. package/dist/form/textareaSizing.d.ts +29 -0
  10. package/dist/form/textareaSizing.js +153 -0
  11. package/dist/guide/index.d.ts +77 -0
  12. package/dist/guide/index.js +379 -0
  13. package/dist/i18n/catalog.d.ts +1 -1
  14. package/dist/i18n/catalog.js +15 -0
  15. package/dist/image-viewer/index.d.ts +2 -0
  16. package/dist/image-viewer/index.js +28 -28
  17. package/dist/index.d.ts +6 -0
  18. package/dist/index.js +5 -0
  19. package/dist/infinite-scroll/gesture.d.ts +19 -0
  20. package/dist/infinite-scroll/gesture.js +218 -0
  21. package/dist/infinite-scroll/index.d.ts +55 -0
  22. package/dist/infinite-scroll/index.js +543 -0
  23. package/dist/infinite-scroll/model.d.ts +33 -0
  24. package/dist/infinite-scroll/model.js +168 -0
  25. package/dist/internal/iconFill.d.ts +16 -0
  26. package/dist/internal/iconFill.js +275 -0
  27. package/dist/inventory-grid/index.d.ts +35 -0
  28. package/dist/inventory-grid/index.js +215 -0
  29. package/dist/inventory-grid/model.d.ts +56 -0
  30. package/dist/inventory-grid/model.js +93 -0
  31. package/dist/inventory-grid/outline.d.ts +9 -0
  32. package/dist/inventory-grid/outline.js +54 -0
  33. package/dist/layout/GridBoard.d.ts +22 -0
  34. package/dist/layout/GridBoard.js +113 -0
  35. package/dist/layout/GridResizable.d.ts +31 -0
  36. package/dist/layout/GridResizable.js +35 -0
  37. package/dist/layout/index.d.ts +19 -4
  38. package/dist/layout/index.js +45 -9
  39. package/dist/layout/swipeNavigation.d.ts +15 -0
  40. package/dist/layout/swipeNavigation.js +121 -0
  41. package/dist/loading/index.d.ts +58 -0
  42. package/dist/loading/index.js +188 -0
  43. package/dist/loading/locales.d.ts +15 -0
  44. package/dist/loading/locales.js +34 -0
  45. package/dist/loading/model.d.ts +6 -0
  46. package/dist/loading/model.js +29 -0
  47. package/dist/masonry/index.d.ts +25 -0
  48. package/dist/masonry/index.js +227 -0
  49. package/dist/masonry/layout.d.ts +35 -0
  50. package/dist/masonry/layout.js +67 -0
  51. package/dist/number-ticker/RollingValue.d.ts +6 -0
  52. package/dist/number-ticker/RollingValue.js +10 -0
  53. package/dist/number-ticker/index.d.ts +4 -0
  54. package/dist/number-ticker/index.js +55 -7
  55. package/dist/number-ticker/rolling.d.ts +35 -0
  56. package/dist/number-ticker/rolling.js +87 -0
  57. package/dist/pagination/index.d.ts +17 -0
  58. package/dist/pagination/index.js +30 -6
  59. package/dist/styles/base.css +9 -1
  60. package/dist/styles/form.css +10 -0
  61. package/dist/styles/guide.css +202 -0
  62. package/dist/styles/image-viewer.css +20 -0
  63. package/dist/styles/infinite-scroll.css +171 -0
  64. package/dist/styles/inventory-grid.css +43 -0
  65. package/dist/styles/layout.css +85 -0
  66. package/dist/styles/loading.css +348 -0
  67. package/dist/styles/masonry.css +68 -0
  68. package/dist/styles/number-ticker.css +46 -0
  69. package/dist/styles/pagination.css +10 -0
  70. package/dist/styles.css +1162 -146
  71. package/dist/toast/manager.d.ts +4 -4
  72. package/package.json +33 -1
@@ -0,0 +1,29 @@
1
+ export function finiteDuration(value, fallback) {
2
+ return typeof value === 'number' && Number.isFinite(value) ? Math.max(0, value) : fallback;
3
+ }
4
+ export function normalizeLoadingMax(max) {
5
+ return typeof max === 'number' && Number.isFinite(max) && max > 0 ? max : 100;
6
+ }
7
+ export function normalizeLoadingProgress(value, max) {
8
+ if (value == null)
9
+ return value;
10
+ if (!Number.isFinite(value))
11
+ return 0;
12
+ return Math.max(0, Math.min(max, value));
13
+ }
14
+ export function normalizeTipIndex(index, length) {
15
+ if (length <= 0)
16
+ return 0;
17
+ if (typeof index !== 'number' || !Number.isFinite(index))
18
+ return 0;
19
+ return ((Math.trunc(index) % length) + length) % length;
20
+ }
21
+ export function nextTipIndex(current, length, order, random = Math.random) {
22
+ if (length <= 1)
23
+ return 0;
24
+ const normalized = normalizeTipIndex(current, length);
25
+ if (order === 'sequence')
26
+ return (normalized + 1) % length;
27
+ const offset = Math.min(length - 2, Math.max(0, Math.floor(random() * (length - 1))));
28
+ return (normalized + 1 + offset) % length;
29
+ }
@@ -0,0 +1,25 @@
1
+ import type { HTMLAttributes, ReactElement, ReactNode, RefAttributes } from 'react';
2
+ import { type MasonryDirection, type MasonryStrategy } from './layout.js';
3
+ export type MasonryKey = string | number | bigint;
4
+ export interface MasonryRenderState<Item> {
5
+ item: Item;
6
+ index: number;
7
+ key: MasonryKey;
8
+ }
9
+ export type MasonryItemProps = Omit<HTMLAttributes<HTMLDivElement>, 'children' | 'style'>;
10
+ export interface MasonryProps<Item> extends Omit<HTMLAttributes<HTMLDivElement>, 'children'> {
11
+ items: readonly Item[];
12
+ getItemKey: (item: Item, index: number) => MasonryKey;
13
+ renderItem: (state: MasonryRenderState<Item>) => ReactNode;
14
+ getItemProps?: (state: MasonryRenderState<Item>) => MasonryItemProps;
15
+ columns?: number | 'auto';
16
+ minColumnWidth?: number;
17
+ maxColumns?: number;
18
+ columnGap?: number;
19
+ rowGap?: number;
20
+ strategy?: MasonryStrategy;
21
+ dir?: MasonryDirection;
22
+ animated?: boolean;
23
+ }
24
+ export declare const Masonry: <Item>(props: MasonryProps<Item> & RefAttributes<HTMLDivElement>) => ReactElement | null;
25
+ export type { MasonryDirection, MasonryStrategy } from './layout.js';
@@ -0,0 +1,227 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { createElement as _createElement } from "react";
3
+ import { forwardRef, useCallback, useEffect, useMemo, useRef, useState, } from 'react';
4
+ import { useBrowserLayoutEffect } from '../internal/useBrowserLayoutEffect.js';
5
+ import { calculateMasonryLayout, normalizeMasonryColumnCount, normalizeMasonrySpacing, } from './layout.js';
6
+ function classes(...values) {
7
+ return values.filter(Boolean).join(' ');
8
+ }
9
+ function internalItemKey(key) {
10
+ return `${typeof key}:${String(key)}`;
11
+ }
12
+ function assignRef(ref, value) {
13
+ if (typeof ref === 'function')
14
+ ref(value);
15
+ else if (ref)
16
+ ref.current = value;
17
+ }
18
+ function cssPixels(value) {
19
+ const parsed = Number.parseFloat(value);
20
+ return Number.isFinite(parsed) ? parsed : 0;
21
+ }
22
+ function samePositions(current, next) {
23
+ if (current.size !== next.length)
24
+ return false;
25
+ return next.every(position => {
26
+ const existing = current.get(position.key);
27
+ return existing !== undefined
28
+ && existing.column === position.column
29
+ && Math.abs(existing.x - position.x) < 0.25
30
+ && Math.abs(existing.y - position.y) < 0.25
31
+ && Math.abs(existing.width - position.width) < 0.25
32
+ && Math.abs(existing.height - position.height) < 0.25;
33
+ });
34
+ }
35
+ function emptyLayout(signature) {
36
+ return { signature, columnCount: 1, height: 0, positions: new Map(), ready: false };
37
+ }
38
+ function MasonryInner({ animated = true, className, columnGap = 16, columns = 'auto', dir, getItemKey, getItemProps, items, maxColumns, minColumnWidth = 240, renderItem, role, rowGap = 16, strategy = 'shortest', style, ...props }, forwardedRef) {
39
+ const rootRef = useRef(null);
40
+ const itemRefs = useRef(new Map());
41
+ const frameRef = useRef(null);
42
+ const safeColumnGap = normalizeMasonrySpacing(columnGap, 16);
43
+ const safeRowGap = normalizeMasonrySpacing(rowGap, 16);
44
+ const safeMinColumnWidth = Number.isFinite(minColumnWidth) && minColumnWidth > 0
45
+ ? minColumnWidth
46
+ : 240;
47
+ const safeMaxColumns = maxColumns === undefined
48
+ ? undefined
49
+ : Number.isFinite(maxColumns)
50
+ ? normalizeMasonryColumnCount(maxColumns)
51
+ : undefined;
52
+ const fixedColumns = columns === 'auto' ? undefined : normalizeMasonryColumnCount(columns);
53
+ const entries = useMemo(() => {
54
+ const next = items.map((item, index) => {
55
+ const key = getItemKey(item, index);
56
+ return { item, index, key, internalKey: internalItemKey(key) };
57
+ });
58
+ const keys = new Set();
59
+ for (const entry of next) {
60
+ if (keys.has(entry.internalKey)) {
61
+ throw new Error(`Masonry item keys must be unique. Received duplicate key: ${String(entry.key)}`);
62
+ }
63
+ keys.add(entry.internalKey);
64
+ }
65
+ return next;
66
+ }, [getItemKey, items]);
67
+ const signature = useMemo(() => JSON.stringify([
68
+ fixedColumns ?? 'auto',
69
+ safeMinColumnWidth,
70
+ safeMaxColumns ?? null,
71
+ safeColumnGap,
72
+ safeRowGap,
73
+ strategy,
74
+ dir ?? null,
75
+ ]), [
76
+ dir,
77
+ fixedColumns,
78
+ safeColumnGap,
79
+ safeMaxColumns,
80
+ safeMinColumnWidth,
81
+ safeRowGap,
82
+ strategy,
83
+ ]);
84
+ const [layout, setLayout] = useState(() => emptyLayout(signature));
85
+ const effectiveLayout = layout.signature === signature ? layout : emptyLayout(signature);
86
+ const ready = effectiveLayout.ready && entries.every(entry => effectiveLayout.positions.has(entry.internalKey));
87
+ const setRootRef = useCallback((node) => {
88
+ rootRef.current = node;
89
+ assignRef(forwardedRef, node);
90
+ }, [forwardedRef]);
91
+ const setItemRef = useCallback((key, node) => {
92
+ if (node)
93
+ itemRefs.current.set(key, node);
94
+ else
95
+ itemRefs.current.delete(key);
96
+ }, []);
97
+ const updateLayout = useCallback(() => {
98
+ const root = rootRef.current;
99
+ if (!root)
100
+ return;
101
+ const rootStyle = getComputedStyle(root);
102
+ const paddingLeft = cssPixels(rootStyle.paddingLeft);
103
+ const paddingRight = cssPixels(rootStyle.paddingRight);
104
+ const paddingTop = cssPixels(rootStyle.paddingTop);
105
+ const paddingBottom = cssPixels(rootStyle.paddingBottom);
106
+ const borderTop = cssPixels(rootStyle.borderTopWidth);
107
+ const borderBottom = cssPixels(rootStyle.borderBottomWidth);
108
+ const containerWidth = root.clientWidth - paddingLeft - paddingRight;
109
+ if (!Number.isFinite(containerWidth) || containerWidth <= 0)
110
+ return;
111
+ const measuredItems = [];
112
+ for (const entry of entries) {
113
+ const node = itemRefs.current.get(entry.internalKey);
114
+ if (!node)
115
+ return;
116
+ measuredItems.push({
117
+ key: entry.internalKey,
118
+ height: node.offsetHeight,
119
+ });
120
+ }
121
+ const resolvedDirection = dir
122
+ ?? (rootStyle.direction === 'rtl' ? 'rtl' : 'ltr');
123
+ const next = calculateMasonryLayout({
124
+ containerWidth,
125
+ items: measuredItems,
126
+ columns: fixedColumns,
127
+ minColumnWidth: safeMinColumnWidth,
128
+ maxColumns: safeMaxColumns,
129
+ columnGap: safeColumnGap,
130
+ rowGap: safeRowGap,
131
+ strategy,
132
+ direction: resolvedDirection,
133
+ });
134
+ const positions = next.positions.map(position => ({
135
+ ...position,
136
+ x: position.x + paddingLeft,
137
+ y: position.y + paddingTop,
138
+ }));
139
+ const contentBlockSize = next.height;
140
+ const blockSize = rootStyle.boxSizing === 'border-box'
141
+ ? borderTop + paddingTop + contentBlockSize + paddingBottom + borderBottom
142
+ : contentBlockSize;
143
+ setLayout(current => current.signature === signature
144
+ && current.ready
145
+ && current.columnCount === next.columnCount
146
+ && Math.abs(current.height - blockSize) < 0.25
147
+ && samePositions(current.positions, positions)
148
+ ? current
149
+ : {
150
+ signature,
151
+ columnCount: next.columnCount,
152
+ height: blockSize,
153
+ positions: new Map(positions.map(position => [position.key, position])),
154
+ ready: true,
155
+ });
156
+ }, [
157
+ dir,
158
+ entries,
159
+ fixedColumns,
160
+ safeColumnGap,
161
+ safeMaxColumns,
162
+ safeMinColumnWidth,
163
+ safeRowGap,
164
+ signature,
165
+ strategy,
166
+ ]);
167
+ const scheduleLayout = useCallback(() => {
168
+ if (frameRef.current !== null || typeof window === 'undefined')
169
+ return;
170
+ frameRef.current = window.requestAnimationFrame(() => {
171
+ frameRef.current = null;
172
+ updateLayout();
173
+ });
174
+ }, [updateLayout]);
175
+ useBrowserLayoutEffect(() => {
176
+ if (layout.signature === signature)
177
+ return;
178
+ setLayout(emptyLayout(signature));
179
+ }, [layout.signature, signature]);
180
+ useEffect(() => {
181
+ const root = rootRef.current;
182
+ if (!root)
183
+ return undefined;
184
+ const observer = typeof ResizeObserver === 'undefined'
185
+ ? null
186
+ : new ResizeObserver(scheduleLayout);
187
+ observer?.observe(root);
188
+ itemRefs.current.forEach(node => observer?.observe(node));
189
+ if (!observer)
190
+ window.addEventListener('resize', scheduleLayout);
191
+ scheduleLayout();
192
+ return () => {
193
+ observer?.disconnect();
194
+ window.removeEventListener('resize', scheduleLayout);
195
+ if (frameRef.current !== null) {
196
+ window.cancelAnimationFrame(frameRef.current);
197
+ frameRef.current = null;
198
+ }
199
+ };
200
+ }, [entries, scheduleLayout, signature]);
201
+ useEffect(() => {
202
+ const fonts = typeof document === 'undefined' ? undefined : document.fonts;
203
+ if (!fonts?.addEventListener)
204
+ return undefined;
205
+ fonts.addEventListener('loadingdone', scheduleLayout);
206
+ return () => fonts.removeEventListener('loadingdone', scheduleLayout);
207
+ }, [scheduleLayout]);
208
+ const rootStyle = {
209
+ ...style,
210
+ '--inkra-masonry-column-gap': `${safeColumnGap}px`,
211
+ '--inkra-masonry-columns': fixedColumns ?? 1,
212
+ '--inkra-masonry-min-column-width': `${safeMinColumnWidth}px`,
213
+ '--inkra-masonry-row-gap': `${safeRowGap}px`,
214
+ blockSize: ready ? `${effectiveLayout.height}px` : style?.blockSize,
215
+ };
216
+ return _jsx("div", { ...props, className: classes('inkra-masonry', className), "data-animated": animated || undefined, "data-columns": ready ? effectiveLayout.columnCount : undefined, "data-layout": ready ? 'ready' : 'fallback', "data-mode": fixedColumns === undefined ? 'adaptive' : 'fixed', "data-strategy": strategy, dir: dir, ref: setRootRef, role: role, style: rootStyle, children: entries.map(entry => {
217
+ const state = entry;
218
+ const itemProps = getItemProps?.(state) ?? {};
219
+ const { className: itemClassName, role: itemRole, ...nativeItemProps } = itemProps;
220
+ const position = ready ? effectiveLayout.positions.get(entry.internalKey) : undefined;
221
+ return _createElement("div", { ...nativeItemProps, "aria-posinset": role === 'list' ? entry.index + 1 : undefined, "aria-setsize": role === 'list' ? entries.length : undefined, className: classes('inkra-masonry__item', itemClassName), "data-column": position?.column, "data-index": entry.index, "data-masonry-key": String(entry.key), "data-positioned": position ? '' : undefined, key: entry.internalKey, ref: node => setItemRef(entry.internalKey, node), role: itemRole ?? (role === 'list' ? 'listitem' : undefined), style: position ? {
222
+ inlineSize: `${position.width}px`,
223
+ transform: `translate3d(${position.x}px, ${position.y}px, 0)`,
224
+ } : undefined }, renderItem(state));
225
+ }) });
226
+ }
227
+ export const Masonry = forwardRef(MasonryInner);
@@ -0,0 +1,35 @@
1
+ export type MasonryStrategy = 'shortest' | 'sequential';
2
+ export type MasonryDirection = 'ltr' | 'rtl';
3
+ export interface MasonryLayoutItem {
4
+ key: string;
5
+ height: number;
6
+ }
7
+ export interface MasonryLayoutOptions {
8
+ containerWidth: number;
9
+ items: readonly MasonryLayoutItem[];
10
+ columns?: number;
11
+ minColumnWidth?: number;
12
+ maxColumns?: number;
13
+ columnGap?: number;
14
+ rowGap?: number;
15
+ strategy?: MasonryStrategy;
16
+ direction?: MasonryDirection;
17
+ }
18
+ export interface MasonryLayoutPosition {
19
+ key: string;
20
+ column: number;
21
+ x: number;
22
+ y: number;
23
+ width: number;
24
+ height: number;
25
+ }
26
+ export interface MasonryLayoutResult {
27
+ columnCount: number;
28
+ columnWidth: number;
29
+ height: number;
30
+ positions: readonly MasonryLayoutPosition[];
31
+ }
32
+ export declare function normalizeMasonrySpacing(value: number | undefined, fallback?: number): number;
33
+ export declare function normalizeMasonryColumnCount(value: number | undefined, fallback?: number): number;
34
+ export declare function resolveMasonryColumnCount({ columns, containerWidth, columnGap, maxColumns, minColumnWidth, }: Pick<MasonryLayoutOptions, 'columns' | 'containerWidth' | 'columnGap' | 'maxColumns' | 'minColumnWidth'>): number;
35
+ export declare function calculateMasonryLayout(options: MasonryLayoutOptions): MasonryLayoutResult;
@@ -0,0 +1,67 @@
1
+ const DEFAULT_MIN_COLUMN_WIDTH = 240;
2
+ const MAX_COLUMN_COUNT = 100;
3
+ export function normalizeMasonrySpacing(value, fallback = 0) {
4
+ return Number.isFinite(value) ? Math.max(0, value) : fallback;
5
+ }
6
+ export function normalizeMasonryColumnCount(value, fallback = 1) {
7
+ if (!Number.isFinite(value))
8
+ return fallback;
9
+ return Math.min(MAX_COLUMN_COUNT, Math.max(1, Math.floor(value)));
10
+ }
11
+ export function resolveMasonryColumnCount({ columns, containerWidth, columnGap = 0, maxColumns, minColumnWidth = DEFAULT_MIN_COLUMN_WIDTH, }) {
12
+ if (columns !== undefined)
13
+ return normalizeMasonryColumnCount(columns);
14
+ const width = Number.isFinite(containerWidth) ? Math.max(0, containerWidth) : 0;
15
+ const gap = normalizeMasonrySpacing(columnGap);
16
+ const minimum = Number.isFinite(minColumnWidth) && minColumnWidth > 0
17
+ ? minColumnWidth
18
+ : DEFAULT_MIN_COLUMN_WIDTH;
19
+ const available = Math.max(0, width + gap);
20
+ const adaptive = Math.max(1, Math.floor(available / (minimum + gap)));
21
+ return Math.min(adaptive, normalizeMasonryColumnCount(maxColumns, MAX_COLUMN_COUNT));
22
+ }
23
+ export function calculateMasonryLayout(options) {
24
+ const containerWidth = Number.isFinite(options.containerWidth)
25
+ ? Math.max(0, options.containerWidth)
26
+ : 0;
27
+ const columnGap = normalizeMasonrySpacing(options.columnGap);
28
+ const rowGap = normalizeMasonrySpacing(options.rowGap);
29
+ const requestedColumnCount = resolveMasonryColumnCount({
30
+ columns: options.columns,
31
+ containerWidth,
32
+ columnGap,
33
+ maxColumns: options.maxColumns,
34
+ minColumnWidth: options.minColumnWidth,
35
+ });
36
+ const columnCount = options.columns === undefined && options.items.length > 0
37
+ ? Math.min(requestedColumnCount, options.items.length)
38
+ : requestedColumnCount;
39
+ const effectiveColumnGap = columnCount > 1
40
+ ? Math.min(columnGap, containerWidth / (columnCount - 1))
41
+ : 0;
42
+ const columnWidth = Math.max(0, (containerWidth - effectiveColumnGap * (columnCount - 1)) / columnCount);
43
+ const columnHeights = Array.from({ length: columnCount }, () => 0);
44
+ const strategy = options.strategy ?? 'shortest';
45
+ const direction = options.direction ?? 'ltr';
46
+ const positions = options.items.map((item, index) => {
47
+ let column = strategy === 'shortest' ? 0 : index % columnCount;
48
+ if (strategy === 'shortest') {
49
+ for (let candidate = 1; candidate < columnCount; candidate += 1) {
50
+ if (columnHeights[candidate] < columnHeights[column])
51
+ column = candidate;
52
+ }
53
+ }
54
+ const height = Number.isFinite(item.height) ? Math.max(0, item.height) : 0;
55
+ const y = columnHeights[column] === 0 ? 0 : columnHeights[column] + rowGap;
56
+ const logicalX = column * (columnWidth + effectiveColumnGap);
57
+ const x = direction === 'rtl' ? containerWidth - columnWidth - logicalX : logicalX;
58
+ columnHeights[column] = y + height;
59
+ return { key: item.key, column, x, y, width: columnWidth, height };
60
+ });
61
+ return {
62
+ columnCount,
63
+ columnWidth,
64
+ height: Math.max(0, ...columnHeights),
65
+ positions,
66
+ };
67
+ }
@@ -0,0 +1,6 @@
1
+ import type { RollingMotionColumn } from './rolling.js';
2
+ export declare function RollingValue({ columns, progress, direction }: {
3
+ columns: RollingMotionColumn[];
4
+ progress: number;
5
+ direction: 'up' | 'down';
6
+ }): import("react").JSX.Element;
@@ -0,0 +1,10 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { PencilText } from '../PencilText.js';
3
+ import { rollMix } from './rolling.js';
4
+ export function RollingValue({ columns, progress, direction }) {
5
+ return _jsx("span", { className: "inkra-number-ticker__reels", dir: "ltr", "data-direction": direction, children: columns.map(column => {
6
+ const width = rollMix(column.fromWidth, column.toWidth, progress);
7
+ return _jsx("span", { className: "inkra-number-ticker__column", "data-place": column.key, "data-digit": column.digit || undefined, "data-departing": column.target === '' || undefined, style: column.digit ? { width: `${width}ch` } : { gridTemplateColumns: `${width}fr` }, children: _jsx("span", { className: "inkra-number-ticker__column-inner", children: column.faces.map(face => _jsx("span", { className: "inkra-number-ticker__face", "data-face": face.text === column.target ? 'to' : 'from', style: { transform: column.digit ? `translateY(${rollMix(face.fromOffset, face.toOffset, progress) * 100}%)` : undefined,
8
+ opacity: rollMix(face.fromOpacity, face.toOpacity, progress) }, children: _jsx(PencilText, { seed: `ticker-reel-${column.key}-${face.text}`, children: face.text }) }, face.text)) }) }, column.key);
9
+ }) });
10
+ }
@@ -1,5 +1,7 @@
1
1
  import type { ComponentPropsWithoutRef, ReactNode } from 'react';
2
2
  import type { NumberTickerCompact, NumberTickerCompactOptions, NumberTickerFormatOptions, NumberTickerLocale, NumberTickerValue } from './model.js';
3
+ export type NumberTickerAnimation = 'count' | 'roll';
4
+ export type NumberTickerRollDirection = 'auto' | 'up' | 'down';
3
5
  export interface NumberTickerRenderDetails {
4
6
  animating: boolean;
5
7
  formattedValue: string;
@@ -19,6 +21,8 @@ export interface NumberTickerProps extends Omit<ComponentPropsWithoutRef<'span'>
19
21
  renderValue?: (value: NumberTickerValue, details: NumberTickerRenderDetails) => ReactNode;
20
22
  getValueLabel?: (value: NumberTickerValue, formattedValue: string) => string;
21
23
  animate?: boolean;
24
+ animation?: NumberTickerAnimation;
25
+ rollDirection?: NumberTickerRollDirection;
22
26
  duration?: number;
23
27
  easing?: (progress: number) => number;
24
28
  showDelta?: boolean;
@@ -5,6 +5,8 @@ import { PencilText } from '../PencilText.js';
5
5
  import { useBrowserLayoutEffect } from '../internal/useBrowserLayoutEffect.js';
6
6
  import { canAnimateTickerValue, createNumberFormatter, createTickerFormatter, easeOutCubic, formatDerivedTickerDelta, formatTickerValue, interpolateTickerValue, tickerValueChanged, subtractTickerValues, tickerValueKey, } from './model.js';
7
7
  import { ExactDecimal } from './precision.js';
8
+ import { RollingValue } from './RollingValue.js';
9
+ import { createRollingMotion, rollingTokens, sameRollingTokens } from './rolling.js';
8
10
  function usePrefersReducedMotion() {
9
11
  const [reduced, setReduced] = useState(false);
10
12
  useBrowserLayoutEffect(() => {
@@ -24,7 +26,7 @@ function lettering(content, seed) {
24
26
  function accessibleAffix(content) {
25
27
  return typeof content === 'string' || typeof content === 'number' ? String(content) : '';
26
28
  }
27
- export const NumberTicker = forwardRef(function NumberTicker({ animate = true, className, compact = false, compactOptions, deltaDuration = 1600, deltaFormatOptions, duration = 420, easing = easeOutCubic, formatOptions, getValueLabel, locale: localeProp, prefix, renderDelta, renderValue, showDelta = false, suffix, value, ...props }, forwardedRef) {
29
+ export const NumberTicker = forwardRef(function NumberTicker({ animate = true, animation = 'count', className, compact = false, compactOptions, deltaDuration = 1600, deltaFormatOptions, duration = 420, easing = easeOutCubic, formatOptions, getValueLabel, locale: localeProp, prefix, renderDelta, renderValue, rollDirection = 'auto', showDelta = false, suffix, value, ...props }, forwardedRef) {
28
30
  const { locale: inheritedLocale } = useInkraLocale();
29
31
  const locale = localeProp ?? inheritedLocale;
30
32
  const rootRef = useRef(null);
@@ -36,6 +38,9 @@ export const NumberTicker = forwardRef(function NumberTicker({ animate = true, c
36
38
  const [announcedValue, setAnnouncedValue] = useState(value);
37
39
  const [animating, setAnimating] = useState(false);
38
40
  const [delta, setDelta] = useState(null);
41
+ const [roll, setRoll] = useState(null);
42
+ const [rollProgress, setRollProgress] = useState(1);
43
+ const rollFrameRef = useRef(null);
39
44
  const reducedMotion = usePrefersReducedMotion();
40
45
  const localeKey = Array.isArray(locale) ? locale.join('\u0000') : locale;
41
46
  const formatOptionsKey = JSON.stringify(formatOptions ?? {});
@@ -45,6 +50,14 @@ export const NumberTicker = forwardRef(function NumberTicker({ animate = true, c
45
50
  const deltaFormatter = useMemo(() => createTickerFormatter(locale, deltaFormatOptions ?? formatOptions, compact, compactOptions), [localeKey, deltaFormatOptionsKey, compact, compactOptionsKey]);
46
51
  const announcementFormatter = useMemo(() => createNumberFormatter(locale, compact ? { ...formatOptions, notation: 'standard' } : formatOptions), [localeKey, formatOptionsKey, compact]);
47
52
  useImperativeHandle(forwardedRef, () => rootRef.current);
53
+ const rollRequested = animation === 'roll' && !renderValue;
54
+ const finalTokens = useMemo(() => rollRequested ? rollingTokens(formatter.formatToParts(value)) : [], [formatter, value, rollRequested]);
55
+ // Keep exact large values readable without creating thousands of painted reels.
56
+ const useRoll = rollRequested && finalTokens.length <= 128;
57
+ const settledTokensRef = useRef(finalTokens);
58
+ const previousFormatterRef = useRef(formatter);
59
+ const previousUseRollRef = useRef(useRoll);
60
+ const columns = useMemo(() => roll?.columns ?? createRollingMotion(finalTokens, finalTokens, 'up'), [roll, finalTokens]);
48
61
  useBrowserLayoutEffect(() => {
49
62
  const previousTarget = targetRef.current;
50
63
  targetRef.current = value;
@@ -72,22 +85,53 @@ export const NumberTicker = forwardRef(function NumberTicker({ animate = true, c
72
85
  && typeof window.matchMedia === 'function'
73
86
  && window.matchMedia('(prefers-reduced-motion: reduce)').matches);
74
87
  const from = displayedRef.current;
75
- if (!animate || reduceImmediately || !canAnimateTickerValue(from, value, duration)) {
88
+ const formatChanged = previousFormatterRef.current !== formatter;
89
+ previousFormatterRef.current = formatter;
90
+ const modeChanged = previousUseRollRef.current !== useRoll;
91
+ previousUseRollRef.current = useRoll;
92
+ if (!animate || reduceImmediately || formatChanged || modeChanged
93
+ || (useRoll && !rollFrameRef.current && sameRollingTokens(settledTokensRef.current, finalTokens))
94
+ || (rollRequested && (!useRoll || settledTokensRef.current.length > 128))
95
+ || !canAnimateTickerValue(from, value, duration)) {
76
96
  displayedRef.current = value;
77
97
  setDisplayedValue(value);
78
98
  setAnnouncedValue(value);
79
99
  setAnimating(false);
100
+ setRoll(null);
101
+ setRollProgress(1);
102
+ settledTokensRef.current = finalTokens;
103
+ rollFrameRef.current = null;
80
104
  return undefined;
81
105
  }
106
+ if (useRoll) {
107
+ const direction = rollDirection === 'auto'
108
+ ? (new ExactDecimal(String(value)).gt(String(from)) ? 'up' : 'down') : rollDirection;
109
+ const nextColumns = createRollingMotion(settledTokensRef.current, finalTokens, direction, rollFrameRef.current ?? undefined);
110
+ setRoll({ columns: nextColumns, direction });
111
+ setRollProgress(0);
112
+ rollFrameRef.current = { columns: nextColumns, progress: 0 };
113
+ }
114
+ else {
115
+ setRoll(null);
116
+ rollFrameRef.current = null;
117
+ }
82
118
  let startTime = null;
83
119
  setAnimating(true);
84
120
  const tick = (time) => {
85
121
  if (startTime === null)
86
122
  startTime = time;
87
123
  const progress = Math.min(1, Math.max(0, (time - startTime) / duration));
88
- const next = interpolateTickerValue(from, value, easing(progress));
124
+ const eased = easing(progress);
125
+ const bounded = Number.isFinite(eased) ? Math.min(1, Math.max(0, eased)) : progress;
126
+ const next = interpolateTickerValue(from, value, bounded);
89
127
  displayedRef.current = next;
90
- setDisplayedValue(next);
128
+ if (useRoll) {
129
+ setRollProgress(bounded);
130
+ if (rollFrameRef.current)
131
+ rollFrameRef.current.progress = bounded;
132
+ }
133
+ else
134
+ setDisplayedValue(next);
91
135
  if (progress < 1) {
92
136
  frameRef.current = requestAnimationFrame(tick);
93
137
  }
@@ -97,6 +141,10 @@ export const NumberTicker = forwardRef(function NumberTicker({ animate = true, c
97
141
  setDisplayedValue(value);
98
142
  setAnnouncedValue(value);
99
143
  setAnimating(false);
144
+ setRoll(null);
145
+ setRollProgress(1);
146
+ settledTokensRef.current = finalTokens;
147
+ rollFrameRef.current = null;
100
148
  }
101
149
  };
102
150
  frameRef.current = requestAnimationFrame(tick);
@@ -106,7 +154,7 @@ export const NumberTicker = forwardRef(function NumberTicker({ animate = true, c
106
154
  frameRef.current = null;
107
155
  }
108
156
  };
109
- }, [animate, deltaDuration, duration, easing, reducedMotion, showDelta, value]);
157
+ }, [animate, deltaDuration, duration, easing, reducedMotion, showDelta, value, useRoll, rollRequested, rollDirection, formatter]);
110
158
  useEffect(() => () => {
111
159
  if (frameRef.current !== null)
112
160
  cancelAnimationFrame(frameRef.current);
@@ -120,7 +168,7 @@ export const NumberTicker = forwardRef(function NumberTicker({ animate = true, c
120
168
  .filter(Boolean).join(' ');
121
169
  const accessibleLabel = getValueLabel?.(announcedValue, announcedFormattedValue) ?? defaultAccessibleLabel;
122
170
  const valueContent = renderValue?.(displayedValue, { animating, formattedValue })
123
- ?? (animating
171
+ ?? (useRoll ? _jsx(RollingValue, { columns: columns, progress: roll ? rollProgress : 1, direction: roll?.direction ?? 'up' }) : animating
124
172
  ? _jsx("span", { className: "inkra-number-ticker__native-value", children: formattedValue })
125
173
  : _jsx(PencilText, { seed: `number-ticker-${finalFormattedValue}`, children: finalFormattedValue }));
126
174
  const deltaContent = delta === null ? null : (() => {
@@ -133,6 +181,6 @@ export const NumberTicker = forwardRef(function NumberTicker({ animate = true, c
133
181
  })();
134
182
  const classes = ['inkra-number-ticker', animating && 'inkra-number-ticker--animating', className]
135
183
  .filter(Boolean).join(' ');
136
- 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 })] });
184
+ return _jsxs("span", { ...props, className: classes, ref: rootRef, "data-animating": animating || undefined, "data-animation": animation, 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 })] });
137
185
  });
138
186
  export { createNumberFormatter, easeOutCubic, formatTickerDelta, formatTickerValue, interpolateTickerValue, numberTickerUnits, } from './model.js';
@@ -0,0 +1,35 @@
1
+ export interface RollingToken {
2
+ key: string;
3
+ text: string;
4
+ digit: boolean;
5
+ }
6
+ export declare function sameRollingTokens(from: readonly RollingToken[], to: readonly RollingToken[]): boolean;
7
+ export interface RollingColumn {
8
+ key: string;
9
+ from: string;
10
+ to: string;
11
+ digit: boolean;
12
+ }
13
+ export interface RollingFace {
14
+ text: string;
15
+ fromOffset: number;
16
+ toOffset: number;
17
+ fromOpacity: number;
18
+ toOpacity: number;
19
+ }
20
+ export interface RollingMotionColumn {
21
+ key: string;
22
+ digit: boolean;
23
+ target: string;
24
+ fromWidth: number;
25
+ toWidth: number;
26
+ faces: RollingFace[];
27
+ }
28
+ export interface RollingFrame {
29
+ columns: RollingMotionColumn[];
30
+ progress: number;
31
+ }
32
+ export declare const rollMix: (from: number, to: number, progress: number) => number;
33
+ export declare function createRollingMotion(from: RollingToken[], to: RollingToken[], direction: 'up' | 'down', previous?: RollingFrame): RollingMotionColumn[];
34
+ export declare function rollingTokens(parts: Intl.NumberFormatPart[]): RollingToken[];
35
+ export declare function rollingColumns(from: RollingToken[], to: RollingToken[]): RollingColumn[];