@efa-one/sdk 1.18.0 → 1.21.0

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