@opencode/ui 2.0.27 → 2.0.29

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,120 +1,128 @@
1
+ /* Vite can link this before the app entry CSS, so name every layer here or `components` ranks below Tailwind's base reset. */
2
+ @layer properties, theme, base, components, utilities;
1
3
  @import "../submit.css";
2
4
 
3
- [data-component="icon-button-v2"] {
4
- -webkit-font-smoothing: antialiased;
5
- -moz-osx-font-smoothing: grayscale;
6
- display: inline-flex;
7
- align-items: center;
8
- justify-content: center;
9
- border-radius: 6px;
10
- color: var(--v2-text-text-base);
11
- user-select: none;
12
- outline: none;
13
- padding: 0;
14
- cursor: default;
15
- }
16
-
17
- [data-component="icon-button-v2"]:focus {
18
- outline: none;
19
- }
20
-
21
- [data-component="icon-button-v2"]:is(:focus-visible, [data-state="focus"]):not(:disabled) {
22
- outline: 2px solid var(--v2-border-border-focus);
23
- outline-offset: 2.5px;
24
- }
25
-
26
- [data-component="icon-button-v2"][data-size="small"] {
27
- width: 20px;
28
- height: 20px;
29
- border-radius: 4px;
30
- }
31
-
32
- [data-component="icon-button-v2"][data-size="normal"] {
33
- width: 24px;
34
- height: 24px;
35
- }
36
-
37
- [data-component="icon-button-v2"][data-size="large"] {
38
- width: 28px;
39
- height: 28px;
40
- }
41
-
42
- [data-component="icon-button-v2"] [data-slot="icon-svg"] {
43
- color: currentColor;
44
- }
45
-
46
- /* Neutral */
47
- [data-component="icon-button-v2"][data-variant="neutral"] {
48
- background-color: var(--v2-background-bg-button-neutral);
49
- color: var(--v2-text-text-base);
50
- box-shadow: var(--v2-elevation-button-neutral);
51
- }
52
-
53
- [data-component="icon-button-v2"][data-variant="neutral"]:is(:hover, [data-state="hover"]):not(:disabled) {
54
- background-image:
55
- linear-gradient(90deg, var(--v2-overlay-simple-overlay-hover) 0%, var(--v2-overlay-simple-overlay-hover) 100%),
56
- linear-gradient(90deg, var(--v2-background-bg-button-neutral) 0%, var(--v2-background-bg-button-neutral) 100%);
57
- }
58
-
59
- [data-component="icon-button-v2"][data-variant="neutral"]:is(:active, [data-state="pressed"]):not(:disabled) {
60
- background-image:
61
- linear-gradient(90deg, var(--v2-overlay-simple-overlay-pressed) 0%, var(--v2-overlay-simple-overlay-pressed) 100%),
62
- linear-gradient(90deg, var(--v2-background-bg-button-neutral) 0%, var(--v2-background-bg-button-neutral) 100%);
63
- }
64
-
65
- [data-component="icon-button-v2"][data-variant="neutral"]:is(:disabled, [data-state="disabled"]) {
66
- opacity: 0.5;
67
- cursor: not-allowed;
68
- }
69
-
70
- /* Ghost */
71
- [data-component="icon-button-v2"][data-variant="ghost"] {
72
- background-color: transparent;
73
- color: var(--v2-text-text-base);
74
- }
75
-
76
- [data-component="icon-button-v2"][data-variant="ghost"]:is(:hover, [data-state="hover"]):not(:disabled):not(
77
- [data-expanded]
78
- ) {
79
- background-color: var(--v2-overlay-simple-overlay-hover);
80
- }
81
-
82
- [data-component="icon-button-v2"][data-variant="ghost"]:is(:active, [data-state="pressed"]):not(:disabled) {
83
- background-color: var(--v2-overlay-simple-overlay-pressed);
84
- }
85
-
86
- [data-component="icon-button-v2"][data-variant="ghost"]:where([data-expanded]):not(:disabled) {
87
- background-color: var(--v2-overlay-simple-overlay-pressed);
88
- }
89
-
90
- [data-component="icon-button-v2"][data-variant="ghost"]:is(:disabled, [data-state="disabled"]) {
91
- opacity: 0.5;
92
- cursor: not-allowed;
93
- }
94
-
95
- /* Ghost */
96
- [data-component="icon-button-v2"][data-variant="ghost-muted"] {
97
- background-color: transparent;
98
- color: var(--v2-icon-icon-muted);
99
- }
100
-
101
- [data-component="icon-button-v2"][data-variant="ghost-muted"]:is(:hover, [data-state="hover"]):not(:disabled):not(
102
- [data-expanded]
103
- ) {
104
- background-color: var(--v2-overlay-simple-overlay-hover);
105
- }
106
-
107
- [data-component="icon-button-v2"][data-variant="ghost-muted"]:is(:active, [data-state="pressed"]):not(:disabled) {
108
- background-color: var(--v2-overlay-simple-overlay-pressed);
109
- color: var(--v2-icon-icon-base);
110
- }
111
-
112
- [data-component="icon-button-v2"][data-variant="ghost-muted"]:where([data-expanded]):not(:disabled) {
113
- background-color: var(--v2-overlay-simple-overlay-pressed);
114
- color: var(--v2-icon-icon-base);
115
- }
116
-
117
- [data-component="icon-button-v2"][data-variant="ghost-muted"]:is(:disabled, [data-state="disabled"]) {
118
- opacity: 0.5;
119
- cursor: not-allowed;
5
+ @layer components {
6
+ [data-component="icon-button-v2"] {
7
+ -webkit-font-smoothing: antialiased;
8
+ -moz-osx-font-smoothing: grayscale;
9
+ display: inline-flex;
10
+ align-items: center;
11
+ justify-content: center;
12
+ border-radius: 6px;
13
+ color: var(--v2-text-text-base);
14
+ user-select: none;
15
+ outline: none;
16
+ padding: 0;
17
+ cursor: default;
18
+ }
19
+
20
+ [data-component="icon-button-v2"]:focus {
21
+ outline: none;
22
+ }
23
+
24
+ [data-component="icon-button-v2"]:is(:focus-visible, [data-state="focus"]):not(:disabled) {
25
+ outline: 2px solid var(--v2-border-border-focus);
26
+ outline-offset: 2.5px;
27
+ }
28
+
29
+ [data-component="icon-button-v2"][data-size="small"] {
30
+ width: 20px;
31
+ height: 20px;
32
+ border-radius: 4px;
33
+ }
34
+
35
+ [data-component="icon-button-v2"][data-size="normal"] {
36
+ width: 24px;
37
+ height: 24px;
38
+ }
39
+
40
+ [data-component="icon-button-v2"][data-size="large"] {
41
+ width: 28px;
42
+ height: 28px;
43
+ }
44
+
45
+ [data-component="icon-button-v2"] [data-slot="icon-svg"] {
46
+ color: currentColor;
47
+ }
48
+
49
+ /* Neutral */
50
+ [data-component="icon-button-v2"][data-variant="neutral"] {
51
+ background-color: var(--v2-background-bg-button-neutral);
52
+ color: var(--v2-text-text-base);
53
+ box-shadow: var(--v2-elevation-button-neutral);
54
+ }
55
+
56
+ [data-component="icon-button-v2"][data-variant="neutral"]:is(:hover, [data-state="hover"]):not(:disabled) {
57
+ background-image:
58
+ linear-gradient(90deg, var(--v2-overlay-simple-overlay-hover) 0%, var(--v2-overlay-simple-overlay-hover) 100%),
59
+ linear-gradient(90deg, var(--v2-background-bg-button-neutral) 0%, var(--v2-background-bg-button-neutral) 100%);
60
+ }
61
+
62
+ [data-component="icon-button-v2"][data-variant="neutral"]:is(:active, [data-state="pressed"]):not(:disabled) {
63
+ background-image:
64
+ linear-gradient(
65
+ 90deg,
66
+ var(--v2-overlay-simple-overlay-pressed) 0%,
67
+ var(--v2-overlay-simple-overlay-pressed) 100%
68
+ ),
69
+ linear-gradient(90deg, var(--v2-background-bg-button-neutral) 0%, var(--v2-background-bg-button-neutral) 100%);
70
+ }
71
+
72
+ [data-component="icon-button-v2"][data-variant="neutral"]:is(:disabled, [data-state="disabled"]) {
73
+ opacity: 0.5;
74
+ cursor: not-allowed;
75
+ }
76
+
77
+ /* Ghost */
78
+ [data-component="icon-button-v2"][data-variant="ghost"] {
79
+ background-color: transparent;
80
+ color: var(--v2-text-text-base);
81
+ }
82
+
83
+ [data-component="icon-button-v2"][data-variant="ghost"]:is(:hover, [data-state="hover"]):not(:disabled):not(
84
+ [data-expanded]
85
+ ) {
86
+ background-color: var(--v2-overlay-simple-overlay-hover);
87
+ }
88
+
89
+ [data-component="icon-button-v2"][data-variant="ghost"]:is(:active, [data-state="pressed"]):not(:disabled) {
90
+ background-color: var(--v2-overlay-simple-overlay-pressed);
91
+ }
92
+
93
+ [data-component="icon-button-v2"][data-variant="ghost"]:where([data-expanded]):not(:disabled) {
94
+ background-color: var(--v2-overlay-simple-overlay-pressed);
95
+ }
96
+
97
+ [data-component="icon-button-v2"][data-variant="ghost"]:is(:disabled, [data-state="disabled"]) {
98
+ opacity: 0.5;
99
+ cursor: not-allowed;
100
+ }
101
+
102
+ /* Ghost */
103
+ [data-component="icon-button-v2"][data-variant="ghost-muted"] {
104
+ background-color: transparent;
105
+ color: var(--v2-icon-icon-muted);
106
+ }
107
+
108
+ [data-component="icon-button-v2"][data-variant="ghost-muted"]:is(:hover, [data-state="hover"]):not(:disabled):not(
109
+ [data-expanded]
110
+ ) {
111
+ background-color: var(--v2-overlay-simple-overlay-hover);
112
+ }
113
+
114
+ [data-component="icon-button-v2"][data-variant="ghost-muted"]:is(:active, [data-state="pressed"]):not(:disabled) {
115
+ background-color: var(--v2-overlay-simple-overlay-pressed);
116
+ color: var(--v2-icon-icon-base);
117
+ }
118
+
119
+ [data-component="icon-button-v2"][data-variant="ghost-muted"]:where([data-expanded]):not(:disabled) {
120
+ background-color: var(--v2-overlay-simple-overlay-pressed);
121
+ color: var(--v2-icon-icon-base);
122
+ }
123
+
124
+ [data-component="icon-button-v2"][data-variant="ghost-muted"]:is(:disabled, [data-state="disabled"]) {
125
+ opacity: 0.5;
126
+ cursor: not-allowed;
127
+ }
120
128
  }
@@ -1,45 +1,68 @@
1
- /* Shared by Composer submit actions and contrast icon buttons. */
2
- :is(
3
- [data-component="button-v2"][data-variant="submit"],
4
- [data-component="icon-button-v2"]:is([data-variant="submit"], [data-variant="contrast"])
5
- ) {
6
- --submit-highlight: var(--v2-alpha-light-20);
7
- --submit-hover: var(--v2-overlay-simple-overlay-contrast-hover);
1
+ /* Vite can link this before the app entry CSS, so name every layer here or `components` ranks below Tailwind's base reset. */
2
+ @layer properties, theme, base, components, utilities;
8
3
 
9
- background-image:
10
- linear-gradient(180deg, var(--submit-highlight) 0%, var(--v2-alpha-light-0) 100%),
11
- linear-gradient(
12
- 90deg,
13
- var(--v2-background-bg-icon-button-contrast) 0%,
14
- var(--v2-background-bg-icon-button-contrast) 100%
15
- );
16
- color: var(--v2-text-text-contrast);
17
- text-shadow: 0 0.5px 0.5px rgba(0, 0, 0, 0.3);
18
- box-shadow: var(--v2-elevation-button-contrast);
19
- }
20
-
21
- [data-color-scheme="dark"]
4
+ @layer components {
5
+ /* Shared by Composer submit actions and contrast icon buttons. */
22
6
  :is(
23
7
  [data-component="button-v2"][data-variant="submit"],
24
8
  [data-component="icon-button-v2"]:is([data-variant="submit"], [data-variant="contrast"])
25
9
  ) {
26
- --submit-highlight: var(--v2-alpha-light-60);
27
- --submit-hover: var(--v2-alpha-dark-8);
10
+ --submit-highlight: var(--v2-alpha-light-20);
11
+ --submit-hover: var(--v2-overlay-simple-overlay-contrast-hover);
28
12
 
29
- color: var(--v2-text-text-inverse);
30
- }
13
+ background-image:
14
+ linear-gradient(180deg, var(--submit-highlight) 0%, var(--v2-alpha-light-0) 100%),
15
+ linear-gradient(
16
+ 90deg,
17
+ var(--v2-background-bg-icon-button-contrast) 0%,
18
+ var(--v2-background-bg-icon-button-contrast) 100%
19
+ );
20
+ color: var(--v2-text-text-contrast);
21
+ text-shadow: 0 0.5px 0.5px rgba(0, 0, 0, 0.3);
22
+ box-shadow: var(--v2-elevation-button-contrast);
23
+ }
31
24
 
32
- [data-color-scheme="dark"] [data-component="icon-button-v2"]:is([data-variant="submit"], [data-variant="contrast"]) {
33
- color: var(--v2-icon-icon-inverse);
34
- }
25
+ [data-color-scheme="dark"]
26
+ :is(
27
+ [data-component="button-v2"][data-variant="submit"],
28
+ [data-component="icon-button-v2"]:is([data-variant="submit"], [data-variant="contrast"])
29
+ ) {
30
+ --submit-highlight: var(--v2-alpha-light-60);
31
+ --submit-hover: var(--v2-alpha-dark-8);
32
+
33
+ color: var(--v2-text-text-inverse);
34
+ }
35
+
36
+ [data-color-scheme="dark"] [data-component="icon-button-v2"]:is([data-variant="submit"], [data-variant="contrast"]) {
37
+ color: var(--v2-icon-icon-inverse);
38
+ }
39
+
40
+ @media (hover: hover) {
41
+ :is(
42
+ [data-component="button-v2"][data-variant="submit"],
43
+ [data-component="icon-button-v2"]:is([data-variant="submit"], [data-variant="contrast"])
44
+ ):is(:hover, [data-state="hover"]):not(:disabled) {
45
+ background-image:
46
+ linear-gradient(90deg, var(--submit-hover) 0%, var(--submit-hover) 100%),
47
+ linear-gradient(180deg, var(--submit-highlight) 0%, var(--v2-alpha-light-0) 100%),
48
+ linear-gradient(
49
+ 90deg,
50
+ var(--v2-background-bg-icon-button-contrast) 0%,
51
+ var(--v2-background-bg-icon-button-contrast) 100%
52
+ );
53
+ }
54
+ }
35
55
 
36
- @media (hover: hover) {
37
56
  :is(
38
57
  [data-component="button-v2"][data-variant="submit"],
39
58
  [data-component="icon-button-v2"]:is([data-variant="submit"], [data-variant="contrast"])
40
- ):is(:hover, [data-state="hover"]):not(:disabled) {
59
+ ):is(:active, [data-state="pressed"]):not(:disabled) {
41
60
  background-image:
42
- linear-gradient(90deg, var(--submit-hover) 0%, var(--submit-hover) 100%),
61
+ linear-gradient(
62
+ 90deg,
63
+ var(--v2-overlay-simple-overlay-contrast-pressed) 0%,
64
+ var(--v2-overlay-simple-overlay-contrast-pressed) 100%
65
+ ),
43
66
  linear-gradient(180deg, var(--submit-highlight) 0%, var(--v2-alpha-light-0) 100%),
44
67
  linear-gradient(
45
68
  90deg,
@@ -47,30 +70,12 @@
47
70
  var(--v2-background-bg-icon-button-contrast) 100%
48
71
  );
49
72
  }
50
- }
51
73
 
52
- :is(
74
+ :is(
53
75
  [data-component="button-v2"][data-variant="submit"],
54
76
  [data-component="icon-button-v2"]:is([data-variant="submit"], [data-variant="contrast"])
55
- ):is(:active, [data-state="pressed"]):not(:disabled) {
56
- background-image:
57
- linear-gradient(
58
- 90deg,
59
- var(--v2-overlay-simple-overlay-contrast-pressed) 0%,
60
- var(--v2-overlay-simple-overlay-contrast-pressed) 100%
61
- ),
62
- linear-gradient(180deg, var(--submit-highlight) 0%, var(--v2-alpha-light-0) 100%),
63
- linear-gradient(
64
- 90deg,
65
- var(--v2-background-bg-icon-button-contrast) 0%,
66
- var(--v2-background-bg-icon-button-contrast) 100%
67
- );
68
- }
69
-
70
- :is(
71
- [data-component="button-v2"][data-variant="submit"],
72
- [data-component="icon-button-v2"]:is([data-variant="submit"], [data-variant="contrast"])
73
- ):is(:disabled, [data-state="disabled"]) {
74
- opacity: 0.4;
75
- cursor: not-allowed;
77
+ ):is(:disabled, [data-state="disabled"]) {
78
+ opacity: 0.4;
79
+ cursor: not-allowed;
80
+ }
76
81
  }
@@ -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. */
@@ -108,6 +117,9 @@ function init() {
108
117
 
109
118
  const onKeyDown = (event: KeyboardEvent) => {
110
119
  if (event.key !== "Escape") return
120
+
121
+ // A control that cancels its own edit on Escape, such as an inline rename, keeps the dialog open.
122
+ if (event.target instanceof Element && event.target.closest("[data-owns-escape]")) return
111
123
  dismiss()
112
124
  event.preventDefault()
113
125
  event.stopPropagation()
@@ -116,7 +128,13 @@ function init() {
116
128
  makeEventListener(window, "keydown", onKeyDown, { capture: true })
117
129
  })
118
130
 
119
- const mount = (element: DialogElement, owner: Owner, onClose: (() => void) | undefined, key?: string) => {
131
+ const mount = (
132
+ element: DialogElement,
133
+ owner: Owner,
134
+ onClose: (() => void) | undefined,
135
+ key: string | undefined,
136
+ focus: FocusReturn | undefined,
137
+ ) => {
120
138
  // A deferred open (e.g. from a focus callback) must not mount after the provider is gone.
121
139
  if (state.disposed) return
122
140
  const id = key ?? Math.random().toString(36).slice(2)
@@ -131,6 +149,7 @@ function init() {
131
149
  const zIndex = () => String(50 + layer() * 10)
132
150
  let dispose: (() => void) | undefined
133
151
  let setClosing: ((closing: boolean) => void) | undefined
152
+ let layerRef: HTMLDivElement | undefined
134
153
 
135
154
  // Stacked dialogs render as sibling portals, so only the top layer may own the focus trap.
136
155
  const node = runWithOwner(owner, () =>
@@ -157,6 +176,7 @@ function init() {
157
176
  }}
158
177
  />
159
178
  <div
179
+ ref={layerRef}
160
180
  data-dialog-layer={layer()}
161
181
  style={{
162
182
  position: "fixed",
@@ -178,19 +198,35 @@ function init() {
178
198
 
179
199
  if (!dispose || !setClosing) return
180
200
 
181
- const active: Active = { id, node, dispose, owner, onClose, setClosing }
201
+ const active: Active = {
202
+ id,
203
+ node,
204
+ dispose,
205
+ owner,
206
+ onClose,
207
+ setClosing,
208
+ focus,
209
+ returnFocus: () => {
210
+ // A close inside a reactive update unmounts the dialog, and its focus trap, only when the update ends.
211
+ if (layerRef?.contains(document.activeElement)) return queueMicrotask(() => returnFocus(focus, layerRef))
212
+ returnFocus(focus, layerRef)
213
+ },
214
+ }
215
+
182
216
  setStack((items) => [...items, active])
183
217
  }
184
218
 
185
219
  const push = (element: DialogElement, owner: Owner, onClose?: () => void, id?: string) => {
186
220
  lock.value = false
187
- mount(element, owner, onClose, id)
221
+ mount(element, owner, onClose, id, captureFocus())
188
222
  }
189
223
 
190
224
  const show = (element: DialogElement, owner: Owner, onClose?: () => void, id?: string) => {
225
+ // A replacement returns focus to where the dialogs it replaces would have, not to their now-removed content.
226
+ const focus = stack().length > 0 ? stack()[0].focus : captureFocus()
191
227
  disposeAll(true)
192
228
  lock.value = false
193
- mount(element, owner, onClose, id)
229
+ mount(element, owner, onClose, id, focus)
194
230
  }
195
231
 
196
232
  return {
@@ -210,6 +246,36 @@ function isolate(fn: () => void) {
210
246
  }
211
247
  }
212
248
 
249
+ /** Remembers the focused element and, in editable content, the caret, which focusing alone does not restore. */
250
+ function captureFocus(): FocusReturn | undefined {
251
+ const element = document.activeElement
252
+
253
+ if (!(element instanceof HTMLElement) || element === document.body) return
254
+ const selection = document.getSelection()
255
+
256
+ if (!element.isContentEditable || !selection?.rangeCount || !element.contains(selection.anchorNode)) {
257
+ return { element }
258
+ }
259
+
260
+ return { element, range: selection.getRangeAt(0).cloneRange() }
261
+ }
262
+
263
+ /** Kobalte only returns focus to a dialog trigger, and these dialogs open without one. */
264
+ function returnFocus(focus: FocusReturn | undefined, layer: HTMLElement | undefined) {
265
+ const active = document.activeElement
266
+
267
+ if (!focus?.element.isConnected) return
268
+
269
+ // Something outside the closed dialog already has focus, for example because the dialog's onClose moved it.
270
+ if (active && active !== document.body && !layer?.contains(active)) return
271
+ focus.element.focus({ preventScroll: true })
272
+
273
+ if (!focus.range || !focus.element.contains(focus.range.commonAncestorContainer)) return
274
+ const selection = document.getSelection()
275
+ selection?.removeAllRanges()
276
+ selection?.addRange(focus.range)
277
+ }
278
+
213
279
  export function DialogProvider(props: ParentProps) {
214
280
  const ctx = init()
215
281
 
@@ -32,6 +32,11 @@
32
32
  color: var(--v2-text-text-contrast);
33
33
  }
34
34
 
35
+ [data-component="tag"][data-variant="subtle"] {
36
+ border: 0;
37
+ background: var(--v2-background-bg-layer-03);
38
+ }
39
+
35
40
  [data-component="tag"][data-appearance="standard"] {
36
41
  height: 18px;
37
42
  padding: 0 6px;
@@ -3,7 +3,7 @@ import "./badge.css"
3
3
 
4
4
  export interface BadgeProps extends ComponentProps<"span"> {
5
5
  appearance?: "standard" | "compact"
6
- variant?: "neutral" | "accent"
6
+ variant?: "neutral" | "accent" | "subtle"
7
7
  }
8
8
 
9
9
  export function Badge(props: BadgeProps) {