Skip to content

Commit 377bf7f

Browse files
author
Aaron Iker
authored
feat(ui): Select, dropdown, popover styles & transitions (anomalyco#11675)
1 parent b39c1f1 commit 377bf7f

19 files changed

+631
-165
lines changed

packages/app/src/components/dialog-select-model.tsx

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -90,9 +90,10 @@ const ModelList: Component<{
9090

9191
export function ModelSelectorPopover<T extends ValidComponent = "div">(props: {
9292
provider?: string
93-
children?: JSX.Element
93+
children?: JSX.Element | ((open: boolean) => JSX.Element)
9494
triggerAs?: T
95-
triggerProps?: ComponentProps<T>
95+
triggerProps?: ComponentProps<T>,
96+
gutter?: number
9697
}) {
9798
const [store, setStore] = createStore<{
9899
open: boolean
@@ -175,14 +176,14 @@ export function ModelSelectorPopover<T extends ValidComponent = "div">(props: {
175176
}}
176177
modal={false}
177178
placement="top-start"
178-
gutter={8}
179+
gutter={props.gutter ?? 8}
179180
>
180181
<Kobalte.Trigger
181182
ref={(el) => setStore("trigger", el)}
182183
as={props.triggerAs ?? "div"}
183184
{...(props.triggerProps as any)}
184185
>
185-
{props.children}
186+
{typeof props.children === "function" ? props.children(store.open) : props.children}
186187
</Kobalte.Trigger>
187188
<Kobalte.Portal>
188189
<Kobalte.Content

packages/app/src/components/prompt-input.tsx

Lines changed: 96 additions & 57 deletions
Large diffs are not rendered by default.

packages/app/src/components/settings-general.tsx

Lines changed: 17 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -60,24 +60,24 @@ export const SettingsGeneral: Component = () => {
6060
const actions =
6161
platform.update && platform.restart
6262
? [
63-
{
64-
label: language.t("toast.update.action.installRestart"),
65-
onClick: async () => {
66-
await platform.update!()
67-
await platform.restart!()
68-
},
63+
{
64+
label: language.t("toast.update.action.installRestart"),
65+
onClick: async () => {
66+
await platform.update!()
67+
await platform.restart!()
6968
},
70-
{
71-
label: language.t("toast.update.action.notYet"),
72-
onClick: "dismiss" as const,
73-
},
74-
]
69+
},
70+
{
71+
label: language.t("toast.update.action.notYet"),
72+
onClick: "dismiss" as const,
73+
},
74+
]
7575
: [
76-
{
77-
label: language.t("toast.update.action.notYet"),
78-
onClick: "dismiss" as const,
79-
},
80-
]
76+
{
77+
label: language.t("toast.update.action.notYet"),
78+
onClick: "dismiss" as const,
79+
},
80+
]
8181

8282
showToast({
8383
persistent: true,
@@ -226,7 +226,7 @@ export const SettingsGeneral: Component = () => {
226226
variant="secondary"
227227
size="small"
228228
triggerVariant="settings"
229-
triggerStyle={{ "font-family": monoFontFamily(settings.appearance.font()), "min-width": "180px" }}
229+
triggerStyle={{ "font-family": monoFontFamily(settings.appearance.font()), "field-sizing": "content" }}
230230
>
231231
{(option) => (
232232
<span style={{ "font-family": monoFontFamily(option?.value) }}>

packages/ui/src/components/button.css

Lines changed: 15 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,13 @@
99
user-select: none;
1010
cursor: default;
1111
outline: none;
12+
padding: 4px 8px;
1213
white-space: nowrap;
14+
transition-property: background-color, border-color, color, box-shadow, opacity;
15+
transition-duration: var(--transition-duration);
16+
transition-timing-function: var(--transition-easing);
17+
outline: none;
18+
line-height: 20px;
1319

1420
&[data-variant="primary"] {
1521
background-color: var(--button-primary-base);
@@ -94,7 +100,6 @@
94100
&:active:not(:disabled) {
95101
background-color: var(--button-secondary-base);
96102
scale: 0.99;
97-
transition: all 150ms ease-out;
98103
}
99104
&:disabled {
100105
border-color: var(--border-disabled);
@@ -109,34 +114,31 @@
109114
}
110115

111116
&[data-size="small"] {
112-
height: 22px;
113-
padding: 0 8px;
117+
padding: 4px 8px;
114118
&[data-icon] {
115-
padding: 0 12px 0 4px;
119+
padding: 4px 12px 4px 4px;
116120
}
117121

118-
font-size: var(--font-size-small);
119-
line-height: var(--line-height-large);
120122
gap: 4px;
121123

122124
/* text-12-medium */
123125
font-family: var(--font-family-sans);
124-
font-size: var(--font-size-small);
126+
font-size: var(--font-size-base);
125127
font-style: normal;
126128
font-weight: var(--font-weight-medium);
127-
line-height: var(--line-height-large); /* 166.667% */
128129
letter-spacing: var(--letter-spacing-normal);
129130
}
130131

131132
&[data-size="normal"] {
132-
height: 24px;
133-
line-height: 24px;
134-
padding: 0 6px;
133+
padding: 4px 6px;
135134
&[data-icon] {
136-
padding: 0 12px 0 4px;
135+
padding: 4px 12px 4px 4px;
136+
}
137+
138+
&[aria-haspopup] {
139+
padding: 4px 6px 4px 8px;
137140
}
138141

139-
font-size: var(--font-size-small);
140142
gap: 6px;
141143

142144
/* text-12-medium */
@@ -148,7 +150,6 @@
148150
}
149151

150152
&[data-size="large"] {
151-
height: 32px;
152153
padding: 6px 12px;
153154

154155
&[data-icon] {

packages/ui/src/components/button.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { Icon, IconProps } from "./icon"
44

55
export interface ButtonProps
66
extends ComponentProps<typeof Kobalte>,
7-
Pick<ComponentProps<"button">, "class" | "classList" | "children"> {
7+
Pick<ComponentProps<"button">, "class" | "classList" | "children" | "style"> {
88
size?: "small" | "normal" | "large"
99
variant?: "primary" | "secondary" | "ghost"
1010
icon?: IconProps["name"]
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
.cycle-label {
2+
--c-duration: 200ms;
3+
--c-stagger: 30ms;
4+
--c-opacity-start: 0;
5+
--c-opacity-end: 1;
6+
--c-blur-start: 0px;
7+
--c-blur-end: 0px;
8+
--c-skew: 10deg;
9+
10+
display: inline-flex;
11+
position: relative;
12+
13+
transform-style: preserve-3d;
14+
perspective: 500px;
15+
transition: width var(--transition-duration) var(--transition-easing);
16+
will-change: width;
17+
overflow: hidden;
18+
19+
.cycle-char {
20+
display: inline-block;
21+
transform-style: preserve-3d;
22+
min-width: 0.25em;
23+
backface-visibility: hidden;
24+
25+
transition-property: transform, opacity, filter;
26+
transition-duration: var(--transition-duration);
27+
transition-timing-function: var(--transition-easing);
28+
transition-delay: calc(var(--i, 0) * var(--c-stagger));
29+
30+
&.enter {
31+
opacity: var(--c-opacity-end);
32+
filter: blur(var(--c-blur-end));
33+
transform: translateY(0) rotateX(0) skewX(0);
34+
}
35+
36+
&.exit {
37+
opacity: var(--c-opacity-start);
38+
filter: blur(var(--c-blur-start));
39+
transform: translateY(50%) rotateX(90deg) skewX(var(--c-skew));
40+
}
41+
42+
&.pre {
43+
opacity: var(--c-opacity-start);
44+
filter: blur(var(--c-blur-start));
45+
transition: none;
46+
transform: translateY(-50%) rotateX(-90deg) skewX(calc(var(--c-skew) * -1));
47+
}
48+
}
49+
}
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
import "./cycle-label.css"
2+
import { createEffect, createSignal, JSX, on } from "solid-js"
3+
4+
export interface CycleLabelProps extends JSX.HTMLAttributes<HTMLSpanElement> {
5+
value: string
6+
onValueChange?: (value: string) => void
7+
duration?: number | ((value: string) => number)
8+
stagger?: number
9+
opacity?: [number, number]
10+
blur?: [number, number]
11+
skewX?: number
12+
onAnimationStart?: () => void
13+
onAnimationEnd?: () => void
14+
}
15+
16+
const segmenter =
17+
typeof Intl !== "undefined" && Intl.Segmenter ? new Intl.Segmenter("en", { granularity: "grapheme" }) : null
18+
19+
const getChars = (text: string): string[] =>
20+
segmenter ? Array.from(segmenter.segment(text), (s) => s.segment) : text.split("")
21+
22+
const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
23+
24+
export function CycleLabel(props: CycleLabelProps) {
25+
const getDuration = (text: string) => {
26+
const d = props.duration ?? Number(getComputedStyle(document.documentElement).getPropertyValue("--transition-duration")) ?? 200
27+
return typeof d === "function" ? d(text) : d
28+
}
29+
const stagger = () => props?.stagger ?? 30
30+
const opacity = () => props?.opacity ?? [0, 1]
31+
const blur = () => props?.blur ?? [0, 0]
32+
const skewX = () => props?.skewX ?? 10
33+
34+
let containerRef: HTMLSpanElement | undefined
35+
let isAnimating = false
36+
const [currentText, setCurrentText] = createSignal(props.value)
37+
38+
const setChars = (el: HTMLElement, text: string, state: "enter" | "exit" | "pre" = "enter") => {
39+
el.innerHTML = ""
40+
const chars = getChars(text)
41+
chars.forEach((char, i) => {
42+
const span = document.createElement("span")
43+
span.textContent = char === " " ? "\u00A0" : char
44+
span.className = `cycle-char ${state}`
45+
span.style.setProperty("--i", String(i))
46+
el.appendChild(span)
47+
})
48+
}
49+
50+
const animateToText = async (newText: string) => {
51+
if (!containerRef || isAnimating) return
52+
if (newText === currentText()) return
53+
54+
isAnimating = true
55+
props.onAnimationStart?.()
56+
57+
const dur = getDuration(newText)
58+
const stag = stagger()
59+
60+
containerRef.style.width = containerRef.offsetWidth + "px"
61+
62+
const oldChars = containerRef.querySelectorAll(".cycle-char")
63+
oldChars.forEach((c) => c.classList.replace("enter", "exit"))
64+
65+
const clone = containerRef.cloneNode(false) as HTMLElement
66+
Object.assign(clone.style, {
67+
position: "absolute",
68+
visibility: "hidden",
69+
width: "auto",
70+
transition: "none",
71+
})
72+
setChars(clone, newText)
73+
document.body.appendChild(clone)
74+
const nextWidth = clone.offsetWidth
75+
clone.remove()
76+
77+
const exitTime = oldChars.length * stag + dur
78+
await wait(exitTime * 0.3)
79+
80+
containerRef.style.width = nextWidth + "px"
81+
82+
const widthDur = 200
83+
await wait(widthDur * 0.3)
84+
85+
setChars(containerRef, newText, "pre")
86+
containerRef.offsetWidth
87+
88+
Array.from(containerRef.children).forEach((c) => (c.className = "cycle-char enter"))
89+
setCurrentText(newText)
90+
props.onValueChange?.(newText)
91+
92+
const enterTime = getChars(newText).length * stag + dur
93+
await wait(enterTime)
94+
95+
containerRef.style.width = ""
96+
isAnimating = false
97+
props.onAnimationEnd?.()
98+
}
99+
100+
createEffect(
101+
on(
102+
() => props.value,
103+
(newValue) => {
104+
if (newValue !== currentText()) {
105+
animateToText(newValue)
106+
}
107+
},
108+
),
109+
)
110+
111+
const initRef = (el: HTMLSpanElement) => {
112+
containerRef = el
113+
setChars(el, props.value)
114+
}
115+
116+
return (
117+
<span
118+
ref={initRef}
119+
class={`cycle-label ${props.class ?? ""}`}
120+
style={{
121+
"--c-duration": `${getDuration(currentText())}ms`,
122+
"--c-stagger": `${stagger()}ms`,
123+
"--c-opacity-start": opacity()[0],
124+
"--c-opacity-end": opacity()[1],
125+
"--c-blur-start": `${blur()[0]}px`,
126+
"--c-blur-end": `${blur()[1]}px`,
127+
"--c-skew": `${skewX()}deg`,
128+
...(typeof props.style === "object" ? props.style : {}),
129+
}}
130+
/>
131+
)
132+
}

0 commit comments

Comments
 (0)