mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-03-08 06:30:04 +00:00
[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:
159
src/components/ui/search-input/SearchInput.stories.ts
Normal file
159
src/components/ui/search-input/SearchInput.stories.ts
Normal 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>
|
||||
`
|
||||
})
|
||||
}
|
||||
132
src/components/ui/search-input/SearchInput.vue
Normal file
132
src/components/ui/search-input/SearchInput.vue
Normal 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>
|
||||
54
src/components/ui/search-input/searchInput.variants.ts
Normal file
54
src/components/ui/search-input/searchInput.variants.ts
Normal 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
|
||||
Reference in New Issue
Block a user