@opencode/ui 2.0.24 → 2.0.26

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 +1,3 @@
1
+ import type { MarkedExtension } from "marked";
1
2
  export declare function createMarkdownParser(highlight: (code: string, language: string) => string | Promise<string>): import("marked").Marked<string, string>;
3
+ export declare const markdownMath: MarkedExtension;
@@ -184,6 +184,38 @@ declare const icons: {
184
184
  viewBox: string;
185
185
  body: string;
186
186
  };
187
+ "outline-arrow-left": {
188
+ viewBox: string;
189
+ body: string;
190
+ };
191
+ "outline-arrow-right": {
192
+ viewBox: string;
193
+ body: string;
194
+ };
195
+ "outline-arrow-up-right": {
196
+ viewBox: string;
197
+ body: string;
198
+ };
199
+ "outline-rotate-clockwise": {
200
+ viewBox: string;
201
+ body: string;
202
+ };
203
+ "outline-globe": {
204
+ viewBox: string;
205
+ body: string;
206
+ };
207
+ "outline-globe-plus": {
208
+ viewBox: string;
209
+ body: string;
210
+ };
211
+ "outline-cookie": {
212
+ viewBox: string;
213
+ body: string;
214
+ };
215
+ "outline-browser-annotate": {
216
+ viewBox: string;
217
+ body: string;
218
+ };
187
219
  "arrow-up-right": {
188
220
  viewBox: string;
189
221
  body: string;
@@ -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": "2.0.24",
3
+ "version": "2.0.26",
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
  }
@@ -240,10 +240,12 @@
240
240
  color: var(--v2-icon-icon-muted);
241
241
  }
242
242
 
