+ ),
+}
diff --git a/packages/app/src/session/composer/queue-panel.tsx b/packages/app/src/session/composer/queue-panel.tsx
index 650fdde5dff..6c27f80d369 100644
--- a/packages/app/src/session/composer/queue-panel.tsx
+++ b/packages/app/src/session/composer/queue-panel.tsx
@@ -11,6 +11,7 @@ import { IconButton } from "@opencode/ui/icon-button"
import { Tooltip } from "@opencode/ui/tooltip"
import { useLanguage } from "@/runtime/i18n/language"
import type { SessionQueueView } from "./queue"
+import "./queue-panel.css"
// Pullout above the composer listing the prompts queued behind the current
// turn. The panel slides under the composer card (negative margin, opaque
@@ -66,6 +67,7 @@ export function SessionQueuePanel(props: { queue: SessionQueueView }) {
{/* Four 32px rows, four 1px gaps, and half a row hint at more queued prompts. */}
4 }}
style={{
diff --git a/packages/ui/src/context/dialog.tsx b/packages/ui/src/context/dialog.tsx
index 612ee89e973..7a99fd71cb8 100644
--- a/packages/ui/src/context/dialog.tsx
+++ b/packages/ui/src/context/dialog.tsx
@@ -18,7 +18,7 @@ import { makeEventListener } from "@solid-primitives/event-listener"
type DialogElement = () => JSX.Element
-type FocusReturn = { element: HTMLElement; range?: Range }
+type FocusReturn = { element: HTMLElement; visible: boolean; range?: Range }
type Active = {
id: string
@@ -265,12 +265,14 @@ function captureFocus(): FocusReturn | undefined {
if (!(element instanceof HTMLElement) || element === document.body) return
const selection = document.getSelection()
+ // Closing with Escape counts as keyboard use, so Chromium would ring a control the pointer had focused.
+ const visible = element.matches(":focus-visible")
if (!element.isContentEditable || !selection?.rangeCount || !element.contains(selection.anchorNode)) {
- return { element }
+ return { element, visible }
}
- return { element, range: selection.getRangeAt(0).cloneRange() }
+ return { element, visible, range: selection.getRangeAt(0).cloneRange() }
}
/** Kobalte only returns focus to a dialog trigger, and these dialogs open without one. */
@@ -281,7 +283,7 @@ function returnFocus(focus: FocusReturn | undefined, layer: HTMLElement | undefi
// Something outside the closed dialog already has focus, for example because the dialog's onClose moved it.
if (active && active !== document.body && !layer?.contains(active)) return
- focus.element.focus({ preventScroll: true })
+ focus.element.focus({ preventScroll: true, focusVisible: focus.visible })
if (!focus.range || !focus.element.contains(focus.range.commonAncestorContainer)) return
const selection = document.getSelection()
diff --git a/packages/ui/src/navigation/menu/menu.tsx b/packages/ui/src/navigation/menu/menu.tsx
index c3347d8a2ef..4aca8f6ed47 100644
--- a/packages/ui/src/navigation/menu/menu.tsx
+++ b/packages/ui/src/navigation/menu/menu.tsx
@@ -1,5 +1,6 @@
import { DropdownMenu } from "@kobalte/core/dropdown-menu"
import { ContextMenu } from "@kobalte/core/context-menu"
+import { makeEventListener } from "@solid-primitives/event-listener"
import {
createContext,
Show,
@@ -41,8 +42,12 @@ const CheckMark: Component = () => (
export type MenuAppearance = "standard" | "compact"
-const MenuAppearanceContext = createContext<{ appearance: Accessor }>({
+const MenuAppearanceContext = createContext<{
+ appearance: Accessor
+ pointerOpened: () => boolean
+}>({
appearance: (): MenuAppearance => "compact",
+ pointerOpened: () => false,
})
function useMenuContext() {
@@ -242,11 +247,28 @@ function MenuSeparator(props: ComponentProps) {
function MenuContent(props: ComponentProps) {
const ctx = useMenuContext()
- const [s, r] = splitProps(props, ["class", "classList"])
+ const [s, r] = splitProps(props, ["class", "classList", "onCloseAutoFocus"])
+
+ // A menu returns focus to its trigger when it closes. Closing with Escape counts as keyboard use, so Chromium would
+ // ring a trigger the pointer opened; restore focus without the ring and keep it for keyboard opens.
+ const onCloseAutoFocus = (event: Event) => {
+ s.onCloseAutoFocus?.(event)
+
+ if (event.defaultPrevented || !ctx.pointerOpened()) return
+ event.preventDefault()
+ const active = document.activeElement
+
+ // An outside press already moved focus where it belongs.
+ if (active && active !== document.body && !active.closest('[role="menu"]')) return
+ const labelledby = event.target instanceof Element ? event.target.getAttribute("aria-labelledby") : null
+ const trigger = labelledby ? document.getElementById(labelledby) : null
+ trigger?.focus({ preventScroll: true, focusVisible: false })
+ }
return (
{
}
function MenuRoot(props: MenuProps) {
- const [local, rest] = splitProps(props, ["appearance", "children"])
+ const [local, rest] = splitProps(props, ["appearance", "children", "onOpenChange"])
const appearance = () => local.appearance ?? "compact"
+ let pointer = false
+ let pointerOpened = false
+
+ // Capture runs before the trigger's own handlers, so the last input is what opened the menu.
+ makeEventListener(window, "pointerdown", () => (pointer = true), { capture: true })
+ makeEventListener(window, "keydown", () => (pointer = false), { capture: true })
return (
-
- {local.children}
+ pointerOpened }}>
+ {
+ if (open) pointerOpened = pointer
+ local.onOpenChange?.(open)
+ }}
+ >
+ {local.children}
+
)
}
@@ -278,7 +314,7 @@ function MenuContextRoot(props: MenuContextProps) {
const appearance = () => local.appearance ?? "compact"
return (
-
+ false }}>
{local.children}
)
diff --git a/packages/ui/src/overlays/dialog/dialog.stories.tsx b/packages/ui/src/overlays/dialog/dialog.stories.tsx
index 207726f4659..fb1e2967c59 100644
--- a/packages/ui/src/overlays/dialog/dialog.stories.tsx
+++ b/packages/ui/src/overlays/dialog/dialog.stories.tsx
@@ -1,6 +1,7 @@
import { Overlay, Portal, Root, Trigger } from "@kobalte/core/dialog"
import { Dialog, DialogBody, DialogFooter, DialogHeader, DialogTitle, DialogTitleGroup } from "./dialog"
import { Button } from "@opencode/ui/button"
+import { useDialog } from "@opencode/ui/context/dialog"
const docs = `### Overview
Dialog content wrapper built on Kobalte's dialog primitive with v2 styling.
@@ -204,3 +205,27 @@ export const Fit = {
),
}
+
+function OpenFromButton() {
+ const dialog = useDialog()
+
+ return (
+
+ )
+}
+
+export const OpenedFromButton = {
+ render: () => ,
+}