Extract shared surface, button, and select styles

This commit is contained in:
Danila Poyarkov 2026-03-06 19:36:36 +03:00
parent bdb95a9d31
commit 30cbba22aa
6 changed files with 167 additions and 11 deletions

View file

@ -11,6 +11,8 @@ import {
SelectViewport
} from 'reka-ui'
import { selectContent, selectItem, selectTrigger } from '@/components/ui/select'
defineProps<{
options: { value: T; label: string }[]
placeholder?: string
@ -23,7 +25,7 @@ const modelValue = defineModel<T>({ required: true })
<SelectRoot v-model="modelValue">
<SelectTrigger
data-test-id="app-select-trigger"
class="flex min-w-0 flex-1 cursor-pointer items-center justify-between rounded border border-border bg-input px-1.5 py-1 text-xs text-surface outline-none hover:bg-hover"
:class="selectTrigger({ class: 'min-w-0 flex-1 rounded px-1.5 py-1 text-xs' })"
>
<SelectValue :placeholder="placeholder" />
<icon-lucide-chevron-down class="ml-1 size-3 shrink-0 text-muted" />
@ -32,14 +34,14 @@ const modelValue = defineModel<T>({ required: true })
<SelectContent
position="popper"
:side-offset="2"
class="z-50 max-h-56 min-w-[var(--reka-select-trigger-width)] overflow-hidden rounded-md border border-border bg-panel shadow-lg"
:class="selectContent({ class: 'max-h-56 min-w-[var(--reka-select-trigger-width)]' })"
>
<SelectViewport class="p-0.5">
<SelectItem
v-for="opt in options"
:key="String(opt.value)"
:value="opt.value"
class="relative flex cursor-pointer items-center rounded py-1.5 pl-6 pr-2 text-xs text-surface outline-none data-[highlighted]:bg-hover"
:class="selectItem({ class: 'rounded py-1.5 pl-6 pr-2 text-xs' })"
>
<SelectItemIndicator class="absolute left-1.5 inline-flex items-center justify-center">
<icon-lucide-check class="size-3 text-accent" />

View file

@ -12,6 +12,8 @@ import {
useFilter
} from 'reka-ui'
import { selectContent, selectItem, selectTrigger } from '@/components/ui/select'
import { panelSurface } from '@/components/ui/surface'
import { listFamilies } from '@/engine/fonts'
const modelValue = defineModel<string>({ required: true })
@ -51,7 +53,7 @@ function onSelect(val: string) {
<PopoverAnchor>
<button
data-test-id="font-picker-trigger"
class="flex w-full cursor-pointer items-center justify-between rounded border border-border bg-input px-2 py-1 text-xs text-surface hover:bg-hover"
:class="selectTrigger({ class: 'w-full rounded px-2 py-1 text-xs' })"
@click="open = !open"
>
<span class="truncate">{{ modelValue }}</span>
@ -61,7 +63,7 @@ function onSelect(val: string) {
<PopoverContent
:side-offset="2"
align="start"
class="z-50 flex min-w-56 w-[var(--reka-popper-anchor-width)] flex-col overflow-hidden rounded-md border border-border bg-panel shadow-lg"
:class="panelSurface({ radius: 'md', padding: 'none', class: 'z-50 flex min-w-56 w-[var(--reka-popper-anchor-width)] flex-col overflow-hidden' })"
@open-auto-focus.prevent
>
<ListboxRoot :model-value="modelValue" @update:model-value="onSelect">
@ -89,7 +91,7 @@ function onSelect(val: string) {
<ListboxItem
:value="option"
data-test-id="font-picker-item"
class="flex w-full cursor-pointer items-center gap-2 px-2 py-2 text-sm text-surface outline-none data-[highlighted]:bg-hover"
:class="selectItem({ class: 'w-full gap-2 px-2 py-2 text-sm' })"
:style="{ fontFamily: `'${option}', sans-serif` }"
>
<icon-lucide-check v-if="option === modelValue" class="size-3 shrink-0 text-accent" />

View file

@ -15,6 +15,8 @@ import {
} from 'reka-ui'
import { computed, ref } from 'vue'
import { uiButton } from '@/components/ui/button'
import { selectContent, selectItem, selectTrigger } from '@/components/ui/select'
import { MODELS, useAIChat } from '@/composables/use-chat'
const { modelId } = useAIChat()
@ -52,7 +54,7 @@ function handleSubmit(e: Event) {
<SelectRoot v-model="modelId">
<SelectTrigger
data-test-id="chat-model-selector"
class="flex items-center gap-1 rounded px-1.5 py-0.5 text-[10px] text-muted hover:bg-hover hover:text-surface"
:class="selectTrigger({ class: 'gap-1 rounded border-none bg-transparent px-1.5 py-0.5 text-[10px] text-muted' })"
>
<icon-lucide-bot class="size-3" />
{{ selectedModelName }}
@ -63,14 +65,14 @@ function handleSubmit(e: Event) {
position="popper"
side="top"
:side-offset="4"
class="z-50 max-h-60 overflow-y-auto rounded-lg border border-border bg-panel p-1 shadow-lg"
:class="selectContent({ radius: 'lg', padding: 'md', class: 'max-h-60 overflow-y-auto' })"
>
<SelectViewport>
<SelectItem
v-for="model in MODELS"
:key="model.id"
:value="model.id"
class="flex cursor-pointer items-center gap-2 rounded px-2 py-1.5 text-[11px] text-surface outline-none data-[highlighted]:bg-hover"
:class="selectItem({ class: 'gap-2 rounded px-2 py-1.5 text-[11px]' })"
>
<SelectItemText class="flex-1">{{ model.name }}</SelectItemText>
<span
@ -101,7 +103,7 @@ function handleSubmit(e: Event) {
<button
type="button"
data-test-id="chat-stop-button"
class="shrink-0 rounded border border-border px-2 py-1.5 text-xs text-muted hover:bg-hover"
:class="uiButton({ tone: 'ghost', shape: 'rounded', size: 'sm', class: 'shrink-0 border border-border px-2 py-1.5' })"
@click="emit('stop')"
>
<icon-lucide-square class="size-3" />
@ -122,7 +124,7 @@ function handleSubmit(e: Event) {
<button
type="submit"
data-test-id="chat-send-button"
class="shrink-0 rounded bg-accent px-2.5 py-1.5 text-xs font-medium text-white hover:bg-accent/90"
:class="uiButton({ tone: 'accent', shape: 'rounded', size: 'sm', class: 'shrink-0 px-2.5 py-1.5 font-medium' })"
:disabled="!input.trim()"
>
<icon-lucide-send class="size-3" />

View file

@ -0,0 +1,60 @@
import { tv } from 'tailwind-variants'
import { twMerge } from 'tailwind-merge'
const button = tv({
base: 'inline-flex items-center justify-center transition-colors select-none',
variants: {
tone: {
ghost: 'bg-transparent text-muted hover:bg-hover hover:text-surface',
accent: 'bg-accent text-white hover:bg-accent/90',
panel: 'bg-panel/70 text-surface backdrop-blur-xl',
panelAccent: 'bg-panel/70 text-accent border-accent/20 backdrop-blur-xl'
},
shape: {
square: 'rounded',
rounded: 'rounded-md',
pill: 'rounded-full'
},
size: {
sm: 'h-7 px-2 text-xs',
md: 'h-8 px-3 text-xs',
icon: 'size-8',
iconSm: 'size-7'
},
bordered: {
true: 'border border-white/10',
false: ''
},
active: {
true: '',
false: ''
}
},
compoundVariants: [
{ tone: 'accent', size: 'icon', class: 'px-0' },
{ tone: 'ghost', size: 'icon', class: 'px-0' },
{ tone: 'panel', size: 'icon', class: 'shadow-md' },
{ tone: 'panel', size: 'iconSm', class: 'shadow-sm' },
{ tone: 'panelAccent', size: 'icon', class: 'shadow-md' },
{ tone: 'panel', bordered: true, class: 'border border-white/10' },
{ tone: 'panelAccent', bordered: true, class: 'border' }
],
defaultVariants: {
tone: 'ghost',
shape: 'rounded',
size: 'sm',
bordered: false,
active: false
}
})
export function uiButton(options?: {
tone?: 'ghost' | 'accent' | 'panel' | 'panelAccent'
shape?: 'square' | 'rounded' | 'pill'
size?: 'sm' | 'md' | 'icon' | 'iconSm'
bordered?: boolean
active?: boolean
class?: string
}) {
return twMerge(button(options), options?.class)
}

View file

@ -0,0 +1,51 @@
import { tv } from 'tailwind-variants'
import { twMerge } from 'tailwind-merge'
const trigger = tv({
base: 'flex items-center justify-between border border-border bg-input text-surface outline-none hover:bg-hover'
})
const content = tv({
base: 'z-50 overflow-hidden border border-border bg-panel',
variants: {
radius: {
md: 'rounded-md',
lg: 'rounded-lg'
},
elevation: {
lg: 'shadow-lg',
xl: 'shadow-xl'
},
padding: {
none: '',
sm: 'p-0.5',
md: 'p-1'
}
},
defaultVariants: {
radius: 'md',
elevation: 'lg',
padding: 'sm'
}
})
const item = tv({
base: 'relative flex cursor-pointer items-center text-surface outline-none data-[highlighted]:bg-hover'
})
export function selectTrigger(options?: { class?: string }) {
return twMerge(trigger(), options?.class)
}
export function selectContent(options?: {
radius?: 'md' | 'lg'
elevation?: 'lg' | 'xl'
padding?: 'none' | 'sm' | 'md'
class?: string
}) {
return twMerge(content(options), options?.class)
}
export function selectItem(options?: { class?: string }) {
return twMerge(item(), options?.class)
}

View file

@ -0,0 +1,39 @@
import { tv } from 'tailwind-variants'
import { twMerge } from 'tailwind-merge'
const surface = tv({
base: 'border border-border bg-panel',
variants: {
elevation: {
md: 'shadow-md',
lg: 'shadow-lg',
xl: 'shadow-xl',
overlay: 'shadow-[0_8px_30px_rgb(0_0_0/0.4)]'
},
radius: {
md: 'rounded-md',
lg: 'rounded-lg',
xl: 'rounded-xl'
},
padding: {
none: '',
sm: 'p-1',
md: 'p-2',
lg: 'p-3'
}
},
defaultVariants: {
elevation: 'lg',
radius: 'lg',
padding: 'sm'
}
})
export function panelSurface(options?: {
elevation?: 'md' | 'lg' | 'xl' | 'overlay'
radius?: 'md' | 'lg' | 'xl'
padding?: 'none' | 'sm' | 'md' | 'lg'
class?: string
}) {
return twMerge(surface(options), options?.class)
}