243
- [data-component="button-v2"][data-variant="ghost-muted"]:is(:hover, [data-state="hover"]):not(:disabled):not(
244
- [data-expanded]
245
- ) {
246
- background-color: var(--v2-overlay-simple-overlay-hover);
243
+ @media (hover: hover) {
244
+ [data-component="button-v2"][data-variant="ghost-muted"]:is(:hover, [data-state="hover"]):not(:disabled):not(
245
+ [data-expanded]
246
+ ) {
247
+ background-color: var(--v2-overlay-simple-overlay-hover);
248
+ }
247
249
  }
248
250
 
249
251
  [data-component="button-v2"][data-variant="ghost-muted"]:is(:active, [data-state="pressed"]):not(:disabled) {
@@ -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;
@@ -33,18 +33,20 @@
33
33
  color: var(--v2-icon-icon-inverse);
34
34
  }
35
35
 
36
- :is(
37
- [data-component="button-v2"][data-variant="submit"],
38
- [data-component="icon-button-v2"]:is([data-variant="submit"], [data-variant="contrast"])
39
- ):is(:hover, [data-state="hover"]):not(:disabled) {
40
- background-image:
41
- linear-gradient(90deg, var(--submit-hover) 0%, var(--submit-hover) 100%),
42
- linear-gradient(180deg, var(--submit-highlight) 0%, var(--v2-alpha-light-0) 100%),
43
- linear-gradient(
44
- 90deg,
45
- var(--v2-background-bg-icon-button-contrast) 0%,
46
- var(--v2-background-bg-icon-button-contrast) 100%
47
- );
36
+ @media (hover: hover) {
37
+ :is(
38
+ [data-component="button-v2"][data-variant="submit"],
39
+ [data-component="icon-button-v2"]:is([data-variant="submit"], [data-variant="contrast"])
40
+ ):is(:hover, [data-state="hover"]):not(:disabled) {
41
+ background-image:
42
+ linear-gradient(90deg, var(--submit-hover) 0%, var(--submit-hover) 100%),
43
+ linear-gradient(180deg, var(--submit-highlight) 0%, var(--v2-alpha-light-0) 100%),
44
+ linear-gradient(
45
+ 90deg,
46
+ var(--v2-background-bg-icon-button-contrast) 0%,
47
+ var(--v2-background-bg-icon-button-contrast) 100%
48
+ );
49
+ }
48
50
  }
49
51
 
50
52
  :is(
@@ -16,7 +16,8 @@ let smallParser: Marked | undefined
16
16
 
17
17
  export function parseSmallMarkdown(text: string) {
18
18
  // Any possible KaTeX delimiter stays on the worker, including escaped ones.
19
- if (text.length > 1024 || text.includes("\\(") || text.includes("$$")) return
19
+ // Dollar math needs a pair, so a lone price such as `$5` can stay on this path.
20
+ if (text.length > 1024 || text.includes("\\(") || text.indexOf("$") !== text.lastIndexOf("$")) return
20
21
 
21
22
  // Ordinary prose does not need the block/inline lexer's cold regular expressions.
22
23
  // Keep every possible Markdown construct, autolink, and hard break on that lexer.
@@ -4,17 +4,56 @@ import markedShiki from "marked-shiki"
4
4
  import { createMarkdownBase } from "./marked-base"
5
5
 
6
6
  export function createMarkdownParser(highlight: (code: string, language: string) => string | Promise<string>) {
7
- return createMarkdownBase().use(katexExtension, markedShiki({ highlight }))
7
+ return createMarkdownBase().use(markdownMath, markedShiki({ highlight }))
8
8
  }
9
9
 
10
- const inlineMathRegex = /^\\\(((?:\\.|[^\\\n])*?)\\\)/
10
+ // `$$...$$` renders as display math anywhere in a line.
11
+ // `$...$` follows Pandoc's boundaries so prices and shell variables stay text: the opening `$` needs a non-space
12
+ // after it, the closing `$` needs a non-space before it and no letter, digit, or underscore after it.
13
+ // Neither form spans a newline, and `$...$` cannot contain an unescaped `$`, so a failed `$...$` attempt stops at
14
+ // the next `$` and a failed `$$...$$` attempt stops at the end of the line.
15
+ const inlineDollarMathRegex =
16
+ /\$\$(?!\$)((?:\\.|[^\\\n])*?(?:\\.|[^\\\n$]))\$\$|\$(?=[^\s$])((?:\\.|[^\\\n$])*?(?:\\\S|[^\s\\$]))\$(?!\w)/y
11
17
 
12
- const blockMathRegex = /^\$\$\n([\s\S]+?)\n\$\$(?:\n|$)/
18
+ const inlineParenMathRegex = /^\\\(((?:\\.|[^\\\n])*?)\\\)/
13
19
 
14
- const katexExtension: MarkedExtension = {
20
+ const blockMathRegex = /^(\${1,2})\n((?:\\[^]|[^\\])+?)\n\1(?:\n|$)/
21
+
22
+ function renderMath(token: Tokens.Generic) {
23
+ return katex.renderToString(token.text, { throwOnError: false, displayMode: token.displayMode })
24
+ }
25
+
26
+ export const markdownMath: MarkedExtension = {
15
27
  extensions: [
16
28
  {
17
- name: "inlineKatex",
29
+ name: "inlineDollarKatex",
30
+ level: "inline",
31
+ // marked calls `start` again for every text token with the rest of the paragraph, so it must not try the
32
+ // regex at each `$`; a `$` that fails the tokenizer just stays in the merged text.
33
+ start(src) {
34
+ const index = src.indexOf("$")
35
+
36
+ if (index === -1) return
37
+
38
+ return index
39
+ },
40
+ tokenizer(src) {
41
+ inlineDollarMathRegex.lastIndex = 0
42
+ const match = inlineDollarMathRegex.exec(src)
43
+
44
+ if (!match) return
45
+
46
+ return {
47
+ type: "inlineDollarKatex",
48
+ raw: match[0],
49
+ text: (match[1] ?? match[2]).trim(),
50
+ displayMode: match[1] !== undefined,
51
+ }
52
+ },
53
+ renderer: renderMath,
54
+ },
55
+ {
56
+ name: "inlineParenKatex",
18
57
  level: "inline",
19
58
  start(src) {
20
59
  const index = src.indexOf("\\(")
@@ -24,18 +63,18 @@ const katexExtension: MarkedExtension = {
24
63
  return index
25
64
  },
26
65
  tokenizer(src) {
27
- const match = src.match(inlineMathRegex)
66
+ const match = src.match(inlineParenMathRegex)
28
67
 
29
68
  if (!match) return
30
69
 
31
70
  return {
32
- type: "inlineKatex",
71
+ type: "inlineParenKatex",
33
72
  raw: match[0],
34
73
  text: match[1].trim(),
35
74
  displayMode: false,
36
75
  }
37
76
  },
38
- renderer: renderKatexToken,
77
+ renderer: renderMath,
39
78
  },
40
79
  {
41
80
  name: "blockKatex",
@@ -48,18 +87,11 @@ const katexExtension: MarkedExtension = {
48
87
  return {
49
88
  type: "blockKatex",
50
89
  raw: match[0],
51
- text: match[1].trim(),
52
- displayMode: true,
90
+ text: match[2].trim(),
91
+ displayMode: match[1].length === 2,
53
92
  }
54
93
  },
55
- renderer: renderKatexToken,
94
+ renderer: (token) => renderMath(token) + "\n",
56
95
  },
57
96
  ],
58
97
  }
59
-
60
- function renderKatexToken(token: Tokens.Generic) {
61
- return katex.renderToString(typeof token.text === "string" ? token.text : "", {
62
- displayMode: token.displayMode === true,
63
- throwOnError: false,
64
- })
65
- }
@@ -186,6 +186,38 @@ const icons = {
186
186
  viewBox: "0 0 16 16",
187
187
  body: `<path d="M4.14908 11.0081H1.76282V1.51758H9.1038V2.55588M14.2225 4.99681H6.75397V14.4873H14.2225V4.99681Z" stroke="currentColor"/>`,
188
188
  },
189
+ "outline-arrow-left": {
190
+ viewBox: "0 0 16 16",
191
+ body: `<path d="M2.44442 7.99999H13.5555M6.22221 4.22222L2.44442 7.99999L6.22221 11.7778" stroke="currentColor"/>`,
192
+ },
193
+ "outline-arrow-right": {
194
+ viewBox: "0 0 16 16",
195
+ body: `<path d="M13.5555 7.99999H2.4444M9.77773 11.7778L13.5555 7.99999L9.77773 4.22222" stroke="currentColor"/>`,
196
+ },
197
+ "outline-arrow-up-right": {
198
+ viewBox: "0 0 16 16",
199
+ body: `<path d="M12.5 3.5L3.5 12.5M12.5 9.62L12.5 3.5L6.38 3.5" stroke="currentColor"/>`,
200
+ },
201
+ "outline-rotate-clockwise": {
202
+ viewBox: "0 0 16 16",
203
+ body: `<path d="M14.1992 8C14.1992 9.18669 13.8473 10.3467 13.188 11.3334C12.5288 12.3201 11.5917 13.0892 10.4953 13.5433C9.39897 13.9974 8.19257 14.1162 7.02868 13.8847C5.86479 13.6532 4.7957 13.0818 3.95658 12.2426C3.11747 11.4035 2.54602 10.3344 2.31451 9.17054C2.083 8.00666 2.20182 6.80026 2.65594 5.7039C3.11007 4.60754 3.87911 3.67047 4.8658 3.01118C5.85249 2.35189 7.01253 2 8.19922 2C9.87922 2 11.4859 2.66667 12.6926 3.82667L14.1992 5.33333M10.866 5.33333L14.1992 5.33333L14.1993 2" stroke="currentColor" stroke-linecap="square" stroke-linejoin="round"/>`,
204
+ },
205
+ "outline-globe": {
206
+ viewBox: "0 0 16 16",
207
+ body: `<path d="M8.00001 14.4445C9.47277 14.4445 10.6667 11.5592 10.6667 8.00001C10.6667 4.44085 9.47277 1.55557 8.00001 1.55557M8.00001 14.4445C6.52725 14.4445 5.33335 11.5592 5.33335 8.00001C5.33335 4.44085 6.52725 1.55557 8.00001 1.55557M8.00001 14.4445C11.5592 14.4445 14.4444 11.5592 14.4444 8C14.4444 4.44083 11.5592 1.55557 8.00001 1.55557M8.00001 14.4445C4.44085 14.4445 1.55556 11.5592 1.55556 8C1.55556 4.44083 4.44085 1.55557 8.00001 1.55557M1.87203 6.00001H14.128M2.03555 10.4445H13.9644" stroke="currentColor"/>`,
208
+ },
209
+ "outline-globe-plus": {
210
+ viewBox: "0 0 16 16",
211
+ body: `<path d="M7 12.6667C5.71133 12.6667 4.66667 10.1296 4.66667 7C4.66667 3.87039 5.71133 1.33333 7 1.33333C8.2426 1.33333 9.25833 3.69221 9.32933 6.66667" stroke="currentColor" stroke-miterlimit="10"/><path d="M12.6667 6.66667H1.33333" stroke="currentColor"/><path d="M12.6667 7.72825V7C12.6667 3.87039 10.1296 1.33333 7 1.33333C3.8704 1.33333 1.33333 3.87039 1.33333 7C1.33333 10.1296 3.8704 12.6667 7 12.6667H7.58694" stroke="currentColor" stroke-miterlimit="10"/><path d="M9.5 11.5H13.5M11.5 9.5V13.5" stroke="currentColor" stroke-miterlimit="10" stroke-linecap="square"/>`,
212
+ },
213
+ "outline-cookie": {
214
+ viewBox: "0 0 16 16",
215
+ body: `<path d="M14 8.5A6 6 0 1 1 7.5 2a2 2 0 0 0 2.5 2.5 2 2 0 0 0 2.5 2.5c.4.6.9 1.1 1.5 1.5Z" stroke="currentColor" stroke-linejoin="round"/><path d="M5.5 6.5h.01M5.5 10h.01M8.5 9h.01M10.5 11.5h.01" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>`,
216
+ },
217
+ "outline-browser-annotate": {
218
+ viewBox: "0 0 16 16",
219
+ body: `<path d="M10.6667 2H14V5.33333M2 5.33333V2H5.33333M2 10.6667V14H5.33333" stroke="currentColor" stroke-linecap="square"/><path d="M11.8571 11.8572L14 14M6.50008 6.50033L14.0001 9.00033L9.00008 14.0003L6.50008 6.50033Z" stroke="currentColor" stroke-linecap="square"/>`,
220
+ },
189
221
  "arrow-up-right": {
190
222
  viewBox: "0 0 16 16",
191
223
  body: `<path d="M11 9.56V5H6.44M11 5L5 11" stroke="currentColor"/>`,
@@ -63,6 +63,8 @@ export function Icon(props: IconProps) {
63
63
  data-directional={
64
64
  iconName() === "arrow-left" ||
65
65
  iconName() === "arrow-right" ||
66
+ iconName() === "outline-arrow-left" ||
67
+ iconName() === "outline-arrow-right" ||
66
68
  iconName() === "chevron-left" ||
67
69
  iconName() === "chevron-right"
68
70
  ? ""
@@ -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;