Skip to content

Commit 643f420

Browse files
committed
Select: redesign as a macOS pop-up button with an over-the-trigger glass menu
Reshapes the house `Select` dropdown (settings, viewer, transfer, debug all render through it) to match a native macOS pop-up button, the look David and his designer landed on for simple selects. - Trigger is a borderless pill that hugs its content: value text + a round chevron stepper (`chevrons-up-down`), replacing the full-width bordered box with the `▼`. On hover/open the whole trigger fills with the stepper's color, so it reads as one uniform pill (the macOS behavior); the chevrons use the brighter primary text color. - Menu opens *over* the trigger, macOS-style: the currently-selected row's label lands on the trigger value, measured and applied as a `transform` on the content (a child of zag's positioner, so it never fights zag's own placement), clamped to the viewport. The geometry is the pure, unit-tested `computeOverlapShift` in `select-positioning.ts`. - Menu is a frosted-glass surface (shared `--color-bg-glass` tokens with tooltips / filter-chip popovers; blur drops under `html.reduce-transparency`). The checkmark marks the current value on the LEFT; the accent fill follows the keyboard/pointer highlight, so a checked-but-not-highlighted row is plain — distinct from the old "checked = accent bg". - Reveal is driven by the open state through an `$effect` (with a rAF retry loop and a `setTimeout` fallback), NOT zag's `onPositioned`, which never fires in zag 1.41.x and would otherwise leave the menu stuck invisible. - New `portal` prop (default off) teleports the open menu to `document.body` so it escapes the settings page's masked, scrolling content wrapper (an ancestor `mask-image` fades descendants regardless of z-index, which left the top rows shaded and un-clickable). `SettingSelect` opts in; the viewer's pickers stay non-portaled per their restricted capability set. Keyboard navigation, ARIA, and the `.select-*` class contract (incl. the a11y-contrast checker's `dropdown_states.go` matrix) are preserved. Verified live (mouse + keyboard) via the Tauri MCP.
1 parent 3a2809b commit 643f420

7 files changed

Lines changed: 459 additions & 118 deletions

File tree

apps/desktop/src/lib/settings/components/SettingSelect.svelte

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,7 @@
176176
onHighlightChange={handleHighlightChange}
177177
contentClass={customHighlighted ? 'custom-highlighted' : ''}
178178
ariaLabel={label}
179+
portal
179180
{disabled}
180181
/>
181182
{/if}

apps/desktop/src/lib/ui/DETAILS.md

Lines changed: 36 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -240,17 +240,47 @@ Props:
240240
- `disabled?`, `placeholder?` (default `Select...`), `ariaLabel` (lands on the trigger).
241241
- `contentClass?: string` — extra class on the `.select-content` element (`SettingSelect` sets `custom-highlighted` to
242242
suppress the checked state on other items while its "Custom…" row is highlighted).
243+
- `portal?: boolean` (default `false`) — teleport the open menu to `document.body`. See "Portal" below.
244+
245+
**macOS pop-up-button look.** The trigger is borderless and hugs its content: value text + a rounded chevron square
246+
(`chevrons-up-down`), not a full-width bezel. The menu is a frosted-glass surface (shared `--color-bg-glass` /
247+
`--color-border-glass` tokens with tooltips and filter-chip popovers; blur dropped under `html.reduce-transparency`).
248+
The checkmark marks the current value on the LEFT (`.select-item-text` is the flex label cell after it); the accent fill
249+
follows the keyboard / pointer highlight (`[data-highlighted]`), so a checked-but-not-highlighted row is plain with just
250+
its checkmark — matching macOS, and distinct from the old "checked = accent bg" behavior.
251+
252+
**macOS overlap positioning (the menu opens *over* the trigger).** Zag positions the *positioner* just below the trigger
253+
(`bottom-start`, `gutter: 0`, `flip: false`, `slide: true`); we then translate the *content* (a child of the positioner,
254+
so the transform never fights zag's own) to land the checked row's label on the trigger's value text, clamped to the
255+
viewport so it stays on screen. The shift is an inline `transform` on the content (`contentStyle`), which does NOT
256+
trigger a zag reposition, so there's no feedback loop. The geometry is the pure, unit-tested `computeOverlapShift`
257+
(`select-positioning.ts`); `Select.svelte` only measures rects and applies the result.
258+
259+
The reveal is driven by the open state through an `$effect` on `isOpen` (set from `onOpenChange`), NOT zag's
260+
`onPositioned` — that callback never fires in this zag version (1.41.x), which is why an earlier `onPositioned`-only wiring
261+
left the menu stuck at `opacity: 0`. The effect retries the measurement across a few `requestAnimationFrame`s (content
262+
mounts and zag places it asynchronously after open) and a `setTimeout` fallback guarantees the content can never stay
263+
invisible if rAF is throttled (unfocused window) or the rows aren't found. The measurement is self-correcting (it folds
264+
the residual gap into the already-applied shift). Content is `opacity: 0` until the first measurement lands, so it never
265+
flashes at the default below-trigger spot. The measurement reads the trigger value via `rootEl.querySelector` (always in
266+
the subtree) and the content via its own `bind:ref` (`contentEl`), so it works whether or not the menu is portaled.
267+
268+
**Portal (`portal` prop).** Because the menu opens *over* the trigger, a bottom-of-list selection pushes the top rows
269+
well above the trigger — into whatever chrome sits there. When the menu isn't portaled it's a descendant of its scroll
270+
container, so an ancestor `overflow` clips it and, worse, an ancestor `mask-image` fades its top rows regardless of
271+
z-index (no z-index escapes an ancestor mask). The settings page's `.settings-content-wrapper` has both, which left the
272+
top rows shaded and un-clickable. `portal` teleports the `Positioner` (via Ark's `Portal`) to `document.body` so the
273+
menu floats above all of it, macOS-style; zag still anchors to the trigger, and the design tokens live on `:root` so
274+
body-level content keeps full theming. `SettingSelect` sets `portal`. **Leave it `false` in the viewer window**, whose
275+
restricted capability set assumes no portal-to-body (`ViewModePicker` / `EncodingPicker`); `Combobox` is non-portaled
276+
for the same reason.
243277

244278
**Stable class contract (load-bearing, don't rename):** `.select-trigger`, `.select-item`, `.select-content`,
245279
`.option-description`. `SettingSelect`'s `handleCustomSubmit` focuses `.select-trigger` via `querySelector`, and the
246280
a11y-contrast checker (`scripts/check-a11y-contrast/dropdown_states.go`) keys on the literal
247281
`.select-item[data-highlighted] .option-description` selector + the `--color-accent` / `--color-accent-fg` tokens. The
248-
highlighted / checked item colors must stay on those accent tokens or the contrast matrix breaks. The styling moved here
249-
verbatim from `SettingSelect`.
250-
251-
Standardized Lucide `chevron-down` indicator (16px, real hit-area) replaces the old tiny `` glyph. No `Portal` (the
252-
viewer's restricted capability set depends on no portal-to-body). No entrance animation (matches the old `SettingSelect`
253-
behavior); any future polish anim must gate behind `prefers-reduced-motion`.
282+
highlighted item colors must stay on those accent tokens or the contrast matrix breaks. No entrance animation; any future
283+
polish anim must gate behind `prefers-reduced-motion`.
254284

255285
## Combobox
256286

0 commit comments

Comments
 (0)