diff --git a/craft/src/panels/right/styles/shared-controls.test.tsx b/craft/src/panels/right/styles/shared-controls.test.tsx new file mode 100644 index 0000000..d49adbc --- /dev/null +++ b/craft/src/panels/right/styles/shared-controls.test.tsx @@ -0,0 +1,340 @@ +import { describe, test, expect, vi } from 'vitest'; +import React from 'react'; +import { createRoot, Root } from 'react-dom/client'; +import { act } from 'react-dom/test-utils'; +import { + NumericUnitInput, + parseCssLength, + SizeControl, + AspectRatioControl, + parseAspectRatioInput, + FocalPointGrid, + FOCAL_POINTS, + SpacingControl, + BorderControl, + buildBorderShorthand, + AnimationControl, + ANIMATIONS, + VisibilityControl, +} from './shared'; + +/* Same DOM-harness pattern as AssetPicker.test.tsx / ImageBlock.render.test.tsx + (no @testing-library/react in this repo). */ +let container: HTMLDivElement; +let root: Root; + +function render(ui: React.ReactElement) { + container = document.createElement('div'); + document.body.appendChild(container); + act(() => { + root = createRoot(container); + root.render(ui); + }); +} + +function unmount() { + act(() => { root.unmount(); }); + container.remove(); +} + +function click(el: Element | null) { + if (!el) throw new Error('element not found'); + act(() => { (el as HTMLElement).dispatchEvent(new MouseEvent('click', { bubbles: true })); }); +} + +function setValue(input: HTMLInputElement | HTMLSelectElement, value: string) { + const proto = input instanceof HTMLSelectElement ? window.HTMLSelectElement.prototype : window.HTMLInputElement.prototype; + const setter = Object.getOwnPropertyDescriptor(proto, 'value')!.set!; + setter.call(input, value); + input.dispatchEvent(new Event('input', { bubbles: true })); + input.dispatchEvent(new Event('change', { bubbles: true })); +} + +function blur(el: Element) { + act(() => { el.dispatchEvent(new FocusEvent('blur', { bubbles: true })); el.dispatchEvent(new FocusEvent('focusout', { bubbles: true })); }); +} + +function q(testId: string): T | null { + return container.querySelector(`[data-testid="${testId}"]`); +} + +/* ---------- NumericUnitInput / parseCssLength ---------- */ +describe('parseCssLength', () => { + test('parses "16px" into number 16 + unit px', () => { + expect(parseCssLength('16px')).toEqual({ num: '16', unit: 'px' }); + }); + test('parses "50%" into number 50 + unit %', () => { + expect(parseCssLength('50%', ['px', '%'])).toEqual({ num: '50', unit: '%' }); + }); + test('treats "auto" and "" as empty', () => { + expect(parseCssLength('auto')).toEqual({ num: '', unit: 'px' }); + expect(parseCssLength('')).toEqual({ num: '', unit: 'px' }); + expect(parseCssLength(undefined)).toEqual({ num: '', unit: 'px' }); + }); + test('falls back to the first configured unit for an unrecognized unit', () => { + expect(parseCssLength('16vh', ['px', '%'])).toEqual({ num: '16', unit: 'px' }); + }); +}); + +describe('NumericUnitInput', () => { + test('renders the parsed number and unit from a "16px" value', () => { + render(); + expect(q('numeric-unit-number')!.value).toBe('16'); + expect(q('numeric-unit-unit')!.value).toBe('px'); + unmount(); + }); + + test('changing the number emits the recombined string', () => { + const onChange = vi.fn(); + render(); + setValue(q('numeric-unit-number')!, '32'); + expect(onChange).toHaveBeenCalledWith('32px'); + unmount(); + }); + + test('changing the unit re-emits with the current number', () => { + const onChange = vi.fn(); + render(); + setValue(q('numeric-unit-unit')!, '%'); + expect(onChange).toHaveBeenCalledWith('16%'); + unmount(); + }); + + test('clearing the number emits empty string, not "0px"', () => { + const onChange = vi.fn(); + render(); + setValue(q('numeric-unit-number')!, ''); + expect(onChange).toHaveBeenCalledWith(''); + unmount(); + }); + + test('omits the unit is + omitted entirely when only one unit is passed. */ +export function parseCssLength(value: string | undefined, units: string[] = ['px', '%']): { num: string; unit: string } { + if (!value || value === 'auto') return { num: '', unit: units[0] }; + const m = String(value).trim().match(/^(-?\d*\.?\d+)\s*([a-zA-Z%]*)$/); + if (!m) return { num: '', unit: units[0] }; + const unit = m[2] || units[0]; + return { num: m[1], unit: units.includes(unit) ? unit : units[0] }; +} + +export interface NumericUnitInputProps { + /** CSS length string, e.g. "16px" / "50%" / "auto" / "". */ + value: string; + /** Called with the recombined string, e.g. "16px". Called with '' when the + * number field is cleared. */ + onChange: (value: string) => void; + /** Allowed unit list; default ['px', '%']. Unit emit(e.target.value, unit)} + style={{ ...inputStyle, flex: 1 }} + /> + {units.length > 1 && ( + + )} + + ); +}; + +/* ---------- SizeControl ---------- + Width (or height) editor: preset buttons (25/50/75/100%, Auto, Full) + + a custom NumericUnitInput. One dimension per instance -- render two + (label="Width" / label="Height") for components needing both, e.g. + ImageBlock / VideoBlock sizing. */ +export interface SizeControlProps { + label: string; + /** CSS value for this one dimension, e.g. "100%" / "400px" / "auto" / "". */ + value: string; + onChange: (value: string) => void; + presets?: { label: string; value: string }[]; + units?: string[]; +} +export const SizeControl: React.FC = ({ label, value, onChange, presets = SIZE_PRESETS, units = ['px', '%'] }) => ( +
+ {label} + +
+ p.value === value) ? '' : value} + onChange={onChange} + units={units} + testId="size-control-custom" + /> +
+
+); + +/* ---------- AspectRatioControl ---------- + Preset buttons + a custom "W:H" text entry. Emits a CSS `aspect-ratio` + value, e.g. "16 / 9". 'Original' emits ''. Custom entry accepts + "16:9" / "16/9" / "16x9" and is parsed on blur or Enter. */ +export function parseAspectRatioInput(raw: string): string { + const m = raw.trim().match(/^(\d+(?:\.\d+)?)\s*[:xX/]\s*(\d+(?:\.\d+)?)$/); + if (!m) return ''; + return `${m[1]} / ${m[2]}`; +} +function formatAspectRatioForInput(value: string): string { + const m = value.match(/^(\d+(?:\.\d+)?)\s*\/\s*(\d+(?:\.\d+)?)$/); + return m ? `${m[1]}:${m[2]}` : ''; +} +export interface AspectRatioControlProps { + /** CSS `aspect-ratio` value, e.g. "16 / 9", or '' for Original/unset. */ + value: string; + onChange: (value: string) => void; + presets?: { label: string; value: string }[]; +} +export const AspectRatioControl: React.FC = ({ value, onChange, presets = ASPECT_RATIOS }) => { + const [custom, setCustom] = useState(() => formatAspectRatioForInput(value)); + + const applyCustom = () => onChange(parseAspectRatioInput(custom)); + + return ( +
+ Aspect Ratio + { onChange(v); setCustom(''); }} /> +
+ setCustom(e.target.value)} + onBlur={applyCustom} + onKeyDown={(e) => { if (e.key === 'Enter') applyCustom(); }} + style={{ ...inputStyle }} + /> +
+
+ ); +}; + +/* ---------- FocalPointGrid ---------- + 3x3 grid of buttons mapping to CSS `object-position` keyword pairs. + Used for image crop framing (object-position on an ImageBlock/VideoBlock + with object-fit: cover). Highlights the active cell; defaults the + highlight to 'center' when value is empty. */ +export const FOCAL_POINTS: { label: string; value: string }[] = [ + { label: '↖', value: 'left top' }, { label: '↑', value: 'center top' }, { label: '↗', value: 'right top' }, + { label: '←', value: 'left center' }, { label: '•', value: 'center' }, { label: '→', value: 'right center' }, + { label: '↙', value: 'left bottom' }, { label: '↓', value: 'center bottom' }, { label: '↘', value: 'right bottom' }, +]; +export interface FocalPointGridProps { + /** CSS `object-position` value, e.g. "center" / "left top". */ + value: string; + onChange: (value: string) => void; +} +export const FocalPointGrid: React.FC = ({ value, onChange }) => { + const active = value || 'center'; + return ( +
+ Focal Point +
+ {FOCAL_POINTS.map((p) => ( + + ))} +
+
+ ); +}; + +/* ---------- SpacingControl ---------- + Margin OR padding, per-side, with a link/unlink toggle. + API: onChange(side, value) -- called once per side. In linked mode + (default), editing the single shared field or clicking a preset calls + onChange for all four sides ('top','right','bottom','left') with the + same value so the consumer can setPropStyle each side independently + (e.g. marginTop/marginRight/marginBottom/marginLeft). In unlinked mode + each side's NumericUnitInput calls onChange for just that side. */ +export type SpacingSide = 'top' | 'right' | 'bottom' | 'left'; +export interface SpacingSideValues { top?: string; right?: string; bottom?: string; left?: string; } +const SPACING_SIDES: SpacingSide[] = ['top', 'right', 'bottom', 'left']; +export interface SpacingControlProps { + label: string; + value: SpacingSideValues; + /** Called per-side. Linked edits/presets call this once for each of the + * 4 sides (in top/right/bottom/left order) with the same value. */ + onChange: (side: SpacingSide, value: string) => void; + presets?: { label: string; value: string }[]; + units?: string[]; +} +export const SpacingControl: React.FC = ({ label, value, onChange, presets = SPACING_PRESETS, units = ['px', '%', 'em', 'rem'] }) => { + const [linked, setLinked] = useState(true); + + const applyToAllSides = (v: string) => SPACING_SIDES.forEach((s) => onChange(s, v)); + const linkedValue = value.top ?? ''; + const isAllEqual = SPACING_SIDES.every((s) => (value[s] ?? '') === linkedValue); + + return ( +
+
+ {label} + +
+ + {linked ? ( +
+ +
+ ) : ( +
+ {SPACING_SIDES.map((s) => ( +
+ + onChange(s, v)} units={units} testId={`spacing-${s}`} /> +
+ ))} +
+ )} +
+ ); +}; + +/* ---------- BorderControl ---------- + Width (px) + style (none/solid/dashed/dotted) + color. Single onChange + receives the merged { width, style, color } object so the consumer can + either setProp each part or build a shorthand via buildBorderShorthand() + (returns 'none' when width is falsy or style is 'none'). */ +export interface BorderValue { width: string; style: string; color: string; } +export interface BorderControlProps { + value: BorderValue; + onChange: (value: BorderValue) => void; + label?: string; +} +export const BorderControl: React.FC = ({ value, onChange, label = 'Border' }) => { + const set = (patch: Partial) => onChange({ ...value, ...patch }); + return ( +
+ {label} +
+
+ set({ width: v })} units={['px']} placeholder="0" testId="border-width" /> +
+ +
+ set({ color: v })} /> +
+ ); +}; +export function buildBorderShorthand(value: BorderValue): string { + if (!value.width || !value.style || value.style === 'none') return 'none'; + return `${value.width} ${value.style} ${value.color || '#000000'}`; +} + +/* ---------- AnimationControl ---------- + "Entrance animation" picker + optional delay. Emits the EXACT prop names + the export machinery already consumes (verified against buildDataAttrs() + in src/utils/html-export.ts): `animation` and `animationDelay`. + NOTE: the export supports fade-in / slide-up / slide-left / slide-right / + zoom-in / bounce (no "slide-down" -- html-export.ts has no such variant; + slide-left/slide-right exist instead), so ANIMATIONS reflects that exact + set rather than guessing. animationDelay is a plain seconds string (e.g. + "0.3") -- html-export.ts's inline script assigns it straight to + el.style.animationDelay, so consumers should suffix/accept a unit-bearing + string like "0.3s" if that's what they choose to store; this control + stores/edits the raw numeric string and leaves unit formatting to the + caller (kept as the simplest contract; see report for the exact + behavior verified against html-export.ts). */ +export const ANIMATIONS: { label: string; value: string }[] = [ + { label: 'None', value: 'none' }, + { label: 'Fade In', value: 'fade-in' }, + { label: 'Slide Up', value: 'slide-up' }, + { label: 'Slide Left', value: 'slide-left' }, + { label: 'Slide Right', value: 'slide-right' }, + { label: 'Zoom In', value: 'zoom-in' }, + { label: 'Bounce', value: 'bounce' }, +]; +export interface AnimationValue { animation: string; animationDelay?: string; } +export interface AnimationControlProps { + value: AnimationValue; + onChange: (value: AnimationValue) => void; +} +export const AnimationControl: React.FC = ({ value, onChange }) => { + const animation = value.animation || 'none'; + const animationDelay = value.animationDelay ?? '0'; + return ( +
+ Entrance Animation + onChange({ animation: v, animationDelay })} /> + {animation !== 'none' && ( +
+ + onChange({ animation, animationDelay: e.target.value })} + style={inputStyle} + /> +
+ )} +
+ ); +}; + +/* ---------- VisibilityControl ---------- + Hide on Desktop / Tablet / Mobile toggles. Emits the EXACT prop names + the export machinery already consumes (verified against buildDataAttrs() + in src/utils/html-export.ts): `hideOnDesktop`, `hideOnTablet`, + `hideOnMobile` (each rendered as data-hide-desktop/tablet/mobile when + truthy). Single onChange receives the merged object. */ +export interface VisibilityValue { hideOnDesktop?: boolean; hideOnTablet?: boolean; hideOnMobile?: boolean; } +export interface VisibilityControlProps { + value: VisibilityValue; + onChange: (value: VisibilityValue) => void; +} +const VISIBILITY_KEYS: (keyof VisibilityValue)[] = ['hideOnDesktop', 'hideOnTablet', 'hideOnMobile']; +export const VisibilityControl: React.FC = ({ value, onChange }) => ( +
+ Visibility +
+ {VISIBILITY_KEYS.map((key) => ( + + ))} +
+
+);