@seekalfred/primitives 0.5.0 → 0.6.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 (239) hide show
  1. package/README.md +664 -9
  2. package/dist/accordion/accordion.d.ts +5 -5
  3. package/dist/accordion/accordion.js +22 -11
  4. package/dist/accordion/accordion.js.map +1 -1
  5. package/dist/action-button/action-button.d.ts +50 -0
  6. package/dist/action-button/action-button.js +159 -0
  7. package/dist/action-button/action-button.js.map +1 -0
  8. package/dist/action-button/index.d.ts +2 -0
  9. package/dist/action-button/index.js +2 -0
  10. package/dist/alert/alert.d.ts +16 -2
  11. package/dist/alert/alert.js +118 -20
  12. package/dist/alert/alert.js.map +1 -1
  13. package/dist/alert-dialog/alert-dialog.d.ts +1 -1
  14. package/dist/alert-dialog/alert-dialog.js +4 -2
  15. package/dist/alert-dialog/alert-dialog.js.map +1 -1
  16. package/dist/avatar/avatar.d.ts +7 -1
  17. package/dist/avatar/avatar.js +51 -18
  18. package/dist/avatar/avatar.js.map +1 -1
  19. package/dist/badge/badge.d.ts +10 -3
  20. package/dist/badge/badge.js +37 -5
  21. package/dist/badge/badge.js.map +1 -1
  22. package/dist/breadcrumb/breadcrumb.d.ts +3 -30
  23. package/dist/breadcrumb/breadcrumb.js +25 -8
  24. package/dist/breadcrumb/breadcrumb.js.map +1 -1
  25. package/dist/button/button.d.ts +36 -23
  26. package/dist/button/button.js +178 -33
  27. package/dist/button/button.js.map +1 -1
  28. package/dist/button-group/button-group.d.ts +76 -0
  29. package/dist/button-group/button-group.js +403 -0
  30. package/dist/button-group/button-group.js.map +1 -0
  31. package/dist/button-group/index.d.ts +2 -0
  32. package/dist/button-group/index.js +2 -0
  33. package/dist/calendar/calendar.d.ts +3 -3
  34. package/dist/calendar/calendar.js +263 -34
  35. package/dist/calendar/calendar.js.map +1 -1
  36. package/dist/card/card.d.ts +14 -7
  37. package/dist/card/card.js +32 -14
  38. package/dist/card/card.js.map +1 -1
  39. package/dist/checkbox/checkbox.d.ts +2 -9
  40. package/dist/checkbox/checkbox.js +28 -29
  41. package/dist/checkbox/checkbox.js.map +1 -1
  42. package/dist/confirm-morph/confirm-morph.d.ts +85 -0
  43. package/dist/confirm-morph/confirm-morph.js +652 -0
  44. package/dist/confirm-morph/confirm-morph.js.map +1 -0
  45. package/dist/confirm-morph/index.d.ts +2 -0
  46. package/dist/confirm-morph/index.js +2 -0
  47. package/dist/copy-button/copy-button.d.ts +32 -0
  48. package/dist/copy-button/copy-button.js +134 -0
  49. package/dist/copy-button/copy-button.js.map +1 -0
  50. package/dist/copy-button/index.d.ts +2 -0
  51. package/dist/copy-button/index.js +2 -0
  52. package/dist/date-picker/date-picker.d.ts +51 -0
  53. package/dist/date-picker/date-picker.js +241 -0
  54. package/dist/date-picker/date-picker.js.map +1 -0
  55. package/dist/date-picker/date-range-picker.d.ts +78 -0
  56. package/dist/date-picker/date-range-picker.js +1015 -0
  57. package/dist/date-picker/date-range-picker.js.map +1 -0
  58. package/dist/date-picker/index.d.ts +4 -0
  59. package/dist/date-picker/index.js +4 -0
  60. package/dist/date-picker/picker-parts.d.ts +46 -0
  61. package/dist/date-picker/picker-parts.js +135 -0
  62. package/dist/date-picker/picker-parts.js.map +1 -0
  63. package/dist/dialog/dialog.d.ts +12 -16
  64. package/dist/dialog/dialog.js +45 -14
  65. package/dist/dialog/dialog.js.map +1 -1
  66. package/dist/empty-state/empty-state.d.ts +44 -0
  67. package/dist/empty-state/empty-state.js +83 -0
  68. package/dist/empty-state/empty-state.js.map +1 -0
  69. package/dist/empty-state/index.d.ts +2 -0
  70. package/dist/empty-state/index.js +2 -0
  71. package/dist/expanding-button-group/expanding-button-group.d.ts +54 -0
  72. package/dist/expanding-button-group/expanding-button-group.js +350 -0
  73. package/dist/expanding-button-group/expanding-button-group.js.map +1 -0
  74. package/dist/expanding-button-group/index.d.ts +2 -0
  75. package/dist/expanding-button-group/index.js +2 -0
  76. package/dist/file-drop-zone/file-drop-zone.d.ts +71 -0
  77. package/dist/file-drop-zone/file-drop-zone.js +116 -0
  78. package/dist/file-drop-zone/file-drop-zone.js.map +1 -0
  79. package/dist/file-drop-zone/index.d.ts +2 -0
  80. package/dist/file-drop-zone/index.js +2 -0
  81. package/dist/filter-bar/filter-bar.d.ts +121 -0
  82. package/dist/filter-bar/filter-bar.js +300 -0
  83. package/dist/filter-bar/filter-bar.js.map +1 -0
  84. package/dist/filter-bar/index.d.ts +2 -0
  85. package/dist/filter-bar/index.js +2 -0
  86. package/dist/floating-button-group/floating-button-group.d.ts +61 -0
  87. package/dist/floating-button-group/floating-button-group.js +340 -0
  88. package/dist/floating-button-group/floating-button-group.js.map +1 -0
  89. package/dist/floating-button-group/index.d.ts +2 -0
  90. package/dist/floating-button-group/index.js +2 -0
  91. package/dist/icon-morph/icon-morph.d.ts +55 -0
  92. package/dist/icon-morph/icon-morph.js +331 -0
  93. package/dist/icon-morph/icon-morph.js.map +1 -0
  94. package/dist/icon-morph/index.d.ts +2 -0
  95. package/dist/icon-morph/index.js +2 -0
  96. package/dist/index.d.ts +24 -2
  97. package/dist/index.js +29 -7
  98. package/dist/input/input.d.ts +23 -1
  99. package/dist/input/input.js +37 -13
  100. package/dist/input/input.js.map +1 -1
  101. package/dist/label/label.d.ts +1 -1
  102. package/dist/label/label.js +4 -2
  103. package/dist/label/label.js.map +1 -1
  104. package/dist/lib/count-to.js +113 -0
  105. package/dist/lib/count-to.js.map +1 -0
  106. package/dist/lib/eye-morph.js +62 -0
  107. package/dist/lib/eye-morph.js.map +1 -0
  108. package/dist/lib/field-message.js +336 -0
  109. package/dist/lib/field-message.js.map +1 -0
  110. package/dist/lib/field-shell.js +81 -0
  111. package/dist/lib/field-shell.js.map +1 -0
  112. package/dist/lib/field-surface.js +4 -2
  113. package/dist/lib/field-surface.js.map +1 -1
  114. package/dist/lib/flip.js +80 -0
  115. package/dist/lib/flip.js.map +1 -0
  116. package/dist/lib/height-follow.js +59 -0
  117. package/dist/lib/height-follow.js.map +1 -0
  118. package/dist/lib/icon-swap.js +47 -0
  119. package/dist/lib/icon-swap.js.map +1 -0
  120. package/dist/lib/leaving-list.js +57 -0
  121. package/dist/lib/leaving-list.js.map +1 -0
  122. package/dist/lib/merge-refs.js +22 -0
  123. package/dist/lib/merge-refs.js.map +1 -0
  124. package/dist/lib/morph-text.js +128 -0
  125. package/dist/lib/morph-text.js.map +1 -0
  126. package/dist/lib/motion.js +162 -0
  127. package/dist/lib/motion.js.map +1 -0
  128. package/dist/lib/pausable-timeout.js +67 -0
  129. package/dist/lib/pausable-timeout.js.map +1 -0
  130. package/dist/lib/rolling-value.js +31 -0
  131. package/dist/lib/rolling-value.js.map +1 -0
  132. package/dist/lib/spring-progress.js +114 -0
  133. package/dist/lib/spring-progress.js.map +1 -0
  134. package/dist/lib/svg-id.js +10 -0
  135. package/dist/lib/svg-id.js.map +1 -0
  136. package/dist/lib/swap-text.js +77 -0
  137. package/dist/lib/swap-text.js.map +1 -0
  138. package/dist/lib/use-copy-feedback.js +36 -0
  139. package/dist/lib/use-copy-feedback.js.map +1 -0
  140. package/dist/menu/menu.d.ts +5 -5
  141. package/dist/menu/menu.js +10 -6
  142. package/dist/menu/menu.js.map +1 -1
  143. package/dist/page-header/index.d.ts +2 -0
  144. package/dist/page-header/index.js +2 -0
  145. package/dist/page-header/page-header.d.ts +75 -0
  146. package/dist/page-header/page-header.js +83 -0
  147. package/dist/page-header/page-header.js.map +1 -0
  148. package/dist/password-field/index.d.ts +2 -0
  149. package/dist/password-field/index.js +2 -0
  150. package/dist/password-field/password-field.d.ts +37 -0
  151. package/dist/password-field/password-field.js +101 -0
  152. package/dist/password-field/password-field.js.map +1 -0
  153. package/dist/password-strength/index.d.ts +2 -0
  154. package/dist/password-strength/index.js +2 -0
  155. package/dist/password-strength/password-strength.d.ts +127 -0
  156. package/dist/password-strength/password-strength.js +455 -0
  157. package/dist/password-strength/password-strength.js.map +1 -0
  158. package/dist/popover/popover.d.ts +10 -2
  159. package/dist/popover/popover.js +6 -4
  160. package/dist/popover/popover.js.map +1 -1
  161. package/dist/progress/progress.d.ts +2 -2
  162. package/dist/progress/progress.js +50 -7
  163. package/dist/progress/progress.js.map +1 -1
  164. package/dist/radio-group/radio-group.d.ts +3 -3
  165. package/dist/radio-group/radio-group.js +14 -10
  166. package/dist/radio-group/radio-group.js.map +1 -1
  167. package/dist/scroll-area/scroll-area.d.ts +17 -6
  168. package/dist/scroll-area/scroll-area.js +30 -6
  169. package/dist/scroll-area/scroll-area.js.map +1 -1
  170. package/dist/search-field/index.d.ts +2 -0
  171. package/dist/search-field/index.js +2 -0
  172. package/dist/search-field/search-field.d.ts +26 -0
  173. package/dist/search-field/search-field.js +155 -0
  174. package/dist/search-field/search-field.js.map +1 -0
  175. package/dist/select/select.d.ts +3 -3
  176. package/dist/select/select.js +85 -24
  177. package/dist/select/select.js.map +1 -1
  178. package/dist/separator/separator.d.ts +1 -1
  179. package/dist/separator/separator.js +4 -2
  180. package/dist/separator/separator.js.map +1 -1
  181. package/dist/sheet/sheet.d.ts +1 -1
  182. package/dist/sheet/sheet.js +6 -3
  183. package/dist/sheet/sheet.js.map +1 -1
  184. package/dist/side-nav/index.d.ts +2 -0
  185. package/dist/side-nav/index.js +2 -0
  186. package/dist/side-nav/side-nav.d.ts +72 -0
  187. package/dist/side-nav/side-nav.js +142 -0
  188. package/dist/side-nav/side-nav.js.map +1 -0
  189. package/dist/skeleton/skeleton.d.ts +8 -2
  190. package/dist/skeleton/skeleton.js +9 -3
  191. package/dist/skeleton/skeleton.js.map +1 -1
  192. package/dist/slider/slider.d.ts +2 -2
  193. package/dist/slider/slider.js +64 -8
  194. package/dist/slider/slider.js.map +1 -1
  195. package/dist/spinner/spinner.d.ts +1 -1
  196. package/dist/spinner/spinner.js +4 -2
  197. package/dist/spinner/spinner.js.map +1 -1
  198. package/dist/split-button/index.d.ts +2 -0
  199. package/dist/split-button/index.js +2 -0
  200. package/dist/split-button/split-button.d.ts +46 -0
  201. package/dist/split-button/split-button.js +122 -0
  202. package/dist/split-button/split-button.js.map +1 -0
  203. package/dist/stat-card/index.d.ts +2 -0
  204. package/dist/stat-card/index.js +2 -0
  205. package/dist/stat-card/stat-card.d.ts +81 -0
  206. package/dist/stat-card/stat-card.js +152 -0
  207. package/dist/stat-card/stat-card.js.map +1 -0
  208. package/dist/styles.css +1 -1
  209. package/dist/switch/switch.d.ts +2 -2
  210. package/dist/switch/switch.js +11 -8
  211. package/dist/switch/switch.js.map +1 -1
  212. package/dist/table/index.d.ts +2 -2
  213. package/dist/table/index.js +2 -2
  214. package/dist/table/table.d.ts +38 -7
  215. package/dist/table/table.js +114 -12
  216. package/dist/table/table.js.map +1 -1
  217. package/dist/tabs/tabs.d.ts +4 -4
  218. package/dist/tabs/tabs.js +153 -12
  219. package/dist/tabs/tabs.js.map +1 -1
  220. package/dist/textarea/textarea.d.ts +24 -1
  221. package/dist/textarea/textarea.js +27 -3
  222. package/dist/textarea/textarea.js.map +1 -1
  223. package/dist/theme-switch/index.d.ts +2 -0
  224. package/dist/theme-switch/index.js +2 -0
  225. package/dist/theme-switch/theme-switch.d.ts +37 -0
  226. package/dist/theme-switch/theme-switch.js +123 -0
  227. package/dist/theme-switch/theme-switch.js.map +1 -0
  228. package/dist/time-picker/index.d.ts +2 -0
  229. package/dist/time-picker/index.js +2 -0
  230. package/dist/time-picker/time-picker.d.ts +54 -0
  231. package/dist/time-picker/time-picker.js +201 -0
  232. package/dist/time-picker/time-picker.js.map +1 -0
  233. package/dist/toast/toast.d.ts +2 -2
  234. package/dist/toast/toast.js +61 -39
  235. package/dist/toast/toast.js.map +1 -1
  236. package/dist/tooltip/tooltip.d.ts +3 -3
  237. package/dist/tooltip/tooltip.js +66 -15
  238. package/dist/tooltip/tooltip.js.map +1 -1
  239. package/package.json +83 -3
