@inkra/react 0.8.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 (48) hide show
  1. package/README.md +89 -0
  2. package/dist/PencilIcon.d.ts +14 -1
  3. package/dist/PencilIcon.js +88 -52
  4. package/dist/guide/index.d.ts +77 -0
  5. package/dist/guide/index.js +379 -0
  6. package/dist/i18n/catalog.d.ts +1 -1
  7. package/dist/i18n/catalog.js +6 -0
  8. package/dist/image-viewer/index.d.ts +2 -0
  9. package/dist/image-viewer/index.js +28 -28
  10. package/dist/index.d.ts +4 -0
  11. package/dist/index.js +4 -0
  12. package/dist/infinite-scroll/gesture.d.ts +19 -0
  13. package/dist/infinite-scroll/gesture.js +218 -0
  14. package/dist/infinite-scroll/index.d.ts +55 -0
  15. package/dist/infinite-scroll/index.js +543 -0
  16. package/dist/infinite-scroll/model.d.ts +33 -0
  17. package/dist/infinite-scroll/model.js +168 -0
  18. package/dist/internal/iconFill.d.ts +16 -0
  19. package/dist/internal/iconFill.js +275 -0
  20. package/dist/inventory-grid/index.js +13 -3
  21. package/dist/inventory-grid/outline.d.ts +9 -0
  22. package/dist/inventory-grid/outline.js +54 -0
  23. package/dist/loading/index.d.ts +58 -0
  24. package/dist/loading/index.js +188 -0
  25. package/dist/loading/locales.d.ts +15 -0
  26. package/dist/loading/locales.js +34 -0
  27. package/dist/loading/model.d.ts +6 -0
  28. package/dist/loading/model.js +29 -0
  29. package/dist/masonry/index.d.ts +25 -0
  30. package/dist/masonry/index.js +227 -0
  31. package/dist/masonry/layout.d.ts +35 -0
  32. package/dist/masonry/layout.js +67 -0
  33. package/dist/number-ticker/RollingValue.d.ts +6 -0
  34. package/dist/number-ticker/RollingValue.js +10 -0
  35. package/dist/number-ticker/index.d.ts +4 -0
  36. package/dist/number-ticker/index.js +55 -7
  37. package/dist/number-ticker/rolling.d.ts +35 -0
  38. package/dist/number-ticker/rolling.js +87 -0
  39. package/dist/styles/guide.css +202 -0
  40. package/dist/styles/image-viewer.css +20 -0
  41. package/dist/styles/infinite-scroll.css +171 -0
  42. package/dist/styles/inventory-grid.css +20 -8
  43. package/dist/styles/loading.css +348 -0
  44. package/dist/styles/masonry.css +68 -0
  45. package/dist/styles/number-ticker.css +46 -0
  46. package/dist/styles.css +879 -8
  47. package/dist/toast/manager.d.ts +4 -4
  48. package/package.json +26 -1
