@brftech/filex-core 0.40.0 → 0.41.1
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.
- package/README.md +32 -25
- package/dist/ArchiveViewer-DxxjhYZD.js +124 -0
- package/dist/ArchiveViewer-DxxjhYZD.js.map +1 -0
- package/dist/CsvViewer-CyuwENI_.js +141 -0
- package/dist/CsvViewer-CyuwENI_.js.map +1 -0
- package/dist/{DrawioViewer-BNALOB04.js → DrawioViewer-BIYsTX97.js} +48 -42
- package/dist/DrawioViewer-BIYsTX97.js.map +1 -0
- package/dist/EpubViewer-K9PjCMmS.js +163 -0
- package/dist/EpubViewer-K9PjCMmS.js.map +1 -0
- package/dist/IpynbViewer-DYU6Zgz2.js +184 -0
- package/dist/IpynbViewer-DYU6Zgz2.js.map +1 -0
- package/dist/MermaidViewer-Bp73aId8.js +137 -0
- package/dist/MermaidViewer-Bp73aId8.js.map +1 -0
- package/dist/PsdViewer-C2eLivw1.js +122 -0
- package/dist/PsdViewer-C2eLivw1.js.map +1 -0
- package/dist/TiffViewer-DsHg5gcz.js +142 -0
- package/dist/TiffViewer-DsHg5gcz.js.map +1 -0
- package/dist/Viewer3D-C_dc2HN9.js +75 -0
- package/dist/Viewer3D-C_dc2HN9.js.map +1 -0
- package/dist/filex-core.js +218 -20361
- package/dist/filex-core.js.map +1 -1
- package/dist/filex-core.umd.cjs +61 -62
- package/dist/filex-core.umd.cjs.map +1 -1
- package/dist/index-vxMZpYb-.js +26700 -0
- package/dist/index-vxMZpYb-.js.map +1 -0
- package/dist/index.d.ts +4212 -862
- package/dist/style.css +1 -1
- package/package.json +1 -1
- package/src/FileExplorer.vue +2154 -506
- package/src/components/AdvancedSearch.vue +870 -0
- package/src/components/Breadcrumb.vue +191 -19
- package/src/components/CommandPalette.vue +66 -22
- package/src/components/ConnectionsPanel.vue +6 -2
- package/src/components/ContextMenu.vue +39 -2
- package/src/components/EncryptedFolderModal.vue +31 -25
- package/src/components/FilePane.vue +951 -0
- package/src/components/FilterBar.vue +568 -72
- package/src/components/GalleryView.vue +172 -56
- package/src/components/GridView.vue +331 -82
- package/src/components/HomeView.vue +331 -0
- package/src/components/InspectorPanel.vue +296 -53
- package/src/components/ListView.vue +1173 -194
- package/src/components/NFSExportsPanel.vue +7 -4
- package/src/components/OnboardingTour.vue +156 -33
- package/src/components/QuickLook.vue +26 -1
- package/src/components/RecentlyOpened.vue +17 -2
- package/src/components/S3KeysPanel.vue +7 -4
- package/src/components/SSHKeysPanel.vue +7 -4
- package/src/components/ShortcutSettings.vue +6 -2
- package/src/components/ShortcutsHelp.vue +4 -1
- package/src/components/SideNav.vue +206 -140
- package/src/components/StorageFields.vue +19 -1
- package/src/components/TabBar.vue +87 -1
- package/src/components/TagPicker.vue +77 -6
- package/src/components/ThemeGallery.vue +13 -198
- package/src/components/ThemePalette.vue +244 -0
- package/src/components/TimeZoneDialog.vue +135 -0
- package/src/components/TimeZonePicker.vue +696 -0
- package/src/components/TokensPanel.vue +35 -14
- package/src/components/Toolbar.vue +953 -465
- package/src/components/ViewSwitcher.vue +9 -4
- package/src/composables/useConnections.ts +35 -4
- package/src/composables/useExplorerTimeZone.ts +89 -0
- package/src/composables/useFileApi.ts +53 -4
- package/src/composables/useLocale.ts +382 -26
- package/src/composables/useRowTouch.ts +95 -0
- package/src/index.ts +177 -3
- package/src/lib/actionIcons.ts +330 -0
- package/src/lib/advSearch.ts +162 -0
- package/src/lib/dateGroups.ts +197 -0
- package/src/lib/destinationTree.ts +235 -0
- package/src/lib/downloadSelection.ts +154 -0
- package/src/lib/fileFilters.ts +319 -12
- package/src/lib/fileIcons.ts +299 -5
- package/src/lib/filePreview.ts +605 -0
- package/src/lib/listing.ts +72 -0
- package/src/lib/shareTtl.ts +31 -2
- package/src/lib/sortOrder.ts +459 -0
- package/src/lib/themes.ts +151 -116
- package/src/lib/timezone.ts +501 -0
- package/src/lib/uiProfile.ts +82 -0
- package/src/lib/viewPrefs.ts +1036 -0
- package/src/locales/en.ts +416 -26
- package/src/locales/tr.ts +409 -26
- package/src/modals/ConvertModal.vue +23 -3
- package/src/modals/DestinationPickerModal.vue +341 -0
- package/src/modals/Modal.vue +18 -4
- package/src/modals/NewDocumentModal.vue +525 -0
- package/src/modals/NewFolderModal.vue +15 -9
- package/src/modals/PermissionsModal.vue +557 -476
- package/src/modals/PreviewModal.vue +516 -156
- package/src/styles/base.css +5409 -8
- package/src/styles/variables.css +139 -65
- package/src/types/Connections.ts +9 -0
- package/src/types/ExplorerConfig.ts +144 -39
- package/src/types/FileNode.ts +28 -0
- package/src/viewers/ArchiveViewer.vue +46 -8
- package/src/viewers/CsvViewer.vue +19 -2
- package/src/viewers/DrawioViewer.vue +12 -1
- package/src/viewers/EpubViewer.vue +50 -6
- package/src/viewers/IpynbViewer.vue +19 -2
- package/src/viewers/MermaidViewer.vue +20 -3
- package/src/viewers/PdfViewer.vue +24 -4
- package/src/viewers/PsdViewer.vue +28 -4
- package/src/viewers/TiffViewer.vue +19 -2
- package/src/viewers/Viewer3D.vue +36 -2
- package/dist/ArchiveViewer-Cdfv9LFC.js +0 -99
- package/dist/ArchiveViewer-Cdfv9LFC.js.map +0 -1
- package/dist/CsvViewer-CqWeV8VO.js +0 -131
- package/dist/CsvViewer-CqWeV8VO.js.map +0 -1
- package/dist/DrawioViewer-BNALOB04.js.map +0 -1
- package/dist/EpubViewer-BPmlImG7.js +0 -145
- package/dist/EpubViewer-BPmlImG7.js.map +0 -1
- package/dist/IpynbViewer-CDlQmuCV.js +0 -175
- package/dist/IpynbViewer-CDlQmuCV.js.map +0 -1
- package/dist/MermaidViewer-sfZWdR-d.js +0 -127
- package/dist/MermaidViewer-sfZWdR-d.js.map +0 -1
- package/dist/PsdViewer-DwuySZsb.js +0 -112
- package/dist/PsdViewer-DwuySZsb.js.map +0 -1
- package/dist/TiffViewer-BDQ3d3CQ.js +0 -132
- package/dist/TiffViewer-BDQ3d3CQ.js.map +0 -1
- package/dist/Viewer3D-CJJJgkuv.js +0 -60
- package/dist/Viewer3D-CJJJgkuv.js.map +0 -1
- package/src/components/SecondaryPane.vue +0 -447
- package/src/modals/ShareModal.vue +0 -139
|
@@ -4,23 +4,36 @@
|
|
|
4
4
|
*
|
|
5
5
|
* Layout:
|
|
6
6
|
* - No selection → [📁 New Folder] [search | ⬆ ↻] [☰ ▦]
|
|
7
|
-
* -
|
|
8
|
-
*
|
|
9
|
-
*
|
|
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
|
-
|
|
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';
|
|
23
|
+
import { actionIconSvg } from '../lib/actionIcons';
|
|
20
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
|
-
*
|
|
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
|
|
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
|
-
*
|
|
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
|
-
*
|
|
100
|
-
*
|
|
101
|
-
*
|
|
102
|
-
*
|
|
103
|
-
*
|
|
104
|
-
*
|
|
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
|
-
* ⚠
|
|
107
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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 —
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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
|
-
/* ===
|
|
198
|
-
*
|
|
199
|
-
*
|
|
200
|
-
*
|
|
201
|
-
*
|
|
202
|
-
*
|
|
203
|
-
|
|
293
|
+
/* === gorunum:v1-selbar — the 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
|
|
206
|
-
const
|
|
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
|
|
209
|
-
const
|
|
210
|
-
|
|
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
|
-
|
|
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 =
|
|
371
|
+
visibleActionCount.value = all;
|
|
218
372
|
return;
|
|
219
373
|
}
|
|
220
|
-
const gap = parseFloat(getComputedStyle(cont).gap) ||
|
|
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
|
|
230
|
-
|
|
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 =
|
|
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
|
-
|
|
246
|
-
|
|
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(
|
|
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
|
|
260
|
-
const r =
|
|
261
|
-
|
|
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
|
-
|
|
266
|
-
|
|
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
|
-
|
|
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
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
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
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
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
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
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
|
-
|
|
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')
|
|
341
|
-
if (props.viewMode !== 'grid' && offersView('grid')) list.push({ key: 'view-grid', label: t('toolbar.view.grid')
|
|
342
|
-
if (props.viewMode !== 'gallery' && offersView('gallery')) list.push({ key: 'view-gallery', label: t('toolbar.view.gallery')
|
|
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
|
-
/*
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
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')
|
|
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')
|
|
353
|
-
|
|
354
|
-
|
|
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
|
-
|
|
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')
|
|
364
|
-
if (props.pasteEnabled) list.push({ key: 'paste', label: t('ctx.paste')
|
|
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
|
-
|
|
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(...
|
|
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
|
|
@@ -397,6 +744,45 @@ function withKey(label: string, key: string): string {
|
|
|
397
744
|
return combo ? `${label} (${combo})` : label;
|
|
398
745
|
}
|
|
399
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
|
+
|
|
400
786
|
const drivePlaceholder = computed(() =>
|
|
401
787
|
props.scopeLabel
|
|
402
788
|
? t('drive.search.placeholder', { scope: props.scopeLabel })
|
|
@@ -413,15 +799,98 @@ function onSearchKeydown(ev: KeyboardEvent) {
|
|
|
413
799
|
ev.preventDefault();
|
|
414
800
|
ev.stopPropagation();
|
|
415
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);
|
|
416
811
|
}
|
|
417
812
|
}
|
|
418
813
|
|
|
419
|
-
/*
|
|
420
|
-
*
|
|
421
|
-
*
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
814
|
+
/* === gorunum:v4-hostmenu — a 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
|
+
});
|
|
425
894
|
|
|
426
895
|
function openMore() {
|
|
427
896
|
const r = moreBtnEl.value?.getBoundingClientRect();
|
|
@@ -460,8 +929,19 @@ function onMoreSelect(a: ContextAction) {
|
|
|
460
929
|
case 'shortcut-settings' /* wiring:c2 */:
|
|
461
930
|
emit('open-shortcut-settings');
|
|
462
931
|
break;
|
|
932
|
+
case 'timezone' /* zaman:z3 */:
|
|
933
|
+
emit('open-timezone');
|
|
934
|
+
break;
|
|
463
935
|
case 'tour' /* wiring:c4 — bubbles to the FileExplorer root listener */:
|
|
464
|
-
|
|
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 }));
|
|
465
945
|
break;
|
|
466
946
|
default:
|
|
467
947
|
fire(a.key);
|
|
@@ -473,28 +953,66 @@ function onMoreSelect(a: ContextAction) {
|
|
|
473
953
|
|
|
474
954
|
<template>
|
|
475
955
|
<div
|
|
956
|
+
ref="rootEl"
|
|
476
957
|
class="fe-toolbar"
|
|
477
958
|
:class="{
|
|
478
959
|
'fe-toolbar--narrow': narrow /* bag:b4 */,
|
|
479
|
-
|
|
480
|
-
|
|
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,
|
|
481
965
|
}"
|
|
482
966
|
>
|
|
483
|
-
<!--
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
967
|
+
<!-- gorunum:v3-shell — the 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. -->
|
|
490
1009
|
<button
|
|
491
1010
|
v-if="navEnabled !== false"
|
|
492
1011
|
type="button"
|
|
493
1012
|
class="fe-btn fe-btn--icon-only fe-toolbar__nav"
|
|
494
|
-
:class="{ 'is-active': navOpen }"
|
|
495
1013
|
:aria-pressed="!!navOpen"
|
|
496
|
-
:title="
|
|
497
|
-
:aria-label="
|
|
1014
|
+
:title="navToggleLabel /* gezinti:g2 — a verb, and the state's verb */"
|
|
1015
|
+
:aria-label="navToggleLabel"
|
|
498
1016
|
data-testid="toolbar-nav"
|
|
499
1017
|
@click="emit('toggle-nav')"
|
|
500
1018
|
>
|
|
@@ -508,79 +1026,80 @@ function onMoreSelect(a: ContextAction) {
|
|
|
508
1026
|
aria-hidden="true"
|
|
509
1027
|
focusable="false"
|
|
510
1028
|
>
|
|
511
|
-
<
|
|
512
|
-
<path d="M9.5 4.5v15" />
|
|
1029
|
+
<path d="M4 7h16M4 12h16M4 17h16" />
|
|
513
1030
|
</svg>
|
|
514
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>
|
|
515
1046
|
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
@click="emit('go-up')"
|
|
523
|
-
>
|
|
524
|
-
<span class="fe-icon">↑</span>
|
|
525
|
-
</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.
|
|
526
1053
|
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
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. -->
|
|
530
1076
|
<button
|
|
531
|
-
v-if="
|
|
1077
|
+
v-if="navEnabled === false && mode === 'none' && !trashActive && !atVirtualRoot && canWrite !== false"
|
|
532
1078
|
type="button"
|
|
533
1079
|
class="fe-btn fe-btn--primary"
|
|
534
1080
|
:title="withKey(t('toolbar.new_folder'), 'new-folder')"
|
|
535
1081
|
@click="emit('new-folder')"
|
|
536
1082
|
>
|
|
537
|
-
|
|
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>
|
|
538
1085
|
<span class="fe-btn__label">{{ t('toolbar.new_folder') }}</span>
|
|
539
1086
|
</button>
|
|
540
|
-
|
|
541
|
-
<button
|
|
542
|
-
v-for="a in visibleToolbarItems"
|
|
543
|
-
:key="a.key"
|
|
544
|
-
type="button"
|
|
545
|
-
class="fe-btn fe-btn--fold"
|
|
546
|
-
:class="{ 'fe-btn--danger': a.danger, 'is-disabled': a.disabled }"
|
|
547
|
-
:disabled="a.disabled"
|
|
548
|
-
:title="withKey(a.label, a.key)"
|
|
549
|
-
@click="fire(a.key)"
|
|
550
|
-
>
|
|
551
|
-
<span class="fe-icon">{{ a.icon }}</span>
|
|
552
|
-
<span class="fe-btn__label">{{ a.label }}</span>
|
|
553
|
-
</button>
|
|
554
|
-
|
|
555
|
-
<!-- ui-fix — actions that don't fit fold into the ⋯ menu so the row stays
|
|
556
|
-
a single line (search/view controls no longer drop to a second row). -->
|
|
557
1087
|
<button
|
|
558
|
-
v-if="
|
|
559
|
-
ref="wideMoreBtnEl"
|
|
1088
|
+
v-if="navEnabled === false && !atVirtualRoot && canWrite !== false"
|
|
560
1089
|
type="button"
|
|
561
1090
|
class="fe-btn fe-btn--icon-only"
|
|
562
|
-
:title="t('toolbar.
|
|
563
|
-
:aria-label="t('toolbar.
|
|
564
|
-
|
|
565
|
-
@click="openWideMore"
|
|
1091
|
+
:title="withKey(t('toolbar.upload'), 'upload')"
|
|
1092
|
+
:aria-label="t('toolbar.upload')"
|
|
1093
|
+
@click="emit('upload')"
|
|
566
1094
|
>
|
|
567
|
-
|
|
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>
|
|
568
1097
|
</button>
|
|
569
1098
|
|
|
570
|
-
<!--
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
v-for="a in toolbarItems"
|
|
575
|
-
:key="'m-' + a.key"
|
|
576
|
-
type="button"
|
|
577
|
-
class="fe-btn"
|
|
578
|
-
tabindex="-1"
|
|
579
|
-
>
|
|
580
|
-
<span class="fe-icon">{{ a.icon }}</span>
|
|
581
|
-
<span class="fe-btn__label">{{ a.label }}</span>
|
|
582
|
-
</button>
|
|
583
|
-
</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. -->
|
|
584
1103
|
|
|
585
1104
|
<button
|
|
586
1105
|
v-if="pasteEnabled && mode === 'none' && !trashActive && !atVirtualRoot && canWrite !== false"
|
|
@@ -589,17 +1108,24 @@ function onMoreSelect(a: ContextAction) {
|
|
|
589
1108
|
:title="withKey(t('ctx.paste'), 'paste')"
|
|
590
1109
|
@click="fire('paste')"
|
|
591
1110
|
>
|
|
592
|
-
|
|
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>
|
|
593
1113
|
<span class="fe-btn__label">{{ t('ctx.paste') }}</span>
|
|
594
1114
|
</button>
|
|
595
1115
|
</div>
|
|
596
1116
|
|
|
597
|
-
<div class="fe-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
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">
|
|
603
1129
|
<svg
|
|
604
1130
|
class="fe-ficon fe-drivesearch__glass"
|
|
605
1131
|
viewBox="0 0 24 24"
|
|
@@ -616,13 +1142,27 @@ function onMoreSelect(a: ContextAction) {
|
|
|
616
1142
|
<input
|
|
617
1143
|
ref="searchEl"
|
|
618
1144
|
type="search"
|
|
619
|
-
class="fe-drivesearch__input"
|
|
1145
|
+
class="fe-search__input fe-drivesearch__input"
|
|
620
1146
|
:placeholder="drivePlaceholder"
|
|
621
1147
|
:value="localSearch"
|
|
622
1148
|
:aria-label="t('toolbar.search')"
|
|
623
1149
|
@input="onSearchInput"
|
|
624
1150
|
@keydown="onSearchKeydown"
|
|
625
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>
|
|
626
1166
|
<button
|
|
627
1167
|
type="button"
|
|
628
1168
|
class="fe-drivesearch__kbd"
|
|
@@ -634,41 +1174,69 @@ function onMoreSelect(a: ContextAction) {
|
|
|
634
1174
|
{{ paletteCombo }}
|
|
635
1175
|
</button>
|
|
636
1176
|
</div>
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
:value="localSearch"
|
|
644
|
-
:aria-label="t('toolbar.search') /* wiring:c4 — was hardcoded EN */"
|
|
645
|
-
@input="onSearchInput"
|
|
646
|
-
/>
|
|
647
|
-
</div>
|
|
648
|
-
<button
|
|
649
|
-
v-if="shell !== 'drive' && !atVirtualRoot && canWrite !== false"
|
|
650
|
-
type="button"
|
|
651
|
-
class="fe-btn fe-btn--icon-only"
|
|
652
|
-
:title="withKey(t('toolbar.upload'), 'upload')"
|
|
653
|
-
:aria-label="t('toolbar.upload') /* wiring:c4 */"
|
|
654
|
-
@click="emit('upload')"
|
|
655
|
-
>
|
|
656
|
-
<span class="fe-icon" aria-hidden="true">⬆</span>
|
|
657
|
-
</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">
|
|
658
1183
|
<button
|
|
659
1184
|
type="button"
|
|
660
1185
|
class="fe-btn fe-btn--icon-only"
|
|
661
1186
|
:title="withKey(t('toolbar.refresh'), 'refresh')"
|
|
662
|
-
:aria-label="t('toolbar.refresh')
|
|
1187
|
+
:aria-label="t('toolbar.refresh')"
|
|
663
1188
|
@click="emit('refresh')"
|
|
664
1189
|
>
|
|
665
|
-
|
|
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>
|
|
666
1192
|
</button>
|
|
667
|
-
<!--
|
|
668
|
-
|
|
669
|
-
click
|
|
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. -->
|
|
670
1238
|
<button
|
|
671
|
-
v-if="
|
|
1239
|
+
v-if="menuActions.length > 0"
|
|
672
1240
|
ref="moreBtnEl"
|
|
673
1241
|
type="button"
|
|
674
1242
|
class="fe-btn fe-btn--icon-only"
|
|
@@ -678,149 +1246,28 @@ function onMoreSelect(a: ContextAction) {
|
|
|
678
1246
|
data-testid="drive-more"
|
|
679
1247
|
@click="openMore"
|
|
680
1248
|
>
|
|
681
|
-
|
|
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>
|
|
682
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>
|
|
683
1256
|
</div>
|
|
684
|
-
|
|
685
|
-
<button
|
|
686
|
-
v-if="shell !== 'drive'"
|
|
687
|
-
type="button"
|
|
688
|
-
class="fe-btn fe-btn--icon-only fe-toolbar__density"
|
|
689
|
-
:class="{ 'is-active': density === 'compact' }"
|
|
690
|
-
:aria-pressed="density === 'compact'"
|
|
691
|
-
:aria-label="density === 'compact' ? t('toolbar.density.comfortable') : t('toolbar.density.compact')"
|
|
692
|
-
:title="density === 'compact' ? t('toolbar.density.comfortable') : t('toolbar.density.compact')"
|
|
693
|
-
@click="toggleDensity"
|
|
694
|
-
>
|
|
695
|
-
<!-- Row-spacing glyph: tight rows when compact is ON, airy rows otherwise. -->
|
|
696
|
-
<svg
|
|
697
|
-
v-if="density === 'compact'"
|
|
698
|
-
class="fe-ficon"
|
|
699
|
-
viewBox="0 0 24 24"
|
|
700
|
-
fill="none"
|
|
701
|
-
stroke="currentColor"
|
|
702
|
-
stroke-width="1.8"
|
|
703
|
-
stroke-linecap="round"
|
|
704
|
-
aria-hidden="true"
|
|
705
|
-
focusable="false"
|
|
706
|
-
>
|
|
707
|
-
<path d="M4 6.5h16M4 10.5h16M4 14.5h16M4 18.5h16" />
|
|
708
|
-
</svg>
|
|
709
|
-
<svg
|
|
710
|
-
v-else
|
|
711
|
-
class="fe-ficon"
|
|
712
|
-
viewBox="0 0 24 24"
|
|
713
|
-
fill="none"
|
|
714
|
-
stroke="currentColor"
|
|
715
|
-
stroke-width="1.8"
|
|
716
|
-
stroke-linecap="round"
|
|
717
|
-
aria-hidden="true"
|
|
718
|
-
focusable="false"
|
|
719
|
-
>
|
|
720
|
-
<path d="M4 5.5h16M4 12h16M4 18.5h16" />
|
|
721
|
-
</svg>
|
|
722
|
-
</button>
|
|
723
|
-
|
|
724
|
-
<!-- koru:k1 — inspector (details panel) toggle.
|
|
725
|
-
surucu:d1 — the drive shell renders it on the breadcrumb row instead,
|
|
726
|
-
beside the view switcher, where both belong to the listing. -->
|
|
727
|
-
<button
|
|
728
|
-
v-if="shell !== 'drive'"
|
|
729
|
-
type="button"
|
|
730
|
-
class="fe-btn fe-btn--icon-only fe-toolbar__inspector"
|
|
731
|
-
:class="{ 'is-active': inspectorOpen }"
|
|
732
|
-
:aria-pressed="!!inspectorOpen"
|
|
733
|
-
:title="withKey(t('toolbar.inspector'), 'inspector')"
|
|
734
|
-
:aria-label="t('toolbar.inspector')"
|
|
735
|
-
@click="emit('toggle-inspector')"
|
|
736
|
-
>
|
|
737
|
-
<svg
|
|
738
|
-
class="fe-ficon"
|
|
739
|
-
viewBox="0 0 24 24"
|
|
740
|
-
fill="none"
|
|
741
|
-
stroke="currentColor"
|
|
742
|
-
stroke-width="1.8"
|
|
743
|
-
stroke-linecap="round"
|
|
744
|
-
aria-hidden="true"
|
|
745
|
-
focusable="false"
|
|
746
|
-
>
|
|
747
|
-
<circle cx="12" cy="12" r="9" />
|
|
748
|
-
<path d="M12 11v5" />
|
|
749
|
-
<circle cx="12" cy="7.6" r="1" fill="currentColor" stroke="none" />
|
|
750
|
-
</svg>
|
|
751
|
-
</button>
|
|
752
|
-
|
|
753
|
-
<!-- wiring:c1 — tema galerisi (palet ikonu) -->
|
|
754
|
-
<button
|
|
755
|
-
v-if="shell !== 'drive'"
|
|
756
|
-
type="button"
|
|
757
|
-
class="fe-btn fe-btn--icon-only fe-toolbar__theme"
|
|
758
|
-
:title="t('theme.menu')"
|
|
759
|
-
:aria-label="t('theme.menu')"
|
|
760
|
-
@click="emit('open-theme')"
|
|
761
|
-
>
|
|
762
|
-
<svg
|
|
763
|
-
class="fe-ficon"
|
|
764
|
-
viewBox="0 0 24 24"
|
|
765
|
-
fill="none"
|
|
766
|
-
stroke="currentColor"
|
|
767
|
-
stroke-width="1.8"
|
|
768
|
-
stroke-linecap="round"
|
|
769
|
-
stroke-linejoin="round"
|
|
770
|
-
aria-hidden="true"
|
|
771
|
-
focusable="false"
|
|
772
|
-
>
|
|
773
|
-
<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" />
|
|
774
|
-
<circle cx="7.8" cy="10.2" r="1.15" fill="currentColor" stroke="none" />
|
|
775
|
-
<circle cx="11.6" cy="7.2" r="1.15" fill="currentColor" stroke="none" />
|
|
776
|
-
<circle cx="16" cy="8.6" r="1.15" fill="currentColor" stroke="none" />
|
|
777
|
-
</svg>
|
|
778
|
-
</button>
|
|
779
|
-
<!-- /wiring:c1 -->
|
|
780
|
-
|
|
781
|
-
<ViewSwitcher
|
|
782
|
-
v-if="shell !== 'drive'"
|
|
783
|
-
:view-mode="viewMode"
|
|
784
|
-
:locale="locale"
|
|
785
|
-
:modes="viewModes"
|
|
786
|
-
@update:view-mode="emit('update:viewMode', $event)"
|
|
787
|
-
/>
|
|
788
1257
|
</template>
|
|
789
1258
|
|
|
790
|
-
<!-- surucu:d1 — narrow
|
|
791
|
-
The
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
the ⌘K chip goes (CSS)
|
|
797
|
-
in the "⋯" menu.
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
class="fe-btn fe-btn--icon-only fe-toolbar__nav"
|
|
803
|
-
:class="{ 'is-active': navOpen }"
|
|
804
|
-
:aria-pressed="!!navOpen"
|
|
805
|
-
:title="t('toolbar.nav')"
|
|
806
|
-
:aria-label="t('toolbar.nav')"
|
|
807
|
-
data-testid="toolbar-nav"
|
|
808
|
-
@click="emit('toggle-nav')"
|
|
809
|
-
>
|
|
810
|
-
<svg
|
|
811
|
-
class="fe-ficon"
|
|
812
|
-
viewBox="0 0 24 24"
|
|
813
|
-
fill="none"
|
|
814
|
-
stroke="currentColor"
|
|
815
|
-
stroke-width="1.8"
|
|
816
|
-
stroke-linecap="round"
|
|
817
|
-
aria-hidden="true"
|
|
818
|
-
focusable="false"
|
|
819
|
-
>
|
|
820
|
-
<rect x="3.5" y="4.5" width="17" height="15" rx="2" />
|
|
821
|
-
<path d="M9.5 4.5v15" />
|
|
822
|
-
</svg>
|
|
823
|
-
</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>
|
|
824
1271
|
<div class="fe-drivesearch" data-testid="drive-search">
|
|
825
1272
|
<svg
|
|
826
1273
|
class="fe-ficon fe-drivesearch__glass"
|
|
@@ -845,6 +1292,23 @@ function onMoreSelect(a: ContextAction) {
|
|
|
845
1292
|
@input="onSearchInput"
|
|
846
1293
|
@keydown="onSearchKeydown"
|
|
847
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>
|
|
848
1312
|
<button
|
|
849
1313
|
type="button"
|
|
850
1314
|
class="fe-drivesearch__kbd"
|
|
@@ -856,141 +1320,165 @@ function onMoreSelect(a: ContextAction) {
|
|
|
856
1320
|
{{ paletteCombo }}
|
|
857
1321
|
</button>
|
|
858
1322
|
</div>
|
|
859
|
-
<
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
</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>
|
|
888
1351
|
<button
|
|
1352
|
+
v-if="menuActions.length > 0"
|
|
1353
|
+
ref="moreBtnEl"
|
|
889
1354
|
type="button"
|
|
890
1355
|
class="fe-btn fe-btn--icon-only"
|
|
891
|
-
:title="t('toolbar.
|
|
892
|
-
:aria-label="t('toolbar.
|
|
893
|
-
|
|
1356
|
+
:title="t('toolbar.more')"
|
|
1357
|
+
:aria-label="t('toolbar.more')"
|
|
1358
|
+
aria-haspopup="menu"
|
|
1359
|
+
data-testid="drive-more"
|
|
1360
|
+
@click="openMore"
|
|
894
1361
|
>
|
|
895
|
-
|
|
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>
|
|
896
1364
|
</button>
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
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)"
|
|
923
1418
|
>
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
</
|
|
927
|
-
</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>
|
|
928
1422
|
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
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>
|
|
939
1437
|
|
|
940
|
-
|
|
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>
|
|
941
1459
|
|
|
942
1460
|
<button
|
|
943
1461
|
type="button"
|
|
944
|
-
class="fe-
|
|
945
|
-
:
|
|
946
|
-
:
|
|
947
|
-
|
|
948
|
-
@click="
|
|
1462
|
+
class="fe-selbar__close"
|
|
1463
|
+
:title="t('selection.clear')"
|
|
1464
|
+
:aria-label="t('selection.clear')"
|
|
1465
|
+
data-testid="selection-clear"
|
|
1466
|
+
@click="clearSelection"
|
|
949
1467
|
>
|
|
950
|
-
|
|
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>
|
|
951
1470
|
</button>
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
type="button"
|
|
955
|
-
class="fe-btn fe-btn--icon-only"
|
|
956
|
-
:title="withKey(t('toolbar.upload'), 'upload')"
|
|
957
|
-
:aria-label="t('toolbar.upload')"
|
|
958
|
-
@click="emit('upload')"
|
|
959
|
-
>
|
|
960
|
-
<span class="fe-icon">⬆</span>
|
|
961
|
-
</button>
|
|
962
|
-
<button
|
|
963
|
-
ref="moreBtnEl"
|
|
964
|
-
type="button"
|
|
965
|
-
class="fe-btn fe-btn--icon-only fe-toolbar__more"
|
|
966
|
-
:title="t('toolbar.more')"
|
|
967
|
-
:aria-label="t('toolbar.more')"
|
|
968
|
-
aria-haspopup="menu"
|
|
969
|
-
@click="openMore"
|
|
970
|
-
>
|
|
971
|
-
<span class="fe-icon">⋯</span>
|
|
972
|
-
</button>
|
|
973
|
-
</template>
|
|
974
|
-
|
|
975
|
-
<ContextMenu
|
|
976
|
-
ref="moreRef"
|
|
977
|
-
:locale="locale"
|
|
978
|
-
:theme="theme || 'auto'"
|
|
979
|
-
:sheet="coarse"
|
|
980
|
-
:actions="menuActions"
|
|
981
|
-
@select="onMoreSelect"
|
|
982
|
-
/>
|
|
983
|
-
</template>
|
|
1471
|
+
</div>
|
|
1472
|
+
</Teleport>
|
|
984
1473
|
|
|
985
|
-
<!-- ui-fix — ⋯ menu for the actions folded away in wide mode -->
|
|
986
1474
|
<ContextMenu
|
|
987
|
-
|
|
988
|
-
ref="wideMoreRef"
|
|
1475
|
+
ref="selMoreRef"
|
|
989
1476
|
:locale="locale"
|
|
990
1477
|
:theme="theme || 'auto'"
|
|
991
|
-
:sheet="
|
|
992
|
-
:actions="
|
|
1478
|
+
:sheet="coarse"
|
|
1479
|
+
:actions="barMenuItems"
|
|
993
1480
|
@select="(a) => fire(a.key)"
|
|
994
1481
|
/>
|
|
1482
|
+
<!-- === /gorunum:v1-selbar === -->
|
|
995
1483
|
</div>
|
|
996
1484
|
</template>
|