@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.
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opencode/ui",
3
- "version": "2.0.27",
3
+ "version": "2.0.28",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -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 = (element: DialogElement, owner: Owner, onClose: (() => void) | undefined, key?: string) => {
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 = { id, node, dispose, owner, onClose, setClosing }
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 target = e.currentTarget as HTMLElement | null
124
- const autofocusEl = target?.querySelector("[autofocus]") as HTMLElement | null
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
- autofocusEl.focus({ preventScroll: true })
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
  >