@brftech/filex-core 0.42.2 → 0.43.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 (234) hide show
  1. package/README.md +14 -2
  2. package/dist/ArchiveViewer-BqU761Wk.js +149 -0
  3. package/dist/ArchiveViewer-BqU761Wk.js.map +1 -0
  4. package/dist/CsvViewer-ByHVK5ga.js +172 -0
  5. package/dist/CsvViewer-ByHVK5ga.js.map +1 -0
  6. package/dist/{DrawioViewer-CC6rJXeP.js → DrawioViewer-Bgrj8-zG.js} +45 -44
  7. package/dist/DrawioViewer-Bgrj8-zG.js.map +1 -0
  8. package/dist/{EpubViewer-CAoTSXTO.js → EpubViewer-DJwUKlHW.js} +50 -50
  9. package/dist/EpubViewer-DJwUKlHW.js.map +1 -0
  10. package/dist/IpynbViewer-Bz0eJ4NS.js +184 -0
  11. package/dist/IpynbViewer-Bz0eJ4NS.js.map +1 -0
  12. package/dist/MermaidViewer-CEDTD_pp.js +136 -0
  13. package/dist/MermaidViewer-CEDTD_pp.js.map +1 -0
  14. package/dist/PsdViewer-CZcxUpZO.js +121 -0
  15. package/dist/PsdViewer-CZcxUpZO.js.map +1 -0
  16. package/dist/TiffViewer-BwI62CbQ.js +141 -0
  17. package/dist/TiffViewer-BwI62CbQ.js.map +1 -0
  18. package/dist/{Viewer3D-CdZeK4j4.js → Viewer3D-DoeZcuMm.js} +16 -16
  19. package/dist/Viewer3D-DoeZcuMm.js.map +1 -0
  20. package/dist/filex-core.js +475 -219
  21. package/dist/filex-core.umd.cjs +165 -108
  22. package/dist/filex-core.umd.cjs.map +1 -1
  23. package/dist/fonts/caveat-latin-ext.woff2 +0 -0
  24. package/dist/fonts/caveat-latin.woff2 +0 -0
  25. package/dist/fonts/dancing-script-latin-ext.woff2 +0 -0
  26. package/dist/fonts/dancing-script-latin.woff2 +0 -0
  27. package/dist/fonts/homemade-apple-latin.woff2 +0 -0
  28. package/dist/fonts/source-serif-4-latin-ext.woff2 +0 -0
  29. package/dist/fonts/source-serif-4-latin.woff2 +0 -0
  30. package/dist/index-DuWnGuv5.js +35901 -0
  31. package/dist/index-DuWnGuv5.js.map +1 -0
  32. package/dist/index.d.ts +5227 -553
  33. package/dist/pdf-CaPYsOUo.js +21302 -0
  34. package/dist/pdf-CaPYsOUo.js.map +1 -0
  35. package/dist/style.css +1 -1
  36. package/package.json +1 -1
  37. package/src/FileExplorer.vue +1055 -253
  38. package/src/assets/fonts/LICENSE-Apache-2.0.txt +202 -0
  39. package/src/assets/fonts/LICENSE-OFL-1.1.txt +93 -0
  40. package/src/assets/fonts/README.md +58 -0
  41. package/src/assets/fonts/caveat-latin-ext.woff2 +0 -0
  42. package/src/assets/fonts/caveat-latin.woff2 +0 -0
  43. package/src/assets/fonts/dancing-script-latin-ext.woff2 +0 -0
  44. package/src/assets/fonts/dancing-script-latin.woff2 +0 -0
  45. package/src/assets/fonts/homemade-apple-latin.woff2 +0 -0
  46. package/src/assets/fonts/source-serif-4-latin-ext.woff2 +0 -0
  47. package/src/assets/fonts/source-serif-4-latin.woff2 +0 -0
  48. package/src/components/AdvancedSearch.vue +49 -0
  49. package/src/components/Breadcrumb.vue +5 -5
  50. package/src/components/ChoiceButtons.vue +152 -0
  51. package/src/components/CommandPalette.vue +5 -5
  52. package/src/components/ConnectionGuideView.vue +1 -1
  53. package/src/components/ConnectionNotice.vue +59 -0
  54. package/src/components/ConnectionsPanel.vue +30 -568
  55. package/src/components/ContextMenu.vue +31 -5
  56. package/src/components/DataTable.vue +1331 -0
  57. package/src/components/FilePane.vue +3 -0
  58. package/src/components/FilterBar.vue +12 -5
  59. package/src/components/GalleryView.vue +50 -2
  60. package/src/components/GridView.vue +51 -3
  61. package/src/components/HomeView.vue +18 -6
  62. package/src/components/InspectorPanel.vue +134 -17
  63. package/src/components/ListView.vue +366 -900
  64. package/src/components/LogoMark.vue +57 -0
  65. package/src/components/NFSExportsPanel.vue +76 -28
  66. package/src/components/OnboardingTour.vue +19 -7
  67. package/src/components/OperationsCenter.vue +77 -1
  68. package/src/components/PendingOpsTray.vue +59 -5
  69. package/src/components/PresenceBar.vue +7 -7
  70. package/src/components/ProductVersion.vue +31 -0
  71. package/src/components/QuickLook.vue +10 -14
  72. package/src/components/RecentlyOpened.vue +31 -15
  73. package/src/components/RecoveryKeyModal.vue +1 -1
  74. package/src/components/RowActions.vue +134 -0
  75. package/src/components/S3KeysPanel.vue +98 -62
  76. package/src/components/SSHKeysPanel.vue +79 -30
  77. package/src/components/SideNav.vue +260 -96
  78. package/src/components/StarButton.vue +1 -0
  79. package/src/components/StorageFields.vue +74 -6
  80. package/src/components/StorageTags.vue +62 -0
  81. package/src/components/TabBar.vue +1 -1
  82. package/src/components/TagKindIcon.vue +56 -0
  83. package/src/components/TagPicker.vue +226 -40
  84. package/src/components/ThemePalette.vue +4 -4
  85. package/src/components/TimeZonePicker.vue +29 -10
  86. package/src/components/TokensPanel.vue +82 -31
  87. package/src/components/Toolbar.vue +28 -6
  88. package/src/components/plugin/PluginConfirmModal.vue +54 -0
  89. package/src/components/plugin/PluginInspectorSection.vue +149 -0
  90. package/src/components/plugin/PluginPageView.vue +339 -0
  91. package/src/components/plugin/PluginViewModal.vue +159 -0
  92. package/src/components/plugin/SurfaceConversation.vue +76 -0
  93. package/src/components/plugin/SurfaceFooterButtons.vue +73 -0
  94. package/src/components/plugin/SurfaceRenderer.vue +299 -0
  95. package/src/components/plugin/SurfaceSections.vue +91 -0
  96. package/src/components/plugin/nodes/PdfFieldEditor.vue +509 -0
  97. package/src/components/plugin/nodes/SurfaceFileChooser.vue +94 -0
  98. package/src/components/plugin/nodes/SurfaceForm.vue +82 -0
  99. package/src/components/plugin/nodes/SurfaceList.vue +127 -0
  100. package/src/components/plugin/nodes/SurfacePdfFields.vue +1285 -0
  101. package/src/components/plugin/nodes/SurfacePeoplePicker.vue +213 -0
  102. package/src/components/plugin/nodes/SurfacePinInput.vue +57 -0
  103. package/src/components/plugin/nodes/SurfacePreview.vue +119 -0
  104. package/src/components/plugin/nodes/SurfaceProgress.vue +48 -0
  105. package/src/components/plugin/nodes/SurfaceSignaturePad.vue +472 -0
  106. package/src/components/plugin/nodes/SurfaceSteps.vue +53 -0
  107. package/src/components/public/PublicLanguagePicker.vue +58 -0
  108. package/src/components/public/PublicLinkPage.vue +286 -0
  109. package/src/components/public/PublicLinkPreview.vue +85 -0
  110. package/src/components/public/PublicPinGate.vue +108 -0
  111. package/src/components/public/PublicRequestBody.vue +204 -0
  112. package/src/components/public/PublicShareBody.vue +188 -0
  113. package/src/components/public/PublicShell.vue +332 -0
  114. package/src/composables/useConnections.ts +8 -2
  115. package/src/composables/useFileApi.ts +183 -44
  116. package/src/composables/useLocale.ts +277 -41
  117. package/src/composables/useNFSExports.ts +8 -10
  118. package/src/composables/useOperations.ts +44 -3
  119. package/src/composables/usePendingOps.ts +59 -8
  120. package/src/composables/usePluginActions.ts +121 -0
  121. package/src/composables/usePluginSurface.ts +223 -0
  122. package/src/composables/usePublicBranding.ts +172 -0
  123. package/src/composables/usePublicLink.ts +573 -0
  124. package/src/composables/usePublicPage.ts +61 -0
  125. package/src/composables/useS3Keys.ts +17 -12
  126. package/src/composables/useSSHKeys.ts +8 -10
  127. package/src/composables/useTokens.ts +8 -12
  128. package/src/composables/useUploadChunked.ts +18 -8
  129. package/src/composables/useViewerFetch.ts +6 -3
  130. package/src/index.ts +404 -8
  131. package/src/lib/actionIcons.ts +46 -1
  132. package/src/lib/advSearch.ts +14 -7
  133. package/src/lib/appLock.ts +130 -0
  134. package/src/lib/connection.ts +110 -0
  135. package/src/lib/destinationTree.ts +19 -3
  136. package/src/lib/direction.ts +353 -0
  137. package/src/lib/dragGhost.ts +6 -0
  138. package/src/lib/dropLimits.ts +69 -0
  139. package/src/lib/errorWords.ts +394 -0
  140. package/src/lib/externalReach.ts +5 -1
  141. package/src/lib/fileFilters.ts +7 -2
  142. package/src/lib/fileIcons.ts +4 -0
  143. package/src/lib/internalPaths.ts +77 -0
  144. package/src/lib/listInput.ts +64 -0
  145. package/src/lib/listing.ts +58 -23
  146. package/src/lib/modalStack.ts +26 -0
  147. package/src/lib/nodeRow.ts +18 -3
  148. package/src/lib/pdfFieldRules.ts +126 -0
  149. package/src/lib/pdfFields.ts +589 -0
  150. package/src/lib/pdfFieldsGeom.ts +203 -0
  151. package/src/lib/pdfjsLoader.ts +51 -0
  152. package/src/lib/personName.ts +47 -0
  153. package/src/lib/pluginApplies.ts +123 -0
  154. package/src/lib/pluginLabel.ts +72 -0
  155. package/src/lib/pluginMenu.ts +253 -0
  156. package/src/lib/pluginPage.ts +58 -0
  157. package/src/lib/plural.ts +181 -0
  158. package/src/lib/popupLayer.ts +103 -0
  159. package/src/lib/prefs.ts +563 -0
  160. package/src/lib/productVersion.ts +35 -0
  161. package/src/lib/publicLayout.ts +39 -0
  162. package/src/lib/serviceGate.ts +104 -0
  163. package/src/lib/shareTtl.ts +25 -11
  164. package/src/lib/signFonts.ts +85 -0
  165. package/src/lib/signaturePad.ts +111 -0
  166. package/src/lib/sortOrder.ts +100 -38
  167. package/src/lib/star.ts +7 -1
  168. package/src/lib/surfaceCell.ts +41 -0
  169. package/src/lib/surfaceConditions.ts +156 -0
  170. package/src/lib/surfaceOpen.ts +106 -0
  171. package/src/lib/surfaceSteps.ts +68 -0
  172. package/src/lib/surfaceValues.ts +191 -0
  173. package/src/lib/symlink.ts +150 -0
  174. package/src/lib/tableColumns.ts +493 -0
  175. package/src/lib/tableEnv.ts +34 -0
  176. package/src/lib/tablePrefs.ts +84 -0
  177. package/src/lib/tags.ts +121 -12
  178. package/src/lib/themes.ts +286 -18
  179. package/src/lib/tour.ts +152 -0
  180. package/src/lib/trashEmpty.ts +111 -0
  181. package/src/lib/trashTimeLeft.ts +23 -0
  182. package/src/lib/uiLocales.ts +460 -0
  183. package/src/lib/viewPrefs.ts +696 -660
  184. package/src/lib/viewPrefsHttp.ts +78 -0
  185. package/src/locales/en.ts +515 -117
  186. package/src/locales/resolve.ts +17 -8
  187. package/src/locales/tr.ts +536 -152
  188. package/src/modals/ConvertModal.vue +62 -11
  189. package/src/modals/DestinationPickerModal.vue +59 -16
  190. package/src/modals/Modal.vue +113 -23
  191. package/src/modals/NewDocumentModal.vue +16 -6
  192. package/src/modals/NewFolderModal.vue +8 -0
  193. package/src/modals/PermissionsModal.vue +272 -163
  194. package/src/modals/PreviewModal.vue +119 -20
  195. package/src/modals/RenameModal.vue +37 -1
  196. package/src/styles/base.css +2890 -260
  197. package/src/styles/sign-fonts.css +118 -0
  198. package/src/styles/variables.css +60 -0
  199. package/src/types/Connections.ts +17 -0
  200. package/src/types/ExplorerConfig.ts +117 -1
  201. package/src/types/FileNode.ts +50 -0
  202. package/src/types/Plugins.ts +494 -0
  203. package/src/types/Public.ts +212 -0
  204. package/src/viewers/ArchiveViewer.vue +93 -48
  205. package/src/viewers/CsvViewer.vue +99 -53
  206. package/src/viewers/DrawioViewer.vue +35 -9
  207. package/src/viewers/EpubViewer.vue +7 -8
  208. package/src/viewers/IpynbViewer.vue +3 -1
  209. package/src/viewers/MermaidViewer.vue +4 -5
  210. package/src/viewers/PdfViewer.vue +8 -17
  211. package/src/viewers/PsdViewer.vue +2 -4
  212. package/src/viewers/TiffViewer.vue +4 -5
  213. package/src/viewers/Viewer3D.vue +1 -1
  214. package/LICENSE +0 -21
  215. package/dist/ArchiveViewer-BUQWLhwX.js +0 -124
  216. package/dist/ArchiveViewer-BUQWLhwX.js.map +0 -1
  217. package/dist/CsvViewer-Bg2_aqzc.js +0 -141
  218. package/dist/CsvViewer-Bg2_aqzc.js.map +0 -1
  219. package/dist/DrawioViewer-CC6rJXeP.js.map +0 -1
  220. package/dist/EpubViewer-CAoTSXTO.js.map +0 -1
  221. package/dist/IpynbViewer-D0GwGpnE.js +0 -184
  222. package/dist/IpynbViewer-D0GwGpnE.js.map +0 -1
  223. package/dist/MermaidViewer-DGwAvRZg.js +0 -137
  224. package/dist/MermaidViewer-DGwAvRZg.js.map +0 -1
  225. package/dist/PsdViewer-DhGweA0m.js +0 -122
  226. package/dist/PsdViewer-DhGweA0m.js.map +0 -1
  227. package/dist/TiffViewer-D0AurvAh.js +0 -142
  228. package/dist/TiffViewer-D0AurvAh.js.map +0 -1
  229. package/dist/Viewer3D-CdZeK4j4.js.map +0 -1
  230. package/dist/index-BArI-B1M.js +0 -26911
  231. package/dist/index-BArI-B1M.js.map +0 -1
  232. package/dist/useViewerFetch-czqbd2Lj.js +0 -25
  233. package/dist/useViewerFetch-czqbd2Lj.js.map +0 -1
  234. package/src/composables/useScrolledX.ts +0 -21
