Files
hyperframes/packages/studio/src/components/editor/propertyPanelFlatSelectRow.tsx
T
Vance Ingalls 28304be74a fix(studio): cancel FlatSlider drags on Escape and right-click
A fresh full-stack re-review checked 15 PR heads independently. Cross-
checked all 9 remaining claims against the actual current tip:

- #2120 (id/selector key qualification, Hide All no-op/race, variable
  parity), #2121 (opacity-zero fallback), #2122 (GSAP preview sibling
  resolution, scrub-label), #2124/#2126 (negative metadata cache), and
  the keyboard-access half of #2121/#2186 were all already fixed by a
  later commit in this same stack (65954c380, PR #2225) — the reviewed
  heads predate it. Verified each in the current source rather than
  taking the isolated-head review at face value.

- #2186's "no ESC/right-click cancel during drag" was the one claim that
  held up: FlatSlider had keyboard arrow-key support but no way to abort
  an in-progress pointer drag. Escape now reverts to the pre-drag value
  and releases pointer capture; a right-click (contextmenu) during a drag
  does the same instead of committing whatever position the pointer last
  reached while the native context menu opens over the slider. Both go
  through commitDraft (not just a visual reset) since the drag's leading-
  edge commit in onPointerDown may already have applied an intermediate
  value that needs actually undoing, not just hiding.

propertyPanelFlatPrimitives.tsx crossed the 600-line file-size gate after
this change; extracted FlatSelectRow into its own file, matching the
FlatToggle/FlatMaskInsetRows precedent from earlier in this stack.

New regression tests for Escape-cancel and contextmenu-cancel. Full
studio suite still at the known pre-existing 55-failure baseline, zero
new regressions. Typecheck/oxlint/oxfmt clean.
2026-07-14 16:28:33 -07:00

93 lines
3.4 KiB
TypeScript

import { RotateCcw } from "../../icons/SystemIcons";
import {
VALUE_TIER_LABEL_CLASS,
VALUE_TIER_VALUE_CLASS,
type PropertyValueTier,
} from "./propertyPanelValueTier";
/* ------------------------------------------------------------------ */
/* FlatSelectRow — label/value row backed by a native <select> */
/* ------------------------------------------------------------------ */
export function FlatSelectRow({
label,
ariaLabel,
value,
options,
tier,
disabled,
onChange,
onReset,
}: {
label: string;
/** Accessible name when a caller renders the visible label OUTSIDE this
* row (label="" to avoid a duplicate) — e.g. Grade's "Preset" row, which
* shows its own label span and would otherwise leave the <select>
* unnamed. Falls back to `label` when omitted. */
ariaLabel?: string;
value: string;
options: Array<string | { value: string; label: string }>;
tier: PropertyValueTier;
disabled?: boolean;
onChange: (nextValue: string) => void;
onReset?: () => void;
}) {
const normalizedOptions = options.map((option) =>
typeof option === "string" ? { value: option, label: option } : option,
);
// A valid authored value outside the preset list (e.g. a `mix-blend-mode`
// or `object-position` this row doesn't offer as a preset) must not be
// silently misrepresented as the first option — the native <select> falls
// back to selectedIndex 0 when `value` matches no <option>, and reselecting
// that visible-but-wrong preset overwrites the real persisted value. Prepend
// the current value so it's always representable, matching legacy
// `SelectField`'s same guard.
const renderedOptions =
value && !normalizedOptions.some((option) => option.value === value)
? [{ value, label: value }, ...normalizedOptions]
: normalizedOptions;
return (
<div className="group flex min-h-[30px] items-center justify-between">
<span className={`text-[11px] ${VALUE_TIER_LABEL_CLASS[tier]}`}>{label}</span>
<span className="flex items-center gap-2">
<label className="flex items-center gap-1.5">
<select
value={value}
disabled={disabled}
aria-label={ariaLabel || label || undefined}
onChange={(e) => onChange(e.target.value)}
className={`appearance-none bg-transparent text-right font-mono text-[11px] outline-none disabled:cursor-not-allowed ${VALUE_TIER_VALUE_CLASS[tier]}`}
>
{renderedOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
<svg
width="10"
height="10"
viewBox="0 0 10 10"
fill="currentColor"
className="flex-shrink-0 text-panel-text-5"
>
<path d="M2 3l3 4 3-4z" />
</svg>
</label>
{tier === "explicitCustom" && onReset && (
<button
type="button"
data-flat-select-reset="true"
title="Remove — fall back to default"
disabled={disabled}
onClick={onReset}
className="flex-shrink-0 text-panel-text-3 opacity-0 transition-opacity hover:text-panel-text-1 group-hover:opacity-100 disabled:cursor-not-allowed disabled:opacity-40"
>
<RotateCcw size={11} />
</button>
)}
</span>
</div>
);
}