diff --git a/.agents/skills/starterkit-grid-standards/references/row-interaction-standard.md b/.agents/skills/starterkit-grid-standards/references/row-interaction-standard.md
index 42285de..059bdfe 100644
--- a/.agents/skills/starterkit-grid-standards/references/row-interaction-standard.md
+++ b/.agents/skills/starterkit-grid-standards/references/row-interaction-standard.md
@@ -6,26 +6,40 @@ Row-Navigation discoverable und keyboard-freundlich machen.
## Standardverhalten
-1. Sichtbare Action-Spalte rechts (`Open`/`Edit`) fuer Listen mit `rowDblClick`.
+1. Konfigurierbares Link-Column: Die primaere Datenspalte (z.B. Name, Beschreibung) wird als klickbarer ``-Link gerendert.
2. Enter auf fokussierter Row loest dieselbe Navigation aus.
3. Doppelklick bleibt als Fallback fuer Power-User.
-4. Nur Rows mit Ziel-URL erhalten `data-row-open-url`.
+4. Nur Rows mit Ziel-URL erhalten `data-row-open-url` und den Link im Link-Column.
+5. Rows ohne URL (z.B. fehlende Berechtigung) zeigen den Inhalt als Plain-Text ohne Link.
## Contract
```js
rowInteraction: {
enabled: true,
- showActionButton: true,
- keepDblClick: true,
- enableEnterOnRow: true,
- actionColumnLabel: 'Actions',
- resolveActionLabel: (url, rowData) => (url.includes('/edit/') ? 'Edit' : 'Open')
+ linkColumn: 0, // Index der sichtbaren Datenspalte, die als Link gerendert wird (default: 0)
+ keepDblClick: true, // Doppelklick-Navigation beibehalten (default: true)
+ enableEnterOnRow: true, // Enter-Key-Navigation auf fokussierter Row (default: true)
+ rowEnterHint: 'Press Enter to open row'
}
```
+### linkColumn-Zuordnung
+
+| Seite | linkColumn | Zielspalte |
+|---|---|---|
+| Users | 2 | First name (nach Avatar + hidden UUID) |
+| Tenants | 1 | Description (nach Avatar) |
+| Roles, Departments, Permissions | 0 | Description |
+| Address book | 1 | Name (nach hidden UUID) |
+| Mail log | 3 | Subject |
+| Scheduled jobs | 0 | Job label |
+| System audit, Import audit, User lifecycle audit | 2 | Event/Profile/Action |
+| API audit | 3 | Path |
+
## Guardrails
1. Keine globale Tab-Stop-Flut auf allen Rows.
-2. Fokusstil fuer Row und Action-Button sichtbar.
-3. Explizite `actions.enabled: true` pro Seite darf Auto-Action uebersteuern.
+2. Fokusstil fuer Row und Link-Element sichtbar.
+3. Explizite `actions.enabled: true` pro Seite darf Link-Column uebersteuern (eigene Action-Spalte).
+4. Link-Column darf keine Spalte mit verschachtelten ``-Elementen umwickeln (z.B. Avatar mit Lightbox).
diff --git a/modules/addressbook/web/js/pages/address-book-index.js b/modules/addressbook/web/js/pages/address-book-index.js
index 076a61a..e2ed6ee 100644
--- a/modules/addressbook/web/js/pages/address-book-index.js
+++ b/modules/addressbook/web/js/pages/address-book-index.js
@@ -3,7 +3,7 @@ import { initMultiSelectCascade } from '../../../../js/components/app-multiselec
import { initStandardListPage } from '../../../../js/core/app-grid-factory.js';
import { readPageConfig } from '../../../../js/core/app-page-config.js';
import { warnOnce } from '../../../../js/core/app-dom.js';
-import { getAppBase, escapeHtml, buildBadgeList, gridFilterMultiCsv, gridFilterText } from '../../../../js/pages/app-list-utils.js';
+import { getAppBase, escapeHtml, buildBadgeList, gridFilterMultiCsv, gridFilterText, withCurrentListReturn } from '../../../../js/pages/app-list-utils.js';
const config = readPageConfig('address-book-index');
if (config) {
@@ -50,7 +50,7 @@ if (config) {
{
name: labels.name || 'Name',
sort: true,
- formatter: (cell) => {
+ formatter: (cell, row) => {
const nameValue = typeof cell === 'object' && cell !== null
? (cell.name ?? '')
: cell;
@@ -58,13 +58,18 @@ if (config) {
? (cell.avatar_uuid ?? '')
: '';
const name = escapeHtml(nameValue || '-');
+ const uuid = row?.cells?.[uuidIndex]?.data;
+ const editUrl = uuid ? withCurrentListReturn(new URL(`address-book/view/${uuid}`, appBase).toString()) : '';
+ const nameHtml = editUrl
+ ? `${name}`
+ : `${name}`;
if (!avatarUuid) {
const initials = escapeHtml(initialsForName(nameValue));
- return gridjs.html(`${initials}${name}`);
+ return gridjs.html(`${initials}${nameHtml}`);
}
const src = new URL(`admin/users/avatar-file?uuid=${avatarUuid}&size=64`, appBase).toString();
const full = new URL(`admin/users/avatar-file?uuid=${avatarUuid}&size=256`, appBase).toString();
- return gridjs.html(`${name}`);
+ return gridjs.html(`
${nameHtml}`);
},
},
{ name: labels.email || 'Email', sort: true },
@@ -99,6 +104,7 @@ if (config) {
rowDataset: (row) => ({
uuid: row?.cells?.[uuidIndex]?.data,
}),
+ rowInteraction: { linkColumn: false },
rowDblClick: {
getUrl: (rowData) => {
const uuid = rowData?.cells?.[uuidIndex]?.data;
diff --git a/tests/Architecture/TypographyTokenContractTest.php b/tests/Architecture/TypographyTokenContractTest.php
index ab1d9f7..ab74edf 100644
--- a/tests/Architecture/TypographyTokenContractTest.php
+++ b/tests/Architecture/TypographyTokenContractTest.php
@@ -175,7 +175,7 @@ class TypographyTokenContractTest extends TestCase
'--text-label' => '--text-sm',
'--text-caption' => '--text-xs',
'--text-title' => '--text-xl',
- '--text-page-title' => '--text-2xl',
+ '--text-page-title' => '--text-3xl',
];
foreach ($aliases as $alias => $primitive) {
diff --git a/web/css/vendor-overrides/gridjs.css b/web/css/vendor-overrides/gridjs.css
index e1abb86..d85ccf5 100644
--- a/web/css/vendor-overrides/gridjs.css
+++ b/web/css/vendor-overrides/gridjs.css
@@ -233,16 +233,20 @@
gap: 0.35rem;
}
- .gridjs-container .grid-actions-row-open {
- justify-content: flex-end;
- width: 100%;
+ .app-grid-link-cell {
+ color: inherit;
+ text-decoration: none;
}
- .gridjs-container .grid-actions .app-grid-row-open {
- padding: 4px 10px;
- font-size: 12px;
- line-height: 1.2;
- white-space: nowrap;
+ .app-grid-link-cell:hover {
+ text-decoration: underline;
+ color: var(--app-primary);
+ }
+
+ .app-grid-link-cell:focus-visible {
+ outline: 2px solid var(--app-primary-focus);
+ outline-offset: 2px;
+ border-radius: 2px;
}
tr.gridjs-tr[data-row-open-url] {
@@ -276,10 +280,6 @@
outline: none;
}
- .gridjs-container table td .grid-actions .app-grid-row-open:focus-visible {
- box-shadow: 0 0 0 var(--app-outline-width) var(--app-primary-focus);
- }
-
html[data-theme="dark"] button.gridjs-sort,
html[data-theme="dark"] button.gridjs-sort-neutral {
filter: invert(1);
diff --git a/web/js/core/app-grid-factory.js b/web/js/core/app-grid-factory.js
index 37960c4..bf4908d 100644
--- a/web/js/core/app-grid-factory.js
+++ b/web/js/core/app-grid-factory.js
@@ -255,28 +255,15 @@ export function createServerGrid(options) {
const rowInteractionEnabled = hasRowOpenHandler && rowInteractionConfig.enabled !== false;
const keepDblClick = rowInteractionEnabled && rowInteractionConfig.keepDblClick !== false;
const enableEnterOnRow = rowInteractionEnabled && rowInteractionConfig.enableEnterOnRow !== false;
- const showRowActionButton = rowInteractionEnabled && rowInteractionConfig.showActionButton !== false;
- const rowActionColumnLabel = String(
- rowInteractionConfig.actionColumnLabel
- || language?.actions?.column
- || 'Actions'
- ).trim() || 'Actions';
- const rowActionOpenLabel = String(language?.actions?.open || 'Open').trim() || 'Open';
- const rowActionEditLabel = String(language?.actions?.edit || 'Edit').trim() || 'Edit';
+ const linkColumnIndex = rowInteractionEnabled && rowInteractionConfig.linkColumn !== false
+ ? (Number.isInteger(rowInteractionConfig.linkColumn) ? rowInteractionConfig.linkColumn : 0)
+ : -1;
const rowEnterHint = String(
rowInteractionConfig.rowEnterHint
|| language?.actions?.rowEnterHint
|| 'Press Enter to open row'
).trim() || 'Press Enter to open row';
const gridRetryLabel = String(language?.errorRetry || 'Retry').trim() || 'Retry';
- const defaultResolveRowActionLabel = (url) => (
- /(^|\/)edit(\/|$|\?)/i.test(String(url || '').trim())
- ? rowActionEditLabel
- : rowActionOpenLabel
- );
- const resolveActionLabel = typeof rowInteractionConfig.resolveActionLabel === 'function'
- ? rowInteractionConfig.resolveActionLabel
- : defaultResolveRowActionLabel;
const resolveRowOpenUrl = (rowData, rowIndex = -1) => {
if (!rowInteractionEnabled) {
return '';
@@ -467,31 +454,29 @@ export function createServerGrid(options) {
wrapperClass: actions.wrapperClass || 'grid-actions',
onAction: typeof actions.onAction === 'function' ? actions.onAction : null
} : null;
- const rowOpenActionConfig = !explicitActionConfig && showRowActionButton ? {
- id: 'row_actions',
- label: gridjsLib.html(
- `${escapeHtmlAttr(rowActionColumnLabel)}`
- ),
- index: null,
- items: [{
- key: 'row-open',
- type: 'link',
- className: 'outline secondary app-grid-row-open',
- label: (rowData, url) => {
- const dynamicLabel = resolveActionLabel(url, rowData);
- const normalizedLabel = String(dynamicLabel || '').trim();
- return normalizedLabel || defaultResolveRowActionLabel(url);
- },
- ariaLabel: (_rowData, _url, label) => `${String(label || '').trim()}. ${rowEnterHint}`,
- title: (_rowData, _url) => rowEnterHint,
- href: ({ id }) => id
- }],
- // For row-open actions the URL itself is the stable row identifier.
- getRowId: (rowData) => resolveRowOpenUrl(rowData),
- wrapperClass: 'grid-actions grid-actions-row-open',
- onAction: null
- } : null;
- const actionConfig = explicitActionConfig || rowOpenActionConfig;
+ const actionConfig = explicitActionConfig || null;
+
+ // Link-column: wrap the target column's formatter output in an for row navigation.
+ if (linkColumnIndex >= 0 && linkColumnIndex < normalizedColumns.length) {
+ const targetCol = normalizedColumns[linkColumnIndex];
+ const originalFormatter = targetCol.formatter || null;
+ targetCol.formatter = (cell, row) => {
+ const rowOpenUrl = resolveRowOpenUrl(row);
+ const inner = originalFormatter ? originalFormatter(cell, row) : cell;
+ if (!rowOpenUrl) {
+ return inner;
+ }
+ const innerHtml = (inner && typeof inner === 'object' && typeof inner.props?.content === 'string')
+ ? inner.props.content
+ : escapeHtmlAttr(String(inner ?? ''));
+ // Avoid nesting inside — if the formatter already has links, skip wrapping.
+ if (/]/i.test(innerHtml)) {
+ return inner;
+ }
+ return gridjsLib.html(`${innerHtml}`);
+ };
+ }
+
const selectionConfig = selection && selection.enabled ? {
id: selection.id || 'selectRow',
index: Number.isInteger(selection.index) ? selection.index : 0,
@@ -543,12 +528,13 @@ export function createServerGrid(options) {
const titleAttr = titleRaw ? ` title="${escapeHtmlAttr(titleRaw)}"` : '';
const className = item.className ? ` ${item.className}` : '';
const type = item.type || 'button';
+ const safeLabel = escapeHtmlAttr(String(label));
if (type === 'link') {
const href = typeof item.href === 'function' ? item.href({ row, id }) : item.href;
- const url = href || '#';
- return `${label}`;
+ const url = escapeHtmlAttr(href || '#');
+ return `${safeLabel}`;
}
- return ``;
+ return ``;
}).join('');
return gridjsLib.html(`