@@ -0,0 +1,1331 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * DataTable — THE table. There is no other.
4
+ *
5
+ * ⚠⚠ This is the explorer's list view with the files taken out of it. The
6
+ * explorer's own listing (`ListView`) renders through it, and so does every
7
+ * other table in the product: every admin page, the connection panels, My
8
+ * shares, the notifications list and an app's `list` node. A raw `<table>` or
9
+ * a second table component anywhere in `web/src` or `packages/core/src` fails
10
+ * `web/tests/ui/tablePinnedActions.test.ts`, and `docs/CONTRIBUTING.md` → "UI
11
+ * rules" says why.
12
+ *
13
+ * The owner, 2026-09-21, verbatim: "Artık explore tablomuz bizim her yerde
14
+ * kullanacağımız tablo yapısıdır; bir yere tablo gerekiyorsa bu tabloyu koymak
15
+ * zorundayız." And, about the round before: "Admin tabloları hâlâ explore
16
+ * tablolarıyla AYNI KODDA DEĞİL … tablo sütunları düzenlenebilir değil,
17
+ * büyütme küçültme yok, sıralama yok." That round had built `ui/Table.vue`, a
18
+ * second table that imitated this one — the same frozen edges, the same
19
+ * Actions menu — and got exactly the parts somebody remembered to copy. The
20
+ * rest (resizing, sorting, the column menu, persistence) lived here and never
21
+ * reached the admin panel. The imitation is deleted; this is the one table.
22
+ *
23
+ * WHAT EVERY TABLE THEREFORE GETS, whoever draws it:
24
+ *
25
+ * · resizable columns — drag the edge, arrow keys on the focused handle,
26
+ * double-click to put a column back to its shipped width;
27
+ * · sorting — click a header, click it again to reverse; or, over rows that
28
+ * are one page of many, headers that are CLOSED and say why, because
29
+ * sorting one page of a paginated list would lie about the order of the
30
+ * rest (the same shape as the explorer's ranked search results);
31
+ * · a column menu (the header's ⋮ or a right-click on the header): show and
32
+ * hide columns, step them left and right, reset;
33
+ * · reordering by dragging a header;
34
+ * · all of it REMEMBERED — on the account, per table (`tableId` →
35
+ * `lib/tablePrefs`), or per folder for the explorer (its own store);
36
+ * · the lead column frozen on the left and ONE actions control frozen on
37
+ * the right while the rest scrolls sideways — the table is as wide as its
38
+ * columns and scrolls, it never sheds a column for want of room.
39
+ *
40
+ * ⚠ The markup and every class name are the explorer's (`fe-list__*`), on
41
+ * purpose: its stylesheet in `styles/base.css` is this table's stylesheet,
42
+ * and the end-to-end suites that address the explorer's rows keep addressing
43
+ * them. Nothing here carries a colour of its own — every value is a `--fe-*`
44
+ * token, so every table follows the palette the person picked.
45
+ */
46
+ import { computed, getCurrentInstance, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
47
+ import type { LocaleCode, ThemeMode } from '../types/ExplorerConfig';
48
+ import { useLocale } from '../composables/useLocale';
49
+ import {
50
+ createColumnStore,
51
+ memoryBacking,
52
+ type ColumnStore,
53
+ type LayoutMetrics,
54
+ type TableColumnSpec,
55
+ } from '../lib/tableColumns';
56
+ import { setTableSort, tableColumnBacking, tableSort } from '../lib/tablePrefs';
57
+ import { useTableEnv } from '../lib/tableEnv';
58
+ import { clampAlongInline, dirOfElement, inlineKeyStep, inlineSign, inlineStartX } from '../lib/direction';
59
+ import ItemCheck from './ItemCheck.vue';
60
+ import RowActions from './RowActions.vue';
61
+ import type { ContextAction } from './ContextMenu.vue';
62
+
63
+ /** One column of a table. */
64
+ export interface DataColumn<Row = any> {
65
+ /** Stable id — the cell slot is `cell-<id>`, and it is what is remembered. */
66
+ id: string;
67
+ /** Header text (translated by the caller). */
68
+ label: string;
69
+ /** Shipped width, px. */
70
+ width?: number;
71
+ min?: number;
72
+ max?: number;
73
+ /** May the person hide it (and therefore move it)? Default true. */
74
+ hideable?: boolean;
75
+ /** May the person drag its edge? Default true. */
76
+ resizable?: boolean;
77
+ /** Does a header click sort by it? Default false. */
78
+ sortable?: boolean;
79
+ /** The first direction a click sorts in. Default 'asc'. */
80
+ sortDir?: 'asc' | 'desc';
81
+ /** What the column sorts by when the TABLE sorts (uncontrolled). Default:
82
+ * `format(row)`, else `row[id]`. Numbers sort as numbers, strings in the
83
+ * viewer's collation with digits read as numbers, empty values last. */
84
+ sortValue?: (row: Row) => string | number | boolean | null | undefined;
85
+ /** The cell's text when no `cell-<id>` slot is given. Default `row[id]`. */
86
+ format?: (row: Row) => string | number | null | undefined;
87
+ /** Hover text for the cell. Default: the text itself when it may be cut. */
88
+ title?: (row: Row) => string | undefined;
89
+ align?: 'left' | 'right' | 'center';
90
+ /** Extra class on the header cell and every body cell of the column. */
91
+ class?: string;
92
+ /** Draw the header label? The explorer's star column draws none. */
93
+ headerLabel?: boolean;
94
+ /** The column that says WHICH ROW this is: pinned first, frozen left, never
95
+ * hidden or moved. Default: the first column. */
96
+ lead?: boolean;
97
+ }
98
+
99
+ interface Group {
100
+ id: string;
101
+ label?: string;
102
+ items: any[];
103
+ }
104
+
105
+ const props = withDefaults(
106
+ defineProps<{
107
+ rows: any[];
108
+ columns: DataColumn[];
109
+ /** How a row is keyed: a property name or a function. Default: its index. */
110
+ rowKey?: string | ((row: any) => string | number);
111
+ /** The name its arrangement is remembered under (`lib/tablePrefs`).
112
+ * Every table in the product passes one; the guard test refuses a table
113
+ * without. Absent = remembered for this mount only. */
114
+ tableId?: string;
115
+ /** The explorer passes its own per-folder store; everybody else gets one
116
+ * built from `tableId`. */
117
+ columnStore?: ColumnStore;
118
+ /** Which non-lead columns this listing will draw at all (the explorer's
119
+ * Location only in cross-folder views). Default: every column. */
120
+ candidates?: string[];
121
+ /** Table arithmetic, for a caller whose tracks differ (the explorer). */
122
+ metrics?: LayoutMetrics;
123
+ locale?: LocaleCode;
124
+ theme?: ThemeMode;
125
+ loading?: boolean;
126
+ /** What an empty table says. */
127
+ empty?: string;
128
+ ariaLabel?: string;
129
+ /** A tick column that selects (issue #26). */
130
+ selectable?: boolean;
131
+ isSelected?: (row: any) => boolean;
132
+ /**
133
+ * CONTROLLED sort: pass it (null = "no sort") and handle `sort`; the
134
+ * caller orders the rows (a server, or the explorer's sort store). Leave
135
+ * it out and the table sorts the rows itself and remembers the choice.
136
+ */
137
+ sort?: { key: string; dir: 'asc' | 'desc' } | null;
138
+ /** Headers closed, with this sentence as the reason (the explorer's
139
+ * ranked search results). */
140
+ sortClosed?: string | null;
141
+ /** Rows in groups under headings (the explorer's date headings). The
142
+ * caller has already ordered them; the table draws them as given. */
143
+ groups?: Group[];
144
+ rowClass?: (row: any, index: number) => string | Record<string, boolean> | undefined;
145
+ /** Attributes for a row (`data-*`, `aria-*`, `draggable`, `tabindex`). */
146
+ rowAttrs?: (row: any) => Record<string, unknown>;
147
+ /** The row's verbs — drawn as ONE labelled Actions control frozen right. */
148
+ rowActions?: (row: any) => ContextAction[];
149
+ /** A test hook for a row's Actions control. */
150
+ rowActionsTestId?: (row: any) => string | undefined;
151
+ /** The trailing column's content width, px (the explorer's ⋮ is 28). */
152
+ actionsWidth?: number;
153
+ /** A card around the table, with the toolbar and pager inside it. The
154
+ * explorer's listing is not framed — its pane is the frame. */
155
+ framed?: boolean;
156
+ page?: number;
157
+ pageSize?: number;
158
+ total?: number;
159
+ /** Pages, for an endpoint that answers "there is another page" and no
160
+ * total. */
161
+ pages?: number;
162
+ /** A line in the footer (a count, a note). */
163
+ footNote?: string;
164
+ }>(),
165
+ {
166
+ locale: undefined,
167
+ framed: true,
168
+ rowKey: undefined,
169
+ tableId: undefined,
170
+ columnStore: undefined,
171
+ candidates: undefined,
172
+ metrics: undefined,
173
+ theme: undefined,
174
+ empty: undefined,
175
+ ariaLabel: undefined,
176
+ isSelected: undefined,
177
+ sort: undefined,
178
+ sortClosed: undefined,
179
+ groups: undefined,
180
+ rowClass: undefined,
181
+ rowAttrs: undefined,
182
+ rowActions: undefined,
183
+ rowActionsTestId: undefined,
184
+ actionsWidth: undefined,
185
+ page: undefined,
186
+ pageSize: undefined,
187
+ total: undefined,
188
+ pages: undefined,
189
+ footNote: undefined,
190
+ },
191
+ );
192
+
193
+ const emit = defineEmits<{
194
+ (e: 'sort', payload: { key: string; dir: 'asc' | 'desc' }): void;
195
+ (e: 'row-click', row: any, ev: MouseEvent): void;
196
+ (e: 'row-dblclick', row: any, ev: MouseEvent): void;
197
+ (e: 'row-contextmenu', row: any, ev: MouseEvent): void;
198
+ (e: 'check-click', row: any, ev: MouseEvent): void;
199
+ (e: 'row-action', key: string, row: any, action: ContextAction): void;
200
+ (e: 'row-dragstart', row: any, ev: DragEvent): void;
201
+ (e: 'row-dragover', row: any, ev: DragEvent): void;
202
+ (e: 'row-dragleave', row: any, ev: DragEvent): void;
203
+ (e: 'row-drop', row: any, ev: DragEvent): void;
204
+ (e: 'row-touchstart', row: any, ev: TouchEvent): void;
205
+ (e: 'row-touchend', ev: TouchEvent): void;
206
+ (e: 'row-touchmove', ev: TouchEvent): void;
207
+ (e: 'page', page: number): void;
208
+ }>();
209
+
210
+ /* ⚠ Two roots (the table and the teleported column menu), so a class or a
211
+ * test id the caller puts on <DataTable> would otherwise go nowhere. */
212
+ defineOptions({ inheritAttrs: false });
213
+
214
+ /* The host's language and light/dark mode (lib/tableEnv) unless this table
215
+ * was handed its own. */
216
+ const env = useTableEnv();
217
+ const lang = computed<LocaleCode>(() => props.locale ?? env?.locale.value ?? 'en');
218
+ const mode = computed<ThemeMode | undefined>(() => props.theme ?? env?.theme?.value);
219
+
220
+ // ⚠ RTL: `dir` (the table's language) goes on the teleported column menu; the
221
+ // table's own maths reads the direction it is actually DRAWN in
222
+ // (`dirOfElement(listEl)`), because a pointer only knows physical x.
223
+ const { t, dir } = useLocale(() => lang.value);
224
+
225
+ // ── the columns ───────────────────────────────────────────────────────
226
+
227
+ const colById = computed(() => {
228
+ const m = new Map<string, DataColumn>();
229
+ for (const c of props.columns) m.set(c.id, c);
230
+ return m;
231
+ });
232
+
233
+ const leadId = computed(() => (props.columns.find((c) => c.lead) ?? props.columns[0])?.id ?? '');
234
+
235
+ /** The columns as the store sees them. */
236
+ const specs = computed<TableColumnSpec[]>(() =>
237
+ props.columns.map((c) => {
238
+ const lead = c.id === leadId.value;
239
+ const width = c.width ?? (lead ? 240 : 140);
240
+ return {
241
+ id: c.id,
242
+ width,
243
+ min: Math.min(c.min ?? (lead ? 120 : 56), width),
244
+ max: Math.max(c.max ?? 900, width),
245
+ hideable: lead ? false : c.hideable !== false,
246
+ resizable: c.resizable !== false,
247
+ };
248
+ }),
249
+ );
250
+
251
+ /**
252
+ * THE store: the caller's (the explorer's per-folder one), else one of this
253
+ * table's own, remembered under `tableId` on the account.
254
+ *
255
+ * ⚠ Built once per mount. `tableId` does not change under a mounted table in
256
+ * any caller, and a store rebuilt on every render would throw away the
257
+ * `computed` that keeps the parse to once per change.
258
+ */
259
+ const ownStore = createColumnStore(
260
+ () => specs.value,
261
+ props.tableId ? tableColumnBacking(props.tableId) : memoryBacking(),
262
+ { lead: leadId.value },
263
+ );
264
+ const store = computed<ColumnStore>(() => props.columnStore ?? ownStore);
265
+
266
+ /** The ids this listing is willing to draw at all, lead excluded. */
267
+ const candidateCols = computed<string[]>(() =>
268
+ (props.candidates ?? props.columns.map((c) => c.id)).filter((id) => id !== store.value.lead),
269
+ );
270
+
271
+ // ── the geometry ──────────────────────────────────────────────────────
272
+
273
+ /** `--fe-gap-sm` between cells and `--fe-gap` down each side. */
274
+ const CELL_GAP = 8;
275
+ const ROW_PAD = 12;
276
+ /** The tick's frozen track: its 28px plus the row padding that moves INTO it. */
277
+ const CHECK_TRACK = 28;
278
+ const LEAD_CHECK = CHECK_TRACK + ROW_PAD;
279
+
280
+ /** The trailing column's content width. The explorer's ⋮ is 28; a labelled
281
+ * "Actions" control needs room for its word. */
282
+ const menuContent = computed(() => props.actionsWidth ?? (props.rowActions ? 92 : 28));
283
+
284
+ const metrics = computed<LayoutMetrics>(() => ({
285
+ gap: CELL_GAP,
286
+ padding: ROW_PAD * 2,
287
+ check: props.selectable ? CHECK_TRACK : 0,
288
+ menu: menuContent.value,
289
+ ...(props.metrics ?? {}),
290
+ }));
291
+
292
+ /**
293
+ * The width the table actually has — its OWN scrollport, watched with a
294
+ * ResizeObserver, not the window's. An embed in a side panel is narrow on a
295
+ * wide screen. 0 until the first observation, which the layout reads as
296
+ * "keep the shipped widths" so the first paint is not a guess that jumps.
297
+ */
298
+ const listEl = ref<HTMLElement | null>(null);
299
+ const listWidth = ref(0);
300
+ let ro: ResizeObserver | null = null;
301
+
302
+ /** Is the table scrolled off its START edge? Draws the frozen edges' dividers —
303
+ * a permanent divider would claim a column floats when it simply fits. */
304
+ const scrolledX = ref(false);
305
+
306
+ function onListScroll(ev: Event) {
307
+ const el = ev.currentTarget as HTMLElement | null;
308
+ /* ⚠⚠ RTL: `scrollLeft` is 0 at the start edge and goes NEGATIVE as a
309
+ right-to-left table scrolls toward its end (the CSSOM rule every current
310
+ engine follows). `> 0` was never true there, so the frozen Name and ⋮
311
+ never drew their edges. Distance from the start, either way. */
312
+ const on = Math.abs(el?.scrollLeft ?? 0) > 0;
313
+ if (on !== scrolledX.value) scrolledX.value = on;
314
+ }
315
+
316
+ /* ⚠ The observer is (re)attached whenever the element appears, not only at
317
+ * mount (filex lesson #226): a table that mounts inside a `v-if` that opens
318
+ * later would otherwise lay out against 0px forever. */
319
+ watch(
320
+ listEl,
321
+ (el) => {
322
+ ro?.disconnect();
323
+ ro = null;
324
+ if (!el || typeof ResizeObserver === 'undefined') return;
325
+ /* ⚠ Straight in, no filter (PR #39's "settler" was taken back out). The
326
+ loop it guarded against — this width feeding a layout that summons the
327
+ vertical scrollbar that changes this width — cannot turn over any more:
328
+ `.fe-list` reserves the scrollbar's room (`scrollbar-gutter: stable`,
329
+ base.css), so the scrollbar never changes what is observed here. The
330
+ filter itself held a REAL resize that came back within 500 ms (a panel
331
+ toggled open and shut) at the narrower width: measured, the table stayed
332
+ 300px narrower than its pane (e2e 139). */
333
+ ro = new ResizeObserver((entries) => {
334
+ const w = entries[0]?.contentRect?.width ?? 0;
335
+ if (Math.abs(w - listWidth.value) >= 1) listWidth.value = w;
336
+ });
337
+ ro.observe(el);
338
+ },
339
+ { flush: 'post' },
340
+ );
341
+
342
+ const layout = computed(() => store.value.layout(listWidth.value, candidateCols.value, metrics.value));
343
+ const visibleCols = computed<string[]>(() => layout.value.visible);
344
+
345
+ /** ONE object, bound to the header and to every row, so the two cannot drift
346
+ * out of line with each other when the table is scrolled sideways. */
347
+ const tableStyle = computed(() => ({ width: `${layout.value.total}px` }));
348
+
349
+ /**
350
+ * THE FROZEN LEAD — only while it leaves room to scroll.
351
+ *
352
+ * ⚠⚠ A sticky cell wider than the pane covers the pane: 900px of Name frozen
353
+ * in a 706px list paints over every column it was frozen to keep company. So
354
+ * at least `PIN_LEAD_ROOM` of the pane has to be left for the columns that
355
+ * slide under it, or nothing is pinned and the table scrolls as one piece.
356
+ */
357
+ const PIN_LEAD_ROOM = 160;
358
+
359
+ const leadWidth = computed(
360
+ () =>
361
+ (props.selectable ? LEAD_CHECK + CELL_GAP : ROW_PAD) +
362
+ (layout.value.widths[store.value.lead] ?? store.value.width(store.value.lead)),
363
+ );
364
+
365
+ const pinLead = computed(
366
+ () => listWidth.value > 0 && listWidth.value - leadWidth.value >= PIN_LEAD_ROOM,
367
+ );
368
+
369
+ /** The trailing control's own track: its content plus the row padding. */
370
+ const menuWidth = computed(() => menuContent.value + ROW_PAD);
371
+
372
+ /**
373
+ * THE FROZEN CONTROL — the same rule as the lead's, for the same reason.
374
+ *
375
+ * ⚠⚠ A sticky cell paints over whatever slides beneath it, and it carries
376
+ * an opaque ground so it must. A labelled `Actions` control is 104px; in a
377
+ * 265px inspector panel it covered the two columns an app was drawing
378
+ * (State, When) so completely that its signer table read as names and
379
+ * buttons with a gap between them, while the person's promised "Waiting /
380
+ * Signed / Refused" was under the pinned cell (v0.43.0). So a control is
381
+ * frozen only while it leaves the columns sliding under it somewhere to be:
382
+ * `PIN_MENU_ROOM` of readable middle beside the lead. Below that nothing is
383
+ * pinned, the row scrolls as one piece, and the person sees the columns
384
+ * first and reaches the verbs with the same swipe the table already asks
385
+ * for.
386
+ *
387
+ * ⚠ Unmeasured (0 on the first tick, a test with no layout) keeps it
388
+ * pinned: that is what every table did before this rule, so nothing flashes.
389
+ */
390
+ const PIN_MENU_ROOM = 160;
391
+
392
+ const pinMenu = computed(
393
+ () =>
394
+ listWidth.value <= 0 ||
395
+ listWidth.value - leadWidth.value - menuWidth.value >= PIN_MENU_ROOM,
396
+ );
397
+
398
+ /** The lead's sticky offset: after the tick when there is one, the edge when
399
+ * there is not. */
400
+ const rootStyle = computed(() => ({
401
+ '--fe-list-lead': props.selectable ? `${LEAD_CHECK + CELL_GAP}px` : '0px',
402
+ }));
403
+
404
+ /** A drawn track's width, as the inline style the cell carries. `flex-basis`,
405
+ * not a grid track: a sticky GRID item is confined to its own grid area and
406
+ * cannot move at all. */
407
+ function colStyle(id: string) {
408
+ let w = layout.value.widths[id] ?? store.value.width(id);
409
+ /* Frozen with no tick in front of it, the lead takes the row's leading
410
+ padding INTO itself — `left: 0` pulls it to the glass, and a 12px inset
411
+ left on the row would scroll away and put the text against the edge. */
412
+ if (id === store.value.lead && pinLead.value && !props.selectable) w += ROW_PAD;
413
+ return { flex: `0 0 ${w}px` };
414
+ }
415
+
416
+ /** The trailing cell's track: content plus the 12px of row padding it absorbs. */
417
+ const menuStyle = computed(() =>
418
+ menuContent.value === 28 ? undefined : { flex: `0 0 ${menuContent.value + ROW_PAD}px` },
419
+ );
420
+
421
+ function colClass(id: string): string[] {
422
+ const c = colById.value.get(id);
423
+ const out = ['fe-list__col', 'fe-list__cell'];
424
+ /* ⚠ The column's OWN class before the lead role. CSS does not care about
425
+ the order, but anything that names a cell by its first
426
+ `fe-list__col--<id>` (the e2e column specs, a person's user stylesheet
427
+ inspected in devtools) read "lead" for the Name column instead of
428
+ "name" once the lead marker came first — 108-column-reorder went red
429
+ on every case on a table that was working. */
430
+ if (c?.class) out.push(c.class);
431
+ if (id === store.value.lead) out.push('fe-list__col--lead');
432
+ if (c?.align === 'right') out.push('is-right');
433
+ else if (c?.align === 'center') out.push('is-center');
434
+ return out;
435
+ }
436
+
437
+ function label(id: string): string {
438
+ return colById.value.get(id)?.label ?? id;
439
+ }
440
+
441
+ // ── sorting ───────────────────────────────────────────────────────────
442
+
443
+ const controlled = computed(() => props.sort !== undefined);
444
+
445
+ /** The table's own sort, when nobody controls it — remembered per table. */
446
+ const localSort = ref<{ key: string; dir: 'asc' | 'desc' } | null>(
447
+ props.tableId ? tableSort(props.tableId) : null,
448
+ );
449
+
450
+ const activeSort = computed(() => (controlled.value ? (props.sort ?? null) : localSort.value));
451
+
452
+ /** More rows exist than are on screen: the list is one page of several. */
453
+ const paged = computed(
454
+ () => (props.pages ?? 1) > 1 || (props.total != null && props.total > props.rows.length),
455
+ );
456
+
457
+ /**
458
+ * WHY the headers are closed, or null when they are open.
459
+ *
460
+ * ⚠⚠ A table that sorts its own rows while holding ONE PAGE of a longer list
461
+ * would put "A" at the top of page 2 and "B" at the top of page 1 and call the
462
+ * result sorted. The honest answer is the one the explorer already gives over
463
+ * a ranked search: the headers close and say why. A caller whose SERVER sorts
464
+ * passes `sort` and is never closed for this.
465
+ */
466
+ const closedWhy = computed<string | null>(() => {
467
+ if (props.sortClosed) return props.sortClosed;
468
+ if (!controlled.value && paged.value) return t('table.sort_paged');
469
+ return null;
470
+ });
471
+
472
+ function isSortable(id: string): boolean {
473
+ return colById.value.get(id)?.sortable === true;
474
+ }
475
+
476
+ function toggleSort(id: string) {
477
+ /* ⚠⚠ The `disabled` attribute is the AFFORDANCE, not the rule: a click
478
+ * dispatched in script still runs this listener. Measured on the explorer
479
+ * 2026-09-13: a scripted click on a closed header re-ordered nothing on
480
+ * screen and still wrote a new stored sort. */
481
+ if (closedWhy.value || !isSortable(id)) return;
482
+ const cur = activeSort.value;
483
+ const col = colById.value.get(id);
484
+ const next =
485
+ cur && cur.key === id
486
+ ? { key: id, dir: cur.dir === 'asc' ? ('desc' as const) : ('asc' as const) }
487
+ : { key: id, dir: col?.sortDir ?? ('asc' as const) };
488
+ if (!controlled.value) {
489
+ localSort.value = next;
490
+ if (props.tableId) setTableSort(props.tableId, next);
491
+ }
492
+ emit('sort', next);
493
+ }
494
+
495
+ function sortArrow(id: string): string {
496
+ const s = activeSort.value;
497
+ if (closedWhy.value || !s || s.key !== id) return '';
498
+ return s.dir === 'asc' ? '↑' : '↓';
499
+ }
500
+
501
+ function ariaSort(id: string): 'ascending' | 'descending' | 'none' | undefined {
502
+ if (!isSortable(id)) return undefined;
503
+ const s = activeSort.value;
504
+ if (closedWhy.value || !s || s.key !== id) return 'none';
505
+ return s.dir === 'asc' ? 'ascending' : 'descending';
506
+ }
507
+
508
+ const sortTitle = computed(() => closedWhy.value ?? t('col.sort'));
509
+
510
+ function valueOf(row: any, id: string): unknown {
511
+ const c = colById.value.get(id);
512
+ if (c?.sortValue) return c.sortValue(row);
513
+ if (c?.format) return c.format(row);
514
+ return row?.[id];
515
+ }
516
+
517
+ /** Empty last in both directions; numbers as numbers; words in the viewer's
518
+ * collation with digits read as numbers ("file 9" before "file 10"). */
519
+ function compareValues(a: unknown, b: unknown): number {
520
+ const ae = a === null || a === undefined || a === '';
521
+ const be = b === null || b === undefined || b === '';
522
+ if (ae && be) return 0;
523
+ if (ae) return 1;
524
+ if (be) return -1;
525
+ if (typeof a === 'number' && typeof b === 'number') return a - b;
526
+ if (typeof a === 'boolean' && typeof b === 'boolean') return Number(a) - Number(b);
527
+ return String(a).localeCompare(String(b), lang.value, { numeric: true, sensitivity: 'base' });
528
+ }
529
+
530
+ const sortedRows = computed<any[]>(() => {
531
+ const s = activeSort.value;
532
+ if (controlled.value || closedWhy.value || !s || !colById.value.has(s.key)) return props.rows;
533
+ const dir = s.dir === 'asc' ? 1 : -1;
534
+ return props.rows
535
+ .map((row, i) => ({ row, i, v: valueOf(row, s.key) }))
536
+ .sort((x, y) => {
537
+ const ae = x.v === null || x.v === undefined || x.v === '';
538
+ const be = y.v === null || y.v === undefined || y.v === '';
539
+ // ⚠ Empties go last in BOTH directions, so the arrow never floats a
540
+ // column of dashes to the top.
541
+ if (ae !== be) return ae ? 1 : -1;
542
+ return dir * compareValues(x.v, y.v) || x.i - y.i;
543
+ })
544
+ .map((x) => x.row);
545
+ });
546
+
547
+ const segments = computed<Group[]>(() => props.groups ?? [{ id: 'all', items: sortedRows.value }]);
548
+
549
+ const rowCount = computed(() => segments.value.reduce((s, g) => s + g.items.length, 0));
550
+
551
+ // ── rows ──────────────────────────────────────────────────────────────
552
+
553
+ function keyOf(row: any, i: number): string | number {
554
+ if (typeof props.rowKey === 'function') return props.rowKey(row);
555
+ if (props.rowKey) return row?.[props.rowKey] as string | number;
556
+ return i;
557
+ }
558
+
559
+ function selected(row: any): boolean {
560
+ return props.isSelected ? props.isSelected(row) : false;
561
+ }
562
+
563
+ function cellValue(row: any, id: string): unknown {
564
+ return row?.[id];
565
+ }
566
+
567
+ function cellText(row: any, id: string): string {
568
+ const c = colById.value.get(id);
569
+ const v = c?.format ? c.format(row) : row?.[id];
570
+ if (v === null || v === undefined || v === '') return '—';
571
+ return String(v);
572
+ }
573
+
574
+ function cellTitle(row: any, id: string): string | undefined {
575
+ const c = colById.value.get(id);
576
+ if (c?.title) return c.title(row);
577
+ const text = cellText(row, id);
578
+ return text === '—' ? undefined : text;
579
+ }
580
+
581
+ function rowClasses(row: any, i: number) {
582
+ return [
583
+ 'fe-list__row',
584
+ { 'is-selected': selected(row), 'is-clickable': !!rowClickable.value },
585
+ props.rowClass?.(row, i),
586
+ ];
587
+ }
588
+
589
+ /** Rows answer a click only when somebody listens for one. */
590
+ const rowClickable = ref(false);
591
+
592
+ // ── resizing ──────────────────────────────────────────────────────────
593
+
594
+ /** `sign` is the gesture's direction, read ONCE at pointerdown: +1 when the
595
+ * column's trailing edge is its right (LTR), -1 when it is its left (RTL). */
596
+ let resizing: { id: string; startX: number; startW: number; sign: 1 | -1 } | null = null;
597
+
598
+ /**
599
+ * THE FIRST DRAG FREEZES THE WHOLE ROW. Until somebody sizes a column the
600
+ * widths are derived from the pane and the auto lead takes the slack — so
601
+ * narrowing one column would widen the lead, which is the behaviour the owner
602
+ * called wrong. Committing every drawn width at the start of the gesture
603
+ * turns the drag into what it looks like.
604
+ */
605
+ function freezeBeforeEdit() {
606
+ if (!store.value.widthsAreAuto()) return;
607
+ store.value.freeze(layout.value.widths);
608
+ }
609
+
610
+ function isResizable(id: string): boolean {
611
+ return store.value.spec(id)?.resizable === true;
612
+ }
613
+
614
+ function onResizeStart(id: string, ev: PointerEvent) {
615
+ if (!isResizable(id)) return;
616
+ ev.preventDefault();
617
+ ev.stopPropagation();
618
+ freezeBeforeEdit();
619
+ /* ⚠⚠ RTL: the handle sits on the column's inline END edge — its LEFT side in
620
+ * a right-to-left table — so dragging it LEFT widens the column. A column
621
+ * edge follows the pointer in both directions; without the sign it grew
622
+ * when the pointer went the other way. */
623
+ resizing = { id, startX: ev.clientX, startW: store.value.width(id), sign: inlineSign(dirOfElement(listEl.value)) };
624
+ /* ⚠ On the WINDOW, not a pointer capture on the handle: the pointer leaves
625
+ * the 9px handle within a few pixels, and a capture on an element Vue
626
+ * re-renders mid-drag is released the moment it is patched. */
627
+ window.addEventListener('pointermove', onResizeMove);
628
+ window.addEventListener('pointerup', onResizeEnd);
629
+ }
630
+
631
+ function onResizeMove(ev: PointerEvent) {
632
+ if (!resizing) return;
633
+ store.value.setWidth(resizing.id, resizing.startW + resizing.sign * (ev.clientX - resizing.startX));
634
+ }
635
+
636
+ function onResizeEnd() {
637
+ resizing = null;
638
+ window.removeEventListener('pointermove', onResizeMove);
639
+ window.removeEventListener('pointerup', onResizeEnd);
640
+ }
641
+
642
+ /** Keyboard resizing — the handle is focusable, so it has to do something.
643
+ * 16px a step, about one character of the 13px body text. */
644
+ function onResizeKey(id: string, ev: KeyboardEvent) {
645
+ /* ⚠ Ctrl/⌘ + arrow is the REORDER (onHeadKey), and the handle sits inside
646
+ the header cell the reorder listens on. */
647
+ if (ev.ctrlKey || ev.metaKey) return;
648
+ /* The arrow moves the EDGE the way it points: → widens in LTR, ← in RTL. */
649
+ const step = 16 * inlineKeyStep(ev.key, dirOfElement(listEl.value));
650
+ if (!step) return;
651
+ ev.preventDefault();
652
+ freezeBeforeEdit();
653
+ store.value.setWidth(id, store.value.width(id) + step);
654
+ }
655
+
656
+ /** Double-click a handle: back to this column's shipped width. */
657
+ function onResizeReset(id: string) {
658
+ const spec = store.value.spec(id);
659
+ if (!spec) return;
660
+ freezeBeforeEdit();
661
+ store.value.setWidth(id, spec.width);
662
+ }
663
+
664
+ // ── reordering ────────────────────────────────────────────────────────
665
+
666
+ /**
667
+ * DRAG A HEADER TO MOVE ITS COLUMN.
668
+ *
669
+ * ⚠⚠ The threshold is the whole difficulty: clicking a header sorts and
670
+ * dragging it moves, and both start with the same pointerdown. Nothing
671
+ * happens until the pointer has travelled `DRAG_SLOP`; above it, the click
672
+ * the browser fires on pointerup is swallowed on its way past
673
+ * (`onHeadClickCapture`), or every completed drag would also re-sort.
674
+ */
675
+ const DRAG_SLOP = 5;
676
+ let dragState: { id: string; startX: number; armed: boolean } | null = null;
677
+ const dragCol = ref<string | null>(null);
678
+ /**
679
+ * Where it would land: a GAP index into the DRAWN columns (with the dragged
680
+ * one still in them — that is the row on screen). ⚠⚠ The drop reads this
681
+ * number against the SAME list; reading it against the list without the
682
+ * dragged column once turned "one place right" into three (2026-09-20).
683
+ * `null` means the release would change nothing, and the marker is absent.
684
+ */
685
+ const dropAt = ref<number | null>(null);
686
+ /** When a real drag last ended — a TIMESTAMP, because a flag left armed by a
687
+ * drag that produced no click would eat the next, unrelated sort click. */
688
+ let dragEndedAt = 0;
689
+ const CLICK_SWALLOW_MS = 300;
690
+
691
+ function isMovable(id: string): boolean {
692
+ return id !== store.value.lead && store.value.spec(id)?.hideable === true;
693
+ }
694
+
695
+ function onHeadPointerDown(id: string, ev: PointerEvent) {
696
+ if (ev.button !== 0 || !isMovable(id)) return;
697
+ dragState = { id, startX: ev.clientX, armed: false };
698
+ window.addEventListener('pointermove', onHeadPointerMove);
699
+ window.addEventListener('pointerup', onHeadPointerUp);
700
+ window.addEventListener('keydown', onDragKey, true);
701
+ /* ⚠ Every way a gesture can END, not just the happy one — a pointer the
702
+ browser takes away fires neither pointerup nor keydown. */
703
+ window.addEventListener('pointercancel', onDragAbort);
704
+ window.addEventListener('blur', onDragAbort);
705
+ }
706
+
707
+ function onDragAbort() {
708
+ const armed = dragState?.armed === true;
709
+ endDrag();
710
+ if (armed) dragEndedAt = Date.now();
711
+ }
712
+
713
+ /** The gaps a dragged column may be dropped into: the pinned columns at
714
+ * either end are un-passable (the explorer's star stays beside its ⋮). */
715
+ function dropGapRange(): { first: number; last: number } {
716
+ const drawn = visibleCols.value;
717
+ let first = 0;
718
+ while (first < drawn.length && !isMovable(drawn[first])) first++;
719
+ let last = drawn.length;
720
+ while (last > first && !isMovable(drawn[last - 1])) last--;
721
+ return { first, last };
722
+ }
723
+
724
+ function onHeadPointerMove(ev: PointerEvent) {
725
+ if (!dragState) return;
726
+ if (!dragState.armed) {
727
+ if (Math.abs(ev.clientX - dragState.startX) < DRAG_SLOP) return;
728
+ dragState.armed = true;
729
+ dragCol.value = dragState.id;
730
+ }
731
+ const head = listEl.value?.querySelector('.fe-list__head');
732
+ if (!head) return;
733
+ /* The header's own cells. ⚠ `children`, not `:scope > …`: the same answer
734
+ in a browser, and the one a DOM without `:scope` (happy-dom) gives too —
735
+ the RTL drop test (web/tests/ui/rtlGeometry) measured nothing without it. */
736
+ const cells = Array.from(head.children).filter((c) => c.classList.contains('fe-list__col')) as HTMLElement[];
737
+ /* The tick (when there is one) and the lead come first, the actions column
738
+ last; only the ones between are positions a column can take. */
739
+ const lead = props.selectable ? 2 : 1;
740
+ const drawnCells = cells.slice(lead, lead + visibleCols.value.length);
741
+ /* ⚠ RTL: gap i is BEFORE cell i in reading order — its right-hand half in a
742
+ * right-to-left table. The pointer is compared against each cell's middle
743
+ * in the direction the columns run. */
744
+ const sign = inlineSign(dirOfElement(listEl.value));
745
+ let idx = drawnCells.length;
746
+ for (let i = 0; i < drawnCells.length; i++) {
747
+ const box = drawnCells[i].getBoundingClientRect();
748
+ if (sign * (ev.clientX - (box.left + box.width / 2)) < 0) {
749
+ idx = i;
750
+ break;
751
+ }
752
+ }
753
+ const { first, last } = dropGapRange();
754
+ idx = Math.max(first, Math.min(last, idx));
755
+ /* ⚠⚠ Its own two gaps are NOT a drop: landing in either leaves the order as
756
+ * it was, so the marker hides and the release writes nothing. (Owner,
757
+ * 2026-09-20: "bırakmadan kendi yerine geri getirip bırakınca yine bir adım
758
+ * ileri gidiyor".) */
759
+ const from = visibleCols.value.indexOf(dragState.id);
760
+ dropAt.value = idx === from || idx === from + 1 ? null : idx;
761
+ }
762
+
763
+ function onHeadPointerUp() {
764
+ const st = dragState;
765
+ const target = dropAt.value;
766
+ endDrag();
767
+ if (!st?.armed) return;
768
+ dragEndedAt = Date.now();
769
+ if (target == null) return;
770
+ applyColumnDrop(st.id, target);
771
+ }
772
+
773
+ /**
774
+ * A gap among the DRAWN columns → a place in the full stored order, through
775
+ * the NEIGHBOUR's identity: a column this listing does not draw still holds a
776
+ * place in the stored sequence, and dropping between two visible columns must
777
+ * not silently reshuffle the one hiding between them.
778
+ */
779
+ function applyColumnDrop(id: string, gap: number) {
780
+ const drawn = visibleCols.value;
781
+ const from = drawn.indexOf(id);
782
+ if (from === -1) return;
783
+ if (gap === from || gap === from + 1) return;
784
+ const restDrawn = drawn.filter((c) => c !== id);
785
+ const beforeId = restDrawn[gap > from ? gap - 1 : gap];
786
+ const restFull = store.value.order().filter((c) => c !== id);
787
+ const index = beforeId ? restFull.indexOf(beforeId) : restFull.length;
788
+ if (index === -1) return;
789
+ store.value.move(id, index);
790
+ }
791
+
792
+ /**
793
+ * The columns a keyboard step may land among: the movable ones THIS LISTING
794
+ * offers, in the person's order. ⚠ Not the stored order — a column this
795
+ * listing does not draw (the explorer's Location in an ordinary folder) would
796
+ * be stepped over invisibly and the button would read as broken (measured
797
+ * 2026-09-20).
798
+ */
799
+ function steppableCols(): string[] {
800
+ return store.value.order().filter((id) => isMovable(id) && candidateCols.value.includes(id));
801
+ }
802
+
803
+ function stepColumn(id: string, delta: -1 | 1) {
804
+ const list = steppableCols();
805
+ const at = list.indexOf(id);
806
+ const to = at + delta;
807
+ if (at === -1 || to < 0 || to >= list.length) return;
808
+ const neighbour = list[to];
809
+ const rest = store.value.order().filter((c) => c !== id);
810
+ const k = rest.indexOf(neighbour);
811
+ if (k === -1) return;
812
+ store.value.move(id, delta === -1 ? k : k + 1);
813
+ }
814
+
815
+ function canStepColumn(id: string, delta: -1 | 1): boolean {
816
+ const list = steppableCols();
817
+ const at = list.indexOf(id);
818
+ return at !== -1 && at + delta >= 0 && at + delta < list.length;
819
+ }
820
+
821
+ /** Ctrl/⌘ + ←/→ on a focused header moves its column one place — the drag,
822
+ * reachable without a pointer. ⚠ RTL: the column moves the way the arrow
823
+ * points, which is one place EARLIER for → in a right-to-left table. */
824
+ function onHeadKey(id: string, ev: KeyboardEvent) {
825
+ if (!ev.ctrlKey && !ev.metaKey) return;
826
+ const delta = inlineKeyStep(ev.key, dirOfElement(listEl.value));
827
+ if (!delta || !isMovable(id)) return;
828
+ ev.preventDefault();
829
+ ev.stopPropagation();
830
+ stepColumn(id, delta as -1 | 1);
831
+ }
832
+
833
+ function onDragKey(ev: KeyboardEvent) {
834
+ if (ev.key !== 'Escape') return;
835
+ ev.stopPropagation();
836
+ onDragAbort();
837
+ }
838
+
839
+ /** THE ONE PLACE THE DRAG STATE IS TORN DOWN — the state the template reads
840
+ * and the window listeners, always both. */
841
+ function endDrag() {
842
+ dragState = null;
843
+ dragCol.value = null;
844
+ dropAt.value = null;
845
+ window.removeEventListener('pointermove', onHeadPointerMove);
846
+ window.removeEventListener('pointerup', onHeadPointerUp);
847
+ window.removeEventListener('keydown', onDragKey, true);
848
+ window.removeEventListener('pointercancel', onDragAbort);
849
+ window.removeEventListener('blur', onDragAbort);
850
+ }
851
+
852
+ /** Capture phase, on the header row: a click that ended a drag never reaches
853
+ * the sort button underneath it. */
854
+ function onHeadClickCapture(ev: MouseEvent) {
855
+ if (Date.now() - dragEndedAt > CLICK_SWALLOW_MS) return;
856
+ dragEndedAt = 0;
857
+ ev.preventDefault();
858
+ ev.stopPropagation();
859
+ }
860
+
861
+ // ── the column menu ───────────────────────────────────────────────────
862
+
863
+ /**
864
+ * A small settings popover, opened by the header's ⋮ or by right-clicking
865
+ * the header. ⚠ Not `ContextMenu`: it is a panel of checkboxes, and a menu
866
+ * closes on every pick, so turning four columns off would be four separate
867
+ * right-clicks.
868
+ */
869
+ const colMenuOpen = ref(false);
870
+ const colMenuX = ref(0);
871
+ const colMenuY = ref(0);
872
+ /** The x the menu was opened at — kept apart from `colMenuX`, which the clamp rewrites. */
873
+ let colMenuAnchor = 0;
874
+ const colMenuEl = ref<HTMLElement | null>(null);
875
+
876
+ const prefersDark = ref(false);
877
+ onMounted(() => {
878
+ try {
879
+ prefersDark.value = window.matchMedia?.('(prefers-color-scheme: dark)').matches ?? false;
880
+ } catch {
881
+ /* jsdom / no matchMedia */
882
+ }
883
+ });
884
+ /** ⚠ The menu is teleported to <body>, outside the `.fe` ancestor that scopes
885
+ * the explorer's tokens, so it carries the theme class `variables.css`
886
+ * re-applies dark values under — the same reason ContextMenu takes one. */
887
+ const colMenuThemeClass = computed(() => `fe-ctx-backdrop--theme-${mode.value || 'auto'}`);
888
+
889
+ /**
890
+ * ⚠ RTL — the two nudges follow READING order, like every other logical
891
+ * thing: the first moves the column one place EARLIER (toward the table's
892
+ * start edge), the second one place later, and they sit in that order. What
893
+ * they SAY is physical — `cols.move_left` "Move {col} left" under ← — so in a
894
+ * right-to-left table, where earlier is RIGHTWARD, the first one wears → and
895
+ * `cols.move_right`, the second ← and `cols.move_left`. Arrow, label and
896
+ * movement agree in both directions, and the tab order is the reading order
897
+ * (a CSS `order` swap would have kept the picture and broken that).
898
+ */
899
+ const nudge = computed(() =>
900
+ dir.value === 'rtl'
901
+ ? { earlier: { key: 'cols.move_right', arrow: '\u2192' }, later: { key: 'cols.move_left', arrow: '\u2190' } }
902
+ : { earlier: { key: 'cols.move_left', arrow: '\u2190' }, later: { key: 'cols.move_right', arrow: '\u2192' } },
903
+ );
904
+
905
+ const menuCols = computed(() =>
906
+ /* In the person's OWN order, so the list reads as the table on screen. */
907
+ store.value
908
+ .order()
909
+ .filter((id) => isMovable(id) && candidateCols.value.includes(id))
910
+ .map((id) => ({
911
+ id,
912
+ label: label(id),
913
+ on: !store.value.hidden(id),
914
+ canEarlier: canStepColumn(id, -1),
915
+ canLater: canStepColumn(id, 1),
916
+ })),
917
+ );
918
+
919
+ const canResetCols = computed(() => store.value.customised());
920
+
921
+ async function openColMenu(ev: MouseEvent) {
922
+ ev.preventDefault();
923
+ ev.stopPropagation();
924
+ const el = ev.currentTarget as HTMLElement | null;
925
+ const box = el?.getBoundingClientRect();
926
+ // ⚠ RTL: a keyboard open (no pointer) hangs the menu from the ⋮'s START edge.
927
+ colMenuAnchor = ev.clientX || (box ? inlineStartX(box, dir.value) : 0);
928
+ colMenuX.value = colMenuAnchor;
929
+ colMenuY.value = ev.clientY || (box ? box.bottom : 0);
930
+ colMenuOpen.value = true;
931
+ await nextTick();
932
+ clampColMenu();
933
+ colMenuEl.value?.focus();
934
+ }
935
+
936
+ function clampColMenu() {
937
+ const el = colMenuEl.value;
938
+ if (!el) return;
939
+ const box = el.getBoundingClientRect();
940
+ const pad = 8;
941
+ /* ⚠ RTL: it hangs toward the inline END of its anchor — leftward in a
942
+ right-to-left table — and is pushed back from THAT edge
943
+ (lib/direction; the LTR arithmetic is unchanged). */
944
+ colMenuX.value = clampAlongInline(colMenuAnchor, box.width, window.innerWidth, dir.value, pad);
945
+ if (box.bottom > window.innerHeight - pad) {
946
+ colMenuY.value = Math.max(pad, window.innerHeight - pad - box.height);
947
+ }
948
+ }
949
+
950
+ function closeColMenu() {
951
+ colMenuOpen.value = false;
952
+ }
953
+
954
+ /**
955
+ * ESCAPE CLOSES IT, from wherever the focus happens to be. ⚠⚠ Half the
956
+ * controls in the menu remove themselves when used ("Reset columns" vanishes
957
+ * once there is nothing to reset), focus falls back to <body>, and a
958
+ * full-screen backdrop that only heard Escape from inside itself became a
959
+ * trap that swallowed every later pointer gesture (measured 2026-09-20).
960
+ */
961
+ function onColMenuKey(ev: KeyboardEvent) {
962
+ if (ev.key !== 'Escape') return;
963
+ ev.stopPropagation();
964
+ closeColMenu();
965
+ }
966
+
967
+ watch(colMenuOpen, (open) => {
968
+ if (typeof window === 'undefined') return;
969
+ if (open) window.addEventListener('keydown', onColMenuKey, true);
970
+ else window.removeEventListener('keydown', onColMenuKey, true);
971
+ });
972
+
973
+ function toggleCol(id: string) {
974
+ store.value.setHidden(id, !store.value.hidden(id));
975
+ }
976
+
977
+ function onResetColumns() {
978
+ store.value.reset();
979
+ }
980
+
981
+ onBeforeUnmount(() => {
982
+ ro?.disconnect();
983
+ ro = null;
984
+ onResizeEnd();
985
+ endDrag();
986
+ if (typeof window !== 'undefined') window.removeEventListener('keydown', onColMenuKey, true);
987
+ });
988
+
989
+ // ── the footer ────────────────────────────────────────────────────────
990
+
991
+ const totalPages = computed(() => {
992
+ if (props.pages != null) return Math.max(1, props.pages);
993
+ if (!props.total || !props.pageSize) return 1;
994
+ return Math.max(1, Math.ceil(props.total / props.pageSize));
995
+ });
996
+ const currentPage = computed(() => props.page ?? 1);
997
+ const showPager = computed(() => totalPages.value > 1);
998
+
999
+ /** "26 – 50 / 58" when the endpoint knows the total. */
1000
+ const rangeLabel = computed(() => {
1001
+ if (props.total != null && props.pageSize != null && props.total > 0) {
1002
+ const from = (currentPage.value - 1) * props.pageSize + 1;
1003
+ const to = Math.min(currentPage.value * props.pageSize, props.total);
1004
+ return `${from} – ${to} / ${props.total}`;
1005
+ }
1006
+ return '';
1007
+ });
1008
+
1009
+ function go(p: number) {
1010
+ if (p < 1 || p > totalPages.value) return;
1011
+ emit('page', p);
1012
+ }
1013
+
1014
+ // ── row events ────────────────────────────────────────────────────────
1015
+
1016
+ /* A row is "clickable" (pointer cursor) when the caller listens for clicks —
1017
+ * read off the vnode props the parent bound. */
1018
+ const inst = getCurrentInstance();
1019
+ rowClickable.value = !!(inst?.vnode.props as Record<string, unknown> | null)?.onRowClick;
1020
+
1021
+ function onRowClick(row: any, ev: MouseEvent) {
1022
+ emit('row-click', row, ev);
1023
+ }
1024
+
1025
+ function onRowAction(key: string, row: any, action: ContextAction) {
1026
+ emit('row-action', key, row, action);
1027
+ }
1028
+
1029
+ defineExpose({ store, layout, pinLead });
1030
+ </script>
1031
+
1032
+ <template>
1033
+ <!-- ⚠ `display: contents` when unframed (the explorer): the listing's pane
1034
+ lays out `.fe-list` exactly as it did before there was a wrapper. -->
1035
+ <div v-bind="$attrs" class="fe-table" :class="{ 'fe-table--framed': framed, 'fe-table--bare': !framed }">
1036
+ <div v-if="$slots.toolbar" class="tbl-bar">
1037
+ <slot name="toolbar" />
1038
+ </div>
1039
+ <div
1040
+ ref="listEl"
1041
+ class="fe-list"
1042
+ :class="{
1043
+ 'is-loading': loading,
1044
+ 'is-scrolled-x': scrolledX,
1045
+ 'is-pin-lead': pinLead,
1046
+ 'is-pin-menu': pinMenu,
1047
+ 'fe-list--nocheck': !selectable,
1048
+ }"
1049
+ :style="rootStyle"
1050
+ role="grid"
1051
+ :aria-label="ariaLabel"
1052
+ :aria-busy="loading ? 'true' : undefined"
1053
+ :data-table-id="tableId"
1054
+ @scroll.passive="onListScroll"
1055
+ >
1056
+ <div
1057
+ class="fe-list__head"
1058
+ role="row"
1059
+ :class="{ 'is-dragging-col': !!dragCol }"
1060
+ :style="tableStyle"
1061
+ @contextmenu="openColMenu"
1062
+ @click.capture="onHeadClickCapture"
1063
+ >
1064
+ <!-- The tick column's header stays empty on purpose: a select-all tick
1065
+ is a real affordance with real consequences and none of the
1066
+ callers asks for one. -->
1067
+ <div v-if="selectable" class="fe-list__col fe-list__col--check" role="columnheader"></div>
1068
+ <div
1069
+ :class="colClass(store.lead)"
1070
+ role="columnheader"
1071
+ :style="colStyle(store.lead)"
1072
+ :aria-sort="ariaSort(store.lead)"
1073
+ :data-col="store.lead"
1074
+ >
1075
+ <button
1076
+ v-if="isSortable(store.lead)"
1077
+ type="button"
1078
+ class="fe-list__sort"
1079
+ :disabled="!!closedWhy"
1080
+ :title="sortTitle"
1081
+ @click="toggleSort(store.lead)"
1082
+ >
1083
+ {{ label(store.lead) }}
1084
+ <span v-if="sortArrow(store.lead)" class="fe-list__sort-arrow" aria-hidden="true">{{ sortArrow(store.lead) }}</span>
1085
+ </button>
1086
+ <span v-else class="fe-list__head-label">{{ label(store.lead) }}</span>
1087
+ <span
1088
+ v-if="isResizable(store.lead)"
1089
+ class="fe-list__resize"
1090
+ role="separator"
1091
+ aria-orientation="vertical"
1092
+ tabindex="0"
1093
+ :aria-label="t('cols.resize', { col: label(store.lead) })"
1094
+ :title="t('cols.resize', { col: label(store.lead) })"
1095
+ @pointerdown="onResizeStart(store.lead, $event)"
1096
+ @dblclick.stop="onResizeReset(store.lead)"
1097
+ @keydown="onResizeKey(store.lead, $event)"
1098
+ ></span>
1099
+ </div>
1100
+ <div
1101
+ v-for="(id, ci) in visibleCols"
1102
+ :key="id"
1103
+ :class="[
1104
+ colClass(id),
1105
+ {
1106
+ 'is-col-dragging': dragCol === id,
1107
+ 'is-drop-before': dropAt === ci,
1108
+ 'is-drop-after': dropAt === visibleCols.length && ci === visibleCols.length - 1,
1109
+ 'is-movable': isMovable(id),
1110
+ },
1111
+ ]"
1112
+ role="columnheader"
1113
+ :style="colStyle(id)"
1114
+ :aria-sort="ariaSort(id)"
1115
+ :aria-label="colById.get(id)?.headerLabel === false ? label(id) : undefined"
1116
+ :data-col="id"
1117
+ @pointerdown="onHeadPointerDown(id, $event)"
1118
+ @keydown="onHeadKey(id, $event)"
1119
+ >
1120
+ <button
1121
+ v-if="isSortable(id)"
1122
+ type="button"
1123
+ class="fe-list__sort"
1124
+ :disabled="!!closedWhy"
1125
+ :title="sortTitle"
1126
+ @click="toggleSort(id)"
1127
+ >
1128
+ {{ label(id) }}
1129
+ <span v-if="sortArrow(id)" class="fe-list__sort-arrow" aria-hidden="true">{{ sortArrow(id) }}</span>
1130
+ </button>
1131
+ <span v-else-if="colById.get(id)?.headerLabel !== false" class="fe-list__head-label">{{ label(id) }}</span>
1132
+ <span
1133
+ v-if="isResizable(id)"
1134
+ class="fe-list__resize"
1135
+ role="separator"
1136
+ aria-orientation="vertical"
1137
+ tabindex="0"
1138
+ :aria-label="t('cols.resize', { col: label(id) })"
1139
+ :title="t('cols.resize', { col: label(id) })"
1140
+ @pointerdown="onResizeStart(id, $event)"
1141
+ @dblclick.stop="onResizeReset(id)"
1142
+ @keydown="onResizeKey(id, $event)"
1143
+ ></span>
1144
+ </div>
1145
+ <div
1146
+ class="fe-list__col fe-list__col--menu"
1147
+ :class="{ 'is-wide': menuContent !== 28 }"
1148
+ :style="menuStyle"
1149
+ role="columnheader"
1150
+ >
1151
+ <button
1152
+ type="button"
1153
+ class="fe-list__menu fe-list__colmenu-btn"
1154
+ :title="t('cols.menu')"
1155
+ :aria-label="t('cols.menu')"
1156
+ aria-haspopup="dialog"
1157
+ :aria-expanded="colMenuOpen ? 'true' : 'false'"
1158
+ @click="openColMenu"
1159
+ ><span aria-hidden="true">&#8942;</span></button>
1160
+ </div>
1161
+ </div>
1162
+ <div class="fe-list__body" role="rowgroup">
1163
+ <template v-for="seg in segments" :key="seg.id">
1164
+ <div v-if="seg.label" class="fe-list__group" role="presentation">{{ seg.label }}</div>
1165
+ <div
1166
+ v-for="(row, ri) in seg.items"
1167
+ :key="keyOf(row, ri)"
1168
+ :class="rowClasses(row, ri)"
1169
+ role="row"
1170
+ :style="tableStyle"
1171
+ :aria-selected="selectable ? (selected(row) ? 'true' : 'false') : undefined"
1172
+ v-bind="rowAttrs ? rowAttrs(row) : {}"
1173
+ @click="onRowClick(row, $event)"
1174
+ @dblclick="emit('row-dblclick', row, $event)"
1175
+ @contextmenu="emit('row-contextmenu', row, $event)"
1176
+ @dragstart="emit('row-dragstart', row, $event)"
1177
+ @dragover="emit('row-dragover', row, $event)"
1178
+ @dragleave="emit('row-dragleave', row, $event)"
1179
+ @drop="emit('row-drop', row, $event)"
1180
+ @touchstart.passive="emit('row-touchstart', row, $event)"
1181
+ @touchend="emit('row-touchend', $event)"
1182
+ @touchmove.passive="emit('row-touchmove', $event)"
1183
+ >
1184
+ <!-- issue #26 — the tick is the one click that selects; the whole
1185
+ cell is its target. `data-fe-control` keeps a finger on it
1186
+ from counting as a tap on the row (useRowTouch). -->
1187
+ <div
1188
+ v-if="selectable"
1189
+ class="fe-list__col fe-list__col--check"
1190
+ role="gridcell"
1191
+ data-fe-control
1192
+ @click.stop="emit('check-click', row, $event)"
1193
+ @dblclick.stop
1194
+ >
1195
+ <ItemCheck :on="selected(row)" :label="cellText(row, store.lead)" />
1196
+ </div>
1197
+ <div :class="colClass(store.lead)" role="gridcell" :style="colStyle(store.lead)" :title="$slots[`cell-${store.lead}`] ? undefined : cellTitle(row, store.lead)">
1198
+ <slot :name="`cell-${store.lead}`" :row="row" :value="cellValue(row, store.lead)">
1199
+ <span class="fe-list__cell-text"><bdi>{{ cellText(row, store.lead) }}</bdi></span>
1200
+ </slot>
1201
+ </div>
1202
+ <div
1203
+ v-for="id in visibleCols"
1204
+ :key="id"
1205
+ :class="colClass(id)"
1206
+ role="gridcell"
1207
+ :style="colStyle(id)"
1208
+ :data-col="id"
1209
+ :title="$slots[`cell-${id}`] ? undefined : cellTitle(row, id)"
1210
+ >
1211
+ <slot :name="`cell-${id}`" :row="row" :value="cellValue(row, id)">
1212
+ <span class="fe-list__cell-text"><bdi>{{ cellText(row, id) }}</bdi></span>
1213
+ </slot>
1214
+ </div>
1215
+ <div
1216
+ class="fe-list__col fe-list__col--menu"
1217
+ :class="{ 'is-wide': menuContent !== 28 }"
1218
+ :style="menuStyle"
1219
+ role="gridcell"
1220
+ data-fe-control
1221
+ @click.stop
1222
+ @dblclick.stop
1223
+ >
1224
+ <slot name="actions" :row="row">
1225
+ <!-- A row with NO verbs at all draws no control (a disabled
1226
+ "Actions" over nothing is a promise the row cannot keep);
1227
+ a row whose verbs are all unavailable right now draws it
1228
+ greyed, with each verb's reason — RowActions' own rule. -->
1229
+ <RowActions
1230
+ v-if="rowActions && rowActions(row).length"
1231
+ :locale="lang"
1232
+ :theme="mode"
1233
+ :actions="rowActions(row)"
1234
+ :testid="rowActionsTestId?.(row)"
1235
+ @select="(key: string, action: ContextAction) => onRowAction(key, row, action)"
1236
+ />
1237
+ </slot>
1238
+ </div>
1239
+ </div>
1240
+ </template>
1241
+ <div v-if="loading && rowCount === 0" class="fe-list__empty fe-list__empty--loading" role="status">
1242
+ {{ t('loading') }}
1243
+ </div>
1244
+ <div v-else-if="!loading && rowCount === 0" class="fe-list__empty">
1245
+ <slot name="empty">{{ empty ?? t('table.empty') }}</slot>
1246
+ </div>
1247
+ </div>
1248
+ </div>
1249
+ <div v-if="showPager || footNote || $slots.foot" class="tbl-foot">
1250
+ <span>
1251
+ <slot name="foot">{{ footNote || rangeLabel }}</slot>
1252
+ </span>
1253
+ <div v-if="showPager" class="tbl-pager">
1254
+ <button
1255
+ type="button"
1256
+ class="tbl-page"
1257
+ :disabled="currentPage <= 1"
1258
+ :aria-label="t('table.prev')"
1259
+ :title="t('table.prev')"
1260
+ @click="go(currentPage - 1)"
1261
+ >‹</button>
1262
+ <span class="tbl-pager__at" dir="ltr">{{ currentPage }} / {{ totalPages }}</span>
1263
+ <button
1264
+ type="button"
1265
+ class="tbl-page"
1266
+ :disabled="currentPage >= totalPages"
1267
+ :aria-label="t('table.next')"
1268
+ :title="t('table.next')"
1269
+ @click="go(currentPage + 1)"
1270
+ >›</button>
1271
+ </div>
1272
+ </div>
1273
+ </div>
1274
+ <Teleport to="body">
1275
+ <div
1276
+ v-if="colMenuOpen"
1277
+ class="fe-colmenu__backdrop"
1278
+ :dir="dir"
1279
+ :class="colMenuThemeClass"
1280
+ :data-prefers-dark="prefersDark ? '1' : '0'"
1281
+ @click="closeColMenu"
1282
+ @contextmenu.prevent="closeColMenu"
1283
+ >
1284
+ <div
1285
+ ref="colMenuEl"
1286
+ class="fe-colmenu"
1287
+ role="dialog"
1288
+ tabindex="-1"
1289
+ :aria-label="t('cols.menu')"
1290
+ :style="{ top: colMenuY + 'px', left: colMenuX + 'px' }"
1291
+ @click.stop
1292
+ @keydown.esc.stop="closeColMenu"
1293
+ >
1294
+ <p class="fe-colmenu__title">{{ t('cols.title') }}</p>
1295
+ <div v-for="c in menuCols" :key="c.id" class="fe-colmenu__line">
1296
+ <button
1297
+ type="button"
1298
+ class="fe-colmenu__row"
1299
+ role="checkbox"
1300
+ :aria-checked="c.on ? 'true' : 'false'"
1301
+ @click="toggleCol(c.id)"
1302
+ >
1303
+ <span class="fe-list__check fe-colmenu__tick" :class="{ 'is-on': c.on }" aria-hidden="true"></span>
1304
+ <span class="fe-colmenu__label">{{ c.label }}</span>
1305
+ </button>
1306
+ <button
1307
+ type="button"
1308
+ class="fe-colmenu__nudge"
1309
+ :disabled="!c.canEarlier"
1310
+ :title="t(nudge.earlier.key, { col: c.label })"
1311
+ :aria-label="t(nudge.earlier.key, { col: c.label })"
1312
+ @click="stepColumn(c.id, -1)"
1313
+ ><span aria-hidden="true">{{ nudge.earlier.arrow }}</span></button>
1314
+ <button
1315
+ type="button"
1316
+ class="fe-colmenu__nudge"
1317
+ :disabled="!c.canLater"
1318
+ :title="t(nudge.later.key, { col: c.label })"
1319
+ :aria-label="t(nudge.later.key, { col: c.label })"
1320
+ @click="stepColumn(c.id, 1)"
1321
+ ><span aria-hidden="true">{{ nudge.later.arrow }}</span></button>
1322
+ </div>
1323
+ <div v-if="canResetCols" class="fe-colmenu__sep" role="separator"></div>
1324
+ <button v-if="canResetCols" type="button" class="fe-colmenu__row" @click="onResetColumns">
1325
+ <span class="fe-colmenu__label">{{ t('cols.reset') }}</span>
1326
+ </button>
1327
+ <slot name="colmenu-extra" :close="closeColMenu" />
1328
+ </div>
1329
+ </div>
1330
+ </Teleport>
1331
+ </template>