the colour picker stays inside the panel

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>
This commit is contained in:
2026-08-31 00:39:12 +01:00
co-authored by Claude Opus 5
parent b3032093fe
commit e0a5f1b653
2 changed files with 119 additions and 9 deletions
+115
View File
@@ -0,0 +1,115 @@
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>
);
};
+4 -9
View File
@@ -2,6 +2,7 @@ import { useRef, useState } from 'react';
import { Plus, Trash2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { ColorField } from './ColorField';
/**
* Text laid over a picture, positioned by dragging it.
@@ -225,15 +226,9 @@ export const OverlayEditor = ({
<option value="right">Right</option>
</select>
{/* A native colour input: it is the one control every platform already knows how to draw,
and a hand-rolled picker here would be worse on every one of them. */}
<input
type="color"
value={overlay.color}
onChange={(event) => update(index, { color: event.currentTarget.value })}
className="size-8 shrink-0 cursor-pointer rounded border border-input bg-transparent p-0.5"
title="Colour"
/>
{/* Not `<input type="color">`. The browser's picker is a window-level popup and opened off
the side of the window in a panel near the right edge — see ColorField. */}
<ColorField value={overlay.color} onChange={(color) => update(index, { color })} />
<Button
size="sm"