@@ -0,0 +1,336 @@
1
+ import { cn } from "./cn.js";
2
+ import { prefersReducedMotion, useExitBackstop, useIsomorphicLayoutEffect, useLeavingItems } from "./motion.js";
3
+ import { SwapText } from "./swap-text.js";
4
+ import { useCallback, useEffect, useRef, useState } from "react";
5
+ import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
6
+ //#region src/lib/field-message.tsx
7
+ const isNumber = (word) => /^\d[\d.,/:]*%?$/.test(word);
8
+ const WORD_OUT = {
9
+ settle: "absolute inline-block whitespace-pre motion-safe:animate-field-word-out",
10
+ quick: "absolute inline-block whitespace-pre motion-safe:animate-field-word-out-quick",
11
+ sharp: "absolute inline-block whitespace-pre motion-safe:animate-field-word-out-sharp"
12
+ };
13
+ /** The longest a spring exit takes (736ms) plus a margin, for the backstop that removes a node whose animation never ends. */
14
+ const SPRING_BACKSTOP = 900;
15
+ const rectOf = (node) => ({
16
+ left: node.offsetLeft,
17
+ top: node.offsetTop,
18
+ width: node.offsetWidth,
19
+ height: node.offsetHeight
20
+ });
21
+ function LeavingWordNode({ item, onDone, exit }) {
22
+ const { id } = item;
23
+ const remove = useCallback(() => onDone(id), [onDone, id]);
24
+ useExitBackstop(true, remove);
25
+ return /* @__PURE__ */ jsx("span", {
26
+ "aria-hidden": "true",
27
+ onAnimationEnd: (event) => {
28
+ if (event.target === event.currentTarget) remove();
29
+ },
30
+ style: item.rect,
31
+ className: WORD_OUT[exit],
32
+ children: item.word
33
+ });
34
+ }
35
+ function LeavingDigit({ id, rect, onDone, children }) {
36
+ const remove = useCallback(() => onDone(id), [onDone, id]);
37
+ useExitBackstop(true, remove, SPRING_BACKSTOP);
38
+ useIsomorphicLayoutEffect(() => {
39
+ if (prefersReducedMotion()) remove();
40
+ }, [remove]);
41
+ return /* @__PURE__ */ jsx("span", {
42
+ "aria-hidden": "true",
43
+ onAnimationEnd: (event) => {
44
+ if (event.target === event.currentTarget && event.animationName === "field-digit-move-out") remove();
45
+ },
46
+ style: rect,
47
+ className: "absolute motion-safe:animate-field-digit-out",
48
+ children
49
+ });
50
+ }
51
+ function DigitColumn({ column, direction, leaving, onDone }) {
52
+ const digitRef = useRef(null);
53
+ const digits = useLeavingItems(column.char, column.char, () => digitRef.current);
54
+ const done = useCallback(() => onDone(column.key), [onDone, column.key]);
55
+ useExitBackstop(leaving, done, SPRING_BACKSTOP);
56
+ useIsomorphicLayoutEffect(() => {
57
+ if (leaving && prefersReducedMotion()) done();
58
+ }, [leaving, done]);
59
+ return /* @__PURE__ */ jsxs("span", {
60
+ style: { "--roll": direction },
61
+ onAnimationEnd: (event) => {
62
+ if (leaving && event.target === event.currentTarget && event.animationName === "field-column-shrink") done();
63
+ },
64
+ className: cn("field-number-column", leaving ? "motion-safe:animate-field-column-out" : column.seq > 0 && "motion-safe:animate-field-column-in"),
65
+ children: [digits.leaving.map((item) => /* @__PURE__ */ jsx(LeavingDigit, {
66
+ id: item.id,
67
+ rect: item.rect,
68
+ onDone: digits.remove,
69
+ children: item.value
70
+ }, item.id)), /* @__PURE__ */ jsx("span", {
71
+ ref: digitRef,
72
+ className: digits.changed ? "motion-safe:animate-field-digit-in" : void 0,
73
+ children: column.char
74
+ }, column.char)]
75
+ });
76
+ }
77
+ /**
78
+ * A count in the copy, such as "12 characters": only the digits that changed
79
+ * roll, and a gained or lost digit opens or closes its width. Digits arrive
80
+ * from below when the number grew and from above when it shrank.
81
+ */
82
+ function RollingNumber({ word }) {
83
+ const [state, setState] = useState(() => ({
84
+ word,
85
+ direction: 1,
86
+ seq: 0,
87
+ columns: toColumns(word, 0, /* @__PURE__ */ new Map()),
88
+ gone: []
89
+ }));
90
+ let { columns, gone, direction } = state;
91
+ if (state.word !== word) {
92
+ const seq = state.seq + 1;
93
+ direction = parseFloat(word.replace(/,/g, "")) < parseFloat(state.word.replace(/,/g, "")) ? -1 : 1;
94
+ columns = toColumns(word, seq, new Map(state.columns.map((column) => [column.key, column])));
95
+ const kept = new Set(columns.map((column) => column.key));
96
+ gone = [...state.gone.filter((column) => !kept.has(column.key)), ...state.columns.filter((column) => !kept.has(column.key))];
97
+ setState({
98
+ word,
99
+ direction,
100
+ seq,
101
+ columns,
102
+ gone
103
+ });
104
+ }
105
+ const finish = useCallback((key) => setState((current) => ({
106
+ ...current,
107
+ gone: current.gone.filter((column) => column.key !== key)
108
+ })), []);
109
+ const all = [...columns.map((column) => ({
110
+ column,
111
+ leaving: false
112
+ })), ...gone.map((column) => ({
113
+ column,
114
+ leaving: true
115
+ }))].sort((a, b) => b.column.key - a.column.key);
116
+ return /* @__PURE__ */ jsx(Fragment$1, { children: all.map(({ column, leaving }) => /* @__PURE__ */ jsx(DigitColumn, {
117
+ column,
118
+ direction,
119
+ leaving,
120
+ onDone: finish
121
+ }, column.key)) });
122
+ }
123
+ function toColumns(word, seq, previous) {
124
+ const chars = word.split("");
125
+ return chars.map((char, index) => {
126
+ const key = chars.length - index;
127
+ const old = previous.get(key);
128
+ return {
129
+ key,
130
+ char,
131
+ seq: old ? old.seq : seq
132
+ };
133
+ });
134
+ }
135
+ /**
136
+ * Copy that changes word by word. Words are keyed by position (numbers keep
137
+ * their identity, so only their digits move), so inserting a word near the
138
+ * front replaces every word after it. A changed word rises in from a soft blur
139
+ * and the old one lifts away from where it stood. Assistive technology reads
140
+ * the plain copy; the animated copy is hidden from it.
141
+ */
142
+ function MotionText({ text, exit, numbers }) {
143
+ const [state, setState] = useState(() => ({
144
+ text,
145
+ seq: 0,
146
+ words: toWords(text, 0, /* @__PURE__ */ new Map(), numbers)
147
+ }));
148
+ let { words } = state;
149
+ if (state.text !== text) {
150
+ const seq = state.seq + 1;
151
+ words = toWords(text, seq, new Map(state.words.map((word) => [word.key, word])), numbers);
152
+ setState({
153
+ text,
154
+ seq,
155
+ words
156
+ });
157
+ }
158
+ const rowRef = useRef(null);
159
+ const before = useRef(/* @__PURE__ */ new Map());
160
+ const nextId = useRef(0);
161
+ const [leaving, setLeaving] = useState([]);
162
+ useIsomorphicLayoutEffect(() => {
163
+ const row = rowRef.current;
164
+ if (!row) return;
165
+ const now = /* @__PURE__ */ new Map();
166
+ for (const node of row.querySelectorAll("[data-word]")) now.set(node.dataset.word, {
167
+ rect: rectOf(node),
168
+ word: node.textContent ?? ""
169
+ });
170
+ const gone = [];
171
+ if (!prefersReducedMotion()) {
172
+ for (const [key, was] of before.current) if (!now.has(key) && was.rect.width && was.rect.height) gone.push({
173
+ id: nextId.current++,
174
+ word: was.word,
175
+ rect: was.rect
176
+ });
177
+ }
178
+ before.current = now;
179
+ if (gone.length) setLeaving((list) => [...list, ...gone]);
180
+ });
181
+ const finish = useCallback((id) => setLeaving((list) => list.filter((item) => item.id !== id)), []);
182
+ return /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsx("span", {
183
+ className: "sr-only",
184
+ children: text
185
+ }), /* @__PURE__ */ jsxs("span", {
186
+ ref: rowRef,
187
+ "aria-hidden": "true",
188
+ className: "relative block",
189
+ children: [leaving.map((item) => /* @__PURE__ */ jsx(LeavingWordNode, {
190
+ item,
191
+ onDone: finish,
192
+ exit
193
+ }, item.id)), words.map((entry, index) => /* @__PURE__ */ jsxs("span", {
194
+ "data-word": entry.key,
195
+ className: cn("inline-block whitespace-pre", entry.seq > 0 && "motion-safe:animate-field-word-in motion-reduce:animate-fade-in"),
196
+ children: [numbers && isNumber(entry.word) ? /* @__PURE__ */ jsx(RollingNumber, { word: entry.word }) : entry.word, index < words.length - 1 ? " " : null]
197
+ }, entry.key))]
198
+ })] });
199
+ }
200
+ function toWords(text, seq, previous, numbers) {
201
+ return text.split(" ").map((word, index) => {
202
+ const key = `${index}:${numbers && isNumber(word) ? "#" : word}`;
203
+ const old = previous.get(key);
204
+ return {
205
+ key,
206
+ word,
207
+ seq: old ? old.seq : seq
208
+ };
209
+ });
210
+ }
211
+ /** Writes the copy's height where the slot's height transition reads it. */
212
+ function syncHeight(slot, copy) {
213
+ const height = copy.getBoundingClientRect().height;
214
+ if (height) slot.style.setProperty("--field-message-h", height + "px");
215
+ }
216
+ /**
217
+ * A field's helper or error row. `text` empty means no row. A row that appears
218
+ * after mount opens its height on a spring and its copy settles in; one that
219
+ * goes away closes the same way, still showing its last text while it does, and
220
+ * text that comes back mid-close reopens the same row. A row present at first
221
+ * render never animates. Error copy is an alert.
222
+ *
223
+ * The slot's height is written from script (a transition cannot start from or
224
+ * end at auto): measured in a layout effect before the row opens, and again
225
+ * whenever the copy resizes, so a message that wraps opens its next line.
226
+ */
227
+ function FieldMessage({ id, text, tone, wordExit = "settle", numbers = true, copy = "words" }) {
228
+ const [row, setRow] = useState(() => text ? {
229
+ text,
230
+ phase: "open",
231
+ animate: false
232
+ } : null);
233
+ if (text && (!row || row.text !== text || row.phase === "closing")) setRow(row ? {
234
+ text,
235
+ phase: row.phase === "closing" ? "open" : row.phase,
236
+ animate: row.animate
237
+ } : {
238
+ text,
239
+ phase: "entering",
240
+ animate: true
241
+ });
242
+ else if (!text && row && row.phase !== "closing") setRow({
243
+ ...row,
244
+ phase: "closing"
245
+ });
246
+ const slotRef = useRef(null);
247
+ const copyRef = useRef(null);
248
+ const phase = row?.phase;
249
+ useIsomorphicLayoutEffect(() => {
250
+ const slot = slotRef.current;
251
+ const copy = copyRef.current;
252
+ if (!slot || !copy || phase === "closing") return;
253
+ syncHeight(slot, copy);
254
+ if (phase === "entering") {
255
+ slot.offsetHeight;
256
+ setRow((current) => current?.phase === "entering" ? {
257
+ ...current,
258
+ phase: "open"
259
+ } : current);
260
+ }
261
+ });
262
+ useIsomorphicLayoutEffect(() => {
263
+ if (phase !== "closing") return;
264
+ const slot = slotRef.current;
265
+ const gone = () => setRow((current) => current?.phase === "closing" ? null : current);
266
+ if (!slot || prefersReducedMotion()) {
267
+ gone();
268
+ return;
269
+ }
270
+ const style = getComputedStyle(slot);
271
+ if (parseFloat(style.height) === 0 || style.opacity === "0") {
272
+ gone();
273
+ return;
274
+ }
275
+ const onEnd = (event) => {
276
+ if (event.target === slot && event.propertyName === "height") gone();
277
+ };
278
+ slot.addEventListener("transitionend", onEnd);
279
+ const backstop = setTimeout(gone, SPRING_BACKSTOP);
280
+ return () => {
281
+ slot.removeEventListener("transitionend", onEnd);
282
+ clearTimeout(backstop);
283
+ };
284
+ }, [phase]);
285
+ useEffect(() => {
286
+ const slot = slotRef.current;
287
+ const copy = copyRef.current;
288
+ if (!slot || !copy || typeof ResizeObserver === "undefined") return;
289
+ let frame = 0;
290
+ const observer = new ResizeObserver(([entry]) => {
291
+ const height = entry?.borderBoxSize[0]?.blockSize ?? copy.offsetHeight;
292
+ if (!height) return;
293
+ cancelAnimationFrame(frame);
294
+ frame = requestAnimationFrame(() => slot.style.setProperty("--field-message-h", height + "px"));
295
+ });
296
+ observer.observe(copy);
297
+ return () => {
298
+ cancelAnimationFrame(frame);
299
+ observer.disconnect();
300
+ };
301
+ }, [row !== null]);
302
+ if (!row) return null;
303
+ const closing = row.phase === "closing";
304
+ return /* @__PURE__ */ jsx("span", {
305
+ ref: slotRef,
306
+ "data-slot": "field-message-slot",
307
+ "data-state": closing || row.phase === "entering" ? "closed" : "open",
308
+ className: "field-message-slot",
309
+ children: /* @__PURE__ */ jsx("span", {
310
+ ref: copyRef,
311
+ id: closing ? void 0 : id,
312
+ role: tone === "error" && !closing ? "alert" : void 0,
313
+ className: cn("block pt-2 text-sm leading-(--leading-normal) " + (tone === "error" ? "text-destructive-strong" : "text-muted-foreground") + (row.animate ? " motion-safe:animate-field-copy-in" : "")),
314
+ children: copy === "swap" ? /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsx("span", {
315
+ className: "sr-only",
316
+ children: row.text
317
+ }), /* @__PURE__ */ jsx("span", {
318
+ "aria-hidden": "true",
319
+ className: "block",
320
+ children: /* @__PURE__ */ jsx(SwapText, {
321
+ swapKey: row.text,
322
+ className: "block",
323
+ children: row.text
324
+ })
325
+ })] }) : /* @__PURE__ */ jsx(MotionText, {
326
+ text: row.text,
327
+ exit: wordExit,
328
+ numbers
329
+ })
330
+ })
331
+ });
332
+ }
333
+ //#endregion
334
+ export { FieldMessage };
335
+
336
+ //# sourceMappingURL=field-message.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"field-message.js","names":[],"sources":["../../src/lib/field-message.tsx"],"sourcesContent":["import { useCallback, useEffect, useRef, useState, type CSSProperties } from \"react\";\n\nimport { cn } from \"./cn.js\";\nimport {\n prefersReducedMotion,\n useExitBackstop,\n useIsomorphicLayoutEffect,\n useLeavingItems,\n type Rect,\n} from \"./motion.js\";\nimport { SwapText } from \"./swap-text.js\";\n\n/*\n * The helper and error copy under a text field. Three layers of presence, each\n * a small piece of React because CSS cannot keep a node that React has already\n * unmounted: the row (opens and closes its height), the words (a changed word\n * rises in while the old one lifts away) and the numbers (one column per digit,\n * a gained or lost digit opens or closes its width, a changed digit rolls).\n *\n * Nothing animates on first mount at any layer: a row present at first render,\n * words in a row that has just opened, and digits in a column that has just\n * opened all appear as they are.\n */\n\nconst isNumber = (word: string) => /^\\d[\\d.,/:]*%?$/.test(word);\n\n/** How a replaced word leaves: Input's 180ms settle, Textarea's 160ms, or the sharper ease-exit curve Password field uses. */\nexport type WordExit = \"settle\" | \"quick\" | \"sharp\";\n\nconst WORD_OUT: Record<WordExit, string> = {\n settle: \"absolute inline-block whitespace-pre motion-safe:animate-field-word-out\",\n quick: \"absolute inline-block whitespace-pre motion-safe:animate-field-word-out-quick\",\n sharp: \"absolute inline-block whitespace-pre motion-safe:animate-field-word-out-sharp\",\n};\n\n/** The longest a spring exit takes (736ms) plus a margin, for the backstop that removes a node whose animation never ends. */\nconst SPRING_BACKSTOP = 900;\n\nconst rectOf = (node: HTMLElement): Rect => ({\n left: node.offsetLeft,\n top: node.offsetTop,\n width: node.offsetWidth,\n height: node.offsetHeight,\n});\n\ninterface Word {\n key: string;\n word: string;\n /** 0 for the words the text mounted with, which do not animate in. */\n seq: number;\n}\n\ninterface LeavingWord {\n id: number;\n word: string;\n rect: Rect;\n}\n\nfunction LeavingWordNode({\n item,\n onDone,\n exit,\n}: {\n item: LeavingWord;\n onDone: (id: number) => void;\n exit: WordExit;\n}) {\n const { id } = item;\n const remove = useCallback(() => onDone(id), [onDone, id]);\n useExitBackstop(true, remove);\n return (\n <span\n aria-hidden=\"true\"\n onAnimationEnd={(event) => {\n if (event.target === event.currentTarget) remove();\n }}\n style={item.rect}\n className={WORD_OUT[exit]}\n >\n {item.word}\n </span>\n );\n}\n\ninterface Column {\n /** Distance from the end of the number, so a new leading digit adds a column on the left. */\n key: number;\n char: string;\n seq: number;\n}\n\nfunction LeavingDigit({\n id,\n rect,\n onDone,\n children,\n}: {\n id: number;\n rect: Rect;\n onDone: (id: number) => void;\n children: string;\n}) {\n const remove = useCallback(() => onDone(id), [onDone, id]);\n useExitBackstop(true, remove, SPRING_BACKSTOP);\n // Reduced motion swaps instantly, and an animation that is switched off never ends.\n useIsomorphicLayoutEffect(() => {\n if (prefersReducedMotion()) remove();\n }, [remove]);\n return (\n <span\n aria-hidden=\"true\"\n onAnimationEnd={(event) => {\n if (event.target === event.currentTarget && event.animationName === \"field-digit-move-out\")\n remove();\n }}\n style={rect}\n className=\"absolute motion-safe:animate-field-digit-out\"\n >\n {children}\n </span>\n );\n}\n\nfunction DigitColumn({\n column,\n direction,\n leaving,\n onDone,\n}: {\n column: Column;\n direction: number;\n leaving: boolean;\n onDone: (key: number) => void;\n}) {\n const digitRef = useRef<HTMLSpanElement>(null);\n const digits = useLeavingItems(column.char, column.char, () => digitRef.current);\n const done = useCallback(() => onDone(column.key), [onDone, column.key]);\n useExitBackstop(leaving, done, SPRING_BACKSTOP);\n useIsomorphicLayoutEffect(() => {\n if (leaving && prefersReducedMotion()) done();\n }, [leaving, done]);\n return (\n <span\n style={{ \"--roll\": direction } as CSSProperties}\n onAnimationEnd={(event) => {\n if (\n leaving &&\n event.target === event.currentTarget &&\n event.animationName === \"field-column-shrink\"\n )\n done();\n }}\n className={cn(\n \"field-number-column\",\n leaving\n ? \"motion-safe:animate-field-column-out\"\n : column.seq > 0 && \"motion-safe:animate-field-column-in\",\n )}\n >\n {digits.leaving.map((item) => (\n <LeavingDigit key={item.id} id={item.id} rect={item.rect} onDone={digits.remove}>\n {item.value}\n </LeavingDigit>\n ))}\n <span\n key={column.char}\n ref={digitRef}\n className={digits.changed ? \"motion-safe:animate-field-digit-in\" : undefined}\n >\n {column.char}\n </span>\n </span>\n );\n}\n\n/**\n * A count in the copy, such as \"12 characters\": only the digits that changed\n * roll, and a gained or lost digit opens or closes its width. Digits arrive\n * from below when the number grew and from above when it shrank.\n */\nfunction RollingNumber({ word }: { word: string }) {\n const [state, setState] = useState(() => ({\n word,\n direction: 1,\n seq: 0,\n columns: toColumns(word, 0, new Map<number, Column>()),\n gone: [] as Column[],\n }));\n let { columns, gone, direction } = state;\n if (state.word !== word) {\n const seq = state.seq + 1;\n direction =\n parseFloat(word.replace(/,/g, \"\")) < parseFloat(state.word.replace(/,/g, \"\")) ? -1 : 1;\n columns = toColumns(word, seq, new Map(state.columns.map((column) => [column.key, column])));\n const kept = new Set(columns.map((column) => column.key));\n // A column that comes back while it is closing is shown again, not kept twice.\n gone = [\n ...state.gone.filter((column) => !kept.has(column.key)),\n ...state.columns.filter((column) => !kept.has(column.key)),\n ];\n setState({ word, direction, seq, columns, gone });\n }\n const finish = useCallback(\n (key: number) =>\n setState((current) => ({\n ...current,\n gone: current.gone.filter((column) => column.key !== key),\n })),\n [],\n );\n // Left to right is key descending, so a closing column keeps its place while it shrinks.\n const all = [\n ...columns.map((column) => ({ column, leaving: false })),\n ...gone.map((column) => ({ column, leaving: true })),\n ].sort((a, b) => b.column.key - a.column.key);\n return (\n <>\n {all.map(({ column, leaving }) => (\n <DigitColumn\n key={column.key}\n column={column}\n direction={direction}\n leaving={leaving}\n onDone={finish}\n />\n ))}\n </>\n );\n}\n\nfunction toColumns(word: string, seq: number, previous: Map<number, Column>): Column[] {\n const chars = word.split(\"\");\n return chars.map((char, index) => {\n const key = chars.length - index;\n const old = previous.get(key);\n return { key, char, seq: old ? old.seq : seq };\n });\n}\n\n/**\n * Copy that changes word by word. Words are keyed by position (numbers keep\n * their identity, so only their digits move), so inserting a word near the\n * front replaces every word after it. A changed word rises in from a soft blur\n * and the old one lifts away from where it stood. Assistive technology reads\n * the plain copy; the animated copy is hidden from it.\n */\nfunction MotionText({ text, exit, numbers }: { text: string; exit: WordExit; numbers: boolean }) {\n const [state, setState] = useState(() => ({\n text,\n seq: 0,\n words: toWords(text, 0, new Map<string, Word>(), numbers),\n }));\n let { words } = state;\n if (state.text !== text) {\n const seq = state.seq + 1;\n words = toWords(text, seq, new Map(state.words.map((word) => [word.key, word])), numbers);\n setState({ text, seq, words });\n }\n\n const rowRef = useRef<HTMLSpanElement>(null);\n const before = useRef(new Map<string, { rect: Rect; word: string }>());\n const nextId = useRef(0);\n const [leaving, setLeaving] = useState<LeavingWord[]>([]);\n\n // After every commit: remember where each word stands, and keep the ones that are gone, where they stood.\n useIsomorphicLayoutEffect(() => {\n const row = rowRef.current;\n if (!row) return;\n const now = new Map<string, { rect: Rect; word: string }>();\n for (const node of row.querySelectorAll<HTMLElement>(\"[data-word]\")) {\n now.set(node.dataset.word!, { rect: rectOf(node), word: node.textContent ?? \"\" });\n }\n const gone: LeavingWord[] = [];\n // Reduced motion removes a replaced word at once, and a box with no size has nothing to lift away.\n if (!prefersReducedMotion()) {\n for (const [key, was] of before.current) {\n if (!now.has(key) && was.rect.width && was.rect.height)\n gone.push({ id: nextId.current++, word: was.word, rect: was.rect });\n }\n }\n before.current = now;\n if (gone.length) setLeaving((list) => [...list, ...gone]);\n });\n\n const finish = useCallback(\n (id: number) => setLeaving((list) => list.filter((item) => item.id !== id)),\n [],\n );\n\n return (\n <>\n <span className=\"sr-only\">{text}</span>\n <span ref={rowRef} aria-hidden=\"true\" className=\"relative block\">\n {leaving.map((item) => (\n <LeavingWordNode key={item.id} item={item} onDone={finish} exit={exit} />\n ))}\n {words.map((entry, index) => (\n <span\n key={entry.key}\n data-word={entry.key}\n className={cn(\n \"inline-block whitespace-pre\",\n entry.seq > 0 && \"motion-safe:animate-field-word-in motion-reduce:animate-fade-in\",\n )}\n >\n {numbers && isNumber(entry.word) ? <RollingNumber word={entry.word} /> : entry.word}\n {index < words.length - 1 ? \" \" : null}\n </span>\n ))}\n </span>\n </>\n );\n}\n\nfunction toWords(text: string, seq: number, previous: Map<string, Word>, numbers: boolean): Word[] {\n return text.split(\" \").map((word, index) => {\n const key = `${index}:${numbers && isNumber(word) ? \"#\" : word}`;\n const old = previous.get(key);\n return { key, word, seq: old ? old.seq : seq };\n });\n}\n\ninterface Row {\n text: string;\n /** entering: mounted closed, about to open. closing: kept on screen while it shuts. */\n phase: \"entering\" | \"open\" | \"closing\";\n /** False for a row the field mounted with, whose copy does not settle in. */\n animate: boolean;\n}\n\n/** Writes the copy's height where the slot's height transition reads it. */\nfunction syncHeight(slot: HTMLElement, copy: HTMLElement) {\n const height = copy.getBoundingClientRect().height;\n if (height) slot.style.setProperty(\"--field-message-h\", height + \"px\");\n}\n\n/**\n * A field's helper or error row. `text` empty means no row. A row that appears\n * after mount opens its height on a spring and its copy settles in; one that\n * goes away closes the same way, still showing its last text while it does, and\n * text that comes back mid-close reopens the same row. A row present at first\n * render never animates. Error copy is an alert.\n *\n * The slot's height is written from script (a transition cannot start from or\n * end at auto): measured in a layout effect before the row opens, and again\n * whenever the copy resizes, so a message that wraps opens its next line.\n */\nexport function FieldMessage({\n id,\n text,\n tone,\n wordExit = \"settle\",\n numbers = true,\n copy = \"words\",\n}: {\n id?: string | undefined;\n text?: string | undefined;\n tone: \"description\" | \"error\";\n /** How a replaced word leaves (Input settles in 180ms, Textarea in 160ms, Password field uses the sharper exit curve). */\n wordExit?: WordExit;\n /** Roll digits in counts such as \"12 characters\". Off for copy that is words only. */\n numbers?: boolean;\n /** `\"words\"` animates a changed message word by word; `\"swap\"` replaces it as a whole, rising in as the old one lifts away. */\n copy?: \"words\" | \"swap\";\n}) {\n // Decided during render, so the id exists in the same commit as the aria-describedby that names it.\n const [row, setRow] = useState<Row | null>(() =>\n text ? { text, phase: \"open\", animate: false } : null,\n );\n if (text && (!row || row.text !== text || row.phase === \"closing\")) {\n setRow(\n row\n ? { text, phase: row.phase === \"closing\" ? \"open\" : row.phase, animate: row.animate }\n : { text, phase: \"entering\", animate: true },\n );\n } else if (!text && row && row.phase !== \"closing\") {\n setRow({ ...row, phase: \"closing\" });\n }\n\n const slotRef = useRef<HTMLSpanElement>(null);\n const copyRef = useRef<HTMLSpanElement>(null);\n const phase = row?.phase;\n\n // Measure before the row opens, and force the closed style to be computed first so there is something to transition from.\n useIsomorphicLayoutEffect(() => {\n const slot = slotRef.current;\n const copy = copyRef.current;\n if (!slot || !copy || phase === \"closing\") return;\n syncHeight(slot, copy);\n if (phase === \"entering\") {\n void slot.offsetHeight;\n setRow((current) =>\n current?.phase === \"entering\" ? { ...current, phase: \"open\" } : current,\n );\n }\n });\n\n // Remove the row once its height has closed. A row that never opened, or reduced motion, has nothing to wait for.\n useIsomorphicLayoutEffect(() => {\n if (phase !== \"closing\") return;\n const slot = slotRef.current;\n const gone = () => setRow((current) => (current?.phase === \"closing\" ? null : current));\n if (!slot || prefersReducedMotion()) {\n gone();\n return;\n }\n const style = getComputedStyle(slot);\n if (parseFloat(style.height) === 0 || style.opacity === \"0\") {\n gone();\n return;\n }\n const onEnd = (event: TransitionEvent) => {\n if (event.target === slot && event.propertyName === \"height\") gone();\n };\n slot.addEventListener(\"transitionend\", onEnd);\n const backstop = setTimeout(gone, SPRING_BACKSTOP);\n return () => {\n slot.removeEventListener(\"transitionend\", onEnd);\n clearTimeout(backstop);\n };\n }, [phase]);\n\n // Later size changes only (the first measurement is the layout effect above). A frame later, so the write cannot report a ResizeObserver loop.\n const mounted = row !== null;\n useEffect(() => {\n const slot = slotRef.current;\n const copy = copyRef.current;\n if (!slot || !copy || typeof ResizeObserver === \"undefined\") return;\n let frame = 0;\n const observer = new ResizeObserver(([entry]) => {\n const height = entry?.borderBoxSize[0]?.blockSize ?? copy.offsetHeight;\n if (!height) return;\n cancelAnimationFrame(frame);\n frame = requestAnimationFrame(() =>\n slot.style.setProperty(\"--field-message-h\", height + \"px\"),\n );\n });\n observer.observe(copy);\n return () => {\n cancelAnimationFrame(frame);\n observer.disconnect();\n };\n }, [mounted]);\n\n if (!row) return null;\n const closing = row.phase === \"closing\";\n return (\n <span\n ref={slotRef}\n data-slot=\"field-message-slot\"\n data-state={closing || row.phase === \"entering\" ? \"closed\" : \"open\"}\n className=\"field-message-slot\"\n >\n <span\n ref={copyRef}\n // A closing row no longer describes the field: it gives up its id and its alert role.\n id={closing ? undefined : id}\n role={tone === \"error\" && !closing ? \"alert\" : undefined}\n className={cn(\n \"block pt-2 text-sm leading-(--leading-normal) \" +\n (tone === \"error\" ? \"text-destructive-strong\" : \"text-muted-foreground\") +\n (row.animate ? \" motion-safe:animate-field-copy-in\" : \"\"),\n )}\n >\n {copy === \"swap\" ? (\n <>\n <span className=\"sr-only\">{row.text}</span>\n <span aria-hidden=\"true\" className=\"block\">\n <SwapText swapKey={row.text} className=\"block\">\n {row.text}\n </SwapText>\n </span>\n </>\n ) : (\n <MotionText text={row.text} exit={wordExit} numbers={numbers} />\n )}\n </span>\n </span>\n );\n}\n"],"mappings":"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"}
@@ -0,0 +1,81 @@
1
+ import { Label as Label$1 } from "../label/label.js";
2
+ import { FieldMessage } from "./field-message.js";
3
+ import { useId } from "react";
4
+ import { InputContext, LabelContext, useSlottedContext } from "react-aria-components";
5
+ import { jsx, jsxs } from "react/jsx-runtime";
6
+ //#region src/lib/field-shell.tsx
7
+ /**
8
+ * True when any field prop was passed at all, even as `undefined`. Deciding by
9
+ * presence, not value, is what keeps `error={maybeMessage}` from moving the
10
+ * control between a wrapper and no wrapper when the message appears: that would
11
+ * remount the control and drop focus and an uncontrolled value.
12
+ */
13
+ const hasFieldProps = (props) => "label" in props || "description" in props || "error" in props;
14
+ /** A control inside a React Aria `TextField` already has its label and messages supplied by the field. */
15
+ function useInsideReactAriaField() {
16
+ const label = useSlottedContext(LabelContext);
17
+ const input = useSlottedContext(InputContext);
18
+ return Object.keys(label ?? {}).length > 0 || Object.keys(input ?? {}).length > 0;
19
+ }
20
+ /**
21
+ * The ids and aria wiring for a control with field props. `active` is false
22
+ * when no field prop was passed, or inside a React Aria field, and then
23
+ * `controlProps` is empty: the control's own props are untouched, and a bare
24
+ * control has no generated `id`.
25
+ */
26
+ function useFieldWiring({ enabled, id, describedBy, invalid, description, error }) {
27
+ const generated = useId();
28
+ const inside = useInsideReactAriaField();
29
+ const active = enabled && !inside;
30
+ const controlId = id ?? generated;
31
+ const hintId = active && description ? `${controlId}-description` : void 0;
32
+ const errorId = active && error ? `${controlId}-error` : void 0;
33
+ return {
34
+ active,
35
+ controlId,
36
+ hintId,
37
+ errorId,
38
+ controlProps: active ? {
39
+ id: controlId,
40
+ "aria-invalid": error ? true : invalid,
41
+ "aria-describedby": [
42
+ describedBy,
43
+ hintId,
44
+ errorId
45
+ ].filter(Boolean).join(" ") || void 0
46
+ } : {}
47
+ };
48
+ }
49
+ /** The wrapper: label above the control, then the helper row and the error row. Each row owns its top space, so a closed row costs nothing. */
50
+ function FieldShell({ label, controlId, description, hintId, error, errorId, wordExit, numbers, children }) {
51
+ return /* @__PURE__ */ jsxs("div", {
52
+ "data-slot": "field",
53
+ className: "grid min-w-0 font-sans",
54
+ children: [
55
+ label == null ? null : /* @__PURE__ */ jsx(Label$1, {
56
+ htmlFor: controlId,
57
+ className: "mb-1.5",
58
+ children: label
59
+ }),
60
+ children,
61
+ /* @__PURE__ */ jsx(FieldMessage, {
62
+ id: hintId,
63
+ text: description,
64
+ tone: "description",
65
+ wordExit,
66
+ numbers
67
+ }),
68
+ /* @__PURE__ */ jsx(FieldMessage, {
69
+ id: errorId,
70
+ text: error,
71
+ tone: "error",
72
+ wordExit,
73
+ numbers
74
+ })
75
+ ]
76
+ });
77
+ }
78
+ //#endregion
79
+ export { FieldShell, hasFieldProps, useFieldWiring };
80
+
81
+ //# sourceMappingURL=field-shell.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"field-shell.js","names":["Label"],"sources":["../../src/lib/field-shell.tsx"],"sourcesContent":["import { useId, type ReactNode } from \"react\";\nimport { InputContext, LabelContext, useSlottedContext } from \"react-aria-components\";\n\nimport { Label } from \"../label/label.js\";\nimport { FieldMessage, type WordExit } from \"./field-message.js\";\n\n/*\n * The label, helper text and error text around a text control, for Input and\n * Textarea. The first lib file that imports a sibling component (Label): the\n * layer rule allows primitives to use primitives, and a field's label is the\n * same Label everywhere else.\n */\n\n/**\n * True when any field prop was passed at all, even as `undefined`. Deciding by\n * presence, not value, is what keeps `error={maybeMessage}` from moving the\n * control between a wrapper and no wrapper when the message appears: that would\n * remount the control and drop focus and an uncontrolled value.\n */\nexport const hasFieldProps = (props: object) =>\n \"label\" in props || \"description\" in props || \"error\" in props;\n\n/** A control inside a React Aria `TextField` already has its label and messages supplied by the field. */\nfunction useInsideReactAriaField() {\n const label = useSlottedContext(LabelContext);\n const input = useSlottedContext(InputContext);\n return Object.keys(label ?? {}).length > 0 || Object.keys(input ?? {}).length > 0;\n}\n\n/**\n * The ids and aria wiring for a control with field props. `active` is false\n * when no field prop was passed, or inside a React Aria field, and then\n * `controlProps` is empty: the control's own props are untouched, and a bare\n * control has no generated `id`.\n */\nexport function useFieldWiring({\n enabled,\n id,\n describedBy,\n invalid,\n description,\n error,\n}: {\n enabled: boolean;\n id: string | undefined;\n describedBy: string | undefined;\n invalid: boolean | \"true\" | \"false\" | \"grammar\" | \"spelling\" | undefined;\n description: string | undefined;\n error: string | undefined;\n}) {\n const generated = useId();\n const inside = useInsideReactAriaField();\n const active = enabled && !inside;\n const controlId = id ?? generated;\n const hintId = active && description ? `${controlId}-description` : undefined;\n const errorId = active && error ? `${controlId}-error` : undefined;\n return {\n active,\n controlId,\n hintId,\n errorId,\n controlProps: active\n ? {\n id: controlId,\n \"aria-invalid\": error ? true : invalid,\n \"aria-describedby\": [describedBy, hintId, errorId].filter(Boolean).join(\" \") || undefined,\n }\n : {},\n };\n}\n\n/** The wrapper: label above the control, then the helper row and the error row. Each row owns its top space, so a closed row costs nothing. */\nexport function FieldShell({\n label,\n controlId,\n description,\n hintId,\n error,\n errorId,\n wordExit,\n numbers,\n children,\n}: {\n label: ReactNode;\n controlId: string;\n description: string | undefined;\n hintId: string | undefined;\n error: string | undefined;\n errorId: string | undefined;\n wordExit?: WordExit;\n numbers?: boolean;\n children: ReactNode;\n}) {\n return (\n <div data-slot=\"field\" className=\"grid min-w-0 font-sans\">\n {label == null ? null : (\n // 6px, the gap Select and DatePicker keep between their label and control, so a labelled Input sits level with them.\n <Label htmlFor={controlId} className=\"mb-1.5\">\n {label}\n </Label>\n )}\n {children}\n <FieldMessage\n id={hintId}\n text={description}\n tone=\"description\"\n wordExit={wordExit}\n numbers={numbers}\n />\n <FieldMessage id={errorId} text={error} tone=\"error\" wordExit={wordExit} numbers={numbers} />\n </div>\n );\n}\n"],"mappings":";;;;;;;;;;;;AAmBA,MAAa,iBAAiB,UAC5B,WAAW,SAAS,iBAAiB,SAAS,WAAW;;AAG3D,SAAS,0BAA0B;CACjC,MAAM,QAAQ,kBAAkB,YAAY;CAC5C,MAAM,QAAQ,kBAAkB,YAAY;CAC5C,OAAO,OAAO,KAAK,SAAS,CAAC,CAAC,CAAC,CAAC,SAAS,KAAK,OAAO,KAAK,SAAS,CAAC,CAAC,CAAC,CAAC,SAAS;AAClF;;;;;;;AAQA,SAAgB,eAAe,EAC7B,SACA,IACA,aACA,SACA,aACA,SAQC;CACD,MAAM,YAAY,MAAM;CACxB,MAAM,SAAS,wBAAwB;CACvC,MAAM,SAAS,WAAW,CAAC;CAC3B,MAAM,YAAY,MAAM;CACxB,MAAM,SAAS,UAAU,cAAc,GAAG,UAAU,gBAAgB,KAAA;CACpE,MAAM,UAAU,UAAU,QAAQ,GAAG,UAAU,UAAU,KAAA;CACzD,OAAO;EACL;EACA;EACA;EACA;EACA,cAAc,SACV;GACE,IAAI;GACJ,gBAAgB,QAAQ,OAAO;GAC/B,oBAAoB;IAAC;IAAa;IAAQ;GAAO,CAAC,CAAC,OAAO,OAAO,CAAC,CAAC,KAAK,GAAG,KAAK,KAAA;EAClF,IACA,CAAC;CACP;AACF;;AAGA,SAAgB,WAAW,EACzB,OACA,WACA,aACA,QACA,OACA,SACA,UACA,SACA,YAWC;CACD,OACE,qBAAC,OAAD;EAAK,aAAU;EAAQ,WAAU;EAAjC,UAAA;GACG,SAAS,OAAO,OAEf,oBAACA,SAAD;IAAO,SAAS;IAAW,WAAU;IAClC,UAAA;GACI,CAAA;GAER;GACD,oBAAC,cAAD;IACE,IAAI;IACJ,MAAM;IACN,MAAK;IACK;IACD;GACV,CAAA;GACD,oBAAC,cAAD;IAAc,IAAI;IAAS,MAAM;IAAO,MAAK;IAAkB;IAAmB;GAAU,CAAA;EACzF;;AAET"}
@@ -25,14 +25,16 @@ const fieldSurfaceVariants = cva([
25
25
  "shadow-surface",
26
26
  "placeholder:text-muted-foreground",
27
27
  "selection:bg-primary selection:text-primary-foreground",
28
- "motion-safe:transition-colors motion-safe:duration-(--duration-fast) motion-safe:ease-standard",
28
+ "motion-safe:transition-[border-color,background-color] motion-safe:duration-(--duration-quick) motion-safe:ease-settle",
29
29
  "outline-none",
30
30
  "data-[focus-visible]:outline-solid",
31
31
  "data-[focus-visible]:outline-(length:--focus-ring-width)",
32
32
  "data-[focus-visible]:outline-offset-(--focus-ring-offset)",
33
33
  "data-[focus-visible]:outline-(--color-ring)",
34
34
  "data-[invalid]:border-destructive-strong",
35
- "data-[disabled]:cursor-not-allowed data-[disabled]:opacity-(--opacity-disabled)"
35
+ "[&:where([data-hovered]:not([data-disabled]))]:border-foreground",
36
+ "[&:where([data-focus-visible])]:border-foreground",
37
+ "data-[disabled]:cursor-not-allowed data-[disabled]:bg-muted data-[disabled]:opacity-(--opacity-disabled)"
36
38
  ]);
