@waveso/ui 0.15.0 → 0.16.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 (129) hide show
  1. package/README.md +25 -22
  2. package/dist/accordion.d.ts +4 -20
  3. package/dist/accordion.js +2 -2
  4. package/dist/action-bar.d.ts +1 -10
  5. package/dist/action-bar.js +6 -5
  6. package/dist/alert-dialog.d.ts +12 -52
  7. package/dist/alert-dialog.js +5 -5
  8. package/dist/alert.d.ts +13 -22
  9. package/dist/alert.js +13 -4
  10. package/dist/animate.d.ts +6 -49
  11. package/dist/animate.js +8 -13
  12. package/dist/aspect-ratio.d.ts +3 -8
  13. package/dist/autocomplete.d.ts +21 -88
  14. package/dist/autocomplete.js +3 -3
  15. package/dist/avatar.d.ts +7 -28
  16. package/dist/avatar.js +4 -4
  17. package/dist/badge.d.ts +3 -11
  18. package/dist/badge.js +6 -6
  19. package/dist/breadcrumb.d.ts +7 -31
  20. package/dist/button-group.d.ts +7 -20
  21. package/dist/button-group.js +5 -4
  22. package/dist/button.d.ts +4 -12
  23. package/dist/button.js +6 -6
  24. package/dist/card.d.ts +16 -33
  25. package/dist/card.js +27 -14
  26. package/dist/checkbox.d.ts +1 -6
  27. package/dist/checkbox.js +2 -2
  28. package/dist/collapsible.d.ts +3 -12
  29. package/dist/combobox.d.ts +18 -81
  30. package/dist/combobox.js +4 -4
  31. package/dist/context-menu.d.ts +17 -68
  32. package/dist/context-menu.js +14 -14
  33. package/dist/count.d.ts +1 -15
  34. package/dist/count.js +3 -2
  35. package/dist/dialog.d.ts +10 -41
  36. package/dist/dialog.js +5 -5
  37. package/dist/direction.d.ts +0 -1
  38. package/dist/drawer.d.ts +11 -44
  39. package/dist/drawer.js +1 -1
  40. package/dist/encrypted-text.d.ts +15 -14
  41. package/dist/encrypted-text.js +205 -66
  42. package/dist/field.d.ts +27 -30
  43. package/dist/field.js +36 -8
  44. package/dist/fieldset.d.ts +14 -0
  45. package/dist/fieldset.js +26 -0
  46. package/dist/film-grain-shader.d.ts +3 -4
  47. package/dist/film-grain-webgl.d.ts +2 -9
  48. package/dist/film-grain-webgl.js +4 -1
  49. package/dist/film-grain.d.ts +1 -11
  50. package/dist/form.d.ts +23 -42
  51. package/dist/form.js +7 -4
  52. package/dist/glyph-matrix.d.ts +25 -0
  53. package/dist/glyph-matrix.js +266 -0
  54. package/dist/gradient-reveal-text.d.ts +4 -17
  55. package/dist/gradient-reveal-text.js +8 -8
  56. package/dist/hooks/use-mobile.d.ts +2 -3
  57. package/dist/infinite-scroll.d.ts +1 -16
  58. package/dist/input-group.d.ts +11 -37
  59. package/dist/input-group.js +1 -1
  60. package/dist/input-otp.d.ts +4 -20
  61. package/dist/input-otp.js +2 -2
  62. package/dist/input.d.ts +3 -11
  63. package/dist/input.js +1 -1
  64. package/dist/item.d.ts +14 -51
  65. package/dist/item.js +17 -11
  66. package/dist/kbd.d.ts +3 -11
  67. package/dist/kbd.js +2 -2
  68. package/dist/label.d.ts +1 -6
  69. package/dist/label.js +1 -1
  70. package/dist/lib/focus.d.ts +3 -5
  71. package/dist/lib/internal-icons.d.ts +19 -22
  72. package/dist/lib/merge-refs.d.ts +9 -0
  73. package/dist/lib/merge-refs.js +16 -0
  74. package/dist/lib/utils.d.ts +3 -5
  75. package/dist/lib/utils.js +9 -1
  76. package/dist/masonry.d.ts +2 -16
  77. package/dist/masonry.js +5 -3
  78. package/dist/menu.d.ts +17 -65
  79. package/dist/menu.js +7 -7
  80. package/dist/menubar.d.ts +16 -55
  81. package/dist/menubar.js +1 -1
  82. package/dist/pagination.d.ts +9 -33
  83. package/dist/pagination.js +1 -1
  84. package/dist/popover.d.ts +9 -43
  85. package/dist/popover.js +1 -1
  86. package/dist/preview-card.d.ts +5 -25
  87. package/dist/preview-card.js +1 -1
  88. package/dist/progress.d.ts +5 -24
  89. package/dist/progress.js +2 -2
  90. package/dist/radio-group.d.ts +2 -9
  91. package/dist/radio-group.js +1 -1
  92. package/dist/radio.d.ts +2 -10
  93. package/dist/radio.js +2 -2
  94. package/dist/resizable.d.ts +43 -0
  95. package/dist/resizable.js +53 -0
  96. package/dist/scroll-area.d.ts +14 -13
  97. package/dist/scroll-area.js +26 -7
  98. package/dist/select.d.ts +15 -64
  99. package/dist/select.js +4 -4
  100. package/dist/separator.d.ts +1 -7
  101. package/dist/separator.js +1 -1
  102. package/dist/sidebar.d.ts +29 -126
  103. package/dist/sidebar.js +14 -14
  104. package/dist/skeleton.d.ts +1 -6
  105. package/dist/slider.d.ts +1 -14
  106. package/dist/slider.js +5 -5
  107. package/dist/spinner.d.ts +8 -6
  108. package/dist/spinner.js +24 -6
  109. package/dist/styles.css +575 -182
  110. package/dist/switch.d.ts +2 -9
  111. package/dist/switch.js +6 -6
  112. package/dist/table.d.ts +14 -34
  113. package/dist/table.js +40 -9
  114. package/dist/tabs.d.ts +5 -22
  115. package/dist/tabs.js +3 -3
  116. package/dist/textarea.d.ts +3 -12
  117. package/dist/textarea.js +1 -1
  118. package/dist/toast.d.ts +13 -39
  119. package/dist/toast.js +4 -4
  120. package/dist/toggle-group.d.ts +3 -19
  121. package/dist/toggle-group.js +16 -10
  122. package/dist/toggle.d.ts +3 -11
  123. package/dist/toggle.js +11 -6
  124. package/dist/tooltip.d.ts +6 -24
  125. package/dist/tooltip.js +10 -6
  126. package/dist/typewriter.d.ts +1 -14
  127. package/dist/video-text.d.ts +25 -0
  128. package/dist/video-text.js +147 -0
  129. package/package.json +57 -51
