[feat] Add reusable SearchInput component (#9168)

## Summary
Add a reusable `SearchInput` component with theme-aware styling, clear
button, loading state, and configurable sizes.

## Changes
- **SearchInput.vue**: Composable search input wrapping Reka UI Combobox
with search/clear/loading icon states
- **searchInput.variants.ts**: CVA-based size variants (`sm`, `md`,
`lg`) using semantic theme tokens (`bg-secondary-background`,
`text-base-foreground`)
- **SearchInput.stories.ts**: Storybook coverage for all sizes, loading,
custom icon/placeholder, and background override

## Review Focus
- Clear button alignment with search icon (`left-3.5` for `icon-sm`
button vs `left-4` for `size-4` icon)
- Theme token choices for light/dark compatibility

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9168-feat-Add-reusable-SearchInput-component-3116d73d365081309290fe84a46852e4)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Alexander Brown <drjkl@comfy.org>
This commit is contained in:
Jin Yi
2026-03-05 14:26:00 +09:00
committed by GitHub
parent a2cb864828
commit d20cc82ef4
3 changed files with 345 additions and 0 deletions

View File

@@ -0,0 +1,159 @@
import type {
ComponentPropsAndSlots,
Meta,
StoryObj
} from '@storybook/vue3-vite'
import { ref } from 'vue'
import SearchInput from './SearchInput.vue'
import { searchInputStoryConfig } from './searchInput.variants'
const { sizes } = searchInputStoryConfig
const meta: Meta<ComponentPropsAndSlots<typeof SearchInput>> = {
title: 'Components/Input/SearchInput',
component: SearchInput,
tags: ['autodocs'],
argTypes: {
modelValue: { control: 'text' },
placeholder: { control: 'text' },
icon: { control: 'text' },
debounceTime: { control: 'number' },
autofocus: { control: 'boolean' },
loading: { control: 'boolean' },
size: {
control: { type: 'select' },
options: sizes
},
'onUpdate:modelValue': { action: 'update:modelValue' },
onSearch: { action: 'search' }
},
args: {
modelValue: '',
size: 'md',
loading: false,
autofocus: false
}
}
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
render: (args) => ({
components: { SearchInput },
setup() {
const searchText = ref(args.modelValue ?? '')
return { searchText, args }
},
template: `
<div style="max-width: 320px;">
<SearchInput v-bind="args" v-model="searchText" />
</div>
`
})
}
export const AllSizes: Story = {
render: () => ({
components: { SearchInput },
setup() {
const sm = ref('')
const md = ref('')
const lg = ref('')
const xl = ref('')
return { sm, md, lg, xl }
},
template: `
<div class="flex flex-col gap-4" style="max-width: 320px;">
<div class="text-xs text-muted-foreground">sm — icon: size-3 (12px)</div>
<SearchInput v-model="sm" size="sm" />
<div class="text-xs text-muted-foreground">md — icon: size-4 (16px, capped)</div>
<SearchInput v-model="md" size="md" />
<div class="text-xs text-muted-foreground">lg — icon: size-4 (16px, capped)</div>
<SearchInput v-model="lg" size="lg" />
<div class="text-xs text-muted-foreground">xl — icon: size-4 (16px, capped)</div>
<SearchInput v-model="xl" size="xl" />
</div>
`
})
}
export const WithValue: Story = {
render: (args) => ({
components: { SearchInput },
setup() {
const searchText = ref('neural network')
return { searchText, args }
},
template: `
<div style="max-width: 320px;">
<SearchInput v-bind="args" v-model="searchText" />
</div>
`
})
}
export const Loading: Story = {
render: (args) => ({
components: { SearchInput },
setup() {
const searchText = ref('')
return { searchText, args }
},
template: `
<div style="max-width: 320px;">
<SearchInput v-bind="args" v-model="searchText" :loading="true" />
</div>
`
})
}
export const CustomPlaceholder: Story = {
...Default,
args: {
placeholder: 'Find a workflow...'
}
}
export const CustomIcon: Story = {
...Default,
args: {
icon: 'icon-[lucide--filter]'
}
}
export const CustomBackground: Story = {
render: (args) => ({
components: { SearchInput },
setup() {
const searchText = ref('')
return { searchText, args }
},
template: `
<div style="max-width: 320px;">
<SearchInput
v-bind="args"
v-model="searchText"
class="bg-component-node-widget-background"
/>
</div>
`
})
}
export const Disabled: Story = {
render: (args) => ({
components: { SearchInput },
setup() {
const searchText = ref('')
return { searchText, args }
},
template: `
<div style="max-width: 320px;">
<SearchInput v-bind="args" v-model="searchText" :disabled="true" />
</div>
`
})
}

