@brftech/filex-core 0.4.2 → 0.5.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 (51) hide show
  1. package/dist/{ArchiveViewer-B_0IQExT.js → ArchiveViewer-DKW-HyXT.js} +2 -2
  2. package/dist/{ArchiveViewer-B_0IQExT.js.map → ArchiveViewer-DKW-HyXT.js.map} +1 -1
  3. package/dist/{CsvViewer-maMfUy5i.js → CsvViewer-D2HmP2yr.js} +2 -2
  4. package/dist/{CsvViewer-maMfUy5i.js.map → CsvViewer-D2HmP2yr.js.map} +1 -1
  5. package/dist/{DrawioViewer-DP_pXI00.js → DrawioViewer-DPNvOFCs.js} +2 -2
  6. package/dist/{DrawioViewer-DP_pXI00.js.map → DrawioViewer-DPNvOFCs.js.map} +1 -1
  7. package/dist/{EpubViewer-DSaqGnVx.js → EpubViewer-ByqjNuNL.js} +2 -2
  8. package/dist/{EpubViewer-DSaqGnVx.js.map → EpubViewer-ByqjNuNL.js.map} +1 -1
  9. package/dist/{IpynbViewer-BmdBanF1.js → IpynbViewer-2iONhbuJ.js} +2 -2
  10. package/dist/{IpynbViewer-BmdBanF1.js.map → IpynbViewer-2iONhbuJ.js.map} +1 -1
  11. package/dist/{MermaidViewer-Drlkl0Z5.js → MermaidViewer-CmiU7hIT.js} +2 -2
  12. package/dist/{MermaidViewer-Drlkl0Z5.js.map → MermaidViewer-CmiU7hIT.js.map} +1 -1
  13. package/dist/{PsdViewer-gftUJrsm.js → PsdViewer-Inhl2yN9.js} +2 -2
  14. package/dist/{PsdViewer-gftUJrsm.js.map → PsdViewer-Inhl2yN9.js.map} +1 -1
  15. package/dist/{TiffViewer-0Uv0-Lxh.js → TiffViewer-C2AFFEze.js} +2 -2
  16. package/dist/{TiffViewer-0Uv0-Lxh.js.map → TiffViewer-C2AFFEze.js.map} +1 -1
  17. package/dist/{Viewer3D-Gu3kUpxV.js → Viewer3D-YdW14q_n.js} +2 -2
  18. package/dist/{Viewer3D-Gu3kUpxV.js.map → Viewer3D-YdW14q_n.js.map} +1 -1
  19. package/dist/filex-core.js +47 -25
  20. package/dist/filex-core.umd.cjs +40 -40
  21. package/dist/filex-core.umd.cjs.map +1 -1
  22. package/dist/index-eARevpz0.js +9952 -0
  23. package/dist/index-eARevpz0.js.map +1 -0
  24. package/dist/index.d.ts +363 -3
  25. package/dist/style.css +1 -1
  26. package/package.json +1 -1
  27. package/src/FileExplorer.vue +269 -1
  28. package/src/components/CommandPalette.vue +12 -0
  29. package/src/components/ContextMenu.vue +114 -8
  30. package/src/components/GridView.vue +33 -1
  31. package/src/components/ListView.vue +39 -6
  32. package/src/components/OnboardingTour.vue +332 -0
  33. package/src/components/OperationsCenter.vue +284 -0
  34. package/src/components/PendingOpsTray.vue +50 -66
  35. package/src/components/QuickLook.vue +142 -0
  36. package/src/components/ShortcutSettings.vue +270 -0
  37. package/src/components/ShortcutsHelp.vue +30 -9
  38. package/src/components/ThemeGallery.vue +253 -0
  39. package/src/components/Toolbar.vue +52 -6
  40. package/src/components/UploadProgress.vue +49 -57
  41. package/src/composables/useKeyboardShortcuts.ts +321 -137
  42. package/src/composables/useOperations.ts +291 -0
  43. package/src/index.ts +45 -0
  44. package/src/lib/dragGhost.ts +41 -0
  45. package/src/lib/themes.ts +505 -0
  46. package/src/locales/en.ts +92 -0
  47. package/src/locales/tr.ts +92 -0
  48. package/src/modals/Modal.vue +41 -5
  49. package/src/styles/base.css +672 -0
  50. package/dist/index-BLocdbmc.js +0 -7993
  51. package/dist/index-BLocdbmc.js.map +0 -1
@@ -50,6 +50,21 @@ import InspectorPanel from './components/InspectorPanel.vue'; /* koru:k1 */
50
50
  import CommandPalette from './components/CommandPalette.vue';
51
51
  import ShortcutsHelp from './components/ShortcutsHelp.vue';
52
52
  /* /cila:c wiring */