37
39
  //#endregion
38
40
  export { fieldSurfaceVariants };
@@ -1 +1 @@
1
- {"version":3,"file":"field-surface.js","names":[],"sources":["../../src/lib/field-surface.ts"],"sourcesContent":["import { cva } from \"class-variance-authority\";\n\n/**\n * The shared surface of a text entry control.\n *\n * Extracted at the Rule of Two, and for a reason the legacy demonstrates:\n * its `input.tsx` and `textarea.tsx` styled the same surface twice and had\n * already drifted. The input is `bg-transparent`, the textarea is\n * `bg-background`. The input focuses with `focus-visible:border-ring` plus a\n * 3px ring; the textarea uses a 2px ring with an offset. The input has\n * `aria-invalid` treatment, the textarea has none. None of those differences\n * is a decision anyone made -- they are two files edited at different times.\n *\n * Everything that must look identical between an Input and a Textarea lives\n * here. Only the box geometry differs, and that is the caller's variant.\n *\n * Every string is a STATIC literal. Tailwind scans source text, so a class\n * assembled at runtime is invisible to it and would never be emitted.\n */\nexport const fieldSurfaceVariants = cva([\n \"w-full min-w-0\",\n\n // The reset in @seekalfred/styles is OPT-IN, so a consumer may load none.\n // A bare <input> keeps the user-agent border, background and Arial face,\n // and content-box would add the padding on top of the width.\n \"box-border font-sans\",\n\n \"rounded-md border-(length:--border-width) border-border\",\n \"bg-background text-foreground\",\n \"shadow-surface\",\n\n // --color-muted-foreground is 5.25:1 on the page, so the placeholder is\n // legible rather than the usual near-invisible grey. It is still text and\n // the contrast contract holds it to the 4.5 floor.\n \"placeholder:text-muted-foreground\",\n \"selection:bg-primary selection:text-primary-foreground\",\n\n \"motion-safe:transition-colors motion-safe:duration-(--duration-fast) motion-safe:ease-standard\",\n\n // outline-solid is load-bearing. `outline-none` sets BOTH\n // outline-style:none and --tw-outline-style:none, and the width utility\n // resolves its style from that same variable -- so a focus rule can apply\n // a width and a colour while faithfully re-applying none, and paint\n // nothing. Button shipped exactly that.\n //\n // An outline, not the legacy `ring-[3px]` box-shadow: box-shadows are\n // discarded under forced-colors, taking the only focus indication with\n // them, and axe has no focus-indicator rule to notice.\n \"outline-none\",\n \"data-[focus-visible]:outline-solid\",\n \"data-[focus-visible]:outline-(length:--focus-ring-width)\",\n \"data-[focus-visible]:outline-offset-(--focus-ring-offset)\",\n \"data-[focus-visible]:outline-(--color-ring)\",\n\n // React Aria sets data-invalid from the native `aria-invalid` attribute,\n // so this costs no API surface and works both bare and inside a\n // TextField. destructive-strong rather than destructive: the fill token\n // is 2.44:1-class and cannot describe a boundary (WCAG 1.4.11).\n //\n // The border CHANGES COLOUR only. Colour alone is not a state cue\n // (WCAG 1.4.1), which is why an invalid field must also carry a message --\n // that is the consumer's job, and the migration table says so.\n \"data-[invalid]:border-destructive-strong\",\n\n \"data-[disabled]:cursor-not-allowed data-[disabled]:opacity-(--opacity-disabled)\",\n]);\n"],"mappings":";;;;;;;;;;;;;;;;;;;AAmBA,MAAa,uBAAuB,IAAI;CACtC;CAKA;CAEA;CACA;CACA;CAKA;CACA;CAEA;CAWA;CACA;CACA;CACA;CACA;CAUA;CAEA;AACF,CAAC"}
1
+ {"version":3,"file":"field-surface.js","names":[],"sources":["../../src/lib/field-surface.ts"],"sourcesContent":["import { cva } from \"class-variance-authority\";\n\n/**\n * The shared surface of a text entry control.\n *\n * Extracted at the Rule of Two, and for a reason the legacy demonstrates:\n * its `input.tsx` and `textarea.tsx` styled the same surface twice and had\n * already drifted. The input is `bg-transparent`, the textarea is\n * `bg-background`. The input focuses with `focus-visible:border-ring` plus a\n * 3px ring; the textarea uses a 2px ring with an offset. The input has\n * `aria-invalid` treatment, the textarea has none. None of those differences\n * is a decision anyone made -- they are two files edited at different times.\n *\n * Everything that must look identical between an Input and a Textarea lives\n * here. Only the box geometry differs, and that is the caller's variant.\n *\n * Every string is a STATIC literal. Tailwind scans source text, so a class\n * assembled at runtime is invisible to it and would never be emitted.\n */\nexport const fieldSurfaceVariants = cva([\n \"w-full min-w-0\",\n\n // The reset in @seekalfred/styles is OPT-IN, so a consumer may load none.\n // A bare <input> keeps the user-agent border, background and Arial face,\n // and content-box would add the padding on top of the width.\n \"box-border font-sans\",\n\n \"rounded-md border-(length:--border-width) border-border\",\n \"bg-background text-foreground\",\n \"shadow-surface\",\n\n // --color-muted-foreground is 5.25:1 on the page, so the placeholder is\n // legible rather than the usual near-invisible grey. It is still text and\n // the contrast contract holds it to the 4.5 floor.\n \"placeholder:text-muted-foreground\",\n \"selection:bg-primary selection:text-primary-foreground\",\n\n // Only the two properties that change, on the settle curve: the border darkens\n // on hover and focus, and the background turns muted when disabled. The focus\n // outline is not animated (it switches between none and solid).\n \"motion-safe:transition-[border-color,background-color] motion-safe:duration-(--duration-quick) motion-safe:ease-settle\",\n\n // outline-solid is load-bearing. `outline-none` sets BOTH\n // outline-style:none and --tw-outline-style:none, and the width utility\n // resolves its style from that same variable -- so a focus rule can apply\n // a width and a colour while faithfully re-applying none, and paint\n // nothing. Button shipped exactly that.\n //\n // An outline, not the legacy `ring-[3px]` box-shadow: box-shadows are\n // discarded under forced-colors, taking the only focus indication with\n // them, and axe has no focus-indicator rule to notice.\n \"outline-none\",\n \"data-[focus-visible]:outline-solid\",\n \"data-[focus-visible]:outline-(length:--focus-ring-width)\",\n \"data-[focus-visible]:outline-offset-(--focus-ring-offset)\",\n \"data-[focus-visible]:outline-(--color-ring)\",\n\n // React Aria sets data-invalid from the native `aria-invalid` attribute,\n // so this costs no API surface and works both bare and inside a\n // TextField. destructive-strong rather than destructive: the fill token\n // is 2.44:1-class and cannot describe a boundary (WCAG 1.4.11).\n //\n // The border CHANGES COLOUR only. Colour alone is not a state cue\n // (WCAG 1.4.1), which is why an invalid field must also carry a message --\n // that is the consumer's job, and the migration table says so.\n \"data-[invalid]:border-destructive-strong\",\n\n // Hover and focus darken the border to the text colour, in addition to the\n // outline ring above (which stays: forced-colours drops a border colour, and\n // WCAG 2.4.7 wants a visible indicator). The conditions sit inside :where(),\n // which adds no specificity, so the invalid border always wins by specificity\n // rather than by the order Tailwind happens to emit utilities in -- on an\n // Input through data-[invalid], on a Select or DatePicker trigger through\n // group-data-[invalid], because their ROOT carries the state. A disabled field\n // does not darken: React Aria's TextArea still reports hover while disabled.\n \"[&:where([data-hovered]:not([data-disabled]))]:border-foreground\",\n \"[&:where([data-focus-visible])]:border-foreground\",\n\n \"data-[disabled]:cursor-not-allowed data-[disabled]:bg-muted data-[disabled]:opacity-(--opacity-disabled)\",\n]);\n"],"mappings":";;;;;;;;;;;;;;;;;;;AAmBA,MAAa,uBAAuB,IAAI;CACtC;CAKA;CAEA;CACA;CACA;CAKA;CACA;CAKA;CAWA;CACA;CACA;CACA;CACA;CAUA;CAUA;CACA;CAEA;AACF,CAAC"}
@@ -0,0 +1,80 @@
1
+ import { prefersReducedMotion, useIsomorphicLayoutEffect } from "./motion.js";
2
+ import { useRef } from "react";
3
+ //#region src/lib/flip.ts
4
+ /**
5
+ * Siblings that shift because something next to them changed size (a wider month
6
+ * name pushes the day and year along) slide to their new place instead of
7
+ * jumping: measure where each `[data-flip]` element was, let the new layout
8
+ * commit, put each one back where it was with a translate, then release it on a
9
+ * transition. CSS cannot do this alone, because the layout move is not a style
10
+ * change.
11
+ *
12
+ * `key` says when to look. Positions are remembered after every look and
13
+ * refreshed when the container resizes, so a move that is not a change of `key`
14
+ * (a window resize) does not become the next flip. Reduced motion does not flip.
15
+ * Elements inside a leaving copy (`[data-leaving]`) are ignored.
16
+ *
17
+ * @param duration CSS time of the slide, e.g. `var(--duration-standard)`.
18
+ * @param easing CSS easing of the slide, e.g. `var(--ease-enter)`.
19
+ */
20
+ function useFlip(container, key, duration = "var(--duration-standard)", easing = "var(--ease-enter)") {
21
+ const remembered = useRef(/* @__PURE__ */ new Map());
22
+ const measure = () => {
23
+ const root = container.current;
24
+ const next = /* @__PURE__ */ new Map();
25
+ if (!root) return next;
26
+ for (const el of root.querySelectorAll("[data-flip]")) {
27
+ if (el.closest("[data-leaving]")) continue;
28
+ const box = el.getBoundingClientRect();
29
+ next.set(el, {
30
+ left: box.left,
31
+ top: box.top
32
+ });
33
+ }
34
+ return next;
35
+ };
36
+ useIsomorphicLayoutEffect(() => {
37
+ const root = container.current;
38
+ if (!root) return;
39
+ const before = remembered.current;
40
+ const after = measure();
41
+ if (before.size > 0 && !prefersReducedMotion()) {
42
+ const moved = [];
43
+ for (const [el, now] of after) {
44
+ const was = before.get(el);
45
+ if (!was) continue;
46
+ const dx = was.left - now.left;
47
+ const dy = was.top - now.top;
48
+ if (Math.abs(dx) < .5 && Math.abs(dy) < .5) continue;
49
+ const node = el;
50
+ node.style.transition = "none";
51
+ node.style.translate = `${dx}px ${dy}px`;
52
+ moved.push(node);
53
+ }
54
+ if (moved.length > 0) {
55
+ root.offsetWidth;
56
+ for (const node of moved) {
57
+ node.style.transition = `translate ${duration} ${easing}`;
58
+ node.style.translate = "";
59
+ node.addEventListener("transitionend", () => {
60
+ node.style.transition = "";
61
+ }, { once: true });
62
+ }
63
+ }
64
+ }
65
+ remembered.current = after;
66
+ }, [key]);
67
+ useIsomorphicLayoutEffect(() => {
68
+ const root = container.current;
69
+ if (!root || typeof ResizeObserver === "undefined") return;
70
+ const observer = new ResizeObserver(() => {
71
+ remembered.current = measure();
72
+ });
73
+ observer.observe(root);
74
+ return () => observer.disconnect();
75
+ }, []);
76
+ }
77
+ //#endregion
78
+ export { useFlip };
79
+
80
+ //# sourceMappingURL=flip.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"flip.js","names":[],"sources":["../../src/lib/flip.ts"],"sourcesContent":["import { useRef, type RefObject } from \"react\";\n\nimport { prefersReducedMotion, useIsomorphicLayoutEffect } from \"./motion.js\";\n\n/**\n * Siblings that shift because something next to them changed size (a wider month\n * name pushes the day and year along) slide to their new place instead of\n * jumping: measure where each `[data-flip]` element was, let the new layout\n * commit, put each one back where it was with a translate, then release it on a\n * transition. CSS cannot do this alone, because the layout move is not a style\n * change.\n *\n * `key` says when to look. Positions are remembered after every look and\n * refreshed when the container resizes, so a move that is not a change of `key`\n * (a window resize) does not become the next flip. Reduced motion does not flip.\n * Elements inside a leaving copy (`[data-leaving]`) are ignored.\n *\n * @param duration CSS time of the slide, e.g. `var(--duration-standard)`.\n * @param easing CSS easing of the slide, e.g. `var(--ease-enter)`.\n */\nexport function useFlip(\n container: RefObject<HTMLElement | null>,\n key: unknown,\n duration = \"var(--duration-standard)\",\n easing = \"var(--ease-enter)\",\n) {\n const remembered = useRef(new Map<Element, { left: number; top: number }>());\n\n const measure = () => {\n const root = container.current;\n const next = new Map<Element, { left: number; top: number }>();\n if (!root) return next;\n for (const el of root.querySelectorAll<HTMLElement>(\"[data-flip]\")) {\n if (el.closest(\"[data-leaving]\")) continue;\n const box = el.getBoundingClientRect();\n next.set(el, { left: box.left, top: box.top });\n }\n return next;\n };\n\n useIsomorphicLayoutEffect(() => {\n const root = container.current;\n if (!root) return;\n const before = remembered.current;\n const after = measure();\n if (before.size > 0 && !prefersReducedMotion()) {\n const moved: HTMLElement[] = [];\n for (const [el, now] of after) {\n const was = before.get(el);\n if (!was) continue;\n const dx = was.left - now.left;\n const dy = was.top - now.top;\n if (Math.abs(dx) < 0.5 && Math.abs(dy) < 0.5) continue;\n const node = el as HTMLElement;\n node.style.transition = \"none\";\n node.style.translate = `${dx}px ${dy}px`;\n moved.push(node);\n }\n if (moved.length > 0) {\n void root.offsetWidth;\n for (const node of moved) {\n node.style.transition = `translate ${duration} ${easing}`;\n node.style.translate = \"\";\n node.addEventListener(\n \"transitionend\",\n () => {\n node.style.transition = \"\";\n },\n { once: true },\n );\n }\n }\n }\n remembered.current = after;\n // Positions are read once per change of `key`; a resize refreshes them below.\n }, [key]);\n\n useIsomorphicLayoutEffect(() => {\n const root = container.current;\n if (!root || typeof ResizeObserver === \"undefined\") return;\n const observer = new ResizeObserver(() => {\n remembered.current = measure();\n });\n observer.observe(root);\n return () => observer.disconnect();\n }, []);\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;AAoBA,SAAgB,QACd,WACA,KACA,WAAW,4BACX,SAAS,qBACT;CACA,MAAM,aAAa,uBAAO,IAAI,IAA4C,CAAC;CAE3E,MAAM,gBAAgB;EACpB,MAAM,OAAO,UAAU;EACvB,MAAM,uBAAO,IAAI,IAA4C;EAC7D,IAAI,CAAC,MAAM,OAAO;EAClB,KAAK,MAAM,MAAM,KAAK,iBAA8B,aAAa,GAAG;GAClE,IAAI,GAAG,QAAQ,gBAAgB,GAAG;GAClC,MAAM,MAAM,GAAG,sBAAsB;GACrC,KAAK,IAAI,IAAI;IAAE,MAAM,IAAI;IAAM,KAAK,IAAI;GAAI,CAAC;EAC/C;EACA,OAAO;CACT;CAEA,gCAAgC;EAC9B,MAAM,OAAO,UAAU;EACvB,IAAI,CAAC,MAAM;EACX,MAAM,SAAS,WAAW;EAC1B,MAAM,QAAQ,QAAQ;EACtB,IAAI,OAAO,OAAO,KAAK,CAAC,qBAAqB,GAAG;GAC9C,MAAM,QAAuB,CAAC;GAC9B,KAAK,MAAM,CAAC,IAAI,QAAQ,OAAO;IAC7B,MAAM,MAAM,OAAO,IAAI,EAAE;IACzB,IAAI,CAAC,KAAK;IACV,MAAM,KAAK,IAAI,OAAO,IAAI;IAC1B,MAAM,KAAK,IAAI,MAAM,IAAI;IACzB,IAAI,KAAK,IAAI,EAAE,IAAI,MAAO,KAAK,IAAI,EAAE,IAAI,IAAK;IAC9C,MAAM,OAAO;IACb,KAAK,MAAM,aAAa;IACxB,KAAK,MAAM,YAAY,GAAG,GAAG,KAAK,GAAG;IACrC,MAAM,KAAK,IAAI;GACjB;GACA,IAAI,MAAM,SAAS,GAAG;IACpB,KAAU;IACV,KAAK,MAAM,QAAQ,OAAO;KACxB,KAAK,MAAM,aAAa,aAAa,SAAS,GAAG;KACjD,KAAK,MAAM,YAAY;KACvB,KAAK,iBACH,uBACM;MACJ,KAAK,MAAM,aAAa;KAC1B,GACA,EAAE,MAAM,KAAK,CACf;IACF;GACF;EACF;EACA,WAAW,UAAU;CAEvB,GAAG,CAAC,GAAG,CAAC;CAER,gCAAgC;EAC9B,MAAM,OAAO,UAAU;EACvB,IAAI,CAAC,QAAQ,OAAO,mBAAmB,aAAa;EACpD,MAAM,WAAW,IAAI,qBAAqB;GACxC,WAAW,UAAU,QAAQ;EAC/B,CAAC;EACD,SAAS,QAAQ,IAAI;EACrB,aAAa,SAAS,WAAW;CACnC,GAAG,CAAC,CAAC;AACP"}