@@ -0,0 +1,188 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { FocusScope } from 'react-aria/FocusScope';
3
+ import { ariaHideOutside } from 'react-aria/private/overlays/ariaHideOutside';
4
+ import { usePreventScroll } from 'react-aria/usePreventScroll';
5
+ import { forwardRef, useCallback, useEffect, useId, useMemo, useRef, useState, } from 'react';
6
+ import { createPortal } from 'react-dom';
7
+ import { Button } from '../Button.js';
8
+ import { PencilLine } from '../PencilLine.js';
9
+ import { PencilSurface } from '../PencilSurface.js';
10
+ import { PencilText } from '../PencilText.js';
11
+ import { Progress, Skeleton } from '../display/index.js';
12
+ import { useInkraLocale } from '../i18n/index.js';
13
+ import { PortalThemeBridge, PortalThemeProvider, usePortalTriggerRef, } from '../overlay/themeBridge.js';
14
+ import { formatLoadingLabel, resolveLoadingLabels } from './locales.js';
15
+ import { finiteDuration, nextTipIndex, normalizeLoadingMax, normalizeLoadingProgress, normalizeTipIndex, } from './model.js';
16
+ function classes(...values) {
17
+ return values.filter(Boolean).join(' ');
18
+ }
19
+ function assignRef(ref, value) {
20
+ if (typeof ref === 'function')
21
+ ref(value);
22
+ else if (ref)
23
+ ref.current = value;
24
+ }
25
+ function useTimedPresence(active, delay, minDuration) {
26
+ const startsVisible = active && delay === 0;
27
+ const [present, setPresent] = useState(startsVisible);
28
+ const visibleAt = useRef(startsVisible ? Date.now() : null);
29
+ useEffect(() => {
30
+ let timer;
31
+ if (active) {
32
+ if (present)
33
+ return undefined;
34
+ timer = setTimeout(() => {
35
+ visibleAt.current = Date.now();
36
+ setPresent(true);
37
+ }, delay);
38
+ }
39
+ else if (present) {
40
+ const elapsed = visibleAt.current == null ? minDuration : Date.now() - visibleAt.current;
41
+ timer = setTimeout(() => {
42
+ visibleAt.current = null;
43
+ setPresent(false);
44
+ }, Math.max(0, minDuration - elapsed));
45
+ }
46
+ return () => clearTimeout(timer);
47
+ }, [active, delay, minDuration, present]);
48
+ return present;
49
+ }
50
+ function useControllableTipIndex(controlledIndex, defaultIndex, length, onChange) {
51
+ const [uncontrolledIndex, setUncontrolledIndex] = useState(() => normalizeTipIndex(defaultIndex, length));
52
+ const controlled = controlledIndex !== undefined;
53
+ const index = normalizeTipIndex(controlled ? controlledIndex : uncontrolledIndex, length);
54
+ const setIndex = useCallback((next) => {
55
+ const normalized = normalizeTipIndex(next, length);
56
+ if (!controlled)
57
+ setUncontrolledIndex(normalized);
58
+ if (normalized !== index)
59
+ onChange?.(normalized);
60
+ }, [controlled, index, length, onChange]);
61
+ return [index, setIndex];
62
+ }
63
+ function useControllableBoolean(value, defaultValue, onChange) {
64
+ const [uncontrolledValue, setUncontrolledValue] = useState(defaultValue);
65
+ const controlled = value !== undefined;
66
+ const current = controlled ? value : uncontrolledValue;
67
+ const setCurrent = useCallback((next) => {
68
+ if (!controlled)
69
+ setUncontrolledValue(next);
70
+ if (next !== current)
71
+ onChange?.(next);
72
+ }, [controlled, current, onChange]);
73
+ return [current, setCurrent];
74
+ }
75
+ function DefaultSkeleton() {
76
+ return _jsxs("div", { className: "inkra-loading__skeleton", "aria-hidden": "true", children: [_jsx(Skeleton, { height: 28, width: "42%" }), _jsx(Skeleton, { height: 16, width: "88%" }), _jsx(Skeleton, { height: 16, width: "76%" }), _jsxs("div", { className: "inkra-loading__skeleton-grid", children: [_jsx(Skeleton, { height: 96 }), _jsx(Skeleton, { height: 96 }), _jsx(Skeleton, { height: 96 })] })] });
77
+ }
78
+ function LoadingAnimationVisual({ animation, seed }) {
79
+ const marks = [0, 1, 2];
80
+ return _jsx("span", { className: "inkra-loading__animation-visual", "data-kind": animation, "aria-hidden": "true", children: animation === 'trail'
81
+ ? marks.map(index => _jsx(PencilLine, { className: "inkra-loading__animation-mark", seed: `${seed}-trail-${index}` }, index))
82
+ : marks.map(index => _jsx(PencilSurface, { as: "span", "aria-hidden": "true", className: "inkra-loading__animation-mark", radius: 8, seed: `${seed}-${animation}-${index}`, variant: animation === 'pulse' ? 'outline' : 'quiet' }, index)) });
83
+ }
84
+ function stageState(stage, activeStage) {
85
+ if (stage.state)
86
+ return stage.state;
87
+ return stage.id === activeStage ? 'active' : 'pending';
88
+ }
89
+ function LoadingLayer({ 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledBy, activeStage, animation = 'progress', background, blocking, children, className, defaultTipIndex, defaultTipPaused = false, description, forwardedRef, labelsResolved, lockScroll, logo, max, mode = 'inline', onCancel, onContinue, onRetry, onTipIndexChange, onTipPausedChange, preset = 'brand-center', present, progress, progressLabel, showTipControls = false, skeleton, slots, stages = [], status = 'loading', style, tipAutoPlay = true, tipIndex, tipInterval, tipOrder = 'sequence', tipPaused, tips = [], tipTransition = 'fade', title, ...props }) {
90
+ const id = useId();
91
+ const rootRef = useRef(null);
92
+ const titleId = `${id}-title`;
93
+ const resolvedMax = normalizeLoadingMax(max);
94
+ const resolvedProgress = normalizeLoadingProgress(progress, resolvedMax);
95
+ const showCustomAnimation = animation !== 'progress'
96
+ && status === 'loading'
97
+ && (progress === undefined || progress === null);
98
+ const showProgress = progress !== undefined
99
+ && (animation === 'progress' || progress !== null);
100
+ const interval = Math.max(1000, finiteDuration(tipInterval, 5000));
101
+ const [currentTip, setCurrentTip] = useControllableTipIndex(tipIndex, defaultTipIndex, tips.length, onTipIndexChange);
102
+ const [tipsPaused, setTipsPaused] = useControllableBoolean(tipPaused, defaultTipPaused, onTipPausedChange);
103
+ const activeStageIndex = stages.findIndex(stage => stage.id === activeStage || stageState(stage, activeStage) === 'active');
104
+ const stageAnnouncement = stages.length > 0 && activeStageIndex >= 0
105
+ ? formatLoadingLabel(labelsResolved.stageProgress, { current: activeStageIndex + 1, total: stages.length })
106
+ : undefined;
107
+ const defaultStatus = labelsResolved[status];
108
+ const effectiveBlocking = mode === 'fullscreen' && (blocking ?? preset !== 'edge');
109
+ const effectiveLockScroll = mode === 'fullscreen' && (lockScroll ?? effectiveBlocking);
110
+ usePreventScroll({ isDisabled: !(effectiveLockScroll && present) });
111
+ useEffect(() => {
112
+ if (!effectiveBlocking || !present || !rootRef.current)
113
+ return undefined;
114
+ return ariaHideOutside([rootRef.current], { shouldUseInert: true });
115
+ }, [effectiveBlocking, present]);
116
+ useEffect(() => {
117
+ if (!present || tips.length <= 1 || !tipAutoPlay || tipsPaused || status !== 'loading')
118
+ return undefined;
119
+ const timer = setInterval(() => setCurrentTip(nextTipIndex(currentTip, tips.length, tipOrder)), interval);
120
+ return () => clearInterval(timer);
121
+ }, [currentTip, interval, present, setCurrentTip, status, tipAutoPlay, tipOrder, tips.length, tipsPaused]);
122
+ const renderedTitle = title ?? defaultStatus;
123
+ const showActions = Boolean(onCancel || status === 'error' && onRetry || status === 'complete' && onContinue);
124
+ const hasInteractiveContent = showActions || showTipControls || children != null
125
+ || Object.values(slots ?? {}).some(node => node != null);
126
+ const semanticRole = effectiveBlocking ? 'dialog' : hasInteractiveContent ? 'region' : 'status';
127
+ const content = _jsxs("div", { ...props, "aria-busy": status === 'loading' || undefined, "aria-label": ariaLabel ?? (ariaLabelledBy || typeof renderedTitle === 'string' ? undefined : defaultStatus), "aria-labelledby": ariaLabelledBy ?? (typeof renderedTitle === 'string' ? titleId : undefined), "aria-live": semanticRole === 'status' ? 'polite' : undefined, "aria-modal": effectiveBlocking || undefined, className: classes('inkra-loading', className), "data-animation": animation, "data-blocking": effectiveBlocking || undefined, "data-mode": mode, "data-preset": preset, "data-status": status, ref: node => {
128
+ rootRef.current = node;
129
+ assignRef(forwardedRef, node);
130
+ }, role: semanticRole, style: style, tabIndex: effectiveBlocking ? -1 : undefined, children: [background == null ? null : _jsx("div", { className: "inkra-loading__background", "aria-hidden": "true", children: background }), _jsx("div", { className: "inkra-loading__veil", "aria-hidden": "true" }), semanticRole === 'region' ? _jsx("span", { className: "inkra-loading__sr-status", role: "status", children: defaultStatus }) : null, _jsx("div", { className: "inkra-loading__layout", children: _jsxs("div", { className: "inkra-loading__main", children: [logo == null ? null : _jsx("div", { className: "inkra-loading__logo", children: logo }), _jsxs("div", { className: "inkra-loading__copy", children: [typeof renderedTitle === 'string'
131
+ ? _jsx(PencilText, { as: "div", className: "inkra-loading__title", id: titleId, seed: `${id}-title`, children: renderedTitle })
132
+ : _jsx("div", { className: "inkra-loading__title", id: titleId, children: renderedTitle }), description == null ? null : typeof description === 'string'
133
+ ? _jsx(PencilText, { as: "div", className: "inkra-loading__description", seed: `${id}-description`, children: description })
134
+ : _jsx("div", { className: "inkra-loading__description", children: description })] }), showCustomAnimation ? _jsxs("div", { className: "inkra-loading__animation-progress", children: [progressLabel == null ? null : typeof progressLabel === 'string'
135
+ ? _jsx(PencilText, { as: "div", className: "inkra-loading__animation-label", children: progressLabel })
136
+ : _jsx("div", { className: "inkra-loading__animation-label", children: progressLabel }), _jsx("div", { "aria-label": typeof progressLabel === 'string' ? progressLabel : defaultStatus, className: "inkra-loading__animation", role: "progressbar", children: _jsx(LoadingAnimationVisual, { animation: animation, seed: `${id}-animation` }) })] }) : null, preset === 'skeleton' ? (skeleton ?? _jsx(DefaultSkeleton, {})) : null, stages.length > 0 ? _jsx("div", { className: "inkra-loading__stages", "aria-label": stageAnnouncement, children: stages.map((stage, index) => {
137
+ const state = stageState(stage, activeStage);
138
+ return _jsxs("div", { className: "inkra-loading__stage", "data-state": state, children: [_jsx(PencilSurface, { className: "inkra-loading__stage-marker", variant: state === 'active' ? 'selection' : state === 'error' ? 'danger' : 'outline', radius: 8, children: _jsx(PencilText, { children: state === 'complete' ? '✓' : state === 'error' ? '!' : String(index + 1) }) }), _jsxs("div", { className: "inkra-loading__stage-copy", children: [typeof stage.label === 'string' ? _jsx(PencilText, { children: stage.label }) : stage.label, stage.description == null ? null : _jsx("span", { className: "inkra-loading__stage-description", children: stage.description })] })] }, stage.id);
139
+ }) }) : null, showProgress ? _jsx(Progress, { "aria-label": typeof progressLabel === 'string' ? progressLabel : defaultStatus, className: "inkra-loading__progress", label: progressLabel, max: resolvedMax, shape: preset === 'brand-center' ? 'circular' : 'linear', size: preset === 'brand-center' ? 'sm' : 'md', value: resolvedProgress ?? null, children: resolvedProgress == null || preset === 'brand-center' ? null : `${Math.round(resolvedProgress / resolvedMax * 100)}%` }) : null, tips.length > 0 ? _jsxs("div", { className: "inkra-loading__tips", "data-transition": tipTransition, children: [_jsx("div", { className: "inkra-loading__tip", children: tips[currentTip] }, `${currentTip}-${tipTransition}`), showTipControls && tips.length > 1 ? _jsxs("div", { className: "inkra-loading__tip-controls", children: [_jsx(Button, { "aria-label": labelsResolved.previousTip, size: "icon-sm", variant: "ghost", onClick: () => setCurrentTip(currentTip - 1), children: "\u2190" }), _jsxs("span", { className: "inkra-loading__tip-position", "aria-live": "off", children: [currentTip + 1, " / ", tips.length] }), tipAutoPlay ? _jsx(Button, { "aria-label": tipsPaused ? labelsResolved.resumeTips : labelsResolved.pauseTips, size: "icon-sm", variant: "ghost", onClick: () => setTipsPaused(!tipsPaused), children: tipsPaused ? '▶' : 'Ⅱ' }) : null, _jsx(Button, { "aria-label": labelsResolved.nextTip, size: "icon-sm", variant: "ghost", onClick: () => setCurrentTip(currentTip + 1), children: "\u2192" })] }) : null] }) : null, children == null ? null : _jsx("div", { className: "inkra-loading__content-layer", children: children }), showActions ? _jsxs("div", { className: "inkra-loading__actions", children: [onCancel ? _jsx(Button, { variant: "ghost", onClick: onCancel, children: labelsResolved.cancel }) : null, status === 'error' && onRetry ? _jsx(Button, { onClick: onRetry, children: labelsResolved.retry }) : null, status === 'complete' && onContinue ? _jsx(Button, { onClick: onContinue, children: labelsResolved.continue }) : null] }) : null] }) }), Object.entries(slots ?? {}).map(([slot, node]) => node == null ? null :
140
+ _jsx("div", { className: "inkra-loading__slot", "data-slot": slot, children: node }, slot)), preset === 'edge' ? _jsx(PencilLine, { className: "inkra-loading__edge-line", seed: `${id}-edge` }) : null] });
141
+ return effectiveBlocking
142
+ ? _jsx(FocusScope, { contain: true, restoreFocus: true, autoFocus: true, children: content })
143
+ : content;
144
+ }
145
+ function FullscreenPortal(props) {
146
+ const [mounted, setMounted] = useState(false);
147
+ const anchorRef = usePortalTriggerRef(null);
148
+ const restoreFocusRef = useRef(typeof document !== 'undefined' && document.activeElement instanceof HTMLElement
149
+ ? document.activeElement
150
+ : null);
151
+ const restoreGenerationRef = useRef(0);
152
+ useEffect(() => setMounted(true), []);
153
+ useEffect(() => {
154
+ const generation = ++restoreGenerationRef.current;
155
+ return () => {
156
+ const target = restoreFocusRef.current;
157
+ if (!target?.isConnected || typeof requestAnimationFrame !== 'function')
158
+ return;
159
+ requestAnimationFrame(() => {
160
+ if (restoreGenerationRef.current !== generation || !target.isConnected)
161
+ return;
162
+ if (document.activeElement === document.body)
163
+ target.focus({ preventScroll: true });
164
+ });
165
+ };
166
+ }, []);
167
+ const layer = _jsx(PortalThemeBridge, { children: _jsx(LoadingLayer, { ...props }) });
168
+ return _jsxs(_Fragment, { children: [_jsx("span", { "aria-hidden": "true", className: "inkra-loading__theme-anchor", ref: anchorRef }), mounted && typeof document !== 'undefined' ? createPortal(layer, document.body) : layer] });
169
+ }
170
+ export const Loading = forwardRef(function Loading({ active = true, delay, labels, minDuration, mode = 'inline', ...props }, ref) {
171
+ const { locale } = useInkraLocale();
172
+ const resolvedDelay = finiteDuration(delay, 0);
173
+ const resolvedMinDuration = finiteDuration(minDuration, 0);
174
+ const present = useTimedPresence(active, resolvedDelay, resolvedMinDuration);
175
+ const labelsResolved = useMemo(() => resolveLoadingLabels(locale, labels), [labels, locale]);
176
+ if (!present)
177
+ return null;
178
+ const layerProps = {
179
+ ...props,
180
+ forwardedRef: ref,
181
+ labelsResolved,
182
+ mode,
183
+ present,
184
+ };
185
+ return mode === 'fullscreen'
186
+ ? _jsx(PortalThemeProvider, { children: _jsx(FullscreenPortal, { ...layerProps }) })
187
+ : _jsx(LoadingLayer, { ...layerProps });
188
+ });
@@ -0,0 +1,15 @@
1
+ export interface LoadingLabels {
2
+ loading: string;
3
+ error: string;
4
+ complete: string;
5
+ cancel: string;
6
+ retry: string;
7
+ continue: string;
8
+ previousTip: string;
9
+ nextTip: string;
10
+ pauseTips: string;
11
+ resumeTips: string;
12
+ stageProgress: string;
13
+ }
14
+ export declare function resolveLoadingLabels(locale: string, overrides?: Partial<LoadingLabels>): LoadingLabels;
15
+ export declare function formatLoadingLabel(template: string, values: Record<string, string | number>): string;
@@ -0,0 +1,34 @@
1
+ import { resolveInkraLocale } from '../i18n/index.js';
2
+ const loadingLabels = {
3
+ en: {
4
+ loading: 'Loading', error: 'Unable to continue', complete: 'Ready', cancel: 'Cancel', retry: 'Retry',
5
+ continue: 'Continue', previousTip: 'Previous tip', nextTip: 'Next tip', pauseTips: 'Pause tips',
6
+ resumeTips: 'Resume tips', stageProgress: 'Step {current} of {total}',
7
+ },
8
+ 'zh-CN': {
9
+ loading: '正在加载', error: '暂时无法继续', complete: '准备就绪', cancel: '取消', retry: '重试',
10
+ continue: '继续', previousTip: '上一条提示', nextTip: '下一条提示', pauseTips: '暂停提示',
11
+ resumeTips: '继续提示', stageProgress: '第 {current} 步,共 {total} 步',
12
+ },
13
+ ja: {
14
+ loading: '読み込み中', error: '続行できません', complete: '準備完了', cancel: 'キャンセル', retry: '再試行',
15
+ continue: '続ける', previousTip: '前のヒント', nextTip: '次のヒント', pauseTips: 'ヒントを一時停止',
16
+ resumeTips: 'ヒントを再開', stageProgress: '{total} ステップ中 {current}',
17
+ },
18
+ ko: {
19
+ loading: '불러오는 중', error: '계속할 수 없습니다', complete: '준비 완료', cancel: '취소', retry: '다시 시도',
20
+ continue: '계속', previousTip: '이전 도움말', nextTip: '다음 도움말', pauseTips: '도움말 일시 정지',
21
+ resumeTips: '도움말 계속', stageProgress: '{total}단계 중 {current}단계',
22
+ },
23
+ fr: {
24
+ loading: 'Chargement', error: 'Impossible de continuer', complete: 'Prêt', cancel: 'Annuler', retry: 'Réessayer',
25
+ continue: 'Continuer', previousTip: 'Conseil précédent', nextTip: 'Conseil suivant', pauseTips: 'Suspendre les conseils',
26
+ resumeTips: 'Reprendre les conseils', stageProgress: 'Étape {current} sur {total}',
27
+ },
28
+ };
29
+ export function resolveLoadingLabels(locale, overrides) {
30
+ return { ...loadingLabels[resolveInkraLocale(locale)], ...overrides };
31
+ }
32
+ export function formatLoadingLabel(template, values) {
33
+ return template.replace(/\{([A-Za-z][A-Za-z0-9]*)\}/g, (match, name) => Object.prototype.hasOwnProperty.call(values, name) ? String(values[name]) : match);
34
+ }
@@ -0,0 +1,6 @@
1
+ export type LoadingTipOrder = 'sequence' | 'random';
2
+ export declare function finiteDuration(value: number | undefined, fallback: number): number;
3
+ export declare function normalizeLoadingMax(max: number | undefined): number;
4
+ export declare function normalizeLoadingProgress(value: number | null | undefined, max: number): number | null | undefined;
5
+ export declare function normalizeTipIndex(index: number | undefined, length: number): number;
6
+ export declare function nextTipIndex(current: number, length: number, order: LoadingTipOrder, random?: () => number): number;
@@ -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;