53
+ /* wiring:c1 — tema galerisi */
54
+ import ThemeGallery from './components/ThemeGallery.vue';
55
+ import { useThemeState, applyThemeToEl, syncThemeStyle } from './lib/themes';
56
+ /* /wiring:c1 */
57
+ /* wiring:c2 — shortcut settings modal + Space quick-look overlay */
58
+ import ShortcutSettings from './components/ShortcutSettings.vue';
59
+ import QuickLook from './components/QuickLook.vue';
60
+ /* /wiring:c2 */
61
+ /* wiring:c3 — unified operations center */
62
+ import OperationsCenter from './components/OperationsCenter.vue';
63
+ import { useOperations } from './composables/useOperations';
64
+ /* /wiring:c3 */
65
+ /* wiring:c4 */
66
+ import OnboardingTour from './components/OnboardingTour.vue';
67
+ /* /wiring:c4 */
53
68
 
54
69
  import NewFolderModal from './modals/NewFolderModal.vue';
55
70
  import RenameModal from './modals/RenameModal.vue';
@@ -1099,6 +1114,7 @@ useKeyboardShortcuts(rootEl, {
1099
1114
  showShortcutsHelp.value = true;
1100
1115
  },
1101
1116
  /* /cila:c wiring */
1117
+ onQuickLook: () => quickLookToggle() /* wiring:c2 */,
1102
1118
  onToggleInspector: () => toggleInspector() /* koru:k1 */,
1103
1119
  hasSelection: () => !selection.isEmpty.value,
1104
1120
  });
