|
6 | 6 | * so the user can see at a glance what kind of input the bar expects. Switching mode preserves |
7 | 7 | * the typed query; this component is presentational, the parent owns `query` and `mode`. |
8 | 8 | * |
| 9 | + * The right gutter shows two things, both managed by the parent dialog: |
| 10 | + * - A subtle "Press Enter to search" hint when auto-apply is off (or AI mode) and the query |
| 11 | + * has changed since the last run. Visible state, not interactive. |
| 12 | + * - A small ⏎ run button. Always present; clicking it is equivalent to pressing Enter. |
| 13 | + * |
| 14 | + * IME composition is also surfaced: `oncompositionstart` and `oncompositionend` let the parent |
| 15 | + * suppress auto-apply mid-composition and fire exactly once on completion (M6 addition). |
| 16 | + * |
9 | 17 | * Keyboard contract (handled by the parent dialog, not here): |
10 | 18 | * - Enter runs the search in the active mode. |
11 | 19 | * - ⌘Enter runs an AI search regardless (only when AI is enabled). |
12 | 20 | * - ⌘1/⌘2/⌘3 switch modes (numbering changes when AI is off). |
13 | 21 | */ |
| 22 | + import IconCornerDownLeft from '~icons/lucide/corner-down-left' |
14 | 23 | import type { SearchMode } from './search-state.svelte' |
15 | 24 |
|
16 | 25 | interface Props { |
|
20 | 29 | mode: SearchMode |
21 | 30 | disabled: boolean |
22 | 31 | aiHighlight: boolean |
| 32 | + /** True when the bar should show the "Press Enter to search" hint. Owned by the parent. */ |
| 33 | + showRunHint?: boolean |
23 | 34 | onInput: (value: string) => void |
| 35 | + /** Click handler for the ⏎ run button. Equivalent to pressing Enter in the input. */ |
| 36 | + onRun: () => void |
| 37 | + /** IME composition entry: parent suppresses auto-apply between start and end. */ |
| 38 | + onCompositionStart?: () => void |
| 39 | + /** IME composition exit: parent fires exactly one debounced search after this. */ |
| 40 | + onCompositionEnd?: () => void |
24 | 41 | } |
25 | 42 |
|
26 | 43 | /* eslint-disable prefer-const -- $bindable() requires `let` destructuring */ |
27 | | - let { inputElement = $bindable(), query, mode, disabled, aiHighlight, onInput }: Props = $props() |
| 44 | + let { |
| 45 | + inputElement = $bindable(), |
| 46 | + query, |
| 47 | + mode, |
| 48 | + disabled, |
| 49 | + aiHighlight, |
| 50 | + showRunHint = false, |
| 51 | + onInput, |
| 52 | + onRun, |
| 53 | + onCompositionStart, |
| 54 | + onCompositionEnd, |
| 55 | + }: Props = $props() |
28 | 56 | /* eslint-enable prefer-const */ |
29 | 57 |
|
30 | 58 | /** Placeholder text per mode. Filenames are the workhorse, so we name the wildcards there. */ |
|
39 | 67 | if (mode === 'regex') return 'Regex search pattern' |
40 | 68 | return 'Filename search pattern' |
41 | 69 | }) |
| 70 | +
|
| 71 | + /** AI mode runs only on explicit Enter / ⌘Enter / Run-button click. Show the hint title to match. */ |
| 72 | + const runTitle = $derived(mode === 'ai' ? 'Run AI search (Enter)' : 'Run search (Enter)') |
42 | 73 | </script> |
43 | 74 |
|
44 | 75 | <div class="search-bar" class:is-disabled={disabled}> |
|
56 | 87 | oninput={(e: Event) => { |
57 | 88 | onInput((e.target as HTMLInputElement).value) |
58 | 89 | }} |
| 90 | + oncompositionstart={() => { |
| 91 | + onCompositionStart?.() |
| 92 | + }} |
| 93 | + oncompositionend={() => { |
| 94 | + onCompositionEnd?.() |
| 95 | + }} |
59 | 96 | {disabled} |
60 | 97 | aria-label={ariaLabel} |
61 | 98 | spellcheck="false" |
62 | 99 | autocomplete="off" |
63 | 100 | autocapitalize="off" |
64 | 101 | /> |
| 102 | + {#if showRunHint} |
| 103 | + <span class="run-hint" aria-hidden="true">Press Enter to search</span> |
| 104 | + {/if} |
| 105 | + <button |
| 106 | + type="button" |
| 107 | + class="run-button" |
| 108 | + {disabled} |
| 109 | + onclick={onRun} |
| 110 | + title={runTitle} |
| 111 | + aria-label={runTitle} |
| 112 | + > |
| 113 | + <IconCornerDownLeft /> |
| 114 | + </button> |
65 | 115 | </div> |
66 | 116 |
|
67 | 117 | <style> |
|
103 | 153 | border-radius: var(--radius-sm); |
104 | 154 | transition: background 1.5s ease-out; |
105 | 155 | } |
| 156 | +
|
| 157 | + .run-hint { |
| 158 | + flex-shrink: 0; |
| 159 | + color: var(--color-text-tertiary); |
| 160 | + font-size: var(--font-size-xs); |
| 161 | + white-space: nowrap; |
| 162 | + } |
| 163 | +
|
| 164 | + .run-button { |
| 165 | + flex-shrink: 0; |
| 166 | + display: inline-flex; |
| 167 | + align-items: center; |
| 168 | + justify-content: center; |
| 169 | + padding: var(--spacing-xxs) var(--spacing-xs); |
| 170 | + background: transparent; |
| 171 | + border: 1px solid var(--color-border-subtle); |
| 172 | + border-radius: var(--radius-sm); |
| 173 | + color: var(--color-text-secondary); |
| 174 | + cursor: default; |
| 175 | + line-height: 1; |
| 176 | + } |
| 177 | +
|
| 178 | + .run-button:hover:not(:disabled) { |
| 179 | + background: var(--color-bg-tertiary); |
| 180 | + color: var(--color-text-primary); |
| 181 | + } |
| 182 | +
|
| 183 | + .run-button:focus-visible { |
| 184 | + outline: 2px solid var(--color-accent); |
| 185 | + outline-offset: 1px; |
| 186 | + } |
| 187 | +
|
| 188 | + .run-button:disabled { |
| 189 | + opacity: 0.5; |
| 190 | + cursor: default; |
| 191 | + } |
106 | 192 | </style> |
0 commit comments