@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 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
+ }
@@ -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 ${VARIANT[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" }) }))] }));
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';
@@ -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 lbl = groupLabelOf(col.accessor(r));
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
- return v != null && String(v).toLowerCase().includes(q);
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
- const va = col.accessor(a);
135
- const vb = col.accessor(b);
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: gridTemplate, background: 'var(--color-surface-raised)' }, children: [selection && (_jsx("input", { type: "checkbox", checked: allSelected, onChange: toggleAll, "aria-label": "Alle ausw\u00E4hlen" })), visibleColumns.map((col) => {
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 (_jsxs(DropdownMenu.Root, { children: [_jsx(DropdownMenu.Trigger, { asChild: true, children: _jsxs("button", { type: "button", className: "flex items-center gap-1 px-1 py-0.5 hover:bg-[var(--color-surface)] text-left", style: { borderRadius: 'var(--border-radius-md)' }, children: [_jsx("span", { 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 text-[var(--color-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) })), _jsxs(DropdownMenu.Item, { onSelect: () => setColumnVisible(col.id, false), variant: "danger", children: [_jsx(EyeOff, { className: "w-4 h-4" }), "Spalte ausblenden"] })] })] }, col.id));
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) : String(col.accessor(row) ?? '') })] }, col.id)))] }, `r:${String(key)}:${idx}`));
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: gridTemplate,
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) : String(col.accessor(row) ?? '') }, col.id))), _jsx("div", {})] }, `r:${String(key)}:${idx}`));
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-[var(--color-danger)] data-[highlighted]:bg-[var(--color-surface-raised)]'
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
  }
@@ -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.17.0",
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",