@brftech/filex-core 0.39.1 → 0.41.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (125) hide show
  1. package/README.md +32 -25
  2. package/dist/ArchiveViewer-BNqukFg8.js +124 -0
  3. package/dist/ArchiveViewer-BNqukFg8.js.map +1 -0
  4. package/dist/CsvViewer-ii_-MgmG.js +141 -0
  5. package/dist/CsvViewer-ii_-MgmG.js.map +1 -0
  6. package/dist/{DrawioViewer-BNALOB04.js → DrawioViewer-B2tuu4rX.js} +48 -42
  7. package/dist/DrawioViewer-B2tuu4rX.js.map +1 -0
  8. package/dist/EpubViewer-p4B8iiWb.js +163 -0
  9. package/dist/EpubViewer-p4B8iiWb.js.map +1 -0
  10. package/dist/IpynbViewer-D_qYRJhu.js +184 -0
  11. package/dist/IpynbViewer-D_qYRJhu.js.map +1 -0
  12. package/dist/MermaidViewer-3ZkfNM8v.js +137 -0
  13. package/dist/MermaidViewer-3ZkfNM8v.js.map +1 -0
  14. package/dist/PsdViewer-CuV-OPSd.js +122 -0
  15. package/dist/PsdViewer-CuV-OPSd.js.map +1 -0
  16. package/dist/TiffViewer-BLeoF4Be.js +142 -0
  17. package/dist/TiffViewer-BLeoF4Be.js.map +1 -0
  18. package/dist/Viewer3D-DHhhU76E.js +75 -0
  19. package/dist/Viewer3D-DHhhU76E.js.map +1 -0
  20. package/dist/filex-core.js +217 -20077
  21. package/dist/filex-core.js.map +1 -1
  22. package/dist/filex-core.umd.cjs +61 -62
  23. package/dist/filex-core.umd.cjs.map +1 -1
  24. package/dist/index-BraG7Cz4.js +26664 -0
  25. package/dist/index-BraG7Cz4.js.map +1 -0
  26. package/dist/index.d.ts +4276 -851
  27. package/dist/style.css +1 -1
  28. package/package.json +1 -1
  29. package/src/FileExplorer.vue +2170 -492
  30. package/src/components/AdvancedSearch.vue +870 -0
  31. package/src/components/Breadcrumb.vue +191 -19
  32. package/src/components/CommandPalette.vue +66 -22
  33. package/src/components/ConnectionsPanel.vue +6 -2
  34. package/src/components/ContextMenu.vue +57 -2
  35. package/src/components/EncryptedFolderModal.vue +31 -25
  36. package/src/components/FilePane.vue +939 -0
  37. package/src/components/FilterBar.vue +568 -72
  38. package/src/components/GalleryView.vue +162 -30
  39. package/src/components/GridView.vue +322 -58
  40. package/src/components/HomeView.vue +331 -0
  41. package/src/components/InspectorPanel.vue +296 -53
  42. package/src/components/ListView.vue +1158 -167
  43. package/src/components/NFSExportsPanel.vue +7 -4
  44. package/src/components/OnboardingTour.vue +156 -33
  45. package/src/components/QuickLook.vue +34 -5
  46. package/src/components/RecentlyOpened.vue +17 -2
  47. package/src/components/S3KeysPanel.vue +7 -4
  48. package/src/components/SSHKeysPanel.vue +7 -4
  49. package/src/components/ShortcutSettings.vue +43 -2
  50. package/src/components/ShortcutsHelp.vue +4 -1
  51. package/src/components/SideNav.vue +206 -140
  52. package/src/components/StorageFields.vue +19 -1
  53. package/src/components/TabBar.vue +87 -1
  54. package/src/components/TagPicker.vue +77 -6
  55. package/src/components/ThemeGallery.vue +13 -198
  56. package/src/components/ThemePalette.vue +244 -0
  57. package/src/components/TimeZoneDialog.vue +127 -0
  58. package/src/components/TimeZonePicker.vue +696 -0
  59. package/src/components/TokensPanel.vue +16 -4
  60. package/src/components/Toolbar.vue +965 -469
  61. package/src/components/ViewSwitcher.vue +9 -4
  62. package/src/composables/useConnections.ts +35 -4
  63. package/src/composables/useExplorerTimeZone.ts +85 -0
  64. package/src/composables/useFileApi.ts +53 -4
  65. package/src/composables/useKeyboardShortcuts.ts +122 -0
  66. package/src/composables/useLocale.ts +365 -25
  67. package/src/index.ts +182 -3
  68. package/src/lib/actionIcons.ts +330 -0
  69. package/src/lib/advSearch.ts +162 -0
  70. package/src/lib/dateGroups.ts +197 -0
  71. package/src/lib/destinationTree.ts +235 -0
  72. package/src/lib/downloadSelection.ts +154 -0
  73. package/src/lib/fileFilters.ts +319 -12
  74. package/src/lib/fileIcons.ts +299 -5
  75. package/src/lib/filePreview.ts +605 -0
  76. package/src/lib/listing.ts +72 -0
  77. package/src/lib/shareTtl.ts +11 -1
  78. package/src/lib/sortOrder.ts +459 -0
  79. package/src/lib/themes.ts +152 -23
  80. package/src/lib/timezone.ts +456 -0
  81. package/src/lib/uiProfile.ts +82 -0
  82. package/src/lib/viewPrefs.ts +1036 -0
  83. package/src/locales/en.ts +432 -25
  84. package/src/locales/tr.ts +425 -25
  85. package/src/modals/ConvertModal.vue +23 -3
  86. package/src/modals/DestinationPickerModal.vue +341 -0
  87. package/src/modals/Modal.vue +18 -4
  88. package/src/modals/NewDocumentModal.vue +525 -0
  89. package/src/modals/NewFolderModal.vue +15 -9
  90. package/src/modals/PermissionsModal.vue +543 -473
  91. package/src/modals/PreviewModal.vue +516 -156
  92. package/src/styles/base.css +5435 -8
  93. package/src/styles/variables.css +140 -60
  94. package/src/types/Connections.ts +9 -0
  95. package/src/types/ExplorerConfig.ts +144 -39
  96. package/src/types/FileNode.ts +28 -0
  97. package/src/viewers/ArchiveViewer.vue +46 -8
  98. package/src/viewers/CsvViewer.vue +19 -2
  99. package/src/viewers/DrawioViewer.vue +12 -1
  100. package/src/viewers/EpubViewer.vue +50 -6
  101. package/src/viewers/IpynbViewer.vue +19 -2
  102. package/src/viewers/MermaidViewer.vue +20 -3
  103. package/src/viewers/PdfViewer.vue +24 -4
  104. package/src/viewers/PsdViewer.vue +28 -4
  105. package/src/viewers/TiffViewer.vue +19 -2
  106. package/src/viewers/Viewer3D.vue +36 -2
  107. package/dist/ArchiveViewer-Cdfv9LFC.js +0 -99
  108. package/dist/ArchiveViewer-Cdfv9LFC.js.map +0 -1
  109. package/dist/CsvViewer-CqWeV8VO.js +0 -131
  110. package/dist/CsvViewer-CqWeV8VO.js.map +0 -1
  111. package/dist/DrawioViewer-BNALOB04.js.map +0 -1
  112. package/dist/EpubViewer-BPmlImG7.js +0 -145
  113. package/dist/EpubViewer-BPmlImG7.js.map +0 -1
  114. package/dist/IpynbViewer-CDlQmuCV.js +0 -175
  115. package/dist/IpynbViewer-CDlQmuCV.js.map +0 -1
  116. package/dist/MermaidViewer-sfZWdR-d.js +0 -127
  117. package/dist/MermaidViewer-sfZWdR-d.js.map +0 -1
  118. package/dist/PsdViewer-DwuySZsb.js +0 -112
  119. package/dist/PsdViewer-DwuySZsb.js.map +0 -1
  120. package/dist/TiffViewer-BDQ3d3CQ.js +0 -132
  121. package/dist/TiffViewer-BDQ3d3CQ.js.map +0 -1
  122. package/dist/Viewer3D-CJJJgkuv.js +0 -60
  123. package/dist/Viewer3D-CJJJgkuv.js.map +0 -1
  124. package/src/components/SecondaryPane.vue +0 -447
  125. package/src/modals/ShareModal.vue +0 -139
@@ -4,23 +4,36 @@
4
4
  *
5
5
  * Layout:
6
6
  * - No selection → [📁 New Folder] [search | ⬆ ↻] [☰ ▦]
7
- * - 1 file selected [👁 🔗 🗑] [search | ⬆ ↻] [☰ ▦]
8
- * - 1 folder selected → [↗ 🗑] [search | ⬆ ↻] [☰ ▦]
9
- * - Multi selection → [✂ ❐ 🗑] [search | ⬆ ↻] [☰ ▦]
7
+ * - Anything selected the SELECTION BAR (gorunum:v1-selbar, below):
8
+ * a tinted row under the breadcrumb, in the
9
+ * filter row's place.
10
10
  *
11
11
  * Presentational only — all logic (rename, share, …) lives in
12
12
  * FileExplorer.vue, which listens for `action` emits.
13
13
  */
14
- import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
14
+ import { computed, nextTick, onBeforeUnmount, onMounted, ref, shallowRef, watch } from 'vue';
15
15
  import type { ViewMode } from '../types/FileNode';
16
16
  import type { LocaleCode, ThemeMode } from '../types/ExplorerConfig';
17
17
  import ContextMenu, { type ContextAction } from './ContextMenu.vue';
18
- import ViewSwitcher from './ViewSwitcher.vue';
18
+ /* gorunum:v2-topbar — ViewSwitcher is no longer mounted here. It lives on the
19
+ * breadcrumb row in EVERY profile now (FileExplorer renders it inside
20
+ * `.fe-subhead__actions`), so the header and the crumb row can no longer end
21
+ * up offering different view modes — which is what two mount points cost. */
19
22
  import { useLocale } from '../composables/useLocale';
20
- import { eventMatchesShortcut, shortcutHint } from '../composables/useKeyboardShortcuts';
23
+ import { actionIconSvg } from '../lib/actionIcons';
24
+ import { eventMatchesShortcut, menuShortcutHint, shortcutHint } from '../composables/useKeyboardShortcuts';
21
25
 
22
26
  export type SelectionMode = 'none' | 'single-file' | 'single-dir' | 'multi';
23
27
 
