# Filament Table UX Standard

> Canonical rules for all Filament table/list surfaces in TenantPilot.
> Every new or modified table must follow this standard.
> Deviations must be documented in the spec or PR with rationale.

**Last reviewed**: 2026-03-09

---

## Governing Principle

**Filament-native first.**
Table UX standardization uses native Filament features wherever reasonably possible.
Custom helpers, macros, traits, or plugins are only allowed when Filament cannot express the pattern cleanly enough inline.

---

## Column Tier Model

Every production table must classify its columns into one of three tiers.
This is a review convention, not a code abstraction.

### Primary

Always visible. Not toggle-hidden. Anchors the row identity.

- name, display_name, title
- primary status (if truly central)
- core subject identifier

### Context

Visible by default. May be toggleable. Provides operational context needed for a normal scan.

- environment, type, category, severity
- key counts / aggregations
- last sync / last activity
- tenant/workspace context where needed

### Detail

Hidden by default (`toggleable(isToggledHiddenByDefault: true)`). Technical or low-frequency information.

- IDs (tenant_id, external_id, entra_id)
- created_at, updated_at
- created_by
- domains, low-frequency secondary statuses

### Visible Column Limit

General-purpose tables should expose **7 or fewer** columns by default.
Denser power-user surfaces may exceed this with documented justification.

---

## Sortability Rules

### Mandatory sortable (when present)

- Primary identifier fields (name, display_name, title)
- Status / outcome / enum columns
- All key timestamps used for recency
- Numeric count and size columns
- Version / sequence numbers

### Default sort

Every production list must define an explicit `defaultSort()`.

| List type | Default pattern |
|---|---|
| Time-series / logs / runs / alerts | newest first |
| Named entities | name ascending |
| Versioned entities | latest/highest version first |
| Audit / evidence | newest recorded first |

---

## Searchability Rules

- Every non-trivial production table must make its primary identifier `searchable()`.
- Additional fields only if query-safe and operationally useful.
- Avoid searchable on relations without proper indexing.
- Avoid searchable on computed values without clear SQL handling.

---

## Toggleability Rules

### Must be hidden by default

- Technical IDs
- External IDs
- Tenant/workspace internal references (unless primary context)
- `created_at`, `updated_at`
- Secondary technical metadata

### Overflow rule

If a table exceeds the calm default surface, lower-value columns must move into Detail via `toggleable(isToggledHiddenByDefault: true)` before any cosmetic workaround is considered.

---

## Timestamp Standard

### Lists

```php
TextColumn::make('created_at')
    ->since()
    ->sortable()
    ->toggleable(isToggledHiddenByDefault: true)
    ->tooltip(fn ($record) => $record->created_at?->toDateTimeString());
```

- Primary visual: `->since()` for scan-first recency
- Absolute date/time available via tooltip

### Detail / view pages

- Use absolute `->dateTime()` or equivalent explicit formatting.

### Exceptions

- Version or evidence tables may use absolute time when exact chronology is the primary task.

---

## Null / Empty Values

```php
->placeholder('—')
```

Consistent across all tables. Do not mix blank cells, `n/a`, `unknown`, or dash variants.

---

## ID Presentation

- Hidden by default
- `copyable()` when operationally relevant
- `limit()` and/or `tooltip()` for long identifiers
- Monospaced styling preferred for technical IDs
- Must not dominate layout width

---

## Status / Badge / Boolean

- Status and enum columns use native `badge()` via centralized `BadgeCatalog` / `BadgeRenderer` (per BADGE-001).
- Booleans use native icon or badge-based rendering consistently.

---

## Empty State Standard

Every production table must define a domain-specific empty state:

```php
->emptyStateHeading('No policies found')
->emptyStateDescription('Sync your first tenant to see policies here.')
->emptyStateActions([
    // exactly 1 primary CTA, RBAC-gated
])
```

---

## Pagination Profiles

Implementation: `App\Support\Filament\TablePaginationProfiles`

| Surface | Page sizes | Default |
|---|---|---|
| Resource | `25, 50, 100` | `25` |
| Relation manager | `10, 25, 50` | `10` |
| Widget | `10` | `10` |
| Picker | `25, 50, 100` | `25` |
| Custom page | `25, 50, all` | `25` unless override documented |

---

## Table State Persistence

Resource list tables in the critical set must use:

```php
->persistFiltersInSession()
->persistSearchInSession()
->persistSortInSession()
```

### Critical persistence set

- TenantResource
- PolicyResource
- BackupSetResource
- BackupScheduleResource
- ProviderConnectionResource
- FindingResource
- OperationRunResource

### Documented exceptions

- Dashboard widgets: no persistence (glance surfaces)
- Directory pages: no persistence (computed health metrics)
- Picker tables: no persistence (workflow-local)

---

## Responsive / Overflow Guardrails

- Long text: use `wrap()` where appropriate
- Long IDs / technical strings: use `limit()` and `tooltip()`
- Solve width problems via better default visibility, not manual width hacks
- No `extraAttributes()` width tuning as a default pattern
- No resize plugin as substitute for better defaults

---

## Performance Guardrails

- No casual `sortable()` / `searchable()` on relation or computed columns without query review
- No row-by-row counts or `exists()` checks in hot lists unless optimized
- Prefer eager loading where relation-backed columns are rendered repeatedly
- No costly default sorts without understanding DB impact

---

## RBAC / Tenancy Safety

- Technical cross-tenant identifiers must not be more prominent than needed
- Empty-state actions and row/header actions remain capability-gated
- Cross-tenant/workspace tables must preserve enough context to avoid ambiguity
- This standard must not change authorization behavior

---

## What Remains Table-Local

These areas must stay explicit per table unless a later spec proves a shared need:

- Filters (governed separately by [filament-filter-ux.md](filament-filter-ux.md))
- Row actions, bulk actions, header actions (governed by [filament-actions-ux.md](filament-actions-ux.md))
- Query scoping
- Empty-state copy
- Domain-specific badge labels/colors/mappings
