@hyperframes/studio 0.7.58 → 0.7.59
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.
- package/dist/assets/{hyperframes-player-CtTDO63S.js → hyperframes-player-Bm07FLkl.js} +1 -1
- package/dist/assets/{index-C47jAC3Q.js → index-B995FG46.js} +1 -1
- package/dist/assets/index-CrkAdJkb.js +426 -0
- package/dist/assets/index-Dj5p8U_A.css +1 -0
- package/dist/assets/{index-DeQPzqwH.js → index-FvzmPhfG.js} +1 -1
- package/dist/index.d.ts +12 -3
- package/dist/index.html +2 -2
- package/dist/index.js +13514 -8528
- package/dist/index.js.map +1 -1
- package/dist/styles/tailwind-preset.d.ts +5 -0
- package/dist/styles/tailwind-preset.js +8 -1
- package/dist/styles/tailwind-preset.js.map +1 -1
- package/package.json +7 -7
- package/src/App.tsx +3 -3
- package/src/components/DesignPanelPromoteProvider.tsx +15 -2
- package/src/components/StudioRightPanel.tsx +29 -51
- package/src/components/editor/AnimationCard.test.tsx +134 -0
- package/src/components/editor/AnimationCard.tsx +19 -4
- package/src/components/editor/ArcPathControls.tsx +1 -0
- package/src/components/editor/GestureRecordControl.tsx +7 -1
- package/src/components/editor/GsapAnimationSection.tsx +109 -14
- package/src/components/editor/InspectorHeaderActions.tsx +42 -4
- package/src/components/editor/PropertyPanel.test.tsx +948 -0
- package/src/components/editor/PropertyPanel.tsx +144 -148
- package/src/components/editor/PropertyPanelEmptyState.test.tsx +74 -0
- package/src/components/editor/PropertyPanelEmptyState.tsx +179 -1
- package/src/components/editor/PropertyPanelFlat.tsx +580 -0
- package/src/components/editor/PropertyPanelFlatFooter.test.tsx +83 -0
- package/src/components/editor/PropertyPanelFlatFooter.tsx +73 -0
- package/src/components/editor/PropertyPanelFlatHeader.test.tsx +79 -0
- package/src/components/editor/PropertyPanelFlatHeader.tsx +112 -0
- package/src/components/editor/gsapAnimationCallbacks.ts +27 -0
- package/src/components/editor/gsapLivePreview.ts +34 -0
- package/src/components/editor/manualEditingAvailability.test.ts +10 -0
- package/src/components/editor/manualEditingAvailability.ts +9 -0
- package/src/components/editor/propertyPanel3dTransform.tsx +5 -0
- package/src/components/editor/propertyPanelColor.test.tsx +28 -0
- package/src/components/editor/propertyPanelColor.tsx +31 -1
- package/src/components/editor/propertyPanelColorGradingControls.tsx +11 -2
- package/src/components/editor/propertyPanelColorGradingSection.tsx +45 -365
- package/src/components/editor/propertyPanelColorGradingSlider.tsx +19 -5
- package/src/components/editor/propertyPanelFill.tsx +22 -5
- package/src/components/editor/propertyPanelFlatColorGradingSection.test.tsx +622 -0
- package/src/components/editor/propertyPanelFlatColorGradingSection.tsx +570 -0
- package/src/components/editor/propertyPanelFlatLayoutSection.test.tsx +293 -0
- package/src/components/editor/propertyPanelFlatLayoutSection.tsx +383 -0
- package/src/components/editor/propertyPanelFlatMaskInsetRows.tsx +102 -0
- package/src/components/editor/propertyPanelFlatMediaSection.test.tsx +436 -0
- package/src/components/editor/propertyPanelFlatMediaSection.tsx +286 -0
- package/src/components/editor/propertyPanelFlatMotionSection.test.tsx +272 -0
- package/src/components/editor/propertyPanelFlatMotionSection.tsx +315 -0
- package/src/components/editor/propertyPanelFlatPrimitives.test.tsx +1174 -0
- package/src/components/editor/propertyPanelFlatPrimitives.tsx +563 -0
- package/src/components/editor/propertyPanelFlatSelectRow.tsx +101 -0
- package/src/components/editor/propertyPanelFlatStyleHelpers.test.ts +23 -0
- package/src/components/editor/propertyPanelFlatStyleHelpers.ts +27 -0
- package/src/components/editor/propertyPanelFlatStyleSections.test.tsx +598 -0
- package/src/components/editor/propertyPanelFlatStyleSections.tsx +516 -0
- package/src/components/editor/propertyPanelFlatTextSection.test.tsx +465 -0
- package/src/components/editor/propertyPanelFlatTextSection.tsx +407 -0
- package/src/components/editor/propertyPanelFlatTimingDerivation.test.ts +71 -0
- package/src/components/editor/propertyPanelFlatTimingDerivation.ts +59 -0
- package/src/components/editor/propertyPanelFlatToggle.test.tsx +63 -0
- package/src/components/editor/propertyPanelFlatToggle.tsx +53 -0
- package/src/components/editor/propertyPanelFont.test.tsx +30 -0
- package/src/components/editor/propertyPanelFont.tsx +136 -95
- package/src/components/editor/propertyPanelHelpers.ts +73 -0
- package/src/components/editor/propertyPanelInputCoverage.test.tsx +587 -0
- package/src/components/editor/propertyPanelMediaSection.tsx +10 -0
- package/src/components/editor/propertyPanelPrimitives.tsx +60 -29
- package/src/components/editor/propertyPanelSections.test.tsx +161 -0
- package/src/components/editor/propertyPanelSections.tsx +154 -90
- package/src/components/editor/propertyPanelStyleSections.tsx +12 -1
- package/src/components/editor/propertyPanelTimingSection.tsx +1 -1
- package/src/components/editor/propertyPanelTypes.ts +14 -1
- package/src/components/editor/propertyPanelValueTier.test.ts +32 -0
- package/src/components/editor/propertyPanelValueTier.ts +28 -0
- package/src/components/editor/useColorGradingController.test.ts +418 -0
- package/src/components/editor/useColorGradingController.ts +596 -0
- package/src/components/panels/VariablesPanel.tsx +4 -0
- package/src/components/sidebar/CompositionsTab.test.ts +28 -2
- package/src/components/sidebar/CompositionsTab.tsx +3 -1
- package/src/components/storyboard/FramePoster.tsx +24 -4
- package/src/components/storyboard/StoryboardFrameFocus.tsx +4 -0
- package/src/components/storyboard/StoryboardFrameTile.tsx +30 -1
- package/src/components/storyboard/StoryboardGrid.tsx +23 -1
- package/src/components/storyboard/StoryboardLoaded.tsx +58 -2
- package/src/components/storyboard/StoryboardView.tsx +9 -2
- package/src/components/storyboard/frameComments.test.ts +95 -0
- package/src/components/storyboard/frameComments.ts +115 -0
- package/src/components/storyboard/useFrameComments.ts +82 -0
- package/src/contexts/DesignPanelInputContext.test.tsx +81 -0
- package/src/contexts/DesignPanelInputContext.tsx +48 -0
- package/src/contexts/DomEditContext.tsx +4 -0
- package/src/contexts/VariablePromoteContext.test.tsx +67 -0
- package/src/contexts/VariablePromoteContext.tsx +10 -5
- package/src/hooks/domEditCommitRunner.ts +11 -0
- package/src/hooks/gsapScriptCommitTypes.ts +6 -4
- package/src/hooks/timelineTrackVisibility.test.ts +52 -0
- package/src/hooks/timelineTrackVisibility.ts +27 -10
- package/src/hooks/useAppHotkeys.ts +9 -0
- package/src/hooks/useDomEditAttributeCommits.ts +123 -1
- package/src/hooks/useDomEditCommits.ts +29 -175
- package/src/hooks/useDomEditCommitsHelpers.ts +158 -0
- package/src/hooks/useDomEditSession.ts +17 -5
- package/src/hooks/useDomEditTextCommits.ts +14 -9
- package/src/hooks/useDomEditWiring.ts +10 -9
- package/src/hooks/useElementLifecycleOps.ts +8 -3
- package/src/hooks/useFileManager.projectOwnership.test.tsx +98 -0
- package/src/hooks/useFileManager.ts +82 -60
- package/src/hooks/useGsapAnimationOps.ts +7 -6
- package/src/hooks/useGsapKeyframeOps.ts +6 -5
- package/src/hooks/useGsapPropertyDebounce.ts +43 -28
- package/src/hooks/useGsapPropertyDebounceFlush.test.ts +35 -0
- package/src/hooks/useGsapScriptCommits.test.tsx +48 -2
- package/src/hooks/useGsapScriptCommits.ts +150 -50
- package/src/hooks/useGsapSelectionHandlers.test.tsx +116 -0
- package/src/hooks/useGsapSelectionHandlers.ts +94 -31
- package/src/hooks/useInspectorSplitResize.ts +51 -0
- package/src/hooks/usePersistentEditHistory.projectOwnership.test.tsx +60 -0
- package/src/hooks/usePersistentEditHistory.test.ts +49 -1
- package/src/hooks/usePersistentEditHistory.ts +71 -31
- package/src/hooks/useProjectCompositionVariables.ts +10 -10
- package/src/hooks/useProjectSignaturePoll.test.tsx +135 -0
- package/src/hooks/useProjectSignaturePoll.ts +68 -0
- package/src/hooks/useSdkSession.lifecycle.test.tsx +137 -0
- package/src/hooks/useSdkSession.ts +155 -14
- package/src/hooks/useSlideshowPersist.ts +7 -1
- package/src/hooks/useStoryboard.ts +23 -4
- package/src/hooks/useTimelineEditing.test.tsx +4 -1
- package/src/hooks/useTimelineEditing.ts +16 -10
- package/src/hooks/useTimelineEditingTypes.ts +3 -0
- package/src/hooks/useTimelineGroupEditing.ts +16 -2
- package/src/hooks/useVariablesPersist.ts +9 -7
- package/src/icons/SystemIcons.tsx +2 -0
- package/src/styles/studio.css +22 -0
- package/src/styles/tailwind-preset.shared.js +7 -0
- package/src/utils/designInputTracking.test.ts +97 -0
- package/src/utils/designInputTracking.ts +80 -0
- package/src/utils/sdkCutover.gate.test.ts +7 -7
- package/src/utils/sdkCutover.test.ts +490 -57
- package/src/utils/sdkCutover.ts +181 -219
- package/src/utils/sdkEditTransaction.ts +292 -0
- package/src/utils/sdkResolverAttempts.ts +94 -0
- package/src/utils/sdkResolverShadow.test.ts +148 -0
- package/src/utils/sdkResolverShadow.ts +139 -106
- package/src/utils/setSlideshowManifest.test.ts +0 -9
- package/src/utils/setSlideshowManifest.ts +20 -27
- package/src/utils/studioFileHistory.test.ts +41 -0
- package/src/utils/studioFileHistory.ts +30 -27
- package/src/utils/studioFileMutationCoordinator.ts +45 -0
- package/src/utils/studioHelpers.test.ts +45 -0
- package/src/utils/studioHelpers.ts +45 -14
- package/src/utils/studioSaveDiagnostics.ts +17 -0
- package/dist/assets/index-B_UvTX3E.js +0 -423
- package/dist/assets/index-uahwWkgw.css +0 -1
|
@@ -0,0 +1,407 @@
|
|
|
1
|
+
import { useEffect, useState } from "react";
|
|
2
|
+
import { useTrackDesignInput } from "../../contexts/DesignPanelInputContext";
|
|
3
|
+
import { Plus, X } from "../../icons/SystemIcons";
|
|
4
|
+
import { isTextEditableSelection, type DomEditSelection } from "./domEditing";
|
|
5
|
+
import type { ImportedFontAsset } from "./fontAssets";
|
|
6
|
+
import { normalizeTextMetricValue } from "./propertyPanelHelpers";
|
|
7
|
+
import { ColorField } from "./propertyPanelColor";
|
|
8
|
+
import { FontFamilyField } from "./propertyPanelFont";
|
|
9
|
+
import { PromotableControl } from "./PromotableControl";
|
|
10
|
+
import { FlatRow, FlatSegmentedRow } from "./propertyPanelFlatPrimitives";
|
|
11
|
+
import {
|
|
12
|
+
resolveValueTier,
|
|
13
|
+
VALUE_TIER_LABEL_CLASS,
|
|
14
|
+
VALUE_TIER_VALUE_CLASS,
|
|
15
|
+
} from "./propertyPanelValueTier";
|
|
16
|
+
import {
|
|
17
|
+
detectAvailableWeights,
|
|
18
|
+
formatTextFieldPreview,
|
|
19
|
+
getTextFieldColor,
|
|
20
|
+
getTextStyleValue,
|
|
21
|
+
TextAreaField,
|
|
22
|
+
WEIGHT_LABELS,
|
|
23
|
+
} from "./propertyPanelSections";
|
|
24
|
+
|
|
25
|
+
/* ------------------------------------------------------------------ */
|
|
26
|
+
/* Flat text section (design_handoff_studio_inspector, #10a) */
|
|
27
|
+
/* ------------------------------------------------------------------ */
|
|
28
|
+
|
|
29
|
+
const ALIGN_OPTIONS = [
|
|
30
|
+
{ key: "left", label: "left", node: "L" },
|
|
31
|
+
{ key: "center", label: "center", node: "C" },
|
|
32
|
+
{ key: "right", label: "right", node: "R" },
|
|
33
|
+
{ key: "justify", label: "justify", node: "J" },
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
const CASE_OPTIONS = [
|
|
37
|
+
{ key: "none", label: "none", node: "–" },
|
|
38
|
+
{ key: "uppercase", label: "uppercase", node: "AG" },
|
|
39
|
+
{ key: "lowercase", label: "lowercase", node: "ag" },
|
|
40
|
+
{ key: "capitalize", label: "capitalize", node: "Ag" },
|
|
41
|
+
];
|
|
42
|
+
|
|
43
|
+
function FlatTextFieldEditor({
|
|
44
|
+
field,
|
|
45
|
+
styles,
|
|
46
|
+
fontAssets,
|
|
47
|
+
onImportFonts,
|
|
48
|
+
onSetText,
|
|
49
|
+
onSetTextFieldStyle,
|
|
50
|
+
autoFocus = false,
|
|
51
|
+
}: {
|
|
52
|
+
field: DomEditSelection["textFields"][number];
|
|
53
|
+
styles: Record<string, string>;
|
|
54
|
+
fontAssets: ImportedFontAsset[];
|
|
55
|
+
onImportFonts?: (files: FileList | File[]) => Promise<ImportedFontAsset[]>;
|
|
56
|
+
onSetText: (value: string, fieldKey?: string) => void;
|
|
57
|
+
onSetTextFieldStyle: (fieldKey: string, property: string, value: string) => void;
|
|
58
|
+
autoFocus?: boolean;
|
|
59
|
+
}) {
|
|
60
|
+
const track = useTrackDesignInput();
|
|
61
|
+
const weight = getTextStyleValue(field, styles, "font-weight", "400");
|
|
62
|
+
const weightOptions = detectAvailableWeights(
|
|
63
|
+
field.computedStyles["font-family"] || styles["font-family"] || "",
|
|
64
|
+
);
|
|
65
|
+
const align = getTextStyleValue(field, styles, "text-align", "start");
|
|
66
|
+
const textTransform = getTextStyleValue(field, styles, "text-transform", "none");
|
|
67
|
+
const fontStyle = getTextStyleValue(field, styles, "font-style", "normal");
|
|
68
|
+
|
|
69
|
+
return (
|
|
70
|
+
<>
|
|
71
|
+
<PromotableControl channel={{ kind: "text" }} enabled={field.source === "self"}>
|
|
72
|
+
{({ value, onCommit }) => (
|
|
73
|
+
<TextAreaField
|
|
74
|
+
flat
|
|
75
|
+
label="Content"
|
|
76
|
+
value={value ?? field.value}
|
|
77
|
+
autoFocus={autoFocus}
|
|
78
|
+
onCommit={onCommit ?? ((next) => onSetText(next, field.key))}
|
|
79
|
+
/>
|
|
80
|
+
)}
|
|
81
|
+
</PromotableControl>
|
|
82
|
+
<PromotableControl
|
|
83
|
+
channel={{ kind: "style", prop: "font-family" }}
|
|
84
|
+
enabled={field.source === "self"}
|
|
85
|
+
>
|
|
86
|
+
{({ value, onCommit }) => (
|
|
87
|
+
<FontFamilyField
|
|
88
|
+
flat
|
|
89
|
+
value={
|
|
90
|
+
value ?? (field.computedStyles["font-family"] || styles["font-family"] || "inherit")
|
|
91
|
+
}
|
|
92
|
+
importedFonts={fontAssets}
|
|
93
|
+
onImportFonts={onImportFonts}
|
|
94
|
+
onCommit={onCommit ?? ((next) => onSetTextFieldStyle(field.key, "font-family", next))}
|
|
95
|
+
/>
|
|
96
|
+
)}
|
|
97
|
+
</PromotableControl>
|
|
98
|
+
<FlatRow
|
|
99
|
+
label="Size"
|
|
100
|
+
value={field.computedStyles["font-size"] || styles["font-size"] || "16px"}
|
|
101
|
+
tier={resolveValueTier(field.inlineStyles["font-size"], styles["font-size"] || "16px")}
|
|
102
|
+
liveCommit
|
|
103
|
+
onCommit={(next) => onSetTextFieldStyle(field.key, "font-size", next)}
|
|
104
|
+
/>
|
|
105
|
+
<div className="flex min-h-[30px] items-center justify-between">
|
|
106
|
+
<span
|
|
107
|
+
className={
|
|
108
|
+
VALUE_TIER_LABEL_CLASS[resolveValueTier(field.inlineStyles["font-weight"], "400")]
|
|
109
|
+
}
|
|
110
|
+
style={{ fontSize: 11 }}
|
|
111
|
+
>
|
|
112
|
+
Weight
|
|
113
|
+
</span>
|
|
114
|
+
<label className="flex items-center gap-1.5">
|
|
115
|
+
<select
|
|
116
|
+
value={weight}
|
|
117
|
+
onChange={(e) => {
|
|
118
|
+
track("select", "Weight");
|
|
119
|
+
onSetTextFieldStyle(field.key, "font-weight", e.target.value);
|
|
120
|
+
}}
|
|
121
|
+
className={`appearance-none bg-transparent text-right font-mono text-[11px] outline-none ${
|
|
122
|
+
VALUE_TIER_VALUE_CLASS[resolveValueTier(field.inlineStyles["font-weight"], "400")]
|
|
123
|
+
}`}
|
|
124
|
+
>
|
|
125
|
+
{(weightOptions.includes(weight) ? weightOptions : [weight, ...weightOptions]).map(
|
|
126
|
+
(option) => (
|
|
127
|
+
<option key={option} value={option}>
|
|
128
|
+
{WEIGHT_LABELS[option] ?? option}
|
|
129
|
+
</option>
|
|
130
|
+
),
|
|
131
|
+
)}
|
|
132
|
+
</select>
|
|
133
|
+
<svg
|
|
134
|
+
width="10"
|
|
135
|
+
height="10"
|
|
136
|
+
viewBox="0 0 10 10"
|
|
137
|
+
fill="currentColor"
|
|
138
|
+
className="flex-shrink-0 text-panel-text-5"
|
|
139
|
+
>
|
|
140
|
+
<path d="M2 3l3 4 3-4z" />
|
|
141
|
+
</svg>
|
|
142
|
+
</label>
|
|
143
|
+
</div>
|
|
144
|
+
<FlatRow
|
|
145
|
+
label="Letter spacing"
|
|
146
|
+
value={getTextStyleValue(field, styles, "letter-spacing", "0px")}
|
|
147
|
+
tier={resolveValueTier(field.inlineStyles["letter-spacing"], "0px")}
|
|
148
|
+
onCommit={(next) =>
|
|
149
|
+
onSetTextFieldStyle(
|
|
150
|
+
field.key,
|
|
151
|
+
"letter-spacing",
|
|
152
|
+
normalizeTextMetricValue("letter-spacing", next),
|
|
153
|
+
)
|
|
154
|
+
}
|
|
155
|
+
onReset={() => onSetTextFieldStyle(field.key, "letter-spacing", "")}
|
|
156
|
+
/>
|
|
157
|
+
<FlatRow
|
|
158
|
+
label="Line height"
|
|
159
|
+
value={getTextStyleValue(field, styles, "line-height", "normal")}
|
|
160
|
+
tier={resolveValueTier(field.inlineStyles["line-height"], "normal")}
|
|
161
|
+
onCommit={(next) =>
|
|
162
|
+
onSetTextFieldStyle(
|
|
163
|
+
field.key,
|
|
164
|
+
"line-height",
|
|
165
|
+
normalizeTextMetricValue("line-height", next),
|
|
166
|
+
)
|
|
167
|
+
}
|
|
168
|
+
onReset={() => onSetTextFieldStyle(field.key, "line-height", "")}
|
|
169
|
+
/>
|
|
170
|
+
<FlatSegmentedRow
|
|
171
|
+
label="Align"
|
|
172
|
+
options={ALIGN_OPTIONS.map((option) => ({
|
|
173
|
+
key: option.key,
|
|
174
|
+
node: option.node,
|
|
175
|
+
label: option.label,
|
|
176
|
+
active:
|
|
177
|
+
align === option.key ||
|
|
178
|
+
(option.key === "left" && align === "start") ||
|
|
179
|
+
(option.key === "right" && align === "end"),
|
|
180
|
+
}))}
|
|
181
|
+
onChange={(next) => {
|
|
182
|
+
// Re-clicking the option that's already visually active for a
|
|
183
|
+
// logical value (authored "start"/"end") must not rewrite it to
|
|
184
|
+
// the physical "left"/"right" — that destroys the logical
|
|
185
|
+
// semantics and is wrong for RTL content. Only write when the
|
|
186
|
+
// user actually picked a different alignment.
|
|
187
|
+
if ((next === "left" && align === "start") || (next === "right" && align === "end")) {
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
onSetTextFieldStyle(field.key, "text-align", next);
|
|
191
|
+
}}
|
|
192
|
+
/>
|
|
193
|
+
<FlatSegmentedRow
|
|
194
|
+
label="Case · Style"
|
|
195
|
+
options={[
|
|
196
|
+
...CASE_OPTIONS.map((option) => ({
|
|
197
|
+
key: option.key,
|
|
198
|
+
node: option.node,
|
|
199
|
+
label: option.label,
|
|
200
|
+
active: textTransform === option.key,
|
|
201
|
+
})),
|
|
202
|
+
{ key: "normal", node: "A", label: "upright", active: fontStyle === "normal" },
|
|
203
|
+
{ key: "italic", node: "A", label: "italic", active: fontStyle === "italic" },
|
|
204
|
+
]}
|
|
205
|
+
spacerAfterIndex={2}
|
|
206
|
+
onChange={(next) => {
|
|
207
|
+
if (next === "normal" || next === "italic") {
|
|
208
|
+
onSetTextFieldStyle(field.key, "font-style", next);
|
|
209
|
+
} else {
|
|
210
|
+
onSetTextFieldStyle(field.key, "text-transform", next);
|
|
211
|
+
}
|
|
212
|
+
}}
|
|
213
|
+
/>
|
|
214
|
+
<PromotableControl
|
|
215
|
+
channel={{ kind: "style", prop: "color" }}
|
|
216
|
+
enabled={field.source === "self"}
|
|
217
|
+
>
|
|
218
|
+
{({ value, onCommit }) => (
|
|
219
|
+
<ColorField
|
|
220
|
+
flat
|
|
221
|
+
label="Color"
|
|
222
|
+
value={value ?? getTextFieldColor(field, styles)}
|
|
223
|
+
onCommit={onCommit ?? ((next) => onSetTextFieldStyle(field.key, "color", next))}
|
|
224
|
+
/>
|
|
225
|
+
)}
|
|
226
|
+
</PromotableControl>
|
|
227
|
+
</>
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
export function FlatTextSection({
|
|
232
|
+
element,
|
|
233
|
+
styles,
|
|
234
|
+
fontAssets,
|
|
235
|
+
onImportFonts,
|
|
236
|
+
onSetText,
|
|
237
|
+
onSetTextFieldStyle,
|
|
238
|
+
onAddTextField,
|
|
239
|
+
onRemoveTextField,
|
|
240
|
+
}: {
|
|
241
|
+
element: DomEditSelection;
|
|
242
|
+
styles: Record<string, string>;
|
|
243
|
+
fontAssets: ImportedFontAsset[];
|
|
244
|
+
onImportFonts?: (files: FileList | File[]) => Promise<ImportedFontAsset[]>;
|
|
245
|
+
onSetText: (value: string, fieldKey?: string) => void;
|
|
246
|
+
onSetTextFieldStyle: (fieldKey: string, property: string, value: string) => void;
|
|
247
|
+
onAddTextField: (afterFieldKey?: string) => string | Promise<string | null> | null;
|
|
248
|
+
onRemoveTextField: (fieldKey: string) => void;
|
|
249
|
+
}) {
|
|
250
|
+
const track = useTrackDesignInput();
|
|
251
|
+
const [activeFieldKey, setActiveFieldKey] = useState<string | null>(
|
|
252
|
+
element.textFields[0]?.key ?? null,
|
|
253
|
+
);
|
|
254
|
+
|
|
255
|
+
useEffect(() => {
|
|
256
|
+
const nextFields = element.textFields;
|
|
257
|
+
setActiveFieldKey((current) => {
|
|
258
|
+
if (current && nextFields.some((field) => field.key === current)) return current;
|
|
259
|
+
return nextFields[0]?.key ?? null;
|
|
260
|
+
});
|
|
261
|
+
}, [element.id, element.selector, element.textFields]);
|
|
262
|
+
|
|
263
|
+
if (!isTextEditableSelection(element)) return null;
|
|
264
|
+
const textFields = element.textFields;
|
|
265
|
+
const activeField = textFields.find((field) => field.key === activeFieldKey) ?? textFields[0];
|
|
266
|
+
if (!activeField) return null;
|
|
267
|
+
|
|
268
|
+
if (textFields.length > 1) {
|
|
269
|
+
return (
|
|
270
|
+
<div className="space-y-1.5">
|
|
271
|
+
<FlatTextLayerList
|
|
272
|
+
fields={textFields}
|
|
273
|
+
activeFieldKey={activeField.key}
|
|
274
|
+
styles={styles}
|
|
275
|
+
onSelect={setActiveFieldKey}
|
|
276
|
+
onAdd={() =>
|
|
277
|
+
void Promise.resolve(onAddTextField(activeField.key)).then((nextKey) => {
|
|
278
|
+
if (nextKey) setActiveFieldKey(nextKey);
|
|
279
|
+
})
|
|
280
|
+
}
|
|
281
|
+
onRemove={onRemoveTextField}
|
|
282
|
+
/>
|
|
283
|
+
<FlatTextFieldEditor
|
|
284
|
+
key={activeField.key}
|
|
285
|
+
field={activeField}
|
|
286
|
+
styles={styles}
|
|
287
|
+
fontAssets={fontAssets}
|
|
288
|
+
onImportFonts={onImportFonts}
|
|
289
|
+
onSetText={onSetText}
|
|
290
|
+
onSetTextFieldStyle={onSetTextFieldStyle}
|
|
291
|
+
autoFocus
|
|
292
|
+
/>
|
|
293
|
+
</div>
|
|
294
|
+
);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
return (
|
|
298
|
+
<div className="space-y-1.5">
|
|
299
|
+
<FlatTextFieldEditor
|
|
300
|
+
field={activeField}
|
|
301
|
+
styles={styles}
|
|
302
|
+
fontAssets={fontAssets}
|
|
303
|
+
onImportFonts={onImportFonts}
|
|
304
|
+
onSetText={onSetText}
|
|
305
|
+
onSetTextFieldStyle={onSetTextFieldStyle}
|
|
306
|
+
/>
|
|
307
|
+
<button
|
|
308
|
+
type="button"
|
|
309
|
+
onClick={() => {
|
|
310
|
+
track("button", "Add text field");
|
|
311
|
+
void onAddTextField(activeField.key);
|
|
312
|
+
}}
|
|
313
|
+
className="mt-0.5 flex items-center gap-[5px] text-[10px] text-panel-text-4 hover:text-panel-text-2"
|
|
314
|
+
>
|
|
315
|
+
<Plus size={10} />
|
|
316
|
+
Add text field
|
|
317
|
+
</button>
|
|
318
|
+
</div>
|
|
319
|
+
);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/* ------------------------------------------------------------------ */
|
|
323
|
+
/* Multi-field layer list (design_handoff_studio_inspector, #10a — */
|
|
324
|
+
/* no mock exists for this row; layout originated by this plan, */
|
|
325
|
+
/* following the "left-rule nested content" convention established */
|
|
326
|
+
/* by Text's own content block, Motion's effect cards, and Media's */
|
|
327
|
+
/* cutout block. Flag for design review.) */
|
|
328
|
+
/* ------------------------------------------------------------------ */
|
|
329
|
+
|
|
330
|
+
export function FlatTextLayerList({
|
|
331
|
+
fields,
|
|
332
|
+
activeFieldKey,
|
|
333
|
+
styles,
|
|
334
|
+
onSelect,
|
|
335
|
+
onAdd,
|
|
336
|
+
onRemove,
|
|
337
|
+
}: {
|
|
338
|
+
fields: DomEditSelection["textFields"];
|
|
339
|
+
activeFieldKey: string;
|
|
340
|
+
styles: Record<string, string>;
|
|
341
|
+
onSelect: (fieldKey: string) => void;
|
|
342
|
+
onAdd: () => void;
|
|
343
|
+
onRemove: (fieldKey: string) => void;
|
|
344
|
+
}) {
|
|
345
|
+
const track = useTrackDesignInput();
|
|
346
|
+
return (
|
|
347
|
+
<div className="mb-2 border-l-2 border-panel-border-input py-0.5 pl-[10px]">
|
|
348
|
+
<div className="mb-1.5 text-[9px] font-semibold uppercase tracking-[0.12em] text-panel-text-5">
|
|
349
|
+
Text layers
|
|
350
|
+
</div>
|
|
351
|
+
<div className="space-y-1">
|
|
352
|
+
{fields.map((field, index) => {
|
|
353
|
+
const active = field.key === activeFieldKey;
|
|
354
|
+
return (
|
|
355
|
+
<div
|
|
356
|
+
key={field.key}
|
|
357
|
+
data-flat-text-layer-row="true"
|
|
358
|
+
data-active={active}
|
|
359
|
+
onClick={() => onSelect(field.key)}
|
|
360
|
+
className={`flex min-h-[26px] cursor-pointer items-center gap-2 rounded px-1 ${
|
|
361
|
+
active ? "bg-panel-accent/10" : "hover:bg-panel-hover"
|
|
362
|
+
}`}
|
|
363
|
+
>
|
|
364
|
+
<span
|
|
365
|
+
className="h-3 w-3 flex-shrink-0 rounded-sm"
|
|
366
|
+
style={{ backgroundColor: getTextFieldColor(field, styles) }}
|
|
367
|
+
/>
|
|
368
|
+
<span className="min-w-0 flex-1 truncate text-[11px] text-panel-text-1">
|
|
369
|
+
{formatTextFieldPreview(field.value) || `Text ${index + 1}`}
|
|
370
|
+
</span>
|
|
371
|
+
<span className="flex-shrink-0 font-mono text-[9px] text-panel-text-4">
|
|
372
|
+
{field.tagName}
|
|
373
|
+
</span>
|
|
374
|
+
{fields.length > 1 && (
|
|
375
|
+
<button
|
|
376
|
+
type="button"
|
|
377
|
+
data-flat-text-layer-remove="true"
|
|
378
|
+
aria-label="Remove text field"
|
|
379
|
+
onClick={(e) => {
|
|
380
|
+
e.stopPropagation();
|
|
381
|
+
track("button", "Remove text field");
|
|
382
|
+
onRemove(field.key);
|
|
383
|
+
}}
|
|
384
|
+
className="flex-shrink-0 text-panel-text-4 hover:text-panel-text-1"
|
|
385
|
+
>
|
|
386
|
+
<X size={10} />
|
|
387
|
+
</button>
|
|
388
|
+
)}
|
|
389
|
+
</div>
|
|
390
|
+
);
|
|
391
|
+
})}
|
|
392
|
+
</div>
|
|
393
|
+
<button
|
|
394
|
+
type="button"
|
|
395
|
+
data-flat-text-layer-add="true"
|
|
396
|
+
onClick={() => {
|
|
397
|
+
track("button", "Add text field");
|
|
398
|
+
onAdd();
|
|
399
|
+
}}
|
|
400
|
+
className="mt-1 flex items-center gap-[5px] text-[10px] text-panel-text-4 hover:text-panel-text-2"
|
|
401
|
+
>
|
|
402
|
+
<Plus size={10} />
|
|
403
|
+
Add text field
|
|
404
|
+
</button>
|
|
405
|
+
</div>
|
|
406
|
+
);
|
|
407
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { deriveElementTiming } from "./propertyPanelFlatTimingDerivation";
|
|
3
|
+
import type { DomEditSelection } from "./domEditingTypes";
|
|
4
|
+
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
|
5
|
+
|
|
6
|
+
function withDataAttributes(
|
|
7
|
+
dataAttributes: Record<string, string>,
|
|
8
|
+
): Pick<DomEditSelection, "dataAttributes"> {
|
|
9
|
+
return { dataAttributes };
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
describe("deriveElementTiming", () => {
|
|
13
|
+
it("uses the explicit data-start/data-duration attributes when duration is authored", () => {
|
|
14
|
+
const result = deriveElementTiming(withDataAttributes({ start: "8", duration: "4" }));
|
|
15
|
+
expect(result).toEqual({ start: 8, duration: 4, inferred: false });
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("infers start/duration from animations when there is no explicit data-duration", () => {
|
|
19
|
+
const animations = [{ position: 2, duration: 3 } as unknown as GsapAnimation];
|
|
20
|
+
const result = deriveElementTiming(
|
|
21
|
+
withDataAttributes({ start: "0", duration: "0" }),
|
|
22
|
+
animations,
|
|
23
|
+
);
|
|
24
|
+
expect(result).toEqual({ start: 2, duration: 3, inferred: true });
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("spans the earliest tween start to the latest tween end across multiple animations", () => {
|
|
28
|
+
const animations = [
|
|
29
|
+
{ position: 1, duration: 2 } as unknown as GsapAnimation, // 1 -> 3
|
|
30
|
+
{ position: 2, duration: 4 } as unknown as GsapAnimation, // 2 -> 6
|
|
31
|
+
];
|
|
32
|
+
const result = deriveElementTiming(withDataAttributes({}), animations);
|
|
33
|
+
expect(result).toEqual({ start: 1, duration: 5, inferred: true });
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("prefers an explicit data-duration over inference even when animations exist", () => {
|
|
37
|
+
const animations = [{ position: 2, duration: 3 } as unknown as GsapAnimation];
|
|
38
|
+
const result = deriveElementTiming(
|
|
39
|
+
withDataAttributes({ start: "0", duration: "10" }),
|
|
40
|
+
animations,
|
|
41
|
+
);
|
|
42
|
+
expect(result).toEqual({ start: 0, duration: 10, inferred: false });
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("falls back to hf-authored-duration when data-duration is absent", () => {
|
|
46
|
+
const result = deriveElementTiming(
|
|
47
|
+
withDataAttributes({ start: "1", "hf-authored-duration": "6" }),
|
|
48
|
+
);
|
|
49
|
+
expect(result).toEqual({ start: 1, duration: 6, inferred: false });
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("returns a zero-duration, non-inferred result with no attributes and no animations", () => {
|
|
53
|
+
const result = deriveElementTiming(withDataAttributes({}));
|
|
54
|
+
expect(result).toEqual({ start: 0, duration: 0, inferred: false });
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
// This is the exact bug from the whole-plan coherence review: Layout's
|
|
58
|
+
// keyframe-seek basis must land on the same absolute time that Motion's
|
|
59
|
+
// Timing row displays as the element's midpoint.
|
|
60
|
+
it("agrees with a keyframe-percentage seek: 50% lands on the same midpoint the Timing row would show", () => {
|
|
61
|
+
const animations = [{ position: 2, duration: 3 } as unknown as GsapAnimation];
|
|
62
|
+
const timing = deriveElementTiming(
|
|
63
|
+
withDataAttributes({ start: "0", duration: "0" }),
|
|
64
|
+
animations,
|
|
65
|
+
);
|
|
66
|
+
const seekTimeAt50Pct = timing.start + (50 / 100) * timing.duration;
|
|
67
|
+
const timingRowMidpoint = timing.start + timing.duration / 2;
|
|
68
|
+
expect(seekTimeAt50Pct).toBe(timingRowMidpoint);
|
|
69
|
+
expect(seekTimeAt50Pct).toBe(3.5);
|
|
70
|
+
});
|
|
71
|
+
});
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
|
2
|
+
import type { DomEditSelection } from "./domEditingTypes";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The single source of truth for an element's clip start/duration in the flat
|
|
6
|
+
* inspector. Both the Motion group's Timing row (`FlatTimingRow`) and the
|
|
7
|
+
* Layout group's keyframe gutter (fed via `elStart`/`elDuration` from
|
|
8
|
+
* `PropertyPanel.tsx` through `PropertyPanelFlat.tsx`) must derive this the
|
|
9
|
+
* same way — otherwise a keyframe-percentage seek in Layout lands on a
|
|
10
|
+
* different absolute time than the range Motion displays for the same
|
|
11
|
+
* element (found by the Plan 3a+3b whole-plan coherence review).
|
|
12
|
+
*
|
|
13
|
+
* Precedence: an explicit `data-duration` (or `data-hf-authored-duration`)
|
|
14
|
+
* wins outright. Only when neither is present do we infer the range from the
|
|
15
|
+
* element's own GSAP tweens (earliest tween start → latest tween end).
|
|
16
|
+
*
|
|
17
|
+
* Scoped to the FLAT inspector only — the legacy (non-flat) panel keeps its
|
|
18
|
+
* own, unrelated `elStart`/`elDuration ?? 1` computation in `PropertyPanel.tsx`
|
|
19
|
+
* untouched.
|
|
20
|
+
*/
|
|
21
|
+
export interface ElementTiming {
|
|
22
|
+
start: number;
|
|
23
|
+
duration: number;
|
|
24
|
+
/** True when duration/start came from `deriveTimingFromAnimations`, not an authored attribute. */
|
|
25
|
+
inferred: boolean;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function deriveTimingFromAnimations(
|
|
29
|
+
animations: GsapAnimation[],
|
|
30
|
+
): { start: number; duration: number } | null {
|
|
31
|
+
let lo = Infinity;
|
|
32
|
+
let hi = -Infinity;
|
|
33
|
+
for (const a of animations) {
|
|
34
|
+
const s = a.resolvedStart ?? (typeof a.position === "number" ? a.position : 0);
|
|
35
|
+
const d = a.duration ?? 0;
|
|
36
|
+
lo = Math.min(lo, s);
|
|
37
|
+
hi = Math.max(hi, s + d);
|
|
38
|
+
}
|
|
39
|
+
if (!Number.isFinite(lo) || !Number.isFinite(hi) || hi <= lo) return null;
|
|
40
|
+
return { start: lo, duration: hi - lo };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function deriveElementTiming(
|
|
44
|
+
element: Pick<DomEditSelection, "dataAttributes">,
|
|
45
|
+
animations: GsapAnimation[] = [],
|
|
46
|
+
): ElementTiming {
|
|
47
|
+
const explicitStart = Number.parseFloat(element.dataAttributes.start ?? "0") || 0;
|
|
48
|
+
const explicitDuration =
|
|
49
|
+
Number.parseFloat(
|
|
50
|
+
element.dataAttributes.duration ?? element.dataAttributes["hf-authored-duration"] ?? "0",
|
|
51
|
+
) || 0;
|
|
52
|
+
|
|
53
|
+
const derived = explicitDuration > 0 ? null : deriveTimingFromAnimations(animations);
|
|
54
|
+
return {
|
|
55
|
+
start: derived ? derived.start : explicitStart,
|
|
56
|
+
duration: derived ? derived.duration : explicitDuration,
|
|
57
|
+
inferred: derived !== null,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
|
|
3
|
+
import React, { act } from "react";
|
|
4
|
+
import { createRoot } from "react-dom/client";
|
|
5
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
6
|
+
import { FlatToggle } from "./propertyPanelFlatToggle";
|
|
7
|
+
|
|
8
|
+
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
9
|
+
|
|
10
|
+
afterEach(() => {
|
|
11
|
+
document.body.innerHTML = "";
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
function renderInto(node: React.ReactElement) {
|
|
15
|
+
const host = document.createElement("div");
|
|
16
|
+
document.body.append(host);
|
|
17
|
+
const root = createRoot(host);
|
|
18
|
+
act(() => {
|
|
19
|
+
root.render(node);
|
|
20
|
+
});
|
|
21
|
+
return { host, root };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
describe("FlatToggle", () => {
|
|
25
|
+
it("renders the off state with a dim label and dim knob, and fires onChange(true) on click", () => {
|
|
26
|
+
const onChange = vi.fn();
|
|
27
|
+
const { host, root } = renderInto(
|
|
28
|
+
<FlatToggle label="Loop" checked={false} onChange={onChange} />,
|
|
29
|
+
);
|
|
30
|
+
const label = host.querySelector('[data-flat-toggle-label="true"]');
|
|
31
|
+
expect(label?.className).toContain("text-panel-text-3");
|
|
32
|
+
const pill = host.querySelector<HTMLButtonElement>('[data-flat-toggle="true"]');
|
|
33
|
+
expect(pill).not.toBeNull();
|
|
34
|
+
act(() => pill?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
|
|
35
|
+
expect(onChange).toHaveBeenCalledWith(true);
|
|
36
|
+
act(() => root.unmount());
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("renders the on state with an emphasized label and mint knob, and fires onChange(false) on click", () => {
|
|
40
|
+
const onChange = vi.fn();
|
|
41
|
+
const { host, root } = renderInto(<FlatToggle label="Loop" checked onChange={onChange} />);
|
|
42
|
+
const label = host.querySelector('[data-flat-toggle-label="true"]');
|
|
43
|
+
expect(label?.className).toContain("text-panel-text-2");
|
|
44
|
+
const knob = host.querySelector('[data-flat-toggle-knob="true"]');
|
|
45
|
+
expect(knob?.className).toContain("bg-panel-accent");
|
|
46
|
+
const pill = host.querySelector<HTMLButtonElement>('[data-flat-toggle="true"]');
|
|
47
|
+
act(() => pill?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
|
|
48
|
+
expect(onChange).toHaveBeenCalledWith(false);
|
|
49
|
+
act(() => root.unmount());
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("does not fire onChange when disabled", () => {
|
|
53
|
+
const onChange = vi.fn();
|
|
54
|
+
const { host, root } = renderInto(
|
|
55
|
+
<FlatToggle label="Loop" checked={false} disabled onChange={onChange} />,
|
|
56
|
+
);
|
|
57
|
+
const pill = host.querySelector<HTMLButtonElement>('[data-flat-toggle="true"]');
|
|
58
|
+
expect(pill?.disabled).toBe(true);
|
|
59
|
+
act(() => pill?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
|
|
60
|
+
expect(onChange).not.toHaveBeenCalled();
|
|
61
|
+
act(() => root.unmount());
|
|
62
|
+
});
|
|
63
|
+
});
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/* ------------------------------------------------------------------ */
|
|
2
|
+
/* FlatToggle — 24×14 pill switch */
|
|
3
|
+
/* (split out of propertyPanelFlatPrimitives.tsx to stay under the */
|
|
4
|
+
/* 600-line file-size gate) */
|
|
5
|
+
/* ------------------------------------------------------------------ */
|
|
6
|
+
|
|
7
|
+
import { useTrackDesignInput } from "../../contexts/DesignPanelInputContext";
|
|
8
|
+
|
|
9
|
+
export function FlatToggle({
|
|
10
|
+
label,
|
|
11
|
+
checked,
|
|
12
|
+
disabled,
|
|
13
|
+
onChange,
|
|
14
|
+
}: {
|
|
15
|
+
label: string;
|
|
16
|
+
checked: boolean;
|
|
17
|
+
disabled?: boolean;
|
|
18
|
+
onChange: (next: boolean) => void;
|
|
19
|
+
}) {
|
|
20
|
+
const track = useTrackDesignInput();
|
|
21
|
+
return (
|
|
22
|
+
<div className="flex min-h-[30px] items-center justify-between">
|
|
23
|
+
<span
|
|
24
|
+
data-flat-toggle-label="true"
|
|
25
|
+
className={`text-[11px] ${checked ? "text-panel-text-2" : "text-panel-text-3"}`}
|
|
26
|
+
>
|
|
27
|
+
{label}
|
|
28
|
+
</span>
|
|
29
|
+
<button
|
|
30
|
+
type="button"
|
|
31
|
+
data-flat-toggle="true"
|
|
32
|
+
role="switch"
|
|
33
|
+
aria-checked={checked}
|
|
34
|
+
aria-label={label}
|
|
35
|
+
disabled={disabled}
|
|
36
|
+
onClick={() => {
|
|
37
|
+
track("toggle", label);
|
|
38
|
+
onChange(!checked);
|
|
39
|
+
}}
|
|
40
|
+
className={`relative h-[14px] w-6 flex-shrink-0 rounded-full transition-colors disabled:cursor-not-allowed disabled:opacity-50 ${
|
|
41
|
+
checked ? "bg-panel-accent/35" : "bg-panel-hover"
|
|
42
|
+
}`}
|
|
43
|
+
>
|
|
44
|
+
<span
|
|
45
|
+
data-flat-toggle-knob="true"
|
|
46
|
+
className={`absolute top-0.5 h-2.5 w-2.5 rounded-full transition-all ${
|
|
47
|
+
checked ? "right-0.5 bg-panel-accent" : "left-0.5 bg-panel-text-4"
|
|
48
|
+
}`}
|
|
49
|
+
/>
|
|
50
|
+
</button>
|
|
51
|
+
</div>
|
|
52
|
+
);
|
|
53
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
|
|
3
|
+
import React, { act } from "react";
|
|
4
|
+
import { createRoot } from "react-dom/client";
|
|
5
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
6
|
+
import { FontFamilyField } from "./propertyPanelFont";
|
|
7
|
+
|
|
8
|
+
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
9
|
+
|
|
10
|
+
afterEach(() => {
|
|
11
|
+
document.body.innerHTML = "";
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
describe("FontFamilyField flat trigger", () => {
|
|
15
|
+
it("renders as a label/value row with a trailing dropdown caret, no boxed border", () => {
|
|
16
|
+
const host = document.createElement("div");
|
|
17
|
+
document.body.append(host);
|
|
18
|
+
const root = createRoot(host);
|
|
19
|
+
act(() => {
|
|
20
|
+
root.render(
|
|
21
|
+
<FontFamilyField flat value="JetBrains Mono" importedFonts={[]} onCommit={vi.fn()} />,
|
|
22
|
+
);
|
|
23
|
+
});
|
|
24
|
+
const trigger = host.querySelector<HTMLButtonElement>('[data-flat-font-trigger="true"]');
|
|
25
|
+
expect(trigger).not.toBeNull();
|
|
26
|
+
expect(trigger?.className).not.toContain("border-neutral-800");
|
|
27
|
+
expect(host.textContent).toContain("JetBrains Mono");
|
|
28
|
+
act(() => root.unmount());
|
|
29
|
+
});
|
|
30
|
+
});
|