@efa-one/sdk 1.18.0 → 1.21.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 +50 -2
- package/frontend/ui/Alert.js +1 -7
- package/frontend/ui/DataTable.d.ts +13 -1
- package/frontend/ui/DataTable.js +291 -27
- package/frontend/ui/DropdownMenu.js +1 -1
- package/frontend/ui/RecordDialog.js +1 -1
- package/frontend/ui/styles.css +62 -3
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -14,7 +14,7 @@ sub-paths — this keeps `express` out of the frontend and `react` out of the ba
|
|
|
14
14
|
|---|---|---|
|
|
15
15
|
| `@efa-one/sdk/backend` | Node/Express | Auth + token exchange (`requireAuth`, `createExchangeRouter`, `requireInternalOrAuth` …), health router, service discovery + gateway client (`serviceClient`, `resolveService`), clients for audit/reporting/mail/notifications, permission resolution/registration (`getUserPermissions`, `registerPermissions`, `checkPermission`), capability registry (`registerApiMetadata`), backend i18n |
|
|
16
16
|
| `@efa-one/sdk/frontend` | Browser/React | postMessage IPC (`registerAppInfo`, `sendAtStart`, `navigateToApp`, `notifyRouteChange` …), react-i18next factory (`initI18n`), `DevHeader` |
|
|
17
|
-
| `@efa-one/sdk/frontend/ui` | Browser/React | The efa-one design-system kit: `Button`, `Input`, `Badge`, `Alert`, `Dialog`, `DropdownMenu`, `Tooltip`, `EmptyState`, `Skeleton`, `RecordDialog`, and the **`DataTable`** (sortable/filterable/groupable list with per-user persisted view; reflows to stacked cards below 640px). Also exports `useIsMobile` — the platform-wide 640px breakpoint hook. Ship its companion CSS once: `import '@efa-one/sdk/frontend/ui/styles.css'` |
|
|
17
|
+
| `@efa-one/sdk/frontend/ui` | Browser/React | The efa-one design-system kit: `Button`, `Input`, `Badge`, `Alert`, `Dialog`, `DropdownMenu`, `Tooltip`, `EmptyState`, `Skeleton`, `RecordDialog`, and the **`DataTable`** (sortable/filterable/groupable list with draggable, resizable columns and a per-user persisted view; reflows to stacked cards below 640px). Also exports `useIsMobile` — the platform-wide 640px breakpoint hook. Ship its companion CSS once: `import '@efa-one/sdk/frontend/ui/styles.css'` |
|
|
18
18
|
| `@efa-one/sdk/frontend/format` | Browser | Platform-wide date/time display — `formatDate` (`01.09.2026`), `formatDateTime` (`01.09.2026, 11:05`), `formatTime`, `formatFileStamp`, `localeForLanguage`. Always use these instead of `toLocaleDateString`/`toLocaleString`: without an options object those render `1.9.2026`, and without a locale argument they follow the browser language (`9/1/2026`) |
|
|
19
19
|
| `@efa-one/sdk/frontend/viewPreferences` | Browser/React | Persistence seam for `DataTable` — `createViewPreferencesClient()` (standard `/api/view-preferences/:listId` adapter) + `useViewPreferences()` |
|
|
20
20
|
|
|
@@ -23,7 +23,17 @@ Every individual module is also reachable directly, e.g.
|
|
|
23
23
|
|
|
24
24
|
The `frontend/ui` kit renders against the platform design tokens (`--color-*`,
|
|
25
25
|
`--border-radius-*`) and expects Tailwind in the consuming app; `lucide-react` and
|
|
26
|
-
the `@radix-ui/*` primitives it uses are optional peer dependencies.
|
|
26
|
+
the `@radix-ui/*` primitives it uses are optional peer dependencies. Tailwind does
|
|
27
|
+
not scan `node_modules`, so the consuming app's `tailwind.config.js` must list the
|
|
28
|
+
kit in `content` — otherwise classes used only inside SDK components (`fixed`,
|
|
29
|
+
`z-50`, …) are purged and dialogs render invisibly:
|
|
30
|
+
|
|
31
|
+
```js
|
|
32
|
+
content: ['./index.html', './src/**/*.{ts,tsx}', './node_modules/@efa-one/sdk/frontend/**/*.js'],
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
On Tailwind v4 (`@import "tailwindcss"`), add the path to the CSS entry file
|
|
36
|
+
instead: `@source "../node_modules/@efa-one/sdk/frontend/**/*.js";`
|
|
27
37
|
|
|
28
38
|
> **Note on legacy prefixes:** Some platform-internal identifiers (postMessage
|
|
29
39
|
> message types, environment variable names, JWT `iss`) still carry technical
|
|
@@ -89,11 +99,17 @@ const viewPrefs = createViewPreferencesClient({ apiBase: getApiBase }); // creat
|
|
|
89
99
|
const columns: ColumnDef<Item>[] = [
|
|
90
100
|
{ id: 'name', label: 'Name', accessor: (r) => r.name, filter: { type: 'text' } },
|
|
91
101
|
{ id: 'status', label: 'Status', accessor: (r) => r.status },
|
|
102
|
+
{ id: 'created', label: 'Angelegt', accessor: (r) => r.createdAt, type: 'date' }, // → 07.10.2026
|
|
92
103
|
];
|
|
93
104
|
|
|
94
105
|
<DataTable listId="items.list" rows={items} columns={columns} rowKey={(r) => r.id} persistence={viewPrefs} />
|
|
95
106
|
```
|
|
96
107
|
|
|
108
|
+
Columns added later show up in views users saved before (at their natural position) — no
|
|
109
|
+
need to bump `DEFAULT_VIEW_VERSION` and reset everyone's view. Grouped lists open collapsed;
|
|
110
|
+
pass `groupsDefaultExpanded` (e.g. with `initialPrefs={{ groupBy: ['category'] }}`) to start
|
|
111
|
+
with every group expanded while keeping groups the user collapses closed.
|
|
112
|
+
|
|
97
113
|
## Build your first app
|
|
98
114
|
|
|
99
115
|
This repo ships a ready-to-copy app scaffold under [`template/`](./template) — the fastest
|
|
@@ -175,6 +191,38 @@ iframe load.
|
|
|
175
191
|
ignores the request and its fixed-window push reaches the app as before; a current
|
|
176
192
|
kernel answers the request directly. No rollout order.
|
|
177
193
|
|
|
194
|
+
**1.19.0 — readable signal colors (WCAG AA).** `styles.css` now defines
|
|
195
|
+
`--color-danger-text`, `--color-warning-text`, `--color-success-text` and
|
|
196
|
+
`--color-primary-text`: the semantic color mixed toward `--color-text-primary`, so
|
|
197
|
+
they darken in light themes and lighten in dark ones and follow every kernel theme,
|
|
198
|
+
including custom ones, plus the classes `.signal-text-danger|warning|success|primary`.
|
|
199
|
+
`Badge`, `Alert`, the danger item of `DropdownMenu` and the DataTable filter icon use
|
|
200
|
+
them for text; fills and borders keep the pure signal color. These colors come from
|
|
201
|
+
classes in `styles.css`, not Tailwind arbitrary values, so they work even when the
|
|
202
|
+
app's Tailwind does not scan the SDK — but `Alert` now needs the `styles.css` import
|
|
203
|
+
like `Badge` already did. No kernel change and no rollout order. Apps should switch their own
|
|
204
|
+
signal-colored text (`text-[var(--color-danger)]`, orange links) to the `-text`
|
|
205
|
+
tokens.
|
|
206
|
+
|
|
207
|
+
**1.20.0 — columns you can move and resize, date columns.** In the DataTable header,
|
|
208
|
+
each column has a grip (right side, on hover) to drag it to a new position and a
|
|
209
|
+
handle on its right edge to drag its width; double-click the handle (or „Standardbreite"
|
|
210
|
+
in the column menu) to go back to the code default, arrow keys resize from the keyboard.
|
|
211
|
+
Order and widths persist per user with the rest of the view (`columnWidths` in the
|
|
212
|
+
stored JSON, no version bump, existing saved views stay). New `ColumnDef.type`:
|
|
213
|
+
`'date'` renders `DD.MM.YYYY`, `'datetime'` `DD.MM.YYYY, HH:MM` (via `format.ts`), sorts
|
|
214
|
+
chronologically, groups by day and lets the text filter match the displayed form.
|
|
215
|
+
`Date` values without a `type` are formatted as dates too. Custom `cell` renderers stay
|
|
216
|
+
the app's job: use `formatDate`/`formatDateTime`, never `toLocaleDateString` (that is
|
|
217
|
+
where `7.10.2026` comes from). No backend or kernel change.
|
|
218
|
+
|
|
219
|
+
**1.21.0 — new columns in old views, groups that start open.** A column added to
|
|
220
|
+
`columns` after a user saved their view now appears in that view at its natural position
|
|
221
|
+
(and stays hidden if it has `defaultVisible: false`), and can be dragged like any other —
|
|
222
|
+
before, it was missing until the user reset the view. New prop `groupsDefaultExpanded`:
|
|
223
|
+
groups start expanded; groups the user collapses stay collapsed. No backend or kernel
|
|
224
|
+
change.
|
|
225
|
+
|
|
178
226
|
## License
|
|
179
227
|
|
|
180
228
|
[Apache-2.0](./LICENSE) — permissive with a patent grant, so customers and partners
|
package/frontend/ui/Alert.js
CHANGED
|
@@ -1,11 +1,5 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { X } from 'lucide-react';
|
|
3
|
-
const VARIANT = {
|
|
4
|
-
error: 'bg-[color-mix(in_srgb,var(--color-danger)_10%,transparent)] border-[var(--color-danger)] text-[var(--color-danger)]',
|
|
5
|
-
success: 'bg-[color-mix(in_srgb,var(--color-success)_10%,transparent)] border-[var(--color-success)] text-[var(--color-success)]',
|
|
6
|
-
warning: 'bg-[color-mix(in_srgb,var(--color-warning)_10%,transparent)] border-[var(--color-warning)] text-[var(--color-warning)]',
|
|
7
|
-
info: 'bg-[color-mix(in_srgb,var(--color-primary)_10%,transparent)] border-[var(--color-primary)] text-[var(--color-primary)]',
|
|
8
|
-
};
|
|
9
3
|
export function Alert({ variant = 'error', children, onDismiss, className = '' }) {
|
|
10
|
-
return (_jsxs("div", { role: "alert", className: `shrink-0 flex items-start gap-2 border rounded-md px-4 py-3 text-sm font-medium
|
|
4
|
+
return (_jsxs("div", { role: "alert", className: `shrink-0 flex items-start gap-2 border rounded-md px-4 py-3 text-sm font-medium alert-${variant} ${className}`, children: [_jsx("span", { className: "flex-1", children: children }), onDismiss && (_jsx("button", { type: "button", onClick: onDismiss, "aria-label": "Meldung schlie\u00DFen", className: "shrink-0 -mr-1 p-0.5 rounded hover:bg-current/10 transition-colors", children: _jsx(X, { className: "w-4 h-4" }) }))] }));
|
|
11
5
|
}
|
|
@@ -18,6 +18,7 @@ export interface ColumnDef<T> {
|
|
|
18
18
|
label: string;
|
|
19
19
|
accessor: (row: T) => unknown;
|
|
20
20
|
cell?: (row: T) => React.ReactNode;
|
|
21
|
+
type?: 'text' | 'date' | 'datetime';
|
|
21
22
|
filter?: FilterDef;
|
|
22
23
|
sortable?: boolean;
|
|
23
24
|
width?: string;
|
|
@@ -38,6 +39,7 @@ interface ViewPrefs {
|
|
|
38
39
|
} | null;
|
|
39
40
|
filters: Record<string, FilterValue>;
|
|
40
41
|
groupBy: string[];
|
|
42
|
+
columnWidths?: Record<string, number>;
|
|
41
43
|
version?: number;
|
|
42
44
|
}
|
|
43
45
|
export interface DataTableSelectionProps<K> {
|
|
@@ -54,9 +56,18 @@ interface DataTableProps<T, K extends string | number> {
|
|
|
54
56
|
selection?: DataTableSelectionProps<K>;
|
|
55
57
|
initialPrefs?: Partial<ViewPrefs>;
|
|
56
58
|
footer?: React.ReactNode;
|
|
59
|
+
groupsDefaultExpanded?: boolean;
|
|
57
60
|
persistence?: ViewPreferencesAdapter;
|
|
58
61
|
}
|
|
59
62
|
export declare function defaultPrefsFor<T>(columns: ColumnDef<T>[], overrides?: Partial<ViewPrefs>): ViewPrefs;
|
|
63
|
+
export declare function reconcileOrder<T>(columns: ColumnDef<T>[], order: string[]): string[];
|
|
64
|
+
export declare function isColumnVisible<T>(column: ColumnDef<T>, visibility: Record<string, boolean>): boolean;
|
|
65
|
+
export declare function formatCellValue<T>(col: ColumnDef<T>, row: T): string;
|
|
66
|
+
export declare const MIN_COLUMN_WIDTH = 60;
|
|
67
|
+
export declare const MAX_COLUMN_WIDTH = 1600;
|
|
68
|
+
export declare function clampColumnWidth(px: number): number;
|
|
69
|
+
export declare function reorderColumns(order: string[], sourceId: string, targetId: string, side: 'before' | 'after'): string[];
|
|
70
|
+
export declare function gridTemplateFor<T>(visibleColumns: ColumnDef<T>[], columnWidths: Record<string, number> | undefined, hasSelection: boolean): string;
|
|
60
71
|
export declare function groupLabelOf(value: unknown): string;
|
|
61
72
|
interface RenderItem<T> {
|
|
62
73
|
kind: 'group' | 'row';
|
|
@@ -68,5 +79,6 @@ interface RenderItem<T> {
|
|
|
68
79
|
row?: T;
|
|
69
80
|
}
|
|
70
81
|
export declare function buildRenderItems<T>(rows: T[], groupBy: string[], columnsById: Map<string, ColumnDef<T>>, expandedGroups: Set<string>): RenderItem<T>[];
|
|
71
|
-
export declare function
|
|
82
|
+
export declare function collectAllGroupKeys<T>(rows: T[], groupBy: string[], columnsById: Map<string, ColumnDef<T>>): string[];
|
|
83
|
+
export declare function DataTable<T, K extends string | number>({ listId, rows, columns, rowKey, onRowClick, selection, initialPrefs, footer, groupsDefaultExpanded, persistence, }: DataTableProps<T, K>): React.ReactElement;
|
|
72
84
|
export {};
|
package/frontend/ui/DataTable.js
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
-
import { useMemo, useState } from 'react';
|
|
2
|
+
import { useEffect, useMemo, useRef, useState } from 'react';
|
|
3
3
|
import * as DropdownMenu from './DropdownMenu.js';
|
|
4
|
-
import { ChevronDown, ChevronUp, ChevronRight, Settings, EyeOff, Filter, RotateCcw, ArrowUp, ArrowDown, Layers, X } from 'lucide-react';
|
|
4
|
+
import { ChevronDown, ChevronUp, ChevronRight, Settings, EyeOff, Filter, RotateCcw, ArrowUp, ArrowDown, Layers, X, GripVertical } from 'lucide-react';
|
|
5
5
|
import { Button } from './Button.js';
|
|
6
6
|
import { useViewPreferences } from '../viewPreferences.js';
|
|
7
7
|
import { useIsMobile } from '../useIsMobile.js';
|
|
8
|
+
import { formatDate, formatDateTime } from '../format.js';
|
|
8
9
|
const GROUP_KEY_SEP = '\x1f';
|
|
9
10
|
const EMPTY_GROUP_LABEL = '– ohne –';
|
|
10
11
|
export const DEFAULT_VIEW_VERSION = 2;
|
|
@@ -17,10 +18,71 @@ export function defaultPrefsFor(columns, overrides) {
|
|
|
17
18
|
sort: null,
|
|
18
19
|
filters: {},
|
|
19
20
|
groupBy: [],
|
|
21
|
+
columnWidths: {},
|
|
20
22
|
version: DEFAULT_VIEW_VERSION,
|
|
21
23
|
...(overrides ?? {}),
|
|
22
24
|
};
|
|
23
25
|
}
|
|
26
|
+
export function reconcileOrder(columns, order) {
|
|
27
|
+
const known = new Set(columns.map((c) => c.id));
|
|
28
|
+
const result = order.filter((id) => known.has(id));
|
|
29
|
+
const present = new Set(result);
|
|
30
|
+
columns.forEach((c, idx) => {
|
|
31
|
+
if (present.has(c.id))
|
|
32
|
+
return;
|
|
33
|
+
result.splice(Math.min(idx, result.length), 0, c.id);
|
|
34
|
+
present.add(c.id);
|
|
35
|
+
});
|
|
36
|
+
return result;
|
|
37
|
+
}
|
|
38
|
+
export function isColumnVisible(column, visibility) {
|
|
39
|
+
return visibility[column.id] ?? column.defaultVisible !== false;
|
|
40
|
+
}
|
|
41
|
+
function isDateColumn(col, value) {
|
|
42
|
+
return col.type === 'date' || col.type === 'datetime' || (col.type == null && value instanceof Date);
|
|
43
|
+
}
|
|
44
|
+
function timeOf(value) {
|
|
45
|
+
if (value == null || value === '')
|
|
46
|
+
return null;
|
|
47
|
+
const t = value instanceof Date ? value.getTime() : new Date(value).getTime();
|
|
48
|
+
return Number.isNaN(t) ? null : t;
|
|
49
|
+
}
|
|
50
|
+
export function formatCellValue(col, row) {
|
|
51
|
+
const v = col.accessor(row);
|
|
52
|
+
if (isDateColumn(col, v)) {
|
|
53
|
+
return col.type === 'datetime' ? formatDateTime(v) : formatDate(v);
|
|
54
|
+
}
|
|
55
|
+
return String(v ?? '');
|
|
56
|
+
}
|
|
57
|
+
export const MIN_COLUMN_WIDTH = 60;
|
|
58
|
+
export const MAX_COLUMN_WIDTH = 1600;
|
|
59
|
+
const KEYBOARD_RESIZE_STEP = 16;
|
|
60
|
+
export function clampColumnWidth(px) {
|
|
61
|
+
if (!Number.isFinite(px))
|
|
62
|
+
return MIN_COLUMN_WIDTH;
|
|
63
|
+
return Math.round(Math.min(MAX_COLUMN_WIDTH, Math.max(MIN_COLUMN_WIDTH, px)));
|
|
64
|
+
}
|
|
65
|
+
export function reorderColumns(order, sourceId, targetId, side) {
|
|
66
|
+
if (sourceId === targetId)
|
|
67
|
+
return order;
|
|
68
|
+
if (!order.includes(sourceId) || !order.includes(targetId))
|
|
69
|
+
return order;
|
|
70
|
+
const without = order.filter((id) => id !== sourceId);
|
|
71
|
+
const targetIdx = without.indexOf(targetId);
|
|
72
|
+
without.splice(side === 'before' ? targetIdx : targetIdx + 1, 0, sourceId);
|
|
73
|
+
return without;
|
|
74
|
+
}
|
|
75
|
+
export function gridTemplateFor(visibleColumns, columnWidths, hasSelection) {
|
|
76
|
+
const cols = [];
|
|
77
|
+
if (hasSelection)
|
|
78
|
+
cols.push('36px');
|
|
79
|
+
visibleColumns.forEach((c) => {
|
|
80
|
+
const w = columnWidths?.[c.id];
|
|
81
|
+
cols.push(typeof w === 'number' && Number.isFinite(w) ? `${clampColumnWidth(w)}px` : (c.width ?? '1fr'));
|
|
82
|
+
});
|
|
83
|
+
cols.push('40px');
|
|
84
|
+
return cols.join(' ');
|
|
85
|
+
}
|
|
24
86
|
export function groupLabelOf(value) {
|
|
25
87
|
if (value == null)
|
|
26
88
|
return '';
|
|
@@ -29,6 +91,12 @@ export function groupLabelOf(value) {
|
|
|
29
91
|
const s = String(value).trim();
|
|
30
92
|
return s;
|
|
31
93
|
}
|
|
94
|
+
function groupBucketLabel(col, raw) {
|
|
95
|
+
const lbl = isDateColumn(col, raw)
|
|
96
|
+
? (timeOf(raw) == null ? '' : formatDate(raw))
|
|
97
|
+
: groupLabelOf(raw);
|
|
98
|
+
return lbl || EMPTY_GROUP_LABEL;
|
|
99
|
+
}
|
|
32
100
|
export function buildRenderItems(rows, groupBy, columnsById, expandedGroups) {
|
|
33
101
|
if (groupBy.length === 0)
|
|
34
102
|
return rows.map((row) => ({ kind: 'row', row }));
|
|
@@ -44,18 +112,27 @@ export function buildRenderItems(rows, groupBy, columnsById, expandedGroups) {
|
|
|
44
112
|
return;
|
|
45
113
|
}
|
|
46
114
|
const buckets = new Map();
|
|
115
|
+
const bucketTime = new Map();
|
|
116
|
+
let chronological = false;
|
|
47
117
|
for (const r of subset) {
|
|
48
|
-
const
|
|
49
|
-
const
|
|
118
|
+
const raw = col.accessor(r);
|
|
119
|
+
const isDate = isDateColumn(col, raw);
|
|
120
|
+
if (isDate)
|
|
121
|
+
chronological = true;
|
|
122
|
+
const bucketLabel = groupBucketLabel(col, raw);
|
|
50
123
|
const arr = buckets.get(bucketLabel) ?? [];
|
|
51
124
|
arr.push(r);
|
|
52
125
|
buckets.set(bucketLabel, arr);
|
|
126
|
+
if (!bucketTime.has(bucketLabel))
|
|
127
|
+
bucketTime.set(bucketLabel, isDate ? timeOf(raw) : null);
|
|
53
128
|
}
|
|
54
129
|
const labels = Array.from(buckets.keys()).sort((a, b) => {
|
|
55
130
|
if (a === EMPTY_GROUP_LABEL)
|
|
56
131
|
return 1;
|
|
57
132
|
if (b === EMPTY_GROUP_LABEL)
|
|
58
133
|
return -1;
|
|
134
|
+
if (chronological)
|
|
135
|
+
return (bucketTime.get(a) ?? 0) - (bucketTime.get(b) ?? 0);
|
|
59
136
|
return a.localeCompare(b, 'de');
|
|
60
137
|
});
|
|
61
138
|
for (const label of labels) {
|
|
@@ -77,7 +154,31 @@ export function buildRenderItems(rows, groupBy, columnsById, expandedGroups) {
|
|
|
77
154
|
recurse(rows, 0, '');
|
|
78
155
|
return out;
|
|
79
156
|
}
|
|
80
|
-
export function
|
|
157
|
+
export function collectAllGroupKeys(rows, groupBy, columnsById) {
|
|
158
|
+
const keys = [];
|
|
159
|
+
const recurse = (subset, depth, parentKey) => {
|
|
160
|
+
if (depth >= groupBy.length)
|
|
161
|
+
return;
|
|
162
|
+
const col = columnsById.get(groupBy[depth]);
|
|
163
|
+
if (!col)
|
|
164
|
+
return;
|
|
165
|
+
const buckets = new Map();
|
|
166
|
+
for (const r of subset) {
|
|
167
|
+
const lbl = groupBucketLabel(col, col.accessor(r));
|
|
168
|
+
const arr = buckets.get(lbl) ?? [];
|
|
169
|
+
arr.push(r);
|
|
170
|
+
buckets.set(lbl, arr);
|
|
171
|
+
}
|
|
172
|
+
for (const [label, childRows] of buckets) {
|
|
173
|
+
const key = parentKey ? `${parentKey}${GROUP_KEY_SEP}${label}` : label;
|
|
174
|
+
keys.push(key);
|
|
175
|
+
recurse(childRows, depth + 1, key);
|
|
176
|
+
}
|
|
177
|
+
};
|
|
178
|
+
recurse(rows, 0, '');
|
|
179
|
+
return keys;
|
|
180
|
+
}
|
|
181
|
+
export function DataTable({ listId, rows, columns, rowKey, onRowClick, selection, initialPrefs, footer, groupsDefaultExpanded, persistence, }) {
|
|
81
182
|
const defaultPrefs = useMemo(() => defaultPrefsFor(columns, initialPrefs), [columns, initialPrefs]);
|
|
82
183
|
const [prefs, setPrefs, reset] = useViewPreferences(listId, defaultPrefs, persistence);
|
|
83
184
|
const isMobile = useIsMobile();
|
|
@@ -94,12 +195,31 @@ export function DataTable({ listId, rows, columns, rowKey, onRowClick, selection
|
|
|
94
195
|
};
|
|
95
196
|
const columnsById = useMemo(() => new Map(columns.map((c) => [c.id, c])), [columns]);
|
|
96
197
|
const groupBy = prefs.groupBy ?? [];
|
|
198
|
+
const columnWidths = prefs.columnWidths ?? {};
|
|
199
|
+
const [dragColumnId, setDragColumnId] = useState(null);
|
|
200
|
+
const [dropTarget, setDropTarget] = useState(null);
|
|
201
|
+
const containerRef = useRef(null);
|
|
202
|
+
const effectiveOrder = useMemo(() => reconcileOrder(columns, prefs.columnOrder), [columns, prefs.columnOrder]);
|
|
203
|
+
const seenGroupKeysRef = useRef(new Set());
|
|
204
|
+
useEffect(() => {
|
|
205
|
+
if (!groupsDefaultExpanded || groupBy.length === 0)
|
|
206
|
+
return;
|
|
207
|
+
const fresh = collectAllGroupKeys(rows, groupBy, columnsById).filter((k) => !seenGroupKeysRef.current.has(k));
|
|
208
|
+
if (fresh.length === 0)
|
|
209
|
+
return;
|
|
210
|
+
fresh.forEach((k) => seenGroupKeysRef.current.add(k));
|
|
211
|
+
setExpandedGroups((prev) => {
|
|
212
|
+
const next = new Set(prev);
|
|
213
|
+
fresh.forEach((k) => next.add(k));
|
|
214
|
+
return next;
|
|
215
|
+
});
|
|
216
|
+
}, [groupsDefaultExpanded, rows, groupBy, columnsById]);
|
|
97
217
|
const visibleColumns = useMemo(() => {
|
|
98
218
|
const byId = new Map(columns.map((c) => [c.id, c]));
|
|
99
|
-
return
|
|
219
|
+
return effectiveOrder
|
|
100
220
|
.map((id) => byId.get(id))
|
|
101
|
-
.filter((c) => Boolean(c) && prefs.columnVisibility
|
|
102
|
-
}, [columns,
|
|
221
|
+
.filter((c) => Boolean(c) && isColumnVisible(c, prefs.columnVisibility));
|
|
222
|
+
}, [columns, effectiveOrder, prefs.columnVisibility]);
|
|
103
223
|
const sortedFilteredRows = useMemo(() => {
|
|
104
224
|
let out = rows;
|
|
105
225
|
for (const col of columns) {
|
|
@@ -110,7 +230,10 @@ export function DataTable({ listId, rows, columns, rowKey, onRowClick, selection
|
|
|
110
230
|
const q = f.text.toLowerCase();
|
|
111
231
|
out = out.filter((r) => {
|
|
112
232
|
const v = col.accessor(r);
|
|
113
|
-
|
|
233
|
+
if (v == null)
|
|
234
|
+
return false;
|
|
235
|
+
const text = isDateColumn(col, v) ? formatCellValue(col, r) : String(v);
|
|
236
|
+
return text.toLowerCase().includes(q);
|
|
114
237
|
});
|
|
115
238
|
}
|
|
116
239
|
else if (col.filter.type === 'multi-select' && f.selected && f.selected.length > 0) {
|
|
@@ -131,8 +254,12 @@ export function DataTable({ listId, rows, columns, rowKey, onRowClick, selection
|
|
|
131
254
|
if (col) {
|
|
132
255
|
const dir = prefs.sort.direction === 'asc' ? 1 : -1;
|
|
133
256
|
out = [...out].sort((a, b) => {
|
|
134
|
-
|
|
135
|
-
|
|
257
|
+
let va = col.accessor(a);
|
|
258
|
+
let vb = col.accessor(b);
|
|
259
|
+
if (isDateColumn(col, va) || isDateColumn(col, vb)) {
|
|
260
|
+
va = timeOf(va);
|
|
261
|
+
vb = timeOf(vb);
|
|
262
|
+
}
|
|
136
263
|
if (va == null && vb == null)
|
|
137
264
|
return 0;
|
|
138
265
|
if (va == null)
|
|
@@ -169,14 +296,7 @@ export function DataTable({ listId, rows, columns, rowKey, onRowClick, selection
|
|
|
169
296
|
next.add(key);
|
|
170
297
|
selection.onChange(next);
|
|
171
298
|
};
|
|
172
|
-
const gridTemplate = useMemo(() =>
|
|
173
|
-
const cols = [];
|
|
174
|
-
if (selection)
|
|
175
|
-
cols.push('36px');
|
|
176
|
-
visibleColumns.forEach((c) => cols.push(c.width ?? '1fr'));
|
|
177
|
-
cols.push('40px');
|
|
178
|
-
return cols.join(' ');
|
|
179
|
-
}, [selection, visibleColumns]);
|
|
299
|
+
const gridTemplate = useMemo(() => gridTemplateFor(visibleColumns, columnWidths, !!selection), [selection, visibleColumns, prefs.columnWidths]);
|
|
180
300
|
const setSort = (columnId, direction) => {
|
|
181
301
|
setPrefs({ ...prefs, sort: { columnId, direction } });
|
|
182
302
|
};
|
|
@@ -190,7 +310,7 @@ export function DataTable({ listId, rows, columns, rowKey, onRowClick, selection
|
|
|
190
310
|
});
|
|
191
311
|
};
|
|
192
312
|
const moveColumn = (columnId, dir) => {
|
|
193
|
-
const order = [...
|
|
313
|
+
const order = [...effectiveOrder];
|
|
194
314
|
const idx = order.indexOf(columnId);
|
|
195
315
|
if (idx < 0)
|
|
196
316
|
return;
|
|
@@ -200,6 +320,23 @@ export function DataTable({ listId, rows, columns, rowKey, onRowClick, selection
|
|
|
200
320
|
[order[idx], order[newIdx]] = [order[newIdx], order[idx]];
|
|
201
321
|
setPrefs({ ...prefs, columnOrder: order });
|
|
202
322
|
};
|
|
323
|
+
const dropColumn = (sourceId, targetId, side) => {
|
|
324
|
+
const order = reorderColumns(effectiveOrder, sourceId, targetId, side);
|
|
325
|
+
if (order.join(GROUP_KEY_SEP) !== prefs.columnOrder.join(GROUP_KEY_SEP)) {
|
|
326
|
+
setPrefs({ ...prefs, columnOrder: order });
|
|
327
|
+
}
|
|
328
|
+
};
|
|
329
|
+
const previewColumnWidth = (columnId, px) => {
|
|
330
|
+
containerRef.current?.style.setProperty('--dt-cols', gridTemplateFor(visibleColumns, { ...columnWidths, [columnId]: px }, !!selection));
|
|
331
|
+
};
|
|
332
|
+
const setColumnWidth = (columnId, px) => {
|
|
333
|
+
const next = { ...columnWidths };
|
|
334
|
+
if (px == null)
|
|
335
|
+
delete next[columnId];
|
|
336
|
+
else
|
|
337
|
+
next[columnId] = clampColumnWidth(px);
|
|
338
|
+
setPrefs({ ...prefs, columnWidths: next });
|
|
339
|
+
};
|
|
203
340
|
const addGroupBy = (columnId) => {
|
|
204
341
|
if (groupBy.includes(columnId))
|
|
205
342
|
return;
|
|
@@ -239,11 +376,18 @@ export function DataTable({ listId, rows, columns, rowKey, onRowClick, selection
|
|
|
239
376
|
setPrefs({ ...prefs, filters });
|
|
240
377
|
};
|
|
241
378
|
const gearButton = (_jsx(ColumnInventoryButton, { columns: columns, prefs: prefs, setColumnVisible: setColumnVisible, moveColumn: moveColumn, reset: reset, groupBy: groupBy, addGroupBy: addGroupBy, removeGroupBy: removeGroupBy, moveGroupBy: moveGroupBy, clearGroupBy: clearGroupBy }));
|
|
242
|
-
return (_jsxs("div", { className: "border border-[var(--color-border)] overflow-x-auto", children: [selection && selection.selected.size > 0 && (_jsxs("div", { className: "flex items-center gap-2 px-3 py-2 text-sm border-b border-[var(--color-border)]", style: { background: 'var(--color-surface-raised)' }, children: [_jsxs("span", { children: [selection.selected.size, " ausgew\u00E4hlt"] }), _jsx("button", { type: "button", className: "text-xs underline text-[var(--color-text-muted)] hover:text-[var(--color-text-primary)]", onClick: () => selection.onChange(new Set()), children: "Auswahl l\u00F6schen" }), _jsx("div", { className: "flex-1" }), selection.bulkActions] })), isMobile ? (_jsxs("div", { className: "flex items-center gap-3 px-3 py-2 text-xs font-medium border-b border-[var(--color-border)]", style: { background: 'var(--color-surface-raised)' }, children: [selection && (_jsxs("label", { className: "flex items-center gap-2 py-2 pr-2 cursor-pointer", children: [_jsx("input", { type: "checkbox", className: "w-5 h-5", checked: allSelected, onChange: toggleAll, "aria-label": "Alle ausw\u00E4hlen" }), _jsx("span", { className: "text-[var(--color-text-muted)]", children: "Alle" })] })), _jsx("div", { className: "flex-1" }), gearButton] })) : (_jsxs("div", { className: "grid items-center min-w-min px-3 py-2 text-xs font-medium border-b border-[var(--color-border)]", style: { gridTemplateColumns:
|
|
379
|
+
return (_jsxs("div", { ref: containerRef, className: "border border-[var(--color-border)] overflow-x-auto", style: { '--dt-cols': gridTemplate }, children: [selection && selection.selected.size > 0 && (_jsxs("div", { className: "flex items-center gap-2 px-3 py-2 text-sm border-b border-[var(--color-border)]", style: { background: 'var(--color-surface-raised)' }, children: [_jsxs("span", { children: [selection.selected.size, " ausgew\u00E4hlt"] }), _jsx("button", { type: "button", className: "text-xs underline text-[var(--color-text-muted)] hover:text-[var(--color-text-primary)]", onClick: () => selection.onChange(new Set()), children: "Auswahl l\u00F6schen" }), _jsx("div", { className: "flex-1" }), selection.bulkActions] })), isMobile ? (_jsxs("div", { className: "flex items-center gap-3 px-3 py-2 text-xs font-medium border-b border-[var(--color-border)]", style: { background: 'var(--color-surface-raised)' }, children: [selection && (_jsxs("label", { className: "flex items-center gap-2 py-2 pr-2 cursor-pointer", children: [_jsx("input", { type: "checkbox", className: "w-5 h-5", checked: allSelected, onChange: toggleAll, "aria-label": "Alle ausw\u00E4hlen" }), _jsx("span", { className: "text-[var(--color-text-muted)]", children: "Alle" })] })), _jsx("div", { className: "flex-1" }), gearButton] })) : (_jsxs("div", { className: "grid items-center min-w-min px-3 py-2 text-xs font-medium border-b border-[var(--color-border)]", style: { gridTemplateColumns: 'var(--dt-cols)', background: 'var(--color-surface-raised)' }, children: [selection && (_jsx("input", { type: "checkbox", checked: allSelected, onChange: toggleAll, "aria-label": "Alle ausw\u00E4hlen" })), visibleColumns.map((col) => {
|
|
243
380
|
const activeSort = prefs.sort?.columnId === col.id ? prefs.sort.direction : null;
|
|
244
381
|
const filterValue = prefs.filters[col.id];
|
|
245
382
|
const isFiltered = filterValue != null;
|
|
246
|
-
return (
|
|
383
|
+
return (_jsx(HeaderCell, { columnId: col.id, label: col.label, dragColumnId: dragColumnId, dropSide: dropTarget?.id === col.id ? dropTarget.side : null, onDragStart: () => setDragColumnId(col.id), onDragEnd: () => { setDragColumnId(null); setDropTarget(null); }, onDragOverSide: (side) => {
|
|
384
|
+
if (dropTarget?.id !== col.id || dropTarget.side !== side)
|
|
385
|
+
setDropTarget({ id: col.id, side });
|
|
386
|
+
}, onDrop: (sourceId, side) => {
|
|
387
|
+
dropColumn(sourceId, col.id, side);
|
|
388
|
+
setDragColumnId(null);
|
|
389
|
+
setDropTarget(null);
|
|
390
|
+
}, onResizePreview: (px) => previewColumnWidth(col.id, px), onResizeCommit: (px) => setColumnWidth(col.id, px), onResizeCancel: () => containerRef.current?.style.setProperty('--dt-cols', gridTemplate), hasCustomWidth: columnWidths[col.id] != null, children: _jsxs(DropdownMenu.Root, { children: [_jsx(DropdownMenu.Trigger, { asChild: true, children: _jsxs("button", { type: "button", className: "flex items-center gap-1 min-w-0 px-1 py-0.5 hover:bg-[var(--color-surface)] text-left", style: { borderRadius: 'var(--border-radius-md)' }, children: [_jsx("span", { className: "min-w-0 truncate", children: col.label }), activeSort === 'asc' && _jsx(ChevronUp, { className: "w-3 h-3" }), activeSort === 'desc' && _jsx(ChevronDown, { className: "w-3 h-3" }), isFiltered && _jsx(Filter, { className: "w-3 h-3 signal-text-primary" })] }) }), _jsxs(DropdownMenu.Content, { className: "min-w-[220px] p-1", children: [col.sortable !== false && (_jsxs(_Fragment, { children: [_jsxs(DropdownMenu.Item, { onSelect: () => setSort(col.id, 'asc'), children: [_jsx(ArrowUp, { className: "w-4 h-4" }), "Sortieren A\u2192Z"] }), _jsxs(DropdownMenu.Item, { onSelect: () => setSort(col.id, 'desc'), children: [_jsx(ArrowDown, { className: "w-4 h-4" }), "Sortieren Z\u2192A"] }), activeSort && (_jsx(DropdownMenu.Item, { onSelect: clearSort, children: "Sortierung zur\u00FCcksetzen" })), _jsx(DropdownMenu.Separator, {})] })), groupBy.includes(col.id) ? (_jsxs(DropdownMenu.Item, { onSelect: () => removeGroupBy(col.id), children: [_jsx(Layers, { className: "w-4 h-4" }), "Gruppierung aufheben"] })) : (_jsxs(DropdownMenu.Item, { onSelect: () => addGroupBy(col.id), children: [_jsx(Layers, { className: "w-4 h-4" }), "Nach dieser Spalte gruppieren"] })), _jsx(DropdownMenu.Separator, {}), col.filter && (_jsx(FilterEditor, { column: col, value: filterValue, onChange: (v) => setFilter(col.id, v) })), columnWidths[col.id] != null && (_jsxs(DropdownMenu.Item, { onSelect: () => setColumnWidth(col.id, null), children: [_jsx(RotateCcw, { className: "w-4 h-4" }), "Standardbreite"] })), _jsxs(DropdownMenu.Item, { onSelect: () => setColumnVisible(col.id, false), variant: "danger", children: [_jsx(EyeOff, { className: "w-4 h-4" }), "Spalte ausblenden"] })] })] }) }, col.id));
|
|
247
391
|
}), gearButton] })), sortedFilteredRows.length === 0 ? (_jsx("div", { className: "px-3 py-8 text-center text-sm text-[var(--color-text-muted)]", children: "Keine Eintr\u00E4ge" })) : (buildRenderItems(sortedFilteredRows, groupBy, columnsById, expandedGroups).map((item, idx) => {
|
|
248
392
|
if (item.kind === 'group') {
|
|
249
393
|
const level = item.level ?? 0;
|
|
@@ -260,15 +404,135 @@ export function DataTable({ listId, rows, columns, rowKey, onRowClick, selection
|
|
|
260
404
|
const isChecked = selection?.selected.has(key) ?? false;
|
|
261
405
|
const rowIndent = groupBy.length > 0 ? 12 + groupBy.length * 18 : undefined;
|
|
262
406
|
if (isMobile) {
|
|
263
|
-
return (_jsxs("div", { className: `flex flex-col gap-1.5 px-3 py-3 border-b border-[var(--color-border)] ${onRowClick ? 'cursor-pointer hover:bg-[var(--color-surface-raised)]' : ''}`, style: { background: isChecked ? 'rgba(99,102,241,0.07)' : undefined, paddingLeft: rowIndent }, onClick: () => onRowClick?.(row), children: [selection && (_jsxs("label", { className: "flex items-center gap-2 self-start py-1 pr-2 cursor-pointer", onClick: (e) => e.stopPropagation(), children: [_jsx("input", { type: "checkbox", className: "w-5 h-5", checked: isChecked, onChange: () => toggleOne(key), "aria-label": "Zeile ausw\u00E4hlen" }), _jsx("span", { className: "text-xs text-[var(--color-text-muted)]", children: "Ausw\u00E4hlen" })] })), visibleColumns.map((col) => (_jsxs("div", { className: "flex flex-col gap-0.5", children: [_jsx("span", { className: "text-xs font-medium uppercase tracking-wide text-[var(--color-text-muted)]", children: col.label }), _jsx("div", { className: "min-w-0 break-words text-sm", children: col.cell ? col.cell(row) :
|
|
407
|
+
return (_jsxs("div", { className: `flex flex-col gap-1.5 px-3 py-3 border-b border-[var(--color-border)] ${onRowClick ? 'cursor-pointer hover:bg-[var(--color-surface-raised)]' : ''}`, style: { background: isChecked ? 'rgba(99,102,241,0.07)' : undefined, paddingLeft: rowIndent }, onClick: () => onRowClick?.(row), children: [selection && (_jsxs("label", { className: "flex items-center gap-2 self-start py-1 pr-2 cursor-pointer", onClick: (e) => e.stopPropagation(), children: [_jsx("input", { type: "checkbox", className: "w-5 h-5", checked: isChecked, onChange: () => toggleOne(key), "aria-label": "Zeile ausw\u00E4hlen" }), _jsx("span", { className: "text-xs text-[var(--color-text-muted)]", children: "Ausw\u00E4hlen" })] })), visibleColumns.map((col) => (_jsxs("div", { className: "flex flex-col gap-0.5", children: [_jsx("span", { className: "text-xs font-medium uppercase tracking-wide text-[var(--color-text-muted)]", children: col.label }), _jsx("div", { className: "min-w-0 break-words text-sm", children: col.cell ? col.cell(row) : formatCellValue(col, row) })] }, col.id)))] }, `r:${String(key)}:${idx}`));
|
|
264
408
|
}
|
|
265
409
|
return (_jsxs("div", { className: `grid items-center min-w-min px-3 py-2 text-sm border-b border-[var(--color-border)] ${onRowClick ? 'cursor-pointer hover:bg-[var(--color-surface-raised)]' : ''}`, style: {
|
|
266
|
-
gridTemplateColumns:
|
|
410
|
+
gridTemplateColumns: 'var(--dt-cols)',
|
|
267
411
|
background: isChecked ? 'rgba(99,102,241,0.07)' : undefined,
|
|
268
412
|
paddingLeft: rowIndent,
|
|
269
|
-
}, onClick: () => onRowClick?.(row), children: [selection && (_jsx("input", { type: "checkbox", checked: isChecked, onChange: () => toggleOne(key), onClick: (e) => e.stopPropagation(), "aria-label": "Zeile ausw\u00E4hlen" })), visibleColumns.map((col) => (_jsx("div", { className: "min-w-0 truncate", children: col.cell ? col.cell(row) :
|
|
413
|
+
}, onClick: () => onRowClick?.(row), children: [selection && (_jsx("input", { type: "checkbox", checked: isChecked, onChange: () => toggleOne(key), onClick: (e) => e.stopPropagation(), "aria-label": "Zeile ausw\u00E4hlen" })), visibleColumns.map((col) => (_jsx("div", { className: "min-w-0 truncate", children: col.cell ? col.cell(row) : formatCellValue(col, row) }, col.id))), _jsx("div", {})] }, `r:${String(key)}:${idx}`));
|
|
270
414
|
})), footer && (_jsx("div", { className: "px-3 py-2 text-xs text-[var(--color-text-muted)] border-t border-[var(--color-border)]", children: footer }))] }));
|
|
271
415
|
}
|
|
416
|
+
const DRAG_MIME = 'application/x-efa-datatable-column';
|
|
417
|
+
function HeaderCell({ columnId, label, dragColumnId, dropSide, hasCustomWidth, onDragStart, onDragEnd, onDragOverSide, onDrop, onResizePreview, onResizeCommit, onResizeCancel, children, }) {
|
|
418
|
+
const cellRef = useRef(null);
|
|
419
|
+
const [hovered, setHovered] = useState(false);
|
|
420
|
+
const [resizing, setResizing] = useState(false);
|
|
421
|
+
const [handleFocused, setHandleFocused] = useState(false);
|
|
422
|
+
const resizeStart = useRef(null);
|
|
423
|
+
const dragStartTimer = useRef(null);
|
|
424
|
+
const isDragSource = dragColumnId === columnId;
|
|
425
|
+
const sideFor = (clientX) => {
|
|
426
|
+
const r = cellRef.current?.getBoundingClientRect();
|
|
427
|
+
return r && clientX < r.left + r.width / 2 ? 'before' : 'after';
|
|
428
|
+
};
|
|
429
|
+
const acceptsDrag = (e) => dragColumnId != null && e.dataTransfer.types.includes(DRAG_MIME);
|
|
430
|
+
const currentWidth = () => cellRef.current?.getBoundingClientRect().width ?? MIN_COLUMN_WIDTH;
|
|
431
|
+
const handle = {
|
|
432
|
+
onPointerDown: (e) => {
|
|
433
|
+
if (e.button !== 0)
|
|
434
|
+
return;
|
|
435
|
+
e.preventDefault();
|
|
436
|
+
e.stopPropagation();
|
|
437
|
+
e.currentTarget.setPointerCapture(e.pointerId);
|
|
438
|
+
const width = currentWidth();
|
|
439
|
+
resizeStart.current = { x: e.clientX, width, last: width };
|
|
440
|
+
setResizing(true);
|
|
441
|
+
},
|
|
442
|
+
onPointerMove: (e) => {
|
|
443
|
+
const s = resizeStart.current;
|
|
444
|
+
if (!s)
|
|
445
|
+
return;
|
|
446
|
+
s.last = clampColumnWidth(s.width + e.clientX - s.x);
|
|
447
|
+
onResizePreview(s.last);
|
|
448
|
+
},
|
|
449
|
+
onPointerUp: (e) => {
|
|
450
|
+
const s = resizeStart.current;
|
|
451
|
+
if (!s)
|
|
452
|
+
return;
|
|
453
|
+
resizeStart.current = null;
|
|
454
|
+
setResizing(false);
|
|
455
|
+
if (e.currentTarget.hasPointerCapture(e.pointerId))
|
|
456
|
+
e.currentTarget.releasePointerCapture(e.pointerId);
|
|
457
|
+
if (Math.abs(e.clientX - s.x) < 2) {
|
|
458
|
+
onResizeCancel();
|
|
459
|
+
return;
|
|
460
|
+
}
|
|
461
|
+
onResizeCommit(s.last);
|
|
462
|
+
},
|
|
463
|
+
onPointerCancel: () => {
|
|
464
|
+
resizeStart.current = null;
|
|
465
|
+
setResizing(false);
|
|
466
|
+
onResizeCancel();
|
|
467
|
+
},
|
|
468
|
+
onDoubleClick: (e) => {
|
|
469
|
+
e.stopPropagation();
|
|
470
|
+
if (hasCustomWidth)
|
|
471
|
+
onResizeCommit(null);
|
|
472
|
+
},
|
|
473
|
+
onKeyDown: (e) => {
|
|
474
|
+
if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight')
|
|
475
|
+
return;
|
|
476
|
+
e.preventDefault();
|
|
477
|
+
const delta = e.key === 'ArrowLeft' ? -KEYBOARD_RESIZE_STEP : KEYBOARD_RESIZE_STEP;
|
|
478
|
+
onResizeCommit(clampColumnWidth(currentWidth() + delta));
|
|
479
|
+
},
|
|
480
|
+
};
|
|
481
|
+
const showChrome = hovered || resizing || handleFocused;
|
|
482
|
+
const indicator = {
|
|
483
|
+
position: 'absolute', top: -8, bottom: -8, width: 2,
|
|
484
|
+
background: 'var(--color-primary)', pointerEvents: 'none',
|
|
485
|
+
[dropSide === 'before' ? 'left' : 'right']: -1,
|
|
486
|
+
};
|
|
487
|
+
return (_jsxs("div", { ref: cellRef, "data-column-id": columnId, style: {
|
|
488
|
+
position: 'relative', display: 'flex', alignItems: 'center', minWidth: 0,
|
|
489
|
+
opacity: isDragSource ? 0.4 : undefined,
|
|
490
|
+
}, onMouseEnter: () => setHovered(true), onMouseLeave: () => setHovered(false), onDragOver: (e) => {
|
|
491
|
+
if (!acceptsDrag(e))
|
|
492
|
+
return;
|
|
493
|
+
e.preventDefault();
|
|
494
|
+
e.dataTransfer.dropEffect = 'move';
|
|
495
|
+
if (!isDragSource)
|
|
496
|
+
onDragOverSide(sideFor(e.clientX));
|
|
497
|
+
}, onDrop: (e) => {
|
|
498
|
+
if (!acceptsDrag(e))
|
|
499
|
+
return;
|
|
500
|
+
e.preventDefault();
|
|
501
|
+
const sourceId = e.dataTransfer.getData(DRAG_MIME);
|
|
502
|
+
if (sourceId)
|
|
503
|
+
onDrop(sourceId, sideFor(e.clientX));
|
|
504
|
+
}, children: [children, _jsx("span", { draggable: true, "aria-hidden": true, title: "Spalte verschieben", onDragStart: (e) => {
|
|
505
|
+
e.dataTransfer.setData(DRAG_MIME, columnId);
|
|
506
|
+
e.dataTransfer.effectAllowed = 'move';
|
|
507
|
+
if (cellRef.current)
|
|
508
|
+
e.dataTransfer.setDragImage(cellRef.current, 8, 8);
|
|
509
|
+
dragStartTimer.current = window.setTimeout(() => {
|
|
510
|
+
dragStartTimer.current = null;
|
|
511
|
+
onDragStart();
|
|
512
|
+
}, 0);
|
|
513
|
+
}, onDragEnd: () => {
|
|
514
|
+
if (dragStartTimer.current != null)
|
|
515
|
+
window.clearTimeout(dragStartTimer.current);
|
|
516
|
+
dragStartTimer.current = null;
|
|
517
|
+
onDragEnd();
|
|
518
|
+
}, style: {
|
|
519
|
+
position: 'absolute', right: 6, top: '50%', transform: 'translateY(-50%)',
|
|
520
|
+
display: (hovered && !dragColumnId && !resizing) || isDragSource ? 'inline-flex' : 'none',
|
|
521
|
+
padding: 2, cursor: 'grab', zIndex: 1,
|
|
522
|
+
color: 'var(--color-text-muted)',
|
|
523
|
+
background: 'var(--color-surface-raised)',
|
|
524
|
+
borderRadius: 'var(--border-radius-md)',
|
|
525
|
+
}, children: _jsx(GripVertical, { style: { width: 12, height: 12 } }) }), _jsx("div", { role: "separator", "aria-orientation": "vertical", "aria-label": `Breite der Spalte ${label} ändern`, tabIndex: 0, title: "Ziehen: Breite \u00E4ndern \u00B7 Doppelklick: Standardbreite", ...handle, onFocus: () => setHandleFocused(true), onBlur: () => setHandleFocused(false), style: {
|
|
526
|
+
position: 'absolute', top: -8, bottom: -8, right: -4, width: 8,
|
|
527
|
+
cursor: 'col-resize', touchAction: 'none', zIndex: 2, outline: 'none',
|
|
528
|
+
display: 'flex', justifyContent: 'center',
|
|
529
|
+
}, children: _jsx("span", { style: {
|
|
530
|
+
width: 2, height: '100%',
|
|
531
|
+
background: resizing || handleFocused ? 'var(--color-primary)' : 'var(--color-border)',
|
|
532
|
+
opacity: showChrome ? 1 : 0,
|
|
533
|
+
transition: 'opacity 120ms',
|
|
534
|
+
} }) }), dropSide && _jsx("span", { style: indicator })] }));
|
|
535
|
+
}
|
|
272
536
|
function FilterEditor({ column, value, onChange, }) {
|
|
273
537
|
if (!column.filter)
|
|
274
538
|
return null;
|
|
@@ -310,7 +574,7 @@ function ColumnInventoryButton({ columns, prefs, setColumnVisible, moveColumn, r
|
|
|
310
574
|
const [open, setOpen] = useState(false);
|
|
311
575
|
const orderedColumns = useMemo(() => {
|
|
312
576
|
const byId = new Map(columns.map((c) => [c.id, c]));
|
|
313
|
-
return prefs.columnOrder.map((id) => byId.get(id)).filter(Boolean);
|
|
577
|
+
return reconcileOrder(columns, prefs.columnOrder).map((id) => byId.get(id)).filter(Boolean);
|
|
314
578
|
}, [columns, prefs.columnOrder]);
|
|
315
579
|
return (_jsxs(DropdownMenu.Root, { open: open, onOpenChange: setOpen, children: [_jsx(DropdownMenu.Trigger, { asChild: true, children: _jsx("button", { type: "button", className: "p-2.5 sm:p-1 hover:bg-[var(--color-surface)] text-[var(--color-text-muted)] justify-self-end", title: "Spalten konfigurieren", style: { borderRadius: 'var(--border-radius-md)' }, children: _jsx(Settings, { className: "w-4 h-4" }) }) }), _jsxs(DropdownMenu.Content, { className: "min-w-[260px] p-2", children: [_jsx("div", { className: "text-xs font-semibold mb-2 text-[var(--color-text-muted)] uppercase", children: "Gruppieren nach" }), groupBy.length === 0 ? (_jsx("div", { className: "text-xs text-[var(--color-text-muted)] italic px-1 mb-2", children: "Keine Gruppierung aktiv." })) : (_jsxs("div", { className: "mb-2 space-y-1", onClick: (e) => e.stopPropagation(), children: [groupBy.map((id, idx) => {
|
|
316
580
|
const col = columns.find((c) => c.id === id);
|
|
@@ -321,7 +585,7 @@ function ColumnInventoryButton({ columns, prefs, setColumnVisible, moveColumn, r
|
|
|
321
585
|
addGroupBy(v);
|
|
322
586
|
e.target.value = '';
|
|
323
587
|
}, className: "flex-1 px-1 py-0.5 text-xs border border-[var(--color-border)] bg-[var(--color-surface)]", style: { borderRadius: 'var(--border-radius-md)' }, children: [_jsx("option", { value: "", children: "\u2013 w\u00E4hlen \u2013" }), groupableCandidates.map((c) => (_jsx("option", { value: c.id, children: c.label }, c.id)))] })] })), _jsx("div", { className: "border-t border-[var(--color-border)] mb-2" }), _jsx("div", { className: "text-xs font-semibold mb-2 text-[var(--color-text-muted)] uppercase", children: "Spalten" }), orderedColumns.map((col, idx) => {
|
|
324
|
-
const visible = prefs.columnVisibility
|
|
588
|
+
const visible = isColumnVisible(col, prefs.columnVisibility);
|
|
325
589
|
return (_jsxs("div", { className: "flex items-center gap-2 px-1 py-1 text-sm", onClick: (e) => e.stopPropagation(), children: [_jsx("input", { type: "checkbox", checked: visible, onChange: () => setColumnVisible(col.id, !visible), "aria-label": `Spalte ${col.label} ${visible ? 'ausblenden' : 'einblenden'}` }), _jsx("span", { className: "flex-1 text-xs", children: col.label }), _jsx("button", { type: "button", disabled: idx === 0, onClick: () => moveColumn(col.id, -1), className: "p-2 sm:p-0.5 disabled:opacity-30 hover:bg-[var(--color-surface-raised)]", title: "Nach oben", style: { borderRadius: 'var(--border-radius-md)' }, children: _jsx(ArrowUp, { className: "w-3 h-3" }) }), _jsx("button", { type: "button", disabled: idx === orderedColumns.length - 1, onClick: () => moveColumn(col.id, 1), className: "p-2 sm:p-0.5 disabled:opacity-30 hover:bg-[var(--color-surface-raised)]", title: "Nach unten", style: { borderRadius: 'var(--border-radius-md)' }, children: _jsx(ArrowDown, { className: "w-3 h-3" }) })] }, col.id));
|
|
326
590
|
}), _jsx("div", { className: "border-t border-[var(--color-border)] mt-2 pt-2", children: _jsxs(Button, { variant: "ghost", onClick: () => { reset(); setOpen(false); }, children: [_jsx(RotateCcw, { className: "w-4 h-4" }), "Zur\u00FCcksetzen"] }) })] })] }));
|
|
327
591
|
}
|
|
@@ -7,7 +7,7 @@ export const Portal = Radix.Portal;
|
|
|
7
7
|
export const Content = React.forwardRef(({ className = '', sideOffset = 4, ...props }, ref) => (_jsx(Radix.Portal, { children: _jsx(Radix.Content, { ref: ref, sideOffset: sideOffset, className: `z-50 min-w-[160px] max-w-[calc(100vw-2rem)] rounded-lg border border-[var(--color-border)] bg-[var(--color-surface)] p-1 shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 ${className}`, ...props }) })));
|
|
8
8
|
Content.displayName = 'DropdownMenu.Content';
|
|
9
9
|
export const Item = React.forwardRef(({ className = '', variant = 'default', ...props }, ref) => (_jsx(Radix.Item, { ref: ref, className: `flex items-center gap-2 px-3 py-1.5 text-sm rounded-sm cursor-pointer outline-none select-none transition-colors data-[disabled]:opacity-50 data-[disabled]:pointer-events-none ${variant === 'danger'
|
|
10
|
-
? 'text-
|
|
10
|
+
? 'signal-text-danger data-[highlighted]:bg-[var(--color-surface-raised)]'
|
|
11
11
|
: 'text-[var(--color-text-primary)] data-[highlighted]:bg-[var(--color-surface-raised)]'} ${className}`, ...props })));
|
|
12
12
|
Item.displayName = 'DropdownMenu.Item';
|
|
13
13
|
export const Separator = React.forwardRef(({ className = '', ...props }, ref) => (_jsx(Radix.Separator, { ref: ref, className: `my-1 h-px bg-[var(--color-border)] ${className}`, ...props })));
|
|
@@ -36,5 +36,5 @@ export function RecordDialog({ open, onOpenChange, mode: controlledMode, onModeC
|
|
|
36
36
|
return (_jsx(Dialog, { open: open !== null, onOpenChange: (o) => { if (!o)
|
|
37
37
|
handleClose(); }, title: title, banner: mode !== 'read' && error
|
|
38
38
|
? _jsx(Alert, { variant: "error", onDismiss: onErrorDismiss, children: error })
|
|
39
|
-
: undefined, footer: mode === 'read' ? (_jsxs(_Fragment, { children: [_jsx(DialogClose, { asChild: true, children: _jsx(Button, { variant: "secondary", children: closeLabel }) }), _jsxs(Button, { onClick: () => setMode('edit'), children: [_jsx(Pencil, { className: "w-4 h-4" }), editLabel] })] })) : (_jsxs(_Fragment, { children: [_jsx(DialogClose, { asChild: true, children: _jsx(Button, { variant: "secondary", children: cancelLabel }) }), _jsx(Button, { onClick: handleSave, loading: !!saving, disabled: !!saveDisabled, children: mode === 'create' ? createLabel : saveLabel })] })), children: mode === 'read' ? (_jsxs("div", { children: [_jsx("div", { className: "flex justify-end mb-2 -mt-1", children: _jsx("button", { onClick: () => setMode('edit'), className: "p-1.5 rounded-md text-[var(--color-text-muted)] hover:text-[var(--color-primary)] hover:bg-[var(--color-surface-raised)] transition-colors", title: editLabel, "aria-label": editLabel, children: _jsx(Pencil, { className: "w-4 h-4" }) }) }), readContent] })) : (_jsx("div", { className: "flex flex-col gap-4", children: editContent })) }));
|
|
39
|
+
: undefined, footer: mode === 'read' ? (_jsxs(_Fragment, { children: [_jsx(DialogClose, { asChild: true, children: _jsx(Button, { variant: "secondary", children: closeLabel }) }), _jsxs(Button, { onClick: () => setMode('edit'), children: [_jsx(Pencil, { className: "w-4 h-4" }), editLabel] })] })) : (_jsxs(_Fragment, { children: [_jsx(DialogClose, { asChild: true, children: _jsx(Button, { variant: "secondary", children: cancelLabel }) }), _jsx(Button, { onClick: handleSave, loading: !!saving, disabled: !!saveDisabled, children: mode === 'create' ? createLabel : saveLabel })] })), children: mode === 'read' ? (_jsxs("div", { children: [_jsx("div", { className: "flex justify-end mb-2 -mt-1", children: _jsx("button", { onClick: () => setMode('edit'), className: "p-1.5 rounded-md text-[var(--color-text-muted)] hover:text-[var(--color-primary-text)] hover:bg-[var(--color-surface-raised)] transition-colors", title: editLabel, "aria-label": editLabel, children: _jsx(Pencil, { className: "w-4 h-4" }) }) }), readContent] })) : (_jsx("div", { className: "flex flex-col gap-4", children: editContent })) }));
|
|
40
40
|
}
|
package/frontend/ui/styles.css
CHANGED
|
@@ -7,8 +7,42 @@
|
|
|
7
7
|
*
|
|
8
8
|
* Die Design-Tokens (`--color-*`, `--border-radius-*`) definiert die App selbst
|
|
9
9
|
* (Kernel-Theme-überschrieben zur Laufzeit) — hier bewusst NICHT dupliziert.
|
|
10
|
+
* Einzige Ausnahme: die abgeleiteten Text-Tokens direkt darunter.
|
|
10
11
|
*/
|
|
11
12
|
|
|
13
|
+
/* ── Signalfarben als TEXT (WCAG AA) ─────────────────────────────
|
|
14
|
+
* Die reinen Signalfarben (`--color-danger|warning|success|primary`) sind
|
|
15
|
+
* Flächen-/Rahmenfarben. Als Schrift auf hellem Grund fallen sie durch den
|
|
16
|
+
* AA-Kontrast (4,5 : 1): im Kernel-Theme „Standard Light" Rot 3,76, Orange 2,95,
|
|
17
|
+
* Grün 2,28, Bernstein 2,15. Für TEXT deshalb immer die `-text`-Variante.
|
|
18
|
+
*
|
|
19
|
+
* Abgeleitet per color-mix() RICHTUNG `--color-text-primary`: im hellen Theme
|
|
20
|
+
* wird die Farbe dunkler, im dunklen heller — ein Token für beide Modi. Weil
|
|
21
|
+
* `--color-text-primary` und die Signalfarben zur Laufzeit per CONVERGE_AUTH
|
|
22
|
+
* auf :root überschrieben werden, rechnet der Browser die Mischung für JEDES
|
|
23
|
+
* Theme neu, auch für Custom Themes aus dem Kernel.
|
|
24
|
+
*
|
|
25
|
+
* Mischanteile gegen alle Built-in-Themes des Kernels nachgerechnet (hell/dunkel,
|
|
26
|
+
* auf surface, background, surface-raised und der 10/15-%-Tönung von Alert/Badge):
|
|
27
|
+
* Minimum 4,86 : 1. Test: test/signalTextContrast.test.ts — wer einen Anteil
|
|
28
|
+
* anhebt, muss den Test grün halten.
|
|
29
|
+
*/
|
|
30
|
+
:root {
|
|
31
|
+
--color-danger-text: color-mix(in srgb, var(--color-danger) 55%, var(--color-text-primary));
|
|
32
|
+
--color-warning-text: color-mix(in srgb, var(--color-warning) 45%, var(--color-text-primary));
|
|
33
|
+
--color-success-text: color-mix(in srgb, var(--color-success) 45%, var(--color-text-primary));
|
|
34
|
+
--color-primary-text: color-mix(in srgb, var(--color-primary) 50%, var(--color-text-primary));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/* Text-Hilfsklassen für die `-text`-Tokens. Das ui-Kit nutzt diese Klassen statt
|
|
38
|
+
* `text-[var(--color-…-text)]`: Arbitrary-Werte erzeugt die Tailwind der App nur,
|
|
39
|
+
* wenn ihr `content` das SDK scannt — ohne das verlöre z. B. der „Löschen"-Eintrag
|
|
40
|
+
* im DropdownMenu still seine Farbe. Apps dürfen sie ebenfalls nutzen. */
|
|
41
|
+
.signal-text-danger { color: var(--color-danger-text); }
|
|
42
|
+
.signal-text-warning { color: var(--color-warning-text); }
|
|
43
|
+
.signal-text-success { color: var(--color-success-text); }
|
|
44
|
+
.signal-text-primary { color: var(--color-primary-text); }
|
|
45
|
+
|
|
12
46
|
/* ── Badge utilities ─────────────────────────────────────────── */
|
|
13
47
|
.badge {
|
|
14
48
|
display: inline-flex;
|
|
@@ -20,21 +54,46 @@
|
|
|
20
54
|
}
|
|
21
55
|
.badge-success {
|
|
22
56
|
background: color-mix(in srgb, var(--color-success) 15%, transparent);
|
|
23
|
-
color: var(--color-success);
|
|
57
|
+
color: var(--color-success-text);
|
|
24
58
|
}
|
|
25
59
|
.badge-warning {
|
|
26
60
|
background: color-mix(in srgb, var(--color-warning) 15%, transparent);
|
|
27
|
-
color: var(--color-warning);
|
|
61
|
+
color: var(--color-warning-text);
|
|
28
62
|
}
|
|
29
63
|
.badge-danger {
|
|
30
64
|
background: color-mix(in srgb, var(--color-danger) 15%, transparent);
|
|
31
|
-
color: var(--color-danger);
|
|
65
|
+
color: var(--color-danger-text);
|
|
32
66
|
}
|
|
33
67
|
.badge-neutral {
|
|
34
68
|
background: var(--color-surface-raised);
|
|
35
69
|
color: var(--color-text-secondary);
|
|
36
70
|
}
|
|
37
71
|
|
|
72
|
+
/* ── Alert (Fehler-/Status-Banner) ───────────────────────────────
|
|
73
|
+
* Fläche = 10-%-Tönung, Rahmen = reine Signalfarbe, Text = `-text`-Variante.
|
|
74
|
+
* Bewusst hier und nicht als Tailwind-Arbitrary im Component: die App-Tailwind
|
|
75
|
+
* scannt `node_modules` nicht zwingend, dann fehlten die Farben ganz. */
|
|
76
|
+
.alert-error {
|
|
77
|
+
background: color-mix(in srgb, var(--color-danger) 10%, transparent);
|
|
78
|
+
border-color: var(--color-danger);
|
|
79
|
+
color: var(--color-danger-text);
|
|
80
|
+
}
|
|
81
|
+
.alert-success {
|
|
82
|
+
background: color-mix(in srgb, var(--color-success) 10%, transparent);
|
|
83
|
+
border-color: var(--color-success);
|
|
84
|
+
color: var(--color-success-text);
|
|
85
|
+
}
|
|
86
|
+
.alert-warning {
|
|
87
|
+
background: color-mix(in srgb, var(--color-warning) 10%, transparent);
|
|
88
|
+
border-color: var(--color-warning);
|
|
89
|
+
color: var(--color-warning-text);
|
|
90
|
+
}
|
|
91
|
+
.alert-info {
|
|
92
|
+
background: color-mix(in srgb, var(--color-primary) 10%, transparent);
|
|
93
|
+
border-color: var(--color-primary);
|
|
94
|
+
color: var(--color-primary-text);
|
|
95
|
+
}
|
|
96
|
+
|
|
38
97
|
/* ── Skeleton loading ────────────────────────────────────────── */
|
|
39
98
|
.skeleton {
|
|
40
99
|
background: linear-gradient(
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@efa-one/sdk",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.21.0",
|
|
4
4
|
"description": "Offizielles SDK für efa-one-Apps: Auth/Session, Service-Discovery, Gateway-Client, Audit/Reporting/Mail/Notifications, Permission- und Capability-Registrierung sowie das Frontend-IPC-/i18n-Toolkit.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "commonjs",
|