@opencode/ui 0.0.0-dev-20716 → 0.0.0-dev-20718

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,7 +1,20 @@
1
- import { type JSX, type ParentProps } from "solid-js";
2
- import type { ComponentProps } from "solid-js";
1
+ import { type Transition } from "@kitlangton/solid-motion";
2
+ import { type ComponentProps, type JSX, type ParentProps } from "solid-js";
3
3
  import "./segmented-control.css";
4
4
  type OnChange = (value: string | null) => void;
5
+ export declare const SEGMENTED_CONTROL_SPRING: Transition;
6
+ export type OffsetElement = {
7
+ offsetLeft: number;
8
+ offsetWidth: number;
9
+ offsetParent: Element | OffsetElement | null;
10
+ getBoundingClientRect: () => {
11
+ left: number;
12
+ };
13
+ };
14
+ export declare function measureOffset(root: OffsetElement, button: OffsetElement): {
15
+ x: number;
16
+ width: number;
17
+ };
5
18
  export type SegmentedControlProps = Omit<ComponentProps<"div">, "onChange"> & ParentProps<{
6
19
  /** Selected value when controlled (including `null` when empty). Omit key for uncontrolled. */
7
20
  value?: string | null;
@@ -11,6 +24,8 @@ export type SegmentedControlProps = Omit<ComponentProps<"div">, "onChange"> & Pa
11
24
  /** When true, clicking the active segment clears selection (`onChange(null)`). Default false. */
12
25
  allowDeselect?: boolean;
13
26
  disabled?: boolean;
27
+ /** Motion transition for the active indicator pill. */
28
+ transition?: Transition;
14
29
  }>;
15
30
  export declare function SegmentedControl(props: SegmentedControlProps): JSX.Element;
16
31
  export type SegmentedControlItemProps = Omit<ComponentProps<"button">, "type" | "children"> & ParentProps<{
@@ -18,4 +33,8 @@ export type SegmentedControlItemProps = Omit<ComponentProps<"button">, "type" |
18
33
  children: JSX.Element;
19
34
  }>;
20
35
  export declare function SegmentedControlItem(props: SegmentedControlItemProps): JSX.Element;
36
+ export declare const SegmentedControlV2: typeof SegmentedControl;
37
+ export declare const SegmentedControlItemV2: typeof SegmentedControlItem;
38
+ export type SegmentedControlV2Props = SegmentedControlProps;
39
+ export type SegmentedControlItemV2Props = SegmentedControlItemProps;
21
40
  export {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opencode/ui",
3
- "version": "0.0.0-dev-20716",
3
+ "version": "0.0.0-dev-20718",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -244,6 +244,7 @@
244
244
  "vite-plugin-solid": "2.11.10"
245
245
  },
