@navette-js/tables 0.0.0-stage → 1.0.0-beta.2

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/lib/index.js ADDED
@@ -0,0 +1,1308 @@
1
+ import { Attributes, cls, e, hasRenderHook, icon, js, renderHook, views, configured, configureUsing } from '@navette-js/support';
2
+ import { t } from '@navette-js/support/i18n';
3
+ import { createHash, randomBytes } from 'node:crypto';
4
+ import { fileURLToPath } from 'node:url';
5
+ views.namespace('filament-tables', fileURLToPath(new URL('../views', import.meta.url)));
6
+ export * from "./query-builder.js";
7
+ /** Render hook slots of a table. */
8
+ export const TablesRenderHook = {
9
+ CONTENT_AFTER: 'tables::content.after',
10
+ CONTENT_BEFORE: 'tables::content.before',
11
+ SELECTION_INDICATOR_ACTIONS_AFTER: 'tables::selection.indicator.actions.after',
12
+ SELECTION_INDICATOR_ACTIONS_BEFORE: 'tables::selection.indicator.actions.before',
13
+ HEADER_AFTER: 'tables::header.after',
14
+ HEADER_BEFORE: 'tables::header.before',
15
+ TOOLBAR_AFTER: 'tables::toolbar.after',
16
+ TOOLBAR_BEFORE: 'tables::toolbar.before',
17
+ TOOLBAR_COLUMN_MANAGER_TRIGGER_AFTER: 'tables::toolbar.toggle-column-trigger.after',
18
+ TOOLBAR_COLUMN_MANAGER_TRIGGER_BEFORE: 'tables::toolbar.toggle-column-trigger.before',
19
+ TOOLBAR_END: 'tables::toolbar.end',
20
+ TOOLBAR_GROUPING_SELECTOR_AFTER: 'tables::toolbar.grouping-selector.after',
21
+ TOOLBAR_GROUPING_SELECTOR_BEFORE: 'tables::toolbar.grouping-selector.before',
22
+ TOOLBAR_REORDER_TRIGGER_AFTER: 'tables::toolbar.reorder-trigger.after',
23
+ TOOLBAR_REORDER_TRIGGER_BEFORE: 'tables::toolbar.reorder-trigger.before',
24
+ TOOLBAR_SEARCH_AFTER: 'tables::toolbar.search.after',
25
+ TOOLBAR_SEARCH_BEFORE: 'tables::toolbar.search.before',
26
+ TOOLBAR_START: 'tables::toolbar.start',
27
+ FILTER_INDICATORS: 'tables::filter.indicators',
28
+ HEADER_CELL: 'tables::header.cell',
29
+ /** Legacy aliases of the column manager trigger slots. */
30
+ TOOLBAR_TOGGLE_COLUMN_TRIGGER_AFTER: 'tables::toolbar.toggle-column-trigger.after',
31
+ TOOLBAR_TOGGLE_COLUMN_TRIGGER_BEFORE: 'tables::toolbar.toggle-column-trigger.before',
32
+ };
33
+ /** Methods and properties that trigger the table's loading state (`wire:target`). */
34
+ export const LOADING_TARGETS = 'activeTab,applyTableFilters,gotoPage,nextPage,previousPage,removeTableFilter,removeTableFilters,reorderTable,resetTableColumnSearch,resetTableFiltersForm,resetTableSearch,setPage,sortTable,tableColumnSearches,tableFilters,tableGrouping,tableRecordsPerPage,tableSearch,tableSort,toggleTableReordering';
35
+ const headline = (s) => s.replace(/[_.-]+/g, ' ').replace(/^./, (c) => c.toUpperCase());
36
+ // ------------------------------------------------------------------ columns
37
+ /** Base class of table columns. */
38
+ export class Column {
39
+ /** Default setup for every instance created afterwards by `make()`, e.g. `TextColumn.configureUsing((column) => column.sortable())`. */
40
+ static configureUsing(configure) { configureUsing(this, configure); }
41
+ name;
42
+ _label = null;
43
+ _sortable = false;
44
+ _searchable = false;
45
+ _individuallySearchable = false;
46
+ _toggleable = false;
47
+ _toggledHiddenByDefault = false;
48
+ _alignment = null;
49
+ _state = null;
50
+ _sortColumn = null;
51
+ _searchColumn = null;
52
+ _url = null;
53
+ /** Label of the column group this column belongs to (set by ColumnGroup). */
54
+ _group = null;
55
+ constructor(name) { this.name = name; }
56
+ label(label) { this._label = label; return this; }
57
+ /** `column`: column (or query alias) to sort by when it differs from the name, e.g. for a relationship. */
58
+ sortable(condition = true) { this._sortable = condition !== false; this._sortColumn = typeof condition === 'object' ? condition.column : null; this._sortInverted = typeof condition === 'object' && condition.inverted === true; return this; }
59
+ getSortColumn() { return this._sortColumn ?? this.name; }
60
+ _sortInverted = false;
61
+ /** `inverted`: the sort column runs opposite to the displayed value (e.g. an age sorted by birth date). */
62
+ isSortInverted() { return this._sortInverted; }
63
+ /** Link on the cell; takes precedence over the row action. */
64
+ url(resolver) { this._url = resolver; return this; }
65
+ getUrl(record) { return this._url ? this._url(record) : null; }
66
+ /** `isGlobal: false` excludes the column from the global search; `isIndividual` adds a search field for this column. */
67
+ getSearchColumn() { return this._searchColumn ?? this.name; }
68
+ searchable(options = true) {
69
+ this._searchColumn = typeof options === 'object' ? (options.column ?? null) : null;
70
+ this._searchable = typeof options === 'boolean' ? options : options.isGlobal !== false;
71
+ this._individuallySearchable = typeof options === 'object' && options.isIndividual === true;
72
+ return this;
73
+ }
74
+ /** Lets the user hide the column from the column manager. */
75
+ toggleable(condition = true, options = {}) { this._toggleable = condition; this._toggledHiddenByDefault = options.isToggledHiddenByDefault === true; return this; }
76
+ alignment(alignment) { this._alignment = alignment; return this; }
77
+ alignEnd() { return this.alignment('end'); }
78
+ alignLeft() { return this.alignment('left'); }
79
+ alignCenter() { return this.alignment('center'); }
80
+ getStateUsing(resolver) { this._state = resolver; return this; }
81
+ getLabel() { return this._label ?? headline(this.name.includes('.') ? this.name.split('.').slice(-2, -1)[0] : this.name); }
82
+ isSortable() { return this._sortable; }
83
+ isGloballySearchable() { return this._searchable; }
84
+ isIndividuallySearchable() { return this._individuallySearchable; }
85
+ isToggleable() { return this._toggleable; }
86
+ isToggledHiddenByDefault() { return this._toggledHiddenByDefault; }
87
+ getAlignment() { return this._alignment; }
88
+ /** Suffix of the `fi-ta-header-cell-…` / `fi-ta-cell-…` classes: underscores become dashes, dots are kept. */
89
+ getCssName() { return this.name.replace(/_/g, '-'); }
90
+ async getState(record) { return this._state ? this._state({ record }) : record[this.name]; }
91
+ /** True for editable cells: they are not wrapped in a link or button to the record. */
92
+ isClickDisabled() { return false; }
93
+ }
94
+ export class TextColumn extends Column {
95
+ _weight = null;
96
+ _copyable = false;
97
+ _money = null;
98
+ _placeholder = null;
99
+ _badge = false;
100
+ _color = null;
101
+ _icon = null;
102
+ _format = null;
103
+ _date = false;
104
+ _numeric = false;
105
+ _summarizers = [];
106
+ _limit = null;
107
+ _tooltip = null;
108
+ static make(name) { return configured(new TextColumn(name)); }
109
+ /** Displays the value in a badge. */
110
+ badge(condition = true) { this._badge = condition; return this; }
111
+ color(color) { this._color = color; return this; }
112
+ icon(resolver) { this._icon = resolver; return this; }
113
+ /** Text displayed instead of the raw value (e.g. an enum label). */
114
+ formatStateUsing(formatter) { this._format = formatter; return this; }
115
+ /** Short date, e.g. "Nov 9, 2026". */
116
+ date(condition = true) { this._date = condition; return this; }
117
+ _dateTime = false;
118
+ /** Date and time, e.g. "Nov 9, 2026 14:30:00". */
119
+ dateTime(condition = true) { this._dateTime = condition; this._date = condition; return this; }
120
+ /** Number formatted with thousands separators. */
121
+ _decimals = null;
122
+ /** `numeric(1)`: fixed number of decimals. */
123
+ numeric(condition = true) { this._numeric = condition !== false; this._decimals = typeof condition === 'number' ? condition : null; return this; }
124
+ /** Summary row below the table. */
125
+ summarize(summarizer) { this._summarizers = Array.isArray(summarizer) ? summarizer : [summarizer]; return this; }
126
+ getSummarizers() { return this._summarizers; }
127
+ weight(weight) { this._weight = weight; return this; }
128
+ /** Truncates the displayed text to `length` characters, followed by "...". */
129
+ limit(length) { this._limit = length; return this; }
130
+ /** Tooltip on hover; `null` for none. */
131
+ tooltip(tooltip) { this._tooltip = tooltip; return this; }
132
+ copyable(condition = true) { this._copyable = condition; return this; }
133
+ /** Amount formatted in the given currency, e.g. `money('usd')`. */
134
+ money(currency = 'usd') { this._money = currency; return this; }
135
+ /** Text displayed when the value is empty. */
136
+ placeholder(text) { this._placeholder = text; return this; }
137
+ /** Cell content: a short markup for plain text, a full markup as soon as an option (badge, icon, color…) is set. */
138
+ async toHtml(record) {
139
+ const state = await this.getState(record);
140
+ if ((state === null || state === undefined || state === '') && this._placeholder !== null)
141
+ return `<div class="fi-ta-text"><p class="fi-ta-placeholder">${e(this._placeholder)}</p></div>`;
142
+ // Empty value without placeholder: render a bare container.
143
+ if (state === null || state === undefined || state === '')
144
+ return `<div class="${cls(['fi-ta-text', this._money || this._numeric ? 'fi-numeric' : null, (this._money || this._numeric) && this._alignment && this._alignment !== 'start' ? `fi-align-${this._alignment}` : null])}"></div>`;
145
+ const currency = typeof this._money === 'function' ? this._money(record) : this._money;
146
+ if (this._badge || this._format || this._date) {
147
+ let shown = state === null || state === undefined ? '' : String(state);
148
+ if (this._date && shown)
149
+ shown = new Intl.DateTimeFormat('en-US', { month: 'short', day: 'numeric', year: 'numeric', timeZone: 'UTC' }).format(new Date(`${shown.replace(' ', 'T').slice(0, 19)}Z`)) + (this._dateTime ? ` ${shown.slice(11, 19)}` : '');
150
+ if (this._format)
151
+ shown = this._format({ state, record });
152
+ if (this._badge) {
153
+ const color = typeof this._color === 'function' ? this._color(record) : (this._color ?? 'primary');
154
+ const iconName = this._icon?.(record) ?? null;
155
+ const classes = ['fi-badge', 'fi-size-sm', ...(color !== 'gray' ? (colorManager?.getComponentClasses('badge', color) ?? []) : [])].join(' ');
156
+ // Without an icon the cell uses the short markup, which writes the classes in the other order.
157
+ return `<div class="${iconName ? 'fi-ta-text-item fi-ta-text' : 'fi-ta-text fi-ta-text-item'} fi-ta-text-has-badges"><span class="${classes}">${iconName ? icon(iconName, { size: 'sm', attributes: new Attributes({ 'aria-hidden': 'true' }) }) : ''}${e(shown)}</span></div>`;
158
+ }
159
+ if (!this._weight && !this._copyable)
160
+ return `<div class="${cls(['fi-ta-text', 'fi-ta-text-item', 'fi-size-sm', this._alignment && this._alignment !== 'start' ? `fi-align-${this._alignment}` : null])}">${e(shown)}</div>`;
161
+ }
162
+ const text = state === null || state === undefined ? '' : currency ? new Intl.NumberFormat('en-US', { style: 'currency', currency: currency.toUpperCase() }).format(Number(state)) : this._numeric && Number.isFinite(Number(state)) ? new Intl.NumberFormat('en-US', this._decimals !== null ? { minimumFractionDigits: this._decimals, maximumFractionDigits: this._decimals } : {}).format(Number(state)) : String(state);
163
+ const align = this._alignment && this._alignment !== 'start' ? `fi-align-${this._alignment}` : null;
164
+ const tooltip = typeof this._tooltip === 'function' ? this._tooltip(record) : this._tooltip;
165
+ if (this._limit !== null || (tooltip !== null && tooltip !== '')) {
166
+ const limited = this._limit !== null && text.length > this._limit ? `${text.slice(0, this._limit).trimEnd()}...` : text;
167
+ const content = tooltip !== null && tooltip !== '' ? `<span x-tooltip="{
168
+ content: ${e(js(tooltip))},
169
+ theme: $store.theme,
170
+ allowHTML: false,
171
+ }">${e(limited)}</span>` : e(limited);
172
+ return `<div class="${cls(['fi-ta-text-item', 'fi-size-sm', this._weight ? `fi-font-${this._weight}` : null, 'fi-ta-text', this._money || this._numeric ? 'fi-numeric' : null, align])}">${content}</div>`;
173
+ }
174
+ // Outside a badge: an icon precedes the text (full markup) and a color tints the text.
175
+ const plainIcon = this._icon?.(record) ?? null;
176
+ const plainColor = typeof this._color === 'function' ? this._color(record) : this._color;
177
+ const colorClasses = plainColor ? (colorManager?.getComponentClasses('text', plainColor) ?? []) : [];
178
+ if (plainIcon)
179
+ return `<div class="${cls(['fi-ta-text-item', 'fi-size-sm', this._weight ? `fi-font-${this._weight}` : null, 'fi-ta-text', ...colorClasses, align])}">${icon(plainIcon, { size: 'sm', attributes: new Attributes({ 'aria-hidden': 'true' }) })}${e(text)}</div>`;
180
+ if (!this._weight && !this._copyable)
181
+ return `<div class="${cls(['fi-ta-text', 'fi-ta-text-item', 'fi-size-sm', this._money || this._numeric ? 'fi-numeric' : null, ...colorClasses, align])}">${e(text)}</div>`;
182
+ const inner = this._copyable
183
+ ? `<span x-data="filamentCopyable" x-bind="bindings" x-bind:aria-label="isStandalone ? ${e(js(t('filament::components/copyable.label', { label: text })))} : null" x-on:click.prevent.stop="if (isStandalone) {
184
+ copy(${e(js(text))}, ${e(js(t('filament::components/copyable.messages.copied')))}, 2000, ${e(js(t('filament::components/copyable.messages.failed')))})
185
+ } else {
186
+ window.navigator.clipboard.writeText(${e(js(text))})
187
+ $tooltip(${e(js(t('filament::components/copyable.messages.copied')))}, {
188
+ theme: $store.theme,
189
+ timeout: 2000,
190
+ })
191
+ }" class="fi-copyable">${e(text)}</span>`
192
+ : e(text);
193
+ return `<div class="${cls(['fi-ta-text-item', 'fi-size-sm', this._weight ? `fi-font-${this._weight}` : null, 'fi-ta-text', this._money || this._numeric ? 'fi-numeric' : null, align])}">${inner}</div>`;
194
+ }
195
+ }
196
+ /** Column summary below the table: sum, average, count or range. */
197
+ export class Summarizer {
198
+ _label = null;
199
+ _money = null;
200
+ label(label) { this._label = label; return this; }
201
+ money(currency = 'usd') { this._money = currency.toUpperCase(); return this; }
202
+ getLabel() { return this._label; }
203
+ format(value) { return this._money ? new Intl.NumberFormat('en-US', { style: 'currency', currency: this._money }).format(value) : new Intl.NumberFormat('en-US').format(value); }
204
+ }
205
+ /** Column total. */
206
+ export class Sum extends Summarizer {
207
+ kind = 'sum';
208
+ static make() { return configured(new Sum()); }
209
+ getLabel() { return this._label ?? t('filament-tables::table.summary.summarizers.sum.label'); }
210
+ summarize(column, values) { return { type: 'text', label: this.getLabel(), value: this.format(values.sum[column] ?? 0) }; }
211
+ }
212
+ /** Column average. */
213
+ export class Average extends Summarizer {
214
+ kind = 'avg';
215
+ static make() { return configured(new Average()); }
216
+ getLabel() { return this._label ?? t('filament-tables::table.summary.summarizers.average.label'); }
217
+ summarize(column, values) { return { type: 'text', label: this.getLabel(), value: this.format(values.avg[column] ?? 0) }; }
218
+ }
219
+ /** Record count. */
220
+ export class Count extends Summarizer {
221
+ kind = 'count';
222
+ static make() { return configured(new Count()); }
223
+ getLabel() { return this._label ?? t('filament-tables::table.summary.summarizers.count.label'); }
224
+ summarize(_column, values) { return { type: 'text', label: this.getLabel(), value: this.format(values.count) }; }
225
+ }
226
+ /** Smallest and largest value of a column. */
227
+ export class Range extends Summarizer {
228
+ kind = 'range';
229
+ _minimalDateTime = false;
230
+ static make() { return configured(new Range()); }
231
+ /** Dates: day only when the bounds fall on different days, day and time otherwise; a single value when both are equal. */
232
+ minimalDateTimeDifference(condition = true) { this._minimalDateTime = condition; return this; }
233
+ summarize(column, values) {
234
+ let bounds = [values.min[column], values.max[column]].filter((v) => v !== null && v !== undefined && v !== '').map(String);
235
+ if (!bounds.length)
236
+ return null;
237
+ if (this._minimalDateTime && bounds.length === 2) {
238
+ const date = (value) => new Intl.DateTimeFormat('en-US', { month: 'short', day: 'numeric', year: 'numeric', timeZone: 'UTC' }).format(new Date(`${value.slice(0, 10)}T00:00:00Z`));
239
+ const dateTime = (value) => `${date(value)} ${value.length > 10 ? value.slice(11, 19) : '00:00:00'}`;
240
+ const days = bounds.map(date);
241
+ if (days[0] !== days[1])
242
+ bounds = days;
243
+ else {
244
+ const times = bounds.map(dateTime);
245
+ bounds = times[0] === times[1] ? [times[0]] : times;
246
+ }
247
+ }
248
+ return { type: 'range', label: this._label, value: bounds.join(' - ') };
249
+ }
250
+ }
251
+ /** Row grouping: rows are sorted by the column and each group gets a header row. */
252
+ export class Group {
253
+ id;
254
+ _label = null;
255
+ _column = null;
256
+ _title = null;
257
+ _description = null;
258
+ _collapsible = false;
259
+ _date = false;
260
+ _titlePrefixedWithLabel = true;
261
+ constructor(id) { this.id = id; }
262
+ static make(id) { return configured(new Group(id)); }
263
+ label(label) { this._label = label; return this; }
264
+ /** Query column holding the group value, when it differs from the id (relationship). */
265
+ column(column) { this._column = column; return this; }
266
+ /** Group title computed from the record (e.g. an enum label). */
267
+ getTitleUsing(resolver) { this._title = resolver; return this; }
268
+ getDescriptionUsing(resolver) { this._description = resolver; return this; }
269
+ /** Aliases using the `get…FromRecordUsing` naming. */
270
+ getTitleFromRecordUsing(resolver) { return this.getTitleUsing(resolver); }
271
+ getDescriptionFromRecordUsing(resolver) { return this.getDescriptionUsing(resolver); }
272
+ /** Groups collapse when their header is clicked. */
273
+ collapsible(condition = true) { this._collapsible = condition; return this; }
274
+ /** The column is a date: rows from the same day form one group. */
275
+ date(condition = true) { this._date = condition; return this; }
276
+ titlePrefixedWithLabel(condition = true) { this._titlePrefixedWithLabel = condition; return this; }
277
+ getId() { return this.id; }
278
+ getLabel() {
279
+ if (this._label !== null)
280
+ return this._label;
281
+ const base = (this.id.includes('.') ? this.id.slice(0, this.id.lastIndexOf('.')) : this.id).split('.').pop() ?? '';
282
+ return base.replace(/([a-z0-9])([A-Z])/g, '$1 $2').replace(/[-_]+/g, ' ').toLowerCase().replace(/^./, (c) => c.toUpperCase());
283
+ }
284
+ getColumn() { return this._column ?? this.id; }
285
+ isCollapsible() { return this._collapsible; }
286
+ isDate() { return this._date; }
287
+ isTitlePrefixedWithLabel() { return this._titlePrefixedWithLabel; }
288
+ /** Group value of a record, as text; `null` when empty. */
289
+ getStringKey(record) {
290
+ let key = record?.[this.getColumn()];
291
+ if (key === null || key === undefined || key === '')
292
+ return null;
293
+ if (typeof key === 'boolean')
294
+ key = key ? 1 : 0;
295
+ return this._date ? String(key).slice(0, 10) : String(key);
296
+ }
297
+ getTitle(record) {
298
+ if (this._title)
299
+ return this._title(record);
300
+ const key = this.getStringKey(record);
301
+ if (key !== null && this._date)
302
+ return new Intl.DateTimeFormat('en-US', { month: 'short', day: 'numeric', year: 'numeric', timeZone: 'UTC' }).format(new Date(`${key}T00:00:00Z`));
303
+ return key;
304
+ }
305
+ getDescription(record) { return this._description ? this._description(record) : null; }
306
+ /** Restricts a query to the records of the group with the given key. */
307
+ scope(query, key) {
308
+ if (key === null)
309
+ query.where((sub) => sub.whereNull(this.getColumn()).orWhere(this.getColumn(), ''));
310
+ else if (this._date)
311
+ query.whereRaw('date(??) = ?', [this.getColumn(), key]);
312
+ else
313
+ query.where(this.getColumn(), key);
314
+ }
315
+ }
316
+ /**
317
+ * Column layout: as soon as a table has one, records are rendered as cards (`fi-ta-content`) instead of table rows,
318
+ * and sorting goes through a "Sort by" select.
319
+ */
320
+ export class ColumnLayout {
321
+ _children;
322
+ constructor(children) { this._children = children; }
323
+ getChildren() { return this._children; }
324
+ /** Leaf columns, in order: the ones the table sorts, searches and toggles. */
325
+ getColumns() { return this._children.flatMap((child) => (child instanceof ColumnLayout ? child.getColumns() : [child])); }
326
+ }
327
+ /** Columns side by side, from a given breakpoint. */
328
+ export class Split extends ColumnLayout {
329
+ _from = null;
330
+ static make(children) { return new Split(children); }
331
+ from(breakpoint) { this._from = breakpoint; return this; }
332
+ getClasses() { return ['fi-ta-split', ...(this._from ? [`${this._from}:fi-ta-split`] : [])]; }
333
+ }
334
+ /** Columns stacked vertically. */
335
+ export class Stack extends ColumnLayout {
336
+ _space = null;
337
+ _alignment = 'start';
338
+ static make(children) { return new Stack(children); }
339
+ /** Gap between columns: 1 (small), 2 (medium) or 3 (large). */
340
+ space(space = 1) { this._space = space; return this; }
341
+ alignment(alignment) { this._alignment = alignment; return this; }
342
+ getClasses() { return ['fi-ta-stack', `fi-align-${this._alignment}`, ...(this._space ? [`fi-gap-${['sm', 'md', 'lg'][this._space - 1]}`] : [])]; }
343
+ }
344
+ const layoutCell = (classes, html) => `<div class="${['fi-grid-col', 'fi-growable', ...classes].join(' ')}" style="--col-span-default: span 1 / span 1;">${html}</div>`;
345
+ /** Renders a record through the layout, skipping columns the user has hidden. */
346
+ async function renderColumnLayout(node, record, key, visible) {
347
+ if (node instanceof ColumnLayout) {
348
+ const children = [];
349
+ for (const child of node.getChildren())
350
+ children.push(await renderColumnLayout(child, record, key, visible));
351
+ return layoutCell([], `<div class="${node.getClasses().join(' ')}">${children.join('')}</div>`);
352
+ }
353
+ if (!visible.has(node))
354
+ return '';
355
+ const alignment = node.getAlignment();
356
+ // Inside a layout, text is inline and carries its own alignment.
357
+ const html = (await node.toHtml(record, key)).replace(/class="([^"]*\bfi-ta-text\b[^"]*)"/, (_match, classes) => `class="${classes.replace(/ fi-align-\w+/g, '')} fi-inline${alignment ? ` fi-align-${alignment}` : ''}"`);
358
+ return layoutCell(['fi-ta-col', ...(alignment ? [`fi-align-${alignment}`] : [])], html);
359
+ }
360
+ /** Column group: a shared label above the column headers. */
361
+ export class ColumnGroup {
362
+ label;
363
+ _columns = [];
364
+ constructor(label) { this.label = label; }
365
+ static make(label) { return configured(new ColumnGroup(label)); }
366
+ columns(columns) { this._columns = columns; for (const c of columns)
367
+ c._group = this.label; return this; }
368
+ getColumns() { return this._columns; }
369
+ }
370
+ /** Color swatch column. */
371
+ export class ColorColumn extends Column {
372
+ static make(name) { return configured(new ColorColumn(name)); }
373
+ async toHtml(record) {
374
+ const state = await this.getState(record);
375
+ // Only hex or simple functional colors are injected into the style attribute (no CSS injection).
376
+ const color = typeof state === 'string' && /^(#[0-9a-fA-F]{3,8}|(rgb|hsl|oklch)a?\([0-9.,%\s/deg-]+\))$/.test(state.trim()) ? state.trim() : null;
377
+ return `<div class="fi-ta-color">${color ? `<div aria-label="${e(color)}" role="img" class="fi-ta-color-item" style="background-color: ${color};"></div>` : ''}</div>`;
378
+ }
379
+ }
380
+ /** Image column: the state is the image URL. Only local or http(s) URLs are rendered. */
381
+ export class ImageColumn extends Column {
382
+ _alt = null;
383
+ _height = '2.5rem';
384
+ _circular = false;
385
+ static make(name) { return configured(new ImageColumn(name)); }
386
+ alt(resolver) { this._alt = resolver; return this; }
387
+ /** CSS height of the image (`2.5rem` by default). */
388
+ imageHeight(height) { if (/^[0-9.]+(rem|px|em)$/.test(height))
389
+ this._height = height; return this; }
390
+ circular(condition = true) { this._circular = condition; return this; }
391
+ async toHtml(record) {
392
+ const state = await this.getState(record);
393
+ const src = typeof state === 'string' && /^(\/(?!\/)|https?:\/\/)/.test(state) ? state : null;
394
+ if (!src)
395
+ return '<div class="fi-ta-image"></div>';
396
+ const alt = this._alt?.(record) ?? null;
397
+ return `<div class="fi-ta-image"><img${alt !== null ? ` alt="${e(alt)}"` : ''} src="${e(src)}" style="height: ${this._height};"${this._circular ? ' class="fi-circular"' : ''} /></div>`;
398
+ }
399
+ }
400
+ /** Icon depending on the value; `boolean()` shows a green check or a red cross. */
401
+ export class IconColumn extends Column {
402
+ _boolean = false;
403
+ static make(name) { return configured(new IconColumn(name)); }
404
+ boolean(condition = true) { this._boolean = condition; return this; }
405
+ async toHtml(record) {
406
+ const state = await this.getState(record);
407
+ if (!this._boolean || state === null || state === undefined)
408
+ return '<div class="fi-ta-icon"></div>';
409
+ const on = state === true || state === 1 || state === '1';
410
+ const classes = colorManager?.getComponentClasses('icon-column', on ? 'success' : 'danger') ?? [];
411
+ return `<div class="fi-ta-icon">${icon(on ? 'o-check-circle' : 'o-x-circle', { size: 'lg', attributes: new Attributes().class(classes) })}<span class="fi-sr-only">${e(t(on ? 'filament-tables::table.columns.icon.boolean.true' : 'filament-tables::table.columns.icon.boolean.false'))}</span></div>`;
412
+ }
413
+ }
414
+ const isBlank = (value) => value === null || value === undefined || (typeof value === 'string' && value.trim() === '') || (Array.isArray(value) && value.length === 0);
415
+ const isNumeric = (value) => (typeof value === 'number' && Number.isFinite(value)) || (typeof value === 'string' && value.trim() !== '' && Number.isFinite(Number(value)));
416
+ /** String rules accepted by `rules([...])`, with their messages. An unknown rule throws at declaration time instead of being ignored. */
417
+ const STRING_RULES = {
418
+ boolean: () => ({ name: 'boolean', passes: (v) => [true, false, 0, 1, '0', '1'].includes(v), message: (a) => `The ${a} field must be true or false.` }),
419
+ string: () => ({ name: 'string', passes: (v) => typeof v === 'string', message: (a) => `The ${a} field must be a string.` }),
420
+ numeric: () => ({ name: 'numeric', passes: isNumeric, message: (a) => `The ${a} field must be a number.` }),
421
+ integer: () => ({ name: 'integer', passes: (v) => isNumeric(v) && Number.isInteger(Number(v)) && !/[.eE]/.test(String(v)), message: (a) => `The ${a} field must be an integer.` }),
422
+ email: () => ({ name: 'email', passes: (v) => typeof v === 'string' && /^[^\s@]+@[^\s@]+$/.test(v), message: (a) => `The ${a} field must be a valid email address.` }),
423
+ url: () => ({ name: 'url', passes: (v) => typeof v === 'string' && /^https?:\/\/[^\s/$.?#][^\s]*$/i.test(v), message: (a) => `The ${a} field must be a valid URL.` }),
424
+ min: (n, numeric) => ({ name: 'min', passes: (v) => (numeric && isNumeric(v) ? Number(v) : [...String(v)].length) >= Number(n), message: (a) => numeric ? `The ${a} field must be at least ${n}.` : `The ${a} field must be at least ${n} characters.` }),
425
+ max: (n, numeric) => ({ name: 'max', passes: (v) => (numeric && isNumeric(v) ? Number(v) : [...String(v)].length) <= Number(n), message: (a) => numeric ? `The ${a} field must not be greater than ${n}.` : `The ${a} field must not be greater than ${n} characters.` }),
426
+ in: (list) => ({ name: 'in', passes: (v) => list.split(',').includes(String(v)), message: (a) => `The selected ${a} is invalid.` }),
427
+ };
428
+ /**
429
+ * Base class of in-cell editable columns. The browser calls `updateTableColumnState(column, key, value)` on the host component:
430
+ * the column must be declared, visible and enabled for this record, and the value must pass the rules; only then do
431
+ * `beforeStateUpdated`, the write and `afterStateUpdated` run. The write does not check model permissions: use `disabled()`
432
+ * to restrict editing.
433
+ */
434
+ export class EditableColumn extends Column {
435
+ _disabled = false;
436
+ _rules = [];
437
+ _validationAttribute = null;
438
+ _validationMessages = {};
439
+ _updateStateUsing = null;
440
+ _beforeStateUpdated = null;
441
+ _afterStateUpdated = null;
442
+ _tooltip = null;
443
+ disabled(condition = true) { this._disabled = condition; return this; }
444
+ async isDisabledFor(record) { return typeof this._disabled === 'function' ? this._disabled(record) : this._disabled; }
445
+ /** An editable cell is neither a link nor a button to the record. */
446
+ isClickDisabled() { return true; }
447
+ isEditable() { return true; }
448
+ /** Rules applied to the received value: strings (`required`, `max:255`, `in:a,b`…) or `{ name, passes, message }` objects. */
449
+ rules(rules) {
450
+ for (const rule of rules)
451
+ if (typeof rule === 'string' && rule !== 'required' && rule !== 'nullable' && !STRING_RULES[rule.split(':')[0]])
452
+ throw new Error(`Validation rule not supported by columns: ${rule}`);
453
+ this._rules = [...rules];
454
+ return this;
455
+ }
456
+ getRules() { return this._rules; }
457
+ /** Same as adding `required` to the rules. */
458
+ required(condition = true) { this._rules = [...this._rules.filter((rule) => rule !== 'required'), ...(condition ? ['required'] : [])]; return this; }
459
+ isRequired() { return this._rules.includes('required'); }
460
+ /** Column name used in messages (defaults to the label with a lowercase first letter). */
461
+ validationAttribute(label) { this._validationAttribute = label; return this; }
462
+ /** Custom messages by rule name, e.g. `{ max: 'Too long.' }`. */
463
+ validationMessages(messages) { this._validationMessages = { ...messages }; return this; }
464
+ getValidationAttribute() { return this._validationAttribute ?? this.getLabel().replace(/^./, (c) => c.toLowerCase()); }
465
+ /** Rules specific to the column type (e.g. select options). */
466
+ getImplicitRules() { return []; }
467
+ /**
468
+ * Error message, or `null` if the value is valid. An empty value is only checked by `required`, and several errors
469
+ * produce the first message followed by "(and N more errors)".
470
+ */
471
+ validate(input) {
472
+ const attribute = this.getValidationAttribute();
473
+ const blank = isBlank(input);
474
+ const numeric = this._rules.some((rule) => rule === 'numeric' || rule === 'integer');
475
+ const errors = [];
476
+ const fail = (name, message) => errors.push(this._validationMessages[name] ?? message);
477
+ if (blank) {
478
+ if (this.isRequired())
479
+ fail('required', `The ${attribute} field is required.`);
480
+ }
481
+ else {
482
+ // Column-type rules first: a malformed value (list, forged object) stops here.
483
+ const implicit = this.getImplicitRules().find((rule) => !rule.passes(input));
484
+ if (implicit)
485
+ return this._validationMessages[implicit.name] ?? implicit.message(attribute);
486
+ // String rules only apply to scalars: a list or an object fails them.
487
+ const isScalar = ['string', 'number', 'boolean'].includes(typeof input);
488
+ for (const rule of this._rules) {
489
+ if (rule === 'required' || rule === 'nullable')
490
+ continue;
491
+ const resolved = typeof rule === 'string' ? STRING_RULES[rule.split(':')[0]](rule.slice(rule.indexOf(':') + 1), numeric) : rule;
492
+ if (!((typeof rule !== 'string' || isScalar) && resolved.passes(input)))
493
+ fail(resolved.name, resolved.message(attribute));
494
+ }
495
+ }
496
+ if (!errors.length)
497
+ return null;
498
+ return errors.length === 1 ? errors[0] : `${errors[0]} (and ${errors.length - 1} more error${errors.length > 2 ? 's' : ''})`;
499
+ }
500
+ /** Custom write, replacing the update of the record's column. Its return value is sent back to the browser. */
501
+ updateStateUsing(callback) { this._updateStateUsing = callback; return this; }
502
+ beforeStateUpdated(callback) { this._beforeStateUpdated = callback; return this; }
503
+ afterStateUpdated(callback) { this._afterStateUpdated = callback; return this; }
504
+ /** Tooltip on the control, shown while no error is displayed. */
505
+ tooltip(tooltip) { this._tooltip = tooltip; return this; }
506
+ getUpdateStateMessages() {
507
+ return { saving: t('filament-tables::table.columns.editable.saving'), saved: t('filament-tables::table.columns.editable.saved'), failure: t('filament-tables::table.columns.editable.failure') };
508
+ }
509
+ /** Value written from a validated value: blank values become `null`. */
510
+ castState(input) { return isBlank(input) ? null : input; }
511
+ /**
512
+ * Saves an already validated value. Only the column's declared name is written: a relationship column (`author.name`)
513
+ * can only be edited with `updateStateUsing()`.
514
+ */
515
+ async updateState(record, input, adapter) {
516
+ const state = this.castState(input);
517
+ await this._beforeStateUpdated?.({ state, record });
518
+ if (this._updateStateUsing) {
519
+ try {
520
+ return await this._updateStateUsing({ state, record });
521
+ }
522
+ finally {
523
+ await this._afterStateUpdated?.({ state, record });
524
+ }
525
+ }
526
+ if (this.name.includes('.'))
527
+ return { error: t('filament-tables::table.columns.editable.unavailable') };
528
+ await adapter.update(adapter.getKey(record), { [this.name]: state });
529
+ await this._afterStateUpdated?.({ state, record });
530
+ return state;
531
+ }
532
+ /** `x-tooltip` expression of editable controls: the column tooltip, replaced by the validation error when there is one. */
533
+ tooltipExpression(record) {
534
+ const tooltip = typeof this._tooltip === 'function' ? this._tooltip(record) : this._tooltip;
535
+ return `error === undefined ? ${tooltip !== null && tooltip !== '' ? `{
536
+ content: ${js(tooltip)},
537
+ theme: $store.theme,
538
+ allowHTML: false,
539
+ aria: { content: 'auto' },
540
+ }` : 'false'} : {
541
+ content: error,
542
+ theme: $store.theme,
543
+ allowHTML: false,
544
+ aria: { content: null },
545
+ }`;
546
+ }
547
+ /** Shared opening markup of editable cells: lazily loaded Alpine component, server state and record key. */
548
+ editableAttributes(component, asset, recordKey, state) {
549
+ return new Attributes().merge({
550
+ 'x-load': 'x-load',
551
+ 'x-load-src': views.render('filament-tables::asset', { path: `columns/${asset}` }).trim(),
552
+ 'x-data': `${component}({
553
+ messages: ${js(this.getUpdateStateMessages())},
554
+ name: ${js(this.name)},
555
+ recordKey: ${js(String(recordKey))},
556
+ state: ${js(state)},
557
+ })`,
558
+ 'x-id': "['table-column-error']",
559
+ 'x-on:click.capture': 'preventEdit($event)',
560
+ 'x-on:keydown.capture': 'preventEdit($event)',
561
+ });
562
+ }
563
+ static STATUS_HTML = `<p x-cloak x-show="error" x-bind:id="$id('table-column-error')" x-ref="error" x-text="error" class="fi-sr-only"></p><p role="status" aria-live="polite" aria-atomic="true" x-text="status" class="fi-sr-only"></p>`;
564
+ }
565
+ /**
566
+ * In-cell editable select (native select only). The received value must be one of the declared options.
567
+ */
568
+ export class SelectColumn extends EditableColumn {
569
+ _options = [];
570
+ _selectablePlaceholder = true;
571
+ static make(name) { return configured(new SelectColumn(name)); }
572
+ options(options) { this._options = (Array.isArray(options) ? options : Object.entries(options)).map(([value, label]) => [String(value), String(label)]); return this; }
573
+ getOptions() { return this._options; }
574
+ selectablePlaceholder(condition = true) { this._selectablePlaceholder = condition; return this; }
575
+ getImplicitRules() {
576
+ return [{ name: 'in', passes: (v) => (typeof v === 'string' || typeof v === 'number') && this._options.some(([value]) => value === String(v)), message: (a) => `The selected ${a} is invalid.` }];
577
+ }
578
+ async toHtml(record, recordKey = '') {
579
+ const state = await this.getState(record);
580
+ const value = state === null || state === undefined ? '' : String(state);
581
+ const optionLabel = this._options.find(([option]) => option === value)?.[1] ?? null;
582
+ const isDisabled = await this.isDisabledFor(record);
583
+ const placeholder = t('filament-forms::components.select.placeholder');
584
+ const messages = this.getUpdateStateMessages();
585
+ const config = `selectTableColumn({
586
+ accessibilityAttributes: [],
587
+ ariaLabel: ${js(this.getLabel())},
588
+ canOptionLabelsWrap: true,
589
+ canSelectPlaceholder: ${js(this._selectablePlaceholder)},
590
+ clearButtonLabel: ${js(t('filament-forms::components.select.actions.clear.label'))},
591
+ errorMessage: ${js(t('filament-forms::components.select.error_message'))},
592
+ hasDynamicOptions: false,
593
+ hasDynamicSearchResults: false,
594
+ hasInitialNoOptionsMessage: true,
595
+ initialOptionLabel: ${js(optionLabel)},
596
+ isDisabled: ${js(isDisabled)},
597
+ isHtmlAllowed: false,
598
+ isNative: true,
599
+ isSearchable: false,
600
+ loadingMessage: ${js(t('filament-forms::components.select.loading_message'))},
601
+ messages: ${js(messages)},
602
+ name: ${js(this.name)},
603
+ noOptionsMessage: ${js(t('filament-forms::components.select.no_options_message'))},
604
+ noSearchResultsMessage: ${js(t('filament-forms::components.select.no_search_results_message'))},
605
+ options: [],
606
+ optionsLimit: 50,
607
+ placeholder: ${js(placeholder)},
608
+ position: null,
609
+ recordKey: ${js(String(recordKey))},
610
+ searchableOptionFields: ${js(['label'])},
611
+ searchDebounce: 1000,
612
+ searchingMessage: ${js(t('filament-forms::components.select.searching_message'))},
613
+ searchLabel: ${js(t('filament-forms::components.select.search_label'))},
614
+ searchPrompt: ${js(t('filament-forms::components.select.search_prompt'))},
615
+ state: ${js(state === null || state === undefined ? null : value)},
616
+ })`;
617
+ const select = new Attributes().merge({
618
+ 'aria-label': e(this.getLabel()), disabled: true, 'wire:loading.attr': 'disabled', 'wire:target': LOADING_TARGETS,
619
+ 'x-bind:aria-invalid': "error ? 'true' : null", 'x-bind:aria-busy': "isLoading ? 'true' : null",
620
+ 'x-bind:aria-describedby': "getAccessibilityAttributes([])[ 'aria-describedby' ]", 'x-bind:disabled': isDisabled ? null : 'isLoading', 'x-on:change': 'clearError()',
621
+ 'x-tooltip': `error === undefined ? false : {
622
+ content: error,
623
+ theme: $store.theme,
624
+ allowHTML: false,
625
+ aria: { content: null },
626
+
627
+ }`,
628
+ }).class(['fi-select-input']);
629
+ const options = `${this._selectablePlaceholder ? `<option value="">${e(placeholder)}</option>` : ''}${this._options.map(([option, label]) => `<option value="${e(option)}">${e(label)}</option>`).join('')}`;
630
+ return `<div wire:ignore.self x-load="x-load" x-load-src="${e(views.render('filament-tables::asset', { path: 'columns/select' }).trim())}" x-data="${e(config)}" x-id="['table-column-error']" x-on:click.capture="preventEdit($event)" x-on:keydown.capture="preventEdit($event)" class="fi-ta-select"><input type="hidden" value="${e(value)}" data-option-label="${e(optionLabel ?? '')}" data-disabled="${isDisabled ? 'true' : 'false'}" x-ref="serverState" /><div x-bind:class="{
631
+ 'fi-disabled': isLoading || ${js(isDisabled)},
632
+ 'fi-invalid': error !== undefined,
633
+ }" x-on:click.prevent.stop class="fi-input-wrp"><select x-model="state" ${select.toHtml()}>${options}</select></div><p x-cloak x-show="error" x-bind:id="$id('table-column-error')" x-ref="error" x-text="error" class="fi-sr-only"></p><p role="status" aria-live="polite" aria-atomic="true" x-text="status" class="fi-sr-only"></p></div>`;
634
+ }
635
+ }
636
+ /** Boolean value received from the browser (already checked by the `boolean` rule): `true`, `false` or `null`. */
637
+ const toBoolean = (value) => (isBlank(value) ? null : value === true || value === 1 || value === '1');
638
+ const ALIGNMENTS = ['start', 'center', 'end', 'left', 'right', 'justify', 'between'];
639
+ const alignmentClass = (alignment) => (alignment && ALIGNMENTS.includes(alignment) ? `fi-align-${alignment}` : null);
640
+ /** In-cell editable toggle. The received value must be a boolean (`boolean` rule by default). */
641
+ export class ToggleColumn extends EditableColumn {
642
+ _onColor = null;
643
+ _offColor = null;
644
+ _onIcon = null;
645
+ _offIcon = null;
646
+ constructor(name) { super(name); this._rules = ['boolean']; }
647
+ static make(name) { return configured(new ToggleColumn(name)); }
648
+ /** Toggle color when on (`primary` by default) and off (`gray`). */
649
+ onColor(color) { this._onColor = color; return this; }
650
+ offColor(color) { this._offColor = color; return this; }
651
+ /** Icons shown inside the toggle's thumb. */
652
+ onIcon(icon) { this._onIcon = icon; return this; }
653
+ offIcon(icon) { this._offIcon = icon; return this; }
654
+ castState(input) { return toBoolean(input); }
655
+ async toHtml(record, recordKey = '') {
656
+ const state = Boolean(await this.getState(record));
657
+ const isDisabled = await this.isDisabledFor(record);
658
+ // Gray is the toggle's default color and adds no class.
659
+ const colorClasses = (color) => (color === 'gray' ? [] : (colorManager?.getComponentClasses('toggle', color) ?? []));
660
+ const onColorClasses = colorClasses(this._onColor ?? 'primary');
661
+ const onClasses = ['fi-toggle-on', ...onColorClasses].join(' ');
662
+ const offClasses = ['fi-toggle-off', ...colorClasses(this._offColor ?? 'gray')].join(' ');
663
+ const toggleIcon = (name) => (name ? icon(name, { size: 'xs' }) : '');
664
+ const outer = this.editableAttributes('toggleTableColumn', 'toggle', recordKey, state).class(['fi-ta-toggle', alignmentClass(this._alignment)]);
665
+ const button = new Attributes().merge({
666
+ 'aria-disabled': 'true', 'aria-label': e(this.getLabel()), disabled: isDisabled, 'wire:loading.attr': 'disabled', 'wire:target': LOADING_TARGETS,
667
+ 'x-bind:aria-invalid': "error ? 'true' : null", 'x-bind:aria-busy': "isLoading ? 'true' : null", 'x-bind:aria-describedby': "getAccessibilityAttributes()['aria-describedby']",
668
+ 'x-tooltip': this.tooltipExpression(record),
669
+ }).class(['fi-toggle']);
670
+ // The live toggle is hidden (x-cloak) until Alpine starts; a static copy is shown meanwhile.
671
+ return `<div wire:ignore.self ${outer.toHtml()}><input type="hidden" value="${state ? 1 : 0}" x-ref="serverState" /><div aria-checked="${state ? 'true' : 'false'}" x-bind:aria-checked="state ? 'true' : 'false'" x-bind:tabindex="$el.hasAttribute('disabled') ? '-1' : '0'" x-bind:aria-disabled="isLoading || $el.hasAttribute('disabled') ? 'true' : null" x-on:click.prevent.stop="if (! $el.hasAttribute('disabled')) { clearError(); state = ! state }" x-on:keydown.enter.prevent.stop="if (! $el.hasAttribute('disabled')) { clearError(); state = ! state }" x-on:keydown.space.prevent.stop="if (! $el.hasAttribute('disabled')) { clearError(); state = ! state }" x-bind:class="state ? ${js(onClasses)} : ${js(offClasses)}"${state ? ' x-cloak' : ''} role="switch" ${button.toHtml()}><div><div aria-hidden="true">${toggleIcon(this._offIcon)}</div><div aria-hidden="true">${toggleIcon(this._onIcon)}</div></div></div>${state ? `<div x-cloak="inline-flex" wire:ignore class="${e(['fi-toggle fi-toggle-on fi-hidden', ...onColorClasses].join(' '))}"><div><div aria-hidden="true"></div><div aria-hidden="true">${toggleIcon(this._onIcon)}</div></div></div>` : ''}${EditableColumn.STATUS_HTML}</div>`;
672
+ }
673
+ }
674
+ /** In-cell editable checkbox. The received value must be a boolean (`boolean` rule by default). */
675
+ export class CheckboxColumn extends EditableColumn {
676
+ constructor(name) { super(name); this._rules = ['boolean']; }
677
+ static make(name) { return configured(new CheckboxColumn(name)); }
678
+ castState(input) { return toBoolean(input); }
679
+ async toHtml(record, recordKey = '') {
680
+ const state = Boolean(await this.getState(record));
681
+ const isDisabled = await this.isDisabledFor(record);
682
+ const outer = this.editableAttributes('checkboxTableColumn', 'checkbox', recordKey, state).class(['fi-ta-checkbox', alignmentClass(this._alignment)]);
683
+ const input = new Attributes().merge({
684
+ 'aria-label': e(this.getLabel()), disabled: true, 'wire:loading.attr': 'disabled', 'wire:target': LOADING_TARGETS,
685
+ 'x-bind:aria-invalid': "error ? 'true' : null", 'x-bind:aria-busy': "isLoading ? 'true' : null", 'x-bind:aria-describedby': "getAccessibilityAttributes([])[ 'aria-describedby' ]",
686
+ 'x-bind:disabled': isDisabled ? null : 'isLoading', 'x-on:change': 'clearError()', 'x-tooltip': this.tooltipExpression(record),
687
+ }).class(['fi-checkbox-input']);
688
+ return `<div x-on:click.stop wire:ignore.self ${outer.toHtml()}><input type="hidden" value="${state ? 1 : 0}" x-ref="serverState" /><input type="checkbox" x-bind:class="{
689
+ 'fi-valid': ! error,
690
+ 'fi-invalid': error,
691
+ }" x-model="state" ${input.toHtml()} />${EditableColumn.STATUS_HTML}</div>`;
692
+ }
693
+ }
694
+ /** In-cell editable text input. Only a string or a number is accepted from the browser. */
695
+ export class TextInputColumn extends EditableColumn {
696
+ _type = 'text';
697
+ _mask = null;
698
+ _inputMode = null;
699
+ _step = null;
700
+ _placeholder = null;
701
+ _prefix = null;
702
+ _suffix = null;
703
+ _prefixIcon = null;
704
+ _suffixIcon = null;
705
+ _prefixIconColor = null;
706
+ _suffixIconColor = null;
707
+ _prefixInline = false;
708
+ _suffixInline = false;
709
+ static make(name) { return configured(new TextInputColumn(name)); }
710
+ /** HTML input type (`text` by default; `number`, `email`, `color`…). */
711
+ type(type) { this._type = type && /^[a-z-]+$/.test(type) ? type : 'text'; return this; }
712
+ /** Alpine input mask (`x-mask`); the input then becomes a text input. */
713
+ mask(mask) { this._mask = mask; return this; }
714
+ inputMode(mode) { this._inputMode = mode; return this; }
715
+ step(step) { this._step = step; return this; }
716
+ placeholder(text) { this._placeholder = text; return this; }
717
+ prefix(label, isInline = false) { this._prefix = label; this._prefixInline = isInline; return this; }
718
+ suffix(label, isInline = false) { this._suffix = label; this._suffixInline = isInline; return this; }
719
+ inlinePrefix(condition = true) { this._prefixInline = condition; return this; }
720
+ inlineSuffix(condition = true) { this._suffixInline = condition; return this; }
721
+ prefixIcon(name, isInline = false) { this._prefixIcon = name; this._prefixInline = isInline; return this; }
722
+ suffixIcon(name, isInline = false) { this._suffixIcon = name; this._suffixInline = isInline; return this; }
723
+ prefixIconColor(color) { this._prefixIconColor = color; return this; }
724
+ suffixIconColor(color) { this._suffixIconColor = color; return this; }
725
+ getImplicitRules() {
726
+ return [{ name: 'string', passes: (v) => typeof v === 'string' || (typeof v === 'number' && Number.isFinite(v)), message: (a) => `The ${a} field must be a string.` }];
727
+ }
728
+ async toHtml(record, recordKey = '') {
729
+ const raw = await this.getState(record);
730
+ const state = raw === undefined ? null : raw;
731
+ const isDisabled = await this.isDisabledFor(record);
732
+ const type = this._mask ? 'text' : this._type;
733
+ // Affix icon: gray is the default color and adds no class.
734
+ const affixIcon = (name, color) => (name ? icon(name, { attributes: new Attributes().class(color && color !== 'gray' ? ['fi-color', `fi-color-${color.replace(/[^a-zA-Z0-9_-]/g, '')}`] : []) }) : '');
735
+ const outer = this.editableAttributes('textInputTableColumn', 'text-input', recordKey, state).class(['fi-ta-text-input']);
736
+ const input = new Attributes().merge({
737
+ 'aria-label': e(this.getLabel()), disabled: true, 'wire:loading.attr': 'disabled', 'wire:target': LOADING_TARGETS,
738
+ 'x-bind:aria-invalid': "error ? 'true' : null", 'x-bind:aria-busy': "isLoading ? 'true' : null", 'x-bind:aria-describedby': "getAccessibilityAttributes([])[ 'aria-describedby' ]",
739
+ 'x-bind:disabled': isDisabled ? null : 'isLoading', 'x-on:input': 'clearError()', inputmode: this._inputMode, placeholder: this._placeholder ? e(this._placeholder) : null, step: this._step,
740
+ type, 'x-mask': this._mask, 'x-tooltip': this.tooltipExpression(record),
741
+ }).class(['fi-input', alignmentClass(this._alignment ?? 'start')]);
742
+ const prefix = this._prefixIcon || this._prefix ? `<div class="fi-input-wrp-prefix fi-input-wrp-prefix-has-content ${this._prefixInline ? 'fi-inline' : ''} ${this._prefix ? 'fi-input-wrp-prefix-has-label' : ''}">${affixIcon(this._prefixIcon, this._prefixIconColor)}${this._prefix ? `<span class="fi-input-wrp-label">${e(this._prefix)}</span>` : ''}</div>` : '';
743
+ const suffix = this._suffixIcon || this._suffix ? `<div class="fi-input-wrp-suffix ${this._suffixInline ? 'fi-inline' : ''} ${this._suffix ? 'fi-input-wrp-suffix-has-label' : ''}">${this._suffix ? `<span class="fi-input-wrp-label">${e(this._suffix)}</span>` : ''}${affixIcon(this._suffixIcon, this._suffixIconColor)}</div>` : '';
744
+ return `<div wire:ignore.self ${outer.toHtml()}><input type="hidden" value="${e(state)}" x-ref="serverState" /><div x-bind:class="{
745
+ 'fi-disabled': isLoading || ${js(isDisabled)},
746
+ 'fi-invalid': error !== undefined,
747
+ }" x-on:click.prevent.stop class="fi-input-wrp">${prefix}<div class="fi-input-wrp-content-ctn"><input${type === 'color' ? ` onclick="if (typeof this.showPicker === 'function') { this.showPicker() }"` : ''} x-model.lazy="state" ${input.toHtml()} /></div>${suffix}</div>${EditableColumn.STATUS_HTML}</div>`;
748
+ }
749
+ }
750
+ /**
751
+ * Custom-rendered column: `view()` takes a function returning the cell HTML, or the name of an EJS view rendered with
752
+ * `{ state, record, column }`. This HTML is the application's own: data inside it must be escaped there (`e()`).
753
+ */
754
+ export class ViewColumn extends Column {
755
+ _view = null;
756
+ _viewData = {};
757
+ static make(name) { return configured(new ViewColumn(name)); }
758
+ view(view, data = {}) { this._view = view; this._viewData = data; return this; }
759
+ async toHtml(record) {
760
+ const state = await this.getState(record);
761
+ if (this._view === null)
762
+ throw new Error(`ViewColumn ${this.name}: no view declared (view()).`);
763
+ if (typeof this._view === 'function')
764
+ return this._view({ state, record, column: this });
765
+ return views.render(this._view, { ...this._viewData, state, record, column: this, getState: () => state, getRecord: () => record });
766
+ }
767
+ }
768
+ /** @deprecated Use `TextColumn.make(name).badge()`. */
769
+ export class BadgeColumn extends TextColumn {
770
+ constructor(name) { super(name); this.badge(); }
771
+ static make(name) { return configured(new BadgeColumn(name)); }
772
+ }
773
+ /** @deprecated Use `IconColumn.make(name).boolean()`. */
774
+ export class BooleanColumn extends IconColumn {
775
+ constructor(name) { super(name); this.boolean(); }
776
+ static make(name) { return configured(new BooleanColumn(name)); }
777
+ }
778
+ /**
779
+ * Writes an editable cell value received from the browser. Everything is checked again: the column must be declared, editable
780
+ * and visible, the record within the adapter's scope and not disabled for this column, `authorize` (the host's edit permission)
781
+ * must accept it, and the value must pass the column's rules.
782
+ */
783
+ export async function updateTableColumnState(table, host, adapter, columnName, recordKey, input, options = {}) {
784
+ const unavailable = { error: t('filament-tables::table.columns.editable.unavailable') };
785
+ const column = getVisibleColumns(table, host).find((c) => c.name === columnName);
786
+ if (!(column instanceof EditableColumn))
787
+ return unavailable;
788
+ if (typeof recordKey !== 'string' && typeof recordKey !== 'number')
789
+ return unavailable;
790
+ const record = (await adapter.keysInScope([String(recordKey)])).length ? await adapter.find(String(recordKey)) : null;
791
+ if (!record || (options.authorize && !(await options.authorize(record))) || (await column.isDisabledFor(record)))
792
+ return unavailable;
793
+ const error = column.validate(input);
794
+ if (error !== null)
795
+ return { error };
796
+ return column.updateState(record, input, adapter);
797
+ }
798
+ /** "Trashed" filter for soft-deletable models. */
799
+ export class TrashedFilter {
800
+ name = 'trashed';
801
+ static make() { return configured(new TrashedFilter()); }
802
+ getLabel() { return t('filament-tables::table.filters.trashed.label'); }
803
+ getDefaultState() { return { value: null }; }
804
+ getFormDefinition() {
805
+ return { placeholder: t('filament-tables::table.filters.trashed.without_trashed'), options: [['1', t('filament-tables::table.filters.trashed.with_trashed')], ['0', t('filament-tables::table.filters.trashed.only_trashed')]] };
806
+ }
807
+ isActive(state) { return ['1', '0'].includes(String(state.value)); }
808
+ apply(query, state) {
809
+ query.trashed = String(state.value) === '1' ? 'with' : String(state.value) === '0' ? 'only' : 'without';
810
+ }
811
+ }
812
+ const filterLabel = (name) => name.replace(/[_.-]+/g, ' ').replace(/^./, (c) => c.toUpperCase());
813
+ /** Filters on a column value picked from a list (single select). */
814
+ export class SelectFilter {
815
+ name;
816
+ _label = null;
817
+ _column = null;
818
+ _options = [];
819
+ constructor(name) { this.name = name; }
820
+ static make(name) { return configured(new SelectFilter(name)); }
821
+ label(label) { this._label = label; return this; }
822
+ /** Filtered column when it differs from the filter name (e.g. a relationship's foreign key). */
823
+ column(column) { this._column = column; return this; }
824
+ /** Fixed options, or an async function (e.g. records of a relationship). */
825
+ options(options) {
826
+ this._options = typeof options === 'function' ? options : (Array.isArray(options) ? options : Object.entries(options)).map(([value, label]) => [String(value), String(label)]);
827
+ return this;
828
+ }
829
+ getLabel() { return this._label ?? filterLabel(this.name); }
830
+ getDefaultState() { return { value: null }; }
831
+ getFormDefinition() { return { placeholder: t('filament-tables::table.filters.select.placeholder'), options: Array.isArray(this._options) ? this._options : [] }; }
832
+ getFormOptions() { return typeof this._options === 'function' ? this._options() : Promise.resolve(this._options); }
833
+ /** Options are evaluated on every render, so the select gets a key derived from them (forces a re-render when they change). */
834
+ hasDynamicOptions = true;
835
+ isActive(state) { return typeof state.value === 'string' && state.value !== ''; }
836
+ apply(query, state) {
837
+ if (!this.isActive(state))
838
+ return;
839
+ const value = String(state.value).slice(0, 255);
840
+ // Fixed options: only an offered value filters. Loaded options: the value is a bound parameter, an unknown value matches nothing.
841
+ if (Array.isArray(this._options) && !this._options.some(([option]) => option === value))
842
+ return;
843
+ const column = this._column ?? this.name;
844
+ (query.modifiers ??= []).push((q) => { q.where(column, value); });
845
+ }
846
+ }
847
+ /** Three-state filter on a boolean column: all, yes, no. */
848
+ export class TernaryFilter {
849
+ name;
850
+ _label = null;
851
+ constructor(name) { this.name = name; }
852
+ static make(name) { return configured(new TernaryFilter(name)); }
853
+ label(label) { this._label = label; return this; }
854
+ getLabel() { return this._label ?? filterLabel(this.name); }
855
+ getDefaultState() { return { value: null }; }
856
+ getFormDefinition() { return { placeholder: '-', options: [['1', t('filament-forms::components.select.boolean.true')], ['0', t('filament-forms::components.select.boolean.false')]] }; }
857
+ isActive(state) { return ['1', '0'].includes(String(state.value)); }
858
+ apply(query, state) {
859
+ if (!this.isActive(state))
860
+ return;
861
+ const column = this.name, on = String(state.value) === '1';
862
+ (query.modifiers ??= []).push((q) => { q.where(column, on ? 1 : 0); });
863
+ }
864
+ }
865
+ /** Custom form filter (`schema()`): the query and indicators are written by the application. */
866
+ export class Filter {
867
+ name;
868
+ _label = null;
869
+ _components = [];
870
+ _query = null;
871
+ _indicate = null;
872
+ constructor(name) { this.name = name; }
873
+ static make(name) { return configured(new Filter(name)); }
874
+ label(label) { this._label = label; return this; }
875
+ /** Filter fields; their names are the only state keys accepted from the browser. */
876
+ schema(components) { this._components = components; return this; }
877
+ /** Query condition built from the sanitized form state. */
878
+ query(modifier) { this._query = modifier; return this; }
879
+ /** Indicators of applied values as `{ field: label }`; removing one clears its field. */
880
+ indicateUsing(resolver) { this._indicate = resolver; return this; }
881
+ fields() { return this._components.map((c) => (typeof c?.getName === 'function' ? String(c.getName()) : null)).filter((name) => name !== null); }
882
+ getLabel() { return this._label ?? filterLabel(this.name); }
883
+ getDefaultState() { return Object.fromEntries(this.fields().map((name) => [name, null])); }
884
+ getFormDefinition() { return { placeholder: '', options: [] }; }
885
+ getFormComponents() { return this._components; }
886
+ sanitizeState(state) {
887
+ const received = state && typeof state === 'object' ? state : {};
888
+ return Object.fromEntries(this.fields().map((name) => { const value = received[name]; return [name, ['string', 'number', 'boolean'].includes(typeof value) && value !== '' ? value : null]; }));
889
+ }
890
+ isActive(state) { return Object.values(this.sanitizeState(state)).some((value) => value !== null); }
891
+ apply(query, state) {
892
+ const data = this.sanitizeState(state);
893
+ if (this._query && this.isActive(data))
894
+ (query.modifiers ??= []).push((q) => { this._query(q, data); });
895
+ }
896
+ getIndicators(state) {
897
+ const data = this.sanitizeState(state);
898
+ if (!this._indicate)
899
+ return this.isActive(data) ? [{ label: this.getLabel(), field: '' }] : [];
900
+ return Object.entries(this._indicate(data)).map(([field, label]) => ({ field, label }));
901
+ }
902
+ removeIndicator(state, field) { return field === '' ? this.getDefaultState() : { ...this.sanitizeState(state), [field]: null }; }
903
+ }
904
+ export class Table {
905
+ _columns = [];
906
+ _recordAction = null;
907
+ _recordActions = [];
908
+ _heading = null;
909
+ _headerActions = [];
910
+ _bulkActions = null;
911
+ _recordClasses = null;
912
+ _filters = [];
913
+ _filtersLayout = 'dropdown';
914
+ _defaultSort = null;
915
+ _recordUrl = null;
916
+ _pageOptions = [5, 10, 25, 50];
917
+ _stackedOnMobile = false;
918
+ _label = 'records';
919
+ _groups = [];
920
+ _defaultGroup = null;
921
+ _defaultGroupDirection = 'asc';
922
+ _groupsOnly = false;
923
+ _groupingSettingsHidden = false;
924
+ _groupingDirectionSettingHidden = false;
925
+ _groupingSettingsInDropdownOnDesktop = false;
926
+ _collapsedGroupsByDefault = false;
927
+ /** Groupings offered in the toolbar (a column id is shorthand for `Group.make(id)`). */
928
+ groups(groups) { this._groups = groups.map((g) => (typeof g === 'string' ? Group.make(g) : g)); return this; }
929
+ getGroups() { return this._groups; }
930
+ getGroup(id) { return this._groups.find((g) => g.getId() === id) ?? null; }
931
+ /**
932
+ * Default grouping. If it is one of `groups()`, it is only preselected when the table opens (`initializeTableGrouping`)
933
+ * and the user can remove it; otherwise it always applies, without a selector.
934
+ */
935
+ defaultGroup(group, direction = 'asc') { this._defaultGroup = group; this._defaultGroupDirection = direction === 'desc' ? 'desc' : 'asc'; return this; }
936
+ getDefaultGroup() {
937
+ if (this._defaultGroup === null)
938
+ return null;
939
+ if (this._defaultGroup instanceof Group)
940
+ return this._defaultGroup;
941
+ return this.getGroup(this._defaultGroup) ?? Group.make(this._defaultGroup);
942
+ }
943
+ getDefaultGroupDirection() { return this._defaultGroupDirection; }
944
+ isDefaultGroupSelectable() { const group = this.getDefaultGroup(); return group !== null && this.getGroup(group.getId()) !== null; }
945
+ /** Shows only group headers and summaries, without rows or pagination. */
946
+ groupsOnly(condition = true) { this._groupsOnly = condition; return this; }
947
+ isGroupsOnly() { return this._groupsOnly; }
948
+ /** Hides the toolbar grouping selector. */
949
+ groupingSettingsHidden(condition = true) { this._groupingSettingsHidden = condition; return this; }
950
+ areGroupingSettingsHidden() { return this._groupingSettingsHidden; }
951
+ /** Hides the grouping direction (ascending / descending) choice. */
952
+ groupingDirectionSettingHidden(condition = true) { this._groupingDirectionSettingHidden = condition; return this; }
953
+ isGroupingDirectionSettingHidden() { return this._groupingDirectionSettingHidden; }
954
+ /** Puts the grouping selector in a dropdown on desktop too. */
955
+ groupingSettingsInDropdownOnDesktop(condition = true) { this._groupingSettingsInDropdownOnDesktop = condition; return this; }
956
+ /** @deprecated Use `groupingSettingsInDropdownOnDesktop()`. */
957
+ groupsInDropdownOnDesktop(condition = true) { return this.groupingSettingsInDropdownOnDesktop(condition); }
958
+ areGroupingSettingsInDropdownOnDesktop() { return this._groupingSettingsInDropdownOnDesktop; }
959
+ /** Collapsible groups start collapsed. */
960
+ collapsedGroupsByDefault(condition = true) { this._collapsedGroupsByDefault = condition; return this; }
961
+ areGroupsCollapsedByDefault() { return this._collapsedGroupsByDefault; }
962
+ _columnsLayout = null;
963
+ columns(columns) {
964
+ this._columns = columns.flatMap((c) => (c instanceof ColumnGroup || c instanceof ColumnLayout ? c.getColumns() : [c]));
965
+ this._columnsLayout = columns.some((c) => c instanceof ColumnLayout) ? columns.filter((c) => !(c instanceof ColumnGroup)) : null;
966
+ return this;
967
+ }
968
+ /** Column layout, or `null` for a regular table. */
969
+ getColumnsLayout() { return this._columnsLayout; }
970
+ /** Action mounted when a row is clicked (e.g. `'edit'`), instead of a URL. */
971
+ recordAction(name) { this._recordAction = name; return this; }
972
+ getRecordAction() { return this._recordAction; }
973
+ /** Heading shown in the table header. */
974
+ heading(heading) { this._heading = heading; return this; }
975
+ getHeading() { return this._heading; }
976
+ /** Actions shown in the table header. */
977
+ headerActions(actions) { this._headerActions = actions; return this; }
978
+ getHeaderActions() { return this._headerActions; }
979
+ /** Bulk actions ("Bulk actions" menu); if never called, the host provides its default ones. */
980
+ groupedBulkActions(actions) { this._bulkActions = actions; return this; }
981
+ getBulkActions() { return this._bulkActions; }
982
+ /** Row actions: `Action`s or an `ActionGroup` (dropdown). */
983
+ recordActions(actions) { this._recordActions = actions; return this; }
984
+ getRecordActions() { return this._recordActions; }
985
+ /** CSS classes added to a row depending on its record. */
986
+ recordClasses(resolver) { this._recordClasses = resolver; return this; }
987
+ getRecordClasses(record) { return this._recordClasses ? this._recordClasses(record) : null; }
988
+ filters(filters, options = {}) { this._filters = filters; if (options.layout)
989
+ this._filtersLayout = options.layout; return this; }
990
+ filtersLayout(layout) { this._filtersLayout = layout; return this; }
991
+ getFiltersLayout() { return this._filtersLayout; }
992
+ /** No-op kept for API compatibility: filters always wait for the "Apply" button. */
993
+ deferFilters() { return this; }
994
+ defaultSort(column, direction = 'asc') { this._defaultSort = { column, direction }; return this; }
995
+ recordUrl(resolver) { this._recordUrl = resolver; return this; }
996
+ _reorderColumn = null;
997
+ /** Lets users reorder rows by hand; `column` stores the position. */
998
+ reorderable(column) { this._reorderColumn = column; return this; }
999
+ getReorderColumn() { return this._reorderColumn; }
1000
+ paginationPageOptions(options) { this._pageOptions = options; return this; }
1001
+ _defaultPageOption = null;
1002
+ /** Rows per page when the table opens. */
1003
+ defaultPaginationPageOption(option) { this._defaultPageOption = option; return this; }
1004
+ getDefaultPageOption() { return this._defaultPageOption !== null && this._pageOptions.includes(this._defaultPageOption) ? this._defaultPageOption : (this._pageOptions.includes(10) ? 10 : this._pageOptions[0]); }
1005
+ _paginationMode = 'default';
1006
+ /** `'simple'`: previous / next buttons only, without page numbers or total. */
1007
+ paginationMode(mode) { this._paginationMode = mode; return this; }
1008
+ getPaginationMode() { return this._paginationMode; }
1009
+ stackedOnMobile(condition = true) { this._stackedOnMobile = condition; return this; }
1010
+ pluralModelLabel(label) { this._label = label; return this; }
1011
+ getColumns() { return this._columns; }
1012
+ getColumn(name) { return this._columns.find((c) => c.name === name); }
1013
+ getFilters() { return this._filters; }
1014
+ getDefaultSort() { return this._defaultSort; }
1015
+ getRecordUrl(record) { return this._recordUrl ? this._recordUrl(record) : null; }
1016
+ getPageOptions() { return this._pageOptions; }
1017
+ isStackedOnMobile() { return this._stackedOnMobile; }
1018
+ getPluralModelLabel() { return this._label; }
1019
+ hasToggleableColumns() { return this._columns.some((c) => c.isToggleable()); }
1020
+ /** Default column manager state. */
1021
+ getDefaultColumnState() {
1022
+ return this._columns.map((c) => ({ type: 'column', name: c.name, label: c.getLabel(), isHidden: false, isToggled: !c.isToggleable() || !c.isToggledHiddenByDefault(), isToggleable: c.isToggleable(), isToggledHiddenByDefault: c.isToggleable() ? c.isToggledHiddenByDefault() : null }));
1023
+ }
1024
+ getDefaultFilterState() { return Object.fromEntries(this._filters.map((f) => [f.name, f.getDefaultState()])); }
1025
+ }
1026
+ /** Visible columns: those left checked in the column manager. Browser state can only hide columns declared toggleable. */
1027
+ export function getVisibleColumns(table, host) {
1028
+ const hidden = new Set((Array.isArray(host.tableColumns) ? host.tableColumns : []).filter((c) => c && c.isToggled === false).map((c) => c.name));
1029
+ return table.getColumns().filter((c) => !(c.isToggleable() && hidden.has(c.name)));
1030
+ }
1031
+ /** Sanitizes column state from the browser: only `isToggled` of toggleable columns is kept. */
1032
+ export function sanitizeColumnState(table, state) {
1033
+ const received = new Map((Array.isArray(state) ? state : []).filter((c) => c && typeof c === 'object').map((c) => [String(c.name), c.isToggled]));
1034
+ return table.getDefaultColumnState().map((c) => (c.isToggleable && typeof received.get(c.name) === 'boolean' ? { ...c, isToggled: received.get(c.name) } : c));
1035
+ }
1036
+ /**
1037
+ * List query (search, column searches, filters, sort) for the host's state, without pagination.
1038
+ * Only what the table declares is accepted, whatever state the browser sends.
1039
+ */
1040
+ export function getTableQuery(table, host) {
1041
+ const [column, direction] = (host.tableSort ?? '').split(':');
1042
+ const sortColumn = table.getColumn(column);
1043
+ const query = {
1044
+ search: host.tableSearch,
1045
+ searchColumns: table.getColumns().filter((c) => c.isGloballySearchable()).map((c) => c.getSearchColumn()),
1046
+ columnSearches: Object.fromEntries(table.getColumns().filter((c) => c.isIndividuallySearchable() && String(host.tableColumnSearches?.[c.name] ?? '').trim() !== '').map((c) => [c.name, String(host.tableColumnSearches[c.name])])),
1047
+ sort: sortColumn?.isSortable() ? { column: sortColumn.getSortColumn(), direction: ((direction === 'desc') !== sortColumn.isSortInverted() ? 'desc' : 'asc') } : table.getDefaultSort(),
1048
+ };
1049
+ const grouping = getTableGrouping(table, host);
1050
+ if (grouping)
1051
+ query.groupSort = { column: grouping.group.getColumn(), direction: grouping.direction };
1052
+ // The host can restrict the query (active list tab).
1053
+ host.modifyTableQuery?.(query);
1054
+ for (const filter of table.getFilters()) {
1055
+ const state = host.tableFilters?.[filter.name];
1056
+ filter.apply(query, state && typeof state === 'object' ? state : filter.getDefaultState());
1057
+ }
1058
+ return query;
1059
+ }
1060
+ /** Current grouping: only a group declared by the table is accepted, whatever state the browser sends. */
1061
+ export function getTableGrouping(table, host) {
1062
+ if (host.isTableReordering === true)
1063
+ return null;
1064
+ const [id, direction] = String(host.tableGrouping ?? '').split(':');
1065
+ const group = table.getGroup(id);
1066
+ if (group)
1067
+ return { group, direction: direction === 'desc' ? 'desc' : 'asc' };
1068
+ // A default grouping missing from the selector always applies; one listed in the selector only applies when chosen.
1069
+ const fallback = table.isDefaultGroupSelectable() ? null : table.getDefaultGroup();
1070
+ return fallback ? { group: fallback, direction: table.getDefaultGroupDirection() } : null;
1071
+ }
1072
+ /** When the table opens (host mount): preselects the default grouping when the user can change it. */
1073
+ export function initializeTableGrouping(table, host) {
1074
+ const group = table.getDefaultGroup();
1075
+ if ((host.tableGrouping === null || host.tableGrouping === undefined || host.tableGrouping === '') && group && table.isDefaultGroupSelectable())
1076
+ host.tableGrouping = `${group.getId()}:${table.getDefaultGroupDirection()}`;
1077
+ }
1078
+ export function countActiveFilters(table, host) {
1079
+ // The count is the number of indicators; an active filter without indicators counts as one.
1080
+ return table.getFilters().reduce((total, f) => { const s = host.tableFilters?.[f.name]; return total + (!s || typeof s !== 'object' ? 0 : f.getIndicators ? f.getIndicators(s).length : f.isActive(s) ? 1 : 0); }, 0);
1081
+ }
1082
+ /** Indicators of the applied filters (only filters that declare some have them). */
1083
+ export function getFilterIndicators(table, host) {
1084
+ return table.getFilters().flatMap((f) => { const s = host.tableFilters?.[f.name]; return s && typeof s === 'object' && f.getIndicators ? f.getIndicators(s).map((indicator) => ({ filter: f.name, ...indicator })) : []; });
1085
+ }
1086
+ /** Cycles a column's sort: none → asc → desc → none. */
1087
+ export function sortTable(host, column = null, direction = null) {
1088
+ const [current, currentDirection] = (host.tableSort ?? '').split(':');
1089
+ if (column === (host.tableSort ? current : null))
1090
+ direction ??= (currentDirection ?? 'asc') === 'asc' ? 'desc' : currentDirection === 'desc' ? null : 'asc';
1091
+ else
1092
+ direction ??= 'asc';
1093
+ host.tableSort = direction ? `${column}:${direction}` : null;
1094
+ host.paginators.page = 1;
1095
+ }
1096
+ /** Loads the page of records matching the host's state. */
1097
+ export async function getTableRecords(table, host, adapter) {
1098
+ const options = table.getPageOptions();
1099
+ const perPage = options.includes(Number(host.tableRecordsPerPage)) ? Number(host.tableRecordsPerPage) : options[0];
1100
+ return adapter.paginate({ ...getTableQuery(table, host), page: Number(host.paginators.page) || 1, perPage });
1101
+ }
1102
+ /** Pagination elements: the first and last pages and a window around the current page, with `...` gaps. */
1103
+ export function paginationElements(current, last, onEachSide = 0) {
1104
+ const range = (a, b) => Array.from({ length: Math.max(0, b - a + 1) }, (_, i) => a + i);
1105
+ if (last < onEachSide * 2 + 8)
1106
+ return range(1, last);
1107
+ const window = onEachSide + 4;
1108
+ if (current <= window)
1109
+ return [...range(1, window + onEachSide), '...', last - 1, last];
1110
+ if (current > last - window)
1111
+ return [1, 2, '...', ...range(last - (window + (onEachSide - 1)), last)];
1112
+ return [1, 2, '...', ...range(current - onEachSide, current + onEachSide), '...', last - 1, last];
1113
+ }
1114
+ let colorManager = null;
1115
+ export const useColorManager = (manager) => { colorManager = manager; };
1116
+ /** Maximum number of rows shown (and reorderable) in reordering mode. */
1117
+ export const REORDER_LIMIT = 1000;
1118
+ /** Summary row; with `groupsOnly`, a leading label cell, then one cell per column. */
1119
+ function summaryRowHtml(row, span, groupsOnly, placeholders) {
1120
+ const items = (cell) => cell.items.map((item) => `<div class="fi-ta-${item.type}-summary">${item.label !== null ? `<span class="fi-ta-${item.type}-summary-label">${e(item.label)}</span>` : ''}<span>${e(item.value)}</span></div>`).join('');
1121
+ const heading = groupsOnly
1122
+ ? `<th scope="row" class="fi-ta-cell fi-ta-summary-row-heading-cell fi-align-start">${e(row.heading)}</th>`
1123
+ : `<th scope="row"${span > 1 ? ` colspan="${span}"` : ''} class="fi-ta-cell fi-align-start fi-ta-summary-row-heading-cell ">${e(row.heading)}</th>`;
1124
+ return `<tr class="fi-ta-row fi-ta-summary-row${row.striped ? ' fi-striped' : ''}">${placeholders.selection ? '<td></td>' : ''}${heading}${row.cells.map((cell) => `<td class="fi-ta-cell fi-align-${e(cell.alignment)} ">${items(cell)}</td>`).join('')}${placeholders.actions ? '<td></td>' : ''}</tr>`;
1125
+ }
1126
+ /**
1127
+ * Table summaries: a single "Summary" row without pagination; with several pages, a header then "This page" and "All …" rows.
1128
+ * Also returns what is needed to compute a group's summary row (query restricted to the group).
1129
+ */
1130
+ async function summarize(table, host, adapter, visible, page, options) {
1131
+ // Groups only: the grouped column has no summary cell (its label already leads the row).
1132
+ const columns = options.groupsOnly && options.group ? visible.filter((c) => c.name !== options.group.getColumn()) : visible;
1133
+ const lists = columns.map((c) => (c instanceof TextColumn ? c.getSummarizers() : []));
1134
+ if (!lists.some((list) => list.length) || !page.records.length)
1135
+ return null;
1136
+ // The first cell holds the row label and spans up to the first summarized column after it.
1137
+ const next = lists.findIndex((list, i) => i > 0 && list.length > 0);
1138
+ const span = options.groupsOnly ? 0 : next < 0 ? columns.length : next;
1139
+ const columnOf = (c) => c.getSortColumn?.() ?? c.name;
1140
+ const names = (kind) => [...new Set(columns.filter((_, i) => lists[i].some((s) => s.kind === kind)).map(columnOf))];
1141
+ const aggregate = (query) => adapter.aggregate(query, { sum: names('sum'), avg: names('avg'), min: names('range'), max: names('range') });
1142
+ const query = getTableQuery(table, host);
1143
+ const all = await aggregate(query);
1144
+ const present = (name) => page.records.map((record) => record[name]).filter((v) => v !== null && v !== undefined && v !== '');
1145
+ const onPage = {
1146
+ count: page.records.length,
1147
+ sum: Object.fromEntries(names('sum').map((name) => [name, present(name).reduce((total, v) => total + Number(v), 0)])),
1148
+ avg: Object.fromEntries(names('avg').map((name) => { const values = present(name); return [name, values.length ? values.reduce((total, v) => total + Number(v), 0) / values.length : 0]; })),
1149
+ min: Object.fromEntries(names('range').map((name) => [name, present(name).map(String).sort()[0] ?? null])),
1150
+ max: Object.fromEntries(names('range').map((name) => [name, present(name).map(String).sort().pop() ?? null])),
1151
+ };
1152
+ const cells = (values) => columns.slice(span).map((c, i) => ({
1153
+ alignment: c.getAlignment() ?? 'start',
1154
+ items: lists[i + span].map((s) => s.summarize(columnOf(c), values)).filter((item) => item !== null),
1155
+ }));
1156
+ const label = table.getPluralModelLabel();
1157
+ const hasPageSummary = !options.groupsOnly && page.lastPage > 1;
1158
+ const rowHtml = (row) => summaryRowHtml(row, span, options.groupsOnly, options.placeholders);
1159
+ const header = hasPageSummary
1160
+ ? `<tr class="fi-ta-row fi-ta-summary-header-row fi-striped">${options.placeholders.selection ? '<td></td>' : ''}${columns.map((c, i) => {
1161
+ const heading = i === 0 ? t('filament-tables::table.summary.heading', { label }) : lists[i].length ? c.getLabel() : null;
1162
+ return heading === null ? '<td class="fi-ta-cell fi-ta-summary-header-cell"></td>' : `<th scope="col" class="fi-ta-cell fi-ta-summary-header-cell fi-align-${e(i === 0 ? 'start' : (c.getAlignment() ?? 'start'))}">${e(heading)}</th>`;
1163
+ }).join('')}${options.placeholders.actions ? '<td></td>' : ''}</tr>`
1164
+ : '';
1165
+ const rows = hasPageSummary
1166
+ ? [{ heading: t('filament-tables::table.summary.subheadings.page', { label }), cells: cells(onPage), striped: false }, { heading: t('filament-tables::table.summary.subheadings.all', { label }), cells: cells(all), striped: false }]
1167
+ : [{ heading: t('filament-tables::table.summary.heading', { label }), cells: cells(all), striped: true }];
1168
+ return {
1169
+ html: header + rows.map(rowHtml).join(''),
1170
+ /** Group summary row: aggregates over all the group's records (not only those on the page). */
1171
+ group: async (group, key, title) => {
1172
+ const values = await aggregate({ ...query, modifiers: [...(query.modifiers ?? []), (q) => group.scope(q, key)] });
1173
+ return rowHtml({ heading: options.groupsOnly ? (title ?? '') : t('filament-tables::table.summary.subheadings.group', { group: title ?? '', label }), cells: cells(values), striped: false });
1174
+ },
1175
+ };
1176
+ }
1177
+ /** Maximum records read for a `groupsOnly` table (which is not paginated). */
1178
+ export const GROUPS_ONLY_LIMIT = 1000;
1179
+ export async function renderTable(table, host, adapter, options = {}) {
1180
+ const reorderColumn = options.canReorder === false ? null : table.getReorderColumn();
1181
+ const reordering = reorderColumn !== null && host.isTableReordering === true;
1182
+ // Reordering: all rows of the query, in position order, without pagination or actions.
1183
+ if (reordering)
1184
+ options = { ...options, bulkActions: [], headerActions: options.headerActions, rowActions: undefined, rowActionsHtml: undefined, filtersForm: null };
1185
+ const grouping = reordering ? null : getTableGrouping(table, host);
1186
+ // Groups only: no pagination and no record rows, just one summary row per group.
1187
+ const groupsOnly = grouping !== null && table.isGroupsOnly();
1188
+ const page = reordering
1189
+ ? await adapter.paginate({ ...getTableQuery(table, host), sort: { column: reorderColumn, direction: 'asc' }, page: 1, perPage: REORDER_LIMIT })
1190
+ : groupsOnly ? await adapter.paginate({ ...getTableQuery(table, host), page: 1, perPage: GROUPS_ONLY_LIMIT }) : await getTableRecords(table, host, adapter);
1191
+ const [sortColumn, sortDirection] = (host.tableSort ?? '').split(':');
1192
+ const number = (n) => n.toLocaleString('en-US');
1193
+ const visible = getVisibleColumns(table, host);
1194
+ const rows = [];
1195
+ const hasBulk = (options.bulkActions ?? []).length > 0;
1196
+ const selectable = (hasBulk || reordering) && !groupsOnly;
1197
+ const filled = (value) => value !== null && value.trim() !== '';
1198
+ // A new group starts when the title changes; the previous group then gets its summary row.
1199
+ let previousGroup = { key: null, title: null };
1200
+ const columnsLayout = table.getColumnsLayout();
1201
+ const visibleSet = new Set(visible);
1202
+ for (const record of page.records) {
1203
+ const key = adapter.getKey(record);
1204
+ let groupHeader = null;
1205
+ let summaryBefore = null;
1206
+ const groupKey = grouping ? grouping.group.getStringKey(record) : null;
1207
+ const groupTitle = grouping ? grouping.group.getTitle(record) : null;
1208
+ if (grouping && String(groupTitle ?? '') !== String(previousGroup.title ?? '')) {
1209
+ if (filled(previousGroup.title))
1210
+ summaryBefore = previousGroup;
1211
+ const query = getTableQuery(table, host);
1212
+ const label = grouping.group.isTitlePrefixedWithLabel() ? grouping.group.getLabel() : null;
1213
+ const description = grouping.group.getDescription(record);
1214
+ if (!groupsOnly)
1215
+ groupHeader = {
1216
+ key: groupKey, title: groupTitle, label, description, collapsible: grouping.group.isCollapsible(),
1217
+ // Keys of the whole group (not only the page), for its "select all" checkbox.
1218
+ keys: hasBulk ? await adapter.listKeys({ ...query, modifiers: [...(query.modifiers ?? []), (q) => grouping.group.scope(q, groupKey)] }) : [],
1219
+ // Header with nothing to announce: hidden from assistive technologies.
1220
+ ariaHidden: !(filled(groupTitle) || filled(label) || filled(description) || grouping.group.isCollapsible() || selectable),
1221
+ };
1222
+ }
1223
+ previousGroup = { key: groupKey, title: groupTitle };
1224
+ const cells = [];
1225
+ if (!groupsOnly)
1226
+ for (const column of visible)
1227
+ cells.push({ name: column.name, cssName: column.getCssName(), alignment: column.getAlignment(), label: column.getLabel(), url: column.getUrl(record), html: await column.toHtml(record, key), plain: column.isClickDisabled() });
1228
+ const recordAction = table.getRecordAction();
1229
+ if (reordering)
1230
+ for (const cell of cells)
1231
+ cell.url = null;
1232
+ let contentHtml = null;
1233
+ if (columnsLayout && !groupsOnly) {
1234
+ contentHtml = '';
1235
+ for (const node of columnsLayout)
1236
+ contentHtml += await renderColumnLayout(node, record, key, visibleSet);
1237
+ }
1238
+ rows.push({
1239
+ contentHtml, summaryBefore, groupSummaryHtml: '',
1240
+ groupHeader, groupKey, collapsedExpression: grouping?.group.isCollapsible() ? `isGroupCollapsed(${js(groupTitle)})` : null,
1241
+ reorder: reordering ? { id: `${host.id}-table-reorder-${createHash('md5').update(key).digest('hex')}`, label: String(record[table.getColumns()[0]?.name ?? ''] ?? key), position: rows.length + 1 } : null,
1242
+ wasClickable: reordering && (recordAction !== null || table.getRecordUrl(record) !== null),
1243
+ key, url: reordering ? null : recordAction ? null : table.getRecordUrl(record), cells, actions: groupsOnly ? [] : ((await options.rowActions?.(record)) ?? []),
1244
+ actionsHtml: options.rowActionsHtml && !groupsOnly ? await options.rowActionsHtml(record) : null, classes: table.getRecordClasses(record),
1245
+ // Row click: the action is mounted with the record key.
1246
+ mountExpression: reordering ? null : recordAction ? `mountAction(${js(recordAction)}, {}, ${js({ table: true, recordKey: key })})` : null,
1247
+ });
1248
+ }
1249
+ const hasRowActions = rows.some((row) => row.actionsHtml !== null || row.actions.length > 0);
1250
+ // Summaries: never while reordering; group summaries go before the next group's header.
1251
+ const summary = reordering ? null : await summarize(table, host, adapter, visible, page, { group: grouping?.group ?? null, groupsOnly, placeholders: { selection: selectable, actions: hasRowActions } });
1252
+ let trailingGroupSummary = '';
1253
+ if (summary && grouping) {
1254
+ for (const row of rows)
1255
+ if (row.summaryBefore)
1256
+ row.groupSummaryHtml = await summary.group(grouping.group, row.summaryBefore.key, row.summaryBefore.title);
1257
+ // Last group on the page: summarized only if it does not continue on the next page.
1258
+ let trailing = filled(previousGroup.title);
1259
+ if (trailing && !groupsOnly && page.page < page.lastPage) {
1260
+ const nextRecord = (await adapter.paginate({ ...getTableQuery(table, host), page: page.page * page.perPage + 1, perPage: 1 })).records[0];
1261
+ trailing = !nextRecord || grouping.group.getStringKey(nextRecord) !== previousGroup.key;
1262
+ }
1263
+ if (trailing)
1264
+ trailingGroupSummary = await summary.group(grouping.group, previousGroup.key, previousGroup.title);
1265
+ }
1266
+ const classes = (component, color) => colorManager?.getComponentClasses(component, color) ?? [];
1267
+ for (const filter of table.getFilters()) {
1268
+ const state = host.tableFilters?.[filter.name];
1269
+ if (filter.loadIndicatorData && state && typeof state === 'object')
1270
+ await filter.loadIndicatorData(state, host);
1271
+ }
1272
+ const scopes = host.getRenderHookScopes?.() ?? [];
1273
+ return views.render('filament-tables::index', {
1274
+ contentLayout: columnsLayout !== null,
1275
+ hook: (name, data = {}) => renderHook(name, scopes, data), hasHook: (name) => hasRenderHook(name, scopes),
1276
+ visibleColumns: visible, isReordering: reordering,
1277
+ groups: reordering || table.areGroupingSettingsHidden() ? [] : table.getGroups().map((g) => ({ id: g.getId(), label: g.getLabel() })),
1278
+ grouping: { directionHidden: table.isGroupingDirectionSettingHidden(), inDropdownOnDesktop: table.areGroupingSettingsInDropdownOnDesktop(), collapsedByDefault: table.areGroupsCollapsedByDefault(), groupsOnly },
1279
+ id: host.id, targets: LOADING_TARGETS, table, rows, page, number,
1280
+ reorder: reorderColumn !== null ? { active: reordering } : null,
1281
+ // No checkboxes without bulk actions (or in groups-only mode), no actions column without row actions.
1282
+ selectable, hasRowActions,
1283
+ // The key changes on every render to force the checkbox to be replaced.
1284
+ randomKey: () => Array.from(randomBytes(16), (b) => 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'[b % 62]).join(''),
1285
+ // "Select all" covers every record of the current query.
1286
+ allCount: table.getPaginationMode() === 'simple' ? '' : page.total, resultsCount: table.getPaginationMode() === 'simple' ? rows.length : page.total,
1287
+ columns: visible.map((c) => ({ name: c.name, grouped: c._group !== null, cssName: c.getCssName(), alignment: c.getAlignment(), label: c.getLabel(), sortable: !reordering && c.isSortable(), individuallySearchable: !reordering && c.isIndividuallySearchable(), sort: sortColumn === c.name ? (sortDirection === 'desc' ? 'descending' : 'ascending') : 'none' })),
1288
+ linkClasses: classes('link', 'primary'), dangerLinkClasses: classes('link', 'danger'), primaryButtonClasses: classes('button', 'primary'), badgeClasses: classes('badge', 'primary'), indicatorBadgeClasses: classes('badge', 'primary'),
1289
+ bulkActions: options.bulkActions ?? [],
1290
+ filtersForm: options.filtersForm ?? null, activeFiltersCount: countActiveFilters(table, host), filtersLayout: table.getFiltersLayout(), filterIndicators: getFilterIndicators(table, host),
1291
+ hasColumnManager: !reordering && table.hasToggleableColumns(),
1292
+ afterContainer: options.after ?? '', heading: table.getHeading(), headerActions: options.headerActions ?? [], summaryHtml: summary?.html ?? '', trailingGroupSummary,
1293
+ // Column group header row: one cell per ungrouped column, one merged cell per run of columns in the same group.
1294
+ headerGroups: visible.some((c) => c._group !== null) ? visible.reduce((runs, c) => {
1295
+ const last = runs[runs.length - 1];
1296
+ if (c._group !== null && last && last.label === c._group)
1297
+ last.span++;
1298
+ else
1299
+ runs.push({ label: c._group, span: 1 });
1300
+ return runs;
1301
+ }, []) : null,
1302
+ isSearchable: !reordering && table.getColumns().some((c) => c.isGloballySearchable()),
1303
+ pagination: reordering || groupsOnly ? '' : views.render('filament::components.pagination', {
1304
+ id: host.id, targets: LOADING_TARGETS + ',paginators', page, number, pageOptions: table.getPageOptions(), elements: paginationElements(page.page, page.lastPage), simple: table.getPaginationMode() === 'simple',
1305
+ attrs: (o) => new Attributes(o),
1306
+ }),
1307
+ });
1308
+ }