It was `<input type="color">`, and the browser's own picker is a window-level popup positioned by the browser. In a panel near the right edge it opened off the side of the window and could not be reached — and nothing in CSS can move it, because it is not in the document. Swatches and a hex field instead, in a panel anchored to the RIGHT of its trigger so it grows inward and cannot leave the screen the same way. Deliberately not a gradient area and a hue strip. That is what the native control does well, and rebuilding it badly to fix a positioning problem would be a worse control than the one being replaced. Text over a photograph is white or black almost every time; the hex field is there for the times it is not. Closes on `pointerdown` anywhere else rather than `click`, so it closes on the way down and a press meant for something behind it does not land twice. Captured, because a control below may stop propagation for its own reasons. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
116 lines
4.0 KiB
TypeScript
116 lines
4.0 KiB
TypeScript
import { useEffect, useRef, useState } from 'react';
|
|
import { Input } from '@/components/ui/input';
|
|
|
|
/**
|
|
* A colour, chosen without leaving the panel.
|
|
*
|
|
* ── Why not `<input type="color">` ──
|
|
*
|
|
* It was one, and the browser's own picker is a window-level popup positioned by the browser. In a
|
|
* panel near the right edge it opened off the side of the window and was simply unreachable — nothing
|
|
* in CSS can move it, because it is not in the document.
|
|
*
|
|
* ── What replaces it ──
|
|
*
|
|
* Swatches and a hex field, in a panel anchored to the RIGHT of its trigger so it grows inward. Text
|
|
* over a photograph is white or black almost every time; the field is there for the times it is not.
|
|
*
|
|
* Deliberately not a gradient area and a hue strip. That is what the native control already does well,
|
|
* and rebuilding it badly to fix a positioning problem would be a worse control than the one being
|
|
* replaced.
|
|
*/
|
|
|
|
/** Enough to cover a photograph, and short enough to read as choices rather than as a palette. */
|
|
const PRESETS = [
|
|
'#ffffff',
|
|
'#f5f5f5',
|
|
'#cccccc',
|
|
'#888888',
|
|
'#333333',
|
|
'#000000',
|
|
'#f0c808',
|
|
'#e05a3a',
|
|
];
|
|
|
|
export const ColorField = ({ value, onChange }: { value: string; onChange: (color: string) => void }) => {
|
|
const [open, setOpen] = useState(false);
|
|
const [draft, setDraft] = useState(value);
|
|
const wrapper = useRef<HTMLDivElement | null>(null);
|
|
|
|
// Follows the value when it changes elsewhere — a different block being selected, or a load.
|
|
useEffect(() => setDraft(value), [value]);
|
|
|
|
/*
|
|
Close on a press anywhere else.
|
|
|
|
`pointerdown` rather than `click`, so it closes on the way down and a press meant for something
|
|
behind the panel does not land twice. Capture, because a control inside a panel below may stop
|
|
propagation for its own reasons.
|
|
*/
|
|
useEffect(() => {
|
|
if (!open) return;
|
|
const onDown = (event: PointerEvent) => {
|
|
if (!wrapper.current?.contains(event.target as Node)) setOpen(false);
|
|
};
|
|
document.addEventListener('pointerdown', onDown, true);
|
|
return () => document.removeEventListener('pointerdown', onDown, true);
|
|
}, [open]);
|
|
|
|
const commit = (next: string) => {
|
|
const trimmed = next.trim();
|
|
const full = /^#?[0-9a-fA-F]{6}$/.test(trimmed) ? `#${trimmed.replace('#', '')}`.toLowerCase() : null;
|
|
if (full) onChange(full);
|
|
};
|
|
|
|
return (
|
|
<div ref={wrapper} className="relative shrink-0">
|
|
<button
|
|
type="button"
|
|
onClick={() => setOpen((was) => !was)}
|
|
className="size-8 rounded border border-input p-0.5"
|
|
title="Colour"
|
|
>
|
|
{/* The swatch is the whole control's state. A checkered backdrop is not needed — these are
|
|
always opaque, because text with an alpha over a photograph is unreadable by definition. */}
|
|
<span className="block size-full rounded-sm" style={{ backgroundColor: value }} />
|
|
</button>
|
|
|
|
{open && (
|
|
<div className="absolute top-9 right-0 z-50 w-52 rounded-md border border-border bg-background p-2 shadow-lg">
|
|
<div className="grid grid-cols-8 gap-1">
|
|
{PRESETS.map((preset) => (
|
|
<button
|
|
key={preset}
|
|
type="button"
|
|
onClick={() => {
|
|
onChange(preset);
|
|
setOpen(false);
|
|
}}
|
|
className={`size-5 rounded border ${
|
|
value.toLowerCase() === preset ? 'border-primary ring-1 ring-primary' : 'border-border'
|
|
}`}
|
|
style={{ backgroundColor: preset }}
|
|
title={preset}
|
|
/>
|
|
))}
|
|
</div>
|
|
|
|
<Input
|
|
value={draft}
|
|
onChange={(event) => setDraft(event.currentTarget.value)}
|
|
onBlur={() => commit(draft)}
|
|
onKeyDown={(event) => {
|
|
if (event.key === 'Enter') {
|
|
commit(draft);
|
|
setOpen(false);
|
|
}
|
|
}}
|
|
placeholder="#ffffff"
|
|
className="mt-2 h-7 font-mono text-xs"
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|