@pixel-point/toolcraft 0.0.2

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 (257) hide show
  1. package/LICENSE.md +98 -0
  2. package/README.md +41 -0
  3. package/bin/create-toolcraft-app.mjs +8 -0
  4. package/bin/toolcraft.mjs +8 -0
  5. package/package.json +24 -0
  6. package/scripts/prepare-pack.mjs +29 -0
  7. package/src/cli.mjs +392 -0
  8. package/src/cli.test.mjs +284 -0
  9. package/src/copy-recursive.mjs +86 -0
  10. package/src/generate.mjs +212 -0
  11. package/src/generate.test.mjs +322 -0
  12. package/src/import-map.mjs +14 -0
  13. package/src/package-json.mjs +80 -0
  14. package/src/package-json.test.mjs +67 -0
  15. package/src/rewrite-imports.mjs +85 -0
  16. package/src/rewrite-imports.test.mjs +58 -0
  17. package/templates/runtime/contracts/component-contracts.test.ts +1165 -0
  18. package/templates/runtime/contracts/component-contracts.ts +1340 -0
  19. package/templates/runtime/contracts/decision-contracts.test.ts +206 -0
  20. package/templates/runtime/contracts/decision-contracts.ts +283 -0
  21. package/templates/runtime/contracts/index.test.ts +14 -0
  22. package/templates/runtime/contracts/index.ts +3 -0
  23. package/templates/runtime/contracts/types.ts +56 -0
  24. package/templates/runtime/export/export.test.ts +203 -0
  25. package/templates/runtime/export/export.ts +132 -0
  26. package/templates/runtime/export/index.ts +1 -0
  27. package/templates/runtime/index.ts +14 -0
  28. package/templates/runtime/react/canvas-shell.test.tsx +424 -0
  29. package/templates/runtime/react/canvas-shell.tsx +408 -0
  30. package/templates/runtime/react/control-renderers.ts +31 -0
  31. package/templates/runtime/react/controls-panel.test.tsx +3736 -0
  32. package/templates/runtime/react/controls-panel.tsx +2327 -0
  33. package/templates/runtime/react/curve-geometry.test.ts +70 -0
  34. package/templates/runtime/react/index.ts +15 -0
  35. package/templates/runtime/react/layer-tree.ts +96 -0
  36. package/templates/runtime/react/layers-panel.test.tsx +487 -0
  37. package/templates/runtime/react/layers-panel.tsx +1348 -0
  38. package/templates/runtime/react/media-file.ts +82 -0
  39. package/templates/runtime/react/panel-host-config.ts +80 -0
  40. package/templates/runtime/react/panel-host-geometry.test.ts +66 -0
  41. package/templates/runtime/react/panel-host-geometry.ts +109 -0
  42. package/templates/runtime/react/panel-host-types.ts +74 -0
  43. package/templates/runtime/react/panel-host.test.tsx +102 -0
  44. package/templates/runtime/react/panel-host.tsx +353 -0
  45. package/templates/runtime/react/runtime-public-api.test.tsx +132 -0
  46. package/templates/runtime/react/settings-transfer.test.ts +150 -0
  47. package/templates/runtime/react/settings-transfer.ts +279 -0
  48. package/templates/runtime/react/storage-key-migration.ts +48 -0
  49. package/templates/runtime/react/theme-runtime.tsx +177 -0
  50. package/templates/runtime/react/timeline-panel.test.tsx +668 -0
  51. package/templates/runtime/react/timeline-panel.tsx +2953 -0
  52. package/templates/runtime/react/toolbar-panel.test.tsx +212 -0
  53. package/templates/runtime/react/toolbar-panel.tsx +205 -0
  54. package/templates/runtime/react/toolcraft-app.integration.test.tsx +350 -0
  55. package/templates/runtime/react/toolcraft-app.test.tsx +339 -0
  56. package/templates/runtime/react/toolcraft-app.tsx +81 -0
  57. package/templates/runtime/react/toolcraft-root.test.tsx +347 -0
  58. package/templates/runtime/react/toolcraft-root.tsx +203 -0
  59. package/templates/runtime/react/use-toolcraft.ts +41 -0
  60. package/templates/runtime/schema/define-toolcraft.test.ts +1524 -0
  61. package/templates/runtime/schema/define-toolcraft.ts +1442 -0
  62. package/templates/runtime/schema/keyframe-capability.test.ts +90 -0
  63. package/templates/runtime/schema/keyframe-capability.ts +51 -0
  64. package/templates/runtime/schema/runtime-targets.ts +40 -0
  65. package/templates/runtime/schema/types.ts +370 -0
  66. package/templates/runtime/state/canvas-zoom.ts +8 -0
  67. package/templates/runtime/state/create-template-state.test.ts +242 -0
  68. package/templates/runtime/state/create-template-state.ts +95 -0
  69. package/templates/runtime/state/keyframe-evaluation.test.ts +141 -0
  70. package/templates/runtime/state/keyframe-evaluation.ts +203 -0
  71. package/templates/runtime/state/persistence.test.ts +217 -0
  72. package/templates/runtime/state/persistence.ts +511 -0
  73. package/templates/runtime/state/reducer.test.ts +937 -0
  74. package/templates/runtime/state/reducer.ts +1212 -0
  75. package/templates/runtime/state/timeline-readiness.ts +43 -0
  76. package/templates/runtime/state/types.ts +242 -0
  77. package/templates/runtime/styles.css +125 -0
  78. package/templates/runtime/testing/performance.test.ts +1058 -0
  79. package/templates/runtime/testing/performance.ts +1078 -0
  80. package/templates/starter/AGENTS.md +186 -0
  81. package/templates/starter/LICENSE.md +98 -0
  82. package/templates/starter/NOTICE.md +8 -0
  83. package/templates/starter/docs/toolcraft/README.md +41 -0
  84. package/templates/starter/docs/toolcraft/acceptance-testing.md +205 -0
  85. package/templates/starter/docs/toolcraft/agent-worklog.md +81 -0
  86. package/templates/starter/docs/toolcraft/assembly-workflow.md +206 -0
  87. package/templates/starter/docs/toolcraft/component-rules.md +299 -0
  88. package/templates/starter/docs/toolcraft/custom-controls.md +71 -0
  89. package/templates/starter/docs/toolcraft/decision-contract.md +71 -0
  90. package/templates/starter/docs/toolcraft/performance.md +112 -0
  91. package/templates/starter/docs/toolcraft/renderer-technique.md +48 -0
  92. package/templates/starter/docs/toolcraft/schema-reference.md +265 -0
  93. package/templates/starter/docs/toolcraft/workflow.md +87 -0
  94. package/templates/starter/e2e/app-browser-acceptance.spec.ts +785 -0
  95. package/templates/starter/e2e/app-controls.spec.ts +41 -0
  96. package/templates/starter/e2e/app-performance.spec.ts +326 -0
  97. package/templates/starter/e2e/canvas-handle-helpers.ts +244 -0
  98. package/templates/starter/e2e/performance-helpers.ts +612 -0
  99. package/templates/starter/e2e/product-observable-helpers.ts +170 -0
  100. package/templates/starter/index.html +12 -0
  101. package/templates/starter/package.json +52 -0
  102. package/templates/starter/playwright.config.ts +43 -0
  103. package/templates/starter/scripts/check-ai-skills.mjs +95 -0
  104. package/templates/starter/scripts/check-toolcraft-docs.mjs +159 -0
  105. package/templates/starter/scripts/check-toolcraft-integrity.mjs +232 -0
  106. package/templates/starter/scripts/run-vite-on-free-port.mjs +48 -0
  107. package/templates/starter/scripts/toolcraft-port.mjs +54 -0
  108. package/templates/starter/scripts/toolcraft-port.test.mjs +73 -0
  109. package/templates/starter/src/app/starter-acceptance.test.ts +5959 -0
  110. package/templates/starter/src/app/starter-acceptance.ts +2646 -0
  111. package/templates/starter/src/app/starter-performance.test.ts +1390 -0
  112. package/templates/starter/src/app/starter-performance.ts +12 -0
  113. package/templates/starter/src/app/starter-schema.test.ts +70 -0
  114. package/templates/starter/src/app/starter-schema.ts +15 -0
  115. package/templates/starter/src/main.tsx +18 -0
  116. package/templates/starter/src/router.tsx +16 -0
  117. package/templates/starter/src/routes/index.tsx +7 -0
  118. package/templates/starter/src/routes/root.tsx +19 -0
  119. package/templates/starter/src/styles.css +120 -0
  120. package/templates/starter/tsconfig.json +11 -0
  121. package/templates/starter/vite.config.ts +13 -0
  122. package/templates/ui/components/composites/accordion.tsx +73 -0
  123. package/templates/ui/components/composites/alert-dialog.tsx +190 -0
  124. package/templates/ui/components/composites/alert.tsx +74 -0
  125. package/templates/ui/components/composites/aspect-ratio.tsx +22 -0
  126. package/templates/ui/components/composites/avatar.tsx +98 -0
  127. package/templates/ui/components/composites/badge.tsx +69 -0
  128. package/templates/ui/components/composites/breadcrumb.tsx +106 -0
  129. package/templates/ui/components/composites/card.tsx +91 -0
  130. package/templates/ui/components/composites/combobox.tsx +486 -0
  131. package/templates/ui/components/composites/command.tsx +296 -0
  132. package/templates/ui/components/composites/context-menu.tsx +247 -0
  133. package/templates/ui/components/composites/dialog.tsx +282 -0
  134. package/templates/ui/components/composites/dropdown-menu.tsx +299 -0
  135. package/templates/ui/components/composites/empty.tsx +110 -0
  136. package/templates/ui/components/composites/hover-card.tsx +44 -0
  137. package/templates/ui/components/composites/index.ts +30 -0
  138. package/templates/ui/components/composites/menubar.tsx +214 -0
  139. package/templates/ui/components/composites/navigation-menu.tsx +167 -0
  140. package/templates/ui/components/composites/pagination.tsx +131 -0
  141. package/templates/ui/components/composites/progress.tsx +72 -0
  142. package/templates/ui/components/composites/radio-group.tsx +84 -0
  143. package/templates/ui/components/composites/resizable.tsx +42 -0
  144. package/templates/ui/components/composites/sheet.tsx +153 -0
  145. package/templates/ui/components/composites/sidebar-structural.tsx +310 -0
  146. package/templates/ui/components/composites/sidebar.tsx +431 -0
  147. package/templates/ui/components/composites/sonner.tsx +35 -0
  148. package/templates/ui/components/composites/spinner.tsx +43 -0
  149. package/templates/ui/components/composites/table.tsx +108 -0
  150. package/templates/ui/components/composites/tabs.tsx +83 -0
  151. package/templates/ui/components/control-layout/index.tsx +437 -0
  152. package/templates/ui/components/controls/actions/actions-control.tsx +139 -0
  153. package/templates/ui/components/controls/actions/index.ts +9 -0
  154. package/templates/ui/components/controls/anchor-grid/anchor-grid-control.tsx +107 -0
  155. package/templates/ui/components/controls/anchor-grid/index.ts +4 -0
  156. package/templates/ui/components/controls/boolean/boolean-controls.tsx +79 -0
  157. package/templates/ui/components/controls/boolean/index.ts +4 -0
  158. package/templates/ui/components/controls/channel-mixer/channel-mixer-control.tsx +95 -0
  159. package/templates/ui/components/controls/channel-mixer/index.ts +4 -0
  160. package/templates/ui/components/controls/channel-tabs/channel-tabs.tsx +42 -0
  161. package/templates/ui/components/controls/channel-tabs/index.ts +6 -0
  162. package/templates/ui/components/controls/code-textarea/code-textarea-control.tsx +90 -0
  163. package/templates/ui/components/controls/code-textarea/index.ts +4 -0
  164. package/templates/ui/components/controls/color/color-control.tsx +571 -0
  165. package/templates/ui/components/controls/color/color-picker-popover.tsx +104 -0
  166. package/templates/ui/components/controls/color/index.ts +41 -0
  167. package/templates/ui/components/controls/color/palette-control-data.ts +436 -0
  168. package/templates/ui/components/controls/color/palette-control.tsx +535 -0
  169. package/templates/ui/components/controls/color/style-guide-color-picker-channel-utils.ts +162 -0
  170. package/templates/ui/components/controls/color/style-guide-color-picker-interactions.ts +190 -0
  171. package/templates/ui/components/controls/color/style-guide-color-picker-logic.ts +485 -0
  172. package/templates/ui/components/controls/color/style-guide-color-picker-parts.tsx +710 -0
  173. package/templates/ui/components/controls/color/style-guide-color-picker.tsx +503 -0
  174. package/templates/ui/components/controls/control-types.ts +43 -0
  175. package/templates/ui/components/controls/curves/curve-geometry.ts +355 -0
  176. package/templates/ui/components/controls/curves/curve-graph.tsx +390 -0
  177. package/templates/ui/components/controls/curves/curves-control.tsx +445 -0
  178. package/templates/ui/components/controls/curves/index.ts +6 -0
  179. package/templates/ui/components/controls/file-drop/file-drop-control.tsx +191 -0
  180. package/templates/ui/components/controls/file-drop/index.ts +5 -0
  181. package/templates/ui/components/controls/font-picker/font-catalog.json +15360 -0
  182. package/templates/ui/components/controls/font-picker/font-catalog.ts +116 -0
  183. package/templates/ui/components/controls/font-picker/font-picker-control.tsx +1202 -0
  184. package/templates/ui/components/controls/font-picker/font-preview-loader.ts +336 -0
  185. package/templates/ui/components/controls/font-picker/index.ts +24 -0
  186. package/templates/ui/components/controls/font-picker/use-hover-intent.ts +46 -0
  187. package/templates/ui/components/controls/gradient/gradient-control-utils.ts +190 -0
  188. package/templates/ui/components/controls/gradient/gradient-control.tsx +612 -0
  189. package/templates/ui/components/controls/gradient/gradient-stop-list.tsx +400 -0
  190. package/templates/ui/components/controls/gradient/gradient-toolbar.tsx +152 -0
  191. package/templates/ui/components/controls/gradient/index.ts +4 -0
  192. package/templates/ui/components/controls/image-picker/image-picker-control.tsx +139 -0
  193. package/templates/ui/components/controls/image-picker/index.ts +7 -0
  194. package/templates/ui/components/controls/index.ts +192 -0
  195. package/templates/ui/components/controls/range-input/index.ts +4 -0
  196. package/templates/ui/components/controls/range-input/range-input-control.tsx +173 -0
  197. package/templates/ui/components/controls/range-slider/index.ts +4 -0
  198. package/templates/ui/components/controls/range-slider/range-slider-control.tsx +122 -0
  199. package/templates/ui/components/controls/range-slider/range-slider-value.ts +61 -0
  200. package/templates/ui/components/controls/segmented/index.ts +8 -0
  201. package/templates/ui/components/controls/segmented/segmented-control.tsx +94 -0
  202. package/templates/ui/components/controls/select/index.ts +4 -0
  203. package/templates/ui/components/controls/select/select-control.tsx +223 -0
  204. package/templates/ui/components/controls/slider/index.ts +4 -0
  205. package/templates/ui/components/controls/slider/slider-control.tsx +150 -0
  206. package/templates/ui/components/controls/slider/slider-value.ts +56 -0
  207. package/templates/ui/components/controls/text-input/index.ts +4 -0
  208. package/templates/ui/components/controls/text-input/text-input-control.tsx +158 -0
  209. package/templates/ui/components/controls/use-measured-element-width.ts +42 -0
  210. package/templates/ui/components/controls/vector/index.ts +8 -0
  211. package/templates/ui/components/controls/vector/vector-control.tsx +401 -0
  212. package/templates/ui/components/panel/index.ts +19 -0
  213. package/templates/ui/components/panel/panel-actions.tsx +165 -0
  214. package/templates/ui/components/panel/panel-header.tsx +61 -0
  215. package/templates/ui/components/panel/panel-icon-button.tsx +96 -0
  216. package/templates/ui/components/panel/panel-section.tsx +168 -0
  217. package/templates/ui/components/panel/panel-surface.tsx +206 -0
  218. package/templates/ui/components/panel/panel.tsx +210 -0
  219. package/templates/ui/components/primitives/animated-loader.tsx +61 -0
  220. package/templates/ui/components/primitives/button-group.tsx +134 -0
  221. package/templates/ui/components/primitives/button.tsx +429 -0
  222. package/templates/ui/components/primitives/checkbox.tsx +62 -0
  223. package/templates/ui/components/primitives/editable-slider-value-label.tsx +337 -0
  224. package/templates/ui/components/primitives/field.tsx +225 -0
  225. package/templates/ui/components/primitives/index.ts +82 -0
  226. package/templates/ui/components/primitives/input-group.tsx +298 -0
  227. package/templates/ui/components/primitives/input.tsx +61 -0
  228. package/templates/ui/components/primitives/internal/button-loading.tsx +178 -0
  229. package/templates/ui/components/primitives/label.tsx +16 -0
  230. package/templates/ui/components/primitives/popover.tsx +126 -0
  231. package/templates/ui/components/primitives/portal-layer-context.tsx +33 -0
  232. package/templates/ui/components/primitives/primitive-arrow-icon.tsx +38 -0
  233. package/templates/ui/components/primitives/scroll-fade-logic.ts +441 -0
  234. package/templates/ui/components/primitives/scroll-fade-render.tsx +75 -0
  235. package/templates/ui/components/primitives/scroll-fade-types.ts +41 -0
  236. package/templates/ui/components/primitives/scroll-fade.tsx +72 -0
  237. package/templates/ui/components/primitives/select.tsx +408 -0
  238. package/templates/ui/components/primitives/selection-state.ts +31 -0
  239. package/templates/ui/components/primitives/separator.tsx +21 -0
  240. package/templates/ui/components/primitives/slider/index.ts +4 -0
  241. package/templates/ui/components/primitives/slider/slider-interaction.tsx +96 -0
  242. package/templates/ui/components/primitives/slider/slider-parts.tsx +303 -0
  243. package/templates/ui/components/primitives/slider/slider-reset.ts +152 -0
  244. package/templates/ui/components/primitives/slider/slider-value.ts +114 -0
  245. package/templates/ui/components/primitives/slider/slider.tsx +511 -0
  246. package/templates/ui/components/primitives/switch.tsx +35 -0
  247. package/templates/ui/components/primitives/textarea.tsx +49 -0
  248. package/templates/ui/components/primitives/toggle-group.tsx +114 -0
  249. package/templates/ui/components/primitives/toggle.tsx +46 -0
  250. package/templates/ui/components/primitives/tooltip.tsx +100 -0
  251. package/templates/ui/hooks/use-mobile.ts +21 -0
  252. package/templates/ui/index.ts +31 -0
  253. package/templates/ui/lib/control-outline.ts +3 -0
  254. package/templates/ui/lib/input-control-style.ts +131 -0
  255. package/templates/ui/lib/style-guide-color-utils.ts +111 -0
  256. package/templates/ui/lib/utils.ts +6 -0
  257. package/templates/ui/styles.css +291 -0
