@opencode/ui 2.0.24 → 2.0.25
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/marked-parser.d.ts +2 -0
- package/dist/icons/catalog.d.ts +32 -0
- package/dist/navigation/segmented-control/segmented-control.d.ts +21 -2
- package/package.json +2 -1
- package/src/actions/button/button.css +11 -9
- package/src/actions/icon-button/icon-button.css +1 -5
- package/src/actions/submit.css +14 -12
- package/src/context/marked-base.tsx +2 -1
- package/src/context/marked-parser.tsx +50 -18
- package/src/icons/catalog.ts +32 -0
- package/src/icons/icon/icon.tsx +2 -0
- package/src/navigation/segmented-control/segmented-control.css +37 -4
- package/src/navigation/segmented-control/segmented-control.tsx +194 -14
- package/src/navigation/tabs/tabs-current.css +1 -5
package/dist/icons/catalog.d.ts
CHANGED
|
@@ -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
|
|
2
|
-
import type
|
|
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.
|
|
3
|
+
"version": "2.0.25",
|
|
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
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
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;
|
package/src/actions/submit.css
CHANGED
|
@@ -33,18 +33,20 @@
|
|
|
33
33
|
color: var(--v2-icon-icon-inverse);
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
-
:
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
|
|
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(
|
|
7
|
+
return createMarkdownBase().use(markdownMath, markedShiki({ highlight }))
|
|
8
8
|
}
|
|
9
9
|
|
|
10
|
-
|
|
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
|
|
18
|
+
const inlineParenMathRegex = /^\\\(((?:\\.|[^\\\n])*?)\\\)/
|
|
13
19
|
|
|
14
|
-
const
|
|
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: "
|
|
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(
|
|
66
|
+
const match = src.match(inlineParenMathRegex)
|
|
28
67
|
|
|
29
68
|
if (!match) return
|
|
30
69
|
|
|
31
70
|
return {
|
|
32
|
-
type: "
|
|
71
|
+
type: "inlineParenKatex",
|
|
33
72
|
raw: match[0],
|
|
34
73
|
text: match[1].trim(),
|
|
35
74
|
displayMode: false,
|
|
36
75
|
}
|
|
37
76
|
},
|
|
38
|
-
renderer:
|
|
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[
|
|
52
|
-
displayMode:
|
|
90
|
+
text: match[2].trim(),
|
|
91
|
+
displayMode: match[1].length === 2,
|
|
53
92
|
}
|
|
54
93
|
},
|
|
55
|
-
renderer:
|
|
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
|
-
}
|
package/src/icons/catalog.ts
CHANGED
|
@@ -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"/>`,
|
package/src/icons/icon/icon.tsx
CHANGED
|
@@ -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:
|
|
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
|
|
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
|
|
232
|
+
const assignRef = (el: HTMLDivElement) => {
|
|
233
|
+
rootEl = el
|
|
104
234
|
const r = local.ref
|
|
105
235
|
|
|
106
|
-
if (
|
|
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:
|
|
270
|
+
e: Parameters<JSX.EventHandler<HTMLButtonElement, E>>[0],
|
|
138
271
|
) {
|
|
139
|
-
if (
|
|
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())
|
|
166
|
-
|
|
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
|
-
|
|
334
|
+
|
|
335
|
+
return
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
if (e.key === "ArrowLeft") {
|
|
180
339
|
e.preventDefault()
|
|
181
|
-
ctx.focusNext(t,
|
|
340
|
+
ctx.focusNext(t, horizontal === 1 ? -1 : 1)
|
|
341
|
+
|
|
342
|
+
return
|
|
182
343
|
}
|
|
183
344
|
|
|
184
|
-
|
|
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
|
-
|
|
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
|