@inkra/react 0.9.0 → 0.11.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 (44) hide show
  1. package/README.md +75 -5
  2. package/THIRD_PARTY_NOTICES.md +9 -0
  3. package/Xiaolai-OFL.txt +94 -0
  4. package/dist/PencilText.js +4 -1
  5. package/dist/assets/ui-hand-xiaolai-extended.woff2 +0 -0
  6. package/dist/assets/ui-hand-xiaolai.woff2 +0 -0
  7. package/dist/assets/xiaolai.css +17 -0
  8. package/dist/form/EditablePencilText.js +4 -2
  9. package/dist/i18n/catalog.d.ts +1 -1
  10. package/dist/i18n/catalog.js +2 -0
  11. package/dist/internal/surfaceRenderer.d.ts +4 -4
  12. package/dist/internal/surfaceRenderer.js +2 -2
  13. package/dist/internal/textRenderer.d.ts +1 -1
  14. package/dist/internal/textRenderer.js +23 -11
  15. package/dist/log/index.js +7 -6
  16. package/dist/number-ticker/MorphValue.d.ts +7 -0
  17. package/dist/number-ticker/MorphValue.js +10 -0
  18. package/dist/number-ticker/index.d.ts +1 -1
  19. package/dist/number-ticker/index.js +65 -11
  20. package/dist/number-ticker/morph-motion.d.ts +46 -0
  21. package/dist/number-ticker/morph-motion.js +121 -0
  22. package/dist/number-ticker/morph.d.ts +7 -0
  23. package/dist/number-ticker/morph.js +78 -0
  24. package/dist/selection/CircularSwitchVisual.d.ts +8 -0
  25. package/dist/selection/CircularSwitchVisual.js +82 -0
  26. package/dist/selection/Switch.d.ts +7 -0
  27. package/dist/selection/Switch.js +15 -3
  28. package/dist/slider/circular.d.ts +35 -0
  29. package/dist/slider/circular.js +196 -0
  30. package/dist/slider/circularMath.d.ts +10 -0
  31. package/dist/slider/circularMath.js +34 -0
  32. package/dist/slider/index.d.ts +5 -0
  33. package/dist/slider/index.js +5 -3
  34. package/dist/styles/base.css +4 -2
  35. package/dist/styles/breadcrumb.css +1 -1
  36. package/dist/styles/log.css +5 -18
  37. package/dist/styles/markdown.css +1 -1
  38. package/dist/styles/number-ticker.css +42 -1
  39. package/dist/styles/selection.css +50 -0
  40. package/dist/styles/slider.css +65 -0
  41. package/dist/styles/slot.css +1 -1
  42. package/dist/styles.css +169 -24
  43. package/dist/tokens.css +13 -10
  44. package/package.json +3 -2
