Some checks failed
ci / 정본·보안·린트·타입·테스트 (push) Failing after 50s
ci / 워크스페이스 빌드 검증 (push) Has been skipped
ci / Supabase Edge Functions + Cloudflare Worker (push) Successful in 20s
ci / .NET API 서버 테스트 (push) Successful in 12s
deploy-site / deploy (push) Successful in 39s
ci / 모바일 린트·타입·Jest (push) Failing after 3h10m31s
124 lines
4.2 KiB
TypeScript
124 lines
4.2 KiB
TypeScript
// src/renderer/components/shared/SearchInput.tsx
|
|
// 공유: 화면 안 검색창. "/" 를 누르면 여기로 포커스가 온다(입력 중이 아닐 때) — 표시만 하고 동작하지 않던
|
|
// 키캡을 실제 단축키로 만들었다(2026-09-28). Esc 는 검색어를 지우고, 비어 있으면 포커스를 놓는다.
|
|
|
|
import React, { useEffect, useRef } from 'react'
|
|
import { TextField, InputAdornment, IconButton, Box } from '@mui/material'
|
|
import { Search, X } from 'lucide-react'
|
|
import { d3roPalette, d3roFontMono, d3roTypo, d3roRadius } from '@d3ro/ui/theme'
|
|
import { useI18n } from '@d3ro/i18n'
|
|
|
|
export interface SearchInputProps {
|
|
value: string
|
|
onChange: (value: string) => void
|
|
placeholder: string
|
|
/** 포커스 단축키. '/' 또는 'Ctrl K'. 빈 문자열이면 끈다 */
|
|
shortcut?: string
|
|
autoFocus?: boolean
|
|
}
|
|
|
|
function isTypingTarget(target: EventTarget | null): boolean {
|
|
if (!(target instanceof HTMLElement)) return false
|
|
return target.isContentEditable || ['INPUT', 'TEXTAREA', 'SELECT'].includes(target.tagName)
|
|
}
|
|
|
|
export function SearchInput({
|
|
value,
|
|
onChange,
|
|
placeholder,
|
|
shortcut = '/',
|
|
autoFocus = false,
|
|
}: SearchInputProps): React.ReactElement {
|
|
const { t } = useI18n()
|
|
const inputRef = useRef<HTMLInputElement>(null)
|
|
|
|
useEffect(() => {
|
|
const onKeyDown = (e: KeyboardEvent): void => {
|
|
const slash = shortcut === '/'
|
|
&& e.key === '/'
|
|
&& !e.ctrlKey
|
|
&& !e.metaKey
|
|
&& !e.altKey
|
|
&& !isTypingTarget(e.target)
|
|
const commandK = shortcut === 'Ctrl K'
|
|
&& (e.ctrlKey || e.metaKey)
|
|
&& !e.altKey
|
|
&& !e.shiftKey
|
|
&& e.key.toLocaleLowerCase() === 'k'
|
|
if (!slash && !commandK) return
|
|
e.preventDefault()
|
|
inputRef.current?.focus()
|
|
}
|
|
if (shortcut !== '/' && shortcut !== 'Ctrl K') return undefined
|
|
window.addEventListener('keydown', onKeyDown)
|
|
return () => window.removeEventListener('keydown', onKeyDown)
|
|
}, [shortcut])
|
|
|
|
return (
|
|
<TextField
|
|
inputRef={inputRef}
|
|
autoFocus={autoFocus}
|
|
placeholder={placeholder}
|
|
value={value}
|
|
onChange={(e) => onChange(e.target.value)}
|
|
onKeyDown={(e) => {
|
|
if (e.key !== 'Escape') return
|
|
if (value) onChange('')
|
|
else inputRef.current?.blur()
|
|
}}
|
|
fullWidth
|
|
sx={{
|
|
mb: 2,
|
|
'& .MuiOutlinedInput-root': {
|
|
bgcolor: d3roPalette.bg.input,
|
|
borderRadius: d3roRadius.button,
|
|
'& fieldset': { borderColor: d3roPalette.border.default },
|
|
'&:hover fieldset': { borderColor: d3roPalette.border.strong },
|
|
'&.Mui-focused fieldset': { borderColor: d3roPalette.accent.main, borderWidth: 1 },
|
|
},
|
|
'& .MuiInputBase-input': {
|
|
fontSize: d3roTypo.compact.size,
|
|
color: d3roPalette.text.primary,
|
|
py: 1,
|
|
'&::placeholder': { color: d3roPalette.text.tertiary, opacity: 1 },
|
|
},
|
|
}}
|
|
slotProps={{
|
|
htmlInput: { 'aria-label': placeholder, type: 'search' },
|
|
input: {
|
|
startAdornment: (
|
|
<InputAdornment position="start">
|
|
<Search size={16} aria-hidden style={{ color: d3roPalette.text.tertiary }} />
|
|
</InputAdornment>
|
|
),
|
|
endAdornment: value ? (
|
|
<InputAdornment position="end">
|
|
<IconButton size="small" aria-label={t('common.clear')} onClick={() => onChange('')} sx={{ p: 0.5 }}>
|
|
<X size={15} />
|
|
</IconButton>
|
|
</InputAdornment>
|
|
) : shortcut ? (
|
|
<InputAdornment position="end" aria-hidden>
|
|
<Box
|
|
component="kbd"
|
|
sx={{
|
|
px: 0.75,
|
|
py: 0.25,
|
|
borderRadius: d3roRadius.xs,
|
|
border: `1px solid ${d3roPalette.border.default}`,
|
|
fontFamily: d3roFontMono,
|
|
fontSize: '11px',
|
|
color: d3roPalette.text.tertiary,
|
|
lineHeight: 1,
|
|
userSelect: 'none',
|
|
}}
|
|
>
|
|
{shortcut}
|
|
</Box>
|
|
</InputAdornment>
|
|
) : undefined,
|
|
},
|
|
}}
|
|
/>
|
|
)
|
|
}
|