@@ -2052,6 +2068,183 @@ async function onCopyPath(adapterPath: string) {
2052
2068
  function buildAuthHeaders(extra: Record<string, string> = {}) {
2053
2069
  return api.authHeadersSync({ ...extra });
2054
2070
  }
2071
+
2072
+ /* === wiring:c1 — tema galerisi ===
2073
+ * Selected theme (shared module state, localStorage `filex.theme`) is applied
2074
+ * as inline `--fe-*` variables on the explorer root, resolved to the active
2075
+ * light/dark variant — plus a mirrored injected stylesheet for the surfaces
2076
+ * that re-declare tokens (teleported context menu, modal backdrops). Theme
2077
+ * selection is independent from the light/dark mode: the mode only picks
2078
+ * WHICH variant of the theme paints. */
2079
+ const showThemeGallery = ref(false);
2080
+ const { themeId: activeThemeId, setTheme: setActiveTheme } = useThemeState();
2081
+ // Resolved mode: explicit config wins, otherwise the OS preference — the same
2082
+ // logic variables.css encodes in CSS. The inline root variables beat every
2083
+ // stylesheet rule, so they must track this resolution at runtime.
2084
+ const themeMq =
2085
+ typeof window !== 'undefined' && window.matchMedia
2086
+ ? window.matchMedia('(prefers-color-scheme: dark)')
2087
+ : undefined;
2088
+ const themeOsDark = ref(!!themeMq?.matches);
2089
+ function onThemeMqChange(e: MediaQueryListEvent) {
2090
+ themeOsDark.value = e.matches;
2091
+ }
2092
+ onMounted(() => themeMq?.addEventListener?.('change', onThemeMqChange));
2093
+ onBeforeUnmount(() => themeMq?.removeEventListener?.('change', onThemeMqChange));
2094
+ const themeResolvedDark = computed(
2095
+ () => props.config.theme === 'dark' || (props.config.theme !== 'light' && themeOsDark.value),
2096
+ );
2097
+ watch(
2098
+ [activeThemeId, themeResolvedDark, rootEl],
2099
+ () => {
2100
+ syncThemeStyle(activeThemeId.value);
2101
+ if (rootEl.value) applyThemeToEl(rootEl.value, activeThemeId.value, themeResolvedDark.value);
2102
+ },
2103
+ { immediate: true },
2104
+ );
2105
+ /* === /wiring:c1 === */
2106
+ /* === wiring:c2 — shortcut settings modal + Space quick-look ===
2107
+ * quickLookTarget follows the selection while the peek is open: arrow
2108
+ * keys emitted by QuickLook move the selection here, and the watcher
2109
+ * below syncs the previewed file. Space toggles (registry action
2110
+ * `quicklook`); Enter promotes the peek into the normal open flow. */
2111
+ const showShortcutSettings = ref(false);
2112
+ const quickLookOpen = ref(false);
2113
+ const quickLookTarget = ref<FileNode | null>(null);
2114
+
2115
+ function quickLookToggle() {
2116
+ if (quickLookOpen.value) {
2117
+ quickLookOpen.value = false;
2118
+ return;
2119
+ }
2120
+ const sel = selection.nodes.value;
2121
+ const n = sel.length === 1 ? sel[0] : null;
2122
+ if (!n || n.type !== 'file' || n.basename === '.trash') return;
2123
+ quickLookTarget.value = n;
2124
+ quickLookOpen.value = true;
2125
+ void markRecent(n);
2126
+ }
2127
+
2128
+ function quickLookNav(delta: number) {
2129
+ const onlyFiles = files.value.filter((f) => f.type === 'file');
2130
+ if (onlyFiles.length === 0) return;
2131
+ const cur = quickLookTarget.value;
2132
+ let idx = cur ? onlyFiles.findIndex((f) => f.path === cur.path) : -1;
2133
+ idx = idx === -1 ? (delta > 0 ? 0 : onlyFiles.length - 1) : (idx + delta + onlyFiles.length) % onlyFiles.length;
2134
+ const next = onlyFiles[idx];
2135
+ if (!next || next.path === cur?.path) return;
2136
+ selection.click(next.path);
2137
+ }
2138
+
2139
+ function quickLookOpenFull() {
2140
+ const n = quickLookTarget.value;
2141
+ quickLookOpen.value = false;
2142
+ if (n) openNode(n);
2143
+ }
2144
+
2145
+ watch(
2146
+ () => selection.nodes.value,
2147
+ (nodes) => {
2148
+ if (!quickLookOpen.value) return;
2149
+ const n = nodes.length === 1 && nodes[0].type === 'file' ? nodes[0] : null;
2150
+ if (n && n.path !== quickLookTarget.value?.path) {
2151
+ quickLookTarget.value = n;
2152
+ void markRecent(n);
2153
+ }
2154
+ },
2155
+ );
2156
+ /* === /wiring:c2 === */
2157
+ /* wiring:c3 — operations center store + failed-upload retry */
2158
+ const opsCenter = useOperations();
2159
+
2160
+ /**
2161
+ * Retry a failed upload from the operations center. The failed row is
2162
+ * already retired by the store; re-run the upload against the job's ORIGINAL
2163
+ * target folder (the user may have navigated away since) via the legacy
2164
+ * single-POST path — works for any storage / size, no chunked precondition.
2165
+ */
2166
+ function retryUploadJob(job: UploadJob) {
2167
+ uploadJobs.value = uploadJobs.value.filter((j) => j.id !== job.id);
2168
+ const file = job.file;
2169
+ const target = job.path || qualify(currentPath.value);
2170
+ const id = crypto.randomUUID();
2171
+ uploadJobs.value = [
2172
+ ...uploadJobs.value,
2173
+ { id, file, path: target, totalBytes: file.size, uploadedBytes: 0, percent: 0, status: 'uploading', cancel() {} },
2174
+ ];
2175
+ const patchRetry = (p: Partial<UploadJob>) => {
2176
+ const idx = uploadJobs.value.findIndex((j) => j.id === id);
2177
+ if (idx === -1) return;
2178
+ const next = [...uploadJobs.value];
2179
+ next[idx] = { ...next[idx], ...p };
2180
+ uploadJobs.value = next;
2181
+ };
2182
+ api
2183
+ .uploadMultipart(target, [file], (percent) => {
2184
+ patchRetry({ percent, uploadedBytes: Math.round((percent / 100) * file.size) });
2185
+ emit('upload-progress', { uploadId: id, percent, done: percent >= 100 });
2186
+ })
2187
+ .then(() => {
2188
+ patchRetry({ percent: 100, uploadedBytes: file.size, status: 'done' });
2189
+ emit('upload-progress', { uploadId: id, percent: 100, done: true });
2190
+ void load();
2191
+ })
2192
+ .catch((err: Error) => {
2193
+ patchRetry({ status: 'error', error: err.message });
2194
+ emit('error', { message: err.message, context: { op: 'upload-retry', file: file.name } });
2195
+ });
2196
+ }
2197
+ /* /wiring:c3 */
2198
+ /* === wiring:c4 — onboarding coach-mark tour ===
2199
+ * First mount with no `filex.tourDone` flag auto-starts the tour (short
2200
+ * delay so the listing/toolbar are laid out). Closing it — finished OR
2201
+ * skipped — stamps the flag; "Turu tekrar başlat" re-opens it any time.
2202
+ * Restart arrives as a bubbled `fe:tour-restart` CustomEvent from the
2203
+ * Toolbar overflow menu, so no extra prop/emit threading through the
2204
+ * shared component tags is needed. */
2205
+ const TOUR_LS_KEY = 'filex.tourDone';
2206
+ const showTour = ref(false);
2207
+ let tourTimer: ReturnType<typeof setTimeout> | undefined;
2208
+
2209
+ function tourAlreadyDone(): boolean {
2210
+ try {
2211
+ return localStorage.getItem(TOUR_LS_KEY) === '1';
2212
+ } catch {
2213
+ return true; // no storage → never auto-nag
2214
+ }
2215
+ }
2216
+
2217
+ function startTour() {
2218
+ showTour.value = true;
2219
+ }
2220
+
2221
+ function onTourClose() {
2222
+ showTour.value = false;
2223
+ try {
2224
+ localStorage.setItem(TOUR_LS_KEY, '1');
2225
+ } catch {
2226
+ /* private mode / quota */
2227
+ }
2228
+ rootEl.value?.focus();
2229
+ }
2230
+
2231
+ function onTourRestartEvent() {
2232
+ startTour();
2233
+ }
2234
+
2235
+ onMounted(() => {
2236
+ rootEl.value?.addEventListener('fe:tour-restart', onTourRestartEvent);
2237
+ if (!tourAlreadyDone()) {
2238
+ tourTimer = setTimeout(() => {
2239
+ if (!showTour.value) startTour();
2240
+ }, 900);
2241
+ }
2242
+ });
2243
+ onBeforeUnmount(() => {
2244
+ if (tourTimer) clearTimeout(tourTimer);
2245
+ rootEl.value?.removeEventListener('fe:tour-restart', onTourRestartEvent);
2246
+ });
2247
+ /* === /wiring:c4 === */
2055
2248
  </script>
2056
2249
 
2057
2250
  <template>
@@ -2089,9 +2282,11 @@ function buildAuthHeaders(extra: Record<string, string> = {}) {
2089
2282
  :theme="config.theme || 'auto' /* bag:b4 */"
2090
2283
  :inspector-open="showInspector /* koru:k1 */"
2091
2284
  @toggle-inspector="toggleInspector /* koru:k1 */"
2285
+ @open-theme="showThemeGallery = true /* wiring:c1 */"
2092
2286
  @update:view-mode="viewMode = $event"
2093
2287
  @update:search-query="searchQuery = $event"
2094
2288
  @update:density="density = $event"
2289
+ @open-shortcut-settings="showShortcutSettings = true /* wiring:c2 */"
2095
2290
  @new-folder="showNewFolder = true"
2096
2291
  @upload="triggerUpload"
2097
2292
  @refresh="() => load()"
@@ -2204,12 +2399,19 @@ function buildAuthHeaders(extra: Record<string, string> = {}) {
2204
2399
  <path d="M24 88h72" stroke-dasharray="3 5" />
2205
2400
  </svg>
2206
2401
  <p class="fe-state__title">{{ t('error.title') }}</p>
2207
- <p class="fe-state__hint">{{ loadError }}</p>
2402
+ <!-- wiring:c4 friendly hint + collapsible technical detail; the raw
2403
+ error message used to sit in the hint slot and read like UI copy. -->
2404
+ <p class="fe-state__hint">{{ t('error.hint') }}</p>
2208
2405
  <div class="fe-state__actions">
2209
2406
  <button type="button" class="fe-btn fe-btn--primary" @click="retryLoad">
2210
2407
  {{ t('error.retry') }}
2211
2408
  </button>
2212
2409
  </div>
2410
+ <details class="fe-state__details">
2411
+ <summary class="fe-state__details-summary">{{ t('error.details') }}</summary>
2412
+ <pre class="fe-state__details-pre">{{ loadError }}</pre>
2413
+ </details>
2414
+ <!-- /wiring:c4 -->
2213
2415
  </div>
2214
2416
  <!-- Search with zero hits — its own message, not "folder is empty". -->
2215
2417
  <div v-else-if="!loading && files.length === 0 && searchQuery" class="fe-state">
@@ -2345,19 +2547,33 @@ function buildAuthHeaders(extra: Record<string, string> = {}) {
2345
2547
  </div>
2346
2548
  </div>
2347
2549
 
2550
+ <!-- wiring:c3 — unified operations center. UploadProgress + PendingOpsTray
2551
+ no longer draw their own corner UIs: they are renderless publishers
2552
+ feeding the opsCenter store; the single visible surface is the
2553
+ OperationsCenter badge + panel below. -->
2348
2554
  <UploadProgress
2349
2555
  :jobs="uploadJobs"
2350
2556
  :locale="locale"
2557
+ :center="opsCenter"
2351
2558
  @cancel="onCancelUpload"
2352
2559
  @dismiss="onDismissUpload"
2560
+ @retry="retryUploadJob"
2353
2561
  />
2354
2562
 
2355
2563
  <PendingOpsTray
2356
2564
  :ops="pendingOps.ops.value"
2357
2565
  :locale="locale"
2566
+ :center="opsCenter"
2358
2567
  @dismiss="(id) => pendingOps.dismiss(id)"
2359
2568
  />
2360
2569
 
2570
+ <OperationsCenter
2571
+ :center="opsCenter"
2572
+ :locale="locale"
2573
+ :narrow="isNarrow"
2574
+ />
2575
+ <!-- /wiring:c3 -->
2576
+
2361
2577
  <!-- bag:b4 — narrow-mode upload FAB (hidden in trash / read-only /
2362
2578
  virtual root; PendingOpsTray+UploadProgress shift up via CSS). -->
2363
2579
  <button
@@ -2534,14 +2750,30 @@ function buildAuthHeaders(extra: Record<string, string> = {}) {
2534
2750
  @open-trash="loadTrash"
2535
2751
  @refresh="() => load()"
2536
2752
  @go-up="goUp"
2753
+ @open-theme="showThemeGallery = true /* wiring:int */"
2754
+ @open-shortcut-settings="showShortcutSettings = true /* wiring:int */"
2755
+ @start-tour="startTour() /* wiring:int */"
2537
2756
  />
2538
2757
  <ShortcutsHelp
2539
2758
  :open="showShortcutsHelp"
2540
2759
  :locale="locale"
2541
2760
  @close="showShortcutsHelp = false"
2761
+ @customize="showShortcutsHelp = false; showShortcutSettings = true /* wiring:c2 */"
2542
2762
  />
2543
2763
  <!-- /cila:c wiring -->
2544
2764
 
2765
+ <!-- wiring:c1 — tema galerisi -->
2766
+ <ThemeGallery
2767
+ :open="showThemeGallery"
2768
+ :locale="locale"
2769
+ :theme="config.theme || 'auto'"
2770
+ :dark="themeResolvedDark"
2771
+ :current="activeThemeId"
2772
+ @close="showThemeGallery = false"
2773
+ @select="setActiveTheme"
2774
+ />
2775
+ <!-- /wiring:c1 -->
2776
+
2545
2777
  <input
2546
2778
  ref="fileInputEl"
2547
2779
  type="file"
@@ -2567,6 +2799,42 @@ function buildAuthHeaders(extra: Record<string, string> = {}) {
2567
2799
  >{{ toast.actionLabel }}</button>
2568
2800
  </div>
2569
2801
  </transition>
2802
+
2803
+ <!-- wiring:c2 — shortcut settings modal + Space quick-look overlay -->
2804
+ <ShortcutSettings
2805
+ :open="showShortcutSettings"
2806
+ :locale="locale"
2807
+ :theme="config.theme || 'auto'"
2808
+ @close="showShortcutSettings = false"
2809
+ />
2810
+ <QuickLook
2811
+ :open="quickLookOpen"
2812
+ :locale="locale"
2813
+ :file="quickLookTarget"
2814
+ :theme="config.theme || 'auto'"
2815
+ :preview-url="(p: string) => api.previewUrl(p)"
2816
+ :download-url="(p: string) => api.downloadUrl(p)"
2817
+ :only-office-base="effectiveOnlyOfficeBase"
2818
+ :only-office-config-endpoint="effectiveOnlyOfficeConfigEndpoint"
2819
+ :auth-headers="() => buildAuthHeaders({ 'Content-Type': 'application/json' })"
2820
+ :auth-credentials="api.credentialsMode()"
2821
+ :drawio-url="effectiveDrawioUrl"
2822
+ :pdf-worker-url="props.config.pdfWorkerUrl || null"
2823
+ :viewer-base-url="props.config.viewerBaseUrl || null"
2824
+ @close="quickLookOpen = false"
2825
+ @nav="quickLookNav"
2826
+ @open-full="quickLookOpenFull"
2827
+ />
2828
+ <!-- /wiring:c2 -->
2829
+ <!-- wiring:c4 — onboarding coach-mark tour (teleports itself to body) -->
2830
+ <OnboardingTour
2831
+ :open="showTour"
2832
+ :locale="locale"
2833
+ :root="rootEl"
2834
+ :theme="config.theme || 'auto'"
2835
+ @close="onTourClose"
2836
+ />
2837
+ <!-- /wiring:c4 -->
2570
2838
  </div>
2571
2839
  </template>
2572
2840
 
@@ -53,6 +53,10 @@ const emit = defineEmits<{
53
53
  (e: 'go-up'): void;
54
54
  /* bul:s3 — an "everywhere" hit was chosen. */
55
55
  (e: 'open-hit', hit: GlobalSearchHit): void;
56
+ /* wiring:int — settings surfaces reachable from anywhere via the palette */
57
+ (e: 'open-theme'): void;
58
+ (e: 'open-shortcut-settings'): void;
59
+ (e: 'start-tour'): void;
56
60
  }>();
57
61
 
58
62
  const { t, nodeDisplayName } = useLocale(() => props.locale);
@@ -126,6 +130,10 @@ const commandItems = computed<PaletteItem[]>(() => {
126
130
  { command: 'open-trash', label: t('cmd.trash'), icon: '🗑', enabled: true },
127
131
  { command: 'refresh', label: t('toolbar.refresh'), icon: '⟳', enabled: true },
128
132
  { command: 'go-up', label: t('toolbar.go_up'), icon: '↑', enabled: props.canGoUp !== false },
133
+ /* wiring:int */
134
+ { command: 'open-theme', label: t('theme.menu'), icon: '🎨', enabled: true },
135
+ { command: 'open-shortcut-settings', label: t('shortcuts.settings.menu'), icon: '⌨', enabled: true },
136
+ { command: 'start-tour', label: t('tour.restart'), icon: '🎓', enabled: true },
129
137
  ];
130
138
  return defs
131
139
  .filter((d) => d.enabled && matchScore(d.label, q) >= 0)
@@ -310,6 +318,10 @@ function choose(item?: PaletteItem) {
310
318
  case 'open-trash': emit('open-trash'); break;
311
319
  case 'refresh': emit('refresh'); break;
312
320
  case 'go-up': emit('go-up'); break;
321
+ /* wiring:int */
322
+ case 'open-theme': emit('open-theme'); break;
323
+ case 'open-shortcut-settings': emit('open-shortcut-settings'); break;
324
+ case 'start-tour': emit('start-tour'); break;
313
325
  }
314
326
  }
315
327
  }
@@ -54,10 +54,23 @@ const y = ref(0);
54
54
  const targetNodes = ref<FileNode[]>([]);
55
55
  const menuEl = ref<HTMLElement | null>(null);
56
56
 
57
+ /* wiring:c4 — where the pointer opened the menu; kept separate from x/y so
58
+ * the edge-flip can re-anchor ABOVE/LEFT of the cursor instead of merely
59
+ * clamping (a clamp near the bottom edge used to leave the menu covering
60
+ * the clicked row). */
61
+ let anchorX = 0;
62
+ let anchorY = 0;
63
+ /* wiring:c4 — focus restore: whatever had focus before the menu opened gets
64
+ * it back on hide, so keyboard users don't fall back to <body>. */
65
+ let prevFocus: HTMLElement | null = null;
66
+
57
67
  async function show(ev: { clientX: number; clientY: number }, nodes: FileNode[]) {
68
+ prevFocus = (document.activeElement as HTMLElement | null) ?? null; /* wiring:c4 */
58
69
  open.value = true;
59
70
  x.value = ev.clientX;
60
71
  y.value = ev.clientY;
72
+ anchorX = ev.clientX; /* wiring:c4 */
73
+ anchorY = ev.clientY; /* wiring:c4 */
61
74
  targetNodes.value = nodes;
62
75
  // After the menu renders, clamp it inside the viewport so a click near the
63
76
  // bottom/right edge doesn't push the menu off-screen (it opens down-right
@@ -70,6 +83,7 @@ async function show(ev: { clientX: number; clientY: number }, nodes: FileNode[])
70
83
  return;
71
84
  }
72
85
  clampToViewport();
86
+ focusItem(0); /* wiring:c4 — keyboard nav starts on the first item */
73
87
  }
74
88
 
75
89
  function clampToViewport() {
@@ -79,11 +93,16 @@ function clampToViewport() {
79
93
  const margin = 8;
80
94
  const vw = window.innerWidth;
81
95
  const vh = window.innerHeight;
82
- if (y.value + rect.height > vh - margin) {
83
- y.value = Math.max(margin, vh - rect.height - margin);
96
+ /* wiring:c4 edge flip: overflowing the bottom/right edge re-anchors the
97
+ * menu above/left of the cursor when there's room; clamping stays as the
98
+ * fallback for tiny viewports. */
99
+ if (anchorY + rect.height > vh - margin) {
100
+ const flipped = anchorY - rect.height;
101
+ y.value = flipped >= margin ? flipped : Math.max(margin, vh - rect.height - margin);
84
102
  }
85
- if (x.value + rect.width > vw - margin) {
86
- x.value = Math.max(margin, vw - rect.width - margin);
103
+ if (anchorX + rect.width > vw - margin) {
104
+ const flipped = anchorX - rect.width;
105
+ x.value = flipped >= margin ? flipped : Math.max(margin, vw - rect.width - margin);
87
106
  }
88
107
  }
89
108
 
@@ -92,7 +111,42 @@ function hide() {
92
111
  /* bag:b4 — reset any in-flight sheet drag so reopening starts clean. */
93
112
  sheetDragging.value = false;
94
113
  sheetDragY.value = 0;
114
+ /* wiring:c4 — give focus back to the opener (row/card/⋯ button). */
115
+ prevFocus?.focus?.();
116
+ prevFocus = null;
117
+ }
118
+
119
+ /* === wiring:c4 — keyboard navigation (menu + sheet) ===
120
+ * Arrow keys cycle through enabled items, Home/End jump, Enter/Space
121
+ * activate the focused <button> natively, Esc closes (see onKey). Disabled
122
+ * buttons are unfocusable, so the query skips them by construction. */
123
+ function menuItems(): HTMLElement[] {
124
+ const host = props.sheet ? sheetEl.value : menuEl.value;
125
+ if (!host) return [];
126
+ return Array.from(
127
+ host.querySelectorAll<HTMLElement>('.fe-ctx__item:not(:disabled)'),
128
+ );
129
+ }
130
+
131
+ function focusItem(idx: number) {
132
+ const items = menuItems();
133
+ if (items.length === 0) return;
134
+ const i = ((idx % items.length) + items.length) % items.length;
135
+ items[i]?.focus();
136
+ }
137
+
138
+ function moveFocus(delta: number) {
139
+ const items = menuItems();
140
+ if (items.length === 0) return;
141
+ const active = document.activeElement as HTMLElement | null;
142
+ const cur = active ? items.indexOf(active) : -1;
143
+ if (cur === -1) {
144
+ focusItem(delta > 0 ? 0 : items.length - 1);
145
+ return;
146
+ }
147
+ focusItem(cur + delta);
95
148
  }
149
+ /* === /wiring:c4 === */
96
150
 
97
151
  /* bag:b4 — sheet drag-to-dismiss. Bound to the grab handle only, so the
98
152
  * scrollable item list keeps its native touch scrolling. Dragging past the
@@ -139,10 +193,60 @@ function pick(a: ContextAction) {
139
193
  }
140
194
 
141
195
  function onKey(e: KeyboardEvent) {
142
- if (e.key === 'Escape') hide();
196
+ if (e.key === 'Escape') {
197
+ // Scoped close: Esc with the menu open closes ONLY the menu — the
198
+ // explorer's global onClose (which also dismisses modals/toasts)
199
+ // shouldn't fire underneath.
200
+ e.stopPropagation();
201
+ hide();
202
+ return;
203
+ }
204
+ if (e.key === 'Enter' || e.key === ' ') {
205
+ // Native button activation handles the pick; stop the bubble so the
206
+ // explorer's global Enter=open shortcut doesn't double-fire and pop
207
+ // the preview modal on top of the chosen action.
208
+ e.stopPropagation();
209
+ return;
210
+ }
211
+ /* wiring:c4 — arrow-key navigation; stopPropagation keeps the explorer's
212
+ * global shortcut handler (Enter=open, Backspace=up…) out of the loop
213
+ * while the menu is up. */
214
+ if (e.key === 'ArrowDown') {
215
+ e.preventDefault();
216
+ e.stopPropagation();
217
+ moveFocus(1);
218
+ } else if (e.key === 'ArrowUp') {
219
+ e.preventDefault();
220
+ e.stopPropagation();
221
+ moveFocus(-1);
222
+ } else if (e.key === 'Home') {
223
+ e.preventDefault();
224
+ focusItem(0);
225
+ } else if (e.key === 'End') {
226
+ e.preventDefault();
227
+ focusItem(-1);
228
+ } else if (e.key === 'Tab') {
229
+ // A menu is not a tab-stop container — Tab closes it (WAI-ARIA menu
230
+ // pattern) instead of tabbing behind the backdrop.
231
+ e.preventDefault();
232
+ hide();
233
+ }
143
234
  }
144
235
 
145
- const visibleActions = computed(() => props.actions.filter((a) => !a.hidden));
236
+ /* wiring:c4 hidden-entry filtering used to leave orphaned separators
237
+ * (leading/trailing/doubled) when the entries around a divider were hidden
238
+ * by RBAC or selection shape. Collapse them here so every visible divider
239
+ * actually separates two groups. */
240
+ const visibleActions = computed(() => {
241
+ const vis = props.actions.filter((a) => !a.hidden);
242
+ const out: ContextAction[] = [];
243
+ for (const a of vis) {
244
+ if (a.divider && (out.length === 0 || out[out.length - 1].divider)) continue;
245
+ out.push(a);
246
+ }
247
+ while (out.length > 0 && out[out.length - 1].divider) out.pop();
248
+ return out;
249
+ });
146
250
 
147
251
  const prefersDark = ref(false);
148
252
  let mq: MediaQueryList | undefined;
@@ -204,7 +308,7 @@ defineExpose({ show, hide });
204
308
  />
205
309
  <div class="fe-sheet__items">
206
310
  <template v-for="(a, i) in visibleActions" :key="a.key || i">
207
- <div v-if="a.divider" class="fe-ctx__sep" />
311
+ <div v-if="a.divider" class="fe-ctx__sep" role="separator" />
208
312
  <button
209
313
  v-else
210
314
  type="button"
@@ -225,11 +329,13 @@ defineExpose({ show, hide });
225
329
  ref="menuEl"
226
330
  class="fe-ctx"
227
331
  role="menu"
332
+ aria-orientation="vertical"
333
+ :aria-label="t('sheet.menu') /* wiring:c4 */"
228
334
  :style="{ top: y + 'px', left: x + 'px' }"
229
335
  @click.stop
230
336
  >
231
337
  <template v-for="(a, i) in visibleActions" :key="a.key || i">
232
- <div v-if="a.divider" class="fe-ctx__sep" />
338
+ <div v-if="a.divider" class="fe-ctx__sep" role="separator" />
233
339
  <button
234
340
  v-else
235
341
  type="button"
@@ -2,11 +2,13 @@
2
2
  /**
3
3
  * GridView — card grid. Thumbnails preferred, fall back to icon.
4
4
  */
5
+ import { ref } from 'vue'; /* wiring:c4 */
5
6
  import type { FileNode } from '../types/FileNode';
6
7
  import type { LocaleCode } from '../types/ExplorerConfig';
7
8
  import { useLocale } from '../composables/useLocale';
8
9
  import { fileIconSvg } from '../lib/fileIcons';
9
10
  import { snippetSegments } from '../lib/snippet'; /* bul:s3 */
11
+ import { applyDragGhost } from '../lib/dragGhost'; /* wiring:c4 */
10
12
 
11
13
  const props = defineProps<{
12
14
  files: FileNode[];
@@ -56,18 +58,34 @@ function onCtx(n: FileNode, ev: MouseEvent) {
56
58
  }
57
59
 
58
60
  function onItemDragStart(n: FileNode, ev: DragEvent) {
61
+ /* wiring:c4 — custom ghost (name + multi-select count badge), visual only. */
62
+ applyDragGhost(
63
+ ev,
64
+ nodeDisplayName(n),
65
+ props.selected.has(n.path) ? props.selected.size : 1,
66
+ );
59
67
  emit('item-drag-start', n, ev);
60
68
  }
61
69
 
70
+ /* wiring:c4 — droptarget highlight, mirrors ListView (visual layer only). */
71
+ const dropTargetPath = ref<string | null>(null);
72
+
62
73
  function onItemDragOver(n: FileNode, ev: DragEvent) {
63
74
  if (n.type !== 'dir') return;
64
75
  if (!ev.dataTransfer?.types.includes('application/x-brf-files')) return;
65
76
  ev.preventDefault();
66
77
  ev.stopPropagation();
67
78
  if (ev.dataTransfer) ev.dataTransfer.dropEffect = 'move';
79
+ dropTargetPath.value = n.path; /* wiring:c4 */
80
+ }
81
+
82
+ /* wiring:c4 */
83
+ function onItemDragLeave(n: FileNode) {
84
+ if (dropTargetPath.value === n.path) dropTargetPath.value = null;
68
85
  }
69
86
 
70
87
  function onItemDrop(n: FileNode, ev: DragEvent) {
88
+ dropTargetPath.value = null; /* wiring:c4 */
71
89
  if (n.type !== 'dir') return;
72
90
  if (!ev.dataTransfer?.types.includes('application/x-brf-files')) return;
73
91
  ev.preventDefault();
@@ -128,7 +146,16 @@ function snippetTitle(snippet: string): string {
128
146
  </script>
129
147
 
130
148
  <template>
131
- <div class="fe-grid" :class="{ 'is-loading': loading }">
149
+ <!-- wiring:c4 — listbox semantics (multi-selectable cards as options);
150
+ localized label + busy state. Structure/layout untouched. -->
151
+ <div
152
+ class="fe-grid"
153
+ :class="{ 'is-loading': loading }"
154
+ role="listbox"
155
+ aria-multiselectable="true"
156
+ :aria-label="t('grid.aria')"
157
+ :aria-busy="loading ? 'true' : undefined"
158
+ >
132
159
  <div
133
160
  v-for="n in files"
134
161
  :key="n.path"
@@ -138,14 +165,19 @@ function snippetTitle(snippet: string): string {
138
165
  'is-dir': n.type === 'dir',
139
166
  'is-trash': n.trashed,
140
167
  'is-clipped': clipped?.has(n.path),
168
+ 'is-droptarget': dropTargetPath === n.path /* wiring:c4 */,
141
169
  }"
142
170
  tabindex="0"
171
+ role="option"
172
+ :aria-selected="isSelected(n) ? 'true' : 'false'"
173
+ :aria-label="nodeDisplayName(n) /* wiring:c4 */"
143
174
  draggable="true"
144
175
  @click="onClick(n, $event)"
145
176
  @dblclick="onDbl(n)"
146
177
  @contextmenu="onCtx(n, $event)"
147
178
  @dragstart="onItemDragStart(n, $event)"
148
179
  @dragover="onItemDragOver(n, $event)"
180
+ @dragleave="onItemDragLeave(n) /* wiring:c4 */"
149
181
  @drop="onItemDrop(n, $event)"
150
182
  @touchstart.passive="onTouchStart(n, $event)"
151
183
  @touchend="cancelPress"