@opencode/ui 2.0.27 → 2.0.28
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/context/dialog.d.ts
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import { type Owner, type ParentProps, type JSX } from "solid-js";
|
|
2
2
|
type DialogElement = () => JSX.Element;
|
|
3
|
+
type FocusReturn = {
|
|
4
|
+
element: HTMLElement;
|
|
5
|
+
range?: Range;
|
|
6
|
+
};
|
|
3
7
|
type Active = {
|
|
4
8
|
id: string;
|
|
5
9
|
node: JSX.Element;
|
|
@@ -7,6 +11,9 @@ type Active = {
|
|
|
7
11
|
owner: Owner;
|
|
8
12
|
onClose?: () => void;
|
|
9
13
|
setClosing: (closing: boolean) => void;
|
|
14
|
+
/** Where focus was before this dialog opened, or before the dialogs it replaced opened. */
|
|
15
|
+
focus?: FocusReturn;
|
|
16
|
+
returnFocus: () => void;
|
|
10
17
|
};
|
|
11
18
|
export declare function useDialogLayer(): {
|
|
12
19
|
setBackdropDismiss: (value: boolean) => void;
|
package/package.json
CHANGED
package/src/context/dialog.tsx
CHANGED
|
@@ -18,6 +18,8 @@ import { makeEventListener } from "@solid-primitives/event-listener"
|
|
|
18
18
|
|
|
19
19
|
type DialogElement = () => JSX.Element
|
|
20
20
|
|
|
21
|
+
type FocusReturn = { element: HTMLElement; range?: Range }
|
|
22
|
+
|
|
21
23
|
type Active = {
|
|
22
24
|
id: string
|
|
23
25
|
node: JSX.Element
|
|
@@ -25,6 +27,9 @@ type Active = {
|
|
|
25
27
|
owner: Owner
|
|
26
28
|
onClose?: () => void
|
|
27
29
|
setClosing: (closing: boolean) => void
|
|
30
|
+
/** Where focus was before this dialog opened, or before the dialogs it replaced opened. */
|
|
31
|
+
focus?: FocusReturn
|
|
32
|
+
returnFocus: () => void
|
|
28
33
|
}
|
|
29
34
|
|
|
30
35
|
const Context = createContext<ReturnType<typeof init>>()
|
|
@@ -81,6 +86,10 @@ function init() {
|
|
|
81
86
|
setExiting((ids) => new Set([...ids, current.id]))
|
|
82
87
|
isolate(() => current.onClose?.())
|
|
83
88
|
current.setClosing(true)
|
|
89
|
+
|
|
90
|
+
// Only the top open dialog holds focus. It returns before close() does, as a native dialog's does, so an
|
|
91
|
+
// action that runs after closing, such as a palette command that opens the terminal, can move it on.
|
|
92
|
+
if (stack().findLast((item) => item === current || !closing.has(item.id)) === current) current.returnFocus()
|
|
84
93
|
}
|
|
85
94
|
|
|
86
95
|
/** Programmatic close. Without an id it closes the top dialog, one at a time; with an id it never waits. */
|
|
@@ -116,7 +125,13 @@ function init() {
|
|
|
116
125
|
makeEventListener(window, "keydown", onKeyDown, { capture: true })
|
|
117
126
|
})
|
|
118
127
|
|
|
119
|
-
const mount = (
|
|
128
|
+
const mount = (
|
|
129
|
+
element: DialogElement,
|
|
130
|
+
owner: Owner,
|
|
131
|
+
onClose: (() => void) | undefined,
|
|
132
|
+
key: string | undefined,
|
|
133
|
+
focus: FocusReturn | undefined,
|
|
134
|
+
) => {
|
|
120
135
|
// A deferred open (e.g. from a focus callback) must not mount after the provider is gone.
|
|
121
136
|
if (state.disposed) return
|
|
122
137
|
const id = key ?? Math.random().toString(36).slice(2)
|
|
@@ -131,6 +146,7 @@ function init() {
|
|
|
131
146
|
const zIndex = () => String(50 + layer() * 10)
|
|
132
147
|
let dispose: (() => void) | undefined
|
|
133
148
|
let setClosing: ((closing: boolean) => void) | undefined
|
|
149
|
+
let layerRef: HTMLDivElement | undefined
|
|
134
150
|
|
|
135
151
|
// Stacked dialogs render as sibling portals, so only the top layer may own the focus trap.
|
|
136
152
|
const node = runWithOwner(owner, () =>
|
|
@@ -157,6 +173,7 @@ function init() {
|
|
|
157
173
|
}}
|
|
158
174
|
/>
|
|
159
175
|
<div
|
|
176
|
+
ref={layerRef}
|
|
160
177
|
data-dialog-layer={layer()}
|
|
161
178
|
style={{
|
|
162
179
|
position: "fixed",
|
|
@@ -178,19 +195,35 @@ function init() {
|
|
|
178
195
|
|
|
179
196
|
if (!dispose || !setClosing) return
|
|
180
197
|
|
|
181
|
-
const active: Active = {
|
|
198
|
+
const active: Active = {
|
|
199
|
+
id,
|
|
200
|
+
node,
|
|
201
|
+
dispose,
|
|
202
|
+
owner,
|
|
203
|
+
onClose,
|
|
204
|
+
setClosing,
|
|
205
|
+
focus,
|
|
206
|
+
returnFocus: () => {
|
|
207
|
+
// A close inside a reactive update unmounts the dialog, and its focus trap, only when the update ends.
|
|
208
|
+
if (layerRef?.contains(document.activeElement)) return queueMicrotask(() => returnFocus(focus, layerRef))
|
|
209
|
+
returnFocus(focus, layerRef)
|
|
210
|
+
},
|
|
211
|
+
}
|
|
212
|
+
|
|
182
213
|
setStack((items) => [...items, active])
|
|
183
214
|
}
|
|
184
215
|
|
|
185
216
|
const push = (element: DialogElement, owner: Owner, onClose?: () => void, id?: string) => {
|
|
186
217
|
lock.value = false
|
|
187
|
-
mount(element, owner, onClose, id)
|
|
218
|
+
mount(element, owner, onClose, id, captureFocus())
|
|
188
219
|
}
|
|
189
220
|
|
|
190
221
|
const show = (element: DialogElement, owner: Owner, onClose?: () => void, id?: string) => {
|
|
222
|
+
// A replacement returns focus to where the dialogs it replaces would have, not to their now-removed content.
|
|
223
|
+
const focus = stack().length > 0 ? stack()[0].focus : captureFocus()
|
|
191
224
|
disposeAll(true)
|
|
192
225
|
lock.value = false
|
|
193
|
-
mount(element, owner, onClose, id)
|
|
226
|
+
mount(element, owner, onClose, id, focus)
|
|
194
227
|
}
|
|
195
228
|
|
|
196
229
|
return {
|
|
@@ -210,6 +243,36 @@ function isolate(fn: () => void) {
|
|
|
210
243
|
}
|
|
211
244
|
}
|
|
212
245
|
|
|
246
|
+
/** Remembers the focused element and, in editable content, the caret, which focusing alone does not restore. */
|
|
247
|
+
function captureFocus(): FocusReturn | undefined {
|
|
248
|
+
const element = document.activeElement
|
|
249
|
+
|
|
250
|
+
if (!(element instanceof HTMLElement) || element === document.body) return
|
|
251
|
+
const selection = document.getSelection()
|
|
252
|
+
|
|
253
|
+
if (!element.isContentEditable || !selection?.rangeCount || !element.contains(selection.anchorNode)) {
|
|
254
|
+
return { element }
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
return { element, range: selection.getRangeAt(0).cloneRange() }
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** Kobalte only returns focus to a dialog trigger, and these dialogs open without one. */
|
|
261
|
+
function returnFocus(focus: FocusReturn | undefined, layer: HTMLElement | undefined) {
|
|
262
|
+
const active = document.activeElement
|
|
263
|
+
|
|
264
|
+
if (!focus?.element.isConnected) return
|
|
265
|
+
|
|
266
|
+
// Something outside the closed dialog already has focus, for example because the dialog's onClose moved it.
|
|
267
|
+
if (active && active !== document.body && !layer?.contains(active)) return
|
|
268
|
+
focus.element.focus({ preventScroll: true })
|
|
269
|
+
|
|
270
|
+
if (!focus.range || !focus.element.contains(focus.range.commonAncestorContainer)) return
|
|
271
|
+
const selection = document.getSelection()
|
|
272
|
+
selection?.removeAllRanges()
|
|
273
|
+
selection?.addRange(focus.range)
|
|
274
|
+
}
|
|
275
|
+
|
|
213
276
|
export function DialogProvider(props: ParentProps) {
|
|
214
277
|
const ctx = init()
|
|
215
278
|
|
|
@@ -120,12 +120,14 @@ export function Dialog(props: DialogProps) {
|
|
|
120
120
|
[local.class ?? ""]: !!local.class,
|
|
121
121
|
}}
|
|
122
122
|
onOpenAutoFocus={(e) => {
|
|
123
|
-
const
|
|
124
|
-
|
|
123
|
+
const autofocusEl =
|
|
124
|
+
e.currentTarget instanceof HTMLElement ? e.currentTarget.querySelector<HTMLElement>("[autofocus]") : null
|
|
125
125
|
|
|
126
126
|
if (autofocusEl) {
|
|
127
127
|
e.preventDefault()
|
|
128
|
-
|
|
128
|
+
// Kobalte attaches its focus trap after this event, so focusing now would leave the trap with no
|
|
129
|
+
// element to return to. Deferring, as Kobalte's own autofocus does, lets it pull stray focus back.
|
|
130
|
+
setTimeout(() => autofocusEl.focus({ preventScroll: true }))
|
|
129
131
|
}
|
|
130
132
|
}}
|
|
131
133
|
>
|