@sableui/react 0.13.1 → 0.14.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 (231) hide show
  1. package/AGENTS.md +2 -2
  2. package/CHANGELOG.md +132 -0
  3. package/CONTRIBUTING.md +7 -5
  4. package/DESIGN.md +8 -3
  5. package/bin/sable.mjs +17 -1
  6. package/dist/blocks/approval-block.js +93 -0
  7. package/dist/blocks/approval-block.meta.js +23 -0
  8. package/dist/blocks/index.d.ts +17 -2
  9. package/dist/blocks/index.js +4 -0
  10. package/dist/components/accordion/accordion.meta.js +10 -3
  11. package/dist/components/area-chart/area-chart.meta.js +10 -3
  12. package/dist/components/attachment/attachment.meta.js +26 -0
  13. package/dist/components/badge/badge.meta.js +2 -1
  14. package/dist/components/banner/banner.meta.js +4 -2
  15. package/dist/components/bar-chart/bar-chart.meta.js +11 -4
  16. package/dist/components/bottom-nav/bottom-nav.js +3 -1
  17. package/dist/components/bottom-nav/bottom-nav.meta.js +32 -0
  18. package/dist/components/breadcrumbs/breadcrumbs.meta.js +26 -0
  19. package/dist/components/button/button.js +21 -4
  20. package/dist/components/button/button.meta.js +7 -7
  21. package/dist/components/button-group/button-group.meta.js +2 -1
  22. package/dist/components/calendar/calendar.js +18 -1
  23. package/dist/components/calendar/calendar.meta.js +38 -0
  24. package/dist/components/carousel/carousel.js +6 -4
  25. package/dist/components/carousel/carousel.meta.js +42 -0
  26. package/dist/components/chat-composer/chat-composer.js +21 -21
  27. package/dist/components/chat-composer/chat-composer.meta.js +38 -0
  28. package/dist/components/chat-composer/use-eased-growth.js +70 -0
  29. package/dist/components/checkbox/checkbox-mark.js +8 -1
  30. package/dist/components/checkbox/checkbox.js +3 -1
  31. package/dist/components/checkbox/checkbox.meta.js +34 -1
  32. package/dist/components/checkbox-card/checkbox-card.js +3 -0
  33. package/dist/components/checkbox-card/checkbox-card.meta.js +32 -0
  34. package/dist/components/code-block/code-block.js +14 -3
  35. package/dist/components/code-block/code-block.meta.js +28 -1
  36. package/dist/components/collapsible/collapsible.meta.js +34 -1
  37. package/dist/components/color-field/color-field.meta.js +26 -0
  38. package/dist/components/combobox/combobox.meta.js +48 -2
  39. package/dist/components/command-menu/command-menu.js +8 -3
  40. package/dist/components/command-menu/command-menu.meta.js +44 -3
  41. package/dist/components/confirm-button/confirm-button.js +179 -0
  42. package/dist/components/confirm-button/confirm-button.meta.js +143 -0
  43. package/dist/components/confirm-button/index.js +7 -0
  44. package/dist/components/context-card/context-card.meta.js +2 -1
  45. package/dist/components/data-table/data-table.js +6 -2
  46. package/dist/components/data-table/data-table.meta.js +32 -0
  47. package/dist/components/dialog/dialog.meta.js +6 -3
  48. package/dist/components/disclosure-picker/disclosure-picker.meta.js +2 -1
  49. package/dist/components/entity/entity.meta.js +26 -0
  50. package/dist/components/feedback/feedback.js +113 -47
  51. package/dist/components/feedback/feedback.meta.js +49 -4
  52. package/dist/components/inline-status/inline-status.meta.js +2 -1
  53. package/dist/components/input-group/input-group.meta.js +27 -1
  54. package/dist/components/line-chart/line-chart.meta.js +10 -3
  55. package/dist/components/menu/menu.meta.js +2 -1
  56. package/dist/components/menubar/menubar.meta.js +2 -1
  57. package/dist/components/meter/meter.meta.js +2 -1
  58. package/dist/components/middle-truncate/middle-truncate.meta.js +1 -1
  59. package/dist/components/note/note.meta.js +34 -1
  60. package/dist/components/notification-badge/notification-badge.js +17 -5
  61. package/dist/components/notification-badge/notification-badge.meta.js +34 -2
  62. package/dist/components/number-ticker/index.js +7 -0
  63. package/dist/components/number-ticker/number-ticker.js +246 -0
  64. package/dist/components/number-ticker/number-ticker.meta.js +159 -0
  65. package/dist/components/otp-field/otp-field.js +23 -3
  66. package/dist/components/otp-field/otp-field.meta.js +23 -2
  67. package/dist/components/page-loader/page-loader.meta.js +2 -1
  68. package/dist/components/panel-group/panel-group.meta.js +26 -0
  69. package/dist/components/panel-section/panel-section.js +37 -10
  70. package/dist/components/panel-section/panel-section.meta.js +32 -0
  71. package/dist/components/pie-chart/pie-chart.js +20 -11
  72. package/dist/components/pie-chart/pie-chart.meta.js +17 -4
  73. package/dist/components/pill/pill.meta.js +34 -1
  74. package/dist/components/popover/popover.meta.js +4 -2
  75. package/dist/components/progress/progress.meta.js +2 -1
  76. package/dist/components/radar-chart/radar-chart.js +4 -3
  77. package/dist/components/radar-chart/radar-chart.meta.js +10 -3
  78. package/dist/components/radial-chart/radial-chart.js +22 -22
  79. package/dist/components/radial-chart/radial-chart.meta.js +17 -4
  80. package/dist/components/radio/radio.meta.js +28 -1
  81. package/dist/components/radio-card/radio-card.meta.js +38 -0
  82. package/dist/components/relative-time/relative-time.meta.js +32 -0
  83. package/dist/components/scroller/scroller.meta.js +32 -0
  84. package/dist/components/scrub-field/scrub-field.meta.js +32 -0
  85. package/dist/components/search-input/search-input.js +9 -7
  86. package/dist/components/search-input/search-input.meta.js +26 -0
  87. package/dist/components/segmented-bar/segmented-bar.meta.js +2 -1
  88. package/dist/components/select/select.meta.js +4 -2
  89. package/dist/components/sheet/sheet.meta.js +4 -2
  90. package/dist/components/show-more/show-more.js +1 -4
  91. package/dist/components/show-more/show-more.meta.js +38 -0
  92. package/dist/components/side-nav/side-nav.js +21 -1
  93. package/dist/components/side-nav/side-nav.meta.js +34 -1
  94. package/dist/components/side-nav/side-nav.variants.js +3 -1
  95. package/dist/components/sidebar/sidebar.js +31 -8
  96. package/dist/components/sidebar/sidebar.meta.js +38 -0
  97. package/dist/components/skeleton/skeleton.meta.js +2 -1
  98. package/dist/components/slider/slider.meta.js +26 -0
  99. package/dist/components/sparkline/sparkline.js +7 -4
  100. package/dist/components/sparkline/sparkline.meta.js +9 -2
  101. package/dist/components/spinner/spinner.meta.js +2 -1
  102. package/dist/components/stepper/stepper.meta.js +4 -2
  103. package/dist/components/switch/switch.meta.js +2 -1
  104. package/dist/components/table/table.meta.js +28 -1
  105. package/dist/components/tabs/tabs.js +27 -8
  106. package/dist/components/tabs/tabs.meta.js +50 -5
  107. package/dist/components/theme-selector/theme-selector.meta.js +32 -0
  108. package/dist/components/theme-switcher/theme-switcher.js +4 -1
  109. package/dist/components/theme-switcher/theme-switcher.meta.js +42 -0
  110. package/dist/components/thinking-orb/thinking-orb.meta.js +4 -2
  111. package/dist/components/toast/toast.meta.js +2 -1
  112. package/dist/components/toggle/toggle.js +28 -5
  113. package/dist/components/toggle/toggle.meta.js +46 -3
  114. package/dist/components/tool-call/index.js +8 -0
  115. package/dist/components/tool-call/tool-call.js +117 -0
  116. package/dist/components/tool-call/tool-call.meta.js +147 -0
  117. package/dist/components/tool-call/tool-call.variants.js +33 -0
  118. package/dist/components/toolbar/toolbar.meta.js +2 -1
  119. package/dist/components/tooltip/index.js +3 -1
  120. package/dist/components/tooltip/tooltip.js +84 -0
  121. package/dist/components/tooltip/tooltip.meta.js +18 -2
  122. package/dist/components/tree/tree.js +71 -6
  123. package/dist/components/tree/tree.meta.js +32 -0
  124. package/dist/components/video/video.meta.js +32 -0
  125. package/dist/file-hashes.json +139 -122
  126. package/dist/foundations/motion.js +49 -3
  127. package/dist/index.d.ts +616 -46
  128. package/dist/index.js +4 -1
  129. package/dist/lib/chart/cartesian.js +9 -10
  130. package/dist/lib/chart/chart-center.js +30 -0
  131. package/dist/lib/chart/chart-frame.js +15 -8
  132. package/dist/lib/chart/dim.js +13 -0
  133. package/dist/lib/chart/format.js +7 -1
  134. package/dist/lib/chart/motion.js +6 -4
  135. package/dist/lib/highlight-glide.js +68 -10
  136. package/dist/lib/taxonomy.js +3 -0
  137. package/dist/{meta-DVG8ceze.d.ts → meta-DX5r5wQL.d.ts} +8 -0
  138. package/dist/motion/MotionProvider.js +45 -3
  139. package/dist/motion/Reveal.js +1 -1
  140. package/dist/motion/TextReveal.js +1 -1
  141. package/dist/motion/index.js +15 -2
  142. package/dist/motion/useAutoSize.js +42 -0
  143. package/dist/motion/useShake.js +25 -0
  144. package/dist/patterns/index.d.ts +1 -1
  145. package/dist/styles.css +298 -5
  146. package/llms/blocks/approval-block.md +128 -0
  147. package/llms/components/accordion.md +4 -3
  148. package/llms/components/area-chart.md +4 -3
  149. package/llms/components/attachment.md +7 -0
  150. package/llms/components/badge.md +1 -1
  151. package/llms/components/banner.md +2 -2
  152. package/llms/components/bar-chart.md +5 -4
  153. package/llms/components/bottom-nav.md +8 -0
  154. package/llms/components/breadcrumbs.md +7 -0
  155. package/llms/components/button-group.md +1 -1
  156. package/llms/components/button.md +6 -6
  157. package/llms/components/calendar.md +9 -0
  158. package/llms/components/carousel.md +17 -0
  159. package/llms/components/chat-composer.md +9 -0
  160. package/llms/components/checkbox-card.md +8 -0
  161. package/llms/components/checkbox.md +9 -1
  162. package/llms/components/code-block.md +8 -0
  163. package/llms/components/collapsible.md +9 -1
  164. package/llms/components/color-field.md +7 -0
  165. package/llms/components/combobox.md +12 -2
  166. package/llms/components/command-menu.md +12 -3
  167. package/llms/components/confirm-button.md +92 -0
  168. package/llms/components/context-card.md +1 -1
  169. package/llms/components/data-table.md +8 -0
  170. package/llms/components/dialog.md +3 -3
  171. package/llms/components/disclosure-picker.md +1 -1
  172. package/llms/components/entity.md +7 -0
  173. package/llms/components/feedback.md +22 -3
  174. package/llms/components/inline-status.md +1 -1
  175. package/llms/components/input-group.md +8 -1
  176. package/llms/components/line-chart.md +4 -3
  177. package/llms/components/menu.md +1 -1
  178. package/llms/components/menubar.md +1 -1
  179. package/llms/components/meter.md +1 -1
  180. package/llms/components/middle-truncate.md +1 -1
  181. package/llms/components/note.md +9 -1
  182. package/llms/components/notification-badge.md +10 -2
  183. package/llms/components/number-ticker.md +86 -0
  184. package/llms/components/otp-field.md +15 -1
  185. package/llms/components/page-loader.md +1 -1
  186. package/llms/components/panel-group.md +7 -0
  187. package/llms/components/panel-section.md +8 -0
  188. package/llms/components/pie-chart.md +6 -4
  189. package/llms/components/pill.md +9 -1
  190. package/llms/components/popover.md +2 -2
  191. package/llms/components/progress.md +1 -1
  192. package/llms/components/radar-chart.md +4 -3
  193. package/llms/components/radial-chart.md +6 -4
  194. package/llms/components/radio-card.md +9 -0
  195. package/llms/components/radio.md +8 -1
  196. package/llms/components/relative-time.md +8 -0
  197. package/llms/components/scroller.md +8 -0
  198. package/llms/components/scrub-field.md +8 -0
  199. package/llms/components/search-input.md +7 -0
  200. package/llms/components/segmented-bar.md +1 -1
  201. package/llms/components/select.md +2 -2
  202. package/llms/components/sheet.md +2 -2
  203. package/llms/components/show-more.md +9 -0
  204. package/llms/components/side-nav.md +9 -1
  205. package/llms/components/sidebar.md +9 -0
  206. package/llms/components/skeleton.md +1 -1
  207. package/llms/components/slider.md +7 -0
  208. package/llms/components/sparkline.md +3 -2
  209. package/llms/components/spinner.md +1 -1
  210. package/llms/components/stepper.md +2 -2
  211. package/llms/components/switch.md +1 -1
  212. package/llms/components/table.md +8 -1
  213. package/llms/components/tabs.md +22 -4
  214. package/llms/components/theme-selector.md +8 -0
  215. package/llms/components/theme-switcher.md +17 -0
  216. package/llms/components/thinking-orb.md +2 -2
  217. package/llms/components/toast.md +1 -1
  218. package/llms/components/toggle.md +20 -3
  219. package/llms/components/tool-call.md +115 -0
  220. package/llms/components/toolbar.md +1 -1
  221. package/llms/components/tooltip.md +12 -2
  222. package/llms/components/tree.md +8 -0
  223. package/llms/components/video.md +8 -0
  224. package/llms/foundations/motion.md +48 -3
  225. package/llms/reference/utilities.md +23 -1
  226. package/llms/tasks/reviewing-motion.md +14 -0
  227. package/llms-full.txt +1004 -98
  228. package/llms.txt +16 -11
  229. package/package.json +1 -1
  230. package/registry.json +2708 -187
  231. package/targets/ios.json +27 -0