@@ -7,6 +7,9 @@ import { canAnimateTickerValue, createNumberFormatter, createTickerFormatter, ea
7
7
  import { ExactDecimal } from './precision.js';
8
8
  import { RollingValue } from './RollingValue.js';
9
9
  import { createRollingMotion, rollingTokens, sameRollingTokens } from './rolling.js';
10
+ import { MorphValue } from './MorphValue.js';
11
+ import { morphTokenMapping } from './morph.js';
12
+ import { createMorphMotion, measureMorphWidths } from './morph-motion.js';
10
13
  function usePrefersReducedMotion() {
11
14
  const [reduced, setReduced] = useState(false);
12
15
  useBrowserLayoutEffect(() => {
@@ -41,6 +44,8 @@ export const NumberTicker = forwardRef(function NumberTicker({ animate = true, a
41
44
  const [roll, setRoll] = useState(null);
42
45
  const [rollProgress, setRollProgress] = useState(1);
43
46
  const rollFrameRef = useRef(null);
47
+ const [morph, setMorph] = useState(null);
48
+ const morphFrameRef = useRef(null);
44
49
  const reducedMotion = usePrefersReducedMotion();
45
50
  const localeKey = Array.isArray(locale) ? locale.join('\u0000') : locale;
46
51
  const formatOptionsKey = JSON.stringify(formatOptions ?? {});
@@ -51,12 +56,18 @@ export const NumberTicker = forwardRef(function NumberTicker({ animate = true, a
51
56
  const announcementFormatter = useMemo(() => createNumberFormatter(locale, compact ? { ...formatOptions, notation: 'standard' } : formatOptions), [localeKey, formatOptionsKey, compact]);
52
57
  useImperativeHandle(forwardedRef, () => rootRef.current);
53
58
  const rollRequested = animation === 'roll' && !renderValue;
54
- const finalTokens = useMemo(() => rollRequested ? rollingTokens(formatter.formatToParts(value)) : [], [formatter, value, rollRequested]);
59
+ const morphRequested = animation === 'morph' && !renderValue;
60
+ const finalParts = useMemo(() => rollRequested || morphRequested ? formatter.formatToParts(value) : [], [formatter, value, rollRequested, morphRequested]);
61
+ const finalTokens = useMemo(() => rollingTokens(finalParts), [finalParts]);
55
62
  // Keep exact large values readable without creating thousands of painted reels.
56
63
  const useRoll = rollRequested && finalTokens.length <= 128;
64
+ const useMorph = morphRequested && finalTokens.length <= 128;
65
+ const useTokens = useRoll || useMorph;
57
66
  const settledTokensRef = useRef(finalTokens);
67
+ const morphPartsRef = useRef(finalParts);
58
68
  const previousFormatterRef = useRef(formatter);
59
69
  const previousUseRollRef = useRef(useRoll);
70
+ const previousAnimationRef = useRef(animation);
60
71
  const columns = useMemo(() => roll?.columns ?? createRollingMotion(finalTokens, finalTokens, 'up'), [roll, finalTokens]);
61
72
  useBrowserLayoutEffect(() => {
62
73
  const previousTarget = targetRef.current;
@@ -87,12 +98,13 @@ export const NumberTicker = forwardRef(function NumberTicker({ animate = true, a
87
98
  const from = displayedRef.current;
88
99
  const formatChanged = previousFormatterRef.current !== formatter;
89
100
  previousFormatterRef.current = formatter;
90
- const modeChanged = previousUseRollRef.current !== useRoll;
101
+ const modeChanged = previousUseRollRef.current !== useRoll || previousAnimationRef.current !== animation;
91
102
  previousUseRollRef.current = useRoll;
103
+ previousAnimationRef.current = animation;
92
104
  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)) {
105
+ || (useTokens && !rollFrameRef.current && (!morphFrameRef.current || morphFrameRef.current.progress >= 1) && sameRollingTokens(settledTokensRef.current, finalTokens))
106
+ || ((rollRequested || morphRequested) && (!useTokens || settledTokensRef.current.length > 128))
107
+ || (!canAnimateTickerValue(from, value, duration) && !(useMorph && canAnimateTickerValue(previousTarget, value, duration)))) {
96
108
  displayedRef.current = value;
97
109
  setDisplayedValue(value);
98
110
  setAnnouncedValue(value);
@@ -101,9 +113,44 @@ export const NumberTicker = forwardRef(function NumberTicker({ animate = true, a
101
113
  setRollProgress(1);
102
114
  settledTokensRef.current = finalTokens;
103
115
  rollFrameRef.current = null;
116
+ setMorph(null);
117
+ morphFrameRef.current = null;
118
+ morphPartsRef.current = finalParts;
104
119
  return undefined;
105
120
  }
106
- if (useRoll) {
121
+ if (useMorph) {
122
+ const direction = rollDirection === 'auto'
123
+ ? (new ExactDecimal(String(value)).gt(String(previousTarget)) ? 'up' : 'down') : rollDirection;
124
+ const previousTokens = morphFrameRef.current?.motion.tokens ?? settledTokensRef.current;
125
+ const zeroPart = formatter.formatToParts(0).find(part => part.type === 'integer')?.value ?? '0';
126
+ const zero = Array.from(zeroPart).slice(-1)[0] ?? '0';
127
+ const mapping = morphTokenMapping(morphPartsRef.current, finalParts, previousTarget, value, zero);
128
+ const motion = createMorphMotion(previousTokens, finalTokens, mapping, direction, measureMorphWidths(rootRef.current, [...previousTokens, ...finalTokens]), morphFrameRef.current ?? undefined);
129
+ // Bound interrupted layers as well as formatted target token counts.
130
+ if (motion.glyphs.length > 512) {
131
+ displayedRef.current = value;
132
+ setDisplayedValue(value);
133
+ setAnnouncedValue(value);
134
+ setAnimating(false);
135
+ setMorph(null);
136
+ morphFrameRef.current = null;
137
+ morphPartsRef.current = finalParts;
138
+ settledTokensRef.current = finalTokens;
139
+ setRoll(null);
140
+ rollFrameRef.current = null;
141
+ setRollProgress(1);
142
+ return undefined;
143
+ }
144
+ setMorph(motion);
145
+ morphFrameRef.current = { motion, progress: 0 };
146
+ morphPartsRef.current = finalParts;
147
+ setRoll(null);
148
+ rollFrameRef.current = null;
149
+ setRollProgress(0);
150
+ }
151
+ else if (useRoll) {
152
+ setMorph(null);
153
+ morphFrameRef.current = null;
107
154
  const direction = rollDirection === 'auto'
108
155
  ? (new ExactDecimal(String(value)).gt(String(from)) ? 'up' : 'down') : rollDirection;
109
156
  const nextColumns = createRollingMotion(settledTokensRef.current, finalTokens, direction, rollFrameRef.current ?? undefined);
@@ -114,6 +161,8 @@ export const NumberTicker = forwardRef(function NumberTicker({ animate = true, a
114
161
  else {
115
162
  setRoll(null);
116
163
  rollFrameRef.current = null;
164
+ setMorph(null);
165
+ morphFrameRef.current = null;
117
166
  }
118
167
  let startTime = null;
119
168
  setAnimating(true);
@@ -125,10 +174,12 @@ export const NumberTicker = forwardRef(function NumberTicker({ animate = true, a
125
174
  const bounded = Number.isFinite(eased) ? Math.min(1, Math.max(0, eased)) : progress;
126
175
  const next = interpolateTickerValue(from, value, bounded);
127
176
  displayedRef.current = next;
128
- if (useRoll) {
177
+ if (useTokens) {
129
178
  setRollProgress(bounded);
130
179
  if (rollFrameRef.current)
131
180
  rollFrameRef.current.progress = bounded;
181
+ if (morphFrameRef.current)
182
+ morphFrameRef.current.progress = bounded;
132
183
  }
133
184
  else
134
185
  setDisplayedValue(next);
@@ -145,6 +196,8 @@ export const NumberTicker = forwardRef(function NumberTicker({ animate = true, a
145
196
  setRollProgress(1);
146
197
  settledTokensRef.current = finalTokens;
147
198
  rollFrameRef.current = null;
199
+ if (morphFrameRef.current)
200
+ morphFrameRef.current.progress = 1;
148
201
  }
149
202
  };
150
203
  frameRef.current = requestAnimationFrame(tick);
@@ -154,7 +207,7 @@ export const NumberTicker = forwardRef(function NumberTicker({ animate = true, a
154
207
  frameRef.current = null;
155
208
  }
156
209
  };
157
- }, [animate, deltaDuration, duration, easing, reducedMotion, showDelta, value, useRoll, rollRequested, rollDirection, formatter]);
210
+ }, [animate, deltaDuration, duration, easing, reducedMotion, showDelta, value, useRoll, useMorph, useTokens, rollRequested, morphRequested, rollDirection, formatter, animation]);
158
211
  useEffect(() => () => {
159
212
  if (frameRef.current !== null)
160
213
  cancelAnimationFrame(frameRef.current);
@@ -168,9 +221,10 @@ export const NumberTicker = forwardRef(function NumberTicker({ animate = true, a
168
221
  .filter(Boolean).join(' ');
169
222
  const accessibleLabel = getValueLabel?.(announcedValue, announcedFormattedValue) ?? defaultAccessibleLabel;
170
223
  const valueContent = renderValue?.(displayedValue, { animating, formattedValue })
171
- ?? (useRoll ? _jsx(RollingValue, { columns: columns, progress: roll ? rollProgress : 1, direction: roll?.direction ?? 'up' }) : animating
172
- ? _jsx("span", { className: "inkra-number-ticker__native-value", children: formattedValue })
173
- : _jsx(PencilText, { seed: `number-ticker-${finalFormattedValue}`, children: finalFormattedValue }));
224
+ ?? (useMorph ? _jsx(MorphValue, { tokens: finalTokens, motion: morph, progress: morph ? rollProgress : 1 })
225
+ : useRoll ? _jsx(RollingValue, { columns: columns, progress: roll ? rollProgress : 1, direction: roll?.direction ?? 'up' }) : animating
226
+ ? _jsx("span", { className: "inkra-number-ticker__native-value", children: formattedValue })
227
+ : _jsx(PencilText, { seed: `number-ticker-${finalFormattedValue}`, children: finalFormattedValue }));
174
228
  const deltaContent = delta === null ? null : (() => {
175
229
  const formattedDelta = formatDerivedTickerDelta(deltaFormatter, delta);
176
230
  const details = {
@@ -0,0 +1,46 @@
1
+ import type { RollingToken } from './rolling.js';
2
+ export interface MorphGlyph {
3
+ id: string;
4
+ referenceKey: string;
5
+ text: string;
6
+ digit: boolean;
7
+ width: number;
8
+ target: boolean;
9
+ fromX: number;
10
+ toX: number;
11
+ fromY: number;
12
+ toY: number;
13
+ fromOpacity: number;
14
+ toOpacity: number;
15
+ }
16
+ export interface MorphMotion {
17
+ glyphs: MorphGlyph[];
18
+ tokens: RollingToken[];
19
+ width: number;
20
+ targetWidth: number;
21
+ kind: 'shift' | 'roll';
22
+ }
23
+ export interface MorphFrame {
24
+ motion: MorphMotion;
25
+ progress: number;
26
+ }
27
+ export type MorphWidths = (token: RollingToken) => number;
28
+ export declare function measureMorphWidths(root: HTMLElement | null, tokens: RollingToken[]): MorphWidths;
29
+ export declare function sampleMorphGlyph(glyph: MorphGlyph, progress: number): {
30
+ x: number;
31
+ y: number;
32
+ opacity: number;
33
+ id: string;
34
+ referenceKey: string;
35
+ text: string;
36
+ digit: boolean;
37
+ width: number;
38
+ target: boolean;
39
+ fromX: number;
40
+ toX: number;
41
+ fromY: number;
42
+ toY: number;
43
+ fromOpacity: number;
44
+ toOpacity: number;
45
+ };
46
+ export declare function createMorphMotion(from: RollingToken[], to: RollingToken[], mapping: Array<[number, number]> | null, direction: 'up' | 'down', widths: MorphWidths, previous?: MorphFrame): MorphMotion;
@@ -0,0 +1,121 @@
1
+ import { rollMix } from './rolling.js';
2
+ let nextGlyph = 0;
3
+ export function measureMorphWidths(root, tokens) {
4
+ if (!root || typeof document === 'undefined')
5
+ return token => token.digit ? 1 : Math.max(.25, Array.from(token.text).length);
6
+ const style = getComputedStyle(root);
7
+ const probe = document.createElement('span');
8
+ Object.assign(probe.style, { position: 'fixed', visibility: 'hidden', pointerEvents: 'none',
9
+ font: style.font, fontVariantNumeric: 'tabular-nums lining-nums', fontKerning: 'none',
10
+ fontVariantLigatures: 'none', letterSpacing: '0', whiteSpace: 'pre', width: '1ch', display: 'inline-block' });
11
+ document.body.append(probe);
12
+ const ch = probe.getBoundingClientRect().width || 1;
13
+ probe.style.width = 'auto';
14
+ const widths = new Map();
15
+ // Measurement happens once when a target changes, never in the frame loop.
16
+ const measure = (token) => {
17
+ if (token.digit)
18
+ return 1;
19
+ if (!widths.has(token.text)) {
20
+ probe.textContent = token.text;
21
+ widths.set(token.text, Math.max(.05, probe.getBoundingClientRect().width / ch));
22
+ }
23
+ return widths.get(token.text);
24
+ };
25
+ // The returned callback cannot retain a live measuring node.
26
+ tokens.forEach(measure);
27
+ const result = token => token.digit ? 1 : widths.get(token.text) ?? Math.max(.25, Array.from(token.text).length);
28
+ probe.remove();
29
+ return result;
30
+ }
31
+ function layout(tokens, widths) {
32
+ const total = tokens.reduce((sum, token) => sum + widths(token), 0);
33
+ let x = -total;
34
+ return { width: total, items: tokens.map(token => {
35
+ const width = widths(token);
36
+ const item = { ...token, x, width };
37
+ x += width;
38
+ return item;
39
+ }) };
40
+ }
41
+ export function sampleMorphGlyph(glyph, progress) {
42
+ const fade = progress * progress * (3 - 2 * progress);
43
+ return { ...glyph, x: rollMix(glyph.fromX, glyph.toX, progress), y: rollMix(glyph.fromY, glyph.toY, progress),
44
+ opacity: rollMix(glyph.fromOpacity, glyph.toOpacity, fade) };
45
+ }
46
+ export function createMorphMotion(from, to, mapping, direction, widths, previous) {
47
+ const before = layout(from, widths), after = layout(to, widths);
48
+ const source = previous ? previous.motion.glyphs.map(glyph => sampleMorphGlyph(glyph, previous.progress))
49
+ .filter(glyph => glyph.opacity > .001 && Math.abs(glyph.y) < 1.001)
50
+ : before.items.map(token => ({ id: `settled-${token.key}`, referenceKey: token.key, text: token.text,
51
+ digit: token.digit, width: token.width, target: true, x: token.x, y: 0, opacity: 1 }));
52
+ const used = new Set();
53
+ const glyphs = [];
54
+ const sign = direction === 'up' ? -1 : 1;
55
+ const pairs = mapping ?? from.flatMap((token, index) => {
56
+ const next = to.findIndex(item => item.key === token.key);
57
+ return next < 0 ? [] : [[index, next]];
58
+ });
59
+ const oldIndexes = new Map(pairs.map(([left, right]) => [right, left]));
60
+ const findSource = (index) => source.filter(item => item.referenceKey === from[index]?.key)
61
+ .sort((left, right) => right.opacity - left.opacity);
62
+ const decimalEnd = (items) => {
63
+ const integers = items.filter(item => item.key.startsWith('integer-'));
64
+ const integer = integers[integers.length - 1];
65
+ return integer ? integer.x + integer.width : 0;
66
+ };
67
+ for (let index = 0; index < to.length; index++) {
68
+ const token = to[index], target = after.items[index];
69
+ const oldIndex = oldIndexes.get(index);
70
+ const candidates = oldIndex === undefined ? [] : findSource(oldIndex);
71
+ const existing = candidates.find(item => item.text === token.text && !used.has(item.id));
72
+ if (existing) {
73
+ used.add(existing.id);
74
+ glyphs.push({ id: existing.id, referenceKey: token.key, text: token.text, digit: token.digit,
75
+ width: existing.width, target: true, fromX: existing.x, toX: target.x,
76
+ fromY: existing.y, toY: 0, fromOpacity: existing.opacity, toOpacity: 1 });
77
+ continue;
78
+ }
79
+ let x = candidates[0]?.x ?? before.items[oldIndex ?? -1]?.x ?? target.x;
80
+ let y = token.digit && !mapping ? -sign : 0;
81
+ if (mapping) {
82
+ if (token.key.startsWith('decimal-'))
83
+ x = decimalEnd(before.items);
84
+ else if (token.digit) {
85
+ const anchor = pairs.filter(([left, right]) => right < index && from[left].digit)
86
+ .sort((left, right) => right[1] - left[1])[0];
87
+ if (anchor) {
88
+ const visible = findSource(anchor[0])[0];
89
+ x = (visible?.x ?? before.items[anchor[0]].x) + target.x - after.items[anchor[1]].x;
90
+ }
91
+ }
92
+ }
93
+ glyphs.push({ id: `morph-${nextGlyph++}`, referenceKey: token.key, text: token.text,
94
+ digit: token.digit, width: target.width, target: true, fromX: x, toX: target.x,
95
+ fromY: y, toY: 0, fromOpacity: mapping || !token.digit ? 0 : 1, toOpacity: 1 });
96
+ }
97
+ for (const item of source) {
98
+ if (used.has(item.id))
99
+ continue;
100
+ let x = item.x;
101
+ let y = item.digit && !mapping ? sign : 0;
102
+ if (mapping) {
103
+ if (item.referenceKey.startsWith('decimal-'))
104
+ x = decimalEnd(after.items);
105
+ else {
106
+ const oldIndex = from.findIndex(token => token.key === item.referenceKey);
107
+ const anchor = pairs.filter(([left]) => left < oldIndex && from[left].digit)
108
+ .sort((left, right) => right[0] - left[0])[0];
109
+ if (anchor) {
110
+ const visible = findSource(anchor[0])[0];
111
+ x += after.items[anchor[1]].x - (visible?.x ?? before.items[anchor[0]].x);
112
+ }
113
+ }
114
+ }
115
+ glyphs.push({ id: item.id, referenceKey: item.referenceKey, text: item.text, digit: item.digit,
116
+ width: item.width, target: false, fromX: item.x, toX: x, fromY: item.y, toY: y,
117
+ fromOpacity: item.opacity, toOpacity: mapping || !item.digit ? 0 : 1 });
118
+ }
119
+ return { glyphs, tokens: to, width: Math.max(before.width, after.width, previous && previous.progress < 1 ? previous.motion.width : 0),
120
+ targetWidth: after.width, kind: mapping ? 'shift' : 'roll' };
121
+ }
@@ -0,0 +1,7 @@
1
+ import type { NumberTickerValue } from './precision.js';
2
+ /**
3
+ * Match an exact decimal shift, rather than guessing identity from equal glyphs.
4
+ * Pair indexes address rollingTokens(parts), including unchanged typed symbols.
5
+ * A null result leaves the transition to the existing place-aligned reels.
6
+ */
7
+ export declare function morphTokenMapping(fromParts: Intl.NumberFormatPart[], toParts: Intl.NumberFormatPart[], fromValue: NumberTickerValue, toValue: NumberTickerValue, zeroGlyph?: string): Array<[number, number]> | null;
@@ -0,0 +1,78 @@
1
+ import { ExactDecimal, tickerDecimal } from './precision.js';
2
+ import { rollingTokens } from './rolling.js';
3
+ const numericParts = new Set(['integer', 'fraction', 'decimal', 'group']);
4
+ const exponentParts = new Set(['exponentSeparator', 'exponentMinusSign', 'exponentInteger']);
5
+ function displayDigits(parts, zeroGlyph) {
6
+ const tokens = rollingTokens(parts);
7
+ const indexes = tokens.flatMap((token, index) => token.digit ? [index] : []);
8
+ const text = indexes.map(index => tokens[index].text);
9
+ const first = text.findIndex(digit => digit !== zeroGlyph);
10
+ if (first < 0)
11
+ return null;
12
+ let last = text.length - 1;
13
+ while (text[last] === zeroGlyph)
14
+ last--;
15
+ const integerLength = parts.filter(part => part.type === 'integer')
16
+ .reduce((length, part) => length + Array.from(part.value).length, 0);
17
+ return { indexes, text, first, last, exponent: integerLength - first - 1 };
18
+ }
19
+ /**
20
+ * Match an exact decimal shift, rather than guessing identity from equal glyphs.
21
+ * Pair indexes address rollingTokens(parts), including unchanged typed symbols.
22
+ * A null result leaves the transition to the existing place-aligned reels.
23
+ */
24
+ export function morphTokenMapping(fromParts, toParts, fromValue, toValue, zeroGlyph = '0') {
25
+ const from = tickerDecimal(fromValue);
26
+ const to = tickerDecimal(toValue);
27
+ if (!from.isFinite() || !to.isFinite() || from.isZero() || to.isZero()
28
+ || from.isNegative() !== to.isNegative())
29
+ return null;
30
+ const shift = to.e - from.e;
31
+ if (shift === 0 || !from.times(new ExactDecimal(10).pow(shift)).eq(to))
32
+ return null;
33
+ // Unit/sign/literal changes are separate semantic transitions. Exponents have
34
+ // their own digit domain and must never be mistaken for mantissa digits.
35
+ if ([...fromParts, ...toParts].some(part => exponentParts.has(part.type)))
36
+ return null;
37
+ const symbols = (parts) => parts.filter(part => !numericParts.has(part.type));
38
+ const fromSymbols = symbols(fromParts);
39
+ const toSymbols = symbols(toParts);
40
+ if (fromSymbols.length !== toSymbols.length || fromSymbols.some((part, index) => {
41
+ const next = toSymbols[index];
42
+ return part.type !== next.type || part.value !== next.value;
43
+ }))
44
+ return null;
45
+ const before = displayDigits(fromParts, zeroGlyph);
46
+ const after = displayDigits(toParts, zeroGlyph);
47
+ if (!before || !after || after.exponent - before.exponent !== shift)
48
+ return null;
49
+ const core = before.text.slice(before.first, before.last + 1);
50
+ const nextCore = after.text.slice(after.first, after.last + 1);
51
+ // Rounding may change the visible significand even for an exact raw shift.
52
+ if (core.length !== nextCore.length || core.some((digit, index) => digit !== nextCore[index]))
53
+ return null;
54
+ const mapping = [];
55
+ for (let index = 0; index < core.length; index++) {
56
+ mapping.push([before.indexes[before.first + index], after.indexes[after.first + index]]);
57
+ }
58
+ // Edge zeros retain their ordinal distance from the significant core. This
59
+ // preserves 10 as the first 10 in 1000 without matching arbitrary repeats.
60
+ for (let offset = 1; offset <= Math.min(before.first, after.first); offset++) {
61
+ mapping.push([before.indexes[before.first - offset], after.indexes[after.first - offset]]);
62
+ }
63
+ const trailing = Math.min(before.text.length - before.last - 1, after.text.length - after.last - 1);
64
+ for (let offset = 1; offset <= trailing; offset++) {
65
+ mapping.push([before.indexes[before.last + offset], after.indexes[after.last + offset]]);
66
+ }
67
+ const fromTokens = rollingTokens(fromParts);
68
+ const toTokens = rollingTokens(toParts);
69
+ const targetSymbols = new Map(toTokens.flatMap((token, index) => token.digit ? [] : [[token.key, { token, index }]]));
70
+ fromTokens.forEach((token, index) => {
71
+ if (token.digit)
72
+ return;
73
+ const next = targetSymbols.get(token.key);
74
+ if (next && next.token.text === token.text)
75
+ mapping.push([index, next.index]);
76
+ });
77
+ return mapping.sort((left, right) => left[0] - right[0]);
78
+ }
@@ -0,0 +1,8 @@
1
+ import type { ReactNode } from 'react';
2
+ export declare function CircularSwitchVisual({ checked, diameter, centerIcon, centerContent, seed }: {
3
+ checked: boolean;
4
+ diameter: number;
5
+ centerIcon?: ReactNode;
6
+ centerContent?: ReactNode | ((checked: boolean) => ReactNode);
7
+ seed: string | number;
8
+ }): import("react").JSX.Element;
@@ -0,0 +1,82 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useEffect, useRef, useState } from 'react';
3
+ import { PencilSurface } from '../PencilSurface.js';
4
+ import { PencilText } from '../PencilText.js';
5
+ import { ProgressRing } from '../display/ProgressRing.js';
6
+ import { useInkraLocale } from '../i18n/index.js';
7
+ /** Animate only the clipping percentage; ProgressRing retains its full pencil texture. */
8
+ function useSwitchArcProgress(checked) {
9
+ const target = checked ? 75 : 0;
10
+ const [progress, setProgress] = useState(target);
11
+ const progressRef = useRef(target);
12
+ const targetRef = useRef(target);
13
+ const frameRef = useRef(null);
14
+ const mediaRef = useRef(null);
15
+ targetRef.current = target;
16
+ useEffect(() => {
17
+ const media = typeof window !== 'undefined' && typeof window.matchMedia === 'function'
18
+ ? window.matchMedia('(prefers-reduced-motion: reduce)') : null;
19
+ mediaRef.current = media;
20
+ const change = () => {
21
+ if (!media?.matches)
22
+ return;
23
+ if (frameRef.current !== null)
24
+ cancelAnimationFrame(frameRef.current);
25
+ frameRef.current = null;
26
+ progressRef.current = targetRef.current;
27
+ setProgress(targetRef.current);
28
+ };
29
+ media?.addEventListener('change', change);
30
+ return () => {
31
+ media?.removeEventListener('change', change);
32
+ if (frameRef.current !== null)
33
+ cancelAnimationFrame(frameRef.current);
34
+ frameRef.current = null;
35
+ };
36
+ }, []);
37
+ useEffect(() => {
38
+ if (progressRef.current === target)
39
+ return;
40
+ if (mediaRef.current?.matches || typeof requestAnimationFrame !== 'function') {
41
+ progressRef.current = target;
42
+ setProgress(target);
43
+ return;
44
+ }
45
+ const from = progressRef.current;
46
+ let start;
47
+ const tick = (time) => {
48
+ frameRef.current = null;
49
+ if (targetRef.current !== target)
50
+ return;
51
+ start ?? (start = time);
52
+ const fraction = Math.min(1, Math.max(0, (time - start) / 200));
53
+ const eased = fraction * fraction * (3 - 2 * fraction);
54
+ const next = from + (target - from) * eased;
55
+ progressRef.current = next;
56
+ setProgress(next);
57
+ if (fraction < 1)
58
+ frameRef.current = requestAnimationFrame(tick);
59
+ };
60
+ frameRef.current = requestAnimationFrame(tick);
61
+ return () => {
62
+ if (frameRef.current !== null)
63
+ cancelAnimationFrame(frameRef.current);
64
+ frameRef.current = null;
65
+ };
66
+ }, [target]);
67
+ return progress;
68
+ }
69
+ export function CircularSwitchVisual({ checked, diameter, centerIcon, centerContent, seed }) {
70
+ const { t } = useInkraLocale();
71
+ const progress = useSwitchArcProgress(checked);
72
+ const radius = diameter / 2 - 25;
73
+ const content = centerContent === undefined ? t(checked ? 'switch.on' : 'switch.off') : typeof centerContent === 'function' ? centerContent(checked) : centerContent;
74
+ const hasIcon = centerIcon != null && typeof centerIcon !== 'boolean';
75
+ const hasContent = content != null && typeof content !== 'boolean';
76
+ const height = (radius - 7) * 2;
77
+ const combined = hasIcon && hasContent;
78
+ const iconSize = combined ? Math.min(24, Math.max(12, 16 + (diameter - 104) / 4), height - 20) : 24;
79
+ const lineHeight = Math.min(diameter <= 112 ? 18 : 24, height - iconSize - 6);
80
+ return _jsxs("span", { className: "inkra-switch__circular", "aria-hidden": "true", style: { '--inkra-circular-switch-diameter': `${diameter}px`, '--inkra-circular-switch-center-width': `${(radius - 9) * 2 + 10}px`, '--inkra-circular-switch-center-height': `${height + 6}px`, '--inkra-circular-switch-icon-size': `${iconSize}px`, '--inkra-circular-switch-value-size': combined ? lineHeight < 18 ? '12px' : lineHeight < 22 ? '14px' : 'var(--inkra-text-lg)' : 'var(--inkra-text-lg)', '--inkra-circular-switch-value-line': combined ? `${lineHeight}px` : 'var(--inkra-line-ui)' }, children: [_jsxs("span", { className: "inkra-switch__circular-arc", children: [_jsx("span", { className: "inkra-switch__circular-track", children: _jsx(ProgressRing, { diameter: diameter - 32, thickness: 5, percent: 75, seed: `${seed}-track` }) }), _jsx("span", { className: "inkra-switch__circular-range", children: _jsx(ProgressRing, { diameter: diameter - 32, thickness: 5, percent: progress, seed: `${seed}-range` }) })] }), (hasIcon || hasContent) && _jsxs("span", { className: "inkra-switch__circular-center", inert: true, children: [hasIcon && _jsx("span", { className: "inkra-switch__circular-icon", children: centerIcon }), hasContent && _jsx("span", { className: "inkra-switch__circular-content", children: typeof content === 'string' || typeof content === 'number'
81
+ ? _jsx(PencilText, { className: "inkra-switch__circular-value", ellipsis: centerContent !== undefined, children: String(content) }) : content })] }), _jsx(PencilSurface, { as: "span", className: "inkra-switch__circular-focus", variant: "outline", radius: diameter / 2, seed: `${seed}-focus` })] });
82
+ }
@@ -2,6 +2,13 @@ import type { InputHTMLAttributes, ReactNode } from 'react';
2
2
  export interface SwitchProps extends Omit<InputHTMLAttributes<HTMLInputElement>, 'children' | 'role' | 'type'> {
3
3
  label: ReactNode;
4
4
  description?: ReactNode;
5
+ variant?: 'standard' | 'circular';
6
+ /** Circular variant outer size in CSS pixels, bounded to 96–512. */
7
+ diameter?: number;
8
+ /** Decorative, non-interactive icon for the circular center. */
9
+ centerIcon?: ReactNode;
10
+ /** Circular state label override. null/false hide it; nodes must be non-interactive. */
11
+ centerContent?: ReactNode | ((checked: boolean) => ReactNode);
5
12
  onCheckedChange?: (checked: boolean) => void;
6
13
  seed?: string | number;
7
14
  }
@@ -4,6 +4,7 @@ import { PencilIcon } from '../PencilIcon.js';
4
4
  import { PencilSurface } from '../PencilSurface.js';
5
5
  import { PencilText } from '../PencilText.js';
6
6
  import { useNativeChecked } from './useNativeChecked.js';
7
+ import { CircularSwitchVisual } from './CircularSwitchVisual.js';
7
8
  const thumbPaths = [
8
9
  { d: 'M 20 3 C 30 3 37 10 37 20 C 37 30 30 37 20 37 C 10 37 3 30 3 20 C 3 10 10 3 20 3 Z', mode: 'wash' },
9
10
  { d: 'M 20 3 C 30 3 37 10 37 20 C 37 30 30 37 20 37 C 10 37 3 30 3 20 C 3 10 10 3 20 3 Z' },
@@ -12,7 +13,7 @@ function ids(...values) {
12
13
  const value = values.filter(Boolean).join(' ');
13
14
  return value || undefined;
14
15
  }
15
- export const Switch = forwardRef(function Switch({ 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledBy, 'aria-describedby': ariaDescribedBy, checked, className, defaultChecked, description, disabled = false, form, id, label, onChange, onCheckedChange, seed, ...props }, forwardedRef) {
16
+ export const Switch = forwardRef(function Switch({ 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledBy, 'aria-describedby': ariaDescribedBy, checked, className, defaultChecked, description, disabled = false, readOnly = false, variant = 'standard', diameter = 128, centerIcon, centerContent, form, id, label, onChange, onCheckedChange, onClick, seed, ...props }, forwardedRef) {
16
17
  const generatedId = useId();
17
18
  const inputId = id ?? `inkra-switch-${generatedId}`;
18
19
  const labelId = `${inputId}-label`;
@@ -20,6 +21,7 @@ export const Switch = forwardRef(function Switch({ 'aria-label': ariaLabel, 'ari
20
21
  const resolvedLabelledBy = ariaLabel == null && ariaLabelledBy == null ? labelId : ariaLabelledBy;
21
22
  const resolvedSeed = seed ?? inputId;
22
23
  const inputRef = useRef(null);
24
+ const resolvedDiameter = Math.min(512, Math.max(96, Number.isFinite(diameter) ? diameter : 128));
23
25
  useImperativeHandle(forwardedRef, () => inputRef.current);
24
26
  const { controlled, syncUncontrolled, visualChecked } = useNativeChecked({
25
27
  checked,
@@ -27,11 +29,21 @@ export const Switch = forwardRef(function Switch({ 'aria-label': ariaLabel, 'ari
27
29
  form,
28
30
  inputRef,
29
31
  });
30
- return (_jsxs("label", { className: ['inkra-selection-control', 'inkra-switch', className].filter(Boolean).join(' '), "data-checked": visualChecked || undefined, "data-disabled": disabled || undefined, htmlFor: inputId, children: [_jsx("input", { ...props, "aria-describedby": ids(ariaDescribedBy, descriptionId), "aria-label": ariaLabel, "aria-labelledby": resolvedLabelledBy, checked: controlled ? checked : undefined, className: "inkra-selection-control__input", defaultChecked: controlled ? undefined : defaultChecked, disabled: disabled, form: form, id: inputId, onChange: (event) => {
32
+ return (_jsxs("label", { className: ['inkra-selection-control', 'inkra-switch', variant === 'circular' && 'inkra-switch--circular', className].filter(Boolean).join(' '), "data-variant": variant, "data-readonly": readOnly || undefined, style: variant === 'circular' ? { '--inkra-circular-switch-diameter': `${resolvedDiameter}px` } : undefined, "data-checked": visualChecked || undefined, "data-disabled": disabled || undefined, htmlFor: inputId, children: [_jsx("input", { ...props, "aria-describedby": ids(ariaDescribedBy, descriptionId), "aria-label": ariaLabel, "aria-labelledby": resolvedLabelledBy, checked: controlled ? checked : undefined, className: "inkra-selection-control__input", defaultChecked: controlled ? undefined : defaultChecked, disabled: disabled, readOnly: readOnly, "aria-readonly": readOnly || undefined, form: form, id: inputId, onChange: (event) => {
33
+ if (readOnly || event.nativeEvent?.defaultPrevented) {
34
+ event.currentTarget.checked = visualChecked;
35
+ return;
36
+ }
31
37
  const nextChecked = event.currentTarget.checked;
32
38
  if (!controlled)
33
39
  syncUncontrolled(nextChecked);
34
40
  onChange?.(event);
35
41
  onCheckedChange?.(nextChecked);
36
- }, ref: inputRef, role: "switch", type: "checkbox" }), _jsxs(PencilSurface, { "aria-hidden": "true", as: "span", className: "inkra-switch__track", radius: 8, seed: `${resolvedSeed}-track`, variant: visualChecked ? 'quiet' : 'outline', children: [_jsx(PencilSurface, { as: "span", "aria-hidden": "true", className: "inkra-selection-control__focus-ring", seed: `${resolvedSeed}-focus`, variant: "outline" }), _jsx(PencilIcon, { className: "inkra-switch__thumb", paths: thumbPaths, seed: `${resolvedSeed}-thumb`, size: 24, sourceSize: 40 })] }), _jsxs("span", { className: "inkra-selection-control__copy", children: [typeof label === 'string' ? (_jsx(PencilText, { as: "span", className: "inkra-selection-control__label", id: labelId, seed: `${resolvedSeed}-label`, children: label })) : _jsx("span", { className: "inkra-selection-control__label", id: labelId, children: label }), description == null ? null : (_jsx("span", { className: "inkra-selection-control__description", id: descriptionId, children: description }))] })] }));
42
+ }, onClick: event => {
43
+ if (readOnly) {
44
+ event.preventDefault();
45
+ return;
46
+ }
47
+ onClick?.(event);
48
+ }, ref: inputRef, role: "switch", type: "checkbox" }), variant === 'circular' ? _jsx(CircularSwitchVisual, { checked: visualChecked, diameter: resolvedDiameter, centerIcon: centerIcon, centerContent: centerContent, seed: resolvedSeed }) : _jsxs(PencilSurface, { "aria-hidden": "true", as: "span", className: "inkra-switch__track", radius: 8, seed: `${resolvedSeed}-track`, variant: visualChecked ? 'quiet' : 'outline', children: [_jsx(PencilSurface, { as: "span", "aria-hidden": "true", className: "inkra-selection-control__focus-ring", seed: `${resolvedSeed}-focus`, variant: "outline" }), _jsx(PencilIcon, { className: "inkra-switch__thumb", paths: thumbPaths, seed: `${resolvedSeed}-thumb`, size: 24, sourceSize: 40 })] }), _jsxs("span", { className: "inkra-selection-control__copy", children: [typeof label === 'string' ? (_jsx(PencilText, { as: "span", className: "inkra-selection-control__label", id: labelId, seed: `${resolvedSeed}-label`, children: label })) : _jsx("span", { className: "inkra-selection-control__label", id: labelId, children: label }), description == null ? null : (_jsx("span", { className: "inkra-selection-control__description", id: descriptionId, children: description }))] })] }));
37
49
  });
@@ -0,0 +1,35 @@
1
+ import type { HTMLAttributes, ReactNode } from 'react';
2
+ export interface CircularSliderProps extends Omit<HTMLAttributes<HTMLDivElement>, 'children' | 'defaultValue' | 'onChange'> {
3
+ value?: number;
4
+ defaultValue?: number;
5
+ onValueChange?: (value: number) => void;
6
+ onValueCommit?: (value: number) => void;
7
+ min?: number;
8
+ max?: number;
9
+ step?: number;
10
+ disabled?: boolean;
11
+ readOnly?: boolean;
12
+ name?: string;
13
+ form?: string;
14
+ size?: 'sm' | 'md' | 'lg';
15
+ /** Outer size in CSS pixels, bounded to 96–512. Defaults to 128/160/192 by size. */
16
+ diameter?: number;
17
+ /** Degrees from the top, increasing clockwise. Defaults to 225. */
18
+ startAngle?: number;
19
+ /** Available arc in degrees, from 1 to 360. Defaults to 270. */
20
+ sweepAngle?: number;
21
+ /** Physical direction of increasing values; unaffected by the page's text direction. */
22
+ clockwise?: boolean;
23
+ /** Shows the default value when centerContent is not specified. */
24
+ showValue?: boolean;
25
+ /** Decorative, non-interactive icon placed above the center content. */
26
+ centerIcon?: ReactNode;
27
+ /** Overrides the default value. Strings/numbers use PencilText; null/false hide it.
28
+ * Custom nodes must be non-interactive; the center is inert and hidden from accessibility APIs.
29
+ */
30
+ centerContent?: ReactNode | ((value: number) => ReactNode);
31
+ /** Used for the center label and aria-valuetext. */
32
+ getValueLabel?: (value: number) => string;
33
+ seed?: string | number;
34
+ }
35
+ export declare const CircularSlider: import("react").ForwardRefExoticComponent<CircularSliderProps & import("react").RefAttributes<HTMLDivElement>>;