@brftech/filex-core 0.39.1 → 0.41.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (125) hide show
  1. package/README.md +32 -25
  2. package/dist/ArchiveViewer-BNqukFg8.js +124 -0
  3. package/dist/ArchiveViewer-BNqukFg8.js.map +1 -0
  4. package/dist/CsvViewer-ii_-MgmG.js +141 -0
  5. package/dist/CsvViewer-ii_-MgmG.js.map +1 -0
  6. package/dist/{DrawioViewer-BNALOB04.js → DrawioViewer-B2tuu4rX.js} +48 -42
  7. package/dist/DrawioViewer-B2tuu4rX.js.map +1 -0
  8. package/dist/EpubViewer-p4B8iiWb.js +163 -0
  9. package/dist/EpubViewer-p4B8iiWb.js.map +1 -0
  10. package/dist/IpynbViewer-D_qYRJhu.js +184 -0
  11. package/dist/IpynbViewer-D_qYRJhu.js.map +1 -0
  12. package/dist/MermaidViewer-3ZkfNM8v.js +137 -0
  13. package/dist/MermaidViewer-3ZkfNM8v.js.map +1 -0
  14. package/dist/PsdViewer-CuV-OPSd.js +122 -0
  15. package/dist/PsdViewer-CuV-OPSd.js.map +1 -0
  16. package/dist/TiffViewer-BLeoF4Be.js +142 -0
  17. package/dist/TiffViewer-BLeoF4Be.js.map +1 -0
  18. package/dist/Viewer3D-DHhhU76E.js +75 -0
  19. package/dist/Viewer3D-DHhhU76E.js.map +1 -0
  20. package/dist/filex-core.js +217 -20077
  21. package/dist/filex-core.js.map +1 -1
  22. package/dist/filex-core.umd.cjs +61 -62
  23. package/dist/filex-core.umd.cjs.map +1 -1
  24. package/dist/index-BraG7Cz4.js +26664 -0
  25. package/dist/index-BraG7Cz4.js.map +1 -0
  26. package/dist/index.d.ts +4276 -851
  27. package/dist/style.css +1 -1
  28. package/package.json +1 -1
  29. package/src/FileExplorer.vue +2170 -492
  30. package/src/components/AdvancedSearch.vue +870 -0
  31. package/src/components/Breadcrumb.vue +191 -19
  32. package/src/components/CommandPalette.vue +66 -22
  33. package/src/components/ConnectionsPanel.vue +6 -2
  34. package/src/components/ContextMenu.vue +57 -2
  35. package/src/components/EncryptedFolderModal.vue +31 -25
  36. package/src/components/FilePane.vue +939 -0
  37. package/src/components/FilterBar.vue +568 -72
  38. package/src/components/GalleryView.vue +162 -30
  39. package/src/components/GridView.vue +322 -58
  40. package/src/components/HomeView.vue +331 -0
  41. package/src/components/InspectorPanel.vue +296 -53
  42. package/src/components/ListView.vue +1158 -167
  43. package/src/components/NFSExportsPanel.vue +7 -4
  44. package/src/components/OnboardingTour.vue +156 -33
  45. package/src/components/QuickLook.vue +34 -5
  46. package/src/components/RecentlyOpened.vue +17 -2
  47. package/src/components/S3KeysPanel.vue +7 -4
  48. package/src/components/SSHKeysPanel.vue +7 -4
  49. package/src/components/ShortcutSettings.vue +43 -2
  50. package/src/components/ShortcutsHelp.vue +4 -1
  51. package/src/components/SideNav.vue +206 -140
  52. package/src/components/StorageFields.vue +19 -1
  53. package/src/components/TabBar.vue +87 -1
  54. package/src/components/TagPicker.vue +77 -6
  55. package/src/components/ThemeGallery.vue +13 -198
  56. package/src/components/ThemePalette.vue +244 -0
  57. package/src/components/TimeZoneDialog.vue +127 -0
  58. package/src/components/TimeZonePicker.vue +696 -0
  59. package/src/components/TokensPanel.vue +16 -4
  60. package/src/components/Toolbar.vue +965 -469
  61. package/src/components/ViewSwitcher.vue +9 -4
  62. package/src/composables/useConnections.ts +35 -4
  63. package/src/composables/useExplorerTimeZone.ts +85 -0
  64. package/src/composables/useFileApi.ts +53 -4
  65. package/src/composables/useKeyboardShortcuts.ts +122 -0
  66. package/src/composables/useLocale.ts +365 -25
  67. package/src/index.ts +182 -3
  68. package/src/lib/actionIcons.ts +330 -0
  69. package/src/lib/advSearch.ts +162 -0
  70. package/src/lib/dateGroups.ts +197 -0
  71. package/src/lib/destinationTree.ts +235 -0
  72. package/src/lib/downloadSelection.ts +154 -0
  73. package/src/lib/fileFilters.ts +319 -12
  74. package/src/lib/fileIcons.ts +299 -5
  75. package/src/lib/filePreview.ts +605 -0
  76. package/src/lib/listing.ts +72 -0
  77. package/src/lib/shareTtl.ts +11 -1
  78. package/src/lib/sortOrder.ts +459 -0
  79. package/src/lib/themes.ts +152 -23
  80. package/src/lib/timezone.ts +456 -0
  81. package/src/lib/uiProfile.ts +82 -0
  82. package/src/lib/viewPrefs.ts +1036 -0
  83. package/src/locales/en.ts +432 -25
  84. package/src/locales/tr.ts +425 -25
  85. package/src/modals/ConvertModal.vue +23 -3
  86. package/src/modals/DestinationPickerModal.vue +341 -0
  87. package/src/modals/Modal.vue +18 -4
  88. package/src/modals/NewDocumentModal.vue +525 -0
  89. package/src/modals/NewFolderModal.vue +15 -9
  90. package/src/modals/PermissionsModal.vue +543 -473
  91. package/src/modals/PreviewModal.vue +516 -156
  92. package/src/styles/base.css +5435 -8
  93. package/src/styles/variables.css +140 -60
  94. package/src/types/Connections.ts +9 -0
  95. package/src/types/ExplorerConfig.ts +144 -39
  96. package/src/types/FileNode.ts +28 -0
  97. package/src/viewers/ArchiveViewer.vue +46 -8
  98. package/src/viewers/CsvViewer.vue +19 -2
  99. package/src/viewers/DrawioViewer.vue +12 -1
  100. package/src/viewers/EpubViewer.vue +50 -6
  101. package/src/viewers/IpynbViewer.vue +19 -2
  102. package/src/viewers/MermaidViewer.vue +20 -3
  103. package/src/viewers/PdfViewer.vue +24 -4
  104. package/src/viewers/PsdViewer.vue +28 -4
  105. package/src/viewers/TiffViewer.vue +19 -2
  106. package/src/viewers/Viewer3D.vue +36 -2
  107. package/dist/ArchiveViewer-Cdfv9LFC.js +0 -99
  108. package/dist/ArchiveViewer-Cdfv9LFC.js.map +0 -1
  109. package/dist/CsvViewer-CqWeV8VO.js +0 -131
  110. package/dist/CsvViewer-CqWeV8VO.js.map +0 -1
  111. package/dist/DrawioViewer-BNALOB04.js.map +0 -1
  112. package/dist/EpubViewer-BPmlImG7.js +0 -145
  113. package/dist/EpubViewer-BPmlImG7.js.map +0 -1
  114. package/dist/IpynbViewer-CDlQmuCV.js +0 -175
  115. package/dist/IpynbViewer-CDlQmuCV.js.map +0 -1
  116. package/dist/MermaidViewer-sfZWdR-d.js +0 -127
  117. package/dist/MermaidViewer-sfZWdR-d.js.map +0 -1
  118. package/dist/PsdViewer-DwuySZsb.js +0 -112
  119. package/dist/PsdViewer-DwuySZsb.js.map +0 -1
  120. package/dist/TiffViewer-BDQ3d3CQ.js +0 -132
  121. package/dist/TiffViewer-BDQ3d3CQ.js.map +0 -1
  122. package/dist/Viewer3D-CJJJgkuv.js +0 -60
  123. package/dist/Viewer3D-CJJJgkuv.js.map +0 -1
  124. package/src/components/SecondaryPane.vue +0 -447
  125. package/src/modals/ShareModal.vue +0 -139