@@ -0,0 +1,1202 @@
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+ import {
5
+ CheckIcon,
6
+ MagnifyingGlassIcon,
7
+ } from "@phosphor-icons/react";
8
+
9
+ import { ControlFieldLabel } from "../../control-layout";
10
+ import {
11
+ Field,
12
+ Input,
13
+ Popover,
14
+ PopoverContent,
15
+ ScrollFade,
16
+ Slider,
17
+ PopoverTrigger,
18
+ SelectTriggerButton,
19
+ } from "../../primitives";
20
+ import { cn } from "../../../lib/utils";
21
+ import type { ControlChangeMeta, ControlValueChangeHandler } from "../control-types";
22
+ import {
23
+ filterFontPickerFonts,
24
+ FONT_PICKER_FILTER_OPTIONS,
25
+ getDefaultFontPickerFontId,
26
+ getFontPickerFontById,
27
+ resolveFontPickerFontId,
28
+ type FontPickerFontCatalogEntry,
29
+ type FontPickerFontFilterValue,
30
+ } from "./font-catalog";
31
+ import {
32
+ queueFontPickerPreviewLoad,
33
+ queueFontPickerPreviewLoadBatch,
34
+ } from "./font-preview-loader";
35
+ import { ColorOpacityControl } from "../color";
36
+ import { StaticSelect } from "../select";
37
+ import { useMeasuredElementWidth } from "../use-measured-element-width";
38
+ import { useHoverIntent } from "./use-hover-intent";
39
+
40
+ export type FontPickerLetterSpacingPreset =
41
+ | "tight"
42
+ | "tighter"
43
+ | "normal"
44
+ | "wide"
45
+ | "wider"
46
+ | "widest";
47
+
48
+ export type FontPickerLineHeightPreset =
49
+ | "loose"
50
+ | "none"
51
+ | "normal"
52
+ | "relaxed"
53
+ | "snug"
54
+ | "tight";
55
+
56
+ export type FontPickerTextCasePreset =
57
+ | "capitalize"
58
+ | "lowercase"
59
+ | "original"
60
+ | "titleCase"
61
+ | "uppercase";
62
+
63
+ export type FontPickerValue = {
64
+ color: string;
65
+ fontId: string;
66
+ fontSize: number;
67
+ fontWeight: string;
68
+ letterSpacing: FontPickerLetterSpacingPreset;
69
+ lineHeight: FontPickerLineHeightPreset;
70
+ opacity: number;
71
+ textCase: FontPickerTextCasePreset;
72
+ };
73
+
74
+ export type FontPickerControlProps = {
75
+ defaultValue?: Partial<FontPickerValue> | string;
76
+ disabled?: boolean;
77
+ name: string;
78
+ onPreviewChange?: (nextFontId: string | null) => void;
79
+ onValueChange?: ControlValueChangeHandler<FontPickerValue>;
80
+ searchPlaceholder?: string;
81
+ value?: Partial<FontPickerValue> | string;
82
+ };
83
+
84
+ const fontItemHeightPx = 36;
85
+ const fontItemGapPx = 1;
86
+ const fontVirtualItemStepPx = fontItemHeightPx + fontItemGapPx;
87
+ const fontListOverscanItems = 6;
88
+ const fontListHeightWithFooterPx = 240;
89
+ const fontPreloadBufferAheadItems = 60;
90
+ const fontPreloadBufferBehindItems = 30;
91
+ const defaultFontPickerFontSizePx = 16;
92
+ const defaultFontPickerColor = "#FFFFFF";
93
+ const defaultFontPickerOpacity = 100;
94
+ const minFontPickerFontSizePx = 1;
95
+ const maxFontPickerFontSizePx = 512;
96
+
97
+ const menuItemInteractionClassName =
98
+ "hover:bg-[color:color-mix(in_oklab,var(--muted-foreground)_10%,transparent)] hover:text-[color:var(--foreground)] focus:bg-[color:color-mix(in_oklab,var(--muted-foreground)_10%,transparent)] focus:text-[color:var(--foreground)]";
99
+
100
+ const letterSpacingSteps: Array<{
101
+ label: string;
102
+ numericValue: number;
103
+ value: FontPickerLetterSpacingPreset;
104
+ }> = [
105
+ { label: "Tighter", numericValue: -0.05, value: "tighter" },
106
+ { label: "Tight", numericValue: -0.025, value: "tight" },
107
+ { label: "Normal", numericValue: 0, value: "normal" },
108
+ { label: "Wide", numericValue: 0.025, value: "wide" },
109
+ { label: "Wider", numericValue: 0.05, value: "wider" },
110
+ { label: "Widest", numericValue: 0.1, value: "widest" },
111
+ ];
112
+
113
+ const lineHeightSteps: Array<{
114
+ label: string;
115
+ numericValue: number;
116
+ value: FontPickerLineHeightPreset;
117
+ }> = [
118
+ { label: "None", numericValue: 1, value: "none" },
119
+ { label: "Tight", numericValue: 1.25, value: "tight" },
120
+ { label: "Snug", numericValue: 1.375, value: "snug" },
121
+ { label: "Normal", numericValue: 1.5, value: "normal" },
122
+ { label: "Relaxed", numericValue: 1.625, value: "relaxed" },
123
+ { label: "Loose", numericValue: 2, value: "loose" },
124
+ ];
125
+
126
+ const textCaseOptions: Array<{
127
+ label: string;
128
+ value: FontPickerTextCasePreset;
129
+ }> = [
130
+ { label: "Original", value: "original" },
131
+ { label: "Uppercase", value: "uppercase" },
132
+ { label: "Lowercase", value: "lowercase" },
133
+ { label: "Capitalize", value: "capitalize" },
134
+ { label: "Title Case", value: "titleCase" },
135
+ ];
136
+
137
+ function isFontPickerTextCase(value: unknown): value is FontPickerTextCasePreset {
138
+ return textCaseOptions.some((option) => option.value === value);
139
+ }
140
+
141
+ function getStepByValue<Value extends string>(
142
+ steps: readonly { numericValue: number; value: Value }[],
143
+ value: Value,
144
+ fallbackValue: Value,
145
+ ): { numericValue: number; value: Value } {
146
+ const step = steps.find((item) => item.value === value);
147
+ if (step) {
148
+ return step;
149
+ }
150
+
151
+ return steps.find((item) => item.value === fallbackValue) ?? steps[0]!;
152
+ }
153
+
154
+ function getStepIndexByValue<Value extends string>(
155
+ steps: readonly { value: Value }[],
156
+ value: Value,
157
+ fallbackValue: Value,
158
+ ): number {
159
+ const stepIndex = steps.findIndex((item) => item.value === value);
160
+ if (stepIndex >= 0) {
161
+ return stepIndex;
162
+ }
163
+
164
+ const fallbackIndex = steps.findIndex((item) => item.value === fallbackValue);
165
+ return fallbackIndex >= 0 ? fallbackIndex : 0;
166
+ }
167
+
168
+ function normalizeFontPickerValue(
169
+ value: FontPickerControlProps["value"],
170
+ ): FontPickerValue {
171
+ const fontId = resolveFontPickerFontId(
172
+ typeof value === "string" ? value : value?.fontId,
173
+ );
174
+ const font = getFontPickerFontById(fontId);
175
+
176
+ if (typeof value === "string") {
177
+ return {
178
+ color: defaultFontPickerColor,
179
+ fontId,
180
+ fontSize: defaultFontPickerFontSizePx,
181
+ fontWeight: resolveFontPickerFontWeight(font),
182
+ letterSpacing: "normal",
183
+ lineHeight: "normal",
184
+ opacity: defaultFontPickerOpacity,
185
+ textCase: "original",
186
+ };
187
+ }
188
+
189
+ return {
190
+ color: normalizeFontPickerColor(value?.color),
191
+ fontId,
192
+ fontSize: normalizeFontPickerFontSize(value?.fontSize),
193
+ fontWeight: resolveFontPickerFontWeight(font, value?.fontWeight),
194
+ letterSpacing: value?.letterSpacing ?? "normal",
195
+ lineHeight: value?.lineHeight ?? "normal",
196
+ opacity: normalizeFontPickerOpacity(value?.opacity),
197
+ textCase: isFontPickerTextCase(value?.textCase) ? value.textCase : "original",
198
+ };
199
+ }
200
+
201
+ function normalizeFontPickerColor(value: unknown): string {
202
+ return typeof value === "string" && value.trim() ? value : defaultFontPickerColor;
203
+ }
204
+
205
+ function normalizeFontPickerOpacity(value: unknown): number {
206
+ const nextOpacity =
207
+ typeof value === "number" ? value : Number.parseFloat(String(value ?? ""));
208
+
209
+ return Number.isFinite(nextOpacity)
210
+ ? Math.min(100, Math.max(0, Math.round(nextOpacity)))
211
+ : defaultFontPickerOpacity;
212
+ }
213
+
214
+ function getFontPickerWeightOptions(
215
+ font: FontPickerFontCatalogEntry | null,
216
+ ): string[] {
217
+ const weights = font?.weights.length ? font.weights : ["400"];
218
+
219
+ return Array.from(new Set(weights.map((weight) => String(weight)))).sort(
220
+ (left, right) => Number(left) - Number(right),
221
+ );
222
+ }
223
+
224
+ function resolveFontPickerFontWeight(
225
+ font: FontPickerFontCatalogEntry | null,
226
+ weight?: string,
227
+ ): string {
228
+ const weights = getFontPickerWeightOptions(font);
229
+ const requestedWeight = typeof weight === "string" ? weight : undefined;
230
+
231
+ if (requestedWeight && weights.includes(requestedWeight)) {
232
+ return requestedWeight;
233
+ }
234
+
235
+ if (weights.includes("400")) {
236
+ return "400";
237
+ }
238
+
239
+ const requestedNumericWeight = Number(requestedWeight ?? 400);
240
+ if (Number.isFinite(requestedNumericWeight)) {
241
+ return weights.reduce((closest, current) => {
242
+ return Math.abs(Number(current) - requestedNumericWeight) <
243
+ Math.abs(Number(closest) - requestedNumericWeight)
244
+ ? current
245
+ : closest;
246
+ }, weights[0] ?? "400");
247
+ }
248
+
249
+ return weights[0] ?? "400";
250
+ }
251
+
252
+ function normalizeFontPickerFontSize(value: unknown): number {
253
+ if (typeof value !== "number" || !Number.isFinite(value)) {
254
+ return defaultFontPickerFontSizePx;
255
+ }
256
+
257
+ return Math.min(
258
+ maxFontPickerFontSizePx,
259
+ Math.max(minFontPickerFontSizePx, Math.round(value)),
260
+ );
261
+ }
262
+
263
+ function getFontFamilyStyle(font: FontPickerFontCatalogEntry | null): React.CSSProperties | undefined {
264
+ return font
265
+ ? {
266
+ fontFamily: `"${font.family}", ui-sans-serif, system-ui, sans-serif`,
267
+ }
268
+ : undefined;
269
+ }
270
+
271
+ function LetterSpacingIcon(): React.JSX.Element {
272
+ return (
273
+ <svg
274
+ aria-hidden
275
+ className="size-4 shrink-0"
276
+ data-slot="font-picker-footer-icon"
277
+ fill="none"
278
+ height="16"
279
+ viewBox="0 0 16 16"
280
+ width="16"
281
+ xmlns="http://www.w3.org/2000/svg"
282
+ >
283
+ <rect fill="#D9D9D9" height="16" width="1" />
284
+ <rect fill="#D9D9D9" height="16" width="1" x="15" />
285
+ <path
286
+ d="M5.18182 13H4L7.41818 3H8.58182L12 13H10.8182L8.03636 4.58203H7.96364L5.18182 13ZM5.61818 9.09375H10.3818V10.168H5.61818V9.09375Z"
287
+ fill="white"
288
+ />
289
+ </svg>
290
+ );
291
+ }
292
+
293
+ function LineHeightIcon(): React.JSX.Element {
294
+ return (
295
+ <svg
296
+ aria-hidden
297
+ className="size-4 shrink-0"
298
+ data-slot="font-picker-footer-icon"
299
+ fill="none"
300
+ height="16"
301
+ viewBox="0 0 16 16"
302
+ width="16"
303
+ xmlns="http://www.w3.org/2000/svg"
304
+ >
305
+ <rect
306
+ fill="#D9D9D9"
307
+ height="16"
308
+ transform="rotate(90 16 0)"
309
+ width="1"
310
+ x="16"
311
+ />
312
+ <rect
313
+ fill="#D9D9D9"
314
+ height="16"
315
+ transform="rotate(90 16 15)"
316
+ width="1"
317
+ x="16"
318
+ y="15"
319
+ />
320
+ <path
321
+ d="M5.18182 13H4L7.41818 3H8.58182L12 13H10.8182L8.03636 4.58203H7.96364L5.18182 13ZM5.61818 9.09375H10.3818V10.168H5.61818V9.09375Z"
322
+ fill="white"
323
+ />
324
+ </svg>
325
+ );
326
+ }
327
+
328
+ function FontPickerFooterControl({
329
+ disabled,
330
+ icon,
331
+ onValueChange,
332
+ steps,
333
+ title,
334
+ valueIndex,
335
+ }: {
336
+ disabled: boolean;
337
+ icon: React.ReactNode;
338
+ onValueChange: (nextValue: number) => void;
339
+ steps: readonly unknown[];
340
+ title: string;
341
+ valueIndex: number;
342
+ }): React.JSX.Element {
343
+ const markerValues = steps.map((_, index) => index);
344
+ const min = 0;
345
+ const max = Math.max(0, markerValues.length - 1);
346
+ const currentValue = Math.min(max, Math.max(min, Math.round(valueIndex)));
347
+
348
+ return (
349
+ <div
350
+ className="flex min-w-0 flex-1 items-center gap-2"
351
+ data-slot="font-picker-footer-control"
352
+ >
353
+ {icon}
354
+ <div className="min-w-0 flex-1" data-slot="font-picker-footer-slider">
355
+ <Slider
356
+ className="[&_[data-slot=slider-range]]:transition-none [&_[data-slot=slider-thumb]]:transition-none"
357
+ disabled={disabled}
358
+ getAriaLabel={() => title}
359
+ markerValues={markerValues}
360
+ max={max}
361
+ min={min}
362
+ onValueChange={(nextValue) => {
363
+ const resolvedValue = Array.isArray(nextValue) ? nextValue[0] : nextValue;
364
+
365
+ if (typeof resolvedValue === "number") {
366
+ onValueChange(Math.min(max, Math.max(min, Math.round(resolvedValue))));
367
+ }
368
+ }}
369
+ showFill
370
+ snapValues={markerValues}
371
+ step={1}
372
+ value={[currentValue]}
373
+ variant="discrete"
374
+ />
375
+ </div>
376
+ </div>
377
+ );
378
+ }
379
+
380
+ export function FontPickerControl({
381
+ defaultValue,
382
+ disabled = false,
383
+ name,
384
+ onPreviewChange,
385
+ onValueChange,
386
+ searchPlaceholder = "Find font",
387
+ value,
388
+ }: FontPickerControlProps): React.JSX.Element {
389
+ const [open, setOpen] = React.useState(false);
390
+ const [query, setQuery] = React.useState("");
391
+ const [category, setCategory] =
392
+ React.useState<FontPickerFontFilterValue>("sans-serif");
393
+ const [scrollTop, setScrollTop] = React.useState(0);
394
+ const [viewportHeight, setViewportHeight] = React.useState(0);
395
+ const [scrollViewportElement, setScrollViewportElement] =
396
+ React.useState<HTMLDivElement | null>(null);
397
+ const searchInputRef = React.useRef<HTMLInputElement>(null);
398
+ const scrollViewportRef = React.useRef<HTMLDivElement | null>(null);
399
+ const previousScrollTopRef = React.useRef(0);
400
+ const scrollDirectionRef = React.useRef<"backward" | "forward">("forward");
401
+ const shouldScrollSelectedOnOpenRef = React.useRef(false);
402
+ const scrollFrameRef = React.useRef<number | null>(null);
403
+ const previewFrameRef = React.useRef<number | null>(null);
404
+ const pendingPreviewFontIdRef = React.useRef<string | null>(null);
405
+ const lastEmittedPreviewFontIdRef = React.useRef<string | null>(null);
406
+ const triggerRef = React.useRef<HTMLButtonElement | null>(null);
407
+ const familyWeightRowRef = React.useRef<HTMLDivElement | null>(null);
408
+ const familyWeightRowWidth = useMeasuredElementWidth(familyWeightRowRef);
409
+ const normalizedValue = normalizeFontPickerValue(value);
410
+ const normalizedDefaultValue = normalizeFontPickerValue(defaultValue);
411
+ const [fontSizeDraft, setFontSizeDraft] = React.useState(
412
+ String(normalizedValue.fontSize),
413
+ );
414
+ const selectedFont = getFontPickerFontById(normalizedValue.fontId);
415
+ const filteredFonts = React.useMemo(
416
+ () => filterFontPickerFonts(query, category),
417
+ [category, query],
418
+ );
419
+ const selectedFontIndex = React.useMemo(() => {
420
+ if (!selectedFont) {
421
+ return -1;
422
+ }
423
+
424
+ return filteredFonts.findIndex((font) => font.id === selectedFont.id);
425
+ }, [filteredFonts, selectedFont]);
426
+ const resolvedViewportHeight =
427
+ viewportHeight > 0 ? viewportHeight : fontListHeightWithFooterPx;
428
+ const selectedFontTop = selectedFontIndex >= 0 ? selectedFontIndex * fontVirtualItemStepPx : 0;
429
+ const selectedFontBottom = selectedFontTop + fontItemHeightPx;
430
+ const selectedFontVisible =
431
+ selectedFontIndex >= 0 &&
432
+ selectedFontBottom > scrollTop &&
433
+ selectedFontTop < scrollTop + resolvedViewportHeight;
434
+ const pinnedSelectedRowSide =
435
+ selectedFont && selectedFontIndex >= 0 && !selectedFontVisible
436
+ ? selectedFontBottom <= scrollTop
437
+ ? "top"
438
+ : "bottom"
439
+ : null;
440
+ const visibleItemCount = Math.max(
441
+ 1,
442
+ Math.ceil(resolvedViewportHeight / fontVirtualItemStepPx),
443
+ );
444
+ const virtualStartIndex = Math.max(
445
+ 0,
446
+ Math.floor(scrollTop / fontVirtualItemStepPx) - fontListOverscanItems,
447
+ );
448
+ const virtualEndIndex = Math.min(
449
+ filteredFonts.length,
450
+ virtualStartIndex + visibleItemCount + fontListOverscanItems * 2,
451
+ );
452
+ const visibleFonts = React.useMemo(
453
+ () => filteredFonts.slice(virtualStartIndex, virtualEndIndex),
454
+ [filteredFonts, virtualEndIndex, virtualStartIndex],
455
+ );
456
+ const topSpacerHeight = virtualStartIndex * fontVirtualItemStepPx;
457
+ const bottomSpacerHeight = Math.max(
458
+ 0,
459
+ (filteredFonts.length - virtualEndIndex) * fontVirtualItemStepPx,
460
+ );
461
+ const emitChange = React.useCallback(
462
+ (nextValue: FontPickerValue, meta?: ControlChangeMeta) => {
463
+ onValueChange?.(nextValue, meta);
464
+ },
465
+ [onValueChange],
466
+ );
467
+
468
+ React.useEffect(() => {
469
+ setFontSizeDraft(String(normalizedValue.fontSize));
470
+ }, [normalizedValue.fontSize]);
471
+
472
+ function commitFontSizeDraft(nextDraft = fontSizeDraft): void {
473
+ const nextSize =
474
+ nextDraft.trim() === ""
475
+ ? normalizedDefaultValue.fontSize
476
+ : normalizeFontPickerFontSize(Number(nextDraft));
477
+
478
+ setFontSizeDraft(String(nextSize));
479
+
480
+ if (nextSize !== normalizedValue.fontSize) {
481
+ emitChange(
482
+ {
483
+ ...normalizedValue,
484
+ fontSize: nextSize,
485
+ },
486
+ { history: "merge" },
487
+ );
488
+ }
489
+ }
490
+
491
+ const emitPreviewImmediately = React.useCallback(
492
+ (nextFontId: string | null) => {
493
+ if (disabled) {
494
+ return;
495
+ }
496
+
497
+ if (nextFontId !== null && lastEmittedPreviewFontIdRef.current === nextFontId) {
498
+ return;
499
+ }
500
+
501
+ lastEmittedPreviewFontIdRef.current = nextFontId;
502
+ onPreviewChange?.(nextFontId);
503
+ },
504
+ [disabled, onPreviewChange],
505
+ );
506
+
507
+ const cancelScheduledPreview = React.useCallback(() => {
508
+ if (previewFrameRef.current !== null) {
509
+ window.cancelAnimationFrame(previewFrameRef.current);
510
+ previewFrameRef.current = null;
511
+ }
512
+
513
+ pendingPreviewFontIdRef.current = null;
514
+ }, []);
515
+
516
+ const emitPreviewChange = React.useCallback(
517
+ (nextFontId: string | null, options?: { immediate?: boolean }) => {
518
+ if (disabled) {
519
+ return;
520
+ }
521
+
522
+ if (nextFontId === null || options?.immediate) {
523
+ cancelScheduledPreview();
524
+ emitPreviewImmediately(nextFontId);
525
+ return;
526
+ }
527
+
528
+ pendingPreviewFontIdRef.current = nextFontId;
529
+ if (previewFrameRef.current !== null) {
530
+ return;
531
+ }
532
+
533
+ previewFrameRef.current = window.requestAnimationFrame(() => {
534
+ previewFrameRef.current = null;
535
+ const scheduledFontId = pendingPreviewFontIdRef.current;
536
+ pendingPreviewFontIdRef.current = null;
537
+
538
+ if (scheduledFontId) {
539
+ emitPreviewImmediately(scheduledFontId);
540
+ }
541
+ });
542
+ },
543
+ [cancelScheduledPreview, disabled, emitPreviewImmediately],
544
+ );
545
+
546
+ const resetViewportScroll = React.useCallback(() => {
547
+ const viewportElement = scrollViewportRef.current;
548
+ if (viewportElement) {
549
+ viewportElement.scrollTop = 0;
550
+ }
551
+
552
+ previousScrollTopRef.current = 0;
553
+ scrollDirectionRef.current = "forward";
554
+ setScrollTop(0);
555
+ }, []);
556
+
557
+ const scrollToFontIndex = React.useCallback((index: number) => {
558
+ if (index < 0 || !scrollViewportRef.current) {
559
+ return;
560
+ }
561
+
562
+ const nextScrollTop = index * fontVirtualItemStepPx;
563
+ scrollDirectionRef.current =
564
+ nextScrollTop >= scrollViewportRef.current.scrollTop
565
+ ? "forward"
566
+ : "backward";
567
+ previousScrollTopRef.current = nextScrollTop;
568
+ scrollViewportRef.current.scrollTop = nextScrollTop;
569
+ setScrollTop(nextScrollTop);
570
+ }, []);
571
+
572
+ const warmFontPreview = React.useCallback(
573
+ (
574
+ fontEntry: FontPickerFontCatalogEntry | null | undefined,
575
+ priority: "high" | "normal" = "normal",
576
+ ) => {
577
+ if (!fontEntry) {
578
+ return;
579
+ }
580
+
581
+ queueFontPickerPreviewLoad(fontEntry, { priority });
582
+ },
583
+ [],
584
+ );
585
+
586
+ const queueBufferedPreload = React.useCallback(
587
+ (direction: "backward" | "forward") => {
588
+ if (!filteredFonts.length) {
589
+ return;
590
+ }
591
+
592
+ const backwardItems =
593
+ direction === "forward"
594
+ ? fontPreloadBufferBehindItems
595
+ : fontPreloadBufferAheadItems;
596
+ const forwardItems =
597
+ direction === "forward"
598
+ ? fontPreloadBufferAheadItems
599
+ : fontPreloadBufferBehindItems;
600
+ const preloadStart = Math.max(0, virtualStartIndex - backwardItems);
601
+ const preloadEnd = Math.min(filteredFonts.length, virtualEndIndex + forwardItems);
602
+
603
+ if (preloadStart >= preloadEnd) {
604
+ return;
605
+ }
606
+
607
+ queueFontPickerPreviewLoadBatch(filteredFonts.slice(preloadStart, preloadEnd), {
608
+ priority: "normal",
609
+ });
610
+ },
611
+ [filteredFonts, virtualEndIndex, virtualStartIndex],
612
+ );
613
+
614
+ const handleHoverPreview = React.useCallback(
615
+ (font: FontPickerFontCatalogEntry) => {
616
+ warmFontPreview(font, "high");
617
+ emitPreviewChange(font.id);
618
+ },
619
+ [emitPreviewChange, warmFontPreview],
620
+ );
621
+
622
+ const {
623
+ cancelIntent: cancelHoverPreviewIntent,
624
+ scheduleIntent: scheduleHoverPreviewIntent,
625
+ } = useHoverIntent({ onIntent: handleHoverPreview });
626
+
627
+ const handleOpenChange = React.useCallback(
628
+ (nextOpen: boolean) => {
629
+ if (nextOpen) {
630
+ shouldScrollSelectedOnOpenRef.current = true;
631
+ setQuery("");
632
+ setCategory(selectedFont?.category ?? "sans-serif");
633
+ resetViewportScroll();
634
+ }
635
+
636
+ setOpen(nextOpen);
637
+
638
+ if (!nextOpen) {
639
+ shouldScrollSelectedOnOpenRef.current = false;
640
+ cancelHoverPreviewIntent();
641
+ emitPreviewChange(null, { immediate: true });
642
+ window.requestAnimationFrame(() => {
643
+ if (document.activeElement === triggerRef.current) {
644
+ triggerRef.current?.blur();
645
+ }
646
+ });
647
+ }
648
+ },
649
+ [
650
+ cancelHoverPreviewIntent,
651
+ emitPreviewChange,
652
+ resetViewportScroll,
653
+ selectedFont?.category,
654
+ ],
655
+ );
656
+
657
+ React.useEffect(() => {
658
+ if (selectedFont) {
659
+ warmFontPreview(selectedFont, "high");
660
+ }
661
+ }, [selectedFont, warmFontPreview]);
662
+
663
+ React.useEffect(() => {
664
+ if (!open) {
665
+ return;
666
+ }
667
+
668
+ if (selectedFont) {
669
+ warmFontPreview(selectedFont, "high");
670
+ }
671
+
672
+ queueFontPickerPreviewLoadBatch(visibleFonts, { priority: "high" });
673
+ }, [open, selectedFont, visibleFonts, warmFontPreview]);
674
+
675
+ React.useEffect(() => {
676
+ if (!open) {
677
+ return;
678
+ }
679
+
680
+ queueBufferedPreload(scrollDirectionRef.current);
681
+ }, [open, queueBufferedPreload, scrollTop]);
682
+
683
+ React.useEffect(() => {
684
+ if (!open) {
685
+ return undefined;
686
+ }
687
+
688
+ const frame = window.requestAnimationFrame(() => {
689
+ searchInputRef.current?.focus({ preventScroll: true });
690
+ });
691
+
692
+ return () => window.cancelAnimationFrame(frame);
693
+ }, [open]);
694
+
695
+ React.useEffect(() => {
696
+ if (!open || !shouldScrollSelectedOnOpenRef.current || selectedFontIndex < 0) {
697
+ return undefined;
698
+ }
699
+
700
+ const frame = window.requestAnimationFrame(() => {
701
+ shouldScrollSelectedOnOpenRef.current = false;
702
+ scrollToFontIndex(selectedFontIndex);
703
+ });
704
+
705
+ return () => window.cancelAnimationFrame(frame);
706
+ }, [open, scrollToFontIndex, selectedFontIndex]);
707
+
708
+ React.useEffect(() => {
709
+ if (!open || !scrollViewportElement) {
710
+ return undefined;
711
+ }
712
+
713
+ const viewportElement = scrollViewportElement;
714
+ const syncMetrics = () => {
715
+ setViewportHeight(
716
+ viewportElement.clientHeight > 0
717
+ ? viewportElement.clientHeight
718
+ : fontListHeightWithFooterPx,
719
+ );
720
+ setScrollTop(viewportElement.scrollTop);
721
+ };
722
+ const handleScroll = () => {
723
+ if (scrollFrameRef.current !== null) {
724
+ window.cancelAnimationFrame(scrollFrameRef.current);
725
+ }
726
+
727
+ scrollFrameRef.current = window.requestAnimationFrame(() => {
728
+ scrollFrameRef.current = null;
729
+ const nextScrollTop = viewportElement.scrollTop;
730
+ scrollDirectionRef.current =
731
+ nextScrollTop >= previousScrollTopRef.current ? "forward" : "backward";
732
+ previousScrollTopRef.current = nextScrollTop;
733
+ setScrollTop(nextScrollTop);
734
+ });
735
+ };
736
+
737
+ syncMetrics();
738
+ viewportElement.addEventListener("scroll", handleScroll, { passive: true });
739
+ window.addEventListener("resize", syncMetrics);
740
+
741
+ return () => {
742
+ viewportElement.removeEventListener("scroll", handleScroll);
743
+ window.removeEventListener("resize", syncMetrics);
744
+
745
+ if (scrollFrameRef.current !== null) {
746
+ window.cancelAnimationFrame(scrollFrameRef.current);
747
+ scrollFrameRef.current = null;
748
+ }
749
+ };
750
+ }, [open, scrollViewportElement]);
751
+
752
+ React.useEffect(() => {
753
+ return () => {
754
+ cancelHoverPreviewIntent();
755
+ cancelScheduledPreview();
756
+ onPreviewChange?.(null);
757
+ };
758
+ }, [cancelHoverPreviewIntent, cancelScheduledPreview, onPreviewChange]);
759
+
760
+ const attachScrollViewport = React.useCallback(
761
+ (node: HTMLDivElement | null) => {
762
+ scrollViewportRef.current = node;
763
+ setScrollViewportElement(node);
764
+ },
765
+ [],
766
+ );
767
+ const clearHoverPreview = React.useCallback(() => {
768
+ cancelHoverPreviewIntent();
769
+ emitPreviewChange(null, { immediate: true });
770
+ }, [cancelHoverPreviewIntent, emitPreviewChange]);
771
+ const selectedFamily = selectedFont?.family ?? getFontPickerFontById(getDefaultFontPickerFontId())?.family ?? "Inter";
772
+ const fontWeightOptions = getFontPickerWeightOptions(selectedFont);
773
+ const selectedFontPreviewStyle = selectedFont
774
+ ? {
775
+ ...getFontFamilyStyle(selectedFont),
776
+ fontWeight: normalizedValue.fontWeight,
777
+ }
778
+ : undefined;
779
+ const letterSpacingStep = getStepByValue(
780
+ letterSpacingSteps,
781
+ normalizedValue.letterSpacing,
782
+ "normal",
783
+ );
784
+ const letterSpacingStepIndex = getStepIndexByValue(
785
+ letterSpacingSteps,
786
+ normalizedValue.letterSpacing,
787
+ "normal",
788
+ );
789
+ const lineHeightStep = getStepByValue(
790
+ lineHeightSteps,
791
+ normalizedValue.lineHeight,
792
+ "normal",
793
+ );
794
+ const lineHeightStepIndex = getStepIndexByValue(
795
+ lineHeightSteps,
796
+ normalizedValue.lineHeight,
797
+ "normal",
798
+ );
799
+
800
+ return (
801
+ <Field className="min-w-0 !gap-y-[9px]">
802
+ <div
803
+ className="grid min-w-0 grid-cols-2 gap-2"
804
+ data-slot="font-picker-family-weight-row"
805
+ ref={familyWeightRowRef}
806
+ >
807
+ <div
808
+ className="min-w-0 space-y-1.5"
809
+ data-slot="font-picker-family-field"
810
+ >
811
+ <ControlFieldLabel>{name}</ControlFieldLabel>
812
+ <Popover onOpenChange={handleOpenChange} open={open}>
813
+ <PopoverTrigger
814
+ data-placeholder-tone="muted"
815
+ data-radius="default"
816
+ data-slot="select-trigger"
817
+ data-size="default"
818
+ data-variant="default"
819
+ render={
820
+ <SelectTriggerButton
821
+ aria-label={`Select ${name}`}
822
+ className="w-full justify-between rounded-lg"
823
+ disabled={disabled}
824
+ open={open}
825
+ ref={triggerRef}
826
+ title={selectedFamily}
827
+ type="button"
828
+ />
829
+ }
830
+ >
831
+ <span
832
+ className="flex min-w-0 flex-1 text-left"
833
+ data-slot="select-value"
834
+ >
835
+ <ScrollFade
836
+ className="no-scrollbar min-w-0"
837
+ containerClassName="min-w-0 flex-1"
838
+ preset="compact"
839
+ side="right"
840
+ watch={[selectedFamily, normalizedValue.fontId]}
841
+ >
842
+ <span
843
+ className="block min-w-max whitespace-nowrap pr-2"
844
+ data-slot="font-picker-trigger-value"
845
+ style={selectedFontPreviewStyle}
846
+ title={selectedFamily}
847
+ >
848
+ {selectedFamily}
849
+ </span>
850
+ </ScrollFade>
851
+ </span>
852
+ </PopoverTrigger>
853
+ <PopoverContent
854
+ align="start"
855
+ className="w-(--anchor-width) gap-0 overflow-hidden rounded-lg border border-[color:color-mix(in_oklab,var(--border)_20%,transparent)] bg-[color:color-mix(in_oklab,var(--popover)_88%,transparent)] p-0 text-[color:var(--popover-foreground)] shadow-sm backdrop-blur-[12.5px]"
856
+ finalFocus={false}
857
+ sideOffset={6}
858
+ style={
859
+ familyWeightRowWidth
860
+ ? { width: familyWeightRowWidth }
861
+ : undefined
862
+ }
863
+ >
864
+ <div>
865
+ <div className="border-b border-[color:color-mix(in_oklab,var(--muted-foreground)_20%,transparent)]">
866
+ <div className="relative h-10">
867
+ <MagnifyingGlassIcon
868
+ aria-hidden
869
+ className="pointer-events-none absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-[color:var(--muted-foreground)]"
870
+ />
871
+ <Input
872
+ ref={searchInputRef}
873
+ className="h-10 border-none bg-transparent pl-[34px] text-[13px] font-normal focus-visible:bg-transparent focus-visible:ring-0"
874
+ name="font-search"
875
+ onChange={(event) => {
876
+ resetViewportScroll();
877
+ setQuery(event.target.value);
878
+ }}
879
+ placeholder={searchPlaceholder}
880
+ type="text"
881
+ value={query}
882
+ />
883
+ </div>
884
+ </div>
885
+ <div className="relative before:absolute before:inset-x-0 before:bottom-0 before:h-px before:bg-[color:color-mix(in_oklab,var(--muted-foreground)_20%,transparent)]">
886
+ <div className="flex h-10 w-full items-center justify-between overflow-x-hidden px-3">
887
+ {FONT_PICKER_FILTER_OPTIONS.map((option) => {
888
+ const active = category === option.value;
889
+
890
+ return (
891
+ <button
892
+ className={cn(
893
+ "relative z-10 h-10 shrink-0 px-0 text-xs font-normal leading-none tracking-normal text-[color:var(--muted-foreground)] transition-colors after:absolute after:inset-x-0 after:bottom-0 after:h-0.5 after:bg-[color:var(--foreground)] after:opacity-0 after:transition-opacity after:duration-200 after:ease-in-out after:content-[''] hover:text-[color:color-mix(in_oklab,var(--foreground)_80%,transparent)]",
894
+ active &&
895
+ "text-[color:var(--foreground)] after:opacity-100",
896
+ )}
897
+ data-state={active ? "active" : "inactive"}
898
+ key={option.value}
899
+ onClick={() => {
900
+ resetViewportScroll();
901
+ setCategory(option.value);
902
+ }}
903
+ type="button"
904
+ >
905
+ {option.label}
906
+ </button>
907
+ );
908
+ })}
909
+ </div>
910
+ </div>
911
+ </div>
912
+ <div
913
+ className="relative isolate pb-1"
914
+ onMouseLeave={clearHoverPreview}
915
+ >
916
+ <div className="px-1 pt-1">
917
+ <div className="relative h-60">
918
+ <ScrollFade
919
+ className="toolcraft-scrollbar h-full"
920
+ containerClassName="h-full"
921
+ data-slot="font-picker-list-viewport"
922
+ height={24}
923
+ preset="default"
924
+ showOppositeSide
925
+ side="bottom"
926
+ viewportRef={attachScrollViewport}
927
+ watch={[
928
+ filteredFonts.length,
929
+ query,
930
+ category,
931
+ virtualStartIndex,
932
+ virtualEndIndex,
933
+ ]}
934
+ >
935
+ {visibleFonts.length ? (
936
+ <div
937
+ className="flex flex-col gap-px"
938
+ data-slot="font-picker-list"
939
+ >
940
+ {topSpacerHeight > 0 ? (
941
+ <div
942
+ aria-hidden
943
+ style={{ height: `${topSpacerHeight}px` }}
944
+ />
945
+ ) : null}
946
+ {visibleFonts.map((font) => {
947
+ const selected = font.id === normalizedValue.fontId;
948
+
949
+ return (
950
+ <button
951
+ className={cn(
952
+ "flex min-h-9 w-full items-center justify-between gap-3 rounded-sm px-2.5 text-left text-sm font-normal text-[color:color-mix(in_oklab,var(--foreground)_85%,transparent)] outline-none",
953
+ menuItemInteractionClassName,
954
+ selected &&
955
+ "bg-[color:color-mix(in_oklab,var(--muted-foreground)_5%,transparent)] font-medium text-[color:var(--foreground)] hover:bg-[color:color-mix(in_oklab,var(--muted-foreground)_5%,transparent)] focus:bg-[color:color-mix(in_oklab,var(--muted-foreground)_5%,transparent)]",
956
+ )}
957
+ key={font.id}
958
+ onBlur={clearHoverPreview}
959
+ onClick={() => {
960
+ cancelHoverPreviewIntent();
961
+ warmFontPreview(font, "high");
962
+ emitPreviewChange(null, { immediate: true });
963
+ emitChange({
964
+ ...normalizedValue,
965
+ fontId: font.id,
966
+ fontWeight: resolveFontPickerFontWeight(
967
+ font,
968
+ normalizedValue.fontWeight,
969
+ ),
970
+ });
971
+ }}
972
+ onFocus={() => {
973
+ cancelHoverPreviewIntent();
974
+ handleHoverPreview(font);
975
+ }}
976
+ onMouseEnter={() =>
977
+ scheduleHoverPreviewIntent(font)
978
+ }
979
+ type="button"
980
+ >
981
+ <span
982
+ className="min-w-0 truncate text-sm"
983
+ style={getFontFamilyStyle(font)}
984
+ >
985
+ {font.family}
986
+ </span>
987
+ {selected ? (
988
+ <CheckIcon
989
+ aria-hidden
990
+ className="size-3.5 shrink-0 text-[color:var(--foreground)]"
991
+ weight="bold"
992
+ />
993
+ ) : null}
994
+ </button>
995
+ );
996
+ })}
997
+ {bottomSpacerHeight > 0 ? (
998
+ <div
999
+ aria-hidden
1000
+ style={{ height: `${bottomSpacerHeight}px` }}
1001
+ />
1002
+ ) : null}
1003
+ </div>
1004
+ ) : null}
1005
+ </ScrollFade>
1006
+ {!visibleFonts.length ? (
1007
+ <div className="pointer-events-none absolute inset-0 flex items-center justify-center">
1008
+ <div className="rounded-sm px-2 text-center text-xs text-[color:var(--muted-foreground)]">
1009
+ No fonts match your search.
1010
+ </div>
1011
+ </div>
1012
+ ) : null}
1013
+ </div>
1014
+ </div>
1015
+ {pinnedSelectedRowSide ? (
1016
+ <div
1017
+ className={cn(
1018
+ "absolute inset-x-0 z-20 overflow-hidden bg-[color:color-mix(in_oklab,var(--popover)_90%,transparent)]",
1019
+ pinnedSelectedRowSide === "top"
1020
+ ? "top-0 border-b border-[color:color-mix(in_oklab,var(--foreground)_10%,transparent)]"
1021
+ : "bottom-0 border-t border-[color:color-mix(in_oklab,var(--foreground)_10%,transparent)]",
1022
+ )}
1023
+ >
1024
+ <button
1025
+ aria-label={`Jump to selected font ${selectedFont?.family ?? ""}`}
1026
+ className={cn(
1027
+ "flex min-h-9 w-full items-center justify-between px-[14px] text-left text-sm font-medium text-[color:var(--foreground)] outline-none",
1028
+ menuItemInteractionClassName,
1029
+ )}
1030
+ data-side={pinnedSelectedRowSide}
1031
+ data-slot="selected-font-jump-row"
1032
+ onClick={() => scrollToFontIndex(selectedFontIndex)}
1033
+ onMouseDown={(event) => event.preventDefault()}
1034
+ type="button"
1035
+ >
1036
+ <span
1037
+ className="min-w-0 flex-1 truncate text-sm"
1038
+ style={selectedFontPreviewStyle}
1039
+ >
1040
+ {selectedFont?.family ?? ""}
1041
+ </span>
1042
+ <CheckIcon
1043
+ aria-hidden
1044
+ className="size-3.5 shrink-0"
1045
+ weight="bold"
1046
+ />
1047
+ </button>
1048
+ </div>
1049
+ ) : null}
1050
+ </div>
1051
+ <div className="flex h-11 shrink-0 items-center gap-5 border-t border-[color:color-mix(in_oklab,var(--muted-foreground)_20%,transparent)] px-3.5">
1052
+ <FontPickerFooterControl
1053
+ disabled={disabled}
1054
+ icon={<LetterSpacingIcon />}
1055
+ onValueChange={(nextIndex) => {
1056
+ const nextStep =
1057
+ letterSpacingSteps[nextIndex] ?? letterSpacingStep;
1058
+
1059
+ emitChange(
1060
+ {
1061
+ ...normalizedValue,
1062
+ letterSpacing: nextStep.value,
1063
+ },
1064
+ { history: "merge" },
1065
+ );
1066
+ }}
1067
+ steps={letterSpacingSteps}
1068
+ title="Letter spacing"
1069
+ valueIndex={letterSpacingStepIndex}
1070
+ />
1071
+ <FontPickerFooterControl
1072
+ disabled={disabled}
1073
+ icon={<LineHeightIcon />}
1074
+ onValueChange={(nextIndex) => {
1075
+ const nextStep =
1076
+ lineHeightSteps[nextIndex] ?? lineHeightStep;
1077
+
1078
+ emitChange(
1079
+ {
1080
+ ...normalizedValue,
1081
+ lineHeight: nextStep.value,
1082
+ },
1083
+ { history: "merge" },
1084
+ );
1085
+ }}
1086
+ steps={lineHeightSteps}
1087
+ title="Line height"
1088
+ valueIndex={lineHeightStepIndex}
1089
+ />
1090
+ </div>
1091
+ </PopoverContent>
1092
+ </Popover>
1093
+ </div>
1094
+ <div
1095
+ className="min-w-0 space-y-1.5"
1096
+ data-slot="font-picker-weight-field"
1097
+ >
1098
+ <ControlFieldLabel>Weight</ControlFieldLabel>
1099
+ <StaticSelect
1100
+ ariaLabel="Font weight"
1101
+ disabled={disabled || fontWeightOptions.length <= 1}
1102
+ onValueChange={(nextWeight) => {
1103
+ emitChange(
1104
+ {
1105
+ ...normalizedValue,
1106
+ fontWeight: resolveFontPickerFontWeight(
1107
+ selectedFont,
1108
+ nextWeight,
1109
+ ),
1110
+ },
1111
+ { history: "merge" },
1112
+ );
1113
+ }}
1114
+ options={fontWeightOptions.map((weight) => ({
1115
+ label: weight,
1116
+ value: weight,
1117
+ }))}
1118
+ scrollFadeValue={false}
1119
+ triggerClassName="min-w-0"
1120
+ value={normalizedValue.fontWeight}
1121
+ />
1122
+ </div>
1123
+ </div>
1124
+ <div
1125
+ className="grid min-w-0 grid-cols-2 gap-2"
1126
+ data-slot="font-picker-typography-controls"
1127
+ >
1128
+ <div className="min-w-0 space-y-1.5" data-slot="font-picker-size-field">
1129
+ <ControlFieldLabel>Size</ControlFieldLabel>
1130
+ <Input
1131
+ aria-label="Font size"
1132
+ className="[appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
1133
+ disabled={disabled}
1134
+ max={maxFontPickerFontSizePx}
1135
+ min={minFontPickerFontSizePx}
1136
+ onBlur={() => commitFontSizeDraft()}
1137
+ onChange={(event) => setFontSizeDraft(event.target.value)}
1138
+ onKeyDown={(event) => {
1139
+ if (event.key === "Enter") {
1140
+ event.preventDefault();
1141
+ commitFontSizeDraft(event.currentTarget.value);
1142
+ event.currentTarget.blur();
1143
+ return;
1144
+ }
1145
+
1146
+ if (event.key === "Escape") {
1147
+ event.preventDefault();
1148
+ setFontSizeDraft(String(normalizedValue.fontSize));
1149
+ event.currentTarget.blur();
1150
+ }
1151
+ }}
1152
+ step={1}
1153
+ type="text"
1154
+ value={fontSizeDraft}
1155
+ />
1156
+ </div>
1157
+ <div
1158
+ className="min-w-0 space-y-1.5"
1159
+ data-slot="font-picker-text-case-field"
1160
+ >
1161
+ <ControlFieldLabel>Case</ControlFieldLabel>
1162
+ <StaticSelect
1163
+ ariaLabel="Text case"
1164
+ disabled={disabled}
1165
+ onValueChange={(nextTextCase) => {
1166
+ emitChange(
1167
+ {
1168
+ ...normalizedValue,
1169
+ textCase: isFontPickerTextCase(nextTextCase)
1170
+ ? nextTextCase
1171
+ : "original",
1172
+ },
1173
+ { history: "merge" },
1174
+ );
1175
+ }}
1176
+ options={textCaseOptions}
1177
+ scrollFadeValue={false}
1178
+ value={normalizedValue.textCase}
1179
+ />
1180
+ </div>
1181
+ </div>
1182
+ <div className="min-w-0" data-slot="font-picker-color-field">
1183
+ <ColorOpacityControl
1184
+ hex={normalizedValue.color}
1185
+ name="Color"
1186
+ onValueChange={(nextColor, meta) => {
1187
+ emitChange(
1188
+ {
1189
+ ...normalizedValue,
1190
+ color: nextColor.hex,
1191
+ opacity: nextColor.opacity,
1192
+ },
1193
+ meta ?? { history: "merge" },
1194
+ );
1195
+ }}
1196
+ opacity={normalizedValue.opacity}
1197
+ showLabel
1198
+ />
1199
+ </div>
1200
+ </Field>
1201
+ );
1202
+ }