246
246
  "dependencies": {
247
+ "@kitlangton/solid-motion": "0.2.2",
247
248
  "@kobalte/core": "0.13.13",
248
249
  "@pierre/diffs": "1.5.1",
249
250
  "@solid-primitives/event-listener": "2.4.6",
@@ -1,12 +1,8 @@
1
1
  @import "../submit.css";
2
2
 
3
- * {
3
+ [data-component="button-v2"] {
4
4
  -webkit-font-smoothing: antialiased;
5
5
  -moz-osx-font-smoothing: grayscale;
6
- text-rendering: geometricPrecision;
7
- }
8
-
9
- [data-component="button-v2"] {
10
6
  position: relative;
11
7
  display: inline-flex;
12
8
  align-items: center;
@@ -26,6 +22,10 @@
26
22
  outline: none;
27
23
  }
28
24
 
25
+ [data-component="button-v2"] > * {
26
+ pointer-events: none;
27
+ }
28
+
29
29
  [data-component="button-v2"]:focus {
30
30
  outline: none;
31
31
  }
@@ -1,12 +1,8 @@
1
1
  @import "../submit.css";
2
2
 
3
- * {
3
+ [data-component="icon-button-v2"] {
4
4
  -webkit-font-smoothing: antialiased;
5
5
  -moz-osx-font-smoothing: grayscale;
6
- text-rendering: geometricPrecision;
7
- }
8
-
9
- [data-component="icon-button-v2"] {
10
6
  display: inline-flex;
11
7
  align-items: center;
12
8
  justify-content: center;
@@ -3,6 +3,8 @@
3
3
  }
4
4
 
5
5
  [data-slot="segmented-control-v2"] {
6
+ position: relative;
7
+ isolation: isolate;
6
8
  box-sizing: border-box;
7
9
  display: flex;
8
10
  flex-direction: row;
@@ -16,9 +18,28 @@
16
18
  border-radius: 6px;
17
19
  box-shadow: 0 0 0 0.5px var(--v2-border-border-base);
18
20
  flex: none;
21
+ user-select: none;
22
+ -webkit-user-select: none;
23
+ }
24
+
25
+ [data-slot="segmented-control-v2-indicator"] {
26
+ position: absolute;
27
+ top: 0;
28
+ bottom: 0;
29
+ left: 0;
30
+ border-radius: 6px;
31
+ background: var(--v2-background-bg-base);
32
+ box-shadow: 0 0 0 0.5px var(--v2-border-border-strong);
33
+ pointer-events: none;
34
+ user-select: none;
35
+ -webkit-user-select: none;
36
+ z-index: 0;
37
+ will-change: transform, width;
19
38
  }
20
39
 
21
40
  [data-slot="segmented-control-v2-item"] {
41
+ position: relative;
42
+ z-index: 1;
22
43
  box-sizing: border-box;
23
44
  display: flex;
24
45
  flex-direction: column;
@@ -34,20 +55,26 @@
34
55
  background: transparent;
35
56
  box-shadow: none;
36
57
  cursor: pointer;
58
+ user-select: none;
59
+ -webkit-user-select: none;
37
60
  font-style: normal;
38
61
  font-weight: 440;
39
62
  font-size: 13px;
40
- line-height: var(--line-height-compact);
63
+ line-height: 100%;
41
64
  letter-spacing: -0.04px;
42
65
  font-variant-numeric: tabular-nums;
43
66
  font-variation-settings: "slnt" 0;
44
67
  color: var(--v2-text-text-muted);
45
68
  transition:
69
+ color 0.15s ease,
46
70
  background-color 0.12s ease,
47
- color 0.12s ease,
48
71
  box-shadow 0.12s ease;
49
72
  }
50
73
 
74
+ [data-slot="segmented-control-v2-item"]:where(:hover:not(:disabled):not([data-pressed])) {
75
+ color: var(--v2-text-text-base);
76
+ }
77
+
51
78
  [data-slot="segmented-control-v2-item"]:where(:disabled) {
52
79
  cursor: not-allowed;
53
80
  opacity: 0.45;
@@ -60,12 +87,15 @@
60
87
  }
61
88
 
62
89
  [data-slot="segmented-control-v2-item"]:where([data-pressed]) {
63
- background: var(--v2-background-bg-base);
64
90
  color: var(--v2-text-text-base);
65
- box-shadow: 0 0 0 0.5px var(--v2-border-border-strong);
66
91
  z-index: 1;
67
92
  }
68
93
 
94
+ [data-slot="segmented-control-v2"]:not([data-ready]) [data-slot="segmented-control-v2-item"]:where([data-pressed]) {
95
+ background: var(--v2-background-bg-base);
96
+ box-shadow: 0 0 0 0.5px var(--v2-border-border-strong);
97
+ }
98
+
69
99
  [data-slot="segmented-control-v2-item-label"] {
70
100
  display: flex;
71
101
  flex-direction: row;
@@ -77,4 +107,7 @@
77
107
  overflow: hidden;
78
108
  text-overflow: ellipsis;
79
109
  white-space: nowrap;
110
+ pointer-events: none;
111
+ user-select: none;
112
+ -webkit-user-select: none;
80
113
  }
@@ -1,25 +1,37 @@
1
+ import { animate, motion, useMotionValue, type Transition } from "@kitlangton/solid-motion"
1
2
  import {
2
3
  createContext,
4
+ createEffect,
3
5
  createMemo,
4
6
  createSignal,
5
7
  mergeProps,
8
+ onCleanup,
9
+ onMount,
10
+ Show,
6
11
  splitProps,
7
12
  useContext,
8
13
  type Accessor,
14
+ type ComponentProps,
9
15
  type JSX,
10
16
  type ParentProps,
11
17
  } from "solid-js"
12
- import type { ComponentProps } from "solid-js"
13
18
  import "./segmented-control.css"
14
19
 
15
20
  type OnChange = (value: string | null) => void
16
21
 
22
+ export const SEGMENTED_CONTROL_SPRING: Transition = {
23
+ type: "spring",
24
+ visualDuration: 0.15,
25
+ bounce: 0,
26
+ }
27
+
17
28
  type SegmentedControlContextValue = {
18
29
  selected: Accessor<string | null>
19
30
  groupDisabled: Accessor<boolean>
20
31
  select: (value: string) => void
21
32
  clearIfAllowed: (value: string) => void
22
33
  focusNext: (from: HTMLButtonElement, direction: 1 | -1) => void
34
+ registerItem: (value: string, el: HTMLButtonElement) => () => void
23
35
  }
24
36
 
25
37
  const SegmentedControlContext = createContext<SegmentedControlContextValue>()
@@ -32,6 +44,33 @@ function useSegmentedControlContext() {
32
44
  return ctx
33
45
  }
34
46
 
47
+ export type OffsetElement = {
48
+ offsetLeft: number
49
+ offsetWidth: number
50
+ offsetParent: Element | OffsetElement | null
51
+ getBoundingClientRect: () => { left: number }
52
+ }
53
+
54
+ function isOffsetElement(node: Element | OffsetElement | null): node is OffsetElement {
55
+ return node !== null && "offsetLeft" in node && "offsetParent" in node
56
+ }
57
+
58
+ export function measureOffset(root: OffsetElement, button: OffsetElement) {
59
+ let offset = 0
60
+ let node: OffsetElement | null = button
61
+
62
+ while (node && node !== root) {
63
+ offset += node.offsetLeft
64
+ node = isOffsetElement(node.offsetParent) ? node.offsetParent : null
65
+ }
66
+
67
+ if (node !== root) {
68
+ offset = button.getBoundingClientRect().left - root.getBoundingClientRect().left
69
+ }
70
+
71
+ return { x: offset, width: button.offsetWidth }
72
+ }
73
+
35
74
  export type SegmentedControlProps = Omit<ComponentProps<"div">, "onChange"> &
36
75
  ParentProps<{
37
76
  /** Selected value when controlled (including `null` when empty). Omit key for uncontrolled. */
@@ -42,10 +81,12 @@ export type SegmentedControlProps = Omit<ComponentProps<"div">, "onChange"> &
42
81
  /** When true, clicking the active segment clears selection (`onChange(null)`). Default false. */
43
82
  allowDeselect?: boolean
44
83
  disabled?: boolean
84
+ /** Motion transition for the active indicator pill. */
85
+ transition?: Transition
45
86
  }>
46
87
 
47
88
  export function SegmentedControl(props: SegmentedControlProps) {
48
- const isControlled = createMemo(() => Object.hasOwn(props as object, "value"))
89
+ const isControlled = createMemo(() => Object.hasOwn(props, "value"))
49
90
  const merged = mergeProps({ allowDeselect: false, disabled: false }, props)
50
91
 
51
92
  const [local, rest] = splitProps(merged, [
@@ -57,13 +98,88 @@ export function SegmentedControl(props: SegmentedControlProps) {
57
98
  "onChange",
58
99
  "allowDeselect",
59
100
  "disabled",
101
+ "transition",
60
102
  "ref",
61
103
  ])
62
104
 
63
105
  const [internal, setInternal] = createSignal<string | null>(local.defaultValue ?? null)
106
+ const [layoutVersion, setLayoutVersion] = createSignal(0)
107
+ const [ready, setReady] = createSignal(false)
64
108
 
65
109
  const selected = createMemo(() => (isControlled() ? (local.value ?? null) : internal()))
66
110
 
111
+ const reducedMotion = () => globalThis.matchMedia?.("(prefers-reduced-motion: reduce)")?.matches === true
112
+
113
+ const x = useMotionValue(0)
114
+ const width = useMotionValue(0)
115
+
116
+ let rootEl: HTMLDivElement | undefined
117
+ let observer: ResizeObserver | undefined
118
+ const items = new Map<string, HTMLButtonElement>()
119
+ let target = { x: 0, width: 0 }
120
+ let lastSelected: string | null = null
121
+
122
+ const syncIndicator = (shouldAnimate: boolean) => {
123
+ const current = selected()
124
+ const button = current !== null ? items.get(current) : undefined
125
+
126
+ if (!rootEl || !button) {
127
+ setReady(false)
128
+
129
+ return
130
+ }
131
+
132
+ const next = measureOffset(rootEl, button)
133
+
134
+ if (next.width === 0) return
135
+
136
+ target = next
137
+
138
+ if (!ready() || !shouldAnimate || reducedMotion()) {
139
+ x.jump(next.x)
140
+ width.jump(next.width)
141
+ setReady(true)
142
+
143
+ return
144
+ }
145
+
146
+ const transition = local.transition ?? SEGMENTED_CONTROL_SPRING
147
+ animate(x, next.x, transition)
148
+ animate(width, next.width, transition)
149
+ }
150
+
151
+ onMount(() => {
152
+ if (!("ResizeObserver" in globalThis)) return
153
+
154
+ observer = new ResizeObserver(() => {
155
+ const current = selected()
156
+ const button = current !== null ? items.get(current) : undefined
157
+
158
+ if (!rootEl || !button) return
159
+
160
+ const next = measureOffset(rootEl, button)
161
+
162
+ if (next.width === 0) return
163
+
164
+ if (ready() && next.x === target.x && next.width === target.width) return
165
+
166
+ syncIndicator(false)
167
+ })
168
+
169
+ if (rootEl) observer.observe(rootEl)
170
+
171
+ items.forEach((button) => observer?.observe(button))
172
+ onCleanup(() => observer?.disconnect())
173
+ })
174
+
175
+ createEffect(() => {
176
+ const current = selected()
177
+ layoutVersion()
178
+ const shouldAnimate = ready() && lastSelected !== null && current !== null && current !== lastSelected
179
+ lastSelected = current
180
+ syncIndicator(shouldAnimate)
181
+ })
182
+
67
183
  const setSelected = (next: string | null) => {
68
184
  if (!isControlled()) setInternal(next)
69
185
  local.onChange?.(next)
@@ -92,19 +208,32 @@ export function SegmentedControl(props: SegmentedControlProps) {
92
208
  next?.focus()
93
209
  }
94
210
 
211
+ const registerItem = (value: string, el: HTMLButtonElement) => {
212
+ items.set(value, el)
213
+ observer?.observe(el)
214
+ setLayoutVersion((v) => v + 1)
215
+
216
+ return () => {
217
+ if (items.get(value) === el) items.delete(value)
218
+ observer?.unobserve(el)
219
+ setLayoutVersion((v) => v + 1)
220
+ }
221
+ }
222
+
95
223
  const ctx: SegmentedControlContextValue = {
96
224
  selected,
97
225
  groupDisabled: () => !!local.disabled,
98
226
  select,
99
227
  clearIfAllowed,
100
228
  focusNext,
229
+ registerItem,
101
230
  }
102
231
 
103
- const assignRef = (el: HTMLDivElement | undefined) => {
232
+ const assignRef = (el: HTMLDivElement) => {
233
+ rootEl = el
104
234
  const r = local.ref
105
235
 
106
- if (typeof r === "function") (r as (el: HTMLDivElement | undefined) => void)(el)
107
- else if (r != null && typeof r === "object" && "value" in r) (r as { value: HTMLDivElement | undefined }).value = el
236
+ if (r instanceof Function) r(el)
108
237
  }
109
238
 
110
239
  return (
@@ -115,11 +244,15 @@ export function SegmentedControl(props: SegmentedControlProps) {
115
244
  role="group"
116
245
  data-component="segmented-control-v2"
117
246
  data-slot="segmented-control-v2"
247
+ data-ready={ready() ? "" : undefined}
118
248
  classList={{
119
249
  ...local.classList,
120
250
  [local.class ?? ""]: !!local.class,
121
251
  }}
122
252
  >
253
+ <Show when={ready() && selected() !== null}>
254
+ <motion.span data-slot="segmented-control-v2-indicator" aria-hidden="true" style={{ x, width }} />
255
+ </Show>
123
256
  {local.children}
124
257
  </div>
125
258
  </SegmentedControlContext.Provider>
@@ -134,9 +267,17 @@ export type SegmentedControlItemProps = Omit<ComponentProps<"button">, "type" |
134
267
 
135
268
  function invokeButtonHandler<E extends Event>(
136
269
  handler: JSX.EventHandlerUnion<HTMLButtonElement, E> | undefined,
137
- e: E & { currentTarget: HTMLButtonElement },
270
+ e: Parameters<JSX.EventHandler<HTMLButtonElement, E>>[0],
138
271
  ) {
139
- if (typeof handler === "function") (handler as (ev: typeof e) => void)(e)
272
+ if (handler instanceof Function) {
273
+ handler(e)
274
+
275
+ return
276
+ }
277
+
278
+ if (Array.isArray(handler)) {
279
+ handler[0](handler[1], e)
280
+ }
140
281
  }
141
282
 
142
283
  export function SegmentedControlItem(props: SegmentedControlItemProps) {
@@ -150,10 +291,18 @@ export function SegmentedControlItem(props: SegmentedControlItemProps) {
150
291
  "disabled",
151
292
  "onClick",
152
293
  "onKeyDown",
294
+ "ref",
153
295
  ])
154
296
 
155
297
  const ctx = useSegmentedControlContext()
156
298
 
299
+ let buttonEl: HTMLButtonElement | undefined
300
+ createEffect(() => {
301
+ if (!buttonEl) return
302
+ const cleanup = ctx.registerItem(local.value, buttonEl)
303
+ onCleanup(cleanup)
304
+ })
305
+
157
306
  const pressed = createMemo(() => ctx.selected() === local.value)
158
307
  const disabled = createMemo(() => ctx.groupDisabled() || !!local.disabled)
159
308
 
@@ -162,27 +311,38 @@ export function SegmentedControlItem(props: SegmentedControlItemProps) {
162
311
 
163
312
  if (e.defaultPrevented || disabled()) return
164
313
 
165
- if (pressed()) ctx.clearIfAllowed(local.value)
166
- else ctx.select(local.value)
314
+ if (pressed()) {
315
+ ctx.clearIfAllowed(local.value)
316
+
317
+ return
318
+ }
319
+
320
+ ctx.select(local.value)
167
321
  }
168
322
 
169
323
  const onKeyDown: JSX.EventHandlerUnion<HTMLButtonElement, KeyboardEvent> = (e) => {
170
324
  invokeButtonHandler(local.onKeyDown, e)
171
325
 
172
326
  if (e.defaultPrevented || disabled()) return
327
+
173
328
  const t = e.currentTarget
174
329
  const horizontal = t.closest(`[data-slot="segmented-control-v2"]`)?.matches(":dir(rtl)") ? -1 : 1
175
330
 
176
331
  if (e.key === "ArrowRight") {
177
332
  e.preventDefault()
178
333
  ctx.focusNext(t, horizontal)
179
- } else if (e.key === "ArrowLeft") {
334
+
335
+ return
336
+ }
337
+
338
+ if (e.key === "ArrowLeft") {
180
339
  e.preventDefault()
181
- ctx.focusNext(t, -horizontal as 1 | -1)
340
+ ctx.focusNext(t, horizontal === 1 ? -1 : 1)
341
+
342
+ return
182
343
  }
183
344
 
184
- // accessibility stuff
185
- else if (e.key === "Home") {
345
+ if (e.key === "Home") {
186
346
  e.preventDefault()
187
347
  const root = t.closest(`[data-slot="segmented-control-v2"]`)
188
348
 
@@ -191,7 +351,11 @@ export function SegmentedControlItem(props: SegmentedControlItemProps) {
191
351
  )
192
352
 
193
353
  first?.focus()
194
- } else if (e.key === "End") {
354
+
355
+ return
356
+ }
357
+
358
+ if (e.key === "End") {
195
359
  e.preventDefault()
196
360
  const root = t.closest(`[data-slot="segmented-control-v2"]`)
197
361
 
@@ -204,9 +368,17 @@ export function SegmentedControlItem(props: SegmentedControlItemProps) {
204
368
  }
205
369
  }
206
370
 
371
+ const assignRef = (el: HTMLButtonElement) => {
372
+ buttonEl = el
373
+ const r = local.ref
374
+
375
+ if (r instanceof Function) r(el)
376
+ }
377
+
207
378
  return (
208
379
  <button
209
380
  {...rest}
381
+ ref={assignRef}
210
382
  type="button"
211
383
  data-slot="segmented-control-v2-item"
212
384
  data-pressed={pressed() ? "" : undefined}
@@ -223,3 +395,11 @@ export function SegmentedControlItem(props: SegmentedControlItemProps) {
223
395
  </button>
224
396
  )
225
397
  }
398
+
399
+ export const SegmentedControlV2 = SegmentedControl
400
+
401
+ export const SegmentedControlItemV2 = SegmentedControlItem
402
+
403
+ export type SegmentedControlV2Props = SegmentedControlProps
404
+
405
+ export type SegmentedControlItemV2Props = SegmentedControlItemProps
@@ -1,10 +1,6 @@
1
- * {
1
+ [data-component="tabs-v2"] {
2
2
  -webkit-font-smoothing: antialiased;
3
3
  -moz-osx-font-smoothing: grayscale;
4
- text-rendering: geometricPrecision;
5
- }
6
-
7
- [data-component="tabs-v2"] {
8
4
  width: 100%;
9
5
  height: 100%;
10
6
  display: flex;