|
22 | 22 | activeModel, |
23 | 23 | rows, |
24 | 24 | showSearch, |
| 25 | + anchored = false, |
25 | 26 | query = $bindable(), |
26 | 27 | selIdx = $bindable(), |
27 | 28 | onClose, |
|
33 | 34 | activeModel: { model: string; reasoning_efforts: string[]; active: boolean } | undefined; |
34 | 35 | rows: Row[]; |
35 | 36 | showSearch: boolean; |
| 37 | + // Anchored: render as a compact popover above the caller (no dimmed overlay, |
| 38 | + // no focus trap) instead of a screen-centered modal. |
| 39 | + anchored?: boolean; |
36 | 40 | query: string; |
37 | 41 | selIdx: number; |
38 | 42 | onClose: () => void; |
|
41 | 45 | } = $props(); |
42 | 46 | </script> |
43 | 47 |
|
44 | | -<div class="overlay" role="presentation" onclick={(e) => e.target === e.currentTarget && onClose()}> |
45 | | - <div class="modal" role="dialog" aria-modal="true" tabindex="-1" aria-label={title} use:focusTrap> |
46 | | - <div class="modal-head"> |
47 | | - <span>{title}</span> |
48 | | - <IconButton onclick={onClose} label="close"><X size={15} /></IconButton> |
49 | | - </div> |
50 | | - {#if chat.picker?.kind === 'model' && activeModel} |
51 | | - <div class="efforts"> |
52 | | - <span class="dim">effort</span> |
53 | | - {#each activeModel.reasoning_efforts as ef (ef)} |
54 | | - <button class="eff" class:on={ef === chat.picker.activeEffort} onclick={() => onEffort(ef)}>{ef}</button> |
55 | | - {/each} |
56 | | - </div> |
57 | | - {/if} |
58 | | - {#if showSearch} |
59 | | - <div class="psearch"> |
60 | | - <Search size={14} /> |
61 | | - <!-- svelte-ignore a11y_autofocus --> |
62 | | - <input bind:value={query} placeholder={t('shell.pickerSearchPlaceholder')} autofocus /> |
63 | | - </div> |
64 | | - {/if} |
65 | | - <div class="rows"> |
66 | | - {#each rows as row, i (row.id)} |
67 | | - <button class="prow" class:sel={i === selIdx} onclick={() => onSelect(row.command)} onmouseenter={() => (selIdx = i)} style:padding-left={row.depth != null ? `${11 + row.depth * 16}px` : null}> |
68 | | - {#if chat.picker?.kind === 'model'}<Vendor model={row.vendor ?? row.label} size={15} />{/if} |
69 | | - {#if row.depth != null && row.depth > 0}<span class="twig">↳</span>{/if} |
70 | | - <span class="prow-main">{row.label || t('shell.empty')}</span> |
71 | | - <span class="prow-detail">{row.detail}</span> |
72 | | - {#if row.active}<Check size={14} class="prow-check" />{/if} |
73 | | - </button> |
| 48 | +{#snippet body()} |
| 49 | + <div class="modal-head"> |
| 50 | + <span>{title}</span> |
| 51 | + <IconButton onclick={onClose} label="close"><X size={15} /></IconButton> |
| 52 | + </div> |
| 53 | + {#if chat.picker?.kind === 'model' && activeModel} |
| 54 | + <div class="efforts"> |
| 55 | + <span class="dim">effort</span> |
| 56 | + {#each activeModel.reasoning_efforts as ef (ef)} |
| 57 | + <button class="eff" class:on={ef === chat.picker.activeEffort} onclick={() => onEffort(ef)}>{ef}</button> |
74 | 58 | {/each} |
75 | | - {#if rows.length === 0}<div class="pempty">{query.trim() ? t('shell.noMatch') : t('shell.noOptions')}</div>{/if} |
76 | 59 | </div> |
77 | | - <div class="modal-foot dim">{t('shell.pickerFoot')}</div> |
| 60 | + {/if} |
| 61 | + {#if showSearch} |
| 62 | + <div class="psearch"> |
| 63 | + <Search size={14} /> |
| 64 | + <!-- svelte-ignore a11y_autofocus --> |
| 65 | + <input bind:value={query} placeholder={t('shell.pickerSearchPlaceholder')} autofocus /> |
| 66 | + </div> |
| 67 | + {/if} |
| 68 | + <div class="rows"> |
| 69 | + {#each rows as row, i (row.id)} |
| 70 | + <button class="prow" class:sel={i === selIdx} onclick={() => onSelect(row.command)} onmouseenter={() => (selIdx = i)} style:padding-left={row.depth != null ? `${11 + row.depth * 16}px` : null}> |
| 71 | + {#if chat.picker?.kind === 'model'}<Vendor model={row.vendor ?? row.label} size={15} />{/if} |
| 72 | + {#if row.depth != null && row.depth > 0}<span class="twig">↳</span>{/if} |
| 73 | + <span class="prow-main">{row.label || t('shell.empty')}</span> |
| 74 | + <span class="prow-detail">{row.detail}</span> |
| 75 | + {#if row.active}<Check size={14} class="prow-check" />{/if} |
| 76 | + </button> |
| 77 | + {/each} |
| 78 | + {#if rows.length === 0}<div class="pempty">{query.trim() ? t('shell.noMatch') : t('shell.noOptions')}</div>{/if} |
| 79 | + </div> |
| 80 | + <div class="modal-foot dim">{t('shell.pickerFoot')}</div> |
| 81 | +{/snippet} |
| 82 | + |
| 83 | +{#if anchored} |
| 84 | + <button class="pop-backdrop" aria-label="close" onclick={onClose}></button> |
| 85 | + <div class="modal anchored" role="dialog" aria-label={title}> |
| 86 | + {@render body()} |
| 87 | + </div> |
| 88 | +{:else} |
| 89 | + <div class="overlay" role="presentation" onclick={(e) => e.target === e.currentTarget && onClose()}> |
| 90 | + <div class="modal" role="dialog" aria-modal="true" tabindex="-1" aria-label={title} use:focusTrap> |
| 91 | + {@render body()} |
| 92 | + </div> |
78 | 93 | </div> |
79 | | -</div> |
| 94 | +{/if} |
80 | 95 |
|
81 | 96 | <style> |
82 | 97 | .overlay { |
|
99 | 114 | box-shadow: var(--shadow-modal); |
100 | 115 | overflow: hidden; |
101 | 116 | } |
| 117 | + /* Anchored popover: sits above the caller (composer's model button), not |
| 118 | + centered. The caller wraps us in a position:relative container. */ |
| 119 | + .pop-backdrop { |
| 120 | + position: fixed; |
| 121 | + inset: 0; |
| 122 | + background: none; |
| 123 | + border: none; |
| 124 | + z-index: 20; |
| 125 | + cursor: default; |
| 126 | + } |
| 127 | + .modal.anchored { |
| 128 | + position: absolute; |
| 129 | + bottom: calc(100% + 8px); |
| 130 | + left: 0; |
| 131 | + z-index: 21; |
| 132 | + width: min(380px, 82vw); |
| 133 | + max-height: min(60vh, 420px); |
| 134 | + border-radius: var(--r-md); |
| 135 | + box-shadow: var(--shadow-pop); |
| 136 | + animation: rise 0.12s ease; |
| 137 | + } |
| 138 | + @keyframes rise { |
| 139 | + from { |
| 140 | + opacity: 0; |
| 141 | + transform: translateY(4px); |
| 142 | + } |
| 143 | + } |
102 | 144 | .modal-head { |
103 | 145 | display: flex; |
104 | 146 | align-items: center; |
|
0 commit comments