@@ -3,33 +3,82 @@
3
3
  * FilterBar — surucu:d1, the chip row under the breadcrumb in the `drive`
4
4
  * profile.
5
5
  *
6
- * Three chips: Type · Modified · Size. Each opens a small single-choice
6
+ * Four chips: Type · People · Modified · Size. Each opens a small single-choice
7
7
  * popover; a chip with a choice made carries that choice as its label, so the
8
8
  * row says what is being filtered without opening anything.
9
9
  *
10
- * ⚠ THREE, not the four the mockup draws. There is no **People** chip because
11
- * there is nothing behind one: a listing row carries no owner
12
- * (`projectFileNodes` emits id/path/basename/type/extension/size/mime_type/
13
- * storage/etag/perm/thumb_url/last_modified and nothing else), `nodes.owner_id`
14
- * is quota bookkeeping that is nil for everything a sync discovered, and the
15
- * listing endpoint reads no owner parameter. A People chip would be a control
16
- * that opens, offers names, and changes nothing see docs and the report on
17
- * this branch; the advanced-search round owns that question.
10
+ * ⚠ Type comes FIRST. The row used to open with People, which put the one
11
+ * chip that is empty in a single-account install in front of the one every
12
+ * listing has an answer for.
13
+ *
14
+ * It was THREE for a long time, and the reason was written here: there was
15
+ * nothing behind a People chip, because a listing row carried no owner and
16
+ * `nodes.owner_id` was quota bookkeeping nobody serialized. That is no longer
17
+ * true migration 00038 made ownership a fact on the row and the listing
18
+ * projection carries it — so the chip now filters on data the row already has,
19
+ * like the other three. If ownership is ever taken back off the wire, this chip
20
+ * has to go with it: a control that opens, offers names and changes nothing is
21
+ * worse than an absent one.
22
+ *
23
+ * ⚠ The People options are NOT a directory listing. `Anyone`, `You` and
24
+ * `System` are always offered; the named accounts are only the ones that
25
+ * actually own something in the rows on screen (`peopleOptions`). Offering
26
+ * every account on the install would put inert names in the menu and leak the
27
+ * user list into a folder view.
28
+ *
29
+ * === surucu:d1-sort — the trailing edge ===============================
30
+ * Two controls at the right of the same row: a button that NAMES the active
31
+ * sort and flips its direction, and a chevron whose menu picks the key.
32
+ *
33
+ * ⚠⚠ They do not own a sort. Both reach for `lib/sortOrder`'s store for the
34
+ * PANE they are inside, the same one the list view's own column headers reach
35
+ * for, so the chip row and the headers are two handles on ONE piece of state
36
+ * — sort by Size here and
37
+ * the Size column's arrow moves, click the Name header and this button says
38
+ * Name. A control with its own private copy of the answer is filex lesson #67
39
+ * one level up, and it is what this row was one commit away from becoming.
40
+ *
41
+ * ⚠⚠ …and while a SEARCH RESULT is on screen there is no key to name. The
42
+ * rows were ranked by the server (`order === 'relevance'`, see
43
+ * `lib/sortOrder`), so this button prints "Relevance" instead of the key,
44
+ * drops its direction arrow, and closes — with the reason in its own tooltip
45
+ * and accessible name. A button reading "Name ↑" over a list that is not in
46
+ * name order is the failure this whole row is otherwise built to avoid, and a
47
+ * control that simply went inert would be the same lie without the wording.
48
+ * The key itself is untouched: it is still there the moment the search is
49
+ * left.
50
+ *
51
+ * ⚠ Deliberately NOT here: the reference build's "Listing actions" ⋮ that sits
52
+ * beside these two. Its menu is New folder / New file / Upload files / Paste /
53
+ * Select all / Deselect all — six commands this component cannot perform and
54
+ * has no handle on. Drawing the menu here would mean six items that open,
55
+ * offer a word and do nothing, which is the failure the People note above is
56
+ * about. It needs events from the explorer; see the report.
18
57
  *
