mirror of
https://github.com/remorses/gpuix.git
synced 2026-10-29 03:04:52 +08:00
fix: layer stack owns dialog focus, drop stale pending focus
Second oracle review of 6382a80 found five issues:
1. A pending focusElement request survived Tab / clicks and stole focus
when its element appeared later. PendingFocus now records the focus at
request time and is dropped if focus moved any other way first. The
check runs before the frame's autoFocus, so an explicit request still
beats same-frame autoFocus (live and test renderer now agree).
2. React read previous focus in a layout effect; in the test renderer
that read flushes and an autoFocus could already hold focus. The
DismissableLayer now reads it during render (React) / component body
(Solid), before its elements are committed, and passes it to
pushDismissLayer({ previousFocus }).
3+4. Each Popup moved focus on its own in effect order, so nested
dialogs opened together focused the outer one and closed together
lost focus. DismissLayer gains initialFocus() / finalFocus(previous);
the host calls them in stack order: only a layer landing on top takes
focus, a parent inserted under same-commit children rewrites their
return target to itself, and a child whose parent already closed skips
its restore.
5. FocusTarget function results now accept refs.
Session: ses_f2717eddcffeXQkI5JXN8eAnFD
This commit is contained in:
1 parent
6382a80c34
commit
ef1c12e1df
10 files changed
+290
-91
No files matched your search
@@ -28,4 +28,6 @@ import * as Dialog from '@gpuix/react/dialog' // or '@gpuix/solid/dialog'
|
||||
|
||||
Escape is now a default action on one layer stack per window. Every open Dialog, Select, Combobox, and Tooltip is a layer. Escape closes only the most recently opened one, so a Select inside a Dialog closes first. It works when nothing is focused, and `event.preventDefault()` in any `onKeyDown` keeps the layer open. A layer mounted inside another stays above it, even when both open in one commit. Custom overlays join the stack with `<DismissableLayer onEscapeKeyDown>`, or `pushDismissLayer(renderer, layer)` without a framework.
|
||||
|
||||
`<anchored fill="window">` covers the whole window and follows a resize in the same frame, because native reads the viewport size during render. `Dialog.Portal` uses it, so it no longer polls the window size from JavaScript every 100 ms, and a resize never leaves an uncovered strip that clicks could reach.
|
||||
|
||||
A raw `<anchored>` with an explicit `backgroundColor: "transparent"` now paints no fill. Without any fill in its style it still paints `#1A1A1A`.
|
||||
@@ -13,3 +13,5 @@
|
||||
Each accepts `true` (default), `false` (focus stays), a ref or element, or a function that returns one of these. `initialFocus` defaults to the popup itself. `finalFocus` defaults to the trigger, else to the element focused when the popup opened, so a dialog opened from app state with no `Trigger` now returns focus on close. The Popup no longer takes `autoFocus` for this.
|
||||
|
||||
`focusElement(id)` now keeps the request until that element's focus handle exists. Before, a frame that rendered before the element's batch arrived dropped it. This happened with Solid, which applies mutations in a microtask, and on Windows and Linux, which render on their own thread. The test renderer now uses the same path.
|
||||
|
||||
Focus moves through the dismiss layer stack, so nested dialogs that open together focus the inner one, and closing both together returns focus to the outer one's target. An explicit `initialFocus` beats an `autoFocus` inside the popup. A pending `focusElement` request is dropped when focus moves any other way first (Tab, a click), so an element created later cannot steal focus.
|
||||
@@ -2160,6 +2160,17 @@ through Metal to the desktop.
|
||||
A raw `<anchored>` with no fill in its style paints `#1A1A1A`. Set
|
||||
`backgroundColor: "transparent"` to paint nothing, as `Dialog.Portal` does.
|
||||
|
||||
`fill="window"` makes an `<anchored>` cover the whole window, like
|
||||
`Dialog.Portal`. Native reads the viewport size every frame, so the layer
|
||||
follows a resize in the same frame. It ignores `position`, `side`, `align`,
|
||||
`anchor`, `offset`, and `fit`.
|
||||
|
||||
```tsx
|
||||
<anchored fill="window" style={{ backgroundColor: 'transparent' }}>
|
||||
<div style={{ position: 'absolute', top: 0, right: 0, bottom: 0, left: 0 }} />
|
||||
</anchored>
|
||||
```
|
||||
|
||||
`FloatingLayer` copies uniform and per-corner border radii to its anchored
|
||||
surface, so rounded Select, Combobox, and Tooltip content does not show square
|
||||
corners behind it. It also puts `visibility` and `opacity` on that outer surface
|
||||
@@ -2225,7 +2236,7 @@ import * as Dialog from '@gpuix/react/dialog'
|
||||
|---|---|
|
||||
| `Root` | `open`, `defaultOpen`, `onOpenChange`, `modal` (default `true`), `disablePointerDismissal` |
|
||||
| `Trigger` | Tab stop. Click, Enter, or Space opens |
|
||||
| `Portal` | Full-window deferred layer. Paints over `<virtual-list>`. Centers its children by default. Modal: blocks clicks and the wheel behind it |
|
||||
| `Portal` | Full-window deferred layer (`<anchored fill="window">`). Follows a resize in the same frame. Paints over `<virtual-list>`. Centers its children by default. Modal: blocks clicks and the wheel behind it |
|
||||
| `Backdrop` | Press closes the dialog |
|
||||
| `Popup` | Moves focus in on open and out on close. Modal: Tab and Shift+Tab stay inside |
|
||||
| `Close` | Tab stop. Click, Enter, or Space closes |
|
||||
@@ -2249,7 +2260,14 @@ tabbable element. GPUI's tab order exists only after the popup has painted,
|
||||
so GPUIX focuses the popup and lets the first Tab walk that order.
|
||||
|
||||
A dialog opened from app state, with no `Trigger`, still returns focus: the
|
||||
Popup records the focused element when it opens.
|
||||
Popup records the focused element before it mounts.
|
||||
|
||||
`initialFocus` beats an `autoFocus` inside the popup. Pass the field as
|
||||
`initialFocus` instead.
|
||||
|
||||
Nested dialogs follow the layer stack. When both open in one update, the inner
|
||||
one takes focus. When both close in one update, focus goes to the outer one's
|
||||
return target.
|
||||
|
||||
A Select or Tooltip inside the Popup opens above it, and Escape closes it first.
|
||||
|
||||
@@ -2282,8 +2300,12 @@ import { DismissableLayer } from '@gpuix/react' // or '@gpuix/solid'
|
||||
```
|
||||
|
||||
A layer mounted inside another is always above it, even when both open in the
|
||||
same commit. Framework-free code uses `pushDismissLayer(renderer, layer)` with a
|
||||
`parent` field, and calls the returned function when the layer closes.
|
||||
same commit. `DismissableLayer` also takes `initialFocus()` and
|
||||
`finalFocus(previous)`, which return an element id or `null`. The stack calls
|
||||
them in stack order, so only the top layer takes focus on open.
|
||||
|
||||
Framework-free code uses `pushDismissLayer(renderer, layer, { previousFocus })`
|
||||
with a `parent` field, and calls the returned function when the layer closes.
|
||||
|
||||
### Trap Tab inside a custom panel
|
||||
|
||||
|
||||
@@ -38,6 +38,32 @@ export interface DismissLayer {
|
||||
readonly parent?: DismissLayer
|
||||
/** Escape reached the window and no handler prevented it. */
|
||||
onEscapeKeyDown(event: KeyEvent): void
|
||||
/**
|
||||
* Element to focus when the layer opens, or null to leave focus. Called
|
||||
* only when the layer lands on top, so the innermost of several layers that
|
||||
* open together takes focus.
|
||||
*/
|
||||
initialFocus?(): number | null
|
||||
/**
|
||||
* Element to focus when the layer closes, or null to leave focus.
|
||||
* `previous` is the focus from before the layer opened. Not called when the
|
||||
* parent layer closed first, because the parent restores for both.
|
||||
*/
|
||||
finalFocus?(previous: number | null): number | null
|
||||
}
|
||||
|
||||
export interface PushLayerOptions {
|
||||
/**
|
||||
* The focused element from before this layer's elements were committed.
|
||||
* Read it before the commit: once committed, an `autoFocus` inside the
|
||||
* layer may already have taken focus.
|
||||
*/
|
||||
previousFocus?: number | null
|
||||
}
|
||||
|
||||
interface LayerEntry {
|
||||
layer: DismissLayer
|
||||
previous: number | null
|
||||
}
|
||||
|
||||
function isAncestor(ancestor: DismissLayer, layer: DismissLayer): boolean {
|
||||
@@ -53,7 +79,7 @@ export interface RendererState {
|
||||
dispatch(event: EventPayload): boolean
|
||||
current(): RendererRootBinding | undefined
|
||||
/** Open a layer. Call the returned function when it closes. */
|
||||
pushLayer(layer: DismissLayer): () => void
|
||||
pushLayer(layer: DismissLayer, options?: PushLayerOptions): () => void
|
||||
}
|
||||
|
||||
type ActiveRoot = RendererRootBinding & { handlers: RendererRootHandlers }
|
||||
@@ -68,8 +94,12 @@ interface KeystrokeFlags {
|
||||
const STATE_KEY = Symbol.for("@gpuix/native/renderer-states")
|
||||
|
||||
/** Register an open overlay on this renderer's layer stack. */
|
||||
export function pushDismissLayer(renderer: NativeRenderer, layer: DismissLayer): () => void {
|
||||
return createRendererState(renderer).pushLayer(layer)
|
||||
export function pushDismissLayer(
|
||||
renderer: NativeRenderer,
|
||||
layer: DismissLayer,
|
||||
options?: PushLayerOptions
|
||||
): () => void {
|
||||
return createRendererState(renderer).pushLayer(layer, options)
|
||||
}
|
||||
|
||||
function allStates(): WeakMap<NativeRenderer, RendererState> {
|
||||
@@ -109,7 +139,7 @@ export function createRendererState(renderer: NativeRenderer): RendererState {
|
||||
const ids = { nextElementId: 0 }
|
||||
let generation = 0
|
||||
let active: ActiveRoot | undefined
|
||||
const layers: DismissLayer[] = []
|
||||
const layers: LayerEntry[] = []
|
||||
// Native emits one keystroke's events back to back, in GPUI bubble order,
|
||||
// and the window event last. So only the latest keystroke needs flags.
|
||||
let keystroke: KeystrokeFlags = { id: undefined, prevented: false, stopped: false }
|
||||
@@ -173,7 +203,7 @@ export function createRendererState(renderer: NativeRenderer): RendererState {
|
||||
const runDefault = () => {
|
||||
if (!keyDown || flags.prevented || hasCommandModifier(keyEvent)) return
|
||||
if (keyEvent.key === "escape") {
|
||||
layers.at(-1)?.onEscapeKeyDown(keyEvent)
|
||||
layers.at(-1)?.layer.onEscapeKeyDown(keyEvent)
|
||||
} else if (keyEvent.key === "tab" && root.handlers.tabNavigation !== false) {
|
||||
if (keyEvent.modifiers?.shift) renderer.focusPrevious?.()
|
||||
else renderer.focusNext?.()
|
||||
@@ -207,15 +237,37 @@ export function createRendererState(renderer: NativeRenderer): RendererState {
|
||||
return true
|
||||
},
|
||||
current: () => active,
|
||||
pushLayer(layer) {
|
||||
pushLayer(layer, options = {}) {
|
||||
const entry: LayerEntry = {
|
||||
layer,
|
||||
previous: options.previousFocus ?? renderer.getFocusedElementId?.() ?? null,
|
||||
}
|
||||
// A framework can open a nested layer before its parent in one commit
|
||||
// (React runs child effects first), so nesting decides, not call order.
|
||||
const firstChild = layers.findIndex((open) => isAncestor(layer, open))
|
||||
if (firstChild < 0) layers.push(layer)
|
||||
else layers.splice(firstChild, 0, layer)
|
||||
const firstChild = layers.findIndex((open) => isAncestor(layer, open.layer))
|
||||
if (firstChild < 0) {
|
||||
layers.push(entry)
|
||||
const target = layer.initialFocus?.() ?? null
|
||||
if (target !== null) renderer.focusElement?.(target)
|
||||
} else {
|
||||
layers.splice(firstChild, 0, entry)
|
||||
// The children opened in the same commit and captured the same
|
||||
// pre-open focus. Closing one alone must return into this layer.
|
||||
const inside = layer.initialFocus?.() ?? null
|
||||
for (const child of layers.slice(firstChild + 1)) {
|
||||
if (isAncestor(layer, child.layer) && child.previous === entry.previous) {
|
||||
child.previous = inside
|
||||
}
|
||||
}
|
||||
}
|
||||
return () => {
|
||||
const index = layers.indexOf(layer)
|
||||
if (index >= 0) layers.splice(index, 1)
|
||||
const index = layers.indexOf(entry)
|
||||
if (index < 0) return
|
||||
layers.splice(index, 1)
|
||||
// A parent that closed first already restored focus for its subtree.
|
||||
if (layer.parent && !layers.some((open) => open.layer === layer.parent)) return
|
||||
const target = layer.finalFocus?.(entry.previous) ?? null
|
||||
if (target !== null) renderer.focusElement?.(target)
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -266,11 +318,14 @@ export interface FocusableNode {
|
||||
* - an element or a ref to one: that element
|
||||
* - a function: returns one of the above; `null` means the default
|
||||
*/
|
||||
export type FocusTarget<Node extends FocusableNode = FocusableNode> =
|
||||
type FocusTargetValue<Node extends FocusableNode> =
|
||||
| boolean
|
||||
| Node
|
||||
| { readonly current: Node | null }
|
||||
| (() => Node | boolean | null | undefined)
|
||||
|
||||
export type FocusTarget<Node extends FocusableNode = FocusableNode> =
|
||||
| FocusTargetValue<Node>
|
||||
| (() => FocusTargetValue<Node> | null | undefined)
|
||||
|
||||
/** Resolve a `FocusTarget` to an element id, or null when focus must stay. */
|
||||
export function resolveFocusTarget<Node extends FocusableNode>(
|
||||
|
||||
@@ -3400,7 +3400,7 @@ pub(crate) struct GpuixView {
|
||||
/// Created lazily for elements with keyboard or focus/blur listeners.
|
||||
/// Handles persist across renders so GPUI maintains focus state.
|
||||
pub(crate) focus_handles: HashMap<u64, gpui::FocusHandle>,
|
||||
pending_focus_element: Option<u64>,
|
||||
pending_focus_element: Option<PendingFocus>,
|
||||
/// Active focus/blur subscriptions keyed by element and event type.
|
||||
pub(crate) focus_subscriptions: HashMap<(u64, String), gpui::Subscription>,
|
||||
/// Registry for custom element types (input, editor, diff, etc.).
|
||||
@@ -4437,7 +4437,7 @@ impl GpuixView {
|
||||
self.pending_focus_element = None;
|
||||
handle.focus(window, cx);
|
||||
} else {
|
||||
self.pending_focus_element = Some(id);
|
||||
self.pending_focus_element = Some(PendingFocus::new(id, window, cx));
|
||||
}
|
||||
cx.notify();
|
||||
}
|
||||
@@ -4611,6 +4611,13 @@ impl GpuixView {
|
||||
window: &mut gpui::Window,
|
||||
cx: &mut gpui::Context<Self>,
|
||||
) {
|
||||
// Checked before this frame's autoFocus runs: an explicit request
|
||||
// outranks autoFocus, but not a Tab or click that came after it.
|
||||
if self.pending_focus_element.is_some_and(|pending| {
|
||||
window.focused(cx).map(|handle| handle.id()) != pending.focused_at_request
|
||||
}) {
|
||||
self.pending_focus_element = None;
|
||||
}
|
||||
let tab_index = |element: &crate::retained_tree::RetainedElement| {
|
||||
element
|
||||
.custom_props
|
||||
@@ -4658,7 +4665,7 @@ impl GpuixView {
|
||||
// microtask, Windows and Linux render on their own thread).
|
||||
if let Some(handle) = self
|
||||
.pending_focus_element
|
||||
.and_then(|id| self.focus_handles.get(&id).cloned())
|
||||
.and_then(|pending| self.focus_handles.get(&pending.id).cloned())
|
||||
{
|
||||
handle.focus(window, cx);
|
||||
self.pending_focus_element = None;
|
||||
@@ -4700,6 +4707,24 @@ impl GpuixView {
|
||||
}
|
||||
}
|
||||
|
||||
/// A `focusElement` request for an element whose focus handle does not exist
|
||||
/// yet. It is dropped when focus moves any other way first (Tab, a click, a
|
||||
/// newer request), so a late element never steals focus from the user.
|
||||
#[derive(Clone, Copy)]
|
||||
struct PendingFocus {
|
||||
id: u64,
|
||||
focused_at_request: Option<gpui::FocusId>,
|
||||
}
|
||||
|
||||
impl PendingFocus {
|
||||
fn new(id: u64, window: &gpui::Window, cx: &gpui::App) -> Self {
|
||||
Self {
|
||||
id,
|
||||
focused_at_request: window.focused(cx).map(|handle| handle.id()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl gpui::Render for GpuixView {
|
||||
fn render(
|
||||
&mut self,
|
||||
@@ -4712,7 +4737,7 @@ impl gpui::Render for GpuixView {
|
||||
|
||||
#[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
|
||||
if let Some(id) = PENDING_FOCUS_ELEMENT.with(|pending| pending.borrow_mut().take()) {
|
||||
self.pending_focus_element = Some(id);
|
||||
self.pending_focus_element = Some(PendingFocus::new(id, window, cx));
|
||||
}
|
||||
|
||||
// Clone Arc so we don't borrow self.tree — frees self for focus_handles access.
|
||||
|
||||
@@ -1158,6 +1158,47 @@ describeNative("floating controls", () => {
|
||||
`)
|
||||
})
|
||||
|
||||
it("sizes the Dialog portal to the window natively", () => {
|
||||
// Not the 1280x800 default, and no JS reads the window size: native must.
|
||||
const root = createTestRoot({ width: 900, height: 500 })
|
||||
root.render(
|
||||
<DialogPrimitive.Root defaultOpen>
|
||||
<DialogPrimitive.Portal testId="portal">
|
||||
<DialogPrimitive.Backdrop testId="backdrop" style={{ backgroundColor: "#00000080" }} />
|
||||
<DialogPrimitive.Popup testId="popup" style={{ width: 200, height: 100, backgroundColor: "#1e293b" }} />
|
||||
</DialogPrimitive.Portal>
|
||||
</DialogPrimitive.Root>
|
||||
)
|
||||
const bounds = (testId) => root.renderer.getElementBounds(root.renderer.findByTestId(testId).id)
|
||||
expect({
|
||||
portal: bounds("portal"),
|
||||
backdrop: bounds("backdrop"),
|
||||
popup: bounds("popup"),
|
||||
}).toMatchInlineSnapshot(`
|
||||
{
|
||||
"backdrop": {
|
||||
"height": 500,
|
||||
"width": 900,
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
},
|
||||
"popup": {
|
||||
"height": 100,
|
||||
"width": 200,
|
||||
"x": 350,
|
||||
"y": 200,
|
||||
},
|
||||
"portal": {
|
||||
"height": 500,
|
||||
"width": 900,
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
},
|
||||
}
|
||||
`)
|
||||
root.unmount()
|
||||
})
|
||||
|
||||
it("keeps a Dialog open when a handler prevents Escape", () => {
|
||||
testRoot.render(
|
||||
<DialogPrimitive.Root defaultOpen>
|
||||
@@ -1281,4 +1322,72 @@ describeNative("floating controls", () => {
|
||||
]
|
||||
`)
|
||||
})
|
||||
|
||||
it("moves focus in stack order when nested dialogs open and close together", () => {
|
||||
function Nested({ open, autoFocusField }) {
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", width: 600, height: 400, padding: 12, gap: 8 }}>
|
||||
<div autoFocus tabIndex={0} testId="launcher" style={{ width: 80, height: 24 }} />
|
||||
<DialogPrimitive.Root open={open.outer}>
|
||||
<DialogPrimitive.Portal>
|
||||
<DialogPrimitive.Popup testId="outer" style={{ width: 400, padding: 16, backgroundColor: "#1e293b" }}>
|
||||
<input autoFocus={autoFocusField} testId="outer-field" style={{ width: 120, height: 28 }} />
|
||||
<DialogPrimitive.Root open={open.inner}>
|
||||
<DialogPrimitive.Portal>
|
||||
<DialogPrimitive.Popup testId="inner" style={{ width: 200, height: 80, backgroundColor: "#334155" }} />
|
||||
</DialogPrimitive.Portal>
|
||||
</DialogPrimitive.Root>
|
||||
</DialogPrimitive.Popup>
|
||||
</DialogPrimitive.Portal>
|
||||
</DialogPrimitive.Root>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
const renderer = testRoot.renderer
|
||||
const focused = () => renderer.getElement(renderer.getFocusedElementId())?.testId
|
||||
const steps = []
|
||||
const show = (label, open, autoFocusField = false) => {
|
||||
testRoot.render(<Nested open={open} autoFocusField={autoFocusField} />)
|
||||
steps.push(`${label}: ${focused()}`)
|
||||
}
|
||||
show("mount", { outer: false, inner: false })
|
||||
show("open both", { outer: true, inner: true })
|
||||
show("close inner", { outer: true, inner: false })
|
||||
show("close outer", { outer: false, inner: false })
|
||||
show("open both", { outer: true, inner: true })
|
||||
show("close both", { outer: false, inner: false })
|
||||
show("open outer, field autoFocus", { outer: true, inner: false }, true)
|
||||
show("close outer", { outer: false, inner: false }, true)
|
||||
expect(steps).toMatchInlineSnapshot(`
|
||||
[
|
||||
"mount: launcher",
|
||||
"open both: inner",
|
||||
"close inner: outer",
|
||||
"close outer: launcher",
|
||||
"open both: inner",
|
||||
"close both: launcher",
|
||||
"open outer, field autoFocus: outer",
|
||||
"close outer: launcher",
|
||||
]
|
||||
`)
|
||||
})
|
||||
|
||||
it("drops a pending focus request when the user moves focus first", () => {
|
||||
function Demo({ late }) {
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", width: 400, height: 300, gap: 8 }}>
|
||||
<div autoFocus tabIndex={0} testId="a" style={{ width: 80, height: 24 }} />
|
||||
<div tabIndex={0} testId="b" style={{ width: 80, height: 24 }} />
|
||||
<div tabIndex={late ? 0 : undefined} testId="late" style={{ width: 80, height: 24 }} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
const renderer = testRoot.renderer
|
||||
testRoot.render(<Demo late={false} />)
|
||||
// No focus handle yet, so the request waits.
|
||||
renderer.focusElement(renderer.findByTestId("late").id)
|
||||
renderer.simulateKeystrokes("tab")
|
||||
testRoot.render(<Demo late />)
|
||||
expect(renderer.getElement(renderer.getFocusedElementId())?.testId).toBe("b")
|
||||
})
|
||||
})
|
||||
@@ -5,7 +5,6 @@ import React, {
|
||||
forwardRef,
|
||||
useCallback,
|
||||
useContext,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
} from "react"
|
||||
import type { ReactElement, ReactNode } from "react"
|
||||
@@ -13,7 +12,6 @@ import type { EventPayload } from "@gpuix/native"
|
||||
import { resolveFocusTarget } from "@gpuix/native/host"
|
||||
import type { FocusTarget, KeyEvent, Props, PublicInstance } from "../types/host.js"
|
||||
import { useGpuix } from "../hooks/use-gpuix.js"
|
||||
import { useWindowSize } from "../hooks/use-window-size.js"
|
||||
import {
|
||||
renderSlot,
|
||||
setRefs,
|
||||
@@ -119,23 +117,17 @@ export interface DialogPortalProps extends Props {}
|
||||
* and everything else. Mounted only while the dialog is open.
|
||||
*/
|
||||
export const DialogPortal = forwardRef<PublicInstance, DialogPortalProps>(
|
||||
function DialogPortal(props, ref) {
|
||||
function DialogPortal({ style, children, ...props }, ref) {
|
||||
const context = useDialogContext("DialogPortal")
|
||||
if (!context.open) return null
|
||||
return <DialogPortalSurface {...props} ref={ref} modal={context.modal} />
|
||||
}
|
||||
)
|
||||
|
||||
const DialogPortalSurface = forwardRef<PublicInstance, DialogPortalProps & { modal: boolean }>(
|
||||
function DialogPortalSurface({ modal, style, children, ...props }, ref) {
|
||||
const size = useWindowSize()
|
||||
const { modal } = context
|
||||
return (
|
||||
<anchored
|
||||
{...props}
|
||||
ref={ref}
|
||||
position={{ x: 0, y: 0 }}
|
||||
anchor="topLeft"
|
||||
fit="switch"
|
||||
// Native sizes the layer to the viewport, so a resize never leaves an
|
||||
// uncovered strip that clicks could reach.
|
||||
fill="window"
|
||||
deferred
|
||||
// Below menus and tooltips (priority 1), so a Select inside the popup
|
||||
// opens on top of it.
|
||||
@@ -145,8 +137,6 @@ const DialogPortalSurface = forwardRef<PublicInstance, DialogPortalProps & { mod
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
width: size.width,
|
||||
height: size.height,
|
||||
// Explicit transparent: the Backdrop paints the dim, not the layer.
|
||||
backgroundColor: "transparent",
|
||||
...style,
|
||||
@@ -195,30 +185,15 @@ export const DialogPopup = forwardRef<PublicInstance, DialogPopupProps>(
|
||||
const context = useDialogContext("DialogPopup")
|
||||
const { renderer } = useGpuix()
|
||||
const popupRef = useRef<PublicInstance | null>(null)
|
||||
const focusTargets = useRef({ initialFocus, finalFocus })
|
||||
focusTargets.current = { initialFocus, finalFocus }
|
||||
const { triggerRef } = context
|
||||
useLayoutEffect(() => {
|
||||
if (!context.open || !renderer) return
|
||||
// Read before moving focus, so a dialog opened from app state (no
|
||||
// trigger) still knows where to return.
|
||||
const previous = renderer.getFocusedElementId?.() ?? null
|
||||
const initial = resolveFocusTarget(
|
||||
focusTargets.current.initialFocus,
|
||||
() => popupRef.current?.id ?? null
|
||||
)
|
||||
if (initial !== null) renderer.focusElement?.(initial)
|
||||
return () => {
|
||||
const final = resolveFocusTarget(
|
||||
focusTargets.current.finalFocus,
|
||||
() => triggerRef.current?.id ?? previous
|
||||
)
|
||||
if (final !== null) renderer.focusElement?.(final)
|
||||
}
|
||||
}, [context.open, renderer, triggerRef])
|
||||
if (!context.open) return null
|
||||
return (
|
||||
<DismissableLayer onEscapeKeyDown={() => context.setOpen(false)}>
|
||||
<DismissableLayer
|
||||
onEscapeKeyDown={() => context.setOpen(false)}
|
||||
initialFocus={() => resolveFocusTarget(initialFocus, () => popupRef.current?.id ?? null)}
|
||||
finalFocus={(previous) =>
|
||||
resolveFocusTarget(finalFocus, () => context.triggerRef.current?.id ?? previous)
|
||||
}
|
||||
>
|
||||
<div
|
||||
{...props}
|
||||
ref={(value: PublicInstance | null) => {
|
||||
|
||||
@@ -138,26 +138,38 @@ export interface DismissableLayerProps {
|
||||
children?: ReactNode
|
||||
/** Escape reached the window, this layer is on top, and nothing prevented it. */
|
||||
onEscapeKeyDown: (event: KeyEvent) => void
|
||||
/** Element to focus when the layer opens on top. Null leaves focus. */
|
||||
initialFocus?: () => number | null
|
||||
/** Element to focus on close. `previous` is the focus from before it opened. */
|
||||
finalFocus?: (previous: number | null) => number | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Put an open overlay on the window's layer stack while mounted. Escape closes
|
||||
* only the top layer. Layers inside this one, such as a Select in a Dialog,
|
||||
* are always above it.
|
||||
* are always above it. The stack also moves focus in and out, in stack order.
|
||||
*/
|
||||
export function DismissableLayer({ children, onEscapeKeyDown }: DismissableLayerProps): ReactElement {
|
||||
export function DismissableLayer({ children, ...handlers }: DismissableLayerProps): ReactElement {
|
||||
const { renderer } = useGpuix()
|
||||
const parent = useContext(DismissLayerContext)
|
||||
const latest = useRef(onEscapeKeyDown)
|
||||
latest.current = onEscapeKeyDown
|
||||
// Read during render, before this layer's elements are committed. After the
|
||||
// commit an autoFocus inside the layer may already hold focus.
|
||||
const [previousFocus] = useState(() => renderer?.getFocusedElementId?.() ?? null)
|
||||
const latest = useRef(handlers)
|
||||
latest.current = handlers
|
||||
const layer = useMemo<DismissLayer>(
|
||||
() => ({ parent, onEscapeKeyDown: (event) => latest.current(event) }),
|
||||
() => ({
|
||||
parent,
|
||||
onEscapeKeyDown: (event) => latest.current.onEscapeKeyDown(event),
|
||||
initialFocus: () => latest.current.initialFocus?.() ?? null,
|
||||
finalFocus: (previous) => latest.current.finalFocus?.(previous) ?? null,
|
||||
}),
|
||||
[parent]
|
||||
)
|
||||
useLayoutEffect(() => {
|
||||
if (!renderer) return
|
||||
return pushDismissLayer(renderer, layer)
|
||||
}, [renderer, layer])
|
||||
return pushDismissLayer(renderer, layer, { previousFocus })
|
||||
}, [renderer, layer, previousFocus])
|
||||
return <DismissLayerContext.Provider value={layer}>{children}</DismissLayerContext.Provider>
|
||||
}
|
||||
|
||||
|
||||
@@ -4,8 +4,6 @@ import {
|
||||
createComponent,
|
||||
createContext,
|
||||
createSignal,
|
||||
onCleanup,
|
||||
onMount,
|
||||
Show,
|
||||
splitProps,
|
||||
useContext,
|
||||
@@ -16,7 +14,6 @@ import { resolveFocusTarget } from "@gpuix/native/host"
|
||||
import type { FocusTarget, HostProps, KeyEvent } from "@gpuix/native/host"
|
||||
import type { HostElement } from "../host.js"
|
||||
import { jsx } from "../jsx-runtime.js"
|
||||
import { createWindowSize } from "../primitives.js"
|
||||
import { useGpuixRequired } from "../root.js"
|
||||
import { DismissableLayer, renderSlot } from "./floating.js"
|
||||
|
||||
@@ -117,12 +114,11 @@ export function DialogPortal(props: DialogPortalProps): JSX.Element {
|
||||
get when() { return state.open() },
|
||||
keyed: true,
|
||||
get children() {
|
||||
const size = createWindowSize()
|
||||
return jsx("anchored", {
|
||||
...props,
|
||||
position: { x: 0, y: 0 },
|
||||
anchor: "topLeft",
|
||||
fit: "switch",
|
||||
// Native sizes the layer to the viewport, so a resize never leaves an
|
||||
// uncovered strip that clicks could reach.
|
||||
fill: "window",
|
||||
deferred: true,
|
||||
// Below menus and tooltips (priority 1), so a Select inside the popup
|
||||
// opens on top of it.
|
||||
@@ -133,8 +129,6 @@ export function DialogPortal(props: DialogPortalProps): JSX.Element {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
width: size().width,
|
||||
height: size().height,
|
||||
// Explicit transparent: the Backdrop paints the dim, not the layer.
|
||||
backgroundColor: "transparent",
|
||||
...props.style,
|
||||
@@ -185,22 +179,11 @@ export function DialogPopup(allProps: DialogPopupProps): JSX.Element {
|
||||
get when() { return state.open() },
|
||||
keyed: true,
|
||||
get children() {
|
||||
// Read before moving focus, so a dialog opened from app state (no
|
||||
// trigger) still knows where to return. The popup is not built yet.
|
||||
const previous = renderer.getFocusedElementId?.() ?? null
|
||||
onMount(() => {
|
||||
const initial = resolveFocusTarget(focus.initialFocus, () => popup?.id ?? null)
|
||||
if (initial !== null) renderer.focusElement?.(initial)
|
||||
})
|
||||
onCleanup(() => {
|
||||
const final = resolveFocusTarget(
|
||||
focus.finalFocus,
|
||||
() => state.trigger.current?.id ?? previous
|
||||
)
|
||||
if (final !== null) renderer.focusElement?.(final)
|
||||
})
|
||||
return createComponent(DismissableLayer, {
|
||||
onEscapeKeyDown: () => state.setOpen(false),
|
||||
initialFocus: () => resolveFocusTarget(focus.initialFocus, () => popup?.id ?? null),
|
||||
finalFocus: (previous) =>
|
||||
resolveFocusTarget(focus.finalFocus, () => state.trigger.current?.id ?? previous),
|
||||
get children() {
|
||||
return jsx("div", {
|
||||
...props,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createComponent, createContext, onCleanup, useContext, type JSX } from "solid-js"
|
||||
import { createComponent, createContext, onCleanup, onMount, useContext, type JSX } from "solid-js"
|
||||
import type { EventPayload } from "@gpuix/native"
|
||||
import { pushDismissLayer } from "@gpuix/native/host"
|
||||
import type { DismissLayer, HostProps, KeyEvent, StyleDesc } from "@gpuix/native/host"
|
||||
@@ -46,19 +46,33 @@ export interface DismissableLayerProps {
|
||||
children?: JSX.Element
|
||||
/** Escape reached the window, this layer is on top, and nothing prevented it. */
|
||||
onEscapeKeyDown: (event: KeyEvent) => void
|
||||
/** Element to focus when the layer opens on top. Null leaves focus. */
|
||||
initialFocus?: () => number | null
|
||||
/** Element to focus on close. `previous` is the focus from before it opened. */
|
||||
finalFocus?: (previous: number | null) => number | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Put an open overlay on the window's layer stack while mounted. Escape closes
|
||||
* only the top layer. Layers inside this one, such as a Select in a Dialog,
|
||||
* are always above it.
|
||||
* are always above it. The stack also moves focus in and out, in stack order.
|
||||
*/
|
||||
export function DismissableLayer(props: DismissableLayerProps): JSX.Element {
|
||||
const renderer = useGpuixRequired()
|
||||
// Read now: Solid has not created this layer's elements yet.
|
||||
const previousFocus = renderer.getFocusedElementId?.() ?? null
|
||||
const layer: DismissLayer = {
|
||||
parent: useContext(DismissLayerContext),
|
||||
onEscapeKeyDown: (event) => props.onEscapeKeyDown(event),
|
||||
initialFocus: () => props.initialFocus?.() ?? null,
|
||||
finalFocus: (previous) => props.finalFocus?.(previous) ?? null,
|
||||
}
|
||||
onCleanup(pushDismissLayer(useGpuixRequired(), layer))
|
||||
let pop: (() => void) | undefined
|
||||
// After the children exist, so initialFocus can name one of them.
|
||||
onMount(() => {
|
||||
pop = pushDismissLayer(renderer, layer, { previousFocus })
|
||||
})
|
||||
onCleanup(() => pop?.())
|
||||
return createComponent(DismissLayerContext.Provider, {
|
||||
value: layer,
|
||||
get children() { return props.children },
|
||||
|
||||
Reference in new issue
Block a user