@brftech/filex-core 0.42.1 → 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.
- package/README.md +14 -2
- package/dist/ArchiveViewer-BqU761Wk.js +149 -0
- package/dist/ArchiveViewer-BqU761Wk.js.map +1 -0
- package/dist/CsvViewer-ByHVK5ga.js +172 -0
- package/dist/CsvViewer-ByHVK5ga.js.map +1 -0
- package/dist/{DrawioViewer-BuhZOAx6.js → DrawioViewer-Bgrj8-zG.js} +45 -44
- package/dist/DrawioViewer-Bgrj8-zG.js.map +1 -0
- package/dist/{EpubViewer-DS3mb3k8.js → EpubViewer-DJwUKlHW.js} +50 -50
- package/dist/EpubViewer-DJwUKlHW.js.map +1 -0
- package/dist/IpynbViewer-Bz0eJ4NS.js +184 -0
- package/dist/IpynbViewer-Bz0eJ4NS.js.map +1 -0
- package/dist/MermaidViewer-CEDTD_pp.js +136 -0
- package/dist/MermaidViewer-CEDTD_pp.js.map +1 -0
- package/dist/PsdViewer-CZcxUpZO.js +121 -0
- package/dist/PsdViewer-CZcxUpZO.js.map +1 -0
- package/dist/TiffViewer-BwI62CbQ.js +141 -0
- package/dist/TiffViewer-BwI62CbQ.js.map +1 -0
- package/dist/{Viewer3D-CIrOn63I.js → Viewer3D-DoeZcuMm.js} +16 -16
- package/dist/Viewer3D-DoeZcuMm.js.map +1 -0
- package/dist/filex-core.js +475 -219
- package/dist/filex-core.umd.cjs +165 -108
- package/dist/filex-core.umd.cjs.map +1 -1
- package/dist/fonts/caveat-latin-ext.woff2 +0 -0
- package/dist/fonts/caveat-latin.woff2 +0 -0
- package/dist/fonts/dancing-script-latin-ext.woff2 +0 -0
- package/dist/fonts/dancing-script-latin.woff2 +0 -0
- package/dist/fonts/homemade-apple-latin.woff2 +0 -0
- package/dist/fonts/source-serif-4-latin-ext.woff2 +0 -0
- package/dist/fonts/source-serif-4-latin.woff2 +0 -0
- package/dist/index-DuWnGuv5.js +35901 -0
- package/dist/index-DuWnGuv5.js.map +1 -0
- package/dist/index.d.ts +5241 -553
- package/dist/pdf-CaPYsOUo.js +21302 -0
- package/dist/pdf-CaPYsOUo.js.map +1 -0
- package/dist/style.css +1 -1
- package/package.json +1 -1
- package/src/FileExplorer.vue +1207 -343
- package/src/assets/fonts/LICENSE-Apache-2.0.txt +202 -0
- package/src/assets/fonts/LICENSE-OFL-1.1.txt +93 -0
- package/src/assets/fonts/README.md +58 -0
- package/src/assets/fonts/caveat-latin-ext.woff2 +0 -0
- package/src/assets/fonts/caveat-latin.woff2 +0 -0
- package/src/assets/fonts/dancing-script-latin-ext.woff2 +0 -0
- package/src/assets/fonts/dancing-script-latin.woff2 +0 -0
- package/src/assets/fonts/homemade-apple-latin.woff2 +0 -0
- package/src/assets/fonts/source-serif-4-latin-ext.woff2 +0 -0
- package/src/assets/fonts/source-serif-4-latin.woff2 +0 -0
- package/src/components/AdvancedSearch.vue +49 -0
- package/src/components/Breadcrumb.vue +5 -5
- package/src/components/ChoiceButtons.vue +152 -0
- package/src/components/CommandPalette.vue +5 -5
- package/src/components/ConnectionGuideView.vue +1 -1
- package/src/components/ConnectionNotice.vue +59 -0
- package/src/components/ConnectionsPanel.vue +30 -568
- package/src/components/ContextMenu.vue +31 -5
- package/src/components/DataTable.vue +1331 -0
- package/src/components/FilePane.vue +3 -0
- package/src/components/FilterBar.vue +12 -5
- package/src/components/GalleryView.vue +50 -2
- package/src/components/GridView.vue +51 -3
- package/src/components/HomeView.vue +18 -6
- package/src/components/InspectorPanel.vue +134 -17
- package/src/components/ListView.vue +366 -900
- package/src/components/LogoMark.vue +57 -0
- package/src/components/NFSExportsPanel.vue +76 -28
- package/src/components/OnboardingTour.vue +19 -7
- package/src/components/OperationsCenter.vue +77 -1
- package/src/components/PendingOpsTray.vue +59 -5
- package/src/components/PresenceBar.vue +7 -7
- package/src/components/ProductVersion.vue +31 -0
- package/src/components/QuickLook.vue +10 -14
- package/src/components/RecentlyOpened.vue +47 -17
- package/src/components/RecoveryKeyModal.vue +1 -1
- package/src/components/RowActions.vue +134 -0
- package/src/components/S3KeysPanel.vue +98 -56
- package/src/components/SSHKeysPanel.vue +79 -26
- package/src/components/SideNav.vue +260 -96
- package/src/components/StarButton.vue +1 -0
- package/src/components/StorageFields.vue +74 -6
- package/src/components/StorageTags.vue +62 -0
- package/src/components/TabBar.vue +1 -1
- package/src/components/TagKindIcon.vue +56 -0
- package/src/components/TagPicker.vue +226 -40
- package/src/components/ThemePalette.vue +4 -4
- package/src/components/TimeZonePicker.vue +29 -10
- package/src/components/TokensPanel.vue +82 -27
- package/src/components/Toolbar.vue +28 -6
- package/src/components/plugin/PluginConfirmModal.vue +54 -0
- package/src/components/plugin/PluginInspectorSection.vue +149 -0
- package/src/components/plugin/PluginPageView.vue +339 -0
- package/src/components/plugin/PluginViewModal.vue +159 -0
- package/src/components/plugin/SurfaceConversation.vue +76 -0
- package/src/components/plugin/SurfaceFooterButtons.vue +73 -0
- package/src/components/plugin/SurfaceRenderer.vue +299 -0
- package/src/components/plugin/SurfaceSections.vue +91 -0
- package/src/components/plugin/nodes/PdfFieldEditor.vue +509 -0
- package/src/components/plugin/nodes/SurfaceFileChooser.vue +94 -0
- package/src/components/plugin/nodes/SurfaceForm.vue +82 -0
- package/src/components/plugin/nodes/SurfaceList.vue +127 -0
- package/src/components/plugin/nodes/SurfacePdfFields.vue +1285 -0
- package/src/components/plugin/nodes/SurfacePeoplePicker.vue +213 -0
- package/src/components/plugin/nodes/SurfacePinInput.vue +57 -0
- package/src/components/plugin/nodes/SurfacePreview.vue +119 -0
- package/src/components/plugin/nodes/SurfaceProgress.vue +48 -0
- package/src/components/plugin/nodes/SurfaceSignaturePad.vue +472 -0
- package/src/components/plugin/nodes/SurfaceSteps.vue +53 -0
- package/src/components/public/PublicLanguagePicker.vue +58 -0
- package/src/components/public/PublicLinkPage.vue +286 -0
- package/src/components/public/PublicLinkPreview.vue +85 -0
- package/src/components/public/PublicPinGate.vue +108 -0
- package/src/components/public/PublicRequestBody.vue +204 -0
- package/src/components/public/PublicShareBody.vue +188 -0
- package/src/components/public/PublicShell.vue +332 -0
- package/src/composables/useConnections.ts +8 -2
- package/src/composables/useFileApi.ts +183 -44
- package/src/composables/useLocale.ts +277 -41
- package/src/composables/useNFSExports.ts +8 -10
- package/src/composables/useOperations.ts +44 -3
- package/src/composables/usePendingOps.ts +59 -8
- package/src/composables/usePluginActions.ts +121 -0
- package/src/composables/usePluginSurface.ts +223 -0
- package/src/composables/usePublicBranding.ts +172 -0
- package/src/composables/usePublicLink.ts +573 -0
- package/src/composables/usePublicPage.ts +61 -0
- package/src/composables/useS3Keys.ts +17 -12
- package/src/composables/useSSHKeys.ts +8 -10
- package/src/composables/useTokens.ts +8 -12
- package/src/composables/useUploadChunked.ts +18 -8
- package/src/composables/useViewerFetch.ts +6 -3
- package/src/index.ts +404 -8
- package/src/lib/actionIcons.ts +46 -1
- package/src/lib/advSearch.ts +14 -7
- package/src/lib/appLock.ts +130 -0
- package/src/lib/connection.ts +110 -0
- package/src/lib/destinationTree.ts +19 -3
- package/src/lib/direction.ts +353 -0
- package/src/lib/dragGhost.ts +6 -0
- package/src/lib/dropLimits.ts +69 -0
- package/src/lib/errorWords.ts +394 -0
- package/src/lib/externalReach.ts +5 -1
- package/src/lib/fileFilters.ts +7 -2
- package/src/lib/fileIcons.ts +4 -0
- package/src/lib/internalPaths.ts +77 -0
- package/src/lib/listInput.ts +64 -0
- package/src/lib/listing.ts +58 -23
- package/src/lib/modalStack.ts +26 -0
- package/src/lib/nodeRow.ts +121 -0
- package/src/lib/pdfFieldRules.ts +126 -0
- package/src/lib/pdfFields.ts +589 -0
- package/src/lib/pdfFieldsGeom.ts +203 -0
- package/src/lib/pdfjsLoader.ts +51 -0
- package/src/lib/personName.ts +47 -0
- package/src/lib/pluginApplies.ts +123 -0
- package/src/lib/pluginLabel.ts +72 -0
- package/src/lib/pluginMenu.ts +253 -0
- package/src/lib/pluginPage.ts +58 -0
- package/src/lib/plural.ts +181 -0
- package/src/lib/popupLayer.ts +103 -0
- package/src/lib/prefs.ts +563 -0
- package/src/lib/productVersion.ts +35 -0
- package/src/lib/publicLayout.ts +39 -0
- package/src/lib/serviceGate.ts +104 -0
- package/src/lib/shareTtl.ts +25 -11
- package/src/lib/signFonts.ts +85 -0
- package/src/lib/signaturePad.ts +111 -0
- package/src/lib/sortOrder.ts +100 -38
- package/src/lib/star.ts +7 -1
- package/src/lib/surfaceCell.ts +41 -0
- package/src/lib/surfaceConditions.ts +156 -0
- package/src/lib/surfaceOpen.ts +106 -0
- package/src/lib/surfaceSteps.ts +68 -0
- package/src/lib/surfaceValues.ts +191 -0
- package/src/lib/symlink.ts +150 -0
- package/src/lib/tableColumns.ts +493 -0
- package/src/lib/tableEnv.ts +34 -0
- package/src/lib/tablePrefs.ts +84 -0
- package/src/lib/tags.ts +121 -12
- package/src/lib/themes.ts +286 -18
- package/src/lib/tour.ts +152 -0
- package/src/lib/trashEmpty.ts +111 -0
- package/src/lib/trashTimeLeft.ts +23 -0
- package/src/lib/uiLocales.ts +460 -0
- package/src/lib/viewPrefs.ts +696 -660
- package/src/lib/viewPrefsHttp.ts +78 -0
- package/src/locales/en.ts +522 -119
- package/src/locales/resolve.ts +17 -8
- package/src/locales/tr.ts +539 -155
- package/src/modals/ConvertModal.vue +62 -11
- package/src/modals/DestinationPickerModal.vue +59 -16
- package/src/modals/Modal.vue +113 -23
- package/src/modals/NewDocumentModal.vue +16 -6
- package/src/modals/NewFolderModal.vue +8 -0
- package/src/modals/PermissionsModal.vue +272 -163
- package/src/modals/PreviewModal.vue +119 -20
- package/src/modals/RenameModal.vue +37 -1
- package/src/styles/base.css +2895 -241
- package/src/styles/sign-fonts.css +118 -0
- package/src/styles/variables.css +60 -0
- package/src/types/Connections.ts +17 -0
- package/src/types/ExplorerConfig.ts +117 -1
- package/src/types/FileNode.ts +58 -0
- package/src/types/Plugins.ts +494 -0
- package/src/types/Public.ts +212 -0
- package/src/viewers/ArchiveViewer.vue +93 -48
- package/src/viewers/CsvViewer.vue +99 -53
- package/src/viewers/DrawioViewer.vue +35 -9
- package/src/viewers/EpubViewer.vue +7 -8
- package/src/viewers/IpynbViewer.vue +3 -1
- package/src/viewers/MermaidViewer.vue +4 -5
- package/src/viewers/PdfViewer.vue +8 -17
- package/src/viewers/PsdViewer.vue +2 -4
- package/src/viewers/TiffViewer.vue +4 -5
- package/src/viewers/Viewer3D.vue +1 -1
- package/LICENSE +0 -21
- package/dist/ArchiveViewer-CLNevfPE.js +0 -124
- package/dist/ArchiveViewer-CLNevfPE.js.map +0 -1
- package/dist/CsvViewer-K8XA3z7N.js +0 -141
- package/dist/CsvViewer-K8XA3z7N.js.map +0 -1
- package/dist/DrawioViewer-BuhZOAx6.js.map +0 -1
- package/dist/EpubViewer-DS3mb3k8.js.map +0 -1
- package/dist/IpynbViewer-BTk7p4Dn.js +0 -184
- package/dist/IpynbViewer-BTk7p4Dn.js.map +0 -1
- package/dist/MermaidViewer-DqucH_io.js +0 -137
- package/dist/MermaidViewer-DqucH_io.js.map +0 -1
- package/dist/PsdViewer-CwiVENmq.js +0 -122
- package/dist/PsdViewer-CwiVENmq.js.map +0 -1
- package/dist/TiffViewer-CpewSVmQ.js +0 -142
- package/dist/TiffViewer-CpewSVmQ.js.map +0 -1
- package/dist/Viewer3D-CIrOn63I.js.map +0 -1
- package/dist/index-Dx0IbBcI.js +0 -26831
- package/dist/index-Dx0IbBcI.js.map +0 -1
- package/dist/useViewerFetch-czqbd2Lj.js +0 -25
- package/dist/useViewerFetch-czqbd2Lj.js.map +0 -1
|
@@ -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">⋮</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>
|