View File

@@ -0,0 +1,132 @@
<template>
<ComboboxRoot :ignore-filter="true" :open="false" :disabled="disabled">
<ComboboxAnchor
:class="
cn(
searchInputVariants({ size }),
disabled && 'opacity-50 pointer-events-none',
className
)
"
@click="focus"
>
<Button
v-if="modelValue"
:class="cn('absolute', sizeConfig.clearPos)"
variant="textonly"
size="icon-sm"
:aria-label="$t('g.clear')"
@click.stop="clearSearch"
>
<i :class="cn('icon-[lucide--x]', sizeConfig.icon)" />
</Button>
<i
v-else-if="loading"
:class="
cn(
'icon-[lucide--loader-circle] absolute animate-spin pointer-events-none',
sizeConfig.iconPos,
sizeConfig.icon
)
"
/>
<i
v-else
:class="
cn(
'absolute pointer-events-none',
sizeConfig.iconPos,
sizeConfig.icon,
icon
)
"
/>
<ComboboxInput
ref="inputRef"
v-model="modelValue"
:class="
cn(
'size-full border-none bg-transparent outline-none',
sizeConfig.inputPl,
sizeConfig.inputText
)
"
:placeholder="placeholderText"
:auto-focus="autofocus"
/>
</ComboboxAnchor>
</ComboboxRoot>
</template>
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/utils/tailwindUtil'
import { watchDebounced } from '@vueuse/core'
import { ComboboxAnchor, ComboboxInput, ComboboxRoot } from 'reka-ui'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import type { SearchInputVariants } from './searchInput.variants'
import {
searchInputSizeConfig,
searchInputVariants
} from './searchInput.variants'
const { t } = useI18n()
const {
placeholder,
icon = 'icon-[lucide--search]',
debounceTime = 300,
autofocus = false,
loading = false,
disabled = false,
size = 'md',
class: className
} = defineProps<{
placeholder?: string
icon?: string
debounceTime?: number
autofocus?: boolean
loading?: boolean
disabled?: boolean
size?: SearchInputVariants['size']
class?: HTMLAttributes['class']
}>()
const emit = defineEmits<{
search: [value: string]
}>()
const sizeConfig = computed(() => searchInputSizeConfig[size])
const modelValue = defineModel<string>({ required: true })
const inputRef = ref<InstanceType<typeof ComboboxInput> | null>(null)
function focus() {
inputRef.value?.$el?.focus()
}
defineExpose({ focus })
const placeholderText = computed(
() => placeholder ?? t('g.searchPlaceholder', { subject: '' })
)
function clearSearch() {
modelValue.value = ''
focus()
}
watchDebounced(
modelValue,
(value: string) => {
emit('search', value)
},
{ debounce: debounceTime }
)
</script>

View File

@@ -0,0 +1,54 @@
import type { VariantProps } from 'cva'
import { cva } from 'cva'
export const searchInputVariants = cva({
base: 'relative flex w-full cursor-text items-center rounded-lg bg-secondary-background text-base-foreground',
variants: {
size: {
sm: 'h-6 p-1',
md: 'h-8 px-2 py-1.5',
lg: 'h-10 px-2 py-2',
xl: 'h-12 px-2 py-2'
}
},
defaultVariants: { size: 'md' }
})
export type SearchInputVariants = VariantProps<typeof searchInputVariants>
export const searchInputSizeConfig = {
sm: {
icon: 'size-3',
iconPos: 'left-2',
inputPl: 'pl-6',
inputText: 'text-xs',
clearPos: 'left-1'
},
md: {
icon: 'size-4',
iconPos: 'left-2.5',
inputPl: 'pl-8',
inputText: 'text-xs',
clearPos: 'left-2.5'
},
lg: {
icon: 'size-4',
iconPos: 'left-2.5',
inputPl: 'pl-8',
inputText: 'text-xs',
clearPos: 'left-2.5'
},
xl: {
icon: 'size-4',
iconPos: 'left-2.5',
inputPl: 'pl-8',
inputText: 'text-xs',
clearPos: 'left-2'
}
} as const
const sizes = ['sm', 'md', 'lg', 'xl'] as const satisfies Array<
SearchInputVariants['size']
>
export const searchInputStoryConfig = { sizes } as const