28
+ /* gorunum:v1-icons — the glyph for a toolbar row. Identical resolution to the
29
+ * context menu's (lib/actionIcons by key, `icon` overriding when a row names a
30
+ * variant or an embedder supplies its own mark), because the toolbar and the
31
+ * menu render the SAME ContextAction[] and must not drift apart visually any
32
+ * more than they may drift apart in content. */
33
+ function iconFor(a: ContextAction): string {
34
+ return actionIconSvg(a.icon || a.key);
35
+ }
36
+
24
37
  const props = defineProps<{
25
38
  viewMode: ViewMode;
26
39
  searchQuery: string;
@@ -38,13 +51,45 @@ const props = defineProps<{
38
51
  actions: ContextAction[];
39
52
  /** Current selection metadata — toolbar uses this to pick its mode. */
40
53
  selectionMode?: SelectionMode;
54
+ /**
55
+ * gorunum:v1-selbar — how many rows are ticked, for the bar's "16 selected".
56
+ *
57
+ * Optional, and the bar does not depend on it: when the parent does not pass
58
+ * one the count is read off the listing itself (`[data-fe-path]` rows carry
59
+ * `aria-selected`, in all three views). A number handed down here always
60
+ * wins — it is the selection the parent actually holds, and the DOM is only
61
+ * a mirror of it.
62
+ */
63
+ selectionCount?: number;
64
+ /**
65
+ * pane:p1 — WHICH pane the selection belongs to, as its `data-pane` value.
66
+ *
67
+ * ⚠⚠ This is not cosmetic and it is not optional once there are two panes.
68
+ * The bar is teleported into a slot inside the pane it acts on, and both
69
+ * halves of a split render `.fe__primary > .fe-selbar-slot` — so a
70
+ * `querySelector` answers in DOCUMENT order and always hands back the LEFT
71
+ * one. Measured 2026-09-13 before this prop existed: ticking rows in the
72
+ * right-hand pane raised no bar at all, and the bar standing over the left
73
+ * pane still described the left pane's older selection.
74
+ *
75
+ * Absent (a bare `<Toolbar>` in a test, an embedder with one listing) keeps
76
+ * the old behaviour: the first `.fe__primary` in the root.
77
+ */
78
+ selectionPane?: string;
41
79
  /** True when clipboard has cut/copy items, so we can enable Paste. */
42
80
  pasteEnabled?: boolean;
43
81
  /** True when the universal converter (FILEX_CONVERT_URL) is available. */
44
82
  convertEnabled?: boolean;
45
83
  /**
46
84
  * True when the current dir has a parent the user can step up to.
47
- * Hidden at storage root because there's nothing above it.
85
+ *
86
+ * ⚠ gorunum:v2-topbar — NOT DRAWN any more, in either layout. "Up one
87
+ * level" and the parent crumb walk to the same folder, and the crumb is
88
+ * always on screen (the trail collapses to `root › … › parent › current`
89
+ * and never past it), so the button was the same verb twice. The keyboard
90
+ * binding and the `go-up` emit are untouched; the prop stays because
91
+ * embedders pass it and because a header that wants the button back has
92
+ * everything it needs here.
48
93
  */
49
94
  canGoUp?: boolean;
50
95
  /**
@@ -92,31 +137,49 @@ const props = defineProps<{
92
137
  * "power user tool", and gallery is the one nobody outside photos asks for.
93
138
  */
94
139
  viewModes?: ViewMode[];
95
- /* === surucu:d1 — the Drive shell ==================================== */
140
+ /* === surucu:d1 — the shell ========================================== */
141
+ /**
142
+ * ⚠ There is no `shell` prop any more. There used to be one ('classic' vs
143
+ * 'drive') and it decided which header a surface got; the header below is
144
+ * now the only one there is, at every width and in every embed. Density,
145
+ * theme, the shortcut editor, the tour and the other view modes live in the
146
+ * "⋯" menu it renders — nothing was dropped from the build, and a control
147
+ * that is on screen in one profile and unreachable in another was the
148
+ * capability split this removed.
149
+ */
150
+ /** Folder name for the search field's placeholder ("Search in Photos"). */
151
+ scopeLabel?: string;
152
+ /* gorunum:v3-shell — the product mark, for a host that cannot fill the
153
+ `#brand` slot. See ExplorerConfig.brand: a `<filex-explorer>` host cannot,
154
+ measured, so these are the only door it has. The slot still wins — they are
155
+ rendered as its FALLBACK content, which is the mechanism for "use mine if
156
+ you gave me one", with no condition to keep in step. */
157
+ brandName?: string;
158
+ brandMarkUrl?: string;
96
159
  /**
97
- * Which header this is.
160
+ * gorunum:v3-shell this surface has no listing for the field to narrow, so
161
+ * Enter hands the query to the command palette instead.
98
162
  *
99
- * 'classic' (default) the row this toolbar has always been.
100
- * 'drive' — `uiProfile: 'drive'`: one search field across the
101
- * width with its ⌘K/Ctrl+K hint, and the controls
102
- * that belong to a *listing* rather than to the app
103
- * (view switcher, details toggle) moved down onto the
104
- * breadcrumb row, where the mockups put them.
163
+ * It exists because putting Home inside this header gave Home a search box,
164
+ * and a box that swallows what you type is worse than no box: measured on the
165
+ * landing page, typing filtered nothing, showed nothing and re-fetched the two
166
+ * per-user lists on every keystroke. The palette IS "search everywhere" — it
167
+ * is what the ⌘K chip on this very field already promises — so on a surface
168
+ * with nothing to filter, that is where the words go.
105
169
  *
106
- * ⚠ Nothing is DELETED by 'drive'. Density, theme, the shortcut editor, the
107
- * tour and both other view modes are all in the "⋯" menu this variant
108
- * renders — the same list the narrow layout has used since bag:b4. A control
109
- * that is on screen in one profile and unreachable in another is a capability
110
- * split, not a preset.
170
+ * ⚠ Gated rather than universal: in a folder, Enter must keep meaning
171
+ * "nothing new", because the debounced input has already run the search.
111
172
  */
112
- shell?: 'classic' | 'drive';
113
- /** Folder name for the drive field's placeholder ("Search in Photos"). */
114
- scopeLabel?: string;
173
+ searchEscalates?: boolean;
115
174
  }>();
116
175
 
117
176
  const emit = defineEmits<{
118
177
  (e: 'update:viewMode', v: ViewMode): void;
119
178
  (e: 'update:searchQuery', v: string): void;
179
+ /* gorunum:v1-advsearch — the filter affordance inside the search field.
180
+ Carries the text already typed so the dialog continues this search
181
+ instead of opening an empty second one, exactly as the ⌘K chip does. */
182
+ (e: 'open-advanced-search', v: string): void;
120
183
  (e: 'new-folder'): void;
121
184
  (e: 'upload'): void;
122
185
  (e: 'refresh'): void;
@@ -128,6 +191,7 @@ const emit = defineEmits<{
128
191
  (e: 'toggle-nav'): void /* gezinti:g1 */;
129
192
  (e: 'open-theme'): void /* wiring:c1 — tema galerisi */;
130
193
  (e: 'open-shortcut-settings'): void /* wiring:c2 */;
194
+ (e: 'open-timezone'): void /* zaman:z3 */;
131
195
  /* surucu:d1 — escalate this query into the command palette (⌘K/Ctrl+K). */
132
196
  (e: 'open-palette', query: string): void;
133
197
  }>();
@@ -145,7 +209,39 @@ const density = ref<Density>(
145
209
  }
146
210
  })(),
147
211
  );
148
- onMounted(() => emit('update:density', density.value));
212
+ /**
213
+ * gorunum:v1 — someone else may own this preference too.
214
+ *
215
+ * The web app's user-settings modal writes the same `filex.density` key, and
216
+ * this component read it exactly once at setup — so flipping "compact file
217
+ * list" there did nothing until the explorer was mounted again. A `storage`
218
+ * event covers another tab; a same-tab write needs the writer to say so, which
219
+ * is what the custom event is for.
220
+ */
221
+ function adoptStoredDensity() {
222
+ try {
223
+ const v = localStorage.getItem(DENSITY_LS_KEY) === 'compact' ? 'compact' : 'comfortable';
224
+ if (v !== density.value) {
225
+ density.value = v;
226
+ emit('update:density', v);
227
+ }
228
+ } catch {
229
+ /* storage blocked */
230
+ }
231
+ }
232
+ function onDensityStorage(e: StorageEvent) {
233
+ if (e.key === null || e.key === DENSITY_LS_KEY) adoptStoredDensity();
234
+ }
235
+
236
+ onMounted(() => {
237
+ emit('update:density', density.value);
238
+ window.addEventListener('storage', onDensityStorage);
239
+ window.addEventListener('filex:density', adoptStoredDensity);
240
+ });
241
+ onBeforeUnmount(() => {
242
+ window.removeEventListener('storage', onDensityStorage);
243
+ window.removeEventListener('filex:density', adoptStoredDensity);
244
+ });
149
245
 