19
58
  * ⚠ Popovers are TELEPORTED to <body>, like ContextMenu, and positioned
20
59
  * `fixed`. An absolutely-positioned panel inside the explorer is clipped by
21
60
  * `.fe__body`'s own scroll container, which is how a menu ends up half visible
22
- * with its own scrollbar.
61
+ * with its own scrollbar. ONE popover machine serves the chips and the sort
62
+ * menu — same positioning, same outside-click, same Escape — because two of
63
+ * them is two chances for a panel to be left open behind the other.
23
64
  *
24
65
  * ⚠ No `<style>` block. Package CSS lives in `styles/base.css` — a scoped block
25
66
  * compiles to `.cls[data-v-HASH]` and the hash does not match in the
26
67
  * web-component build, so the rules silently stop applying in every embed.
27
68
  */
28
- import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue';
69
+ import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
29
70
  import { useLocale } from '../composables/useLocale';
30
71
  import type { LocaleCode, ThemeMode } from '../types/ExplorerConfig';
72
+ import type { FileNode } from '../types/FileNode';
31
73
  import type { DriveFilters, ModifiedFilter, SizeFilter, TypeFilter } from '../lib/fileFilters';
32
- import { activeFilterCount } from '../lib/fileFilters';
74
+ import { activeFilterCount, peopleOptions } from '../lib/fileFilters';
75
+ import {
76
+ SORT_KEYS,
77
+ setSortLocale,
78
+ useSortStore,
79
+ type ListingOrder,
80
+ type SortKey,
81
+ } from '../lib/sortOrder';
33
82
 
