@opencode/ui 2.0.26 → 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.26",
3
+ "version": "2.0.28",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -195,6 +195,7 @@
195
195
  },
196
196
  "./styles": "./src/styles/index.css",
197
197
  "./styles/tokens": "./src/styles/tokens/index.css",
198
+ "./styles/tokens/*": "./src/styles/tokens/*",
198
199
  "./styles/tailwind": "./src/styles/tailwind/index.css",
199
200
  "./theme": {
200
201
  "types": "./dist/theme/index.d.ts",
@@ -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
 
@@ -3,10 +3,10 @@ import { Marked } from "marked"
3
3
  export function createMarkdownBase() {
4
4
  return new Marked({
5
5
  renderer: {
6
- link({ href, title, text }) {
6
+ link({ href, title, tokens }) {
7
7
  const titleAttr = title ? ` title="${title}"` : ""
8
8
 
9
- return `<a href="${href}"${titleAttr} class="external-link" target="_blank" rel="noopener noreferrer">${text}</a>`
9
+ return `<a href="${href}"${titleAttr} class="external-link" target="_blank" rel="noopener noreferrer">${this.parser.parseInline(tokens)}</a>`
10
10
  },
11
11
  },
12
12
  })
@@ -52,7 +52,7 @@ export const additionalIcons = {
52
52
  "magnifying-glass-menu": `<path d="M2.08325 10.0002H4.58325M2.08325 5.41683H5.41659M2.08325 14.5835H5.41659M16.4583 13.9585L18.7499 16.2502M17.9166 10.0002C17.9166 12.9917 15.4915 15.4168 12.4999 15.4168C9.50838 15.4168 7.08325 12.9917 7.08325 10.0002C7.08325 7.00862 9.50838 4.5835 12.4999 4.5835C15.4915 4.5835 17.9166 7.00862 17.9166 10.0002Z" stroke="currentColor" stroke-linecap="square"/>`,
53
53
  "window-cursor": `<path d="M17.5 10.4167V3.75H2.5V17.0833H10.4166M17.9166 13.5897L11.6666 11.6667L13.5897 17.9167L15.032 15.0321L17.9166 13.5897Z" stroke="currentColor" stroke-linecap="square"/><path d="M5.41675 6.125C5.71576 6.12518 5.95777 6.36795 5.95777 6.66699C5.95759 6.96589 5.71565 7.20783 5.41675 7.20801C5.11771 7.20801 4.87494 6.966 4.87476 6.66699C4.87476 6.36784 5.1176 6.125 5.41675 6.125ZM8.33276 6.125C8.63191 6.125 8.87476 6.36784 8.87476 6.66699C8.87458 6.966 8.63181 7.20801 8.33276 7.20801C8.03386 7.20783 7.79192 6.96589 7.79175 6.66699C7.79175 6.36795 8.03376 6.12518 8.33276 6.125ZM11.2498 6.125C11.5489 6.125 11.7917 6.36784 11.7917 6.66699C11.7916 6.966 11.5488 7.20801 11.2498 7.20801C10.9507 7.20801 10.7079 6.966 10.7078 6.66699C10.7078 6.36784 10.9506 6.125 11.2498 6.125Z" fill="currentColor" stroke="currentColor" stroke-width="0.25" stroke-linecap="square"/>`,
54
54
  task: `<path d="M9.99992 2.0835V17.9168M7.08325 3.75016H2.08325V16.2502H7.08325M12.9166 16.2502H17.9166V3.75016H12.9166" stroke="currentColor" stroke-linecap="square"/>`,
55
- subagent: `<path d="M4.5 5C4.5 4.72386 4.72386 4.5 5 4.5H11C11.2761 4.5 11.5 4.72386 11.5 5V11C11.5 11.2761 11.2761 11.5 11 11.5H5C4.72386 11.5 4.5 11.2761 4.5 11V5Z" fill="currentColor"/><path d="M13.5 2C13.7761 2 14 2.22386 14 2.5V13.5C14 13.7761 13.7761 14 13.5 14H2.5C2.22386 14 2 13.7761 2 13.5V2.5C2 2.22386 2.22386 2 2.5 2H13.5ZM3 13H13V3H3V13Z" fill="currentColor"/>`,
55
+ subagent: `<path d="M4 4.5C4 4.22386 4.22386 4 4.5 4H11.5C11.7761 4 12 4.22386 12 4.5V11.5C12 11.7761 11.7761 12 11.5 12H4.5C4.22386 12 4 11.7761 4 11.5V4.5Z" fill="currentColor"/><path d="M14.5 1C14.7761 1 15 1.22386 15 1.5V14.5C15 14.7761 14.7761 15 14.5 15H1.5C1.22386 15 1 14.7761 1 14.5V1.5C1 1.22386 1.22386 1 1.5 1H14.5ZM2 14H14V2H2V14Z" fill="currentColor"/>`,
56
56
  stop: `<rect x="5" y="5" width="10" height="10" fill="currentColor"/>`,
57
57
  status: `<path d="M2 10V18H18V10M2 10V2H18V10M2 10H18M5 6H9M5 14H9" stroke="currentColor"/>`,
58
58
  "status-active": `<path d="M18 2H2V10H18V2Z" fill="currentColor" fill-opacity="0.1"/>
@@ -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
  >
@@ -23,6 +23,7 @@
23
23
  5. Use the user's configured `sans` font-feature-settings by default.
24
24
  6. Use the user's configured `sans` font-variation-settings by default.
25
25
  7. Disable tap highlights on iOS.
26
+ 8. Avoid one-word last lines in all wrapped text. Chromium keeps the line count, so heights do not change.
26
27
  */
27
28
 
28
29
  html,
@@ -34,6 +35,7 @@ html,
34
35
  font-feature-settings: var(--font-family-sans--font-feature-settings, normal); /* 5 */
35
36
  font-variation-settings: var(--font-family-sans--font-variation-settings, normal); /* 6 */
36
37
  -webkit-tap-highlight-color: transparent; /* 7 */
38
+ text-wrap-style: pretty; /* 8 */
37
39
  }
38
40
 
39
41
  /*
@@ -111,6 +113,7 @@ strong {
111
113
  2. Use the user's configured `mono` font-feature-settings by default.
112
114
  3. Use the user's configured `mono` font-variation-settings by default.
113
115
  4. Correct the odd `em` font sizing in all browsers.
116
+ 5. Keep code wrapping greedy so wrapped lines break where an editor would.
114
117
  */
115
118
 
116
119
  code,
@@ -121,6 +124,17 @@ pre {
121
124
  font-feature-settings: var(--font-family-mono--font-feature-settings, normal); /* 2 */
122
125
  font-variation-settings: var(--font-family-mono--font-variation-settings, normal); /* 3 */
123
126
  font-size: 1em; /* 4 */
127
+ text-wrap-style: auto; /* 5 */
128
+ }
129
+
130
+ /*
131
+ Keep earlier lines still while the user types.
132
+ */
133
+
134
+ input,
135
+ textarea,
136
+ [contenteditable] {
137
+ text-wrap-style: stable;
124
138
  }
125
139
 
126
140
  /*
@@ -49,6 +49,9 @@
49
49
  --container-5xl: 64rem;
50
50
  --container-6xl: 72rem;
51
51
  --container-7xl: 80rem;
52
+ /* Shared by the session timeline, composer dock, and drop target so their edges stay aligned.
53
+ The composer dock's 12px gutter leaves the design's 720px composer. */
54
+ --container-session: 744px;
52
55
 
53
56
  --font-sans: var(--font-family-sans);
54
57
  --font-sans--font-feature-settings: var(--font-family-sans--font-feature-settings);