150
246
  function toggleDensity() {
151
247
  density.value = density.value === 'compact' ? 'comfortable' : 'compact';
@@ -175,11 +271,11 @@ function onSearchInput(ev: Event) {
175
271
  }
176
272
 
177
273
  async function focusSearch() {
178
- /* bag:b4 — in narrow mode the input is collapsed behind the icon. */
179
- if (props.narrow && !searchOpen.value) {
180
- searchOpen.value = true;
181
- await nextTick();
182
- }
274
+ /* bag:b4 — the field used to be collapsed behind a magnifier at this width
275
+ and this had to expand it first. It is on screen in both layouts now, so
276
+ there is nothing to open; the tick stays because a caller may have just
277
+ mounted the row. */
278
+ await nextTick();
183
279
  searchEl.value?.focus();
184
280
  searchEl.value?.select();
185
281
  }
@@ -194,44 +290,100 @@ const mode = computed<SelectionMode>(() => props.selectionMode ?? 'none');
194
290
  // context menu uses, the two menus are guaranteed to match.
195
291
  const toolbarItems = computed(() => props.actions.filter((a) => !a.divider && !a.hidden));
196
292
 
197
- /* === ui-fixwide-mode action folding ===============================
198
- * Long selection-action rows used to wrap the whole toolbar and push the
199
- * search / view controls onto a second line. The row is now single-line:
200
- * actions that do not fit fold into a "⋯" menu. Widths come from a
201
- * hidden measurement strip (all actions always rendered there), so the
202
- * calculation never mutates what it measures. */
203
- const primaryEl = ref<HTMLElement | null>(null);
293
+ /* === gorunum:v1-selbarthe selection bar ===========================
294
+ * Measured on the reference shell: the moment anything is ticked the FILTER
295
+ * ROW is replaced by a tinted bar "16 selected", then icon-only actions
296
+ * (download · share · move · copy to · star · delete), then an × that clears
297
+ * the selection. What filex did instead was fill the toolbar's primary row
298
+ * with labelled buttons, one of them a loud red Delete.
299
+ *
300
+ * It is not a second menu. The icons are the SAME `ContextAction[]` the
301
+ * right-click menu renders (`selectionActionList` in FileExplorer, handed here
302
+ * as `actions`), in the order FileExplorer built them; the bar only chooses
303
+ * which of them it can say with a glyph, and everything else — open, preview,
304
+ * rename, tags, paste, the keep-on-device rows — is one click away in the
305
+ * bar's own "⋯". Nothing is removed from the build.
306
+ *
307
+ * ⚠ Why the bar is TELEPORTED out of this component: the row it replaces lives
308
+ * under the breadcrumb, inside `.fe__primary`, two boxes below the toolbar.
309
+ * The toolbar is the only component handed the selection, so the bar is
310
+ * RENDERED here and MOUNTED there, into a slot this component inserts right
311
+ * after the breadcrumb wrapper. DOM order then still matches reading order
312
+ * (the bar comes before the listing it acts on), which a `order:` trick on the
313
+ * flex column would have broken for anyone tabbing through the page.
314
+ * ⚠ With no `.fe__primary` to mount into (a bare `<Toolbar>` in a test, an
315
+ * embedder using this component on its own) the Teleport is DISABLED and the
316
+ * bar renders in place, so the actions never disappear.
317
+ */
318
+
319
+ /** Keys the bar is willing to say with a glyph alone. The reference draws six
320
+ * — download, share, move, copy to, star, delete — which are filex's
321
+ * download / access / cut / copy / star / delete; `restore` joins them for a
322
+ * `.trash/` listing, whose whole vocabulary is restore + delete.
323
+ * ⚠ A SET, never an order: the bar walks `actions` in the parent's order, so
324
+ * it cannot end up disagreeing with the right-click menu about what comes
325
+ * first (filex lesson #67 — one list, two renderers). */
326
+ const BAR_ICON_KEYS = new Set(['download', 'access', 'cut', 'copy', 'star', 'restore', 'delete']);
327
+
328
+ const hasSelection = computed(() => mode.value !== 'none');
329
+
330
+ /** The row's two halves, in ONE pass over the parent's list so the order is
331
+ * the parent's. A key is promoted only if it also HAS a glyph — an icon-only
332
+ * button with no icon is an empty 28px box. */
333
+ const barSplit = computed(() => {
334
+ const icons: ContextAction[] = [];
335
+ const rest: ContextAction[] = [];
336
+ for (const a of toolbarItems.value) {
337
+ if (BAR_ICON_KEYS.has(a.key) && iconFor(a)) icons.push(a);
338
+ else rest.push(a);
339
+ }
340
+ return { icons, rest };
341
+ });
342
+
343
+ /* ── folding (kept from ui-fix, moved onto the bar) ───────────────────
344
+ * Actions that do not fit fold into the "⋯" instead of wrapping the row onto
345
+ * a second line. Widths come from a hidden measurement strip that renders the
346
+ * SAME buttons as the visible row — measure a node nobody sees and the
347
+ * arithmetic is about a width nothing has. */
348
+ const selBarEl = ref<HTMLElement | null>(null);
204
349
  const measureEl = ref<HTMLElement | null>(null);
205
- const wideMoreBtnEl = ref<HTMLElement | null>(null);
206
- const wideMoreRef = ref<InstanceType<typeof ContextMenu> | null>(null);
350
+ const selMoreBtnEl = ref<HTMLElement | null>(null);
351
+ const selMoreRef = ref<InstanceType<typeof ContextMenu> | null>(null);
207
352
  const visibleActionCount = ref(Number.MAX_SAFE_INTEGER);
208
- const visibleToolbarItems = computed(() => toolbarItems.value.slice(0, visibleActionCount.value));
209
- const overflowToolbarItems = computed(() => toolbarItems.value.slice(visibleActionCount.value));
210
- const WIDE_MORE_BTN_W = 40;
353
+ const visibleBarItems = computed(() => barSplit.value.icons.slice(0, visibleActionCount.value));
354
+ const foldedBarItems = computed(() => barSplit.value.icons.slice(visibleActionCount.value));
355
+ /** The bar's "⋯": what did not fit, then everything that was never an icon. */
356
+ const barMenuItems = computed<ContextAction[]>(() => {
357
+ const folded = foldedBarItems.value;
358
+ const rest = barSplit.value.rest;
359
+ if (folded.length && rest.length) {
360
+ return [...folded, { divider: true, key: 'selbar-sep', label: '' }, ...rest];
361
+ }
362
+ return [...folded, ...rest];
363
+ });
364
+ const SELBAR_MORE_BTN_W = 32;
211
365
 
212
366
  function recalcFold() {
213
- if (props.narrow) return;
214
- const cont = primaryEl.value;
367
+ const cont = selBarEl.value;
215
368
  const meas = measureEl.value;
369
+ const all = barSplit.value.icons.length;
216
370
  if (!cont || !meas) {
217
- visibleActionCount.value = toolbarItems.value.length;
371
+ visibleActionCount.value = all;
218
372
  return;
219
373
  }
220
- const gap = parseFloat(getComputedStyle(cont).gap) || 8;
221
- let fixed = 0;
222
- for (const child of Array.from(cont.children) as HTMLElement[]) {
223
- if (child === meas || child === wideMoreBtnEl.value) continue;
224
- if (child.classList.contains('fe-btn--fold')) continue;
225
- if (child.offsetWidth > 0) fixed += child.offsetWidth + gap;
226
- }
374
+ const gap = parseFloat(getComputedStyle(cont).gap) || 4;
227
375
  const widths = (Array.from(meas.children) as HTMLElement[]).map((c) => c.offsetWidth + gap);
228
376
  const total = widths.reduce((s, w) => s + w, 0);
229
- const avail = cont.clientWidth - fixed;
230
- if (total <= avail) {
377
+ const avail = cont.clientWidth;
378
+ // The "⋯" is already on screen whenever something never was an icon, so its
379
+ // width is not free room; when nothing is folded away yet it still has to be
380
+ // paid for the moment the first icon folds.
381
+ const moreW = (selMoreBtnEl.value?.offsetWidth || SELBAR_MORE_BTN_W) + gap;
382
+ if (total <= avail - (barSplit.value.rest.length ? moreW : 0)) {
231
383
  visibleActionCount.value = widths.length;
232
384
  return;
233
385
  }
234
- let used = WIDE_MORE_BTN_W + gap;
386
+ let used = moreW;
235
387
  let count = 0;
236
388
  for (const w of widths) {
237
389
  if (used + w > avail) break;
@@ -242,10 +394,12 @@ function recalcFold() {
242
394
  }
243
395
 
244
396
  let foldRo: ResizeObserver | undefined;
245
- onMounted(() => {
246
- if (typeof ResizeObserver !== 'undefined' && primaryEl.value) {
397
+ watch(selBarEl, (el) => {
398
+ foldRo?.disconnect();
399
+ foldRo = undefined;
400
+ if (el && typeof ResizeObserver !== 'undefined') {
247
401
  foldRo = new ResizeObserver(() => recalcFold());
248
- foldRo.observe(primaryEl.value);
402
+ foldRo.observe(el);
249
403
  }
250
404
  void nextTick(recalcFold);
251
405
  });
@@ -256,39 +410,209 @@ watch(
256
410
  { deep: false },
257
411
  );
258
412
 
259
- function openWideMore() {
260
- const r = wideMoreBtnEl.value?.getBoundingClientRect();
261
- wideMoreRef.value?.show({ clientX: r ? r.right : 0, clientY: r ? r.bottom + 4 : 0 } as MouseEvent, []);
413
+ function openBarMore() {
414
+ const r = selMoreBtnEl.value?.getBoundingClientRect();
415
+ selMoreRef.value?.show({ clientX: r ? r.right : 0, clientY: r ? r.bottom + 4 : 0 } as MouseEvent, []);
262
416
  }
263
- /* === /ui-fix ========================================================= */
264
417
 
265
- function fire(key: string) {
266
- emit('action', key);
418
+ /* ── where the bar lands ──────────────────────────────────────────────── */
419
+ const rootEl = ref<HTMLElement | null>(null);
420
+ /** The slot this component mounts the bar into, inside `.fe__primary` — one
421
+ * row above the filter row, and below both the crumbs and the tab strip.
422
+ * `shallowRef` on purpose: a DOM node is not state to make reactive. */
423
+ const selSlot = shallowRef<HTMLElement | null>(null);
424
+ /** True when WE created the slot, so teardown only removes our own node. */
425
+ const selSlotOwned = ref(false);
426
+ /** The listing's own ground, for the × (see clearSelection). */
427
+ const selBodyEl = shallowRef<HTMLElement | null>(null);
428
+ const selPrimaryEl = shallowRef<HTMLElement | null>(null);
429
+
430
+ /**
431
+ * Which `.fe__primary` this bar belongs to right now.
432
+ *
433
+ * ⚠ `selectionPane` is our own prop and only ever 'main' / 'split', but it is
434
+ * interpolated into a selector, so anything that is not a bare identifier is
435
+ * refused rather than escaped — a selector built from unvetted text is a
436
+ * selector that can be made to match something else.
437
+ */
438
+ function findPrimary(): HTMLElement | null {
439
+ const root = rootEl.value?.closest('.fe');
440
+ if (!root) return null;
441
+ const pane = props.selectionPane;
442
+ if (pane && /^[A-Za-z][\w-]*$/.test(pane)) {
443
+ const owned = root.querySelector<HTMLElement>(`.fe__primary[data-pane="${pane}"]`);
444
+ if (owned) return owned;
445
+ }
446
+ return root.querySelector<HTMLElement>('.fe__primary');
267
447
  }
268
448
 
269
- /* === bag:b4 narrow-mode state: expandable search + "⋯" overflow menu === */
449
+ /** Let go of the slot we are pointing at, removing it only if we made it. */
450
+ function releaseSelSlot() {
451
+ const prev = selSlot.value;
452
+ if (prev) {
453
+ prev.classList.remove('is-active');
454
+ if (selSlotOwned.value) prev.remove();
455
+ }
456
+ selSlot.value = null;
457
+ selSlotOwned.value = false;
458
+ }
270
459
 
271
- const searchOpen = ref(false);
272
- // Leaving narrow mode discards the transient expanded-search state so the
273
- // wide layout always comes back exactly as it was.
274
- watch(
275
- () => props.narrow,
276
- (n) => {
277
- if (!n) searchOpen.value = false;
278
- },
279
- );
460
+ /**
461
+ * Point the teleport at the pane that owns the selection.
462
+ *
463
+ * ⚠⚠ Called on mount AND whenever `selectionPane` changes — the original was a
464
+ * one-shot `onMounted`, and a cached node is the whole bug: with two panes it
465
+ * cached the left one forever, so the right pane could never raise a bar and
466
+ * the left pane's bar described a selection nobody was touching.
467
+ *
468
+ * ⚠ `isConnected` is checked too, not just identity: the split pane is keyed
469
+ * on the active tab, so switching tabs REPLACES its DOM and would otherwise
470
+ * leave the teleport aimed at a detached node — a bar that renders into
471
+ * nothing looks exactly like a bar that was never built.
472
+ */
473
+ function resolveSelSlot() {
474
+ const primary = findPrimary();
475
+ if (!primary) {
476
+ releaseSelSlot();
477
+ selPrimaryEl.value = null;
478
+ selBodyEl.value = null;
479
+ return;
480
+ }
481
+ if (selPrimaryEl.value === primary && selSlot.value?.isConnected) return;
482
+ releaseSelSlot();
483
+ selPrimaryEl.value = primary;
484
+ selBodyEl.value = primary.querySelector<HTMLElement>(':scope > .fe__body');
485
+ /* gorunum:v2-topbar — prefer the slot the PARENT rendered.
486
+ * ⚠ The row the bar has to sit under is now the tab strip, and the strip is
487
+ * `v-if`'d on there being a tab — which is seeded in FileExplorer's own
488
+ * onMounted, i.e. after this one. A node inserted here "after the crumbs"
489
+ * would therefore land ABOVE a strip that appears a tick later, and the
490
+ * selection bar would sit between the crumbs and the tabs for the rest of
491
+ * the session. So FileExplorer renders `.fe-selbar-slot` itself, in the
492
+ * right place, declaratively; this fallback is for a bare <Toolbar> in a
493
+ * test and for an embedder mounting the component on its own. */
494
+ const existing = primary.querySelector<HTMLElement>(':scope > .fe-selbar-slot');
495
+ if (existing) {
496
+ selSlot.value = existing;
497
+ selSlotOwned.value = false;
498
+ } else {
499
+ const slot = document.createElement('div');
500
+ slot.className = 'fe-selbar-slot';
501
+ /* ⚠ Two queries, not one comma list: `querySelector` answers in DOCUMENT
502
+ * order, so a list would hand back the crumb row (which comes first) even
503
+ * when a tab strip is standing below it. */
504
+ const head =
505
+ primary.querySelector<HTMLElement>(':scope > .fe-tabs') ??
506
+ primary.querySelector<HTMLElement>(':scope > .fe-subhead, :scope > .fe-subhead--plain');
507
+ if (head) head.insertAdjacentElement('afterend', slot);
508
+ else primary.prepend(slot);
509
+ selSlot.value = slot;
510
+ selSlotOwned.value = true;
511
+ }
512
+ syncSlotActive();
513
+ reobserveSelection();
514
+ }
280
515
 
281
- async function openSearch() {
282
- searchOpen.value = true;
283
- await nextTick();
284
- searchEl.value?.focus();
516
+ onMounted(resolveSelSlot);
517
+ onBeforeUnmount(releaseSelSlot);
518
+
519
+ /** The filter row hides behind `.fe-selbar-slot.is-active ~ .fe-filterbar`
520
+ * (styles/base.css). A class toggled here rather than `:has()` on the slot:
521
+ * the state is known in JS, and a rule that depends on a teleport having
522
+ * landed is a rule that is right only after a repaint.
523
+ * ⚠ `releaseSelSlot` clears the class off the slot we are leaving, so the
524
+ * pane we walked away from does not keep its filter row hidden. */
525
+ function syncSlotActive() {
526
+ selSlot.value?.classList.toggle('is-active', hasSelection.value);
285
527
  }
286
- function closeSearch() {
287
- // Keep the query (it still filters the listing); the icon shows an
288
- // active state while a query is set.
289
- searchOpen.value = false;
528
+ watch(hasSelection, syncSlotActive, { immediate: true });
529
+
530
+ /* The bar follows the focus. Re-resolving on `hasSelection` too is what covers
531
+ * a pane that was remounted (a tab switch) while nothing was selected. */
532
+ watch([() => props.selectionPane, hasSelection], () => resolveSelSlot());
533
+
534
+ /* ── how many ─────────────────────────────────────────────────────────── */
535
+ const domSelCount = ref(0);
536
+ let selMo: MutationObserver | undefined;
537
+ let selTick = false;
538
+ function recountSelection() {
539
+ const primary = selPrimaryEl.value;
540
+ if (!primary) return;
541
+ // `[data-fe-path]` is the row/card in all three views, and `aria-selected`
542
+ // is the contract they already publish for a screen reader. No view-private
543
+ // class, and nothing that is not a listing row can match.
544
+ domSelCount.value = primary.querySelectorAll('[data-fe-path][aria-selected="true"]').length;
545
+ }
546
+ function scheduleRecount() {
547
+ if (selTick) return;
548
+ selTick = true;
549
+ const later = (cb: () => void) =>
550
+ typeof requestAnimationFrame === 'function' ? requestAnimationFrame(cb) : setTimeout(cb, 16);
551
+ later(() => {
552
+ selTick = false;
553
+ recountSelection();
554
+ });
555
+ }
556
+ /**
557
+ * (Re)attach the row counter to the pane the bar is standing in.
558
+ *
559
+ * ⚠ The observer is torn down and rebuilt rather than left where it was: it
560
+ * watches ONE `.fe__primary`, and after a focus change that is the wrong one —
561
+ * the fallback count would then be the other pane's ticked rows.
562
+ */
563
+ function reobserveSelection() {
564
+ selMo?.disconnect();
565
+ selMo = undefined;
566
+ if (!hasSelection.value) {
567
+ domSelCount.value = 0;
568
+ return;
569
+ }
570
+ recountSelection();
571
+ if (typeof MutationObserver === 'undefined' || !selPrimaryEl.value) return;
572
+ selMo = new MutationObserver(scheduleRecount);
573
+ selMo.observe(selPrimaryEl.value, {
574
+ subtree: true,
575
+ childList: true,
576
+ attributes: true,
577
+ attributeFilter: ['aria-selected'],
578
+ });
579
+ }
580
+ watch(hasSelection, reobserveSelection);
581
+ onBeforeUnmount(() => selMo?.disconnect());
582
+
583
+ /** Never smaller than what the mode already proves: 'multi' is at least two
584
+ * rows even in the frame before the listing has been counted. */
585
+ const selectedCount = computed(() => {
586
+ if (typeof props.selectionCount === 'number') return props.selectionCount;
587
+ if (mode.value === 'none') return 0;
588
+ return Math.max(domSelCount.value, mode.value === 'multi' ? 2 : 1);
589
+ });
590
+ const selectionLabel = computed(() => t('selection.count', { n: String(selectedCount.value) }));
591
+
592
+ function clearSelection() {
593
+ /* The gesture the explorer already owns: a click on the listing's empty
594
+ * ground (`@click.self="selection.clear()"` on `.fe__body`). Dispatched ON
595
+ * that element, so `event.target === event.currentTarget` and Vue's `.self`
596
+ * lets it through — the × therefore goes through the SAME clear a user's own
597
+ * click does instead of a second path that can drift from it.
598
+ * The emit is for a parent that would rather own the verb: an unhandled key
599
+ * falls through `dispatchItemAction` without doing anything. */
600
+ emit('action', 'clear-selection');
601
+ selBodyEl.value?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
602
+ }
603
+ /* === /gorunum:v1-selbar ============================================== */
604
+
605
+ function fire(key: string) {
606
+ emit('action', key);
290
607
  }
291
608
 
609
+ /* === bag:b4 — narrow-mode state: the "⋯" overflow menu ===================
610
+ * ⚠ `searchOpen` / `openSearch` / `closeSearch` went with the layout that
611
+ * needed them: at 390px the field is in the row itself now, so there is no
612
+ * collapsed state to expand and no magnifier to press. `.fe-toolbar--searching`
613
+ * in styles/base.css is that state's leftover and matches nothing.
614
+ */
615
+
292
616
  // Coarse-pointer detection — the overflow menu renders as a bottom sheet on
293
617
  // touch devices, matching the file context menu.
294
618
  const coarse = ref(false);
@@ -326,48 +650,71 @@ function offersView(v: ViewMode): boolean {
326
650
  * to the same room. */
327
651
  const utilityActions = computed<ContextAction[]>(() => {
328
652
  const list: ContextAction[] = [];
329
- list.push({ key: 'refresh', label: t('toolbar.refresh'), icon: '↻' });
653
+ /* Only in the narrow layout, and that is a duplicate being REMOVED rather
654
+ than a layout rule. The wide header draws a Refresh button in its own
655
+ trailing cluster, three glyphs to the left of this menu — so listing it
656
+ here as well was the same verb twice on one row. The narrow header has no
657
+ Refresh button (the cluster there is AI · "⋯" · the host's doors), so at
658
+ that width the menu is the only door and it stays. Provable from inside
659
+ this component: the button and this row are drawn by the same file, under
660
+ the same `narrow` flag. */
661
+ if (props.narrow) list.push({ key: 'refresh', label: t('toolbar.refresh') });
330
662
  list.push({
331
663
  key: 'density',
332
664
  label:
333
665
  density.value === 'compact'
334
666
  ? t('toolbar.density.comfortable')
335
667
  : t('toolbar.density.compact'),
336
- icon: '⇅',
337
668
  });
338
669
  /* wiring:d2 — narrow-mode ⋯ menu: BOTH inactive views are listed
339
670
  (list/grid/gallery); the old single toggle fell short with three modes. */
340
- if (props.viewMode !== 'list' && offersView('list')) list.push({ key: 'view-list', label: t('toolbar.view.list'), icon: '☰' });
341
- if (props.viewMode !== 'grid' && offersView('grid')) list.push({ key: 'view-grid', label: t('toolbar.view.grid'), icon: '▦' });
342
- if (props.viewMode !== 'gallery' && offersView('gallery')) list.push({ key: 'view-gallery', label: t('toolbar.view.gallery'), icon: '▣' });
671
+ if (props.viewMode !== 'list' && offersView('list')) list.push({ key: 'view-list', label: t('toolbar.view.list') });
672
+ if (props.viewMode !== 'grid' && offersView('grid')) list.push({ key: 'view-grid', label: t('toolbar.view.grid') });
673
+ if (props.viewMode !== 'gallery' && offersView('gallery')) list.push({ key: 'view-gallery', label: t('toolbar.view.gallery') });
343
674
  /* /wiring:d2 */
344
- /* gezinti:g1 reachable from the overflow menu too, because the toolbar's
345
- own toggle is hidden while the narrow search field is expanded. */
346
- if (props.navEnabled !== false) {
347
- list.push({ key: 'nav', label: t('toolbar.nav'), icon: '☰' });
348
- }
675
+ /* No navigation row here any more, and nothing replaced it.
676
+ gezinti:g1 added one because the toolbar's own toggle was hidden while the
677
+ narrow search field was expanded — and that expanded-search layout no
678
+ longer exists (see the bag:b4 note at the top of this file). The hamburger
679
+ in `.fe-toolbar__brand` is drawn at EVERY width and in every embed under
680
+ exactly the condition this row was pushed under (`navEnabled !== false`),
681
+ so the row could only ever be a second copy of a button that is always on
682
+ screen. */
349
683
  /* koru:k1 — inspector toggle also reachable from the narrow overflow menu */
350
- list.push({ key: 'inspector', label: t('toolbar.inspector'), icon: 'ℹ' });
684
+ list.push({ key: 'inspector', label: t('toolbar.inspector') });
351
685
  /* wiring:c1 — the theme gallery is opened from the ⋯ menu in narrow mode too */
352
- list.push({ key: 'theme', label: t('theme.menu'), icon: '🎨' });
353
- list.push({ key: 'shortcut-settings', label: t('shortcuts.settings.menu'), icon: '⌨' }) /* wiring:c2 */;
354
- list.push({ key: 'tour', label: t('tour.restart'), icon: '🎓' }); /* wiring:c4 */
686
+ list.push({ key: 'theme', label: t('theme.menu') });
687
+ /* zaman:z3 — the embed's time-zone setting. An embed has no settings dialog,
688
+ so this menu is its only door; a host with its own settings surface (the
689
+ filex web app) claims the settings half and drops this row, because its
690
+ dialog already carries the same picker. */
691
+ list.push({ key: 'timezone', label: t('tz.menu') });
692
+ list.push({ key: 'shortcut-settings', label: t('shortcuts.settings.menu') }) /* wiring:c2 */;
693
+ list.push({ key: 'tour', label: t('tour.restart') }); /* wiring:c4 */
355
694
  return list;
356
695
  });
357
696
 
358
- const moreActions = computed<ContextAction[]>(() => {
697
+ /** The narrow layout's whole menu: the folder/selection verbs first, then
698
+ * whatever is left of the settings half (which a host may have claimed —
699
+ * see `publishHeaderMenu`, hence the parameter rather than a closure). */
700
+ function narrowActions(util: ContextAction[]): ContextAction[] {
359
701
  const list: ContextAction[] = [];
360
702
  const writable =
361
703
  !props.trashActive && !props.atVirtualRoot && props.canWrite !== false;
362
704
  if (mode.value === 'none' && writable) {
363
- list.push({ key: 'new-folder', label: t('toolbar.new_folder'), icon: '📁' });
364
- if (props.pasteEnabled) list.push({ key: 'paste', label: t('ctx.paste'), icon: '📋' });
705
+ list.push({ key: 'new-folder', label: t('toolbar.new_folder') });
706
+ if (props.pasteEnabled) list.push({ key: 'paste', label: t('ctx.paste') });
365
707
  }
366
- list.push(...toolbarItems.value);
708
+ /* gorunum:v1-selbar — the selection's verbs are on screen in the selection
709
+ bar now, and the ones it could not draw are in the bar's own "⋯". Listing
710
+ them here as well would be a second door into the same room — the reason
711
+ the drive header already carries only the settings half. */
712
+ if (!hasSelection.value) list.push(...toolbarItems.value);
713
+ if (!util.length) return list;
367
714
  if (list.length) list.push({ divider: true, key: 'bag-sep', label: '' });
368
- list.push(...utilityActions.value);
715
+ list.push(...util);
369
716
  return list;
370
- });
717
+ }
371
718
 
372
719
  /* === surucu:d1 — the drive header's search field ======================
373
720
  * ONE field, and it is the one the toolbar already had: typing here sets
@@ -389,6 +736,53 @@ const moreActions = computed<ContextAction[]>(() => {
389
736
  * that key. */
390
737
  const paletteCombo = computed(() => shortcutHint('palette'));
391
738
 
739
+ /* tus:t1 — a button's tooltip names its key. Same source as the right-click
740
+ * menu's key column, so the two can only ever say the same thing, and a verb
741
+ * with no binding gets its plain label back rather than an empty bracket. */
742
+ function withKey(label: string, key: string): string {
743
+ const combo = menuShortcutHint(key);
744
+ return combo ? `${label} (${combo})` : label;
745
+ }
746
+
747
+ /* === gezinti:g2 — the collapse control says what it DOES ==================
748
+ *
749
+ * The button's label was `toolbar.nav` — "Navigation" / "Gezinti", a NOUN
750
+ * naming the panel, which never says the button does anything to it. Measured
751
+ * by the agent that narrowed the panel: the control works at every width; the
752
+ * owner still could not find how to collapse the panel, because nothing on
753
+ * screen said it could be collapsed. A static "Show or hide navigation" was
754
+ * the stopgap; this is the fix, and it brings `sidenav.collapse` /
755
+ * `sidenav.expand` back from being orphaned when the in-panel toggle went.
756
+ *
757
+ * ⚠⚠ TWO pairs, because `navOpen` means two different things.
758
+ * `FileExplorer` hands down `isNarrow ? navDrawerOpen : sideNavExpanded`:
759
+ * · wide — the panel is always on screen; the toggle moves it between a
760
+ * full column and a 56px icon rail. It is a WIDTH change, so:
761
+ * collapse / expand.
762
+ * · ≤560 — the panel is a DRAWER over the listing behind a scrim, and
763
+ * "closed" means it is not on screen at all. Nothing narrows, so
764
+ * "expand" would promise a width change to something that has no
765
+ * width; the pair there is open / close (`sidenav.close` is the
766
+ * string the drawer's own dismiss and its scrim already use, so
767
+ * the two doors out of the drawer say the same words).
768
+ * Forcing one pair onto both widths would make the label wrong at one of them,
769
+ * which is the failure this whole change is undoing.
770
+ *
771
+ * ⚠ ONE computed, bound to BOTH `title` and `aria-label`, so the tooltip and
772
+ * the screen reader cannot be told two different things (the same rule the AI
773
+ * affordance above is built on).
774
+ *
775
+ * ⚠ `aria-pressed` is left as it was, deliberately. With a state-bound label
776
+ * it is arguably redundant, but the split toggle in TabBar.vue is built the
777
+ * same way (changing label + `aria-pressed`) and changing one without the
778
+ * other is the drift this wave exists to remove — that call belongs to one
779
+ * pass over both, not to this line.
780
+ */
781
+ const navToggleLabel = computed(() => {
782
+ if (props.narrow) return props.navOpen ? t('sidenav.close') : t('sidenav.open');
783
+ return props.navOpen ? t('sidenav.collapse') : t('sidenav.expand');
784
+ });
785
+
392
786
  const drivePlaceholder = computed(() =>
393
787
  props.scopeLabel
394
788
  ? t('drive.search.placeholder', { scope: props.scopeLabel })
@@ -405,15 +799,98 @@ function onSearchKeydown(ev: KeyboardEvent) {
405
799
  ev.preventDefault();
406
800
  ev.stopPropagation();
407
801
  emit('open-palette', localSearch.value);
802
+ return;
803
+ }
804
+ /* gorunum:v3-shell — nothing on screen to narrow (Home): Enter means the
805
+ same thing the chip does. Empty text is left alone — Enter on an empty box
806
+ opening a palette nobody asked for is a keystroke doing something. */
807
+ if (props.searchEscalates && ev.key === 'Enter' && localSearch.value) {
808
+ ev.preventDefault();
809
+ ev.stopPropagation();
810
+ emit('open-palette', localSearch.value);
408
811
  }
409
812
  }
410
813
 
411
- /* surucu:d1the drive header renders the selection actions as buttons, so
412
- * its "⋯" carries only the settings half; the narrow layout renders no action
413
- * buttons at all and still needs the whole list. */
414
- const menuActions = computed<ContextAction[]>(() =>
415
- props.shell === 'drive' && !props.narrow ? utilityActions.value : moreActions.value,
416
- );
814
+ /* === gorunum:v4-hostmenua host may take the settings half over ==========
815
+ *
816
+ * The header can end up with TWO dropdowns side by side: this component's "⋯"
817
+ * and whatever the embedder put in `header-actions` (in the filex web app that
818
+ * is the account avatar). Owner's decision, 2026-09-13, verbatim: *"`...`
819
+ * bölgesini admin dropdown'ının içine alacağız."* — one menu in that corner,
820
+ * not two.
821
+ *
822
+ * ⚠ It is a CLAIM, not a prop, and that is the whole design. This component
823
+ * cannot ask "did my host fill the slot with a menu?": `$slots['header-actions']`
824
+ * is truthy on every mount, because FileExplorer declares the pass-through
825
+ * template unconditionally (the same trap its own `#brand` comment documents),
826
+ * and a config flag would have to be threaded through a component this file
827
+ * may not edit. So the toolbar ANNOUNCES the rows on a bubbling DOM event and
828
+ * a host that intends to render them says so by setting `claimed` — the
829
+ * `preventDefault()` shape, synchronous, with no registration order to get
830
+ * right. Nobody listening (every embed today) → nothing changes and the "⋯"
831
+ * keeps everything it had.
832
+ *
833
+ * ⚠ `composed: true`: the web-component build puts this DOM inside a shadow
834
+ * root, and an uncomposed event stops at its boundary — i.e. the one build
835
+ * where a host is most likely to be listening would never hear it.
836
+ *
837
+ * ⚠ Re-announced on every change, not once on mount: the labels are locale
838
+ * strings and two of them are STATEFUL ("Compact view" ⇄ "Comfortable view",
839
+ * and which view modes are inactive). A host holding the first list would
840
+ * print yesterday's label for the rest of the session. Re-announcing is also
841
+ * what lets a late listener claim, so the host does not have to win a race
842
+ * with the explorer's mount.
843
+ */
844
+ export interface HeaderMenuClaim {
845
+ /** The rows, already localised, in the order this menu would draw them. */
846
+ items: ContextAction[];
847
+ /** Run one of them by key — the SAME handler the "⋯" uses, so a claimed row
848
+ * and an unclaimed one cannot drift apart in what they do. */
849
+ run: (key: string) => void;
850
+ /** Set to true by a host that will render `items` itself. */
851
+ claimed: boolean;
852
+ }
853
+ const HEADER_MENU_EVENT = 'fe:header-menu';
854
+ const hostOwnsUtilities = ref(false);
855
+
856
+ function publishHeaderMenu() {
857
+ const el = rootEl.value;
858
+ if (!el) return;
859
+ const detail: HeaderMenuClaim = {
860
+ items: utilityActions.value.map((a) => ({ ...a })),
861
+ run: (key: string) => onMoreSelect({ key, label: '' }),
862
+ claimed: false,
863
+ };
864
+ el.dispatchEvent(
865
+ new CustomEvent(HEADER_MENU_EVENT, { detail, bubbles: true, composed: true }),
866
+ );
867
+ hostOwnsUtilities.value = !!detail.claimed;
868
+ }
869
+
870
+ onMounted(() => void nextTick(publishHeaderMenu));
871
+ watch(utilityActions, () => void nextTick(publishHeaderMenu), { deep: false });
872
+ onBeforeUnmount(() => {
873
+ /* ⚠ On `document`, not on our own root: by this point the element may
874
+ already be detached and a bubbling event from it would reach nobody,
875
+ leaving the host drawing rows for an explorer that is gone. */
876
+ if (typeof document === 'undefined') return;
877
+ document.dispatchEvent(
878
+ new CustomEvent(HEADER_MENU_EVENT, {
879
+ detail: { items: [], run: () => {}, claimed: false } as HeaderMenuClaim,
880
+ }),
881
+ );
882
+ });
883
+
884
+ /* surucu:d1 — the wide header renders the selection's actions as buttons (the
885
+ * selection bar), so its "⋯" carries only the settings half; the narrow layout
886
+ * renders no action buttons at all and still needs the whole list.
887
+ * gorunum:v4-hostmenu — minus whatever the host took over. At 1440 that empties
888
+ * the list and the "⋯" is not drawn at all; at 390 the folder verbs are still
889
+ * this menu's, so it stays and simply loses its settings tail. */
890
+ const menuActions = computed<ContextAction[]>(() => {
891
+ const util = hostOwnsUtilities.value ? [] : utilityActions.value;
892
+ return props.narrow ? narrowActions(util) : util;
893
+ });
417
894
 
418
895
  function openMore() {
419
896
  const r = moreBtnEl.value?.getBoundingClientRect();
@@ -452,8 +929,19 @@ function onMoreSelect(a: ContextAction) {
452
929
  case 'shortcut-settings' /* wiring:c2 */:
453
930
  emit('open-shortcut-settings');
454
931
  break;
932
+ case 'timezone' /* zaman:z3 */:
933
+ emit('open-timezone');
934
+ break;
455
935
  case 'tour' /* wiring:c4 — bubbles to the FileExplorer root listener */:
456
- moreBtnEl.value?.dispatchEvent(new CustomEvent('fe:tour-restart', { bubbles: true }));
936
+ /* ⚠ From the TOOLBAR's root, not from the "⋯" button. That button is no
937
+ longer always on screen — a host that claimed the settings half
938
+ (gorunum:v4-hostmenu) leaves it unrendered, `moreBtnEl` null, and the
939
+ optional chain then swallowed the whole restart in silence: the row
940
+ was clicked, nothing happened, and nothing errored. Measured in a real
941
+ browser — the row opened the shortcut editor fine and the tour row did
942
+ nothing at all. The toolbar root is inside `.fe`, which is where the
943
+ listener lives, so the event still arrives. */
944
+ rootEl.value?.dispatchEvent(new CustomEvent('fe:tour-restart', { bubbles: true }));
457
945
  break;
458
946
  default:
459
947
  fire(a.key);
@@ -465,28 +953,66 @@ function onMoreSelect(a: ContextAction) {
465
953
 
466
954
  <template>
467
955
  <div
956
+ ref="rootEl"
468
957
  class="fe-toolbar"
469
958
  :class="{
470
959
  'fe-toolbar--narrow': narrow /* bag:b4 */,
471
- 'fe-toolbar--searching': narrow && searchOpen /* bag:b4 */,
472
- 'fe-toolbar--drive': shell === 'drive' /* surucu:d1 */,
960
+ /* surucu:d1 ALWAYS on. It is no longer a variant: this is the only
961
+ header filex has, and the modifier survives as the hook the one
962
+ visual definition in styles/base.css was written against. Dropping it
963
+ would mean moving those rules, not deleting a state. */
964
+ 'fe-toolbar--drive': true,
473
965
  }"
474
966
  >
475
- <!-- bag:b4wide layout, untouched; renders exactly as before when not narrow -->
476
- <template v-if="!narrow">
477
- <div ref="primaryEl" class="fe-toolbar__primary">
478
- <!-- gezinti:g1 navigation panel toggle. Leftmost, the position every
479
- Drive-shaped UI puts it in; the panel's own header carries the same
480
- action, and in narrow mode where the panel is an off-screen drawer
481
- this is the ONLY way back to it. -->
967
+ <!-- gorunum:v3-shellthe far-left corner, and it belongs to this row.
968
+ The collapse control and the product mark used to be somewhere else
969
+ each: the toggle inside the navigation panel's own header, one row
970
+ down, and the mark up in a page bar that no longer exists — which left
971
+ the top-left of every screen empty while the button that opens the
972
+ panel sat below the line it is supposed to open.
973
+
974
+ ⚠ Rendered for EVERY width and every embed, outside the two layout
975
+ branches, so there is one definition of "the way back to the panel"
976
+ instead of a wide copy and a narrow copy that drift.
977
+
978
+ ⚠ The mark itself is a SLOT. This package has no product branding of
979
+ its own and must not grow any: `<filex-explorer>` inside somebody
980
+ else's app has no "filex" wordmark to show, and an embed that passes
981
+ nothing renders nothing here — the toggle simply sits at the edge.
982
+ Hidden at 390px even when a host fills it, where the row is three
983
+ items wide already. -->
984
+ <!-- ⚠ Not rendered at all when there is neither a panel to collapse nor a
985
+ mark to show — a confined embed (`rootPath`, so `sideNav` defaults
986
+ off) would otherwise reserve the gutter below for an empty box.
987
+ ⚠ The mark counts in BOTH of its forms: the slot an SFC host fills and
988
+ the `config.brand` a web-component host has to use instead. Asking only
989
+ about the slot would have hidden the desktop app's own logo the moment
990
+ it confined an explorer — the same silent blank corner this whole
991
+ branch exists to stop. -->
992
+ <div
993
+ v-if="navEnabled !== false || !!$slots.brand || !!brandName || !!brandMarkUrl"
994
+ class="fe-toolbar__brand"
995
+ :class="{
996
+ 'fe-toolbar__brand--gutter': navEnabled !== false,
997
+ /* gezinti:g3 — the panel collapsed to its rail, so this gutter has to
998
+ collapse with it. `navOpen === false`, never `!navOpen`: the prop is
999
+ optional, and an embedder that does not pass it must keep the
1000
+ expanded layout rather than lose its wordmark to a `undefined`. */
1001
+ 'fe-toolbar__brand--rail': !narrow && navEnabled !== false && navOpen === false,
1002
+ }"
1003
+ >
1004
+ <!-- ⚠ `aria-pressed` without the `is-active` styling, and the two are
1005
+ not the same claim. A screen reader has to be told the panel is
1006
+ open; a sighted person can see it, and a permanently highlighted
1007
+ button in the top-left corner reads as "something is selected here"
1008
+ on a control nobody selected. The panel's presence IS its state. -->
482
1009
  <button
483
1010
  v-if="navEnabled !== false"
484
1011
  type="button"
485
1012
  class="fe-btn fe-btn--icon-only fe-toolbar__nav"
486
- :class="{ 'is-active': navOpen }"
487
1013
  :aria-pressed="!!navOpen"
488
- :title="t('toolbar.nav')"
489
- :aria-label="t('toolbar.nav')"
1014
+ :title="navToggleLabel /* gezinti:g2 — a verb, and the state's verb */"
1015
+ :aria-label="navToggleLabel"
490
1016
  data-testid="toolbar-nav"
491
1017
  @click="emit('toggle-nav')"
492
1018
  >
@@ -500,98 +1026,106 @@ function onMoreSelect(a: ContextAction) {
500
1026
  aria-hidden="true"
501
1027
  focusable="false"
502
1028
  >
503
- <rect x="3.5" y="4.5" width="17" height="15" rx="2" />
504
- <path d="M9.5 4.5v15" />
1029
+ <path d="M4 7h16M4 12h16M4 17h16" />
505
1030
  </svg>
506
1031
  </button>
1032
+ <!-- gezinti:g3 — the mark goes with the gutter. The rail is 56px and the
1033
+ brand's content measures 113px, so it cannot be shown in the
1034
+ collapsed state at any size; the reference does the same thing
1035
+ (measured signed-in: logo and wordmark both gone the moment the
1036
+ panel collapses, `☰` re-centred in the rail, search field slid
1037
+ left). ⚠ `navOpen !== false` — an embedder that passes no state at
1038
+ all keeps its mark. -->
1039
+ <div v-if="!narrow && navOpen !== false" class="fe-toolbar__mark">
1040
+ <slot name="brand">
1041
+ <img v-if="brandMarkUrl" class="fe-toolbar__markimg" :src="brandMarkUrl" alt="" />
1042
+ <span v-if="brandName">{{ brandName }}</span>
1043
+ </slot>
1044
+ </div>
1045
+ </div>
507
1046
 
508
- <button
509
- v-if="canGoUp"
510
- type="button"
511
- class="fe-btn fe-btn--icon-only"
512
- :title="t('toolbar.go_up')"
513
- :aria-label="t('toolbar.go_up')"
514
- @click="emit('go-up')"
515
- >
516
- <span class="fe-icon">↑</span>
517
- </button>
1047
+ <!-- === gorunum:v2-topbar — the wide header ==========================
1048
+ ONE header, in the reference shell's shape, for every profile: the
1049
+ search field takes the row and the trailing cluster carries what
1050
+ belongs to the APP rather than to a listing. The page chrome that
1051
+ used to sit above the explorer (wordmark, admin link, sign out,
1052
+ language) is gone, so this row is the only header on the screen.
518
1053
 
519
- <!-- surucu:d1 in the drive shell the panel's "+ New" menu is the one
520
- primary action; a second New Folder button here would be a second
521
- front door to the same modal. -->
1054
+ What LEFT this row, and the single door each one kept no control
1055
+ was dropped from the build:
1056
+ · navigation collapse CAME BACK, to `.fe-toolbar__brand` above,
1057
+ at every width. It had gone to the panel's own edge control, which
1058
+ put the button that opens the panel one row BELOW the line it
1059
+ opens and left this corner empty; the panel no longer draws its
1060
+ own (except as the drawer's close button at 390px).
1061
+ · Up one level → the parent crumb, always drawn (the trail
1062
+ collapses to `root › … › parent › current`, never past it).
1063
+ The keyboard binding is untouched.
1064
+ · New Folder / Upload → the panel's primary block
1065
+ (`sidenav-upload` / `sidenav-new-folder`, or its "+ New" menu in
1066
+ the drive profile). They come BACK here when a deployment has no
1067
+ panel at all (`sideNav: false`, or a confined root), because then
1068
+ there is nowhere else for them to live.
1069
+ · density, theme → user settings, and the "⋯" menu below.
1070
+ · Details ⓘ, views → the breadcrumb row, beside the crumbs.
1071
+ -->
1072
+ <template v-if="!narrow">
1073
+ <div class="fe-toolbar__primary">
1074
+ <!-- No navigation panel in this deployment → no primary block to hold
1075
+ these, so they are the one door and they render here. -->
522
1076
  <button
523
- v-if="shell !== 'drive' && mode === 'none' && !trashActive && !atVirtualRoot && canWrite !== false"
1077
+ v-if="navEnabled === false && mode === 'none' && !trashActive && !atVirtualRoot && canWrite !== false"
524
1078
  type="button"
525
1079
  class="fe-btn fe-btn--primary"
526
- :title="t('toolbar.new_folder')"
1080
+ :title="withKey(t('toolbar.new_folder'), 'new-folder')"
527
1081
  @click="emit('new-folder')"
528
1082
  >
529
- <span class="fe-icon">📁</span>
1083
+ <!-- eslint-disable-next-line vue/no-v-html — static markup from lib/actionIcons -->
1084
+ <span class="fe-icon" aria-hidden="true" v-html="actionIconSvg('new-folder')"></span>
530
1085
  <span class="fe-btn__label">{{ t('toolbar.new_folder') }}</span>
531
1086
  </button>
532
-
533
1087
  <button
534
- v-for="a in visibleToolbarItems"
535
- :key="a.key"
536
- type="button"
537
- class="fe-btn fe-btn--fold"
538
- :class="{ 'fe-btn--danger': a.danger, 'is-disabled': a.disabled }"
539
- :disabled="a.disabled"
540
- :title="a.label"
541
- @click="fire(a.key)"
542
- >
543
- <span class="fe-icon">{{ a.icon }}</span>
544
- <span class="fe-btn__label">{{ a.label }}</span>
545
- </button>
546
-
547
- <!-- ui-fix — actions that don't fit fold into the ⋯ menu so the row stays
548
- a single line (search/view controls no longer drop to a second row). -->
549
- <button
550
- v-if="overflowToolbarItems.length > 0"
551
- ref="wideMoreBtnEl"
1088
+ v-if="navEnabled === false && !atVirtualRoot && canWrite !== false"
552
1089
  type="button"
553
1090
  class="fe-btn fe-btn--icon-only"
554
- :title="t('toolbar.more')"
555
- :aria-label="t('toolbar.more')"
556
- aria-haspopup="menu"
557
- @click="openWideMore"
1091
+ :title="withKey(t('toolbar.upload'), 'upload')"
1092
+ :aria-label="t('toolbar.upload')"
1093
+ @click="emit('upload')"
558
1094
  >
559
- <span class="fe-icon">⋯</span>
1095
+ <!-- eslint-disable-next-line vue/no-v-html — static markup from lib/actionIcons -->
1096
+ <span class="fe-icon" aria-hidden="true" v-html="actionIconSvg('upload')"></span>
560
1097
  </button>
561
1098
 
562
- <!-- Invisible measuring strip: EVERY action is always rendered here, so
563
- the fold calculation runs off real measured widths. -->
564
- <div ref="measureEl" class="fe-toolbar__measure" aria-hidden="true">
565
- <button
566
- v-for="a in toolbarItems"
567
- :key="'m-' + a.key"
568
- type="button"
569
- class="fe-btn"
570
- tabindex="-1"
571
- >
572
- <span class="fe-icon">{{ a.icon }}</span>
573
- <span class="fe-btn__label">{{ a.label }}</span>
574
- </button>
575
- </div>
1099
+ <!-- gorunum:v1-selbar the selection's actions used to be a row of
1100
+ labelled buttons here (Details / Cut / Copy / Paste / Star / a red
1101
+ filled Delete). They are now the selection bar, under the tab strip,
1102
+ where the reference puts them. -->
576
1103
 
577
1104
  <button
578
1105
  v-if="pasteEnabled && mode === 'none' && !trashActive && !atVirtualRoot && canWrite !== false"
579
1106
  type="button"
580
1107
  class="fe-btn"
581
- :title="t('ctx.paste')"
1108
+ :title="withKey(t('ctx.paste'), 'paste')"
582
1109
  @click="fire('paste')"
583
1110
  >
584
- <span class="fe-icon">📋</span>
1111
+ <!-- eslint-disable-next-line vue/no-v-html — static markup from lib/actionIcons -->
1112
+ <span class="fe-icon" aria-hidden="true" v-html="actionIconSvg('paste')"></span>
585
1113
  <span class="fe-btn__label">{{ t('ctx.paste') }}</span>
586
1114
  </button>
587
1115
  </div>
588
1116
 
589
- <div class="fe-toolbar__spacer" />
590
-
591
- <div class="fe-toolbar__search-group" :class="{ 'fe-toolbar__search-group--drive': shell === 'drive' }">
592
- <!-- surucu:d1 the drive shell's one search field: wide, in the header,
593
- with the ⌘K chip that opens the palette carrying this query. -->
594
- <div v-if="shell === 'drive'" class="fe-drivesearch" data-testid="drive-search">
1117
+ <div class="fe-toolbar__search-group fe-toolbar__search-group--drive">
1118
+ <!-- gorunum:v1 — ONE search field, in every shell. This used to be two
1119
+ blocks a bare input in a bordered group for the classic profile
1120
+ and a wide field with the ⌘K chip for the drive one; the same box
1121
+ in two shapes is two products. It carries both class names on
1122
+ purpose: the `fe-drivesearch*` half is the one visual definition
1123
+ (styles/base.css), the `fe-search*` half is the hook the onboarding
1124
+ tour has always looked for.
1125
+ ⚠ The chip prints `shortcutHint('palette')` and the field escalates
1126
+ through `eventMatchesShortcut(ev, 'palette')` — never a literal key
1127
+ (web/tests/ui/shortcutHints.test.ts). -->
1128
+ <div class="fe-search fe-drivesearch" data-testid="drive-search">
595
1129
  <svg
596
1130
  class="fe-ficon fe-drivesearch__glass"
597
1131
  viewBox="0 0 24 24"
@@ -608,13 +1142,27 @@ function onMoreSelect(a: ContextAction) {
608
1142
  <input
609
1143
  ref="searchEl"
610
1144
  type="search"
611
- class="fe-drivesearch__input"
1145
+ class="fe-search__input fe-drivesearch__input"
612
1146
  :placeholder="drivePlaceholder"
613
1147
  :value="localSearch"
614
1148
  :aria-label="t('toolbar.search')"
615
1149
  @input="onSearchInput"
616
1150
  @keydown="onSearchKeydown"
617
1151
  />
1152
+ <!-- gorunum:v1-advsearch — the filter affordance the header field has
1153
+ always been specced with, beside the palette chip. It opens the
1154
+ advanced dialog carrying whatever is already typed. -->
1155
+ <button
1156
+ type="button"
1157
+ class="fe-drivesearch__tune"
1158
+ :title="t('advsearch.open')"
1159
+ :aria-label="t('advsearch.open')"
1160
+ data-testid="drive-search-advanced"
1161
+ @click="emit('open-advanced-search', localSearch)"
1162
+ >
1163
+ <!-- eslint-disable-next-line vue/no-v-html — static markup from lib/actionIcons -->
1164
+ <span class="fe-icon" aria-hidden="true" v-html="actionIconSvg('filter')"></span>
1165
+ </button>
618
1166
  <button
619
1167
  type="button"
620
1168
  class="fe-drivesearch__kbd"
@@ -626,41 +1174,69 @@ function onMoreSelect(a: ContextAction) {
626
1174
  {{ paletteCombo }}
627
1175
  </button>
628
1176
  </div>
629
- <div v-else class="fe-search">
630
- <input
631
- ref="searchEl"
632
- type="search"
633
- class="fe-search__input"
634
- :placeholder="t('toolbar.search.placeholder')"
635
- :value="localSearch"
636
- :aria-label="t('toolbar.search') /* wiring:c4 — was hardcoded EN */"
637
- @input="onSearchInput"
638
- />
639
- </div>
640
- <button
641
- v-if="shell !== 'drive' && !atVirtualRoot && canWrite !== false"
642
- type="button"
643
- class="fe-btn fe-btn--icon-only"
644
- :title="t('toolbar.upload')"
645
- :aria-label="t('toolbar.upload') /* wiring:c4 */"
646
- @click="emit('upload')"
647
- >
648
- <span class="fe-icon" aria-hidden="true">⬆</span>
649
- </button>
1177
+ </div>
1178
+
1179
+ <!-- gorunum:v2-topbar — the trailing cluster. Refresh and the "" are the
1180
+ explorer's own; the AI mark is drawn but not yet wired; the host's
1181
+ account-level doors arrive through the slot. -->
1182
+ <div class="fe-toolbar__tail">
650
1183
  <button
651
1184
  type="button"
652
1185
  class="fe-btn fe-btn--icon-only"
653
- :title="t('toolbar.refresh')"
654
- :aria-label="t('toolbar.refresh') /* wiring:c4 */"
1186
+ :title="withKey(t('toolbar.refresh'), 'refresh')"
1187
+ :aria-label="t('toolbar.refresh')"
655
1188
  @click="emit('refresh')"
656
1189
  >
657
- <span class="fe-icon" aria-hidden="true">↻</span>
1190
+ <!-- eslint-disable-next-line vue/no-v-html — static markup from lib/actionIcons -->
1191
+ <span class="fe-icon" aria-hidden="true" v-html="actionIconSvg('refresh')"></span>
658
1192
  </button>
659
- <!-- surucu:d1density, theme, the shortcut editor, the tour and the
660
- other view modes. Everything the drive header does not draw is one
661
- click away here; nothing is removed from the build. -->
1193
+ <!-- gorunum:v2-topbar — the AI assistant, announced and not pretending.
1194
+ `disabled`, not "opens an empty panel": a control that answers a
1195
+ click with nothing is read as broken, and the next thing that
1196
+ happens is a bug report about a feature that was never claimed. It
1197
+ says what it is and that it is not here yet, in both languages, and
1198
+ it will become live in place — the glyph does not move under
1199
+ anybody's finger the day it starts working. -->
1200
+ <span class="fe-toolbar__soon-wrap">
1201
+ <button
1202
+ type="button"
1203
+ class="fe-btn fe-btn--icon-only fe-toolbar__soon"
1204
+ disabled
1205
+ :aria-label="t('ai.assistant.soon')"
1206
+ aria-disabled="true"
1207
+ data-testid="header-ai"
1208
+ @click.prevent
1209
+ >
1210
+ <!-- eslint-disable-next-line vue/no-v-html — static markup from lib/actionIcons -->
1211
+ <span class="fe-icon" aria-hidden="true" v-html="actionIconSvg('ai')"></span>
1212
+ </button>
1213
+ <!-- gorunum:v4-hostmenu — the words, on hover, and nowhere else at
1214
+ rest. Owner's decision, 2026-09-13, verbatim: *"Coming soon
1215
+ yazısını kaldıralım, üstüne gelince gözüksün."*
1216
+ ⚠ `aria-hidden`, and the button keeps `aria-label` with the SAME
1217
+ sentence: hover is a mouse gesture that a touch screen and a
1218
+ keyboard do not have, so the words have to be in the accessible
1219
+ name — and if they were in both, a screen reader would read the
1220
+ sentence twice.
1221
+ ⚠ The native `title` went with the pill. It is not an accessible
1222
+ name (the label above is), it never appears on touch, and leaving
1223
+ it would stack the OS tooltip on top of this one. -->
1224
+ <span
1225
+ class="fe-toolbar__soon-tip"
1226
+ aria-hidden="true"
1227
+ data-testid="header-ai-tip"
1228
+ >{{ t('ai.assistant.soon') }}</span>
1229
+ </span>
1230
+ <!-- Density, theme, the shortcut editor, the tour and the other view
1231
+ modes. Everything the header does not draw is one click away here;
1232
+ nothing is removed from the build. A menu is the one place a
1233
+ duplicate is allowed to live.
1234
+ gorunum:v4-hostmenu — ⚠ not drawn when there is nothing left in it:
1235
+ a host that claimed the settings half (the web app's avatar menu)
1236
+ empties this list at 1440, and a button that opens an empty menu is
1237
+ the "second control doing the same job" this pass removes. -->
662
1238
  <button
663
- v-if="shell === 'drive'"
1239
+ v-if="menuActions.length > 0"
664
1240
  ref="moreBtnEl"
665
1241
  type="button"
666
1242
  class="fe-btn fe-btn--icon-only"
@@ -670,149 +1246,28 @@ function onMoreSelect(a: ContextAction) {
670
1246
  data-testid="drive-more"
671
1247
  @click="openMore"
672
1248
  >
673
- <span class="fe-icon" aria-hidden="true">⋯</span>
1249
+ <!-- eslint-disable-next-line vue/no-v-html — static markup from lib/actionIcons -->
1250
+ <span class="fe-icon" aria-hidden="true" v-html="actionIconSvg('more')"></span>
674
1251
  </button>
1252
+ <!-- The host's own doors (admin panel, settings, sign out). Empty in an
1253
+ embed, which is why the cluster is a slot and not a prop: an
1254
+ embedder that has no account chrome renders nothing at all here. -->
1255
+ <slot name="header-actions"></slot>
675
1256
  </div>
676
-
677
- <button
678
- v-if="shell !== 'drive'"
679
- type="button"
680
- class="fe-btn fe-btn--icon-only fe-toolbar__density"
681
- :class="{ 'is-active': density === 'compact' }"
682
- :aria-pressed="density === 'compact'"
683
- :aria-label="density === 'compact' ? t('toolbar.density.comfortable') : t('toolbar.density.compact')"
684
- :title="density === 'compact' ? t('toolbar.density.comfortable') : t('toolbar.density.compact')"
685
- @click="toggleDensity"
686
- >
687
- <!-- Row-spacing glyph: tight rows when compact is ON, airy rows otherwise. -->
688
- <svg
689
- v-if="density === 'compact'"
690
- class="fe-ficon"
691
- viewBox="0 0 24 24"
692
- fill="none"
693
- stroke="currentColor"
694
- stroke-width="1.8"
695
- stroke-linecap="round"
696
- aria-hidden="true"
697
- focusable="false"
698
- >
699
- <path d="M4 6.5h16M4 10.5h16M4 14.5h16M4 18.5h16" />
700
- </svg>
701
- <svg
702
- v-else
703
- class="fe-ficon"
704
- viewBox="0 0 24 24"
705
- fill="none"
706
- stroke="currentColor"
707
- stroke-width="1.8"
708
- stroke-linecap="round"
709
- aria-hidden="true"
710
- focusable="false"
711
- >
712
- <path d="M4 5.5h16M4 12h16M4 18.5h16" />
713
- </svg>
714
- </button>
715
-
716
- <!-- koru:k1 — inspector (details panel) toggle.
717
- surucu:d1 — the drive shell renders it on the breadcrumb row instead,
718
- beside the view switcher, where both belong to the listing. -->
719
- <button
720
- v-if="shell !== 'drive'"
721
- type="button"
722
- class="fe-btn fe-btn--icon-only fe-toolbar__inspector"
723
- :class="{ 'is-active': inspectorOpen }"
724
- :aria-pressed="!!inspectorOpen"
725
- :title="t('toolbar.inspector')"
726
- :aria-label="t('toolbar.inspector')"
727
- @click="emit('toggle-inspector')"
728
- >
729
- <svg
730
- class="fe-ficon"
731
- viewBox="0 0 24 24"
732
- fill="none"
733
- stroke="currentColor"
734
- stroke-width="1.8"
735
- stroke-linecap="round"
736
- aria-hidden="true"
737
- focusable="false"
738
- >
739
- <circle cx="12" cy="12" r="9" />
740
- <path d="M12 11v5" />
741
- <circle cx="12" cy="7.6" r="1" fill="currentColor" stroke="none" />
742
- </svg>
743
- </button>
744
-
745
- <!-- wiring:c1 — tema galerisi (palet ikonu) -->
746
- <button
747
- v-if="shell !== 'drive'"
748
- type="button"
749
- class="fe-btn fe-btn--icon-only fe-toolbar__theme"
750
- :title="t('theme.menu')"
751
- :aria-label="t('theme.menu')"
752
- @click="emit('open-theme')"
753
- >
754
- <svg
755
- class="fe-ficon"
756
- viewBox="0 0 24 24"
757
- fill="none"
758
- stroke="currentColor"
759
- stroke-width="1.8"
760
- stroke-linecap="round"
761
- stroke-linejoin="round"
762
- aria-hidden="true"
763
- focusable="false"
764
- >
765
- <path d="M12 3a9 9 0 1 0 0 18h1.4a2.1 2.1 0 0 0 1.5-3.6 2.1 2.1 0 0 1 1.5-3.6H19a2 2 0 0 0 2-2A9 9 0 0 0 12 3z" />
766
- <circle cx="7.8" cy="10.2" r="1.15" fill="currentColor" stroke="none" />
767
- <circle cx="11.6" cy="7.2" r="1.15" fill="currentColor" stroke="none" />
768
- <circle cx="16" cy="8.6" r="1.15" fill="currentColor" stroke="none" />
769
- </svg>
770
- </button>
771
- <!-- /wiring:c1 -->
772
-
773
- <ViewSwitcher
774
- v-if="shell !== 'drive'"
775
- :view-mode="viewMode"
776
- :locale="locale"
777
- :modes="viewModes"
778
- @update:view-mode="emit('update:viewMode', $event)"
779
- />
780
1257
  </template>
781
1258
 
782
- <!-- surucu:d1 — narrow DRIVE layout: [☰] [ search ] [⋯].
783
- The classic narrow row hides search behind a magnifier, which is the
784
- right trade when the row also carries Up / Upload / actions. This shell
785
- does not: "+ New" lives in the drawer, upload has the floating button
786
- at this width already, and the whole point of the profile is that the
787
- search field is the thing you see. So the field keeps the row, and only
788
- the ⌘K chip goes (CSS) a phone cannot press it, and the palette stays
789
- in the "⋯" menu. -->
790
- <template v-else-if="shell === 'drive'">
791
- <button
792
- v-if="navEnabled !== false"
793
- type="button"
794
- class="fe-btn fe-btn--icon-only fe-toolbar__nav"
795
- :class="{ 'is-active': navOpen }"
796
- :aria-pressed="!!navOpen"
797
- :title="t('toolbar.nav')"
798
- :aria-label="t('toolbar.nav')"
799
- data-testid="toolbar-nav"
800
- @click="emit('toggle-nav')"
801
- >
802
- <svg
803
- class="fe-ficon"
804
- viewBox="0 0 24 24"
805
- fill="none"
806
- stroke="currentColor"
807
- stroke-width="1.8"
808
- stroke-linecap="round"
809
- aria-hidden="true"
810
- focusable="false"
811
- >
812
- <rect x="3.5" y="4.5" width="17" height="15" rx="2" />
813
- <path d="M9.5 4.5v15" />
814
- </svg>
815
- </button>
1259
+ <!-- surucu:d1 — narrow layout: [☰] [ search ] [⋯] + the host cluster.
1260
+ The ONLY narrow layout now. There used to be a second one that hid
1261
+ search behind a magnifier and expanded it over the row; it was the
1262
+ right trade when the row also carried Up / Upload / actions, and this
1263
+ one does not "+ New" lives in the drawer, upload has the floating
1264
+ button this width already draws (`.fe-fab`), and Up went to the parent
1265
+ crumb. So the field keeps the row, and only the ⌘K chip goes (CSS): a
1266
+ phone cannot press it, and the palette stays in the "⋯" menu.
1267
+ gorunum:v2-topbar the trailing cluster is the wide header's, to the
1268
+ glyph: a control on screen at 1440 and unreachable at 390 is a
1269
+ capability split, not a layout. -->
1270
+ <template v-else>
816
1271
  <div class="fe-drivesearch" data-testid="drive-search">
817
1272
  <svg
818
1273
  class="fe-ficon fe-drivesearch__glass"
@@ -837,6 +1292,23 @@ function onMoreSelect(a: ContextAction) {
837
1292
  @input="onSearchInput"
838
1293
  @keydown="onSearchKeydown"
839
1294
  />
1295
+ <!-- gorunum:v1-advsearch — the filter affordance the header field has
1296
+ always been specced with, beside the palette chip. It opens the
1297
+ advanced dialog carrying whatever is already typed.
1298
+ ⚠ Added to ALL THREE field blocks on purpose: this file's own note
1299
+ says the same box in two shapes is two products, so a control that
1300
+ existed only in the drive shell would re-open exactly that split. -->
1301
+ <button
1302
+ type="button"
1303
+ class="fe-drivesearch__tune"
1304
+ :title="t('advsearch.open')"
1305
+ :aria-label="t('advsearch.open')"
1306
+ data-testid="drive-search-advanced"
1307
+ @click="emit('open-advanced-search', localSearch)"
1308
+ >
1309
+ <!-- eslint-disable-next-line vue/no-v-html — static markup from lib/actionIcons -->
1310
+ <span class="fe-icon" aria-hidden="true" v-html="actionIconSvg('filter')"></span>
1311
+ </button>
840
1312
  <button
841
1313
  type="button"
842
1314
  class="fe-drivesearch__kbd"
@@ -848,141 +1320,165 @@ function onMoreSelect(a: ContextAction) {
848
1320
  {{ paletteCombo }}
849
1321
  </button>
850
1322
  </div>
851
- <button
852
- ref="moreBtnEl"
853
- type="button"
854
- class="fe-btn fe-btn--icon-only"
855
- :title="t('toolbar.more')"
856
- :aria-label="t('toolbar.more')"
857
- aria-haspopup="menu"
858
- data-testid="drive-more"
859
- @click="openMore"
860
- >
861
- <span class="fe-icon">⋯</span>
862
- </button>
863
- </template>
864
-
865
- <!-- bag:b4 — narrow layout: [↑?] ... [🔍] [⬆] [⋯], search expands full-width -->
866
- <template v-else>
867
- <template v-if="searchOpen">
868
- <div class="fe-search fe-search--full">
869
- <input
870
- ref="searchEl"
871
- type="search"
872
- class="fe-search__input"
873
- :placeholder="t('toolbar.search.placeholder')"
874
- :value="localSearch"
875
- :aria-label="t('toolbar.search')"
876
- @input="onSearchInput"
877
- @keydown.esc.stop.prevent="closeSearch"
878
- />
879
- </div>
1323
+ <div class="fe-toolbar__tail">
1324
+ <!-- gorunum:v2-topbar — the same cluster as the wide header. A control
1325
+ that is on screen at 1440 and unreachable at 390 is a capability
1326
+ split, not a layout, so the AI mark and the host's doors come
1327
+ along; only Refresh stays behind (it is in the "⋯"). -->
1328
+ <span class="fe-toolbar__soon-wrap">
1329
+ <button
1330
+ type="button"
1331
+ class="fe-btn fe-btn--icon-only fe-toolbar__soon"
1332
+ disabled
1333
+ :aria-label="t('ai.assistant.soon')"
1334
+ aria-disabled="true"
1335
+ data-testid="header-ai"
1336
+ @click.prevent
1337
+ >
1338
+ <!-- eslint-disable-next-line vue/no-v-html — static markup from lib/actionIcons -->
1339
+ <span class="fe-icon" aria-hidden="true" v-html="actionIconSvg('ai')"></span>
1340
+ </button>
1341
+ <!-- gorunum:v4-hostmenu — the same affordance as the wide header, to
1342
+ the glyph. ⚠ A phone has no hover at all, so here the accessible
1343
+ name is the ONLY door to these words — which is exactly why it
1344
+ carries the whole sentence and not a `title`. -->
1345
+ <span
1346
+ class="fe-toolbar__soon-tip"
1347
+ aria-hidden="true"
1348
+ data-testid="header-ai-tip"
1349
+ >{{ t('ai.assistant.soon') }}</span>
1350
+ </span>
880
1351
  <button
1352
+ v-if="menuActions.length > 0"
1353
+ ref="moreBtnEl"
881
1354
  type="button"
882
1355
  class="fe-btn fe-btn--icon-only"
883
- :title="t('toolbar.search.close')"
884
- :aria-label="t('toolbar.search.close')"
885
- @click="closeSearch"
1356
+ :title="t('toolbar.more')"
1357
+ :aria-label="t('toolbar.more')"
1358
+ aria-haspopup="menu"
1359
+ data-testid="drive-more"
1360
+ @click="openMore"
886
1361
  >
887
- <span class="fe-icon">✕</span>
1362
+ <!-- eslint-disable-next-line vue/no-v-html — static markup from lib/actionIcons -->
1363
+ <span class="fe-icon" aria-hidden="true" v-html="actionIconSvg('more')"></span>
888
1364
  </button>
889
- </template>
890
- <template v-else>
891
- <!-- gezinti:g1 — navigation panel toggle. Leftmost, the position every
892
- Drive-shaped UI puts it in; the panel's own header carries the same
893
- action, and in narrow mode where the panel is an off-screen drawer
894
- this is the ONLY way back to it. -->
895
- <button
896
- v-if="navEnabled !== false"
897
- type="button"
898
- class="fe-btn fe-btn--icon-only fe-toolbar__nav"
899
- :class="{ 'is-active': navOpen }"
900
- :aria-pressed="!!navOpen"
901
- :title="t('toolbar.nav')"
902
- :aria-label="t('toolbar.nav')"
903
- data-testid="toolbar-nav"
904
- @click="emit('toggle-nav')"
905
- >
906
- <svg
907
- class="fe-ficon"
908
- viewBox="0 0 24 24"
909
- fill="none"
910
- stroke="currentColor"
911
- stroke-width="1.8"
912
- stroke-linecap="round"
913
- aria-hidden="true"
914
- focusable="false"
1365
+ <slot name="header-actions"></slot>
1366
+ </div>
1367
+ </template>
1368
+
1369
+
1370
+ <!-- The "⋯" menu of whichever layout is on screen.
1371
+ ⚠ Rendered unconditionally, outside the three layout branches. It used
1372
+ to live inside the narrow non-drive branch alone, which left both drive
1373
+ layouts (`data-testid="drive-more"`, wide and narrow) with a button
1374
+ whose `moreRef` was null — the click resolved to nothing and the whole
1375
+ settings half of the menu was unreachable in that profile. -->
1376
+ <ContextMenu
1377
+ ref="moreRef"
1378
+ :locale="locale"
1379
+ :theme="theme || 'auto'"
1380
+ :sheet="coarse"
1381
+ :actions="menuActions"
1382
+ @select="onMoreSelect"
1383
+ />
1384
+
1385
+ <!-- === gorunum:v1-selbar — the selection bar ======================
1386
+ Teleported into the slot this component put right after the breadcrumb
1387
+ (see the script block). With no slot to mount into the Teleport is
1388
+ disabled and the bar renders here instead, so a bare <Toolbar> still
1389
+ offers the selection's actions. -->
1390
+ <Teleport :to="selSlot || 'body'" :disabled="!selSlot">
1391
+ <div
1392
+ v-if="hasSelection"
1393
+ class="fe-selbar"
1394
+ :class="{ 'fe-selbar--inline': !selSlot }"
1395
+ role="toolbar"
1396
+ :aria-label="selectionLabel"
1397
+ data-testid="selection-bar"
1398
+ >
1399
+ <span class="fe-selbar__count" role="status" data-testid="selection-count">{{
1400
+ selectionLabel
1401
+ }}</span>
1402
+
1403
+ <div ref="selBarEl" class="fe-selbar__actions">
1404
+ <button
1405
+ v-for="a in visibleBarItems"
1406
+ :key="a.key"
1407
+ type="button"
1408
+ class="fe-selbar__btn"
1409
+ :class="{ 'fe-selbar__btn--danger': a.danger, 'is-disabled': a.disabled }"
1410
+ :disabled="a.disabled"
1411
+ :title="a.title || withKey(a.label, a.key) /* tasi:m1 — a greyed
1412
+ row says WHY it is grey; everything else says what it does
1413
+ and which key does it */"
1414
+ :aria-label="a.label"
1415
+ :data-key="a.key"
1416
+ :data-testid="`selbar-${a.key}`"
1417
+ @click="fire(a.key)"
915
1418
  >
916
- <rect x="3.5" y="4.5" width="17" height="15" rx="2" />
917
- <path d="M9.5 4.5v15" />
918
- </svg>
919
- </button>
1419
+ <!-- eslint-disable-next-line vue/no-v-html static markup from lib/actionIcons -->
1420
+ <span class="fe-icon" aria-hidden="true" v-html="iconFor(a)"></span>
1421
+ </button>
920
1422
 
921
- <button
922
- v-if="canGoUp"
923
- type="button"
924
- class="fe-btn fe-btn--icon-only"
925
- :title="t('toolbar.go_up')"
926
- :aria-label="t('toolbar.go_up')"
927
- @click="emit('go-up')"
928
- >
929
- <span class="fe-icon">↑</span>
930
- </button>
1423
+ <button
1424
+ v-if="barMenuItems.length > 0"
1425
+ ref="selMoreBtnEl"
1426
+ type="button"
1427
+ class="fe-selbar__btn"
1428
+ :title="t('toolbar.more')"
1429
+ :aria-label="t('toolbar.more')"
1430
+ aria-haspopup="menu"
1431
+ data-testid="selbar-more"
1432
+ @click="openBarMore"
1433
+ >
1434
+ <!-- eslint-disable-next-line vue/no-v-html — static markup from lib/actionIcons -->
1435
+ <span class="fe-icon" aria-hidden="true" v-html="actionIconSvg('more')"></span>
1436
+ </button>
931
1437
 
932
- <div class="fe-toolbar__spacer" />
1438
+ <!-- Invisible measuring strip: every icon action is always rendered
1439
+ here, as the SAME button the visible row renders, so the fold
1440
+ arithmetic runs off widths something really has.
1441
+ ⚠ `data-key` is on BOTH copies: which icons are on screen is a
1442
+ function of the viewport (that is what folding means), so a test
1443
+ that wants the list this selection OFFERS has to read it from a
1444
+ strip the fold does not touch. -->
1445
+ <div ref="measureEl" class="fe-selbar__measure" aria-hidden="true">
1446
+ <button
1447
+ v-for="a in barSplit.icons"
1448
+ :key="'m-' + a.key"
1449
+ type="button"
1450
+ class="fe-selbar__btn"
1451
+ :data-key="a.key"
1452
+ tabindex="-1"
1453
+ >
1454
+ <!-- eslint-disable-next-line vue/no-v-html — static markup from lib/actionIcons -->
1455
+ <span class="fe-icon" aria-hidden="true" v-html="iconFor(a)"></span>
1456
+ </button>
1457
+ </div>
1458
+ </div>
933
1459
 
934
1460
  <button
935
1461
  type="button"
936
- class="fe-btn fe-btn--icon-only fe-toolbar__search-toggle"
937
- :class="{ 'is-active': !!localSearch }"
938
- :title="t('toolbar.search')"
939
- :aria-label="t('toolbar.search')"
940
- @click="openSearch"
1462
+ class="fe-selbar__close"
1463
+ :title="t('selection.clear')"
1464
+ :aria-label="t('selection.clear')"
1465
+ data-testid="selection-clear"
1466
+ @click="clearSelection"
941
1467
  >
942
- <span class="fe-icon">🔍</span>
1468
+ <!-- eslint-disable-next-line vue/no-v-html — static markup from lib/actionIcons -->
1469
+ <span class="fe-icon" aria-hidden="true" v-html="actionIconSvg('close')"></span>
943
1470
  </button>
944
- <button
945
- v-if="!atVirtualRoot && canWrite !== false"
946
- type="button"
947
- class="fe-btn fe-btn--icon-only"
948
- :title="t('toolbar.upload')"
949
- :aria-label="t('toolbar.upload')"
950
- @click="emit('upload')"
951
- >
952
- <span class="fe-icon">⬆</span>
953
- </button>
954
- <button
955
- ref="moreBtnEl"
956
- type="button"
957
- class="fe-btn fe-btn--icon-only fe-toolbar__more"
958
- :title="t('toolbar.more')"
959
- :aria-label="t('toolbar.more')"
960
- aria-haspopup="menu"
961
- @click="openMore"
962
- >
963
- <span class="fe-icon">⋯</span>
964
- </button>
965
- </template>
966
-
967
- <ContextMenu
968
- ref="moreRef"
969
- :locale="locale"
970
- :theme="theme || 'auto'"
971
- :sheet="coarse"
972
- :actions="menuActions"
973
- @select="onMoreSelect"
974
- />
975
- </template>
1471
+ </div>
1472
+ </Teleport>
976
1473
 
977
- <!-- ui-fix — ⋯ menu for the actions folded away in wide mode -->
978
1474
  <ContextMenu
979
- v-if="!narrow"
980
- ref="wideMoreRef"
1475
+ ref="selMoreRef"
981
1476
  :locale="locale"
982
1477
  :theme="theme || 'auto'"
983
- :sheet="false"
984
- :actions="overflowToolbarItems"
1478
+ :sheet="coarse"
1479
+ :actions="barMenuItems"
985
1480
  @select="(a) => fire(a.key)"
986
1481
  />
1482
+ <!-- === /gorunum:v1-selbar === -->
987
1483
  </div>
988
1484
  </template>