@@ -0,0 +1,246 @@
1
+ "use client";
2
+
3
+ // src/components/number-ticker/number-ticker.tsx
4
+ import {
5
+ useEffect,
6
+ useMemo,
7
+ useRef,
8
+ useState
9
+ } from "react";
10
+ import { cn } from "../../lib/cn.js";
11
+ import { useIsomorphicLayoutEffect } from "../../lib/use-isomorphic-layout-effect.js";
12
+ import { motionAttribute, useMotionLevel } from "../../motion/MotionProvider.js";
13
+ import { Fragment, jsx, jsxs } from "react/jsx-runtime";
14
+ var COPIES = 3;
15
+ var CELLS = COPIES * 10;
16
+ var MIDDLE = 10;
17
+ var ROLL = "calc(var(--motion-duration-slower) * var(--motion-expressive, 1)) var(--motion-ease-emphasized)";
18
+ var RESIZE = `width ${ROLL}, opacity var(--motion-duration-fast) var(--motion-ease-standard)`;
19
+ function rollDelay(placeFromRight) {
20
+ return `calc(${Math.min(placeFromRight, 8) * 30}ms * var(--motion-scale, 1) * var(--motion-expressive, 1))`;
21
+ }
22
+ function cellsFor(parts, glyphs, prefix, suffix) {
23
+ const digitOf = (ch) => glyphs.indexOf(ch);
24
+ const integerDigits = parts.filter((p) => p.type === "integer").reduce((n, p) => n + [...p.value].filter((ch) => digitOf(ch) >= 0).length, 0);
25
+ const cells = [];
26
+ if (prefix) cells.push({ key: "prefix", rank: 0, kind: "static", char: prefix });
27
+ let seenInteger = 0;
28
+ let fraction = 0;
29
+ let after = 0;
30
+ let afterNumber = false;
31
+ const before = [];
32
+ const typeCount = /* @__PURE__ */ new Map();
33
+ for (const part of parts) {
34
+ if (part.type === "integer") {
35
+ afterNumber = true;
36
+ for (const ch of part.value) {
37
+ const digit = digitOf(ch);
38
+ const place = integerDigits - 1 - seenInteger;
39
+ if (digit < 0) {
40
+ cells.push({ key: `i${place}x`, rank: 2e6 - place * 2, kind: "static", char: ch });
41
+ continue;
42
+ }
43
+ seenInteger++;
44
+ cells.push({ key: `i${place}`, rank: 2e6 - place * 2, kind: "digit", digit });
45
+ }
46
+ } else if (part.type === "group") {
47
+ const place = integerDigits - seenInteger;
48
+ cells.push({ key: `g${place}`, rank: 2e6 - place * 2 + 1, kind: "static", char: part.value });
49
+ } else if (part.type === "decimal") {
50
+ afterNumber = true;
51
+ cells.push({ key: "decimal", rank: 3e6, kind: "static", char: part.value });
52
+ } else if (part.type === "fraction") {
53
+ for (const ch of part.value) {
54
+ const digit = digitOf(ch);
55
+ const at = fraction++;
56
+ cells.push(
57
+ digit < 0 ? { key: `f${at}x`, rank: 4e6 + at, kind: "static", char: ch } : { key: `f${at}`, rank: 4e6 + at, kind: "digit", digit }
58
+ );
59
+ }
60
+ } else {
61
+ const side = afterNumber ? "a" : "b";
62
+ const n = typeCount.get(`${side}${part.type}`) ?? 0;
63
+ typeCount.set(`${side}${part.type}`, n + 1);
64
+ const cell = { key: `${side}-${part.type}-${n}`, rank: 5e6 + after++, kind: "static", char: part.value };
65
+ if (afterNumber) cells.push(cell);
66
+ else before.push(cell);
67
+ }
68
+ }
69
+ before.forEach((cell, i) => {
70
+ cell.rank = 2e6 - 1e5 - (before.length - i);
71
+ });
72
+ if (suffix) cells.push({ key: "suffix", rank: 6e6, kind: "static", char: suffix });
73
+ return [...before, ...cells].sort((a, b) => a.rank - b.rank);
74
+ }
75
+ var GLYPH = "before:content-[attr(data-char)]";
76
+ var CELL = "relative inline-block whitespace-pre";
77
+ function StaticCell({ char }) {
78
+ return /* @__PURE__ */ jsxs(Fragment, { children: [
79
+ /* @__PURE__ */ jsx("span", { "data-char": char, className: cn("invisible", GLYPH) }),
80
+ /* @__PURE__ */ jsx("span", { "data-char": char, className: cn("absolute inset-0 overflow-x-clip", GLYPH) })
81
+ ] });
82
+ }
83
+ var translateFor = (pos) => `0 ${-pos / CELLS * 100}%`;
84
+ function DigitCell({ digit, glyphs, direction, delay, animate }) {
85
+ const stack = useRef(null);
86
+ const [initial] = useState(() => MIDDLE + digit);
87
+ const pos = useRef(initial);
88
+ const snap = (el, to) => {
89
+ const transition = el.style.transition;
90
+ el.style.transition = "none";
91
+ el.style.translate = translateFor(to);
92
+ void el.offsetHeight;
93
+ el.style.transition = transition;
94
+ pos.current = to;
95
+ };
96
+ useIsomorphicLayoutEffect(() => {
97
+ const el = stack.current;
98
+ if (!el || pos.current % 10 === digit) return;
99
+ if (!animate) {
100
+ snap(el, MIDDLE + digit);
101
+ return;
102
+ }
103
+ const target = (from) => {
104
+ let to2 = from - from % 10 + digit;
105
+ if (direction === "up" && to2 < from) to2 += 10;
106
+ if (direction === "down" && to2 > from) to2 -= 10;
107
+ return to2;
108
+ };
109
+ let to = target(pos.current);
110
+ if (to < 0 || to >= CELLS) {
111
+ snap(el, MIDDLE + pos.current % 10);
112
+ to = target(pos.current);
113
+ }
114
+ el.style.translate = translateFor(to);
115
+ pos.current = to;
116
+ }, [digit, direction, animate]);
117
+ const settle = (event) => {
118
+ if (event.target !== event.currentTarget || event.propertyName !== "translate") return;
119
+ const at = pos.current;
120
+ if (at < MIDDLE || at >= MIDDLE + 10) snap(event.currentTarget, MIDDLE + at % 10);
121
+ };
122
+ return /* @__PURE__ */ jsxs(Fragment, { children: [
123
+ /* @__PURE__ */ jsx("span", { "data-char": glyphs[digit], className: cn("invisible", GLYPH) }),
124
+ /* @__PURE__ */ jsx("span", { className: "absolute inset-0 overflow-clip", children: /* @__PURE__ */ jsx(
125
+ "span",
126
+ {
127
+ ref: stack,
128
+ onTransitionEnd: settle,
129
+ className: "absolute inset-x-0 top-0 flex flex-col",
130
+ style: { translate: translateFor(initial), transition: `translate ${ROLL} ${delay}` },
131
+ children: Array.from({ length: CELLS }, (_, i) => (
132
+ // biome-ignore lint/suspicious/noArrayIndexKey: a column's positions are fixed
133
+ /* @__PURE__ */ jsx("span", { "data-char": glyphs[i % 10], className: GLYPH }, i)
134
+ ))
135
+ }
136
+ ) })
137
+ ] });
138
+ }
139
+ function NumberTicker({
140
+ value,
141
+ format,
142
+ locales,
143
+ prefix,
144
+ suffix,
145
+ trend = "auto",
146
+ motion,
147
+ className,
148
+ ...props
149
+ }) {
150
+ const { expressive } = useMotionLevel(motion);
151
+ const formatKey = JSON.stringify(format ?? {});
152
+ const localeKey = Array.isArray(locales) ? locales.join(",") : locales ?? "";
153
+ const { formatter, glyphs } = useMemo(() => {
154
+ const f = new Intl.NumberFormat(locales, format);
155
+ const digits = new Intl.NumberFormat(locales, { numberingSystem: f.resolvedOptions().numberingSystem, useGrouping: false });
156
+ return { formatter: f, glyphs: Array.from({ length: 10 }, (_, d) => digits.format(d)) };
157
+ }, [formatKey, localeKey]);
158
+ const prefixText = typeof prefix === "string" || typeof prefix === "number" ? String(prefix) : "";
159
+ const suffixText = typeof suffix === "string" || typeof suffix === "number" ? String(suffix) : "";
160
+ const parts = formatter.formatToParts(value);
161
+ const text = `${prefixText}${parts.map((p) => p.value).join("")}${suffixText}`;
162
+ const [track, setTrack] = useState(() => ({
163
+ text,
164
+ value,
165
+ cells: cellsFor(parts, glyphs, prefixText, suffixText),
166
+ leaving: [],
167
+ direction: "up",
168
+ changed: false
169
+ }));
170
+ let current = track;
171
+ if (track.text !== text) {
172
+ const cells = cellsFor(parts, glyphs, prefixText, suffixText);
173
+ const keys = new Set(cells.map((c) => c.key));
174
+ current = {
175
+ text,
176
+ value,
177
+ cells,
178
+ leaving: expressive ? [...track.leaving, ...track.cells].filter((c) => !keys.has(c.key)) : [],
179
+ direction: trend === "auto" ? value < track.value ? "down" : "up" : trend,
180
+ changed: true
181
+ };
182
+ setTrack(current);
183
+ }
184
+ useEffect(() => {
185
+ if (!current.leaving.length) return;
186
+ const id = setTimeout(() => setTrack((t) => t.leaving.length ? { ...t, leaving: [] } : t), 1200);
187
+ return () => clearTimeout(id);
188
+ }, [current.leaving]);
189
+ const drop = (key) => (event) => {
190
+ if (event.target !== event.currentTarget || event.propertyName !== "width") return;
191
+ setTrack((t) => ({ ...t, leaving: t.leaving.filter((c) => c.key !== key) }));
192
+ };
193
+ const leavingKeys = new Set(current.leaving.map((c) => c.key));
194
+ const shown = [...current.cells, ...current.leaving].sort((a, b) => a.rank - b.rank);
195
+ const animate = expressive && current.changed;
196
+ const placeFromRight = /* @__PURE__ */ new Map();
197
+ let place = 0;
198
+ for (let i = shown.length - 1; i >= 0; i--) if (shown[i].kind === "digit") placeFromRight.set(shown[i].key, place++);
199
+ return /* @__PURE__ */ jsxs(
200
+ "span",
201
+ {
202
+ "data-slot": "number-ticker",
203
+ "data-motion": motionAttribute(motion),
204
+ className: cn("inline-block whitespace-nowrap tabular-nums [interpolate-size:allow-keywords]", className),
205
+ ...props,
206
+ children: [
207
+ prefixText ? null : prefix,
208
+ /* @__PURE__ */ jsx("span", { className: "sr-only", children: text }),
209
+ /* @__PURE__ */ jsx("span", { "aria-hidden": "true", dir: "ltr", children: shown.map((cell) => {
210
+ const leaving = leavingKeys.has(cell.key);
211
+ return /* @__PURE__ */ jsx(
212
+ "span",
213
+ {
214
+ onTransitionEnd: leaving ? drop(cell.key) : void 0,
215
+ className: cn(
216
+ CELL,
217
+ // A cell that arrives after the first paint grows in from nothing.
218
+ // @starting-style only applies to a newly inserted element, so the
219
+ // cells already on screen ignore it.
220
+ animate && !leaving && "starting:w-0 starting:opacity-0",
221
+ leaving && "w-0 opacity-0"
222
+ ),
223
+ style: animate ? { transition: RESIZE } : void 0,
224
+ children: cell.kind === "digit" ? /* @__PURE__ */ jsx(
225
+ DigitCell,
226
+ {
227
+ digit: cell.digit,
228
+ glyphs,
229
+ direction: current.direction,
230
+ delay: rollDelay(placeFromRight.get(cell.key) ?? 0),
231
+ animate
232
+ }
233
+ ) : /* @__PURE__ */ jsx(StaticCell, { char: cell.char })
234
+ },
235
+ cell.key
236
+ );
237
+ }) }),
238
+ suffixText ? null : suffix
239
+ ]
240
+ }
241
+ );
242
+ }
243
+ NumberTicker.displayName = "NumberTicker";
244
+ export {
245
+ NumberTicker
246
+ };
@@ -0,0 +1,159 @@
1
+ // src/components/number-ticker/number-ticker.meta.ts
2
+ var numberTickerMeta = {
3
+ name: "NumberTicker",
4
+ summary: "A number that rolls to its new value digit by digit, like an odometer. Formatted with Intl.NumberFormat (currency, percent, compact, any locale); separators, signs and units hold still while each digit column turns, and a digit that appears or goes away eases its width so nothing around it jumps. At rest on first paint; under subtle or reduced motion the value simply updates.",
5
+ status: "stable",
6
+ keywords: [
7
+ "number",
8
+ "counter",
9
+ "odometer",
10
+ "rolling number",
11
+ "animated number",
12
+ "ticker",
13
+ "count up",
14
+ "stat",
15
+ "kpi",
16
+ "metric",
17
+ "price"
18
+ ],
19
+ usage: {
20
+ description: `Use NumberTicker for a figure that changes in place while someone is looking at it \u2014 a dashboard total after a filter changes, a live count, a price as a plan's options change, a score. The roll says "this number moved, and by roughly this much"; the reader sees the change happen instead of noticing, later, that a number is different.`,
21
+ bestPractices: [
22
+ {
23
+ guidance: "do",
24
+ description: "Use it for the headline figure that a control on the same screen changes \u2014 the total above a chart, the price beside a quantity stepper \u2014 so cause and effect are seen together."
25
+ },
26
+ {
27
+ guidance: "do",
28
+ description: "Format through `format` (currency, percent, compact) rather than pre-formatting a string, so separators and units stay put while only the digits roll."
29
+ },
30
+ {
31
+ guidance: "do",
32
+ description: 'Pass `aria-live="polite"` only when the change itself is news the reader would otherwise miss; most tickers sit beside the control that changed them and need no announcement.'
33
+ },
34
+ {
35
+ guidance: "dont",
36
+ description: "Do not use it for values that change many times a second (a stopwatch, a stock tick, a progress percentage) \u2014 the rolls never land and read as a blur. Render the number as plain tabular text."
37
+ },
38
+ {
39
+ guidance: "dont",
40
+ description: "Do not use it for numbers that are names rather than amounts \u2014 an order ID, a phone number, a year, a version. Rolling implies a quantity going up or down."
41
+ },
42
+ {
43
+ guidance: "dont",
44
+ description: "Do not animate a number on page load to make it feel alive. The first paint is at rest on purpose; for an entrance, reveal the card it sits in (the Reveal motion primitive)."
45
+ },
46
+ {
47
+ guidance: "dont",
48
+ description: "Do not use it for a value inside a known range that needs a picture of how full it is \u2014 show a Meter, with the number beside it.",
49
+ instead: ["Meter"]
50
+ }
51
+ ]
52
+ },
53
+ related: {
54
+ components: ["Meter", "NotificationBadge", "PieChart", "RadialChart", "Sparkline"]
55
+ },
56
+ props: [
57
+ {
58
+ name: "value",
59
+ type: "number",
60
+ description: "The number to show. When it changes, the digits roll to it."
61
+ },
62
+ {
63
+ name: "format",
64
+ type: "Intl.NumberFormatOptions",
65
+ description: 'How to write the number \u2014 `{ style: "currency", currency: "USD" }`, `{ style: "percent" }`, `{ notation: "compact" }`, fraction digits. Passed to Intl.NumberFormat; an inline object is fine (it is compared by value).'
66
+ },
67
+ {
68
+ name: "locales",
69
+ type: "string | string[]",
70
+ default: "runtime locale",
71
+ description: "BCP 47 locale(s) for the formatting. Digits roll through the locale's own numbering system."
72
+ },
73
+ {
74
+ name: "prefix",
75
+ type: "ReactNode",
76
+ description: "Before the number. A string becomes part of the number's text (and rolls in or out with it); an element is rendered as given."
77
+ },
78
+ {
79
+ name: "suffix",
80
+ type: "ReactNode",
81
+ description: `After the number ("+", " pts"). A string becomes part of the number's text; an element is rendered as given.`
82
+ },
83
+ {
84
+ name: "trend",
85
+ type: '"auto" | "up" | "down"',
86
+ default: '"auto"',
87
+ description: "Which way the digits roll. `auto` rolls up as the value grows and down as it shrinks; `up` / `down` fix it (a countdown, a score that only climbs)."
88
+ },
89
+ {
90
+ name: "motion",
91
+ type: '"full" | "subtle" | "off"',
92
+ default: '"full"',
93
+ description: "How much motion this component uses: `full` (default) inherits the app's policy, `subtle` keeps feedback and drops the flourish, `off` stills it. It can only turn motion down \u2014 it never overrides a reader's reduce-motion setting. The roll is expressive, so `subtle` updates the value in place."
94
+ },
95
+ {
96
+ name: "className",
97
+ type: "string",
98
+ description: "Merged onto the root span. Set the type tier here (`text-heading-2`, `text-display-lg`); the ticker adds tabular figures so widths hold still."
99
+ },
100
+ {
101
+ name: "...span props",
102
+ type: 'Omit<ComponentPropsWithRef<"span">, "children" | "prefix">',
103
+ description: "Remaining span attributes pass through to the root \u2014 `aria-live` to announce changes, `id`, data attributes."
104
+ }
105
+ ],
106
+ tokensUsed: [],
107
+ motion: [
108
+ {
109
+ slot: "arrival",
110
+ recipe: "none",
111
+ part: "Root",
112
+ why: "The first paint is at rest. A number counting up on page load is decoration that delays the reading; the roll is kept for the moment the value actually changes."
113
+ },
114
+ {
115
+ slot: "response",
116
+ recipe: "none",
117
+ part: "Root",
118
+ why: "It is a reading, not a control; the control that changes it gives the feedback."
119
+ },
120
+ {
121
+ slot: "transition",
122
+ recipe: "custom",
123
+ part: "Digit",
124
+ why: "Each digit column turns to its new place on the expressive roll (the slower token, emphasized easing, staggered from the ones column), and arriving or departing columns ease their width. The roll is what shows a change happened and how big it was \u2014 the hundreds turning reads as a bigger move than the ones. No recipe can translate per-digit columns, so it is custom; it is multiplied by --motion-expressive, so `subtle` and reduced motion land the value at once."
125
+ },
126
+ {
127
+ slot: "activity",
128
+ recipe: "none",
129
+ part: "Root",
130
+ why: "A number has no working state; show a Skeleton while it loads."
131
+ }
132
+ ],
133
+ a11y: [
134
+ 'The whole written number (with any string prefix or suffix) is real text in a visually hidden span; the rolling columns are aria-hidden and draw their glyphs as generated content, so screen readers, find-in-page and copy all get "$1,234.50", never a stack of digits.',
135
+ 'It is not a live region by default \u2014 a figure that changes because the reader moved a control is already where their attention is. Pass `aria-live="polite"` when a change elsewhere should be announced.',
136
+ "Tabular figures keep every digit the same width, so a roll never shifts the text around it."
137
+ ],
138
+ examples: [
139
+ {
140
+ label: "Basic",
141
+ code: '<NumberTicker value={1284} className="text-heading-2" />'
142
+ },
143
+ {
144
+ label: "Currency",
145
+ code: '<NumberTicker value={4280.5} format={{ style: "currency", currency: "USD" }} className="text-display-lg" />'
146
+ },
147
+ {
148
+ label: "Percent, announced",
149
+ code: '<NumberTicker value={0.428} format={{ style: "percent", maximumFractionDigits: 1 }} aria-live="polite" />'
150
+ },
151
+ {
152
+ label: "Subtle",
153
+ code: '<NumberTicker value={92} suffix=" pts" motion="subtle" />'
154
+ }
155
+ ]
156
+ };
157
+ export {
158
+ numberTickerMeta
159
+ };
@@ -1,8 +1,10 @@
1
1
  // src/components/otp-field/otp-field.tsx
