|
2 | 2 | import { explorerState } from './explorer-state.svelte' |
3 | 3 | import { getActiveTab } from '../tabs/tab-state-manager.svelte' |
4 | 4 | import { capabilitiesFor } from './volume-capabilities' |
| 5 | + import { getFirstShortcutReactive } from '$lib/shortcuts/reactive-shortcuts.svelte' |
| 6 | + import { fnKeyToCommand } from './function-key-commands' |
5 | 7 | import type { CommandId } from '$lib/commands' |
6 | 8 |
|
7 | 9 | interface Props { |
|
19 | 21 | const { visible = true, onCommand }: Props = $props() |
20 | 22 |
|
21 | 23 | /** |
22 | | - * Each F-key button's command id. Held in a typed map (not inlined as a |
23 | | - * string literal at the `onCommand?.(…)` call site) so the `CommandId` type |
24 | | - * is checked and `cmdr/no-raw-command-dispatch` stays satisfied: the call |
25 | | - * site passes a typed value, never a magic string. |
| 24 | + * Each visible button's CHIP shows its command's live effective first shortcut |
| 25 | + * (`getFirstShortcutReactive`), not the hardcoded F-key. Rebinding `file.copy` |
| 26 | + * to `⌘C` in Settings re-renders the F5 button's chip as `⌘C` immediately, so |
| 27 | + * the bar never lies about what the keys do. The chip keeps the bar's quiet |
| 28 | + * `<kbd>` look (a boxed `ShortcutChip` pill repeated 8× fights the flat bar); |
| 29 | + * truthfulness is the must, the chip style is the want (see the migration plan). |
| 30 | + * |
| 31 | + * The Shift fork stays presentational: WHICH buttons appear on Shift is fixed, |
| 32 | + * but each shown button reads ITS command's effective FIRST binding. Both Rename |
| 33 | + * buttons (F2 and the Shift-revealed one) therefore show `file.rename`'s first |
| 34 | + * binding — slightly odd, but truthful, which is the whole point. |
| 35 | + * |
| 36 | + * When a command has no binding the chip renders nothing (the button stays |
| 37 | + * clickable and keeps its label); an empty `<kbd>` would read as broken. |
26 | 38 | */ |
27 | | - const fnKeyToCommand = { |
28 | | - view: 'file.view', |
29 | | - edit: 'file.edit', |
30 | | - copy: 'file.copy', |
31 | | - move: 'file.move', |
32 | | - rename: 'file.rename', |
33 | | - newFile: 'file.newFile', |
34 | | - newFolder: 'file.newFolder', |
35 | | - delete: 'file.delete', |
36 | | - deletePermanently: 'file.deletePermanently', |
37 | | - } as const satisfies Record<string, CommandId> |
| 39 | + function shortcutFor(id: CommandId): string | undefined { |
| 40 | + return getFirstShortcutReactive(id) |
| 41 | + } |
38 | 42 |
|
39 | 43 | /** |
40 | 44 | * Capabilities for the focused pane, read straight off the explorer store. |
|
82 | 86 |
|
83 | 87 | <svelte:document onkeydown={handleKeyDown} onkeyup={handleKeyUp} /> |
84 | 88 |
|
| 89 | +<!-- |
| 90 | + One command button. The chip reads the command's live effective first shortcut; |
| 91 | + the aria-label interpolates the same dynamic combo so screen readers hear what |
| 92 | + actually triggers the action ("Copy (F5)" → "Copy (⌘C)" after a rebind). When |
| 93 | + unbound, both the chip and the parenthetical drop — the label alone, still clickable. |
| 94 | +--> |
| 95 | +{#snippet commandButton(id: CommandId, label: string, action: string, enabled: boolean)} |
| 96 | + {@const shortcut = shortcutFor(id)} |
| 97 | + <button |
| 98 | + onclick={() => onCommand?.(id)} |
| 99 | + disabled={!enabled} |
| 100 | + tabindex={-1} |
| 101 | + aria-label={shortcut ? `${action} (${shortcut})` : action} |
| 102 | + > |
| 103 | + {#if shortcut}<kbd>{shortcut}</kbd>{/if}<span>{label}</span> |
| 104 | + </button> |
| 105 | +{/snippet} |
| 106 | + |
| 107 | +<!-- A fixed F-key slot with no Shift action. Presentational only (not a command). --> |
| 108 | +{#snippet emptySlot(fnKey: string)} |
| 109 | + <button disabled tabindex={-1} aria-label="{fnKey} (no shift action)"> |
| 110 | + <kbd>{fnKey}</kbd> |
| 111 | + </button> |
| 112 | +{/snippet} |
| 113 | + |
85 | 114 | {#if visible} |
86 | 115 | <div |
87 | 116 | class="function-key-bar" |
|
91 | 120 | e.preventDefault() |
92 | 121 | }} |
93 | 122 | > |
| 123 | + <!-- eslint-disable @typescript-eslint/no-confusing-void-expression -- Svelte {@render} syntax --> |
94 | 124 | {#if shiftHeld} |
95 | | - <button disabled tabindex={-1} aria-label="F2 (no shift action)"> |
96 | | - <kbd>F2</kbd> |
97 | | - </button> |
98 | | - <button disabled tabindex={-1} aria-label="F3 (no shift action)"> |
99 | | - <kbd>F3</kbd> |
100 | | - </button> |
101 | | - <button |
102 | | - onclick={() => onCommand?.(fnKeyToCommand.newFile)} |
103 | | - disabled={!canMkfile} |
104 | | - tabindex={-1} |
105 | | - aria-label="Create new file (Shift+F4)" |
106 | | - > |
107 | | - <kbd>⇧F4</kbd><span>New file</span> |
108 | | - </button> |
109 | | - <button disabled tabindex={-1} aria-label="F5 (no shift action)"> |
110 | | - <kbd>F5</kbd> |
111 | | - </button> |
112 | | - <button |
113 | | - onclick={() => onCommand?.(fnKeyToCommand.rename)} |
114 | | - disabled={!canRename} |
115 | | - tabindex={-1} |
116 | | - aria-label="Rename (Shift+F6)" |
117 | | - > |
118 | | - <kbd>⇧F6</kbd><span>Rename</span> |
119 | | - </button> |
120 | | - <button disabled tabindex={-1} aria-label="F7 (no shift action)"> |
121 | | - <kbd>F7</kbd> |
122 | | - </button> |
123 | | - <button |
124 | | - onclick={() => onCommand?.(fnKeyToCommand.deletePermanently)} |
125 | | - disabled={!canSourceOps} |
126 | | - tabindex={-1} |
127 | | - aria-label="Delete permanently (Shift+F8)" |
128 | | - > |
129 | | - <kbd>⇧F8</kbd><span>Permanently</span> |
130 | | - </button> |
| 125 | + {@render emptySlot('F2')} |
| 126 | + {@render emptySlot('F3')} |
| 127 | + {@render commandButton(fnKeyToCommand.newFile, 'New file', 'Create new file', canMkfile)} |
| 128 | + {@render emptySlot('F5')} |
| 129 | + {@render commandButton(fnKeyToCommand.rename, 'Rename', 'Rename', canRename)} |
| 130 | + {@render emptySlot('F7')} |
| 131 | + {@render commandButton( |
| 132 | + fnKeyToCommand.deletePermanently, |
| 133 | + 'Permanently', |
| 134 | + 'Delete permanently', |
| 135 | + canSourceOps, |
| 136 | + )} |
131 | 137 | {:else} |
132 | | - <button |
133 | | - onclick={() => onCommand?.(fnKeyToCommand.rename)} |
134 | | - disabled={!canRename} |
135 | | - tabindex={-1} |
136 | | - aria-label="Rename (F2)" |
137 | | - > |
138 | | - <kbd>F2</kbd><span>Rename</span> |
139 | | - </button> |
140 | | - <button |
141 | | - onclick={() => onCommand?.(fnKeyToCommand.view)} |
142 | | - tabindex={-1} |
143 | | - aria-label="View file (F3)" |
144 | | - > |
145 | | - <kbd>F3</kbd><span>View</span> |
146 | | - </button> |
147 | | - <button |
148 | | - onclick={() => onCommand?.(fnKeyToCommand.edit)} |
149 | | - tabindex={-1} |
150 | | - aria-label="Edit file (F4)" |
151 | | - > |
152 | | - <kbd>F4</kbd><span>Edit</span> |
153 | | - </button> |
154 | | - <button |
155 | | - onclick={() => onCommand?.(fnKeyToCommand.copy)} |
156 | | - disabled={!canSourceOps} |
157 | | - tabindex={-1} |
158 | | - aria-label="Copy (F5)" |
159 | | - > |
160 | | - <kbd>F5</kbd><span>Copy</span> |
161 | | - </button> |
162 | | - <button |
163 | | - onclick={() => onCommand?.(fnKeyToCommand.move)} |
164 | | - disabled={!canSourceOps} |
165 | | - tabindex={-1} |
166 | | - aria-label="Move (F6)" |
167 | | - > |
168 | | - <kbd>F6</kbd><span>Move</span> |
169 | | - </button> |
170 | | - <button |
171 | | - onclick={() => onCommand?.(fnKeyToCommand.newFolder)} |
172 | | - disabled={!canMkdir} |
173 | | - tabindex={-1} |
174 | | - aria-label="New folder (F7)" |
175 | | - > |
176 | | - <kbd>F7</kbd><span>New folder</span> |
177 | | - </button> |
178 | | - <button |
179 | | - onclick={() => onCommand?.(fnKeyToCommand.delete)} |
180 | | - disabled={!canSourceOps} |
181 | | - tabindex={-1} |
182 | | - aria-label="Delete (F8)" |
183 | | - > |
184 | | - <kbd>F8</kbd><span>Delete</span> |
185 | | - </button> |
| 138 | + {@render commandButton(fnKeyToCommand.rename, 'Rename', 'Rename', canRename)} |
| 139 | + {@render commandButton(fnKeyToCommand.view, 'View', 'View file', true)} |
| 140 | + {@render commandButton(fnKeyToCommand.edit, 'Edit', 'Edit file', true)} |
| 141 | + {@render commandButton(fnKeyToCommand.copy, 'Copy', 'Copy', canSourceOps)} |
| 142 | + {@render commandButton(fnKeyToCommand.move, 'Move', 'Move', canSourceOps)} |
| 143 | + {@render commandButton(fnKeyToCommand.newFolder, 'New folder', 'New folder', canMkdir)} |
| 144 | + {@render commandButton(fnKeyToCommand.delete, 'Delete', 'Delete', canSourceOps)} |
186 | 145 | {/if} |
| 146 | + <!-- eslint-enable @typescript-eslint/no-confusing-void-expression --> |
187 | 147 | </div> |
188 | 148 | {/if} |
189 | 149 |
|
|
196 | 156 |
|
197 | 157 | button { |
198 | 158 | flex: 1; |
| 159 | + /* min-width: 0 lets a button shrink below its content size so a long custom |
| 160 | + binding (e.g. ⌘⇧⌥K) can't force the bar wider than the window: the label |
| 161 | + truncates instead. Without it, flex items refuse to shrink past content. */ |
| 162 | + min-width: 0; |
199 | 163 | display: flex; |
200 | 164 | align-items: center; |
201 | 165 | justify-content: center; |
|
210 | 174 | transition: background-color var(--transition-fast); |
211 | 175 | } |
212 | 176 |
|
| 177 | + /* The label truncates before the chip does: a long binding keeps the key |
| 178 | + readable (the chip is the truthful claim) while the word gives way. */ |
| 179 | + button > span { |
| 180 | + overflow: hidden; |
| 181 | + text-overflow: ellipsis; |
| 182 | + white-space: nowrap; |
| 183 | + min-width: 0; |
| 184 | + } |
| 185 | +
|
213 | 186 | button:last-child { |
214 | 187 | border-right: none; |
215 | 188 | } |
|
229 | 202 | color: var(--color-text-secondary); |
230 | 203 | /* stylelint-disable-next-line declaration-property-value-disallowed-list */ |
231 | 204 | padding: 1px var(--spacing-xs); |
| 205 | + white-space: nowrap; |
| 206 | + flex-shrink: 0; |
232 | 207 | } |
233 | 208 | </style> |
0 commit comments