34
83
  const props = defineProps<{
35
84
  value: DriveFilters;
@@ -39,13 +88,87 @@ const props = defineProps<{
39
88
  /** Rows currently shown / rows the folder holds, for the count chip. */
40
89
  shown?: number;
41
90
  total?: number;
91
+ /**
92
+ * The rows the People chip derives its named accounts from — the listing
93
+ * BEFORE filtering, so choosing a person does not empty the menu that
94
+ * offered them.
95
+ *
96
+ * ⚠ Optional. Without it the chip still offers Anyone / You / System, which
97
+ * are the three that never depend on data; an embedder who passes nothing
98
+ * gets a working control rather than a broken one.
99
+ */
100
+ files?: FileNode[];
101
+ /* === surucu:d1-actions — the "⋮" listing menu ======================
102
+ * ⚠ Every row in it is a command this component cannot run: the clipboard,
103
+ * the selection and the write permissions all live in the explorer. So the
104
+ * menu takes its two ENABLED states as props and emits the verbs, and the
105
+ * host decides whether a row is reachable. A menu that draws "Paste" alive
106
+ * over an empty clipboard is a menu that lies once per folder. */
107
+ /** There is something on the clipboard to paste here. */
108
+ canPaste?: boolean;
109
+ /** This folder can be written to (false at a read-only root, in the trash). */
110
+ canWrite?: boolean;
111
+ /**
112
+ * Where the listing behind this row got its order — see `lib/sortOrder`.
113
+ * `relevance` (a search result) closes the sort control and makes it name
114
+ * the order it is actually in. Omitted = an ordinary folder listing, which
115
+ * is what every embedder that never searches has.
116
+ */
117
+ order?: ListingOrder;
118
+ /* === surucu:d1-scope — the row where there is nothing to chip ==========
119
+ *
120
+ * ⚠⚠ `find` is not "a smaller filter bar". It is this row over a listing
121
+ * whose rows are not files: the multi-storage ROOT (every row is a drive)
122
+ * and HOME (three blocks of cards). Type / People / Modified / Size all
123
+ * answer from `extension`, `mime_type`, `owner_id`, `size` and
124
+ * `last_modified`, and a drive has none of the five — the chips would open,
125
+ * offer ten words and narrow nothing, which is the exact failure the People
126
+ * note above is about. The name box is the one dimension those rows DO
127
+ * have, so it is the one that stays.
128
+ *
129
+ * ⚠ And it stays REACHABLE, rather than the row being dropped: "root
130
+ * folder'da filtre barı kalsın, en kötü o bardan search kalacak — orada adam
131
+ * isterse storage ismi aratabilir" (owner, 2026-09-13). A box that filters
132
+ * nothing would be worse than no box; this one filters the rows in hand
133
+ * through the same `DriveFilters.name` predicate every other listing uses.
134
+ */
135
+ mode?: 'full' | 'find';
136
+ /**
137
+ * What the name box says it narrows. The default names a FOLDER, which is
138
+ * wrong in both `find` places — neither the drive list nor Home is one.
139
+ * Passed already translated: the pane knows which of the two it is, the
140
+ * catalogue lookup belongs with the caller that knows.
141
+ */
142
+ findLabel?: string;
42
143
  }>();
43
144
 
44
- const emit = defineEmits<{ (e: 'update:value', v: DriveFilters): void }>();
145
+ const emit = defineEmits<{
146
+ (e: 'update:value', v: DriveFilters): void;
147
+ /* surucu:d1-actions — the listing's own verbs. Names match the events the
148
+ navigation panel already emits for the same two commands, so the host
149
+ wires one handler for each rather than one per menu. */
150
+ (e: 'new-folder'): void;
151
+ (e: 'upload'): void;
152
+ (e: 'paste'): void;
153
+ (e: 'select-all'): void;
154
+ }>();
45
155
 
46
156
  const { t } = useLocale(() => props.locale);
47
157
 
48
- type Group = 'type' | 'modified' | 'size';
158
+ /** surucu:d1-scope everything except the name box is drawn only in `full`. */
159
+ const full = computed(() => (props.mode ?? 'full') === 'full');
160
+ /** The name box's placeholder AND its accessible name — one value, read twice,
161
+ * so the two cannot describe different things. */
162
+ const findLabel = computed(() => props.findLabel || t('filter.find'));
163
+
164
+ type Group = 'people' | 'type' | 'modified' | 'size';
165
+ /** Everything this row can open. `sort` is not a filter group — it shares the
166
+ * popover machine, not the filter model. */
167
+ type PopId = Group | 'sort' | 'actions';
168
+
169
+ /** The chips, in the order they are drawn. One array, so the template loop and
170
+ * `optionsFor` cannot disagree about how many there are. */
171
+ const GROUPS: Group[] = ['type', 'people', 'modified', 'size'];
49
172
 
50
173
  const TYPE_OPTIONS: TypeFilter[] = [
51
174
  'any', 'folder', 'document', 'spreadsheet', 'presentation', 'pdf',
@@ -54,42 +177,171 @@ const TYPE_OPTIONS: TypeFilter[] = [
54
177
  const MODIFIED_OPTIONS: ModifiedFilter[] = ['any', 'today', '7d', '30d', 'year'];
55
178
  const SIZE_OPTIONS: SizeFilter[] = ['any', 'lt1', '1to10', '10to100', 'gt100'];
56
179
 
180
+ /** Anyone / You / System, plus every other account owning a row on screen. */
181
+ const peopleChoices = computed(() => peopleOptions(props.files ?? []));
182
+
57
183
  function optionsFor(g: Group): string[] {
184
+ if (g === 'people') return peopleChoices.value.map((o) => o.value);
58
185
  if (g === 'type') return TYPE_OPTIONS;
59
186
  if (g === 'modified') return MODIFIED_OPTIONS;
60
187
  return SIZE_OPTIONS;
61
188
  }
62
189
 
63
190
  function optionLabel(g: Group, v: string): string {
191
+ // A named account is labelled by its NAME, which no locale file can know.
192
+ // The three fixed members are ordinary keys like every other group's.
193
+ if (g === 'people' && v.startsWith('u:')) {
194
+ return peopleChoices.value.find((o) => o.value === v)?.name || v.slice(2);
195
+ }
64
196
  return t(`filter.${g}.${v}`);
65
197
  }
66
198
 
67
199
  /** The chip's own label: the group name until a choice is made, then the
68
200
  * choice — a row of three identical words tells the reader nothing. */
69
201
  function chipLabel(g: Group): string {
70
- const v = props.value[g];
202
+ const v = g === 'people' ? (props.value.people ?? 'any') : props.value[g];
71
203
  return v === 'any' ? t(`filter.${g}`) : optionLabel(g, v);
72
204
  }
73
205
 
206
+ /** `value[g]` for a group whose field is optional. */
207
+ function chosen(g: Group): string {
208
+ return g === 'people' ? (props.value.people ?? 'any') : props.value[g];
209
+ }
210
+
74
211
  const activeCount = computed(() => activeFilterCount(props.value));
75
212
 
213
+ // ── surucu:d1-sort — the sort control ─────────────────────────────────
214
+ // Reading through the STORE OF THE PANE this row is inside, so the call
215
+ // subscribes this render to that pane's state and there is no second place
216
+ // holding a copy that could go stale.
217
+ //
218
+ // ⚠⚠ Per pane, not per bundle. The split view draws this row in both halves;
219
+ // while the key lived in a module-level singleton, choosing "Size ↓" on the
220
+ // right re-ordered the listing on the left (measured 2026-09-13). With no
221
+ // provider — one listing, a bare <FilterBar> in a test — this is the default
222
+ // store and nothing changes.
223
+ const sort = useSortStore();
224
+ const sortKey = computed<SortKey>(() => sort.key.value);
225
+ const sortAscending = computed(() => sort.dir.value === 'asc');
226
+
227
+ /** The rows behind this row are the server's ranked answer, so the key below
228
+ * is not what is on screen. One boolean, read by everything in the control. */
229
+ const ranked = computed(() => props.order === 'relevance');
230
+
231
+ /** "Modified", "Size" … — the SAME words the list view's column headers use
232
+ * (`col.*`), because they name the same sort. Over a ranked listing it is
233
+ * "Relevance" instead: the button must name the order it is IN. */
234
+ const sortKeyLabel = computed(() => (ranked.value ? t('sort.relevance') : t(`col.${sortKey.value}`)));
235
+ /** Read only by a screen reader, exactly as in the reference build: the arrow
236
+ * says the direction to everyone who can see it. Over a ranked listing the
237
+ * arrow is gone and this carries the REASON instead — the same sentence the
238
+ * tooltip shows, because "disabled" with no explanation is the state the
239
+ * owner specifically did not want. */
240
+ const sortDirLabel = computed(() =>
241
+ ranked.value ? t('sort.relevance_why') : t(sortAscending.value ? 'sort.asc' : 'sort.desc'),
242
+ );
243
+
244
+ /** The sort's own `type` order is by the word the Type column prints, so the
245
+ * comparator has to know which catalogue that word comes from. */
246
+ watch(() => props.locale, (l) => setSortLocale(l), { immediate: true });
247
+
248
+ /**
249
+ * ⚠ `setSort`, not `chooseSortKey`: a menu item NAMES a destination, so
250
+ * picking the key that is already active must leave it where it is rather
251
+ * than quietly reverse it. The button beside this menu is the toggle, and a
252
+ * column header — where clicking the active one again is what everybody
253
+ * expects — uses the other one.
254
+ */
255
+ function pickSortKey(k: SortKey): void {
256
+ sort.setSort(k);
257
+ close(true);
258
+ }
259
+
260
+ /**
261
+ * ⚠⚠ The two sort handles refuse to act over a RANKED listing, in the handler
262
+ * and not only through the `disabled` attribute on the button.
263
+ *
264
+ * `disabled` suppresses the browser's own activation, but a click dispatched
265
+ * in script still reaches this listener, and the damage is silent: nothing on
266
+ * screen moves (the rows are the server's), the shared key is written to
267
+ * `filex.list-sort`, and the FOLDER the person goes back to is re-ordered
268
+ * behind their back. Measured 2026-09-13 on the list view's twin control — a
269
+ * scripted click on the closed Size header left the rows untouched and still
270
+ * turned the stored sort from Name ↑ into Size ↑.
271
+ */
272
+ function flipSortDir(): void {
273
+ if (ranked.value) return;
274
+ sort.toggleSortDir();
275
+ }
276
+ function openSortMenu(): void {
277
+ if (ranked.value) return;
278
+ void toggle('sort');
279
+ }
280
+
281
+ /* === surucu:d1-actions — what the "⋮" offers ==========================
282
+ * The reference build's listing menu, minus one row: it also carries "File",
283
+ * and this explorer has no create-empty-file command anywhere — not in the
284
+ * "+ New" menu, not in the right-click menu, not as a shortcut. Shipping the
285
+ * word with nothing behind it is the one thing this menu was held back for.
286
+ *
287
+ * ⚠ Disabled rather than hidden, all of them. A menu whose height changes
288
+ * between folders is a menu people misclick, and "Paste" greyed out says the
289
+ * clipboard is empty — "Paste" absent says the feature does not exist.
290
+ *
291
+ * ⚠⚠ And there is no "Deselect all", which the reference build does carry.
292
+ * MEASURED, not assumed: the instant anything is selected this whole row is
293
+ * replaced by the selection bar (`.fe-selbar-slot.is-active`) — filter row
294
+ * hidden, this button hidden — and that bar draws its own "Clear selection"
295
+ * button. So a Deselect row here could never be reached (having a selection is
296
+ * exactly the state that hides the menu) and would duplicate a control that IS
297
+ * on screen at that moment. "Select all" is the opposite case and stays: it is
298
+ * reachable precisely when it is useful, and it exists nowhere else but a
299
+ * keystroke.
300
+ */
301
+ const actionRows = computed(() => [
302
+ { key: 'new-folder', label: t('toolbar.new_folder'), disabled: props.canWrite === false },
303
+ { key: 'upload', label: t('toolbar.upload'), disabled: props.canWrite === false },
304
+ { key: 'paste', label: t('ctx.paste'), disabled: !props.canPaste || props.canWrite === false },
305
+ { key: 'sep', label: '', divider: true, disabled: true },
306
+ { key: 'select-all', label: t('shortcuts.select_all'), disabled: false },
307
+ ]);
308
+
309
+ function runAction(key: string): void {
310
+ close(true);
311
+ if (key === 'new-folder') emit('new-folder');
312
+ else if (key === 'upload') emit('upload');
313
+ else if (key === 'paste') emit('paste');
314
+ else if (key === 'select-all') emit('select-all');
315
+ }
316
+
76
317
  // ── the popover ──────────────────────────────────────────────────────
77
- const openGroup = ref<Group | null>(null);
318
+ const openPop = ref<PopId | null>(null);
78
319
  const pos = ref({ x: 0, y: 0 });
79
320
  const panelEl = ref<HTMLElement | null>(null);
80
- const chipEls = ref<Record<string, HTMLElement | null>>({});
321
+ const anchorEls = ref<Record<string, HTMLElement | null>>({});
81
322
 
82
- function setChipEl(g: Group, el: unknown) {
83
- chipEls.value[g] = (el as HTMLElement | null) ?? null;
323
+ function setAnchorEl(id: PopId, el: unknown) {
324
+ anchorEls.value[id] = (el as HTMLElement | null) ?? null;
84
325
  }
85
326
 
86
- // The open popover's data, precomputed. Reading `value[openGroup]` straight
327
+ // The open popover's data, precomputed. Reading `value[openPop]` straight
87
328
  // from the template would lean on template narrowing across a Teleport, and
88
329
  // `vue-tsc --noEmit` is part of this package's build.
330
+ const openGroup = computed<Group | null>(() =>
331
+ openPop.value && openPop.value !== 'sort' && openPop.value !== 'actions'
332
+ ? openPop.value
333
+ : null,
334
+ );
89
335
  const popOptions = computed<string[]>(() => (openGroup.value ? optionsFor(openGroup.value) : []));
90
- const popTitle = computed(() => (openGroup.value ? t(`filter.${openGroup.value}`) : ''));
336
+ const popTitle = computed(() =>
337
+ openGroup.value
338
+ ? t(`filter.${openGroup.value}`)
339
+ : openPop.value === 'actions'
340
+ ? t('filter.actions')
341
+ : t('sort.by'),
342
+ );
91
343
  function popChecked(opt: string): boolean {
92
- return !!openGroup.value && props.value[openGroup.value] === opt;
344
+ return !!openGroup.value && chosen(openGroup.value) === opt;
93
345
  }
94
346
  function popLabel(opt: string): string {
95
347
  return openGroup.value ? optionLabel(openGroup.value, opt) : opt;
@@ -98,14 +350,14 @@ function popPick(opt: string): void {
98
350
  if (openGroup.value) pick(openGroup.value, opt);
99
351
  }
100
352
 
101
- async function toggle(g: Group) {
102
- if (openGroup.value === g) {
353
+ async function toggle(id: PopId) {
354
+ if (openPop.value === id) {
103
355
  close();
104
356
  return;
105
357
  }
106
- const r = chipEls.value[g]?.getBoundingClientRect();
358
+ const r = anchorEls.value[id]?.getBoundingClientRect();
107
359
  pos.value = { x: r ? r.left : 8, y: r ? r.bottom + 6 : 8 };
108
- openGroup.value = g;
360
+ openPop.value = id;
109
361
  await nextTick();
110
362
  // Keep it on screen: a chip near the right edge would otherwise open a panel
111
363
  // that runs off it, and the row is right beside the info panel.
@@ -116,38 +368,107 @@ async function toggle(g: Group) {
116
368
  pos.value = { ...pos.value, x: Math.max(8, window.innerWidth - 8 - box.width) };
117
369
  }
118
370
  if (box.bottom > window.innerHeight - 8) {
119
- const r2 = chipEls.value[g]?.getBoundingClientRect();
371
+ const r2 = anchorEls.value[id]?.getBoundingClientRect();
120
372
  pos.value = { ...pos.value, y: Math.max(8, (r2 ? r2.top : 0) - box.height - 6) };
121
373
  }
122
- panel.querySelector<HTMLElement>('[data-checked="true"]')?.focus();
374
+ // `data-active` rather than a tick: the sort menu draws no check mark (the
375
+ // button beside it already names the active key), but the keyboard still
376
+ // has to land on it.
377
+ panel.querySelector<HTMLElement>('[data-active="true"]')?.focus();
123
378
  }
124
379
  }
125
380
 
126
381
  function close(restoreFocus = false) {
127
- const g = openGroup.value;
128
- openGroup.value = null;
129
- if (restoreFocus && g) chipEls.value[g]?.focus();
382
+ const id = openPop.value;
383
+ openPop.value = null;
384
+ if (restoreFocus && id) anchorEls.value[id]?.focus();
130
385
  }
131
386
 
387
+ /* surucu:d1-sort — ⚠ a listing can BECOME ranked while the Sort-by menu is
388
+ * open: the header field searches on a debounce, so the rows change under a
389
+ * popover that is already up. Leaving it there would offer four keys the
390
+ * listing no longer obeys, and the anchor it is positioned against has just
391
+ * gone disabled — so it would also be unreachable by keyboard. */
392
+ watch(
393
+ () => props.order,
394
+ (o) => {
395
+ if (o === 'relevance' && openPop.value === 'sort') close();
396
+ },
397
+ );
398
+
132
399
  function pick(g: Group, v: string) {
133
400
  emit('update:value', { ...props.value, [g]: v } as DriveFilters);
134
401
  close(true);
135
402
  }
136
403
 
137
404
  function clearAll() {
138
- emit('update:value', { type: 'any', modified: 'any', size: 'any' });
405
+ localName.value = '';
406
+ // ⚠ Every dimension, named. This literal is hand-written rather than
407
+ // EMPTY_FILTERS because it must NOT reset the advanced-search fields the
408
+ // dialog owns — but a dimension left out here is a chip "Clear" cannot
409
+ // clear, which is how `people` would have stayed set forever.
410
+ //
411
+ // ⚠ The sort is NOT one of them. "Clear filters" un-hides rows; it does not
412
+ // re-order the ones that were never hidden, and a button that silently threw
413
+ // away the order you chose would be a second, invisible job.
414
+ emit('update:value', {
415
+ ...props.value,
416
+ type: 'any',
417
+ modified: 'any',
418
+ size: 'any',
419
+ name: '',
420
+ people: 'any',
421
+ });
139
422
  }
140
423
 
424
+ /* === gorunum:v1 — "Filter in this folder…" ============================
425
+ * The chips narrow the rows in hand by type/date/size; this narrows the same
426
+ * rows by name, and it is part of the SAME model (`DriveFilters.name`) rather
427
+ * than a second channel — one predicate, one "Clear", one empty state.
428
+ *
429
+ * ⚠ Not the header field. That one runs a server search and replaces the
430
+ * listing with hits from below this folder; this one never leaves the folder
431
+ * and never issues a request.
432
+ *
433
+ * Debounced, because every emit clears the selection in the parent (a row the
434
+ * filter just hid would still be what Delete acts on) — doing that on each
435
+ * keystroke would be a selection that dies while you type. */
436
+ const localName = ref(props.value.name ?? '');
437
+ watch(
438
+ () => props.value.name ?? '',
439
+ (v) => {
440
+ // The parent resets the filters on navigation; the box has to follow, or
441
+ // it keeps printing a word that no longer filters anything.
442
+ if (v !== localName.value.trim()) localName.value = v;
443
+ },
444
+ );
445
+ let nameTimer: ReturnType<typeof setTimeout> | undefined;
446
+ function onNameInput(ev: Event) {
447
+ localName.value = (ev.target as HTMLInputElement).value;
448
+ if (nameTimer) clearTimeout(nameTimer);
449
+ nameTimer = setTimeout(() => {
450
+ emit('update:value', { ...props.value, name: localName.value.trim() });
451
+ }, 140);
452
+ }
453
+ function clearName() {
454
+ if (nameTimer) clearTimeout(nameTimer);
455
+ localName.value = '';
456
+ emit('update:value', { ...props.value, name: '' });
457
+ }
458
+ onBeforeUnmount(() => {
459
+ if (nameTimer) clearTimeout(nameTimer);
460
+ });
461
+
141
462
  function onDocPointer(ev: PointerEvent) {
142
- if (!openGroup.value) return;
463
+ if (!openPop.value) return;
143
464
  const el = ev.target as Node;
144
465
  if (panelEl.value?.contains(el)) return;
145
- if (Object.values(chipEls.value).some((c) => c?.contains(el))) return;
466
+ if (Object.values(anchorEls.value).some((c) => c?.contains(el))) return;
146
467
  close();
147
468
  }
148
469
 
149
470
  function onKey(ev: KeyboardEvent) {
150
- if (ev.key === 'Escape' && openGroup.value) {
471
+ if (ev.key === 'Escape' && openPop.value) {
151
472
  ev.stopPropagation();
152
473
  close(true);
153
474
  }
@@ -164,37 +485,70 @@ onBeforeUnmount(() => {
164
485
  </script>
165
486
 
166
487
  <template>
167
- <div class="fe-filterbar" role="group" :aria-label="t('filter.aria')" data-testid="filterbar">
168
- <button
169
- v-for="g in (['type', 'modified', 'size'] as Group[])"
170
- :key="g"
171
- :ref="(el) => setChipEl(g, el)"
172
- type="button"
173
- class="fe-filterbar__chip"
174
- :class="{ 'is-set': value[g] !== 'any', 'is-open': openGroup === g }"
175
- :aria-expanded="openGroup === g"
176
- aria-haspopup="listbox"
177
- :data-testid="`filter-${g}`"
178
- @click="toggle(g)"
179
- >
180
- <span class="fe-filterbar__label">{{ chipLabel(g) }}</span>
488
+ <div class="fe-filterbar" :data-mode="full ? 'full' : 'find'" data-testid="filterbar">
489
+ <!-- `display: contents` — the chips stay flex items of the row while the
490
+ group role covers exactly the four that ARE filters. The sort control
491
+ at the far end is not one of them and must not be announced as one. -->
492
+ <div v-if="full" class="fe-filterbar__chips" role="group" :aria-label="t('filter.aria')">
493
+ <button
494
+ v-for="g in GROUPS"
495
+ :key="g"
496
+ :ref="(el) => setAnchorEl(g, el)"
497
+ type="button"
498
+ class="fe-filterbar__chip"
499
+ :class="{ 'is-set': chosen(g) !== 'any', 'is-open': openPop === g }"
500
+ :aria-expanded="openPop === g"
501
+ aria-haspopup="listbox"
502
+ :data-testid="`filter-${g}`"
503
+ @click="toggle(g)"
504
+ >
505
+ <span class="fe-filterbar__label">{{ chipLabel(g) }}</span>
506
+ <svg
507
+ class="fe-ficon fe-filterbar__caret"
508
+ viewBox="0 0 24 24"
509
+ fill="none"
510
+ stroke="currentColor"
511
+ stroke-width="2"
512
+ stroke-linecap="round"
513
+ stroke-linejoin="round"
514
+ aria-hidden="true"
515
+ focusable="false"
516
+ >
517
+ <path d="M7 10l5 5 5-5" />
518
+ </svg>
519
+ </button>
520
+ </div>
521
+
522
+ <!-- gorunum:v1 — the name box. Same height as the chips beside it; it is
523
+ one more way to narrow THIS folder, not a second search. -->
524
+ <div class="fe-filterbar__find">
181
525
  <svg
182
- class="fe-ficon fe-filterbar__caret"
526
+ class="fe-ficon fe-filterbar__find-glass"
183
527
  viewBox="0 0 24 24"
184
528
  fill="none"
185
529
  stroke="currentColor"
186
- stroke-width="2"
530
+ stroke-width="1.9"
187
531
  stroke-linecap="round"
188
- stroke-linejoin="round"
189
532
  aria-hidden="true"
190
533
  focusable="false"
191
534
  >
192
- <path d="M7 10l5 5 5-5" />
535
+ <circle cx="11" cy="11" r="6.5" />
536
+ <path d="M16 16l4.5 4.5" />
193
537
  </svg>
194
- </button>
538
+ <input
539
+ type="search"
540
+ class="fe-filterbar__find-input"
541
+ :placeholder="findLabel"
542
+ :aria-label="findLabel"
543
+ :value="localName"
544
+ data-testid="filter-find"
545
+ @input="onNameInput"
546
+ @keydown.esc.stop.prevent="clearName"
547
+ />
548
+ </div>
195
549
 
196
550
  <button
197
- v-if="activeCount > 0"
551
+ v-if="full && activeCount > 0"
198
552
  type="button"
199
553
  class="fe-filterbar__clear"
200
554
  data-testid="filter-clear"
@@ -204,40 +558,182 @@ onBeforeUnmount(() => {
204
558
  </button>
205
559
 
206
560
  <span
207
- v-if="activeCount > 0 && typeof shown === 'number' && typeof total === 'number'"
561
+ v-if="full && activeCount > 0 && typeof shown === 'number' && typeof total === 'number'"
208
562
  class="fe-filterbar__count"
209
563
  role="status"
210
564
  data-testid="filter-count"
211
565
  >{{ t('filter.count', { shown: String(shown), total: String(total) }) }}</span
212
566
  >
213
567
 
568
+ <!-- surucu:d1-sort — pushed to the trailing edge of the row.
569
+ ⚠ `full` only: a drive list and Home's three card blocks are not a
570
+ sorted listing this row owns, so a button naming a sort key would be
571
+ naming one nothing on screen obeys — the same lie the ranked-search
572
+ case above is built to avoid. -->
573
+ <div v-if="full" class="fe-filterbar__sort">
574
+ <button
575
+ :ref="(el) => setAnchorEl('sort', el)"
576
+ type="button"
577
+ class="fe-filterbar__sortdir"
578
+ :class="{ 'is-ranked': ranked }"
579
+ :disabled="ranked"
580
+ :title="ranked ? sortDirLabel : undefined"
581
+ data-testid="sort-dir"
582
+ @click="flipSortDir()"
583
+ >
584
+ <span>{{ sortKeyLabel }}</span>
585
+ <!-- The accessible name becomes "Modified, Descending". The arrow
586
+ carries the same fact for everyone who can see it, so it is not
587
+ repeated as a title. Ranked: "Relevance, <why it is closed>" —
588
+ the reason reaches a screen reader through the same channel it
589
+ reaches a mouse, rather than living only in a tooltip. -->
590
+ <span class="fe-sr-only">, {{ sortDirLabel }}</span>
591
+ <svg
592
+ v-if="!ranked"
593
+ class="fe-ficon fe-filterbar__sortarrow"
594
+ viewBox="0 0 24 24"
595
+ fill="none"
596
+ stroke="currentColor"
597
+ stroke-width="2"
598
+ stroke-linecap="round"
599
+ stroke-linejoin="round"
600
+ aria-hidden="true"
601
+ focusable="false"
602
+ >
603
+ <template v-if="sortAscending">
604
+ <path d="M12 19V5" />
605
+ <path d="M5 12l7-7 7 7" />
606
+ </template>
607
+ <template v-else>
608
+ <path d="M12 5v14" />
609
+ <path d="M19 12l-7 7-7-7" />
610
+ </template>
611
+ </svg>
612
+ </button>
613
+ <button
614
+ type="button"
615
+ class="fe-filterbar__sortpick"
616
+ :aria-label="ranked ? sortDirLabel : t('sort.by')"
617
+ :title="ranked ? sortDirLabel : t('sort.by')"
618
+ :disabled="ranked"
619
+ aria-haspopup="menu"
620
+ :aria-expanded="openPop === 'sort'"
621
+ data-testid="sort-menu"
622
+ @click="openSortMenu()"
623
+ >
624
+ <svg
625
+ class="fe-ficon fe-filterbar__caret"
626
+ viewBox="0 0 24 24"
627
+ fill="none"
628
+ stroke="currentColor"
629
+ stroke-width="2"
630
+ stroke-linecap="round"
631
+ stroke-linejoin="round"
632
+ aria-hidden="true"
633
+ focusable="false"
634
+ >
635
+ <path d="M7 10l5 5 5-5" />
636
+ </svg>
637
+ </button>
638
+ </div>
639
+
640
+ <!-- surucu:d1-actions — the listing's own menu, at the very end of the row.
641
+ ⚠ VERTICAL dots, and the header's overflow is HORIZONTAL dots, which is
642
+ the whole distinction: in this product ⋮ means "act on this thing" (it
643
+ is the glyph on every file row) and ⋯ means "settings for the app". Two
644
+ menus one row apart would otherwise read as two doors to one place. -->
645
+ <button
646
+ v-if="full"
647
+ :ref="(el) => setAnchorEl('actions', el)"
648
+ type="button"
649
+ class="fe-filterbar__actions"
650
+ :aria-label="t('filter.actions')"
651
+ :title="t('filter.actions')"
652
+ aria-haspopup="menu"
653
+ :aria-expanded="openPop === 'actions'"
654
+ data-testid="listing-actions"
655
+ @click="toggle('actions')"
656
+ >
657
+ <svg
658
+ class="fe-ficon"
659
+ viewBox="0 0 24 24"
660
+ fill="none"
661
+ stroke="currentColor"
662
+ stroke-width="2"
663
+ stroke-linecap="round"
664
+ aria-hidden="true"
665
+ focusable="false"
666
+ >
667
+ <circle cx="12" cy="5.5" r="1.2" fill="currentColor" stroke="none" />
668
+ <circle cx="12" cy="12" r="1.2" fill="currentColor" stroke="none" />
669
+ <circle cx="12" cy="18.5" r="1.2" fill="currentColor" stroke="none" />
670
+ </svg>
671
+ </button>
672
+
214
673
  <Teleport to="body">
215
674
  <div
216
- v-if="openGroup"
675
+ v-if="openPop"
217
676
  ref="panelEl"
218
677
  class="fe-filterpop"
219
678
  :class="{
220
679
  'fe--theme-light': theme === 'light',
221
680
  'fe--theme-dark': theme === 'dark',
681
+ 'fe-filterpop--sort': openPop === 'sort',
682
+ 'fe-filterpop--actions': openPop === 'actions',
222
683
  }"
223
- role="listbox"
684
+ :role="openPop === 'sort' || openPop === 'actions' ? 'menu' : 'listbox'"
224
685
  :aria-label="popTitle"
225
686
  :style="{ left: pos.x + 'px', top: pos.y + 'px' }"
226
687
  >
227
- <button
228
- v-for="opt in popOptions"
229
- :key="opt"
230
- type="button"
231
- class="fe-filterpop__item"
232
- role="option"
233
- :aria-selected="popChecked(opt)"
234
- :data-checked="popChecked(opt) ? 'true' : 'false'"
235
- :data-testid="`filter-opt-${opt}`"
236
- @click="popPick(opt)"
237
- >
238
- <span class="fe-filterpop__tick" aria-hidden="true">{{ popChecked(opt) ? '✓' : '' }}</span>
239
- <span>{{ popLabel(opt) }}</span>
240
- </button>
688
+ <template v-if="openPop === 'actions'">
689
+ <template v-for="a in actionRows" :key="a.key">
690
+ <hr v-if="a.divider" class="fe-filterpop__rule" aria-hidden="true" />
691
+ <button
692
+ v-else
693
+ type="button"
694
+ class="fe-filterpop__item"
695
+ role="menuitem"
696
+ :disabled="a.disabled"
697
+ :aria-disabled="a.disabled"
698
+ :data-active="'false'"
699
+ :data-testid="`listing-action-${a.key}`"
700
+ @click="runAction(a.key)"
701
+ >
702
+ <span>{{ a.label }}</span>
703
+ </button>
704
+ </template>
705
+ </template>
706
+ <template v-else-if="openPop === 'sort'">
707
+ <button
708
+ v-for="k in SORT_KEYS"
709
+ :key="k"
710
+ type="button"
711
+ class="fe-filterpop__item"
712
+ role="menuitem"
713
+ :data-active="sortKey === k ? 'true' : 'false'"
714
+ :data-testid="`sort-opt-${k}`"
715
+ @click="pickSortKey(k)"
716
+ >
717
+ <span>{{ t(`col.${k}`) }}</span>
718
+ </button>
719
+ </template>
720
+ <template v-else>
721
+ <button
722
+ v-for="opt in popOptions"
723
+ :key="opt"
724
+ type="button"
725
+ class="fe-filterpop__item"
726
+ role="option"
727
+ :aria-selected="popChecked(opt)"
728
+ :data-checked="popChecked(opt) ? 'true' : 'false'"
729
+ :data-active="popChecked(opt) ? 'true' : 'false'"
730
+ :data-testid="`filter-opt-${opt}`"
731
+ @click="popPick(opt)"
732
+ >
733
+ <span class="fe-filterpop__tick" aria-hidden="true">{{ popChecked(opt) ? '✓' : '' }}</span>
734
+ <span>{{ popLabel(opt) }}</span>
735
+ </button>
736
+ </template>
241
737
  </div>
242
738
  </Teleport>
243
739
  </div>