@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.
- package/dist/context/dialog.d.ts +7 -0
- package/package.json +2 -1
- package/src/context/dialog.tsx +67 -4
- package/src/context/marked-base.tsx +2 -2
- package/src/icons/icon/additional-icons.ts +1 -1
- package/src/overlays/dialog/dialog.tsx +5 -3
- package/src/styles/base.css +14 -0
- package/src/styles/tailwind/index.css +3 -0
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
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@opencode/ui",
|
|
3
|
-
"version": "2.0.
|
|
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",
|
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
|
|
|
@@ -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,
|
|
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">${
|
|
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.
|
|
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
|
|
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
|
>
|
package/src/styles/base.css
CHANGED
|
@@ -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);
|