142 lines
5.2 KiB
TypeScript
142 lines
5.2 KiB
TypeScript
|
|
import React, { useState } from 'react';
|
||
|
|
|
||
|
|
export interface SpacingValue {
|
||
|
|
top: string;
|
||
|
|
right: string;
|
||
|
|
bottom: string;
|
||
|
|
left: string;
|
||
|
|
}
|
||
|
|
|
||
|
|
interface SpacingInputProps {
|
||
|
|
label: string;
|
||
|
|
value: SpacingValue;
|
||
|
|
onChange: (value: SpacingValue) => void;
|
||
|
|
}
|
||
|
|
|
||
|
|
const UNITS = ['px', 'em', '%'] as const;
|
||
|
|
|
||
|
|
const labelStyle: React.CSSProperties = {
|
||
|
|
fontSize: 11, fontWeight: 600, color: '#a1a1aa', display: 'block', marginBottom: 6,
|
||
|
|
textTransform: 'uppercase', letterSpacing: '0.3px',
|
||
|
|
};
|
||
|
|
|
||
|
|
const inputStyle: React.CSSProperties = {
|
||
|
|
width: '100%', padding: '4px 6px', background: '#27272a', color: '#e4e4e7',
|
||
|
|
border: '1px solid #3f3f46', borderRadius: 4, fontSize: 11, textAlign: 'center',
|
||
|
|
};
|
||
|
|
|
||
|
|
const sideLabel: React.CSSProperties = {
|
||
|
|
fontSize: 9, color: '#71717a', textAlign: 'center', marginTop: 2, textTransform: 'uppercase',
|
||
|
|
letterSpacing: '0.5px',
|
||
|
|
};
|
||
|
|
|
||
|
|
function parseValue(val: string): { num: string; unit: string } {
|
||
|
|
const match = val.match(/^(-?\d*\.?\d+)\s*(px|em|%|rem)?$/);
|
||
|
|
if (match) return { num: match[1], unit: match[2] || 'px' };
|
||
|
|
return { num: val.replace(/[^0-9.-]/g, '') || '0', unit: 'px' };
|
||
|
|
}
|
||
|
|
|
||
|
|
export const SpacingInput: React.FC<SpacingInputProps> = ({ label, value, onChange }) => {
|
||
|
|
const [linked, setLinked] = useState(false);
|
||
|
|
const [unit, setUnit] = useState<string>(() => parseValue(value.top).unit || 'px');
|
||
|
|
|
||
|
|
const handleSideChange = (side: keyof SpacingValue, raw: string) => {
|
||
|
|
const numericPart = raw.replace(/[^0-9.-]/g, '');
|
||
|
|
const newVal = numericPart ? `${numericPart}${unit}` : '0';
|
||
|
|
|
||
|
|
if (linked) {
|
||
|
|
onChange({ top: newVal, right: newVal, bottom: newVal, left: newVal });
|
||
|
|
} else {
|
||
|
|
onChange({ ...value, [side]: newVal });
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
const handleUnitChange = (newUnit: string) => {
|
||
|
|
setUnit(newUnit);
|
||
|
|
// Re-apply current numeric values with new unit
|
||
|
|
const updated: SpacingValue = { top: '', right: '', bottom: '', left: '' };
|
||
|
|
for (const side of ['top', 'right', 'bottom', 'left'] as const) {
|
||
|
|
const { num } = parseValue(value[side]);
|
||
|
|
updated[side] = num && num !== '0' ? `${num}${newUnit}` : '0';
|
||
|
|
}
|
||
|
|
onChange(updated);
|
||
|
|
};
|
||
|
|
|
||
|
|
const sides: { key: keyof SpacingValue; label: string }[] = [
|
||
|
|
{ key: 'top', label: 'T' },
|
||
|
|
{ key: 'right', label: 'R' },
|
||
|
|
{ key: 'bottom', label: 'B' },
|
||
|
|
{ key: 'left', label: 'L' },
|
||
|
|
];
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div style={{ marginBottom: 12 }}>
|
||
|
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 6 }}>
|
||
|
|
<label style={{ ...labelStyle, marginBottom: 0 }}>{label}</label>
|
||
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||
|
|
{/* Unit selector */}
|
||
|
|
<div style={{ display: 'flex', gap: 2 }}>
|
||
|
|
{UNITS.map((u) => (
|
||
|
|
<button
|
||
|
|
key={u}
|
||
|
|
onClick={() => handleUnitChange(u)}
|
||
|
|
style={{
|
||
|
|
padding: '1px 5px', fontSize: 9, borderRadius: 3, cursor: 'pointer',
|
||
|
|
border: '1px solid #3f3f46',
|
||
|
|
background: unit === u ? '#3b82f6' : '#27272a',
|
||
|
|
color: unit === u ? '#fff' : '#71717a',
|
||
|
|
}}
|
||
|
|
>{u}</button>
|
||
|
|
))}
|
||
|
|
</div>
|
||
|
|
{/* Link toggle */}
|
||
|
|
<button
|
||
|
|
onClick={() => setLinked(!linked)}
|
||
|
|
title={linked ? 'Unlink sides' : 'Link all sides'}
|
||
|
|
style={{
|
||
|
|
padding: '2px 6px', fontSize: 11, borderRadius: 3, cursor: 'pointer',
|
||
|
|
border: '1px solid #3f3f46',
|
||
|
|
background: linked ? '#3b82f6' : '#27272a',
|
||
|
|
color: linked ? '#fff' : '#71717a',
|
||
|
|
}}
|
||
|
|
>
|
||
|
|
<i className={`fa ${linked ? 'fa-link' : 'fa-unlink'}`} style={{ fontSize: 9 }} />
|
||
|
|
</button>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr 1fr', gap: 4 }}>
|
||
|
|
{sides.map((s) => (
|
||
|
|
<div key={s.key}>
|
||
|
|
<input
|
||
|
|
type="text"
|
||
|
|
value={parseValue(value[s.key]).num}
|
||
|
|
onChange={(e) => handleSideChange(s.key, e.target.value)}
|
||
|
|
style={inputStyle}
|
||
|
|
/>
|
||
|
|
<div style={sideLabel}>{s.label}</div>
|
||
|
|
</div>
|
||
|
|
))}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
};
|
||
|
|
|
||
|
|
/** Parse a CSS shorthand like "10px 20px 10px 20px" or "10px" into SpacingValue */
|
||
|
|
export function parseSpacingShorthand(val: string | undefined): SpacingValue {
|
||
|
|
if (!val) return { top: '0', right: '0', bottom: '0', left: '0' };
|
||
|
|
const parts = val.trim().split(/\s+/);
|
||
|
|
if (parts.length === 1) return { top: parts[0], right: parts[0], bottom: parts[0], left: parts[0] };
|
||
|
|
if (parts.length === 2) return { top: parts[0], right: parts[1], bottom: parts[0], left: parts[1] };
|
||
|
|
if (parts.length === 3) return { top: parts[0], right: parts[1], bottom: parts[2], left: parts[1] };
|
||
|
|
return { top: parts[0], right: parts[1], bottom: parts[2], left: parts[3] };
|
||
|
|
}
|
||
|
|
|
||
|
|
/** Convert SpacingValue back to CSS shorthand */
|
||
|
|
export function spacingToShorthand(val: SpacingValue): string {
|
||
|
|
const { top, right, bottom, left } = val;
|
||
|
|
if (top === right && right === bottom && bottom === left) return top || '0';
|
||
|
|
if (top === bottom && right === left) return `${top} ${right}`;
|
||
|
|
if (right === left) return `${top} ${right} ${bottom}`;
|
||
|
|
return `${top} ${right} ${bottom} ${left}`;
|
||
|
|
}
|