2
2
  import * as OtpParts from "@base-ui/react/otp-field";
3
- import { Fragment } from "react";
3
+ import { Fragment, useEffect, useRef } from "react";
4
4
  import { cn } from "../../lib/cn.js";
5
5
  import { FIELD_INVALID, FIELD_SHELL } from "../../lib/field-shell.js";
6
+ import { motionAttribute } from "../../motion/MotionProvider.js";
7
+ import { useShake } from "../../motion/useShake.js";
6
8
  import { jsx, jsxs } from "react/jsx-runtime";
7
9
  var resolvedBase;
8
10
  function otpBase() {
@@ -19,17 +21,31 @@ function OtpField({
19
21
  groupSize,
20
22
  getSlotLabel = defaultSlotLabel,
21
23
  "aria-invalid": ariaInvalid,
24
+ motion,
25
+ onAnimationEnd,
22
26
  ...root
23
27
  }) {
24
28
  const invalid = ariaInvalid === true || ariaInvalid === "true";
25
29
  const Base = otpBase();
30
+ const { shake, shakeProps } = useShake();
31
+ const wasInvalid = useRef(invalid);
32
+ useEffect(() => {
33
+ if (invalid && !wasInvalid.current) shake();
34
+ wasInvalid.current = invalid;
35
+ }, [invalid, shake]);
26
36
  return /* @__PURE__ */ jsx(
27
37
  Base.Root,
28
38
  {
29
39
  length,
30
40
  "data-invalid": invalid ? "" : void 0,
31
- className: cn("inline-flex max-w-full items-center gap-2", className),
41
+ "data-motion": motionAttribute(motion),
42
+ className: cn("inline-flex max-w-full items-center gap-2 motion-shake", className),
32
43
  ...root,
44
+ "data-shake": shakeProps["data-shake"],
45
+ onAnimationEnd: (event) => {
46
+ shakeProps.onAnimationEnd(event);
47
+ onAnimationEnd?.(event);
48
+ },
33
49
  children: Array.from({ length }, (_, index) => (
34
50
  // Slots are positional and never reorder, so the index is the identity.
35
51
  // biome-ignore lint/suspicious/noArrayIndexKey: positional slots
@@ -55,7 +71,11 @@ function OtpField({
55
71
  // Focus is FIELD_SHELL's ring alone, like every other field — a ring-
56
72
  // coloured border under it made a 3px edge only here.
57
73
  "data-[filled]:border-border-strong",
58
- FIELD_INVALID
74
+ FIELD_INVALID,
75
+ // The digits turn too, not just the box: on a six-slot code the
76
+ // border alone is a thin line, and the characters are what the
77
+ // user is reading to find the mistake.
78
+ "aria-[invalid=true]:text-destructive"
59
79
  )
60
80
  }
61
81
  )
@@ -34,6 +34,10 @@ var otpFieldMeta = {
34
34
  guidance: "dont",
35
35
  description: 'Do not use OtpField for a password, an arbitrary-length PIN, or anything the user types from memory. Slots promise a fixed length; use Input with `type="password"` and `autocomplete="current-password"` instead.'
36
36
  },
37
+ {
38
+ guidance: "do",
39
+ description: "Clear the error as soon as the user edits the code (`onValueChange` \u2192 reset the error), and set it again when the next code is refused. Each refusal is then a fresh change to invalid, so the field shakes for every wrong code, not only the first."
40
+ },
37
41
  {
38
42
  guidance: "dont",
39
43
  description: "Do not intercept paste or clear the field on a wrong code before the user has seen the error. Blocking paste breaks SMS autofill and password managers, and wiping the slots erases the evidence of which digit was wrong."
@@ -123,7 +127,13 @@ var otpFieldMeta = {
123
127
  {
124
128
  name: "aria-invalid",
125
129
  type: "boolean",
126
- description: "Marks every slot invalid (destructive border). `<Field error>` sets it for you."
130
+ description: "Marks every slot invalid (destructive border and digits). Turning it on after mount shakes the field once. `<Field error>` sets it for you."
131
+ },
132
+ {
133
+ name: "motion",
134
+ type: '"full" | "subtle" | "off"',
135
+ default: '"full"',
136
+ description: "How much motion this component uses: `full` (default) inherits the app's policy, `subtle` keeps feedback and drops the flourish, `off` stills it. It can only turn motion down \u2014 it never overrides a reader's reduce-motion setting. The shake is the flourish; the destructive colour stays at every level."
127
137
  },
128
138
  {
129
139
  name: "disabled / readOnly / required / name / id / form",
@@ -160,6 +170,12 @@ var otpFieldMeta = {
160
170
  part: "Slot",
161
171
  why: "A filled slot strengthens its border, so a pasted code visibly lands in every slot at once. Easing it reads as the code arriving rather than the field flickering."
162
172
  },
173
+ {
174
+ slot: "transition",
175
+ recipe: "motion-shake",
176
+ part: "Root",
177
+ why: "A refused code shakes the field once as it turns invalid \u2014 the short horizontal no that makes a second wrong code feel noticed rather than ignored. It never carries the refusal alone: the digits and borders turn destructive and the Field says why. It plays on the change to invalid, not on mount, and is expressive, so `subtle` keeps only the colour."
178
+ },
163
179
  {
164
180
  slot: "activity",
165
181
  recipe: "none",
@@ -171,7 +187,8 @@ var otpFieldMeta = {
171
187
  'The root is a role=group named by the field label; the first slot shares the label through `id`/`htmlFor`, and later slots are announced by position ("Character 2 of 6").',
172
188
  "Only one slot is in the tab order. Arrow keys, Home/End and Backspace move between slots; Tab leaves the whole field.",
173
189
  'The first slot carries autocomplete="one-time-code" and the numeric virtual keyboard, so iOS and Android offer the SMS code above the keyboard.',
174
- "Slots render at 20px (`text-mono-lg`), above the 16px floor that stops iOS Safari zooming on focus."
190
+ "Slots render at 20px (`text-mono-lg`), above the 16px floor that stops iOS Safari zooming on focus.",
191
+ "An invalid code is never signalled by motion alone: every slot carries aria-invalid, the digits and borders turn destructive, and the Field's error message is wired through aria-describedby. The shake resolves instantly under reduced motion and `subtle`."
175
192
  ],
176
193
  examples: [
177
194
  {
@@ -186,6 +203,10 @@ var otpFieldMeta = {
186
203
  label: "With an error",
187
204
  code: '<Field label="Verification code" error="That code has expired. Request a new code and try again."><OtpField length={6} groupSize={3} defaultValue="482913" /></Field>'
188
205
  },
206
+ {
207
+ label: "Verify on completion, shake on a wrong code",
208
+ code: 'const [code, setCode] = useState("")\nconst [wrong, setWrong] = useState(false)\n<Field label="Verification code" error={wrong ? "That code does not match. Check the message and try again." : undefined}><OtpField length={6} groupSize={3} value={code} onValueChange={(next) => { setCode(next); setWrong(false) }} onValueComplete={(value) => setWrong(value !== expected)} /></Field>'
209
+ },
189
210
  {
190
211
  label: "Alphanumeric",
191
212
  code: '<Field label="Pairing code"><OtpField length={8} groupSize={4} validationType="alphanumeric" /></Field>'
@@ -22,7 +22,8 @@ var pageLoaderMeta = {
22
22
  },
23
23
  {
24
24
  guidance: "dont",
25
- description: "Do not use PageLoader for small inline waits; use Spinner, an indeterminate Progress, or Skeleton."
25
+ description: "Do not use PageLoader for small inline waits; use Spinner, an indeterminate Progress, or Skeleton.",
26
+ instead: ["Spinner", "Progress", "Skeleton"]
26
27
  },
27
28
  {
28
29
  guidance: "dont",
@@ -83,6 +83,32 @@ var panelGroupMeta = {
83
83
  description: "Controlled width for a Panel, clamped and optionally persisted to localStorage (only when `storageKey` is passed). Read through useSyncExternalStore, so there is no server/client hydration mismatch and no setState in an effect; do not hand-roll a localStorage read during render or a write from an effect. Without `storageKey` the width is kept in memory per hook instance. The third element is the `{ min, max }` it clamps to: spread it onto the paired PanelResizer so the separator's aria-valuemin/max and Home/End match the real clamp."
84
84
  }
85
85
  ],
86
+ motion: [
87
+ {
88
+ slot: "arrival",
89
+ recipe: "none",
90
+ part: "Root",
91
+ why: "Panels are the app's frame, present from first paint. A dock that slid in on load would make the workspace look like it is still assembling."
92
+ },
93
+ {
94
+ slot: "response",
95
+ recipe: "motion-interactive",
96
+ part: "PanelResizer",
97
+ why: "The hairline widens into a visible bar on hover and turns primary on keyboard focus, eased, so a 1px edge says it can be grabbed before the pointer commits."
98
+ },
99
+ {
100
+ slot: "transition",
101
+ recipe: "none",
102
+ part: "Panel",
103
+ why: "Width tracks the pointer one-to-one while dragging, and keyboard steps land at once. An eased width would trail the cursor, and resizing is a layout change: easing it would reflow the canvas beside the panel on every frame."
104
+ },
105
+ {
106
+ slot: "activity",
107
+ recipe: "none",
108
+ part: "Root",
109
+ why: "Layout chrome has no working state."
110
+ }
111
+ ],
86
112
  a11y: [
87
113
  "PanelResizer is a role=separator with aria-orientation, aria-valuenow/min/max, and is focusable \u2014 a pointer-only resizer is a keyboard trap.",
88
114
  "Arrow keys resize by `step`; Home and End jump to the minimum and maximum.",
@@ -1,6 +1,7 @@
1
1
  "use client";
2
2
 
3
3
  // src/components/panel-section/panel-section.tsx
4
+ import { useState } from "react";
4
5
  import { Icon } from "../../icon/index.js";
5
6
  import { cn } from "../../lib/cn.js";
6
7
  import { beginScrub } from "../../lib/scrub-gesture.js";
@@ -110,16 +111,42 @@ function PropertyPair({ labels, className, children, ...props }) {
110
111
  ] });
111
112
  }
112
113
  PropertyPair.displayName = "PropertyPair";
113
- function PropertyGroup({ label, summary, className, children, ...props }) {
114
- return /* @__PURE__ */ jsxs(Collapsible, { className: cn("border-t border-hairline", className), ...props, children: [
115
- /* @__PURE__ */ jsxs(CollapsibleTrigger, { className: "group/property-group flex h-(--control-sm) w-full items-center gap-2 rounded-xs text-start motion-interactive focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring", children: [
116
- /* @__PURE__ */ jsx("span", { className: "min-w-0 flex-1 truncate text-ui-md text-foreground", children: label }),
117
- summary != null ? /* @__PURE__ */ jsx("span", { className: "flex min-w-0 shrink items-center gap-2 truncate text-ui-md text-muted-foreground", children: summary }) : null,
118
- /* @__PURE__ */ jsx(Icon, { name: "plus", className: "shrink-0 text-xs text-muted-foreground group-data-[panel-open]/property-group:hidden" }),
119
- /* @__PURE__ */ jsx(Icon, { name: "minus", className: "hidden shrink-0 text-xs text-muted-foreground group-data-[panel-open]/property-group:block" })
120
- ] }),
121
- /* @__PURE__ */ jsx(CollapsiblePanel, { children: /* @__PURE__ */ jsx("div", { className: "flex flex-col gap-1.5 pt-0.5 pb-2", children }) })
122
- ] });
114
+ function PropertyGroup({
115
+ label,
116
+ summary,
117
+ className,
118
+ children,
119
+ open: openProp,
120
+ defaultOpen = false,
121
+ onOpenChange,
122
+ ...props
123
+ }) {
124
+ const [openState, setOpenState] = useState(defaultOpen);
125
+ const open = openProp ?? openState;
126
+ return /* @__PURE__ */ jsxs(
127
+ Collapsible,
128
+ {
129
+ className: cn("border-t border-hairline", className),
130
+ open: openProp,
131
+ defaultOpen,
132
+ onOpenChange: (next, details) => {
133
+ setOpenState(next);
134
+ onOpenChange?.(next, details);
135
+ },
136
+ ...props,
137
+ children: [
138
+ /* @__PURE__ */ jsxs(CollapsibleTrigger, { className: "flex h-(--control-sm) w-full items-center gap-2 rounded-xs text-start motion-interactive focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring", children: [
139
+ /* @__PURE__ */ jsx("span", { className: "min-w-0 flex-1 truncate text-ui-md text-foreground", children: label }),
140
+ summary != null ? /* @__PURE__ */ jsx("span", { className: "flex min-w-0 shrink items-center gap-2 truncate text-ui-md text-muted-foreground", children: summary }) : null,
141
+ /* @__PURE__ */ jsxs("span", { "aria-hidden": "true", className: "motion-icon-swap shrink-0 text-xs text-muted-foreground", children: [
142
+ /* @__PURE__ */ jsx("span", { "data-active": !open, className: "inline-flex", children: /* @__PURE__ */ jsx(Icon, { name: "plus" }) }),
143
+ /* @__PURE__ */ jsx("span", { "data-active": open, className: "inline-flex", children: /* @__PURE__ */ jsx(Icon, { name: "minus" }) })
144
+ ] })
145
+ ] }),
146
+ /* @__PURE__ */ jsx(CollapsiblePanel, { children: /* @__PURE__ */ jsx("div", { className: "flex flex-col gap-1.5 pt-0.5 pb-2", children }) })
147
+ ]
148
+ }
149
+ );
123
150
  }
124
151
  PropertyGroup.displayName = "PropertyGroup";
125
152
  export {
@@ -101,6 +101,38 @@ var panelSectionMeta = {
101
101
  description: "A collapsible set of properties inside a section, at the panel's scale (an Accordion is page furniture). The row shows the label, then the summary (the group's value, readable while closed: a word, a few swatches, or both), then a + that turns to \u2212 when open. Groups open independently and stack with a hairline between them. Built on Collapsible."
102
102
  }
103
103
  ],
104
+ motion: [
105
+ {
106
+ slot: "arrival",
107
+ recipe: "none",
108
+ part: "Root",
109
+ why: "Sections are part of the inspector they sit in and arrive with it. Animating each in would make a panel switch look like a page load."
110
+ },
111
+ {
112
+ slot: "response",
113
+ recipe: "motion-interactive",
114
+ part: "PropertyGroup trigger",
115
+ why: "The fold row eases its focus ring, so arrowing down a dense inspector shows where the keyboard is without the ring flickering from row to row."
116
+ },
117
+ {
118
+ slot: "transition",
119
+ recipe: "motion-disclosure",
120
+ part: "PropertyGroup panel",
121
+ why: "The group opens by easing its height, so the rows below slide down to make room instead of jumping. In an inspector, the next section moving is otherwise how you lose your place."
122
+ },
123
+ {
124
+ slot: "transition",
125
+ recipe: "motion-icon-swap",
126
+ part: "PropertyGroup indicator",
127
+ why: "The + collapses into \u2212 as the group opens, both glyphs stacked as one mark, so the indicator reads as this row changing state rather than a different icon landing."
128
+ },
129
+ {
130
+ slot: "activity",
131
+ recipe: "none",
132
+ part: "Root",
133
+ why: "The inspector edits values in place; nothing in it loads."
134
+ }
135
+ ],
104
136
  a11y: [
105
137
  "PropertyGroup's row is a real button with aria-expanded (Collapsible); its summary is part of the button's name, so a closed group still announces its value.",
106
138
  "PropertyRow renders a real <label>; pass htmlFor with the control's id so clicking the label focuses it.",