@efa-one/sdk 1.17.0 → 1.20.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 +61 -2
- package/frontend/ipc.d.ts +19 -0
- package/frontend/ipc.js +79 -0
- package/frontend/ui/Alert.js +1 -7
- package/frontend/ui/DataTable.d.ts +8 -0
- package/frontend/ui/DataTable.js +224 -19
- 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
|
|
@@ -61,6 +71,24 @@ import { initI18n, registerAppInfo, DevHeader } from '@efa-one/sdk/frontend';
|
|
|
61
71
|
registerAppInfo({ appName: 'efa-chat', version: __APP_VERSION__ });
|
|
62
72
|
```
|
|
63
73
|
|
|
74
|
+
```tsx
|
|
75
|
+
// Frontend — receive the login token from the kernel (inside a mount effect)
|
|
76
|
+
import { subscribeConvergeAuth } from '@efa-one/sdk/frontend';
|
|
77
|
+
|
|
78
|
+
useEffect(() => subscribeConvergeAuth({
|
|
79
|
+
onAuth: async ({ token, serviceKey, theme, language }) => {
|
|
80
|
+
try { await exchange(token, serviceKey); return true; } catch { return false; }
|
|
81
|
+
},
|
|
82
|
+
onTimeout: () => setError('No login received from the platform — reload the page.'),
|
|
83
|
+
}), []);
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
The app asks for the token itself (`CONVERGE_AUTH_REQUEST`) as soon as its listener
|
|
87
|
+
is attached and repeats the request with backoff. Waiting passively for the kernel's
|
|
88
|
+
push is not enough: the push goes out in a fixed window after the tile opens, and an
|
|
89
|
+
app that needs longer to mount (cold cache, slow connection) would miss it and spin
|
|
90
|
+
forever.
|
|
91
|
+
|
|
64
92
|
```tsx
|
|
65
93
|
// Frontend — a fully-featured list in a few lines
|
|
66
94
|
import { DataTable, type ColumnDef } from '@efa-one/sdk/frontend/ui';
|
|
@@ -71,6 +99,7 @@ const viewPrefs = createViewPreferencesClient({ apiBase: getApiBase }); // creat
|
|
|
71
99
|
const columns: ColumnDef<Item>[] = [
|
|
72
100
|
{ id: 'name', label: 'Name', accessor: (r) => r.name, filter: { type: 'text' } },
|
|
73
101
|
{ id: 'status', label: 'Status', accessor: (r) => r.status },
|
|
102
|
+
{ id: 'created', label: 'Angelegt', accessor: (r) => r.createdAt, type: 'date' }, // → 07.10.2026
|
|
74
103
|
];
|
|
75
104
|
|
|
76
105
|
<DataTable listId="items.list" rows={items} columns={columns} rowKey={(r) => r.id} persistence={viewPrefs} />
|
|
@@ -152,6 +181,36 @@ kernel without that endpoint, every authenticated request fails closed with
|
|
|
152
181
|
Sessions issued by an older SDK are rejected once (401) and renewed on the next
|
|
153
182
|
iframe load.
|
|
154
183
|
|
|
184
|
+
**1.18.0 — auth handshake on request.** `subscribeConvergeAuth` sends
|
|
185
|
+
`CONVERGE_AUTH_REQUEST` to the kernel. It works against any kernel: an older kernel
|
|
186
|
+
ignores the request and its fixed-window push reaches the app as before; a current
|
|
187
|
+
kernel answers the request directly. No rollout order.
|
|
188
|
+
|
|
189
|
+
**1.19.0 — readable signal colors (WCAG AA).** `styles.css` now defines
|
|
190
|
+
`--color-danger-text`, `--color-warning-text`, `--color-success-text` and
|
|
191
|
+
`--color-primary-text`: the semantic color mixed toward `--color-text-primary`, so
|
|
192
|
+
they darken in light themes and lighten in dark ones and follow every kernel theme,
|
|
193
|
+
including custom ones, plus the classes `.signal-text-danger|warning|success|primary`.
|
|
194
|
+
`Badge`, `Alert`, the danger item of `DropdownMenu` and the DataTable filter icon use
|
|
195
|
+
them for text; fills and borders keep the pure signal color. These colors come from
|
|
196
|
+
classes in `styles.css`, not Tailwind arbitrary values, so they work even when the
|
|
197
|
+
app's Tailwind does not scan the SDK — but `Alert` now needs the `styles.css` import
|
|
198
|
+
like `Badge` already did. No kernel change and no rollout order. Apps should switch their own
|
|
199
|
+
signal-colored text (`text-[var(--color-danger)]`, orange links) to the `-text`
|
|
200
|
+
tokens.
|
|
201
|
+
|
|
202
|
+
**1.20.0 — columns you can move and resize, date columns.** In the DataTable header,
|
|
203
|
+
each column has a grip (right side, on hover) to drag it to a new position and a
|
|
204
|
+
handle on its right edge to drag its width; double-click the handle (or „Standardbreite"
|
|
205
|
+
in the column menu) to go back to the code default, arrow keys resize from the keyboard.
|
|
206
|
+
Order and widths persist per user with the rest of the view (`columnWidths` in the
|
|
207
|
+
stored JSON, no version bump, existing saved views stay). New `ColumnDef.type`:
|
|
208
|
+
`'date'` renders `DD.MM.YYYY`, `'datetime'` `DD.MM.YYYY, HH:MM` (via `format.ts`), sorts
|
|
209
|
+
chronologically, groups by day and lets the text filter match the displayed form.
|
|
210
|
+
`Date` values without a `type` are formatted as dates too. Custom `cell` renderers stay
|
|
211
|
+
the app's job: use `formatDate`/`formatDateTime`, never `toLocaleDateString` (that is
|
|
212
|
+
where `7.10.2026` comes from). No backend or kernel change.
|
|
213
|
+
|
|
155
214
|
## License
|
|
156
215
|
|
|
157
216
|
[Apache-2.0](./LICENSE) — permissive with a patent grant, so customers and partners
|
package/frontend/ipc.d.ts
CHANGED
|
@@ -24,3 +24,22 @@ export declare function navigateToApp(serviceKey: string, options?: {
|
|
|
24
24
|
path?: string;
|
|
25
25
|
}): void;
|
|
26
26
|
export declare function notifyRouteChange(path: string): void;
|
|
27
|
+
export declare const CONVERGE_AUTH = "CONVERGE_AUTH";
|
|
28
|
+
export declare const CONVERGE_AUTH_REQUEST = "CONVERGE_AUTH_REQUEST";
|
|
29
|
+
export interface ConvergeAuthMessage<TTheme = unknown> {
|
|
30
|
+
type: typeof CONVERGE_AUTH;
|
|
31
|
+
token: string;
|
|
32
|
+
serviceKey?: string;
|
|
33
|
+
language?: string;
|
|
34
|
+
theme?: TTheme;
|
|
35
|
+
embed?: boolean;
|
|
36
|
+
}
|
|
37
|
+
export interface SubscribeConvergeAuthOptions<TTheme = unknown> {
|
|
38
|
+
onAuth: (message: ConvergeAuthMessage<TTheme>) => Promise<boolean>;
|
|
39
|
+
onTimeout?: () => void;
|
|
40
|
+
requestDelaysMs?: readonly number[];
|
|
41
|
+
timeoutMs?: number;
|
|
42
|
+
}
|
|
43
|
+
export declare const DEFAULT_AUTH_REQUEST_DELAYS_MS: readonly number[];
|
|
44
|
+
export declare const DEFAULT_AUTH_TIMEOUT_MS = 15000;
|
|
45
|
+
export declare function subscribeConvergeAuth<TTheme = unknown>(options: SubscribeConvergeAuthOptions<TTheme>): () => void;
|
package/frontend/ipc.js
CHANGED
|
@@ -59,3 +59,82 @@ export function notifyRouteChange(path) {
|
|
|
59
59
|
return;
|
|
60
60
|
window.parent.postMessage({ type: 'CONVERGE_ROUTE_CHANGED', path }, getParentOrigin());
|
|
61
61
|
}
|
|
62
|
+
export const CONVERGE_AUTH = 'CONVERGE_AUTH';
|
|
63
|
+
export const CONVERGE_AUTH_REQUEST = 'CONVERGE_AUTH_REQUEST';
|
|
64
|
+
export const DEFAULT_AUTH_REQUEST_DELAYS_MS = [0, 500, 1000, 2000, 4000, 8000];
|
|
65
|
+
export const DEFAULT_AUTH_TIMEOUT_MS = 15000;
|
|
66
|
+
function isAuthMessage(data) {
|
|
67
|
+
const d = data;
|
|
68
|
+
return !!d && d.type === CONVERGE_AUTH && typeof d.token === 'string' && d.token !== '';
|
|
69
|
+
}
|
|
70
|
+
export function subscribeConvergeAuth(options) {
|
|
71
|
+
if (!isEmbedded())
|
|
72
|
+
return () => { };
|
|
73
|
+
const delays = options.requestDelaysMs ?? DEFAULT_AUTH_REQUEST_DELAYS_MS;
|
|
74
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_AUTH_TIMEOUT_MS;
|
|
75
|
+
let active = true;
|
|
76
|
+
let received = false;
|
|
77
|
+
let authenticated = false;
|
|
78
|
+
let inFlight = false;
|
|
79
|
+
let queued = null;
|
|
80
|
+
const timers = [];
|
|
81
|
+
const clearTimers = () => {
|
|
82
|
+
timers.forEach((t) => clearTimeout(t));
|
|
83
|
+
timers.length = 0;
|
|
84
|
+
};
|
|
85
|
+
const start = (message) => {
|
|
86
|
+
inFlight = true;
|
|
87
|
+
options.onAuth(message).then((ok) => finish(ok), () => finish(false));
|
|
88
|
+
};
|
|
89
|
+
const finish = (ok) => {
|
|
90
|
+
inFlight = false;
|
|
91
|
+
if (!active)
|
|
92
|
+
return;
|
|
93
|
+
if (ok) {
|
|
94
|
+
authenticated = true;
|
|
95
|
+
queued = null;
|
|
96
|
+
clearTimers();
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
const next = queued;
|
|
100
|
+
queued = null;
|
|
101
|
+
if (next)
|
|
102
|
+
start(next);
|
|
103
|
+
};
|
|
104
|
+
const handler = (event) => {
|
|
105
|
+
if (!isFromPlatformParent(event))
|
|
106
|
+
return;
|
|
107
|
+
if (!isAuthMessage(event.data))
|
|
108
|
+
return;
|
|
109
|
+
received = true;
|
|
110
|
+
if (authenticated)
|
|
111
|
+
return;
|
|
112
|
+
const message = event.data;
|
|
113
|
+
if (inFlight) {
|
|
114
|
+
queued = message;
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
start(message);
|
|
118
|
+
};
|
|
119
|
+
const request = () => {
|
|
120
|
+
if (!active || received)
|
|
121
|
+
return;
|
|
122
|
+
window.parent.postMessage({ type: CONVERGE_AUTH_REQUEST }, getParentOrigin());
|
|
123
|
+
};
|
|
124
|
+
window.addEventListener('message', handler);
|
|
125
|
+
for (const ms of delays) {
|
|
126
|
+
if (ms <= 0)
|
|
127
|
+
request();
|
|
128
|
+
else
|
|
129
|
+
timers.push(setTimeout(request, ms));
|
|
130
|
+
}
|
|
131
|
+
timers.push(setTimeout(() => {
|
|
132
|
+
if (active && !received)
|
|
133
|
+
options.onTimeout?.();
|
|
134
|
+
}, timeoutMs));
|
|
135
|
+
return () => {
|
|
136
|
+
active = false;
|
|
137
|
+
clearTimers();
|
|
138
|
+
window.removeEventListener('message', handler);
|
|
139
|
+
};
|
|
140
|
+
}
|
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> {
|
|
@@ -57,6 +59,12 @@ interface DataTableProps<T, K extends string | number> {
|
|
|
57
59
|
persistence?: ViewPreferencesAdapter;
|
|
58
60
|
}
|
|
59
61
|
export declare function defaultPrefsFor<T>(columns: ColumnDef<T>[], overrides?: Partial<ViewPrefs>): ViewPrefs;
|
|
62
|
+
export declare function formatCellValue<T>(col: ColumnDef<T>, row: T): string;
|
|
63
|
+
export declare const MIN_COLUMN_WIDTH = 60;
|
|
64
|
+
export declare const MAX_COLUMN_WIDTH = 1600;
|
|
65
|
+
export declare function clampColumnWidth(px: number): number;
|
|
66
|
+
export declare function reorderColumns(order: string[], sourceId: string, targetId: string, side: 'before' | 'after'): string[];
|
|
67
|
+
export declare function gridTemplateFor<T>(visibleColumns: ColumnDef<T>[], columnWidths: Record<string, number> | undefined, hasSelection: boolean): string;
|
|
60
68
|
export declare function groupLabelOf(value: unknown): string;
|
|
61
69
|
interface RenderItem<T> {
|
|
62
70
|
kind: 'group' | 'row';
|
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 { 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,56 @@ 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
|
+
function isDateColumn(col, value) {
|
|
27
|
+
return col.type === 'date' || col.type === 'datetime' || (col.type == null && value instanceof Date);
|
|
28
|
+
}
|
|
29
|
+
function timeOf(value) {
|
|
30
|
+
if (value == null || value === '')
|
|
31
|
+
return null;
|
|
32
|
+
const t = value instanceof Date ? value.getTime() : new Date(value).getTime();
|
|
33
|
+
return Number.isNaN(t) ? null : t;
|
|
34
|
+
}
|
|
35
|
+
export function formatCellValue(col, row) {
|
|
36
|
+
const v = col.accessor(row);
|
|
37
|
+
if (isDateColumn(col, v)) {
|
|
38
|
+
return col.type === 'datetime' ? formatDateTime(v) : formatDate(v);
|
|
39
|
+
}
|
|
40
|
+
return String(v ?? '');
|
|
41
|
+
}
|
|
42
|
+
export const MIN_COLUMN_WIDTH = 60;
|
|
43
|
+
export const MAX_COLUMN_WIDTH = 1600;
|
|
44
|
+
const KEYBOARD_RESIZE_STEP = 16;
|
|
45
|
+
export function clampColumnWidth(px) {
|
|
46
|
+
if (!Number.isFinite(px))
|
|
47
|
+
return MIN_COLUMN_WIDTH;
|
|
48
|
+
return Math.round(Math.min(MAX_COLUMN_WIDTH, Math.max(MIN_COLUMN_WIDTH, px)));
|
|
49
|
+
}
|
|
50
|
+
export function reorderColumns(order, sourceId, targetId, side) {
|
|
51
|
+
if (sourceId === targetId)
|
|
52
|
+
return order;
|
|
53
|
+
if (!order.includes(sourceId) || !order.includes(targetId))
|
|
54
|
+
return order;
|
|
55
|
+
const without = order.filter((id) => id !== sourceId);
|
|
56
|
+
const targetIdx = without.indexOf(targetId);
|
|
57
|
+
without.splice(side === 'before' ? targetIdx : targetIdx + 1, 0, sourceId);
|
|
58
|
+
return without;
|
|
59
|
+
}
|
|
60
|
+
export function gridTemplateFor(visibleColumns, columnWidths, hasSelection) {
|
|
61
|
+
const cols = [];
|
|
62
|
+
if (hasSelection)
|
|
63
|
+
cols.push('36px');
|
|
64
|
+
visibleColumns.forEach((c) => {
|
|
65
|
+
const w = columnWidths?.[c.id];
|
|
66
|
+
cols.push(typeof w === 'number' && Number.isFinite(w) ? `${clampColumnWidth(w)}px` : (c.width ?? '1fr'));
|
|
67
|
+
});
|
|
68
|
+
cols.push('40px');
|
|
69
|
+
return cols.join(' ');
|
|
70
|
+
}
|
|
24
71
|
export function groupLabelOf(value) {
|
|
25
72
|
if (value == null)
|
|
26
73
|
return '';
|
|
@@ -44,18 +91,28 @@ export function buildRenderItems(rows, groupBy, columnsById, expandedGroups) {
|
|
|
44
91
|
return;
|
|
45
92
|
}
|
|
46
93
|
const buckets = new Map();
|
|
94
|
+
const bucketTime = new Map();
|
|
95
|
+
let chronological = false;
|
|
47
96
|
for (const r of subset) {
|
|
48
|
-
const
|
|
97
|
+
const raw = col.accessor(r);
|
|
98
|
+
const isDate = isDateColumn(col, raw);
|
|
99
|
+
if (isDate)
|
|
100
|
+
chronological = true;
|
|
101
|
+
const lbl = isDate ? (timeOf(raw) == null ? '' : formatDate(raw)) : groupLabelOf(raw);
|
|
49
102
|
const bucketLabel = lbl || EMPTY_GROUP_LABEL;
|
|
50
103
|
const arr = buckets.get(bucketLabel) ?? [];
|
|
51
104
|
arr.push(r);
|
|
52
105
|
buckets.set(bucketLabel, arr);
|
|
106
|
+
if (!bucketTime.has(bucketLabel))
|
|
107
|
+
bucketTime.set(bucketLabel, isDate ? timeOf(raw) : null);
|
|
53
108
|
}
|
|
54
109
|
const labels = Array.from(buckets.keys()).sort((a, b) => {
|
|
55
110
|
if (a === EMPTY_GROUP_LABEL)
|
|
56
111
|
return 1;
|
|
57
112
|
if (b === EMPTY_GROUP_LABEL)
|
|
58
113
|
return -1;
|
|
114
|
+
if (chronological)
|
|
115
|
+
return (bucketTime.get(a) ?? 0) - (bucketTime.get(b) ?? 0);
|
|
59
116
|
return a.localeCompare(b, 'de');
|
|
60
117
|
});
|
|
61
118
|
for (const label of labels) {
|
|
@@ -94,6 +151,10 @@ export function DataTable({ listId, rows, columns, rowKey, onRowClick, selection
|
|
|
94
151
|
};
|
|
95
152
|
const columnsById = useMemo(() => new Map(columns.map((c) => [c.id, c])), [columns]);
|
|
96
153
|
const groupBy = prefs.groupBy ?? [];
|
|
154
|
+
const columnWidths = prefs.columnWidths ?? {};
|
|
155
|
+
const [dragColumnId, setDragColumnId] = useState(null);
|
|
156
|
+
const [dropTarget, setDropTarget] = useState(null);
|
|
157
|
+
const containerRef = useRef(null);
|
|
97
158
|
const visibleColumns = useMemo(() => {
|
|
98
159
|
const byId = new Map(columns.map((c) => [c.id, c]));
|
|
99
160
|
return prefs.columnOrder
|
|
@@ -110,7 +171,10 @@ export function DataTable({ listId, rows, columns, rowKey, onRowClick, selection
|
|
|
110
171
|
const q = f.text.toLowerCase();
|
|
111
172
|
out = out.filter((r) => {
|
|
112
173
|
const v = col.accessor(r);
|
|
113
|
-
|
|
174
|
+
if (v == null)
|
|
175
|
+
return false;
|
|
176
|
+
const text = isDateColumn(col, v) ? formatCellValue(col, r) : String(v);
|
|
177
|
+
return text.toLowerCase().includes(q);
|
|
114
178
|
});
|
|
115
179
|
}
|
|
116
180
|
else if (col.filter.type === 'multi-select' && f.selected && f.selected.length > 0) {
|
|
@@ -131,8 +195,12 @@ export function DataTable({ listId, rows, columns, rowKey, onRowClick, selection
|
|
|
131
195
|
if (col) {
|
|
132
196
|
const dir = prefs.sort.direction === 'asc' ? 1 : -1;
|
|
133
197
|
out = [...out].sort((a, b) => {
|
|
134
|
-
|
|
135
|
-
|
|
198
|
+
let va = col.accessor(a);
|
|
199
|
+
let vb = col.accessor(b);
|
|
200
|
+
if (isDateColumn(col, va) || isDateColumn(col, vb)) {
|
|
201
|
+
va = timeOf(va);
|
|
202
|
+
vb = timeOf(vb);
|
|
203
|
+
}
|
|
136
204
|
if (va == null && vb == null)
|
|
137
205
|
return 0;
|
|
138
206
|
if (va == null)
|
|
@@ -169,14 +237,7 @@ export function DataTable({ listId, rows, columns, rowKey, onRowClick, selection
|
|
|
169
237
|
next.add(key);
|
|
170
238
|
selection.onChange(next);
|
|
171
239
|
};
|
|
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]);
|
|
240
|
+
const gridTemplate = useMemo(() => gridTemplateFor(visibleColumns, columnWidths, !!selection), [selection, visibleColumns, prefs.columnWidths]);
|
|
180
241
|
const setSort = (columnId, direction) => {
|
|
181
242
|
setPrefs({ ...prefs, sort: { columnId, direction } });
|
|
182
243
|
};
|
|
@@ -200,6 +261,23 @@ export function DataTable({ listId, rows, columns, rowKey, onRowClick, selection
|
|
|
200
261
|
[order[idx], order[newIdx]] = [order[newIdx], order[idx]];
|
|
201
262
|
setPrefs({ ...prefs, columnOrder: order });
|
|
202
263
|
};
|
|
264
|
+
const dropColumn = (sourceId, targetId, side) => {
|
|
265
|
+
const order = reorderColumns(prefs.columnOrder, sourceId, targetId, side);
|
|
266
|
+
if (order.join(GROUP_KEY_SEP) !== prefs.columnOrder.join(GROUP_KEY_SEP)) {
|
|
267
|
+
setPrefs({ ...prefs, columnOrder: order });
|
|
268
|
+
}
|
|
269
|
+
};
|
|
270
|
+
const previewColumnWidth = (columnId, px) => {
|
|
271
|
+
containerRef.current?.style.setProperty('--dt-cols', gridTemplateFor(visibleColumns, { ...columnWidths, [columnId]: px }, !!selection));
|
|
272
|
+
};
|
|
273
|
+
const setColumnWidth = (columnId, px) => {
|
|
274
|
+
const next = { ...columnWidths };
|
|
275
|
+
if (px == null)
|
|
276
|
+
delete next[columnId];
|
|
277
|
+
else
|
|
278
|
+
next[columnId] = clampColumnWidth(px);
|
|
279
|
+
setPrefs({ ...prefs, columnWidths: next });
|
|
280
|
+
};
|
|
203
281
|
const addGroupBy = (columnId) => {
|
|
204
282
|
if (groupBy.includes(columnId))
|
|
205
283
|
return;
|
|
@@ -239,11 +317,18 @@ export function DataTable({ listId, rows, columns, rowKey, onRowClick, selection
|
|
|
239
317
|
setPrefs({ ...prefs, filters });
|
|
240
318
|
};
|
|
241
319
|
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:
|
|
320
|
+
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
321
|
const activeSort = prefs.sort?.columnId === col.id ? prefs.sort.direction : null;
|
|
244
322
|
const filterValue = prefs.filters[col.id];
|
|
245
323
|
const isFiltered = filterValue != null;
|
|
246
|
-
return (
|
|
324
|
+
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) => {
|
|
325
|
+
if (dropTarget?.id !== col.id || dropTarget.side !== side)
|
|
326
|
+
setDropTarget({ id: col.id, side });
|
|
327
|
+
}, onDrop: (sourceId, side) => {
|
|
328
|
+
dropColumn(sourceId, col.id, side);
|
|
329
|
+
setDragColumnId(null);
|
|
330
|
+
setDropTarget(null);
|
|
331
|
+
}, 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
332
|
}), 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
333
|
if (item.kind === 'group') {
|
|
249
334
|
const level = item.level ?? 0;
|
|
@@ -260,15 +345,135 @@ export function DataTable({ listId, rows, columns, rowKey, onRowClick, selection
|
|
|
260
345
|
const isChecked = selection?.selected.has(key) ?? false;
|
|
261
346
|
const rowIndent = groupBy.length > 0 ? 12 + groupBy.length * 18 : undefined;
|
|
262
347
|
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) :
|
|
348
|
+
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
349
|
}
|
|
265
350
|
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:
|
|
351
|
+
gridTemplateColumns: 'var(--dt-cols)',
|
|
267
352
|
background: isChecked ? 'rgba(99,102,241,0.07)' : undefined,
|
|
268
353
|
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) :
|
|
354
|
+
}, 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
355
|
})), footer && (_jsx("div", { className: "px-3 py-2 text-xs text-[var(--color-text-muted)] border-t border-[var(--color-border)]", children: footer }))] }));
|
|
271
356
|
}
|
|
357
|
+
const DRAG_MIME = 'application/x-efa-datatable-column';
|
|
358
|
+
function HeaderCell({ columnId, label, dragColumnId, dropSide, hasCustomWidth, onDragStart, onDragEnd, onDragOverSide, onDrop, onResizePreview, onResizeCommit, onResizeCancel, children, }) {
|
|
359
|
+
const cellRef = useRef(null);
|
|
360
|
+
const [hovered, setHovered] = useState(false);
|
|
361
|
+
const [resizing, setResizing] = useState(false);
|
|
362
|
+
const [handleFocused, setHandleFocused] = useState(false);
|
|
363
|
+
const resizeStart = useRef(null);
|
|
364
|
+
const dragStartTimer = useRef(null);
|
|
365
|
+
const isDragSource = dragColumnId === columnId;
|
|
366
|
+
const sideFor = (clientX) => {
|
|
367
|
+
const r = cellRef.current?.getBoundingClientRect();
|
|
368
|
+
return r && clientX < r.left + r.width / 2 ? 'before' : 'after';
|
|
369
|
+
};
|
|
370
|
+
const acceptsDrag = (e) => dragColumnId != null && e.dataTransfer.types.includes(DRAG_MIME);
|
|
371
|
+
const currentWidth = () => cellRef.current?.getBoundingClientRect().width ?? MIN_COLUMN_WIDTH;
|
|
372
|
+
const handle = {
|
|
373
|
+
onPointerDown: (e) => {
|
|
374
|
+
if (e.button !== 0)
|
|
375
|
+
return;
|
|
376
|
+
e.preventDefault();
|
|
377
|
+
e.stopPropagation();
|
|
378
|
+
e.currentTarget.setPointerCapture(e.pointerId);
|
|
379
|
+
const width = currentWidth();
|
|
380
|
+
resizeStart.current = { x: e.clientX, width, last: width };
|
|
381
|
+
setResizing(true);
|
|
382
|
+
},
|
|
383
|
+
onPointerMove: (e) => {
|
|
384
|
+
const s = resizeStart.current;
|
|
385
|
+
if (!s)
|
|
386
|
+
return;
|
|
387
|
+
s.last = clampColumnWidth(s.width + e.clientX - s.x);
|
|
388
|
+
onResizePreview(s.last);
|
|
389
|
+
},
|
|
390
|
+
onPointerUp: (e) => {
|
|
391
|
+
const s = resizeStart.current;
|
|
392
|
+
if (!s)
|
|
393
|
+
return;
|
|
394
|
+
resizeStart.current = null;
|
|
395
|
+
setResizing(false);
|
|
396
|
+
if (e.currentTarget.hasPointerCapture(e.pointerId))
|
|
397
|
+
e.currentTarget.releasePointerCapture(e.pointerId);
|
|
398
|
+
if (Math.abs(e.clientX - s.x) < 2) {
|
|
399
|
+
onResizeCancel();
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
onResizeCommit(s.last);
|
|
403
|
+
},
|
|
404
|
+
onPointerCancel: () => {
|
|
405
|
+
resizeStart.current = null;
|
|
406
|
+
setResizing(false);
|
|
407
|
+
onResizeCancel();
|
|
408
|
+
},
|
|
409
|
+
onDoubleClick: (e) => {
|
|
410
|
+
e.stopPropagation();
|
|
411
|
+
if (hasCustomWidth)
|
|
412
|
+
onResizeCommit(null);
|
|
413
|
+
},
|
|
414
|
+
onKeyDown: (e) => {
|
|
415
|
+
if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight')
|
|
416
|
+
return;
|
|
417
|
+
e.preventDefault();
|
|
418
|
+
const delta = e.key === 'ArrowLeft' ? -KEYBOARD_RESIZE_STEP : KEYBOARD_RESIZE_STEP;
|
|
419
|
+
onResizeCommit(clampColumnWidth(currentWidth() + delta));
|
|
420
|
+
},
|
|
421
|
+
};
|
|
422
|
+
const showChrome = hovered || resizing || handleFocused;
|
|
423
|
+
const indicator = {
|
|
424
|
+
position: 'absolute', top: -8, bottom: -8, width: 2,
|
|
425
|
+
background: 'var(--color-primary)', pointerEvents: 'none',
|
|
426
|
+
[dropSide === 'before' ? 'left' : 'right']: -1,
|
|
427
|
+
};
|
|
428
|
+
return (_jsxs("div", { ref: cellRef, "data-column-id": columnId, style: {
|
|
429
|
+
position: 'relative', display: 'flex', alignItems: 'center', minWidth: 0,
|
|
430
|
+
opacity: isDragSource ? 0.4 : undefined,
|
|
431
|
+
}, onMouseEnter: () => setHovered(true), onMouseLeave: () => setHovered(false), onDragOver: (e) => {
|
|
432
|
+
if (!acceptsDrag(e))
|
|
433
|
+
return;
|
|
434
|
+
e.preventDefault();
|
|
435
|
+
e.dataTransfer.dropEffect = 'move';
|
|
436
|
+
if (!isDragSource)
|
|
437
|
+
onDragOverSide(sideFor(e.clientX));
|
|
438
|
+
}, onDrop: (e) => {
|
|
439
|
+
if (!acceptsDrag(e))
|
|
440
|
+
return;
|
|
441
|
+
e.preventDefault();
|
|
442
|
+
const sourceId = e.dataTransfer.getData(DRAG_MIME);
|
|
443
|
+
if (sourceId)
|
|
444
|
+
onDrop(sourceId, sideFor(e.clientX));
|
|
445
|
+
}, children: [children, _jsx("span", { draggable: true, "aria-hidden": true, title: "Spalte verschieben", onDragStart: (e) => {
|
|
446
|
+
e.dataTransfer.setData(DRAG_MIME, columnId);
|
|
447
|
+
e.dataTransfer.effectAllowed = 'move';
|
|
448
|
+
if (cellRef.current)
|
|
449
|
+
e.dataTransfer.setDragImage(cellRef.current, 8, 8);
|
|
450
|
+
dragStartTimer.current = window.setTimeout(() => {
|
|
451
|
+
dragStartTimer.current = null;
|
|
452
|
+
onDragStart();
|
|
453
|
+
}, 0);
|
|
454
|
+
}, onDragEnd: () => {
|
|
455
|
+
if (dragStartTimer.current != null)
|
|
456
|
+
window.clearTimeout(dragStartTimer.current);
|
|
457
|
+
dragStartTimer.current = null;
|
|
458
|
+
onDragEnd();
|
|
459
|
+
}, style: {
|
|
460
|
+
position: 'absolute', right: 6, top: '50%', transform: 'translateY(-50%)',
|
|
461
|
+
display: (hovered && !dragColumnId && !resizing) || isDragSource ? 'inline-flex' : 'none',
|
|
462
|
+
padding: 2, cursor: 'grab', zIndex: 1,
|
|
463
|
+
color: 'var(--color-text-muted)',
|
|
464
|
+
background: 'var(--color-surface-raised)',
|
|
465
|
+
borderRadius: 'var(--border-radius-md)',
|
|
466
|
+
}, 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: {
|
|
467
|
+
position: 'absolute', top: -8, bottom: -8, right: -4, width: 8,
|
|
468
|
+
cursor: 'col-resize', touchAction: 'none', zIndex: 2, outline: 'none',
|
|
469
|
+
display: 'flex', justifyContent: 'center',
|
|
470
|
+
}, children: _jsx("span", { style: {
|
|
471
|
+
width: 2, height: '100%',
|
|
472
|
+
background: resizing || handleFocused ? 'var(--color-primary)' : 'var(--color-border)',
|
|
473
|
+
opacity: showChrome ? 1 : 0,
|
|
474
|
+
transition: 'opacity 120ms',
|
|
475
|
+
} }) }), dropSide && _jsx("span", { style: indicator })] }));
|
|
476
|
+
}
|
|
272
477
|
function FilterEditor({ column, value, onChange, }) {
|
|
273
478
|
if (!column.filter)
|
|
274
479
|
return null;
|
|
@@ -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.20.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",
|