package/dist/drawer.js CHANGED
@@ -46,7 +46,7 @@ function DrawerViewport({ className, ...props }) {
46
46
  function DrawerContent({ className, children, showCloseButton = false, ...props }) {
47
47
  return /* @__PURE__ */ jsxs(DrawerPortal, { children: [/* @__PURE__ */ jsx(DrawerOverlay, {}), /* @__PURE__ */ jsx(DrawerViewport, { children: /* @__PURE__ */ jsxs(Drawer$1.Popup, {
48
48
  "data-slot": "drawer-content",
49
- className: cn("group/drawer-content bg-foundation fixed z-50 flex flex-col overflow-y-auto border-edge text-sm shadow-lg outline-hidden", "transition-[translate] duration-(--duration-lg) ease-(--ease) data-[swiping]:duration-0", "data-[swipe-direction=down]:inset-x-0 data-[swipe-direction=down]:bottom-0 data-[swipe-direction=down]:mt-24 data-[swipe-direction=down]:max-h-[80vh] data-[swipe-direction=down]:rounded-t-lg data-[swipe-direction=down]:border-t", "data-[swipe-direction=up]:inset-x-0 data-[swipe-direction=up]:top-0 data-[swipe-direction=up]:mb-24 data-[swipe-direction=up]:max-h-[80vh] data-[swipe-direction=up]:rounded-b-lg data-[swipe-direction=up]:border-b", "data-[swipe-direction=left]:inset-y-0 data-[swipe-direction=left]:left-0 data-[swipe-direction=left]:w-3/4 data-[swipe-direction=left]:rounded-r-lg data-[swipe-direction=left]:border-r data-[swipe-direction=left]:sm:max-w-sm", "data-[swipe-direction=right]:inset-y-0 data-[swipe-direction=right]:right-0 data-[swipe-direction=right]:w-3/4 data-[swipe-direction=right]:rounded-l-lg data-[swipe-direction=right]:border-l data-[swipe-direction=right]:sm:max-w-sm", "data-[swipe-direction=down]:data-[starting-style]:translate-y-full data-[swipe-direction=down]:data-[ending-style]:translate-y-full", "data-[swipe-direction=up]:data-[starting-style]:-translate-y-full data-[swipe-direction=up]:data-[ending-style]:-translate-y-full", "data-[swipe-direction=left]:data-[starting-style]:-translate-x-full data-[swipe-direction=left]:data-[ending-style]:-translate-x-full", "data-[swipe-direction=right]:data-[starting-style]:translate-x-full data-[swipe-direction=right]:data-[ending-style]:translate-x-full", className),
49
+ className: cn("group/drawer-content bg-foundation fixed z-50 flex flex-col overflow-y-auto border-shade text-sm shadow-lg outline-hidden", "transition-[translate] duration-(--duration-lg) ease-(--ease) data-[swiping]:duration-0", "data-[swipe-direction=down]:inset-x-0 data-[swipe-direction=down]:bottom-0 data-[swipe-direction=down]:mt-24 data-[swipe-direction=down]:max-h-[80vh] data-[swipe-direction=down]:rounded-t-lg data-[swipe-direction=down]:border-t", "data-[swipe-direction=up]:inset-x-0 data-[swipe-direction=up]:top-0 data-[swipe-direction=up]:mb-24 data-[swipe-direction=up]:max-h-[80vh] data-[swipe-direction=up]:rounded-b-lg data-[swipe-direction=up]:border-b", "data-[swipe-direction=left]:inset-y-0 data-[swipe-direction=left]:left-0 data-[swipe-direction=left]:w-3/4 data-[swipe-direction=left]:rounded-r-lg data-[swipe-direction=left]:border-r data-[swipe-direction=left]:sm:max-w-sm", "data-[swipe-direction=right]:inset-y-0 data-[swipe-direction=right]:right-0 data-[swipe-direction=right]:w-3/4 data-[swipe-direction=right]:rounded-l-lg data-[swipe-direction=right]:border-l data-[swipe-direction=right]:sm:max-w-sm", "data-[swipe-direction=down]:data-[starting-style]:translate-y-full data-[swipe-direction=down]:data-[ending-style]:translate-y-full", "data-[swipe-direction=up]:data-[starting-style]:-translate-y-full data-[swipe-direction=up]:data-[ending-style]:-translate-y-full", "data-[swipe-direction=left]:data-[starting-style]:-translate-x-full data-[swipe-direction=left]:data-[ending-style]:-translate-x-full", "data-[swipe-direction=right]:data-[starting-style]:translate-x-full data-[swipe-direction=right]:data-[ending-style]:translate-x-full", className),
50
50
  ...props,
51
51
  children: [
52
52
  /* @__PURE__ */ jsx("div", {
@@ -1,6 +1,4 @@
1
- import * as _$react_jsx_runtime0 from "react/jsx-runtime";
2
1
  import * as React from "react";
3
-
4
2
  //#region src/encrypted-text.d.ts
5
3
  type EncryptedTextProps = React.ComponentProps<"span"> & {
6
4
  text: string;
@@ -11,18 +9,21 @@ type EncryptedTextProps = React.ComponentProps<"span"> & {
11
9
  revealedClassName?: string;
12
10
  scrambleOnly?: boolean;
13
11
  scrambleOneChar?: boolean;
12
+ /** Where the reveal begins. Default: "start" */
13
+ revealDirection?: "start" | "end" | "center" | "random";
14
+ /** Scramble and reveal again when hovered. Default: false */
15
+ scrambleOnHover?: boolean;
16
+ /** Scramble and reveal again after every reveal. Default: false */
17
+ loop?: boolean;
18
+ /** Pause between a reveal and the next loop, in ms. Default: 2000 */
19
+ loopDelayMs?: number;
14
20
  };
15
- declare function EncryptedText({
16
- text,
17
- className,
18
- revealDelayMs,
19
- charset,
20
- flipDelayMs,
21
- encryptedClassName,
22
- revealedClassName,
23
- scrambleOnly,
24
- scrambleOneChar,
25
- ...props
26
- }: EncryptedTextProps): _$react_jsx_runtime0.JSX.Element | null;
21
+ /**
22
+ * Text that starts scrambled and reveals itself, character by character, once
23
+ * it scrolls into view. Each character keeps its real width while scrambled
24
+ * (the stand-in glyph sits over an invisible copy and narrows to fit), so the
25
+ * text never shifts what's around it. Screen readers get the real text.
26
+ */
27
+ declare function EncryptedText({ text, className, revealDelayMs, charset, flipDelayMs, encryptedClassName, revealedClassName, scrambleOnly, scrambleOneChar, revealDirection, scrambleOnHover, loop, loopDelayMs, onPointerEnter, ...props }: EncryptedTextProps): React.JSX.Element | null;
27
28
  //#endregion
28
29
  export { EncryptedText, type EncryptedTextProps };
@@ -1,29 +1,95 @@
1
1
  "use client";
2
2
  import { cn } from "./lib/utils.js";
3
- import { jsx, jsxs } from "react/jsx-runtime";
3
+ import { Fragment, jsx, jsxs } from "react/jsx-runtime";
4
4
  import * as React from "react";
5
- import { useReducedMotion } from "motion/react";
6
5
  //#region src/encrypted-text.tsx
7
6
  const DEFAULT_CHARSET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*()_+-={}[];:,.<>/?";
8
- function randomChar(charset) {
9
- return charset.charAt(Math.floor(Math.random() * charset.length));
7
+ const REDUCED_MOTION = "(prefers-reduced-motion: reduce)";
8
+ /** Opacity of a scrambled character far from its turn. */
9
+ const ENCRYPTED_OPACITY = .6;
10
+ /** Characters ahead of the reveal that brighten toward full opacity. */
11
+ const LEAD = 4;
12
+ function isSpace(char) {
13
+ return /\s/.test(char);
10
14
  }
11
- function scramblePreservingSpaces(original, charset) {
12
- if (!original) return "";
13
- let result = "";
14
- for (let i = 0; i < original.length; i += 1) result += original[i] === " " ? " " : randomChar(charset);
15
- return result;
15
+ function randomGlyph(pool) {
16
+ return pool[Math.floor(Math.random() * pool.length)] ?? "";
16
17
  }
17
- function EncryptedText({ text, className, revealDelayMs = 50, charset = DEFAULT_CHARSET, flipDelayMs = 50, encryptedClassName, revealedClassName, scrambleOnly = false, scrambleOneChar = false, ...props }) {
18
- const prefersReducedMotion = useReducedMotion();
18
+ /**
19
+ * A scramble that's the same on the server and in the browser, so the text
20
+ * can start encrypted without a hydration mismatch.
21
+ */
22
+ function initialFrame(chars, pool) {
23
+ let rank = 0;
24
+ return {
25
+ glyphs: chars.map((char, index) => isSpace(char) ? char : pool[((char.codePointAt(0) ?? 0) + index * 7) % pool.length] ?? char),
26
+ scales: chars.map(() => 1),
27
+ ranks: chars.map((char) => isSpace(char) ? -1 : rank++),
28
+ revealed: 0
29
+ };
30
+ }
31
+ /** Each character's place in the reveal order for a direction; -1 for spaces. */
32
+ function rankLetters(chars, direction) {
33
+ const order = chars.flatMap((char, index) => isSpace(char) ? [] : [index]);
34
+ if (direction === "end") order.reverse();
35
+ if (direction === "center") {
36
+ const middle = (chars.length - 1) / 2;
37
+ order.sort((a, b) => Math.abs(a - middle) - Math.abs(b - middle));
38
+ }
39
+ if (direction === "random") for (let i = order.length - 1; i > 0; i--) {
40
+ const j = Math.floor(Math.random() * (i + 1));
41
+ [order[i], order[j]] = [order[j] ?? 0, order[i] ?? 0];
42
+ }
43
+ const ranks = chars.map(() => -1);
44
+ order.forEach((index, rank) => {
45
+ ranks[index] = rank;
46
+ });
47
+ return ranks;
48
+ }
49
+ const glyphWidths = /* @__PURE__ */ new Map();
50
+ let measurer;
51
+ /** Measures a glyph in a font, caching it once the font has loaded. */
52
+ function measureGlyph(font, glyph) {
53
+ const key = `${font}|${glyph}`;
54
+ const cached = glyphWidths.get(key);
55
+ if (cached !== void 0) return cached;
56
+ measurer ??= document.createElement("canvas").getContext("2d");
57
+ if (!measurer) return 0;
58
+ measurer.font = font;
59
+ const width = measurer.measureText(glyph).width;
60
+ if (document.fonts.check(font)) glyphWidths.set(key, width);
61
+ return width;
62
+ }
63
+ /** How much to narrow a stand-in glyph so it fits the real one's width. */
64
+ function squeeze(font, char, glyph) {
65
+ const room = measureGlyph(font, char);
66
+ const need = measureGlyph(font, glyph);
67
+ return need > room && need > 0 ? Math.max(.05, room / need) : 1;
68
+ }
69
+ function subscribeReducedMotion(onChange) {
70
+ const query = window.matchMedia(REDUCED_MOTION);
71
+ query.addEventListener("change", onChange);
72
+ return () => query.removeEventListener("change", onChange);
73
+ }
74
+ const getReducedMotion = () => window.matchMedia(REDUCED_MOTION).matches;
75
+ const getServerReducedMotion = () => false;
76
+ /**
77
+ * Text that starts scrambled and reveals itself, character by character, once
78
+ * it scrolls into view. Each character keeps its real width while scrambled
79
+ * (the stand-in glyph sits over an invisible copy and narrows to fit), so the
80
+ * text never shifts what's around it. Screen readers get the real text.
81
+ */
82
+ function EncryptedText({ text, className, revealDelayMs = 50, charset = DEFAULT_CHARSET, flipDelayMs = 50, encryptedClassName, revealedClassName, scrambleOnly = false, scrambleOneChar = false, revealDirection = "start", scrambleOnHover = false, loop = false, loopDelayMs = 2e3, onPointerEnter, ...props }) {
83
+ const prefersReducedMotion = React.useSyncExternalStore(subscribeReducedMotion, getReducedMotion, getServerReducedMotion);
19
84
  const ref = React.useRef(null);
85
+ const restartRef = React.useRef(null);
86
+ const isRunningRef = React.useRef(false);
20
87
  const [isInView, setIsInView] = React.useState(false);
21
- const [revealCount, setRevealCount] = React.useState(0);
22
- const [, setFlipTick] = React.useState(0);
23
- const animationFrameRef = React.useRef(null);
24
- const startTimeRef = React.useRef(0);
25
- const lastFlipTimeRef = React.useRef(0);
26
- const scrambleCharsRef = React.useRef([]);
88
+ const chars = React.useMemo(() => Array.from(text), [text]);
89
+ const pool = React.useMemo(() => Array.from(charset.length > 0 ? charset : DEFAULT_CHARSET), [charset]);
90
+ const initial = React.useMemo(() => initialFrame(chars, pool), [chars, pool]);
91
+ const [live, setLive] = React.useState();
92
+ const frame = live?.chars === chars ? live.frame : initial;
27
93
  React.useEffect(() => {
28
94
  if (prefersReducedMotion) return;
29
95
  const el = ref.current;
@@ -38,78 +104,151 @@ function EncryptedText({ text, className, revealDelayMs = 50, charset = DEFAULT_
38
104
  return () => observer.disconnect();
39
105
  }, [prefersReducedMotion]);
40
106
  React.useEffect(() => {
41
- if (prefersReducedMotion) return;
42
- if (!isInView) return;
43
- scrambleCharsRef.current = (text ? scramblePreservingSpaces(text, charset) : "").split("");
44
- startTimeRef.current = performance.now();
45
- lastFlipTimeRef.current = startTimeRef.current;
46
- setRevealCount(0);
47
- let isCancelled = false;
107
+ const root = ref.current;
108
+ if (prefersReducedMotion || !isInView || !root) return;
109
+ const computed = getComputedStyle(root);
110
+ const font = `${computed.fontStyle} ${computed.fontWeight} ${computed.fontSize} ${computed.fontFamily}`;
111
+ const letters = chars.filter((char) => !isSpace(char)).length;
112
+ let glyphs = [];
113
+ let scales = [];
114
+ let ranks = [];
115
+ let revealed = 0;
116
+ let start = 0;
117
+ let lastFlip = 0;
118
+ let frameId = 0;
119
+ let timer;
120
+ const scramble = (index) => {
121
+ const glyph = randomGlyph(pool);
122
+ glyphs[index] = glyph;
123
+ scales[index] = squeeze(font, chars[index] ?? glyph, glyph);
124
+ };
125
+ const publish = () => {
126
+ setLive({
127
+ chars,
128
+ frame: {
129
+ glyphs: [...glyphs],
130
+ scales: [...scales],
131
+ ranks,
132
+ revealed
133
+ }
134
+ });
135
+ };
48
136
  const update = (now) => {
49
- if (isCancelled) return;
50
- const totalLength = text.length;
51
- if (scrambleOnly) {
52
- if (now - lastFlipTimeRef.current >= Math.max(0, flipDelayMs)) {
53
- if (scrambleOneChar) {
54
- const indices = [];
55
- for (let i = 0; i < totalLength; i++) if (text[i] !== " ") indices.push(i);
56
- if (indices.length > 0) {
57
- const idx = indices[Math.floor(Math.random() * indices.length)];
58
- scrambleCharsRef.current[idx] = randomChar(charset);
59
- }
60
- } else for (let index = 0; index < totalLength; index += 1) scrambleCharsRef.current[index] = text[index] === " " ? " " : randomChar(charset);
61
- lastFlipTimeRef.current = now;
62
- setFlipTick((t) => t + 1 & 65535);
137
+ let isChanged = false;
138
+ if (!scrambleOnly) {
139
+ const next = Math.min(letters, Math.floor((now - start) / Math.max(1, revealDelayMs)));
140
+ isChanged = next !== revealed;
141
+ revealed = next;
142
+ if (revealed >= letters) {
143
+ publish();
144
+ isRunningRef.current = false;
145
+ if (loop) timer = setTimeout(run, Math.max(0, loopDelayMs));
146
+ return;
63
147
  }
64
- animationFrameRef.current = requestAnimationFrame(update);
65
- return;
66
148
  }
67
- const elapsedMs = now - startTimeRef.current;
68
- const currentRevealCount = Math.min(totalLength, Math.floor(elapsedMs / Math.max(1, revealDelayMs)));
69
- setRevealCount(currentRevealCount);
70
- if (currentRevealCount >= totalLength) return;
71
- if (now - lastFlipTimeRef.current >= Math.max(0, flipDelayMs)) {
72
- for (let index = currentRevealCount; index < totalLength; index += 1) scrambleCharsRef.current[index] = text[index] === " " ? " " : randomChar(charset);
73
- lastFlipTimeRef.current = now;
74
- setFlipTick((t) => t + 1 & 65535);
149
+ if (now - lastFlip >= Math.max(0, flipDelayMs)) {
150
+ lastFlip = now;
151
+ isChanged = true;
152
+ if (scrambleOnly && scrambleOneChar) {
153
+ const scrambled = chars.flatMap((char, index) => isSpace(char) ? [] : [index]);
154
+ const index = scrambled[Math.floor(Math.random() * scrambled.length)];
155
+ if (index !== void 0) scramble(index);
156
+ } else chars.forEach((char, index) => {
157
+ if (!isSpace(char) && (ranks[index] ?? 0) >= revealed) scramble(index);
158
+ });
75
159
  }
76
- animationFrameRef.current = requestAnimationFrame(update);
160
+ if (isChanged) publish();
161
+ frameId = requestAnimationFrame(update);
162
+ };
163
+ const run = () => {
164
+ cancelAnimationFrame(frameId);
165
+ clearTimeout(timer);
166
+ isRunningRef.current = true;
167
+ ranks = rankLetters(chars, revealDirection);
168
+ glyphs = [...chars];
169
+ scales = chars.map(() => 1);
170
+ chars.forEach((char, index) => {
171
+ if (!isSpace(char)) scramble(index);
172
+ });
173
+ revealed = 0;
174
+ start = performance.now();
175
+ lastFlip = start;
176
+ publish();
177
+ frameId = requestAnimationFrame(update);
77
178
  };
78
- animationFrameRef.current = requestAnimationFrame(update);
179
+ restartRef.current = run;
180
+ run();
79
181
  return () => {
80
- isCancelled = true;
81
- if (animationFrameRef.current !== null) cancelAnimationFrame(animationFrameRef.current);
182
+ cancelAnimationFrame(frameId);
183
+ clearTimeout(timer);
184
+ isRunningRef.current = false;
185
+ restartRef.current = null;
82
186
  };
83
187
  }, [
84
188
  prefersReducedMotion,
85
189
  isInView,
86
- text,
190
+ chars,
191
+ pool,
87
192
  revealDelayMs,
88
- charset,
89
193
  flipDelayMs,
90
194
  scrambleOnly,
91
- scrambleOneChar
195
+ scrambleOneChar,
196
+ revealDirection,
197
+ loop,
198
+ loopDelayMs
92
199
  ]);
200
+ const handlePointerEnter = (event) => {
201
+ onPointerEnter?.(event);
202
+ if (scrambleOnHover && !scrambleOnly && !isRunningRef.current) restartRef.current?.();
203
+ };
93
204
  if (!text) return null;
205
+ const words = [];
206
+ chars.forEach((char, index) => {
207
+ const word = words.at(-1);
208
+ if (isSpace(char) || !word || isSpace(chars[index - 1] ?? " ")) words.push({
209
+ start: index,
210
+ letters: [char]
211
+ });
212
+ else word.letters.push(char);
213
+ });
94
214
  return /* @__PURE__ */ jsxs("span", {
95
215
  ref,
96
216
  "data-slot": "encrypted-text",
97
217
  className,
218
+ onPointerEnter: handlePointerEnter,
98
219
  ...props,
99
220
  children: [/* @__PURE__ */ jsx("span", {
100
221
  className: "sr-only",
101
222
  children: text
102
- }), text.split("").map((char, index) => {
103
- const isRevealed = prefersReducedMotion || !isInView || !scrambleOnly && index < revealCount;
104
- const displayChar = isRevealed ? char : char === " " ? " " : scrambleCharsRef.current[index] ?? char;
105
- return /* @__PURE__ */ jsx("span", {
106
- "aria-hidden": "true",
107
- "data-slot": "encrypted-text-char",
108
- "data-revealed": isRevealed || void 0,
109
- className: cn(isRevealed ? revealedClassName : encryptedClassName),
110
- children: displayChar
111
- }, index);
112
- })]
223
+ }), words.map((word) => isSpace(word.letters[0] ?? "") ? /* @__PURE__ */ jsx(React.Fragment, { children: word.letters[0] }, word.start) : /* @__PURE__ */ jsx("span", {
224
+ "aria-hidden": "true",
225
+ "data-slot": "encrypted-text-word",
226
+ className: "whitespace-nowrap",
227
+ children: word.letters.map((char, offset) => {
228
+ const index = word.start + offset;
229
+ const rank = frame.ranks[index] ?? 0;
230
+ const isRevealed = prefersReducedMotion || !scrambleOnly && rank < frame.revealed;
231
+ const distance = rank - frame.revealed;
232
+ const lift = Math.max(0, 1 - distance / LEAD);
233
+ const scale = frame.scales[index] ?? 1;
234
+ return /* @__PURE__ */ jsx("span", {
235
+ "data-slot": "encrypted-text-char",
236
+ "data-revealed": isRevealed || void 0,
237
+ className: cn("motion-color relative inline-block", isRevealed ? revealedClassName : encryptedClassName),
238
+ style: isRevealed || scrambleOnly ? void 0 : { opacity: ENCRYPTED_OPACITY + .4 * lift },
239
+ children: isRevealed ? char : /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx("span", {
240
+ "data-slot": "encrypted-text-ghost",
241
+ className: "invisible",
242
+ children: char
243
+ }), /* @__PURE__ */ jsx("span", {
244
+ "data-slot": "encrypted-text-glyph",
245
+ className: "absolute top-0 left-1/2 -translate-x-1/2",
246
+ style: scale < 1 ? { scale: `${scale} 1` } : void 0,
247
+ children: frame.glyphs[index] ?? char
248
+ })] })
249
+ }, index);
250
+ })
251
+ }, word.start))]
113
252
  });
114
253
  }
115
254
  //#endregion
package/dist/field.d.ts CHANGED
@@ -1,40 +1,37 @@
1
- import * as _$react_jsx_runtime0 from "react/jsx-runtime";
2
1
  import * as React from "react";
2
+ import { VariantProps } from "class-variance-authority";
3
3
  import { Field as FieldPrimitive } from "@base-ui/react/field";
4
- import * as _$_base_ui_react0 from "@base-ui/react";
5
-
6
4
  //#region src/field.d.ts
7
- type FieldProps = React.ComponentProps<typeof FieldPrimitive.Root>;
5
+ declare const fieldVariants: (props?: ({
6
+ orientation?: "horizontal" | "vertical" | null | undefined;
7
+ } & import("class-variance-authority/types").ClassProp) | undefined) => string;
8
+ type FieldProps = React.ComponentProps<typeof FieldPrimitive.Root> & VariantProps<typeof fieldVariants>;
8
9
  type FieldLabelProps = React.ComponentProps<typeof FieldPrimitive.Label>;
10
+ type FieldContentProps = React.ComponentProps<"div">;
9
11
  type FieldDescriptionProps = React.ComponentProps<typeof FieldPrimitive.Description>;
10
12
  type FieldItemProps = React.ComponentProps<typeof FieldPrimitive.Item>;
11
13
  type FieldErrorProps = React.ComponentProps<typeof FieldPrimitive.Error>;
12
14
  type FieldValidityProps = React.ComponentProps<typeof FieldPrimitive.Validity>;
13
15
  type FieldControlProps = React.ComponentProps<typeof FieldPrimitive.Control>;
14
- declare function Field({
15
- className,
16
- ...props
17
- }: FieldProps): _$react_jsx_runtime0.JSX.Element;
18
- declare function FieldLabel({
19
- className,
20
- ...props
21
- }: FieldLabelProps): _$react_jsx_runtime0.JSX.Element;
22
- declare function FieldControl({
23
- className,
24
- ...props
25
- }: FieldControlProps): _$react_jsx_runtime0.JSX.Element;
26
- declare function FieldDescription({
27
- className,
28
- ...props
29
- }: FieldDescriptionProps): _$react_jsx_runtime0.JSX.Element;
30
- declare function FieldItem({
31
- className,
32
- ...props
33
- }: FieldItemProps): _$react_jsx_runtime0.JSX.Element;
34
- declare function FieldError({
35
- className,
36
- ...props
37
- }: FieldErrorProps): _$react_jsx_runtime0.JSX.Element;
38
- declare const FieldValidity: React.FC<_$_base_ui_react0.FieldValidityProps>;
16
+ /**
17
+ * A labelled control with its messages. Below the control, `FieldError`
18
+ * comes first and `FieldDescription` under it, so the hint stays visible
19
+ * while the error is showing. An invalid field doesn't tint its text: the
20
+ * control's danger ring and the error carry the state, and the label
21
+ * stays neutral.
22
+ */
23
+ declare function Field({ className, orientation, ...props }: FieldProps): React.JSX.Element;
24
+ declare function FieldLabel({ className, ...props }: FieldLabelProps): React.JSX.Element;
25
+ declare function FieldControl({ className, ...props }: FieldControlProps): React.JSX.Element;
26
+ /**
27
+ * A label and its description stacked together, beside a control. The label
28
+ * row is as tall as the default checkbox, radio and switch (24px), so its
29
+ * text centers on the control.
30
+ */
31
+ declare function FieldContent({ className, ...props }: FieldContentProps): React.JSX.Element;
32
+ declare function FieldDescription({ className, ...props }: FieldDescriptionProps): React.JSX.Element;
33
+ declare function FieldItem({ className, ...props }: FieldItemProps): React.JSX.Element;
34
+ declare function FieldError({ className, ...props }: FieldErrorProps): React.JSX.Element;
35
+ declare const FieldValidity: React.FC<import("@base-ui/react").FieldValidityProps>;
39
36
  //#endregion
40
- export { Field, FieldControl, type FieldControlProps, FieldDescription, type FieldDescriptionProps, FieldError, type FieldErrorProps, FieldItem, type FieldItemProps, FieldLabel, type FieldLabelProps, FieldPrimitive, type FieldProps, FieldValidity, type FieldValidityProps };
37
+ export { Field, FieldContent, type FieldContentProps, FieldControl, type FieldControlProps, FieldDescription, type FieldDescriptionProps, FieldError, type FieldErrorProps, FieldItem, type FieldItemProps, FieldLabel, type FieldLabelProps, FieldPrimitive, type FieldProps, FieldValidity, type FieldValidityProps, fieldVariants };
package/dist/field.js CHANGED
@@ -1,50 +1,78 @@
1
1
  "use client";
2
2
  import { cn } from "./lib/utils.js";
3
3
  import { jsx } from "react/jsx-runtime";
4
+ import { cva } from "class-variance-authority";
4
5
  import { Field as FieldPrimitive } from "@base-ui/react/field";
5
6
  //#region src/field.tsx
6
- function Field({ className, ...props }) {
7
+ const fieldVariants = cva("flex w-full gap-3", {
8
+ variants: { orientation: {
9
+ vertical: "flex-col *:w-full [&>.sr-only]:w-auto",
10
+ horizontal: "flex flex-row items-center *:data-[slot=field-label]:flex-auto has-[>[data-slot=field-content]]:items-start has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px"
11
+ } },
12
+ defaultVariants: { orientation: "vertical" }
13
+ });
14
+ /**
15
+ * A labelled control with its messages. Below the control, `FieldError`
16
+ * comes first and `FieldDescription` under it, so the hint stays visible
17
+ * while the error is showing. An invalid field doesn't tint its text: the
18
+ * control's danger ring and the error carry the state, and the label
19
+ * stays neutral.
20
+ */
21
+ function Field({ className, orientation, ...props }) {
7
22
  return /* @__PURE__ */ jsx(FieldPrimitive.Root, {
8
23
  "data-slot": "field",
9
- className: cn("grid w-full gap-2 data-[invalid]:text-destructive", className),
24
+ "data-orientation": orientation ?? "vertical",
25
+ className: cn(fieldVariants({ orientation }), className),
10
26
  ...props
11
27
  });
12
28
  }
13
29
  function FieldLabel({ className, ...props }) {
14
30
  return /* @__PURE__ */ jsx(FieldPrimitive.Label, {
15
31
  "data-slot": "field-label",
16
- className: cn("text-sm font-medium leading-none data-[disabled]:cursor-not-allowed data-[disabled]:opacity-70", className),
32
+ className: cn("flex w-fit items-center gap-2 text-sm leading-snug font-medium select-none [&[for]]:cursor-clickable data-disabled:pointer-events-none data-disabled:opacity-30", className),
17
33
  ...props
18
34
  });
19
35
  }
20
36
  function FieldControl({ className, ...props }) {
21
37
  return /* @__PURE__ */ jsx(FieldPrimitive.Control, {
22
38
  "data-slot": "field-control",
23
- className: cn("data-[disabled]:cursor-not-allowed data-[disabled]:opacity-50", className),
39
+ className,
40
+ ...props
41
+ });
42
+ }
43
+ /**
44
+ * A label and its description stacked together, beside a control. The label
45
+ * row is as tall as the default checkbox, radio and switch (24px), so its
46
+ * text centers on the control.
47
+ */
48
+ function FieldContent({ className, ...props }) {
49
+ return /* @__PURE__ */ jsx("div", {
50
+ "data-slot": "field-content",
51
+ className: cn("flex flex-1 flex-col gap-2 leading-snug [&>[data-slot=field-label]]:min-h-6", className),
24
52
  ...props
25
53
  });
26
54
  }
27
55
  function FieldDescription({ className, ...props }) {
28
56
  return /* @__PURE__ */ jsx(FieldPrimitive.Description, {
29
57
  "data-slot": "field-description",
30
- className: cn("text-muted text-left text-sm leading-normal font-normal", "[&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4", className),
58
+ className: cn("text-muted text-start text-sm leading-normal font-normal", "[&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4", "last:mt-0 nth-last-2:-mt-1 group-has-data-horizontal/field:text-balance", className),
31
59
  ...props
32
60
  });
33
61
  }
34
62
  function FieldItem({ className, ...props }) {
35
63
  return /* @__PURE__ */ jsx(FieldPrimitive.Item, {
36
64
  "data-slot": "field-item",
37
- className: cn("flex items-center gap-2", className),
65
+ className: cn("group/field flex items-center gap-2", className),
38
66
  ...props
39
67
  });
40
68
  }
41
69
  function FieldError({ className, ...props }) {
42
70
  return /* @__PURE__ */ jsx(FieldPrimitive.Error, {
43
71
  "data-slot": "field-error",
44
- className: cn("text-destructive text-sm font-normal", className),
72
+ className: cn("text-danger text-sm font-normal -mb-3", className),
45
73
  ...props
46
74
  });
47
75
  }
48
76
  const FieldValidity = FieldPrimitive.Validity;
49
77
  //#endregion
50
- export { Field, FieldControl, FieldDescription, FieldError, FieldItem, FieldLabel, FieldPrimitive, FieldValidity };
78
+ export { Field, FieldContent, FieldControl, FieldDescription, FieldError, FieldItem, FieldLabel, FieldPrimitive, FieldValidity, fieldVariants };
@@ -0,0 +1,14 @@
1
+ import * as React from "react";
2
+ import { Fieldset as Fieldset$1 } from "@base-ui/react/fieldset";
3
+ //#region src/fieldset.d.ts
4
+ type FieldsetProps = React.ComponentProps<typeof Fieldset$1.Root>;
5
+ type FieldsetLegendProps = React.ComponentProps<typeof Fieldset$1.Legend>;
6
+ /**
7
+ * Groups related fields under one legend (an address, a set of choices).
8
+ * `disabled` disables every `Field` inside it.
9
+ */
10
+ declare function Fieldset({ className, ...props }: FieldsetProps): React.JSX.Element;
11
+ /** The fieldset's title, announced as its accessible name. */
12
+ declare function FieldsetLegend({ className, ...props }: FieldsetLegendProps): React.JSX.Element;
13
+ //#endregion
14
+ export { Fieldset, FieldsetLegend, type FieldsetLegendProps, type FieldsetProps };
@@ -0,0 +1,26 @@
1
+ "use client";
2
+ import { cn } from "./lib/utils.js";
3
+ import { jsx } from "react/jsx-runtime";
4
+ import { Fieldset as Fieldset$1 } from "@base-ui/react/fieldset";
5
+ //#region src/fieldset.tsx
6
+ /**
7
+ * Groups related fields under one legend (an address, a set of choices).
8
+ * `disabled` disables every `Field` inside it.
9
+ */
10
+ function Fieldset({ className, ...props }) {
11
+ return /* @__PURE__ */ jsx(Fieldset$1.Root, {
12
+ "data-slot": "fieldset",
13
+ className: cn("flex w-full min-w-0 flex-col gap-7 has-[>[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3 *:data-[slot=field-group]:gap-4", className),
14
+ ...props
15
+ });
16
+ }
17
+ /** The fieldset's title, announced as its accessible name. */
18
+ function FieldsetLegend({ className, ...props }) {
19
+ return /* @__PURE__ */ jsx(Fieldset$1.Legend, {
20
+ "data-slot": "fieldset-legend",
21
+ className: cn("mb-3 text-base font-medium data-disabled:opacity-30", className),
22
+ ...props
23
+ });
24
+ }
25
+ //#endregion
26
+ export { Fieldset, FieldsetLegend };
@@ -1,5 +1,4 @@
1
1
  //#region src/film-grain-shader.d.ts
2
- declare const vertexShader = "\nattribute vec2 position;\nvarying vec2 vUv;\n\nvoid main() {\n vUv = position * 0.5 + 0.5;\n gl_Position = vec4(position, 0.0, 1.0);\n}\n";
3
- declare const fragmentShader = "\nprecision highp float;\n\nuniform float uTime;\nuniform vec2 uResolution;\nuniform float uDensity;\nuniform float uOpacity;\nuniform float uFps;\nuniform vec3 uColor;\n\nvarying vec2 vUv;\n\n// Stable hash\nfloat hash(vec2 p, float seed) {\n return fract(sin(dot(p + seed, vec2(127.1, 311.7))) * 43758.5453123);\n}\n\n// Film response curve (soft toe + shoulder rolloff)\nfloat filmCurve(float x) {\n return smoothstep(0.0, 0.2, x) * (1.0 - smoothstep(0.8, 1.0, x));\n}\n\nvoid main() {\n vec2 uv = gl_FragCoord.xy;\n vec2 p = floor(uv);\n\n // Temporal coherence: blend between current and next frame seed\n float frame = floor(uTime * uFps);\n float t = fract(uTime * uFps);\n float seed = frame * 0.1731;\n\n // Multi-scale grain with inter-frame blending on fine layer\n float fineA = hash(p, seed);\n float fineB = hash(p, seed + 0.1731);\n float fine = mix(fineA, fineB, t);\n\n float medium = hash(floor(uv * 0.5), seed + 3.1);\n float coarse = hash(floor(uv * 0.25), seed + 7.93);\n\n float grain = fine * 0.65 + medium * 0.25 + coarse * 0.10;\n\n // Emulsion clumping (organic structure)\n float cluster = hash(floor(uv * 0.18), seed + 9.2);\n grain *= mix(0.75, 1.35, cluster);\n\n // Density threshold\n grain = smoothstep(1.0 - uDensity, 1.0, grain);\n\n // Film response curve (replaces raw pow gamma)\n grain = filmCurve(grain);\n\n // Micro-blur: sample neighbors for optical diffusion\n float n1 = hash(p + vec2(1.0, 0.0), seed);\n float n2 = hash(p - vec2(1.0, 0.0), seed);\n float n3 = hash(p + vec2(0.0, 1.0), seed);\n float n4 = hash(p - vec2(0.0, 1.0), seed);\n float neighbors = (n1 + n2 + n3 + n4) * 0.25;\n grain = mix(grain, neighbors, 0.15);\n\n // Chromatic aberration (reuse neighbor hashes)\n float rShift = n1 * 0.015;\n float bShift = n2 * 0.015;\n\n vec3 color = uColor * vec3(\n grain + rShift,\n grain,\n grain + bShift\n );\n\n color = clamp(color, 0.0, 1.0);\n\n gl_FragColor = vec4(color, grain * uOpacity);\n}\n";
4
- //#endregion
5
- export { fragmentShader, vertexShader };
2
+ export declare const vertexShader = "\nattribute vec2 position;\nvarying vec2 vUv;\n\nvoid main() {\n vUv = position * 0.5 + 0.5;\n gl_Position = vec4(position, 0.0, 1.0);\n}\n";
3
+ export declare const fragmentShader = "\nprecision highp float;\n\nuniform float uTime;\nuniform vec2 uResolution;\nuniform float uDensity;\nuniform float uOpacity;\nuniform float uFps;\nuniform vec3 uColor;\n\nvarying vec2 vUv;\n\n// Stable hash\nfloat hash(vec2 p, float seed) {\n return fract(sin(dot(p + seed, vec2(127.1, 311.7))) * 43758.5453123);\n}\n\n// Film response curve (soft toe + shoulder rolloff)\nfloat filmCurve(float x) {\n return smoothstep(0.0, 0.2, x) * (1.0 - smoothstep(0.8, 1.0, x));\n}\n\nvoid main() {\n vec2 uv = gl_FragCoord.xy;\n vec2 p = floor(uv);\n\n // Temporal coherence: blend between current and next frame seed\n float frame = floor(uTime * uFps);\n float t = fract(uTime * uFps);\n float seed = frame * 0.1731;\n\n // Multi-scale grain with inter-frame blending on fine layer\n float fineA = hash(p, seed);\n float fineB = hash(p, seed + 0.1731);\n float fine = mix(fineA, fineB, t);\n\n float medium = hash(floor(uv * 0.5), seed + 3.1);\n float coarse = hash(floor(uv * 0.25), seed + 7.93);\n\n float grain = fine * 0.65 + medium * 0.25 + coarse * 0.10;\n\n // Emulsion clumping (organic structure)\n float cluster = hash(floor(uv * 0.18), seed + 9.2);\n grain *= mix(0.75, 1.35, cluster);\n\n // Density threshold\n grain = smoothstep(1.0 - uDensity, 1.0, grain);\n\n // Film response curve (replaces raw pow gamma)\n grain = filmCurve(grain);\n\n // Micro-blur: sample neighbors for optical diffusion\n float n1 = hash(p + vec2(1.0, 0.0), seed);\n float n2 = hash(p - vec2(1.0, 0.0), seed);\n float n3 = hash(p + vec2(0.0, 1.0), seed);\n float n4 = hash(p - vec2(0.0, 1.0), seed);\n float neighbors = (n1 + n2 + n3 + n4) * 0.25;\n grain = mix(grain, neighbors, 0.15);\n\n // Chromatic aberration (reuse neighbor hashes)\n float rShift = n1 * 0.015;\n float bShift = n2 * 0.015;\n\n vec3 color = uColor * vec3(\n grain + rShift,\n grain,\n grain + bShift\n );\n\n color = clamp(color, 0.0, 1.0);\n\n gl_FragColor = vec4(color, grain * uOpacity);\n}\n";
4
+ //#endregion
@@ -1,5 +1,3 @@
1
- import * as _$react from "react";
2
-
3
1
  //#region src/film-grain-webgl.d.ts
4
2
  interface UseFilmGrainOptions {
5
3
  density: number;
@@ -8,10 +6,5 @@ interface UseFilmGrainOptions {
8
6
  /** Hex color for canvas fallback grain. Default '#ffffff' */
9
7
  color?: string;
10
8
  }
11
- declare function useFilmGrain({
12
- density,
13
- fps,
14
- color
15
- }: UseFilmGrainOptions): _$react.RefObject<HTMLCanvasElement | null>;
16
- //#endregion
17
- export { useFilmGrain };
9
+ export declare function useFilmGrain({ density, fps, color }: UseFilmGrainOptions): import("react").RefObject<HTMLCanvasElement | null>;
10
+ //#endregion
@@ -92,7 +92,10 @@ function generateGrainTexture(width, height, density, color) {
92
92
  const [r, g, b, a] = parseHex(color);
93
93
  const imageData = ctx.createImageData(width, height);
94
94
  const pixels = new Uint32Array(imageData.data.buffer);
95
- for (let i = 0; i < pixels.length; i++) if (Math.random() < density) pixels[i] = (Math.round(a * (.4 + Math.random() * .6)) << 24 | b << 16 | g << 8 | r) >>> 0;
95
+ for (let i = 0; i < pixels.length; i++) if (Math.random() < density) {
96
+ const variation = Math.round(a * (.4 + Math.random() * .6));
97
+ pixels[i] = (variation << 24 | b << 16 | g << 8 | r) >>> 0;
98
+ }
96
99
  ctx.putImageData(imageData, 0, 0);
97
100
  return offscreen;
98
101
  }
@@ -1,6 +1,4 @@
1
- import * as _$react_jsx_runtime0 from "react/jsx-runtime";
2
1
  import * as React from "react";
3
-
4
2
  //#region src/film-grain.d.ts
5
3
  interface FilmGrainProps {
6
4
  density?: number;
@@ -11,14 +9,6 @@ interface FilmGrainProps {
11
9
  className?: string;
12
10
  style?: React.CSSProperties;
13
11
  }
14
- declare function FilmGrain({
15
- density,
16
- opacity,
17
- blendMode,
18
- fps,
19
- color,
20
- className,
21
- style
22
- }: FilmGrainProps): _$react_jsx_runtime0.JSX.Element;
12
+ declare function FilmGrain({ density, opacity, blendMode, fps, color, className, style }: FilmGrainProps): React.JSX.Element;
23
13
  //#endregion
24
14
  export { FilmGrain, type FilmGrainProps };