@navette-js/tables 1.0.0-beta.3 → 1.0.0-beta.5

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 CHANGED
@@ -1,5 +1,8 @@
1
- import { Attributes, cls, e, hasRenderHook, icon, js, renderHook, views, configured, configureUsing } from '@navette-js/support';
2
- import { t } from '@navette-js/support/i18n';
1
+ import { Attributes, cls, e, hasRenderHook, icon, js, markdownToHtml, renderHook, sanitizeHtml, views, configured, configureUsing } from '@navette-js/support';
2
+ import { diffForHumans, t, tc } from '@navette-js/support/i18n';
3
+ import { Checkbox, Select, Toggle } from '@navette-js/forms';
4
+ import { modal } from '@navette-js/support/ui';
5
+ import { applyConditions, loadRelationshipOptions, modelOfHost } from "./query-builder.js";
3
6
  import { createHash, randomBytes } from 'node:crypto';
4
7
  import { fileURLToPath } from 'node:url';
5
8
  views.namespace('filament-tables', fileURLToPath(new URL('../views', import.meta.url)));
@@ -31,6 +34,8 @@ export const TablesRenderHook = {
31
34
  TOOLBAR_TOGGLE_COLUMN_TRIGGER_BEFORE: 'tables::toolbar.toggle-column-trigger.before',
32
35
  };
33
36
  /** Methods and properties that trigger the table's loading state (`wire:target`). */
37
+ /** Screen size names of `visibleFrom()` and `hiddenFrom()`. */
38
+ const BREAKPOINTS = ['sm', 'md', 'lg', 'xl', '2xl'];
34
39
  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
40
  const headline = (s) => s.replace(/[_.-]+/g, ' ').replace(/^./, (c) => c.toUpperCase());
36
41
  // ------------------------------------------------------------------ columns
@@ -62,6 +67,14 @@ export class Column {
62
67
  isSortInverted() { return this._sortInverted; }
63
68
  /** Link on the cell; takes precedence over the row action. */
64
69
  url(resolver) { this._url = resolver; return this; }
70
+ _cellAction = null;
71
+ /**
72
+ * Clicking the cell runs an action on its record: the name of a record action of the table, or an `Action` (checked and
73
+ * authorized like record actions when it is mounted). A column `url()` takes precedence; the action takes precedence over the row's link and action.
74
+ */
75
+ action(action) { this._cellAction = action; return this; }
76
+ getAction() { return this._cellAction !== null && typeof this._cellAction === 'object' ? this._cellAction : null; }
77
+ getActionName() { return this._cellAction === null ? null : typeof this._cellAction === 'string' ? this._cellAction : this._cellAction.getName(); }
65
78
  getUrl(record) { return this._url ? this._url(record) : null; }
66
79
  /** `isGlobal: false` excludes the column from the global search; `isIndividual` adds a search field for this column. */
67
80
  getSearchColumn() { return this._searchColumn ?? this.name; }
@@ -74,10 +87,42 @@ export class Column {
74
87
  /** Lets the user hide the column from the column manager. */
75
88
  toggleable(condition = true, options = {}) { this._toggleable = condition; this._toggledHiddenByDefault = options.isToggledHiddenByDefault === true; return this; }
76
89
  alignment(alignment) { this._alignment = alignment; return this; }
90
+ _visibleFrom = null;
91
+ _hiddenFrom = null;
92
+ _columnWidth = null;
93
+ _grow = null;
94
+ _wrapHeader = false;
95
+ /** Shows the column only from this screen size up (`sm`, `md`, `lg`, `xl`, `2xl`). */
96
+ visibleFrom(breakpoint) { this._visibleFrom = breakpoint !== null && BREAKPOINTS.includes(breakpoint) ? breakpoint : null; return this; }
97
+ /** Hides the column from this screen size up. */
98
+ hiddenFrom(breakpoint) { this._hiddenFrom = breakpoint !== null && BREAKPOINTS.includes(breakpoint) ? breakpoint : null; return this; }
99
+ /** Classes hiding the header and cells on some screen sizes. */
100
+ getResponsiveClasses() { return [this._hiddenFrom ? `${this._hiddenFrom}:fi-hidden` : null, this._visibleFrom ? `${this._visibleFrom}:fi-visible` : null].filter((c) => c !== null); }
101
+ /** Width of the column, as a CSS length (`200px`, `20%`...). */
102
+ width(width) { const text = typeof width === 'number' ? `${width}px` : width; this._columnWidth = text !== null && /^[0-9.]+(px|rem|em|%|ch|vw)$/.test(text.trim()) ? text.trim() : null; return this; }
103
+ getWidth() { return this._columnWidth; }
104
+ /** The column takes the remaining width of the table (when it has no `width()`). */
105
+ grow(condition = true) { this._grow = condition; return this; }
106
+ /** In a table, columns do not grow unless told to; in a layout (split), they do. */
107
+ canGrow(defaultValue = false) { return this._grow ?? defaultValue; }
108
+ /** Lets a long label wrap in the header. */
109
+ wrapHeader(condition = true) { this._wrapHeader = condition; return this; }
110
+ canHeaderWrap() { return this._wrapHeader; }
77
111
  alignEnd() { return this.alignment('end'); }
78
112
  alignLeft() { return this.alignment('left'); }
79
113
  alignCenter() { return this.alignment('center'); }
80
114
  getStateUsing(resolver) { this._state = resolver; return this; }
115
+ /** Fixed state, or a function of the record (alias of `getStateUsing()` for a value). */
116
+ state(state) { this._state = typeof state === 'function' ? ({ record }) => state(record) : () => state; return this; }
117
+ _default = null;
118
+ /** Value used when the state is empty. */
119
+ default(value) { this._default = value; return this; }
120
+ _hidden = false;
121
+ _visible = true;
122
+ /** Removes the column from the table (header, cells, search, sort and column manager). */
123
+ hidden(condition = true) { this._hidden = condition; return this; }
124
+ visible(condition = true) { this._visible = condition; return this; }
125
+ isHidden() { return Boolean(typeof this._hidden === 'function' ? this._hidden() : this._hidden) || !(typeof this._visible === 'function' ? this._visible() : this._visible); }
81
126
  getLabel() { return this._label ?? headline(this.name.includes('.') ? this.name.split('.').slice(-2, -1)[0] : this.name); }
82
127
  isSortable() { return this._sortable; }
83
128
  isGloballySearchable() { return this._searchable; }
@@ -87,141 +132,408 @@ export class Column {
87
132
  getAlignment() { return this._alignment; }
88
133
  /** Suffix of the `fi-ta-header-cell-…` / `fi-ta-cell-…` classes: underscores become dashes, dots are kept. */
89
134
  getCssName() { return this.name.replace(/_/g, '-'); }
90
- async getState(record) { return this._state ? this._state({ record }) : record[this.name]; }
135
+ async getState(record) {
136
+ const state = this._state ? await this._state({ record }) : record[this.name];
137
+ return (state === null || state === undefined || state === '') && this._default !== null ? this._default : state;
138
+ }
91
139
  /** True for editable cells: they are not wrapped in a link or button to the record. */
92
140
  isClickDisabled() { return false; }
93
141
  }
142
+ const isNumericValue = (value) => (typeof value === 'number' && Number.isFinite(value)) || (typeof value === 'string' && value.trim() !== '' && Number.isFinite(Number(value)));
143
+ const resolveItem = (option, record, state) => (typeof option === 'function' ? option(record, state) : option);
144
+ const isBlankState = (value) => value === null || value === undefined || (typeof value === 'string' && value.trim() === '') || (Array.isArray(value) && value.length === 0);
145
+ /** Date in a PHP-style format (`M j, Y`, `d/m/Y H:i`…), in UTC or in the given IANA time zone. Database values without a zone are read as UTC. */
146
+ export function formatDate(value, format, timeZone = null) {
147
+ const raw = value instanceof Date ? value : new Date(/[zZ]|[+-]\d\d:?\d\d$/.test(String(value)) ? String(value) : `${String(value).replace(' ', 'T').slice(0, 19)}Z`);
148
+ if (Number.isNaN(raw.getTime()))
149
+ return String(value);
150
+ const parts = Object.fromEntries(new Intl.DateTimeFormat('en-US', { timeZone: timeZone ?? 'UTC', year: 'numeric', month: 'numeric', day: 'numeric', hour: 'numeric', minute: 'numeric', second: 'numeric', hourCycle: 'h23', weekday: 'long' }).formatToParts(raw).map((p) => [p.type, p.value]));
151
+ const year = Number(parts.year), month = Number(parts.month), day = Number(parts.day), hour = Number(parts.hour), minute = Number(parts.minute), second = Number(parts.second);
152
+ const monthName = new Intl.DateTimeFormat('en-US', { month: 'long', timeZone: 'UTC' }).format(new Date(Date.UTC(2000, month - 1, 1)));
153
+ const pad = (n) => String(n).padStart(2, '0');
154
+ const zone = timeZone ? (new Intl.DateTimeFormat('en-US', { timeZone, timeZoneName: 'short' }).formatToParts(raw).find((p) => p.type === 'timeZoneName')?.value ?? timeZone) : 'UTC';
155
+ const tokens = {
156
+ d: () => pad(day), j: () => String(day), D: () => parts.weekday.slice(0, 3), l: () => parts.weekday,
157
+ S: () => (day % 10 === 1 && day !== 11 ? 'st' : day % 10 === 2 && day !== 12 ? 'nd' : day % 10 === 3 && day !== 13 ? 'rd' : 'th'),
158
+ m: () => pad(month), n: () => String(month), M: () => monthName.slice(0, 3), F: () => monthName,
159
+ Y: () => String(year), y: () => String(year).slice(-2),
160
+ H: () => pad(hour), G: () => String(hour), h: () => pad(hour % 12 || 12), g: () => String(hour % 12 || 12),
161
+ i: () => pad(minute), s: () => pad(second), a: () => (hour < 12 ? 'am' : 'pm'), A: () => (hour < 12 ? 'AM' : 'PM'), T: () => zone,
162
+ };
163
+ let out = '';
164
+ for (let index = 0; index < format.length; index++) {
165
+ const character = format[index];
166
+ if (character === '\\' && index + 1 < format.length) {
167
+ out += format[++index];
168
+ continue;
169
+ }
170
+ out += tokens[character] ? tokens[character]() : character;
171
+ }
172
+ return out;
173
+ }
174
+ const TEXT_SIZES = new Set(['xs', 'sm', 'md', 'lg']);
175
+ const tooltipAttribute = (tooltip) => `{
176
+ content: ${js(tooltip)},
177
+ theme: $store.theme,
178
+ allowHTML: false,
179
+ }`;
94
180
  export class TextColumn extends Column {
95
181
  _weight = null;
96
182
  _copyable = false;
183
+ _copyableState = null;
184
+ _copyMessage = null;
185
+ _copyMessageDuration = 2000;
97
186
  _money = null;
98
187
  _placeholder = null;
99
188
  _badge = false;
100
189
  _color = null;
101
190
  _icon = null;
191
+ _iconColor = null;
192
+ _iconPosition = 'before';
102
193
  _format = null;
103
- _date = false;
194
+ _date = null;
195
+ _timeZone = null;
196
+ _since = false;
104
197
  _numeric = false;
198
+ _decimals = null;
105
199
  _summarizers = [];
106
200
  _limit = null;
201
+ _words = null;
202
+ _prefix = null;
203
+ _suffix = null;
107
204
  _tooltip = null;
205
+ _size = null;
206
+ _fontFamily = null;
207
+ _wrap = false;
208
+ _lineClamp = null;
209
+ _inline = false;
210
+ _descriptionAbove = null;
211
+ _descriptionBelow = null;
212
+ _listWithLineBreaks = false;
213
+ _bulleted = false;
214
+ _listLimit = null;
215
+ _expandableLimitedList = false;
216
+ _separator = null;
108
217
  static make(name) { return configured(new TextColumn(name)); }
109
218
  /** Displays the value in a badge. */
110
219
  badge(condition = true) { this._badge = condition; return this; }
220
+ /** Text color (or badge color): a color name, or a function of the record and the state item. */
111
221
  color(color) { this._color = color; return this; }
112
- icon(resolver) { this._icon = resolver; return this; }
222
+ /** Icon next to the text: a name, or a function of the record and the state item returning a name or `null`. */
223
+ icon(icon) { this._icon = icon; return this; }
224
+ iconColor(color) { this._iconColor = color; return this; }
225
+ iconPosition(position) { this._iconPosition = position; return this; }
113
226
  /** Text displayed instead of the raw value (e.g. an enum label). */
114
227
  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. */
228
+ /** Date in a PHP-style format, `M j, Y` by default ("Nov 9, 2026"). `timeZone`: IANA zone to display the date in. */
229
+ date(format = 'M j, Y', timeZone = null) { this._date = format === false ? null : format === true ? 'M j, Y' : format; this._timeZone = timeZone; return this; }
230
+ /** Date and time, `M j, Y H:i:s` by default. */
231
+ dateTime(format = 'M j, Y H:i:s', timeZone = null) { return this.date(format === true ? 'M j, Y H:i:s' : format, timeZone); }
232
+ /** Time only, `H:i:s` by default. */
233
+ time(format = 'H:i:s', timeZone = null) { return this.date(format, timeZone); }
234
+ /** Relative date, e.g. "2 days ago". */
235
+ since(condition = true) { this._since = condition; return this; }
236
+ /** Time zone used by `date()`, `dateTime()` and `time()`. */
237
+ timezone(timeZone) { this._timeZone = timeZone; return this; }
238
+ /** Number formatted with thousands separators; `numeric(1)`: fixed number of decimals. */
123
239
  numeric(condition = true) { this._numeric = condition !== false; this._decimals = typeof condition === 'number' ? condition : null; return this; }
124
240
  /** Summary row below the table. */
125
241
  summarize(summarizer) { this._summarizers = Array.isArray(summarizer) ? summarizer : [summarizer]; return this; }
126
242
  getSummarizers() { return this._summarizers; }
243
+ /** Font weight: `thin`, `light`, `medium`, `semibold`, `bold`… */
127
244
  weight(weight) { this._weight = weight; return this; }
245
+ /** Text size: `xs`, `sm` (default), `md` or `lg`. */
246
+ size(size) { this._size = size; return this; }
247
+ /** Font family: `sans`, `serif` or `mono`. */
248
+ fontFamily(family) { this._fontFamily = family; return this; }
249
+ /** Lets long text wrap onto several lines instead of overflowing. */
250
+ wrap(condition = true) { this._wrap = condition; return this; }
251
+ /** Clamps the text to this number of lines. */
252
+ lineClamp(lines) { this._lineClamp = lines; return this; }
253
+ /** Renders inline (without a block layout), e.g. inside a stacked layout. */
254
+ inline(condition = true) { this._inline = condition; return this; }
128
255
  /** Truncates the displayed text to `length` characters, followed by "...". */
129
256
  limit(length) { this._limit = length; return this; }
257
+ /** Truncates the displayed text to this number of words, followed by "...". */
258
+ words(count) { this._words = count; return this; }
259
+ prefix(prefix) { this._prefix = prefix; return this; }
260
+ suffix(suffix) { this._suffix = suffix; return this; }
130
261
  /** Tooltip on hover; `null` for none. */
131
262
  tooltip(tooltip) { this._tooltip = tooltip; return this; }
132
263
  copyable(condition = true) { this._copyable = condition; return this; }
264
+ /** Value copied to the clipboard, when it differs from the displayed text. */
265
+ copyableState(state) { this._copyableState = state; return this; }
266
+ copyMessage(message) { this._copyMessage = message; return this; }
267
+ copyMessageDuration(milliseconds) { this._copyMessageDuration = milliseconds; return this; }
133
268
  /** Amount formatted in the given currency, e.g. `money('usd')`. */
134
269
  money(currency = 'usd') { this._money = currency; return this; }
135
270
  /** Text displayed when the value is empty. */
136
271
  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. */
272
+ /** Secondary text below the value (`'below'`, the default) or above it. */
273
+ description(text, position = 'below') {
274
+ if (position === 'above')
275
+ this._descriptionAbove = text;
276
+ else
277
+ this._descriptionBelow = text;
278
+ return this;
279
+ }
280
+ /** One value per line, for a state that is a list (or a string split by `separator()`). */
281
+ listWithLineBreaks(condition = true) { this._listWithLineBreaks = condition; return this; }
282
+ /** Bulleted list. */
283
+ bulleted(condition = true) { this._bulleted = condition; return this; }
284
+ /** Shows only the first values of a list; `expandable`: a link reveals the others. */
285
+ limitList(count, expandable = false) { this._listLimit = count; this._expandableLimitedList = expandable; return this; }
286
+ expandableLimitedList(condition = true) { this._expandableLimitedList = condition; return this; }
287
+ /** Splits a string state into a list on this separator. */
288
+ separator(separator = ',') { this._separator = separator; return this; }
289
+ _html = false;
290
+ _markdown = false;
291
+ /**
292
+ * Renders the state as HTML. It is sanitized first: only formatting elements (paragraphs, emphasis, lists, links with safe
293
+ * URLs...) are kept, without any attribute that could run scripts or change the page's styles.
294
+ */
295
+ html(condition = true) { this._html = condition; return this; }
296
+ /** Renders the state as Markdown. Raw HTML in it is shown as text, and links only keep safe URLs. */
297
+ markdown(condition = true) { this._markdown = condition; return this; }
298
+ /** Item text as HTML: Markdown, sanitized HTML, or escaped text. */
299
+ renderItemText(text, record, item) {
300
+ if (resolveItem(this._markdown, record, item))
301
+ return markdownToHtml(text);
302
+ if (resolveItem(this._html, record, item))
303
+ return sanitizeHtml(text);
304
+ return e(text);
305
+ }
306
+ /** Displayed text of one state item, escaped by the caller. */
307
+ formatItem(item, record) {
308
+ if (this._format)
309
+ return String(this._format({ state: item, record }) ?? '');
310
+ let text;
311
+ const currency = typeof this._money === 'function' ? this._money(record) : this._money;
312
+ if (this._since)
313
+ text = diffForHumans(item) ?? String(item);
314
+ else if (this._date !== null)
315
+ text = formatDate(item, this._date, this._timeZone);
316
+ else if (currency && isNumericValue(item))
317
+ text = new Intl.NumberFormat('en-US', { style: 'currency', currency: currency.toUpperCase() }).format(Number(item));
318
+ else if (this._numeric && isNumericValue(item))
319
+ text = new Intl.NumberFormat('en-US', this._decimals !== null ? { minimumFractionDigits: this._decimals, maximumFractionDigits: this._decimals } : {}).format(Number(item));
320
+ else
321
+ text = String(item);
322
+ if (this._limit !== null && text.length > this._limit)
323
+ text = `${text.slice(0, this._limit).trimEnd()}...`;
324
+ if (this._words !== null) {
325
+ const words = text.split(/\s+/);
326
+ if (words.length > this._words)
327
+ text = `${words.slice(0, this._words).join(' ')}...`;
328
+ }
329
+ return `${this._prefix ?? ''}${text}${this._suffix ?? ''}`;
330
+ }
331
+ stateItems(state) {
332
+ if (Array.isArray(state))
333
+ return state.filter((item) => !isBlankState(item));
334
+ if (this._separator !== null && typeof state === 'string')
335
+ return state.split(this._separator).map((item) => item.trim()).filter(Boolean);
336
+ return [state];
337
+ }
338
+ hasDescriptions() { return this._descriptionAbove !== null || this._descriptionBelow !== null; }
339
+ /** Plain value without per-item options: Filament's short markup. */
340
+ canRenderOptimized(state) {
341
+ return !Array.isArray(state) && this._separator === null && !isBlankState(state) && !this._bulleted && !this._listWithLineBreaks && this._icon === null && this._tooltip === null
342
+ && this._copyable === false && this._weight === null && this._fontFamily === null && this._lineClamp === null && this._size === null && !this.hasDescriptions() && !this._wrap && this._html === false && this._markdown === false;
343
+ }
344
+ alignClass() { return this._alignment && this._alignment !== 'start' ? `fi-align-${this._alignment}` : null; }
345
+ containerClasses() { return ['fi-ta-text', this._inline ? 'fi-inline' : null, this._numeric || this._money ? 'fi-numeric' : null, this.alignClass()]; }
346
+ /** Cell content, as Filament renders it: a short markup for a plain value, the full markup as soon as an item option is set. */
138
347
  async toHtml(record) {
139
348
  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 });
349
+ if (isBlankState(state)) {
350
+ return `<div class="${cls(this.containerClasses())}">${this._placeholder !== null ? `<p class="fi-ta-placeholder">${e(this._placeholder)}</p>` : ''}</div>`;
351
+ }
352
+ if (this.canRenderOptimized(state)) {
353
+ let html = e(this.formatItem(state, record));
354
+ const color = resolveItem(this._color, record, state);
152
355
  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>`;
356
+ const badgeColor = color ?? 'primary';
357
+ html = `<span class="${cls(['fi-badge', 'fi-size-sm', ...(badgeColor !== 'gray' ? (colorManager?.getComponentClasses('badge', badgeColor) ?? []) : [])])}">${html}</span>`;
158
358
  }
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>`;
359
+ const classes = this._badge ? ['fi-ta-text', 'fi-ta-text-item', 'fi-ta-text-has-badges'] : ['fi-ta-text', 'fi-ta-text-item', 'fi-size-sm', ...(color ? (colorManager?.getComponentClasses('text', color) ?? []) : [])];
360
+ return `<div class="${cls([...classes, this._inline ? 'fi-inline' : null, this._numeric || this._money ? 'fi-numeric' : null, this.alignClass()])}">${html}</div>`;
161
361
  }
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>`;
362
+ let items = this.stateItems(state);
363
+ let count = items.length;
364
+ const limit = this._listLimit ?? count;
365
+ const overLimit = limit && count > limit ? count - limit : 0;
366
+ if (overLimit && (!this._listWithLineBreaks || !this._expandableLimitedList))
367
+ items = items.slice(0, limit);
368
+ // Several values without line breaks: one comma-separated item.
369
+ let collapsedHtml = null;
370
+ if (count > 1 && !this._listWithLineBreaks && !this._badge) {
371
+ collapsedHtml = items.map((item) => e(this.formatItem(item, record))).join(', ');
372
+ count = 1;
373
+ items = [items[0]];
374
+ }
375
+ const itemData = (item) => {
376
+ const formatted = collapsedHtml !== null ? collapsedHtml.replace(/<[^>]+>/g, '') : this.formatItem(item, record);
377
+ const color = resolveItem(this._color, record, item) ?? (this._badge ? 'primary' : null);
378
+ const sizeOption = resolveItem(this._size, record, item);
379
+ const size = sizeOption === 'base' ? 'md' : sizeOption ?? 'sm';
380
+ const iconName = this._icon === null ? null : resolveItem(this._icon, record, item);
381
+ const iconColor = resolveItem(this._iconColor, record, item);
382
+ const iconHtml = iconName ? icon(iconName, { size: size === 'md' ? 'md' : size === 'lg' ? 'lg' : 'sm', attributes: new Attributes({ 'aria-hidden': 'true' }).class(iconColor ? (colorManager?.getComponentClasses('icon-column', iconColor) ?? []) : []) }) : '';
383
+ const copyable = resolveItem(this._copyable, record, item);
384
+ const tooltip = resolveItem(this._tooltip, record, item);
385
+ const fontFamily = resolveItem(this._fontFamily, record, item);
386
+ const weight = resolveItem(this._weight, record, item);
387
+ const itemClasses = ['fi-ta-text-item', fontFamily ? `fi-font-${fontFamily}` : null];
388
+ if (!this._badge)
389
+ itemClasses.push(TEXT_SIZES.has(size) ? `fi-size-${size}` : size, weight ? `fi-font-${weight}` : null, ...(color ? (colorManager?.getComponentClasses('text', color) ?? []) : []));
390
+ const itemStyle = !this._badge && this._lineClamp ? ` style="--line-clamp: ${Math.floor(this._lineClamp)}"` : '';
391
+ let content = null;
392
+ if (this._badge || copyable || (tooltip !== null && tooltip !== '')) {
393
+ const attributes = [];
394
+ if (copyable) {
395
+ const copied = resolveItem(this._copyableState, record, item) ?? formatted;
396
+ const message = this._copyMessage ?? t('filament::components/copyable.messages.copied');
397
+ attributes.push(...copyableAttributes(copied, formatted, message, this._copyMessageDuration));
398
+ }
399
+ if (tooltip !== null && tooltip !== '')
400
+ attributes.push(`x-tooltip="${e(tooltipAttribute(tooltip))}"`);
401
+ const contentClasses = [copyable ? 'fi-copyable' : null];
402
+ if (this._badge)
403
+ contentClasses.push('fi-badge', TEXT_SIZES.has(size) ? `fi-size-${size}` : size, ...(color && color !== 'gray' ? (colorManager?.getComponentClasses('badge', color) ?? []) : []));
404
+ const classString = cls(contentClasses);
405
+ content = `<span ${[...attributes, classString ? `class="${classString}"` : ''].filter(Boolean).join(' ')}>`;
406
+ }
407
+ const before = this._iconPosition === 'before' ? iconHtml : '', after = this._iconPosition === 'after' ? iconHtml : '';
408
+ const inner = `${content ?? ''}${before}${collapsedHtml ?? this.renderItemText(formatted, record, item)}${after}${content ? '</span>' : ''}`;
409
+ return { itemClasses, itemStyle, inner };
410
+ };
411
+ const containerExtra = [this._badge ? 'fi-ta-text-has-badges' : null, this._lineClamp && !this._badge ? 'fi-ta-text-has-line-clamp' : null, this._wrap ? 'fi-wrapped' : null];
412
+ const descriptionAbove = typeof this._descriptionAbove === 'function' ? this._descriptionAbove(record) : this._descriptionAbove;
413
+ const descriptionBelow = typeof this._descriptionBelow === 'function' ? this._descriptionBelow(record) : this._descriptionBelow;
414
+ const hasDescriptions = (descriptionAbove ?? '') !== '' || (descriptionBelow ?? '') !== '';
415
+ if (count === 1 && !this._bulleted && !hasDescriptions && !this._lineClamp) {
416
+ const { itemClasses, itemStyle, inner } = itemData(items[0]);
417
+ return `<div class="${cls([...itemClasses, ...this.containerClasses(), ...containerExtra])}"${itemStyle}>${inner}</div>`;
173
418
  }
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>`;
419
+ const container = [...this.containerClasses(), ...containerExtra, this._bulleted ? 'fi-bulleted' : null, this._listWithLineBreaks ? 'fi-ta-text-has-line-breaks' : null];
420
+ const listItems = (forList) => forList.map((item, index) => {
421
+ const { itemClasses, itemStyle, inner } = itemData(item);
422
+ const hidden = index + 1 > limit ? ' x-show="! isLimited" x-cloak x-transition' : '';
423
+ return `<li${hidden} class="${cls(itemClasses)}"${itemStyle}>${inner}</li>`;
424
+ }).join('');
425
+ if (hasDescriptions || overLimit) {
426
+ const expandable = overLimit && this._expandableLimitedList;
427
+ let body;
428
+ if (count === 1 && !this._bulleted) {
429
+ const { itemClasses, itemStyle, inner } = itemData(items[0]);
430
+ body = `<p class="${cls(itemClasses)}"${itemStyle}>${inner}</p>`;
431
+ }
432
+ else
433
+ body = `<ul>${listItems(items)}</ul>`;
434
+ let more = '';
435
+ if (overLimit) {
436
+ more = expandable
437
+ ? `<div class="fi-ta-text-list-limited-message"><div role="button" x-bind:aria-expanded="(! isLimited).toString()" x-on:click.prevent.stop="isLimited = false" x-show="isLimited" class="fi-link fi-size-xs">${e(tc('filament-tables::table.columns.text.actions.expand_list', overLimit, { count: overLimit }))}</div><div role="button" x-bind:aria-expanded="(! isLimited).toString()" x-on:click.prevent.stop="isLimited = true" x-cloak x-show="! isLimited" class="fi-link fi-size-xs">${e(tc('filament-tables::table.columns.text.actions.collapse_list', overLimit, { count: overLimit }))}</div></div>`
438
+ : `<div class="fi-ta-text-list-limited-message">${e(tc('filament-tables::table.columns.text.more_list_items', overLimit, { count: overLimit }))}</div>`;
439
+ }
440
+ return `<div${expandable ? ' x-data="{ isLimited: true }"' : ''} class="${cls([...container, hasDescriptions ? 'fi-ta-text-has-descriptions' : null, overLimit ? 'fi-ta-text-list-limited' : null])}">`
441
+ + `${(descriptionAbove ?? '') !== '' ? `<p class="fi-ta-text-description">${e(descriptionAbove)}</p>` : ''}${body}${more}`
442
+ + `${(descriptionBelow ?? '') !== '' ? `<p class="fi-ta-text-description">${e(descriptionBelow)}</p>` : ''}</div>`;
443
+ }
444
+ return `<ul class="${cls(container)}">${listItems(items)}</ul>`;
194
445
  }
195
446
  }
196
- /** Column summary below the table: sum, average, count or range. */
447
+ /**
448
+ * Column summary below the table: sum, average, count, range, distinct values, or your own with `Summarizer.make().using()`.
449
+ */
197
450
  export class Summarizer {
451
+ kind = 'custom';
198
452
  _label = null;
199
453
  _money = null;
454
+ _decimals = null;
455
+ _prefix = null;
456
+ _suffix = null;
457
+ _formatStateUsing = null;
458
+ _labelHidden = false;
459
+ _hidden = false;
460
+ _using = null;
461
+ _scope = null;
462
+ /**
463
+ * Restricts the records summarized by this summarizer (all records, the page, or a group), e.g.
464
+ * `Count.make().query((query) => query.where('is_published', 1))`. The function receives the data adapter's query builder.
465
+ */
466
+ query(scope) { this._scope = scope; return this; }
467
+ getQueryScope() { return this._scope; }
468
+ static make() { return configured(new Summarizer()); }
200
469
  label(label) { this._label = label; return this; }
201
470
  money(currency = 'usd') { this._money = currency.toUpperCase(); return this; }
471
+ /** Number formatting, with a fixed number of decimals. */
472
+ numeric(decimals = null) { this._decimals = decimals; return this; }
473
+ prefix(prefix) { this._prefix = prefix; return this; }
474
+ suffix(suffix) { this._suffix = suffix; return this; }
475
+ /** Formats the value yourself (the result is escaped). */
476
+ formatStateUsing(callback) { this._formatStateUsing = callback; return this; }
477
+ /** Keeps the label for screen readers only. */
478
+ hiddenLabel(condition = true) { this._labelHidden = condition; return this; }
479
+ isLabelHidden() { return this._labelHidden; }
480
+ hidden(condition = true) { this._hidden = condition; return this; }
481
+ visible(condition = true) { return this.hidden(typeof condition === 'function' ? () => !condition() : !condition); }
482
+ isHidden() { return typeof this._hidden === 'function' ? this._hidden() : this._hidden; }
483
+ /** Computes the value from the summarized records (`records()` loads them: keep the dataset small). */
484
+ using(callback) { this._using = callback; return this; }
485
+ hasCustomCalculation() { return this._using !== null; }
486
+ async compute(dataset) { return this._using ? this._using(dataset) : null; }
202
487
  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); }
488
+ /** Formatted value: `formatStateUsing()`, money or number, then the prefix and suffix. */
489
+ format(value) {
490
+ let text;
491
+ if (this._formatStateUsing)
492
+ text = this._formatStateUsing(value) ?? '';
493
+ else if (typeof value === 'number' && Number.isFinite(value)) {
494
+ const digits = this._decimals !== null ? { minimumFractionDigits: this._decimals, maximumFractionDigits: this._decimals } : {};
495
+ text = this._money ? new Intl.NumberFormat('en-US', { style: 'currency', currency: this._money, ...digits }).format(value) : new Intl.NumberFormat('en-US', digits).format(value);
496
+ }
497
+ else
498
+ text = value === null || value === undefined ? '' : String(value);
499
+ return `${this._prefix ?? ''}${text}${this._suffix ?? ''}`;
500
+ }
501
+ item(value, type = 'text') { return { type, label: this.getLabel(), value: this.format(value), labelHidden: this._labelHidden }; }
502
+ /** Summary item from the aggregates (or from `customValue`, computed by `using()`). */
503
+ summarize(_column, _values, customValue) { return this.item(customValue ?? null); }
204
504
  }
205
505
  /** Column total. */
206
506
  export class Sum extends Summarizer {
207
507
  kind = 'sum';
208
508
  static make() { return configured(new Sum()); }
209
509
  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) }; }
510
+ summarize(column, values, customValue) { return this.item(customValue ?? values.sum[column] ?? 0); }
211
511
  }
212
512
  /** Column average. */
213
513
  export class Average extends Summarizer {
214
514
  kind = 'avg';
215
515
  static make() { return configured(new Average()); }
216
516
  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) }; }
517
+ summarize(column, values, customValue) { return this.item(customValue ?? values.avg[column] ?? 0); }
218
518
  }
219
519
  /** Record count. */
220
520
  export class Count extends Summarizer {
221
521
  kind = 'count';
222
522
  static make() { return configured(new Count()); }
223
523
  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) }; }
524
+ summarize(_column, values, customValue) { return this.item(customValue ?? values.count); }
525
+ }
526
+ /** Distinct values of the column, as a (bulleted) list. */
527
+ export class Values extends Summarizer {
528
+ kind = 'values';
529
+ _bulleted = true;
530
+ static make() { return configured(new Values()); }
531
+ bulleted(condition = true) { this._bulleted = condition; return this; }
532
+ constructor() { super(); this._using = async ({ column, records }) => [...new Set((await records()).map((record) => record[column]).filter((value) => value !== null && value !== undefined && value !== ''))]; }
533
+ summarize(_column, _values, customValue) {
534
+ const list = Array.isArray(customValue) ? customValue : [];
535
+ return { type: 'values', label: this.getLabel(), value: '', labelHidden: this._labelHidden, values: list.map((value) => this.format(value)), bulleted: this._bulleted };
536
+ }
225
537
  }
226
538
  /** Smallest and largest value of a column. */
227
539
  export class Range extends Summarizer {
@@ -230,7 +542,7 @@ export class Range extends Summarizer {
230
542
  static make() { return configured(new Range()); }
231
543
  /** Dates: day only when the bounds fall on different days, day and time otherwise; a single value when both are equal. */
232
544
  minimalDateTimeDifference(condition = true) { this._minimalDateTime = condition; return this; }
233
- summarize(column, values) {
545
+ summarize(column, values, _customValue) {
234
546
  let bounds = [values.min[column], values.max[column]].filter((v) => v !== null && v !== undefined && v !== '').map(String);
235
547
  if (!bounds.length)
236
548
  return null;
@@ -245,7 +557,7 @@ export class Range extends Summarizer {
245
557
  bounds = times[0] === times[1] ? [times[0]] : times;
246
558
  }
247
559
  }
248
- return { type: 'range', label: this._label, value: bounds.join(' - ') };
560
+ return { type: 'range', label: this._label, value: `${this._prefix ?? ''}${bounds.join(' - ')}${this._suffix ?? ''}`, labelHidden: this._labelHidden };
249
561
  }
250
562
  }
251
563
  /** Row grouping: rows are sorted by the column and each group gets a header row. */
@@ -258,7 +570,17 @@ export class Group {
258
570
  _collapsible = false;
259
571
  _date = false;
260
572
  _titlePrefixedWithLabel = true;
573
+ _keyUsing = null;
574
+ _scopeUsing = null;
575
+ _orderUsing = null;
261
576
  constructor(id) { this.id = id; }
577
+ /** Group key of a record, when it is not the column value (records with the same key form a group). Use with `scopeQueryByKeyUsing()`. */
578
+ getKeyFromRecordUsing(resolver) { this._keyUsing = resolver; return this; }
579
+ /** Restricts the query to one group (summaries, "select the group"): needed when `getKeyFromRecordUsing()` is not the column value. */
580
+ scopeQueryByKeyUsing(scope) { this._scopeUsing = scope; return this; }
581
+ /** Orders the query by group, when ordering by the column is not right (e.g. a status with a custom order). */
582
+ orderQueryUsing(order) { this._orderUsing = order; return this; }
583
+ getOrderQueryUsing() { return this._orderUsing; }
262
584
  static make(id) { return configured(new Group(id)); }
263
585
  label(label) { this._label = label; return this; }
264
586
  /** Query column holding the group value, when it differs from the id (relationship). */
@@ -287,6 +609,10 @@ export class Group {
287
609
  isTitlePrefixedWithLabel() { return this._titlePrefixedWithLabel; }
288
610
  /** Group value of a record, as text; `null` when empty. */
289
611
  getStringKey(record) {
612
+ if (this._keyUsing) {
613
+ const custom = this._keyUsing(record);
614
+ return custom === null || custom === undefined || custom === '' ? null : String(custom);
615
+ }
290
616
  let key = record?.[this.getColumn()];
291
617
  if (key === null || key === undefined || key === '')
292
618
  return null;
@@ -305,6 +631,10 @@ export class Group {
305
631
  getDescription(record) { return this._description ? this._description(record) : null; }
306
632
  /** Restricts a query to the records of the group with the given key. */
307
633
  scope(query, key) {
634
+ if (this._scopeUsing) {
635
+ this._scopeUsing(query, key);
636
+ return;
637
+ }
308
638
  if (key === null)
309
639
  query.where((sub) => sub.whereNull(this.getColumn()).orWhere(this.getColumn(), ''));
310
640
  else if (this._date)
@@ -319,15 +649,46 @@ export class Group {
319
649
  */
320
650
  export class ColumnLayout {
321
651
  _children;
652
+ _grow = null;
653
+ _visibleFrom = null;
654
+ _hiddenFrom = null;
655
+ _columnSpan = { default: 1 };
656
+ _collapsible = false;
657
+ // Collapsed until the user opens it, as in Filament.
658
+ _collapsed = true;
659
+ _hidden = false;
322
660
  constructor(children) { this._children = children; }
323
661
  getChildren() { return this._children; }
324
662
  /** Leaf columns, in order: the ones the table sorts, searches and toggles. */
325
663
  getColumns() { return this._children.flatMap((child) => (child instanceof ColumnLayout ? child.getColumns() : [child])); }
664
+ /** `false`: the component takes only the width it needs in a split, instead of sharing the remaining space. */
665
+ grow(condition = true) { this._grow = condition; return this; }
666
+ canGrow() { return this._grow ?? true; }
667
+ visibleFrom(breakpoint) { this._visibleFrom = breakpoint !== null && BREAKPOINTS.includes(breakpoint) ? breakpoint : null; return this; }
668
+ hiddenFrom(breakpoint) { this._hiddenFrom = breakpoint !== null && BREAKPOINTS.includes(breakpoint) ? breakpoint : null; return this; }
669
+ getResponsiveClasses() { return [this._hiddenFrom ? `${this._hiddenFrom}:fi-hidden` : null, this._visibleFrom ? `${this._visibleFrom}:fi-visible` : null].filter((c) => c !== null); }
670
+ /** Columns of a parent `Grid` taken by the component: a number, `'full'`, or one per breakpoint. */
671
+ columnSpan(span) { this._columnSpan = typeof span === 'object' ? { default: 1, ...span } : { default: span }; return this; }
672
+ columnSpanFull() { return this.columnSpan('full'); }
673
+ getColumnSpan() { return this._columnSpan; }
674
+ /**
675
+ * Content hidden behind a toggle button at the bottom of the record. Only one top-level component of the layout can
676
+ * be collapsible. It starts collapsed; `collapsed(false)` shows it open.
677
+ */
678
+ collapsible(condition = true) { this._collapsible = condition; return this; }
679
+ collapsed(condition = true) { this._collapsible = true; this._collapsed = condition; return this; }
680
+ isCollapsible() { return this._collapsible; }
681
+ isCollapsed() { return this._collapsed; }
682
+ hidden(condition = true) { this._hidden = condition; return this; }
683
+ visible(condition = true) { return this.hidden(typeof condition === 'function' ? () => !condition() : !condition); }
684
+ isHidden() { return typeof this._hidden === 'function' ? this._hidden() : this._hidden; }
685
+ /** Inner markup of the component, around the already rendered children. */
686
+ async renderContent(children, _record, _key) { return `<div class="${this.getClasses().join(' ')}">${children.join('')}</div>`; }
326
687
  }
327
688
  /** Columns side by side, from a given breakpoint. */
328
689
  export class Split extends ColumnLayout {
329
690
  _from = null;
330
- static make(children) { return new Split(children); }
691
+ static make(children) { return configured(new Split(children)); }
331
692
  from(breakpoint) { this._from = breakpoint; return this; }
332
693
  getClasses() { return ['fi-ta-split', ...(this._from ? [`${this._from}:fi-ta-split`] : [])]; }
333
694
  }
@@ -335,27 +696,76 @@ export class Split extends ColumnLayout {
335
696
  export class Stack extends ColumnLayout {
336
697
  _space = null;
337
698
  _alignment = 'start';
338
- static make(children) { return new Stack(children); }
699
+ static make(children) { return configured(new Stack(children)); }
339
700
  /** Gap between columns: 1 (small), 2 (medium) or 3 (large). */
340
701
  space(space = 1) { this._space = space; return this; }
341
702
  alignment(alignment) { this._alignment = alignment; return this; }
342
703
  getClasses() { return ['fi-ta-stack', `fi-align-${this._alignment}`, ...(this._space ? [`fi-gap-${['sm', 'md', 'lg'][this._space - 1]}`] : [])]; }
343
704
  }
344
- const layoutCell = (classes, html) => `<div class="${['fi-grid-col', 'fi-growable', ...classes].join(' ')}" style="--col-span-default: span 1 / span 1;">${html}</div>`;
705
+ /** Responsive grid of columns: `Grid.make(2)` (2 columns from `lg`) or `Grid.make({ default: 1, md: 2, xl: 3 })`. */
706
+ export class Grid extends ColumnLayout {
707
+ _gridColumns = {};
708
+ constructor(columns = 2) { super([]); this.columns(columns); }
709
+ static make(columns = 2) { return configured(new Grid(columns)); }
710
+ /** Number of columns, or one per breakpoint (\`default\`, \`sm\`, \`md\`, \`lg\`, \`xl\`, \`2xl\`). */
711
+ columns(columns = 2) {
712
+ const added = columns === null ? {} : typeof columns === 'number' ? { lg: columns } : columns;
713
+ this._gridColumns = { ...this._gridColumns, ...Object.fromEntries(Object.entries(added).filter(([, count]) => Number.isInteger(count) && count > 0 && count <= 12)) };
714
+ return this;
715
+ }
716
+ /** Components of the grid. */
717
+ schema(children) { this._children = children; return this; }
718
+ getGridColumns() { return this._gridColumns; }
719
+ getClasses() {
720
+ const columns = this._gridColumns;
721
+ const gap = (count) => (count === 1 ? 'fi-gap-sm' : 'fi-gap-lg');
722
+ return ['fi-ta-grid', gap(columns.default ?? 1), ...['sm', 'md', 'lg', 'xl', '2xl'].filter((breakpoint) => columns[breakpoint]).map((breakpoint) => `${breakpoint}:${gap(columns[breakpoint])}`)];
723
+ }
724
+ async renderContent(children) { return `<div ${new Attributes().grid(this._gridColumns).class(this.getClasses()).toHtml()}>${children.join('')}</div>`; }
725
+ }
726
+ /** Plain container, styled as a panel: often made collapsible. */
727
+ export class Panel extends ColumnLayout {
728
+ static make(children) { return configured(new Panel(children)); }
729
+ getClasses() { return ['fi-ta-panel']; }
730
+ }
731
+ /**
732
+ * Your own markup inside the record: a function returning HTML, or the name of an EJS view rendered with `record` and
733
+ * `recordKey`. The HTML is inserted as it is: escape record data with `e()` (EJS's `<%= %>` does it).
734
+ */
735
+ export class View extends ColumnLayout {
736
+ _view;
737
+ _viewData = {};
738
+ constructor(view) { super([]); this._view = view; }
739
+ static make(view) { return configured(new View(view)); }
740
+ /** Extra data passed to the EJS view. */
741
+ viewData(data) { this._viewData = data; return this; }
742
+ /** Columns rendered inside the view are not part of the table: the view has no children. */
743
+ components(children) { this._children = children; return this; }
744
+ getClasses() { return []; }
745
+ async renderContent(children, record, key) {
746
+ if (typeof this._view === 'function')
747
+ return this._view({ record, recordKey: key });
748
+ return views.render(this._view, { ...this._viewData, record, recordKey: key, getRecord: () => record, children: children.join('') });
749
+ }
750
+ }
345
751
  /** Renders a record through the layout, skipping columns the user has hidden. */
346
752
  async function renderColumnLayout(node, record, key, visible) {
347
753
  if (node instanceof ColumnLayout) {
754
+ if (node.isHidden())
755
+ return '';
348
756
  const children = [];
349
757
  for (const child of node.getChildren())
350
758
  children.push(await renderColumnLayout(child, record, key, visible));
351
- return layoutCell([], `<div class="${node.getClasses().join(' ')}">${children.join('')}</div>`);
759
+ const cell = new Attributes().gridColumn(node.getColumnSpan()).class([{ 'fi-growable': node.canGrow() }, ...node.getResponsiveClasses()]);
760
+ return `<div ${cell.toHtml()}>${await node.renderContent(children, record, key)}</div>`;
352
761
  }
353
762
  if (!visible.has(node))
354
763
  return '';
355
764
  const alignment = node.getAlignment();
356
765
  // Inside a layout, text is inline and carries its own alignment.
357
766
  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);
767
+ const cell = new Attributes().gridColumn({ default: 1 }).class([{ 'fi-growable': node.canGrow(true) }, 'fi-ta-col', ...(alignment ? [`fi-align-${alignment}`] : []), ...node.getResponsiveClasses()]);
768
+ return `<div ${cell.toHtml()}>${html}</div>`;
359
769
  }
360
770
  /** Column group: a shared label above the column headers. */
361
771
  export class ColumnGroup {
@@ -367,48 +777,212 @@ export class ColumnGroup {
367
777
  c._group = this.label; return this; }
368
778
  getColumns() { return this._columns; }
369
779
  }
370
- /** Color swatch column. */
780
+ /** Only plain CSS lengths are written into a style attribute. */
781
+ const cssLength = (value) => {
782
+ if (value === null)
783
+ return null;
784
+ const text = typeof value === 'number' ? `${value}px` : value.trim();
785
+ return /^[0-9.]+(rem|px|em|%|vh|vw)$/.test(text) ? text : null;
786
+ };
787
+ /** Only hex or simple functional colors are written into a style attribute (no CSS injection). */
788
+ const sanitizeCssColor = (value) => (typeof value === 'string' && /^(#[0-9a-fA-F]{3,8}|(rgb|hsl|oklch)a?\([0-9.,%\s/deg-]+\))$/.test(value.trim()) ? value.trim() : null);
789
+ /** Only local or http(s) URLs become image sources. */
790
+ const safeImageUrl = (value) => (typeof value === 'string' && /^(\/(?!\/)|https?:\/\/)/.test(value) ? value : null);
791
+ /** Attributes of a copyable element (Filament's `filamentCopyable` Alpine component). */
792
+ const copyableAttributes = (copied, label, message, duration) => [
793
+ 'x-data="filamentCopyable"', 'x-bind="bindings"',
794
+ `x-bind:aria-label="isStandalone ? ${e(js(t('filament::components/copyable.label', { label })))} : null"`,
795
+ `x-on:click.prevent.stop="${e(`if (isStandalone) {\n copy(${js(copied)}, ${js(message)}, ${duration}, ${js(t('filament::components/copyable.messages.failed'))})\n} else {\n window.navigator.clipboard.writeText(${js(copied)})\n $tooltip(${js(message)}, {\n theme: $store.theme,\n timeout: ${duration},\n })\n}`)}"`,
796
+ ];
797
+ /** Color swatch per state (a color, or a list of colors). */
371
798
  export class ColorColumn extends Column {
799
+ _copyable = false;
800
+ _copyMessage = null;
801
+ _copyMessageDuration = 2000;
802
+ _tooltip = null;
803
+ _placeholder = null;
804
+ _wrap = false;
805
+ _inline = false;
372
806
  static make(name) { return configured(new ColorColumn(name)); }
807
+ /** Clicking the swatch copies the color. Browsers only expose the clipboard on HTTPS or `localhost`. */
808
+ copyable(condition = true) { this._copyable = condition; return this; }
809
+ copyMessage(message) { this._copyMessage = message; return this; }
810
+ copyMessageDuration(milliseconds) { this._copyMessageDuration = milliseconds; return this; }
811
+ tooltip(tooltip) { this._tooltip = tooltip; return this; }
812
+ placeholder(text) { this._placeholder = text; return this; }
813
+ /** Lets several swatches wrap onto more lines. */
814
+ wrap(condition = true) { this._wrap = condition; return this; }
815
+ inline(condition = true) { this._inline = condition; return this; }
373
816
  async toHtml(record) {
374
817
  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>`;
818
+ const align = this._alignment && this._alignment !== 'start' ? `fi-align-${this._alignment}` : null;
819
+ const base = ['fi-ta-color', this._inline ? 'fi-inline' : null, align];
820
+ if (isBlankState(state))
821
+ return `<div class="${cls(base)}">${this._placeholder !== null ? `<p class="fi-ta-placeholder">${e(this._placeholder)}</p>` : ''}</div>`;
822
+ const items = (Array.isArray(state) ? state : [state]).map((item) => {
823
+ const color = sanitizeCssColor(item);
824
+ const copyable = resolveItem(this._copyable, record, item);
825
+ const tooltip = resolveItem(this._tooltip, record, item);
826
+ const attributes = [];
827
+ // The swatch shows its value only through its color: it is a named image for screen readers.
828
+ if (!copyable && color)
829
+ attributes.push(`aria-label="${e(color)}"`, 'role="img"');
830
+ if (copyable)
831
+ attributes.push(...copyableAttributes(color ?? '', color ?? this.name, this._copyMessage ?? t('filament::components/copyable.messages.copied'), this._copyMessageDuration));
832
+ if (tooltip)
833
+ attributes.push(`x-tooltip="${e(tooltipAttribute(tooltip))}"`);
834
+ attributes.push(`class="${cls(['fi-ta-color-item', copyable ? 'fi-copyable' : null])}"`);
835
+ if (color)
836
+ attributes.push(`style="background-color: ${color};"`);
837
+ return `<div ${attributes.join(' ')}></div>`;
838
+ });
839
+ return `<div class="${cls([...base, this._wrap ? 'fi-wrapped' : null])}">${items.join('')}</div>`;
378
840
  }
379
841
  }
380
- /** Image column: the state is the image URL. Only local or http(s) URLs are rendered. */
842
+ /** Image per state (an image URL, or a list of URLs). Only local or http(s) URLs are rendered. */
381
843
  export class ImageColumn extends Column {
382
844
  _alt = null;
383
- _height = '2.5rem';
845
+ _height = null;
846
+ _width = null;
384
847
  _circular = false;
848
+ _square = false;
849
+ _stacked = false;
850
+ _ring = null;
851
+ _overlap = null;
852
+ _limit = null;
853
+ _limitedRemainingText = false;
854
+ _limitedRemainingTextSize = null;
855
+ _defaultImageUrl = null;
856
+ _tooltip = null;
857
+ _placeholder = null;
858
+ _wrap = false;
859
+ _inline = false;
385
860
  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; }
861
+ alt(alt) { this._alt = alt; return this; }
862
+ /** CSS height of the images (`2.5rem` by default, `2rem` when stacked). */
863
+ imageHeight(height) { this._height = cssLength(height); return this; }
864
+ imageWidth(width) { this._width = cssLength(width); return this; }
865
+ /** Same width and height. */
866
+ imageSize(size) { return this.imageHeight(size).imageWidth(size); }
390
867
  circular(condition = true) { this._circular = condition; return this; }
868
+ square(condition = true) { this._square = condition; return this; }
869
+ /** Overlapping images, with a `ring` (border width, 0 to 8) and an `overlap` (0 to 8). */
870
+ stacked(condition = true) { this._stacked = condition; return this; }
871
+ ring(width) { this._ring = width; return this; }
872
+ overlap(overlap) { this._overlap = overlap; return this; }
873
+ /** Shows only the first images; `limitedRemainingText()` adds "+3" for the others. */
874
+ limit(count) { this._limit = count; return this; }
875
+ limitedRemainingText(condition = true, size = null) { this._limitedRemainingText = condition; this._limitedRemainingTextSize = size; return this; }
876
+ /** Image shown when the state is empty. */
877
+ defaultImageUrl(url) { this._defaultImageUrl = url; return this; }
878
+ tooltip(tooltip) { this._tooltip = tooltip; return this; }
879
+ placeholder(text) { this._placeholder = text; return this; }
880
+ wrap(condition = true) { this._wrap = condition; return this; }
881
+ inline(condition = true) { this._inline = condition; return this; }
391
882
  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>`;
883
+ let state = await this.getState(record);
884
+ const align = this._alignment && this._alignment !== 'start' ? `fi-align-${this._alignment}` : null;
885
+ const base = ['fi-ta-image', this._inline ? 'fi-inline' : null, align];
886
+ const fallback = safeImageUrl(typeof this._defaultImageUrl === 'function' ? this._defaultImageUrl(record) : this._defaultImageUrl);
887
+ let items = (Array.isArray(state) ? state : isBlankState(state) ? [] : [state]).filter((item) => safeImageUrl(item) !== null);
888
+ if (!items.length && fallback)
889
+ items = [fallback];
890
+ if (!items.length)
891
+ return `<div class="${cls(base)}">${this._placeholder !== null ? `<p class="fi-ta-placeholder">${e(this._placeholder)}</p>` : ''}</div>`;
892
+ const overLimit = this._limit !== null && items.length > this._limit ? items.length - this._limit : 0;
893
+ if (overLimit)
894
+ items = items.slice(0, this._limit);
895
+ const height = this._height ?? (this._stacked ? '2rem' : '2.5rem');
896
+ const width = this._width ?? (this._circular || this._square ? height : null);
897
+ const style = `height: ${height};${width ? ` width: ${width};` : ''}`;
898
+ const ring = this._stacked && this._ring !== null && Number.isInteger(this._ring) ? `fi-ta-image-ring fi-ta-image-ring-${this._ring}` : null;
899
+ const overlap = this._stacked ? (this._overlap ?? 2) : null;
900
+ const classes = [...base, this._circular ? 'fi-circular' : null, this._wrap ? 'fi-wrapped' : null, this._stacked ? 'fi-stacked' : null, ring, overlap ? `fi-ta-image-overlap-${overlap}` : null];
901
+ const images = items.map((item) => {
902
+ const alt = resolveItem(this._alt, record, item) ?? '';
903
+ const tooltip = resolveItem(this._tooltip, record, item);
904
+ return `<img alt="${e(alt)}" src="${e(String(item))}"${tooltip ? ` x-tooltip="${e(tooltipAttribute(tooltip))}"` : ''} style="${style}" />`;
905
+ }).join('');
906
+ const remaining = overLimit && this._limitedRemainingText ? `<div class="${cls(['fi-ta-image-limited-remaining-text', this._limitedRemainingTextSize ? `fi-size-${this._limitedRemainingTextSize}` : null])}" style="${style}">+${overLimit}</div>` : '';
907
+ return `<div class="${cls(classes)}">${images}${remaining}</div>`;
398
908
  }
399
909
  }
400
910
  /** Icon depending on the value; `boolean()` shows a green check or a red cross. */
911
+ /**
912
+ * Icon per state. `boolean()` shows a green check or a red cross; `icon()` and `color()` choose the icon and color from the
913
+ * record and the state, e.g. `IconColumn.make('status').icon((record) => STATUSES[record.status].icon)`.
914
+ */
401
915
  export class IconColumn extends Column {
402
916
  _boolean = false;
917
+ _icon = null;
918
+ _color = null;
919
+ _size = null;
920
+ _trueIcon = null;
921
+ _falseIcon = null;
922
+ _trueColor = null;
923
+ _falseColor = null;
924
+ _tooltip = null;
925
+ _placeholder = null;
926
+ _listWithLineBreaks = false;
927
+ _wrap = false;
928
+ _inline = false;
403
929
  static make(name) { return configured(new IconColumn(name)); }
404
930
  boolean(condition = true) { this._boolean = condition; return this; }
931
+ icon(icon) { this._icon = icon; return this; }
932
+ color(color) { this._color = color; return this; }
933
+ /** Icon size: `xs`, `sm`, `md`, `lg` (default), `xl` or `2xl`. */
934
+ size(size) { this._size = size; return this; }
935
+ /** Icons and colors of a boolean column; `false` shows no icon for that value. */
936
+ trueIcon(icon) { this._trueIcon = icon; return this; }
937
+ falseIcon(icon) { this._falseIcon = icon; return this; }
938
+ trueColor(color) { this._trueColor = color; return this; }
939
+ falseColor(color) { this._falseColor = color; return this; }
940
+ tooltip(tooltip) { this._tooltip = tooltip; return this; }
941
+ placeholder(text) { this._placeholder = text; return this; }
942
+ /** One icon per line, for a state that is a list. */
943
+ listWithLineBreaks(condition = true) { this._listWithLineBreaks = condition; return this; }
944
+ wrap(condition = true) { this._wrap = condition; return this; }
945
+ inline(condition = true) { this._inline = condition; return this; }
946
+ isBooleanItem(item) { return this._boolean || typeof item === 'boolean'; }
947
+ iconOf(record, item) {
948
+ const own = this._icon === null ? null : resolveItem(this._icon, record, item);
949
+ if (own)
950
+ return own;
951
+ if (!this.isBooleanItem(item) || item === null || item === undefined)
952
+ return null;
953
+ const on = item === true || item === 1 || item === '1';
954
+ const chosen = on ? this._trueIcon : this._falseIcon;
955
+ return chosen === false ? null : chosen ?? (on ? 'o-check-circle' : 'o-x-circle');
956
+ }
957
+ colorOf(record, item) {
958
+ const own = this._color === null ? null : resolveItem(this._color, record, item);
959
+ if (own)
960
+ return own;
961
+ if (!this.isBooleanItem(item) || item === null || item === undefined)
962
+ return null;
963
+ const on = item === true || item === 1 || item === '1';
964
+ return on ? (this._trueColor ?? 'success') : (this._falseColor ?? 'danger');
965
+ }
405
966
  async toHtml(record) {
406
967
  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>`;
968
+ const align = this._alignment && this._alignment !== 'start' ? `fi-align-${this._alignment}` : null;
969
+ const base = ['fi-ta-icon', this._inline ? 'fi-inline' : null, align];
970
+ if (isBlankState(state))
971
+ return `<div class="${cls(base)}">${this._placeholder !== null ? `<p class="fi-ta-placeholder">${e(this._placeholder)}</p>` : ''}</div>`;
972
+ const items = Array.isArray(state) ? state : [state];
973
+ const html = items.map((item) => {
974
+ const name = this.iconOf(record, item);
975
+ if (!name)
976
+ return '';
977
+ const color = this.colorOf(record, item);
978
+ const size = resolveItem(this._size, record, item);
979
+ const tooltip = resolveItem(this._tooltip, record, item);
980
+ const attributes = new Attributes(tooltip ? { 'x-tooltip': tooltipAttribute(tooltip) } : {}).class(color ? (colorManager?.getComponentClasses('icon-column', color) ?? []) : []);
981
+ // The icon is hidden from assistive technology: a text alternative goes with it.
982
+ const alternative = tooltip ? tooltip : this.isBooleanItem(item) ? t(`filament-tables::table.columns.icon.boolean.${item === true || item === 1 || item === '1' ? 'true' : 'false'}`) : (typeof item === 'string' || typeof item === 'number') ? String(item) : null;
983
+ return `${icon(name, { size: (size && size !== 'base' ? size : 'lg'), attributes })}${alternative ? `<span class="fi-sr-only">${e(alternative)}</span>` : ''}`;
984
+ }).join('');
985
+ return `<div class="${cls([...base, this._listWithLineBreaks ? 'fi-ta-icon-has-line-breaks' : null, this._wrap ? 'fi-wrapped' : null])}">${html}</div>`;
412
986
  }
413
987
  }
414
988
  const isBlank = (value) => value === null || value === undefined || (typeof value === 'string' && value.trim() === '') || (Array.isArray(value) && value.length === 0);
@@ -468,6 +1042,8 @@ export class EditableColumn extends Column {
468
1042
  * Error message, or `null` if the value is valid. An empty value is only checked by `required`, and several errors
469
1043
  * produce the first message followed by "(and N more errors)".
470
1044
  */
1045
+ /** Validation of a value for a given record (column types whose rules depend on the record override it). */
1046
+ async validateFor(_record, input) { return this.validate(input); }
471
1047
  validate(input) {
472
1048
  const attribute = this.getValidationAttribute();
473
1049
  const blank = isBlank(input);
@@ -530,18 +1106,20 @@ export class EditableColumn extends Column {
530
1106
  return state;
531
1107
  }
532
1108
  /** `x-tooltip` expression of editable controls: the column tooltip, replaced by the validation error when there is one. */
533
- tooltipExpression(record) {
1109
+ tooltipExpression(record, extra = null) {
534
1110
  const tooltip = typeof this._tooltip === 'function' ? this._tooltip(record) : this._tooltip;
1111
+ const more = extra ? `
1112
+ ${extra}` : '';
535
1113
  return `error === undefined ? ${tooltip !== null && tooltip !== '' ? `{
536
1114
  content: ${js(tooltip)},
537
1115
  theme: $store.theme,
538
1116
  allowHTML: false,
539
- aria: { content: 'auto' },
1117
+ aria: { content: 'auto' },${more}
540
1118
  }` : 'false'} : {
541
1119
  content: error,
542
1120
  theme: $store.theme,
543
1121
  allowHTML: false,
544
- aria: { content: null },
1122
+ aria: { content: null },${more}
545
1123
  }`;
546
1124
  }
547
1125
  /** Shared opening markup of editable cells: lazily loaded Alpine component, server state and record key. */
@@ -562,30 +1140,95 @@ export class EditableColumn extends Column {
562
1140
  }
563
1141
  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
1142
  }
565
- /**
566
- * In-cell editable select (native select only). The received value must be one of the declared options.
567
- */
1143
+ const normalizeSelectOptions = (options) => (Array.isArray(options) ? options : Object.entries(options)).map(([value, label]) => [String(value), String(label)]);
568
1144
  export class SelectColumn extends EditableColumn {
569
1145
  _options = [];
570
1146
  _selectablePlaceholder = true;
1147
+ _native = true;
1148
+ _searchable = false;
1149
+ _disabledOptions = [];
1150
+ _placeholder = null;
1151
+ _optionsLimit = 50;
1152
+ _canOptionLabelsWrap = true;
1153
+ _position = null;
1154
+ _inline = false;
571
1155
  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; }
1156
+ /** `{ value: label }` pairs, `[value, label]` pairs, or a function of the record returning either. */
1157
+ options(options) { this._options = typeof options === 'function' ? options : normalizeSelectOptions(options); return this; }
1158
+ /** Static options (empty when they depend on the record: use `getOptionsFor()`). */
1159
+ getOptions() { return typeof this._options === 'function' ? [] : this._options; }
1160
+ async getOptionsFor(record) {
1161
+ if (this._optionsRelationship)
1162
+ return (this._relationshipOptions ??= loadRelationshipOptions(record?.constructor, this._optionsRelationship.name, this._optionsRelationship.title, this._optionsRelationship.modify));
1163
+ return typeof this._options === 'function' ? normalizeSelectOptions(await this._options(record)) : this._options;
1164
+ }
1165
+ _optionsRelationship = null;
1166
+ _relationshipOptions = null;
1167
+ /**
1168
+ * Options from the records of a relationship of the record's model (an Objection model with `relationMappings`): the
1169
+ * column stores the picked record's key, e.g. `SelectColumn.make('author_id').optionsRelationship('author', 'name')`.
1170
+ * Up to 1,000 options are listed; any related record's key is accepted when saving.
1171
+ */
1172
+ optionsRelationship(name, titleAttribute = null, modifyQueryUsing = null) {
1173
+ this._optionsRelationship = { name, title: titleAttribute, modify: modifyQueryUsing };
1174
+ this._relationshipOptions = null;
1175
+ return this;
1176
+ }
574
1177
  selectablePlaceholder(condition = true) { this._selectablePlaceholder = condition; return this; }
1178
+ /** `false`: a JavaScript select instead of the browser's native one. */
1179
+ native(condition = true) { this._native = condition; return this; }
1180
+ _allowHtml = false;
1181
+ /** Option labels are HTML, sanitized on the server (JavaScript select only; the native select shows their text). */
1182
+ allowHtml(condition = true) { this._allowHtml = condition; return this; }
1183
+ labelForDisplay(label) { return !this._allowHtml ? label : this.isNative() ? label.replace(/<[^>]*>/g, '') : sanitizeHtml(label); }
1184
+ /** A search field in the select (implies a JavaScript select). */
1185
+ searchableOptions(condition = true) { this._searchable = condition; return this; }
1186
+ isNative() { return this._native && !this._searchable; }
1187
+ /** Options that cannot be chosen. A disabled option is also refused on the server. */
1188
+ disableOptionWhen(callback) { this._disabledOptions.push(callback); return this; }
1189
+ isOptionDisabled(value, label, record) { return this._disabledOptions.some((callback) => callback(value, label, record)); }
1190
+ placeholder(text) { this._placeholder = text; return this; }
1191
+ /** Maximum number of options listed by the JavaScript select (50 by default). */
1192
+ optionsLimit(limit) { this._optionsLimit = limit; return this; }
1193
+ /** `false`: long option labels are truncated in the JavaScript select instead of wrapping. */
1194
+ allowOptionsLabelsWrap(condition = true) { this._canOptionLabelsWrap = condition; return this; }
1195
+ /** Side where the JavaScript select's dropdown opens (automatic by default). */
1196
+ position(position) { this._position = position; return this; }
1197
+ inline(condition = true) { this._inline = condition; return this; }
575
1198
  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.` }];
1199
+ // Options that depend on the record are checked by `validateFor()`.
1200
+ if (typeof this._options === 'function' || this._optionsRelationship)
1201
+ return [];
1202
+ const options = this._options;
1203
+ return [{ name: 'in', passes: (v) => (typeof v === 'string' || typeof v === 'number') && options.some(([value]) => value === String(v)), message: (a) => `The selected ${a} is invalid.` }];
1204
+ }
1205
+ /** The value must be one of the record's options, and not a disabled one. */
1206
+ async validateFor(record, input) {
1207
+ if (!isBlank(input)) {
1208
+ // Relationship options: the received key must be one of the related model's records (in the modified query).
1209
+ const options = this._optionsRelationship && (typeof input === 'string' || typeof input === 'number')
1210
+ ? await loadRelationshipOptions(record?.constructor, this._optionsRelationship.name, this._optionsRelationship.title, this._optionsRelationship.modify, [String(input)])
1211
+ : await this.getOptionsFor(record);
1212
+ const option = (typeof input === 'string' || typeof input === 'number') ? options.find(([value]) => value === String(input)) : undefined;
1213
+ if (!option || this.isOptionDisabled(option[0], option[1], record))
1214
+ return this._validationMessages.in ?? `The selected ${this.getValidationAttribute()} is invalid.`;
1215
+ }
1216
+ return this.validate(input);
577
1217
  }
578
1218
  async toHtml(record, recordKey = '') {
579
1219
  const state = await this.getState(record);
580
1220
  const value = state === null || state === undefined ? '' : String(state);
581
- const optionLabel = this._options.find(([option]) => option === value)?.[1] ?? null;
1221
+ const options = await this.getOptionsFor(record);
1222
+ const found = options.find(([option]) => option === value)?.[1] ?? null;
1223
+ const optionLabel = found === null ? null : this.labelForDisplay(found);
582
1224
  const isDisabled = await this.isDisabledFor(record);
583
- const placeholder = t('filament-forms::components.select.placeholder');
584
- const messages = this.getUpdateStateMessages();
1225
+ const isNative = this.isNative();
1226
+ const placeholder = this._placeholder ?? t('filament-forms::components.select.placeholder');
1227
+ const optionsForJs = isNative ? [] : options.map(([option, label]) => ({ label: this.labelForDisplay(label), value: option, isDisabled: this.isOptionDisabled(option, label, record) }));
585
1228
  const config = `selectTableColumn({
586
1229
  accessibilityAttributes: [],
587
1230
  ariaLabel: ${js(this.getLabel())},
588
- canOptionLabelsWrap: true,
1231
+ canOptionLabelsWrap: ${js(this._canOptionLabelsWrap)},
589
1232
  canSelectPlaceholder: ${js(this._selectablePlaceholder)},
590
1233
  clearButtonLabel: ${js(t('filament-forms::components.select.actions.clear.label'))},
591
1234
  errorMessage: ${js(t('filament-forms::components.select.error_message'))},
@@ -594,18 +1237,18 @@ export class SelectColumn extends EditableColumn {
594
1237
  hasInitialNoOptionsMessage: true,
595
1238
  initialOptionLabel: ${js(optionLabel)},
596
1239
  isDisabled: ${js(isDisabled)},
597
- isHtmlAllowed: false,
598
- isNative: true,
599
- isSearchable: false,
1240
+ isHtmlAllowed: ${js(this._allowHtml && !isNative)},
1241
+ isNative: ${js(isNative)},
1242
+ isSearchable: ${js(this._searchable)},
600
1243
  loadingMessage: ${js(t('filament-forms::components.select.loading_message'))},
601
- messages: ${js(messages)},
1244
+ messages: ${js(this.getUpdateStateMessages())},
602
1245
  name: ${js(this.name)},
603
1246
  noOptionsMessage: ${js(t('filament-forms::components.select.no_options_message'))},
604
1247
  noSearchResultsMessage: ${js(t('filament-forms::components.select.no_search_results_message'))},
605
- options: [],
606
- optionsLimit: 50,
1248
+ options: ${js(optionsForJs)},
1249
+ optionsLimit: ${js(this._optionsLimit)},
607
1250
  placeholder: ${js(placeholder)},
608
- position: null,
1251
+ position: ${js(this._position)},
609
1252
  recordKey: ${js(String(recordKey))},
610
1253
  searchableOptionFields: ${js(['label'])},
611
1254
  searchDebounce: 1000,
@@ -614,23 +1257,20 @@ export class SelectColumn extends EditableColumn {
614
1257
  searchPrompt: ${js(t('filament-forms::components.select.search_prompt'))},
615
1258
  state: ${js(state === null || state === undefined ? null : value)},
616
1259
  })`;
1260
+ const tooltip = this.tooltipExpression(record, isNative ? null : 'triggerTarget: $el.isConnected && select ? select.selectButton : null,');
617
1261
  const select = new Attributes().merge({
618
1262
  'aria-label': e(this.getLabel()), disabled: true, 'wire:loading.attr': 'disabled', 'wire:target': LOADING_TARGETS,
619
1263
  'x-bind:aria-invalid': "error ? 'true' : null", 'x-bind:aria-busy': "isLoading ? 'true' : null",
620
1264
  '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
- }`,
1265
+ 'x-tooltip': tooltip,
628
1266
  }).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="{
1267
+ const control = isNative
1268
+ ? `<select x-model="state" ${select.toHtml()}>${this._selectablePlaceholder ? `<option value="">${e(placeholder)}</option>` : ''}${options.map(([option, label]) => `<option${this.isOptionDisabled(option, label, record) ? ' disabled' : ''} value="${e(option)}">${e(this.labelForDisplay(label))}</option>`).join('')}</select>`
1269
+ : '<div wire:ignore class="fi-select-input"><div x-ref="select"></div></div>';
1270
+ 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="${cls(['fi-ta-select', this._inline ? 'fi-inline' : null])}"><input type="hidden" value="${e(value)}" data-option-label="${e(optionLabel ?? '')}" data-disabled="${isDisabled ? 'true' : 'false'}" x-ref="serverState" /><div x-bind:class="{
631
1271
  'fi-disabled': isLoading || ${js(isDisabled)},
632
1272
  '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>`;
1273
+ }" x-on:click.prevent.stop${isNative ? '' : ` x-tooltip="${e(tooltip)}"`} class="fi-input-wrp">${control}</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
1274
  }
635
1275
  }
636
1276
  /** Boolean value received from the browser (already checked by the `boolean` rule): `true`, `false` or `null`. */
@@ -790,120 +1430,402 @@ export async function updateTableColumnState(table, host, adapter, columnName, r
790
1430
  const record = (await adapter.keysInScope([String(recordKey)])).length ? await adapter.find(String(recordKey)) : null;
791
1431
  if (!record || (options.authorize && !(await options.authorize(record))) || (await column.isDisabledFor(record)))
792
1432
  return unavailable;
793
- const error = column.validate(input);
1433
+ const error = await column.validateFor(record, input);
794
1434
  if (error !== null)
795
1435
  return { error };
796
1436
  return column.updateState(record, input, adapter);
797
1437
  }
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
- }
1438
+ const FILTERS_LAYOUTS = ['dropdown', 'modal', 'above-content', 'above-content-collapsible', 'below-content', 'before-content', 'before-content-collapsible', 'after-content', 'after-content-collapsible'];
1439
+ /** Widths accepted for the filters dropdown, modal or side panel. */
1440
+ const FILTERS_WIDTHS = ['xs', 'sm', 'md', 'lg', 'xl', '2xl', '3xl', '4xl', '5xl', '6xl', '7xl', 'full', 'screen-sm', 'screen-md', 'screen-lg', 'screen-xl', 'screen-2xl', 'screen'];
812
1441
  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 {
1442
+ /** Badge shown above the table for an applied filter. */
1443
+ export class Indicator {
1444
+ _label;
1445
+ _removeField = null;
1446
+ _removable = true;
1447
+ _color = null;
1448
+ constructor(label) { this._label = label; }
1449
+ static make(label) { return new Indicator(label); }
1450
+ label(label) { this._label = label; return this; }
1451
+ getLabel() { return this._label; }
1452
+ /** Field of the filter form cleared when the indicator is removed (by default, the whole filter is reset). */
1453
+ removeField(field) { this._removeField = field; return this; }
1454
+ getRemoveField() { return this._removeField; }
1455
+ /** `false`: no remove button. A forged removal is refused on the server too. */
1456
+ removable(condition = true) { this._removable = condition; return this; }
1457
+ isRemovable() { return this._removable; }
1458
+ color(color) { this._color = color; return this; }
1459
+ getColor() { return this._color; }
1460
+ }
1461
+ const toIndicators = (result) => {
1462
+ if (result === null || result === undefined || result === '')
1463
+ return [];
1464
+ const one = (item, field) => {
1465
+ const indicator = item instanceof Indicator ? item : typeof item === 'string' && item !== '' ? Indicator.make(item) : null;
1466
+ if (!indicator)
1467
+ return null;
1468
+ return { label: indicator.getLabel(), field: indicator.getRemoveField() ?? field ?? '', removable: indicator.isRemovable(), color: indicator.getColor() };
1469
+ };
1470
+ if (typeof result === 'string' || result instanceof Indicator)
1471
+ return [one(result, null)].filter((i) => i !== null);
1472
+ if (Array.isArray(result))
1473
+ return result.map((item) => one(item, null)).filter((i) => i !== null);
1474
+ return Object.entries(result).map(([field, item]) => one(item, field)).filter((i) => i !== null);
1475
+ };
1476
+ /** A received select value: a string of at most 255 characters. */
1477
+ const filterValue = (value) => ((typeof value === 'string' || typeof value === 'number') && String(value) !== '' ? String(value).slice(0, 255) : null);
1478
+ /** Shared options of the built-in filters: label, default state, indicators and custom query. */
1479
+ class BaseFilter {
815
1480
  name;
816
1481
  _label = null;
1482
+ _default = undefined;
1483
+ _indicator = null;
1484
+ _indicateUsing = null;
1485
+ _query = null;
1486
+ _columnSpan = { default: 1, lg: 1 };
1487
+ constructor(name) { this.name = name; }
1488
+ label(label) { this._label = label; return this; }
1489
+ getLabel() { return this._label ?? filterLabel(this.name); }
1490
+ /** State applied when the table opens and when the user clicks "Reset". */
1491
+ default(value = true) { this._default = value; return this; }
1492
+ /** Label of the indicator (the filter label by default). */
1493
+ indicator(indicator) { this._indicator = indicator; return this; }
1494
+ getIndicator() { return this._indicator ?? this.getLabel(); }
1495
+ /** Indicators of the applied state, replacing the default one. */
1496
+ indicateUsing(callback) { this._indicateUsing = callback; return this; }
1497
+ /** Query condition built from the sanitized state, replacing the default one. */
1498
+ query(modifier) { this._query = modifier; return this; }
1499
+ /** Width of the filter in the filters form grid. */
1500
+ columnSpan(span) { this._columnSpan = typeof span === 'object' ? span : { default: span }; return this; }
1501
+ columnSpanFull() { return this.columnSpan('full'); }
1502
+ getColumnSpan() { return this._columnSpan; }
1503
+ getFormDefinition() { return { placeholder: '', options: [] }; }
1504
+ defaultIndicators(_data) { return null; }
1505
+ getIndicators(state) {
1506
+ const data = this.sanitizeState(state);
1507
+ return toIndicators(this._indicateUsing ? this._indicateUsing(data) : this.defaultIndicators(data));
1508
+ }
1509
+ /** Prefixes a value label with the indicator, unless the indicator is a ready-made `Indicator`. */
1510
+ labelled(valueLabel) {
1511
+ const indicator = this.getIndicator();
1512
+ return indicator instanceof Indicator ? indicator : `${indicator}: ${valueLabel}`;
1513
+ }
1514
+ }
1515
+ /** Value of the "None" option of a relationship select filter. */
1516
+ const EMPTY_RELATIONSHIP_OPTION = '__empty';
1517
+ /** Filters on a column value picked from a list. */
1518
+ export class SelectFilter extends BaseFilter {
817
1519
  _column = null;
818
1520
  _options = [];
819
- constructor(name) { this.name = name; }
1521
+ _multiple = false;
1522
+ _searchable = false;
1523
+ _selectablePlaceholder = true;
1524
+ _placeholder = null;
1525
+ _optionLabels = new Map();
820
1526
  static make(name) { return configured(new SelectFilter(name)); }
821
- label(label) { this._label = label; return this; }
822
1527
  /** Filtered column when it differs from the filter name (e.g. a relationship's foreign key). */
823
1528
  column(column) { this._column = column; return this; }
1529
+ attribute(column) { return this.column(column); }
1530
+ getColumn() { return this._column ?? this.name; }
824
1531
  /** Fixed options, or an async function (e.g. records of a relationship). */
825
1532
  options(options) {
826
1533
  this._options = typeof options === 'function' ? options : (Array.isArray(options) ? options : Object.entries(options)).map(([value, label]) => [String(value), String(label)]);
827
1534
  return this;
828
1535
  }
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); }
1536
+ /** Several values can be picked: records matching any of them are shown. */
1537
+ multiple(condition = true) { this._multiple = condition; return this; }
1538
+ isMultiple() { return this._multiple; }
1539
+ searchable(condition = true) { this._searchable = condition; return this; }
1540
+ /** `false`: the empty option cannot be picked again once a value is chosen. */
1541
+ selectablePlaceholder(condition = true) { this._selectablePlaceholder = condition; return this; }
1542
+ placeholder(placeholder) { this._placeholder = placeholder; return this; }
1543
+ /** No-op: options are always loaded with the form. */
1544
+ preload() { return this; }
1545
+ _relationship = null;
1546
+ _emptyOptionLabel = null;
1547
+ _host = null;
1548
+ /**
1549
+ * Options from the records of a relationship of the table's model (an Objection model with `relationMappings`); records
1550
+ * related to the picked ones are shown. `hasEmptyOption` adds a "None" option for records without related records.
1551
+ */
1552
+ relationship(name, titleAttribute = null, modifyQueryUsing = null, hasEmptyOption = false) {
1553
+ this._relationship = { name, title: titleAttribute, modify: modifyQueryUsing, empty: hasEmptyOption };
1554
+ return this;
1555
+ }
1556
+ hasRelationship() { return this._relationship !== null; }
1557
+ /** Label of the "None" option of `relationship(..., hasEmptyOption: true)`. */
1558
+ emptyRelationshipOptionLabel(label) { this._emptyOptionLabel = label; return this; }
1559
+ getEmptyRelationshipOptionLabel() { return this._emptyOptionLabel ?? t('filament-tables::table.filters.select.relationship.empty_option_label'); }
1560
+ getFormDefinition() { return { placeholder: this._placeholder ?? t('filament-tables::table.filters.select.placeholder'), options: Array.isArray(this._options) && !this._relationship ? this._options : [] }; }
1561
+ getFormOptions(host = this._host) {
1562
+ if (this._relationship)
1563
+ return this.loadRelationshipOptions(host, null);
1564
+ return typeof this._options === 'function' ? this._options() : Promise.resolve(this._options);
1565
+ }
1566
+ async loadRelationshipOptions(host, keys) {
1567
+ const relation = this._relationship;
1568
+ const options = await loadRelationshipOptions(modelOfHost(host), relation.name, relation.title, relation.modify, keys === null ? null : keys.filter((key) => key !== EMPTY_RELATIONSHIP_OPTION));
1569
+ return relation.empty && (keys === null || keys.includes(EMPTY_RELATIONSHIP_OPTION)) ? [[EMPTY_RELATIONSHIP_OPTION, this.getEmptyRelationshipOptionLabel()], ...options] : options;
1570
+ }
833
1571
  /** Options are evaluated on every render, so the select gets a key derived from them (forces a re-render when they change). */
834
1572
  hasDynamicOptions = true;
835
- isActive(state) { return typeof state.value === 'string' && state.value !== ''; }
1573
+ getFormComponents() {
1574
+ const select = Select.make(this._multiple ? 'values' : 'value').label(this.getLabel()).placeholder(this.getFormDefinition().placeholder).options((ctx) => this.getFormOptions(ctx?.livewire));
1575
+ if (this._multiple)
1576
+ select.multiple();
1577
+ if (this._searchable)
1578
+ select.searchable();
1579
+ if (!this._selectablePlaceholder)
1580
+ select.selectablePlaceholder(false);
1581
+ return [select];
1582
+ }
1583
+ defaultValues() { return (Array.isArray(this._default) ? this._default : this._default === undefined || this._default === null ? [] : [this._default]).map(filterValue).filter((v) => v !== null); }
1584
+ getResetState() { return this._multiple ? { values: [] } : { value: null }; }
1585
+ getDefaultState() { const values = this.defaultValues(); return this._multiple ? { values } : { value: values[0] ?? null }; }
1586
+ sanitizeState(state) {
1587
+ const received = state && typeof state === 'object' ? state : {};
1588
+ if (!this._multiple)
1589
+ return { value: filterValue(received.value) };
1590
+ const values = Array.isArray(received.values) ? received.values : [];
1591
+ return { values: [...new Set(values.map(filterValue).filter((v) => v !== null))].slice(0, 100) };
1592
+ }
1593
+ /** Values that can filter: with fixed options, only offered values; loaded options are bound parameters (an unknown value matches nothing). */
1594
+ values(state) {
1595
+ const data = this.sanitizeState(state);
1596
+ const values = this._multiple ? data.values : data.value === null ? [] : [data.value];
1597
+ // Relationship or loaded options: the keys are bound parameters, an unknown key matches nothing.
1598
+ return Array.isArray(this._options) && !this._relationship ? values.filter((value) => this._options.some(([option]) => option === value)) : values;
1599
+ }
1600
+ isActive(state) { return this.values(state).length > 0; }
836
1601
  apply(query, state) {
837
- if (!this.isActive(state))
1602
+ const values = this.values(state);
1603
+ if (!values.length)
838
1604
  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))
1605
+ const data = this.sanitizeState(state);
1606
+ if (this._query) {
1607
+ (query.modifiers ??= []).push((q) => { this._query(q, data); });
842
1608
  return;
843
- const column = this._column ?? this.name;
844
- (query.modifiers ??= []).push((q) => { q.where(column, value); });
1609
+ }
1610
+ if (this._relationship) {
1611
+ const { name, modify } = this._relationship;
1612
+ const keys = values.filter((value) => value !== EMPTY_RELATIONSHIP_OPTION);
1613
+ const empty = this._relationship.empty && values.includes(EMPTY_RELATIONSHIP_OPTION);
1614
+ // Records related to one of the picked records, or (the "None" option) without related records.
1615
+ (query.modifiers ??= []).push((q) => {
1616
+ q.where((inner) => {
1617
+ if (keys.length)
1618
+ inner.orWhere((related) => applyConditions(related, [{ kind: 'related', relationship: name, keys, negate: false, ...(modify ? { modify } : {}) }]));
1619
+ if (empty)
1620
+ inner.orWhere((none) => applyConditions(none, [{ kind: 'related', relationship: name, keys: null, negate: true, ...(modify ? { modify } : {}) }]));
1621
+ });
1622
+ });
1623
+ return;
1624
+ }
1625
+ const column = this.getColumn();
1626
+ (query.modifiers ??= []).push((q) => { if (this._multiple)
1627
+ q.whereIn(column, values);
1628
+ else
1629
+ q.where(column, values[0]); });
1630
+ }
1631
+ /** Labels of the applied values, loaded before the indicators are computed. */
1632
+ async loadIndicatorData(state, host) {
1633
+ const values = this.values(state);
1634
+ if (!values.length)
1635
+ return;
1636
+ this._optionLabels = new Map(this._relationship ? await this.loadRelationshipOptions(host, values) : await this.getFormOptions());
1637
+ }
1638
+ defaultIndicators(data) {
1639
+ const labels = this.values(data).map((value) => this._optionLabels.get(value) ?? (Array.isArray(this._options) ? this._options.find(([option]) => option === value)?.[1] : undefined)).filter((label) => label !== undefined);
1640
+ if (!labels.length)
1641
+ return null;
1642
+ const joined = labels.length > 1 ? `${labels.slice(0, -1).join(', ')} & ${labels[labels.length - 1]}` : labels[0];
1643
+ return [this.labelled(joined)];
845
1644
  }
846
1645
  }
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; }
1646
+ /** Three-state filter: all, yes, no. By default on a boolean column; `nullable()` checks whether the column is filled. */
1647
+ export class TernaryFilter extends BaseFilter {
1648
+ _column = null;
1649
+ _trueLabel = null;
1650
+ _falseLabel = null;
1651
+ _placeholder = null;
1652
+ _nullable = false;
1653
+ _queries = null;
852
1654
  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)); }
1655
+ attribute(column) { this._column = column; return this; }
1656
+ column(column) { return this.attribute(column); }
1657
+ getColumn() { return this._column ?? this.name; }
1658
+ trueLabel(label) { this._trueLabel = label; return this; }
1659
+ falseLabel(label) { this._falseLabel = label; return this; }
1660
+ placeholder(label) { this._placeholder = label; return this; }
1661
+ getTrueLabel() { return this._trueLabel ?? t('filament-forms::components.select.boolean.true'); }
1662
+ getFalseLabel() { return this._falseLabel ?? t('filament-forms::components.select.boolean.false'); }
1663
+ /** "Yes" shows records where the column is filled, "No" those where it is empty. */
1664
+ nullable(condition = true) { this._nullable = condition; return this; }
1665
+ /** Custom conditions for "yes", "no" and, optionally, the empty choice. */
1666
+ queries(queries) { this._queries = queries; return this; }
1667
+ getFormDefinition() { return { placeholder: this._placeholder ?? '-', options: [['1', this.getTrueLabel()], ['0', this.getFalseLabel()]] }; }
1668
+ getFormComponents() {
1669
+ const definition = this.getFormDefinition();
1670
+ return [Select.make('value').label(this.getLabel()).placeholder(definition.placeholder).options(definition.options)];
1671
+ }
1672
+ getResetState() { return { value: null }; }
1673
+ getDefaultState() { return { value: this._default === true || this._default === 1 || this._default === '1' ? '1' : this._default === false || this._default === 0 || this._default === '0' ? '0' : null }; }
1674
+ sanitizeState(state) {
1675
+ const value = state && typeof state === 'object' ? String(state.value ?? '') : '';
1676
+ return { value: value === '1' || value === '0' ? value : null };
1677
+ }
1678
+ isActive(state) { return this.sanitizeState(state).value !== null; }
858
1679
  apply(query, state) {
859
- if (!this.isActive(state))
1680
+ const data = this.sanitizeState(state);
1681
+ const value = data.value;
1682
+ if (this._query) {
1683
+ (query.modifiers ??= []).push((q) => { this._query(q, data); });
1684
+ return;
1685
+ }
1686
+ if (this._queries) {
1687
+ const modifier = value === '1' ? this._queries.true : value === '0' ? this._queries.false : this._queries.blank;
1688
+ if (modifier)
1689
+ (query.modifiers ??= []).push((q) => { modifier(q); });
860
1690
  return;
861
- const column = this.name, on = String(state.value) === '1';
862
- (query.modifiers ??= []).push((q) => { q.where(column, on ? 1 : 0); });
1691
+ }
1692
+ if (value === null)
1693
+ return;
1694
+ const column = this.getColumn(), on = value === '1';
1695
+ (query.modifiers ??= []).push((q) => { if (this._nullable) {
1696
+ if (on)
1697
+ q.whereNotNull(column);
1698
+ else
1699
+ q.whereNull(column);
1700
+ }
1701
+ else
1702
+ q.where(column, on ? 1 : 0); });
1703
+ }
1704
+ defaultIndicators(data) {
1705
+ if (data.value === null)
1706
+ return null;
1707
+ return [this.labelled(data.value === '1' ? this.getTrueLabel() : this.getFalseLabel())];
863
1708
  }
864
1709
  }
865
- /** Custom form filter (`schema()`): the query and indicators are written by the application. */
866
- export class Filter {
867
- name;
868
- _label = null;
1710
+ /** "Trashed" filter for soft-deletable models: without (default), with, or only deleted records. */
1711
+ export class TrashedFilter extends TernaryFilter {
1712
+ constructor(name = 'trashed') { super(name); }
1713
+ static make(name = 'trashed') { return configured(new TrashedFilter(name)); }
1714
+ getLabel() { return this._label ?? t('filament-tables::table.filters.trashed.label'); }
1715
+ getTrueLabel() { return this._trueLabel ?? t('filament-tables::table.filters.trashed.with_trashed'); }
1716
+ getFalseLabel() { return this._falseLabel ?? t('filament-tables::table.filters.trashed.only_trashed'); }
1717
+ getFormDefinition() { return { placeholder: this._placeholder ?? t('filament-tables::table.filters.trashed.without_trashed'), options: [['1', this.getTrueLabel()], ['0', this.getFalseLabel()]] }; }
1718
+ apply(query, state) {
1719
+ const value = this.sanitizeState(state).value;
1720
+ query.trashed = value === '1' ? 'with' : value === '0' ? 'only' : 'without';
1721
+ }
1722
+ defaultIndicators(data) {
1723
+ if (data.value === null)
1724
+ return null;
1725
+ return [data.value === '1' ? this.getTrueLabel() : this.getFalseLabel()];
1726
+ }
1727
+ }
1728
+ /**
1729
+ * Custom filter. Without `schema()`, it is a checkbox (or a toggle with `toggle()`) and `query()` runs when it is checked.
1730
+ * With `schema()`, the query and indicators are written by the application.
1731
+ */
1732
+ export class Filter extends BaseFilter {
869
1733
  _components = [];
870
- _query = null;
871
- _indicate = null;
872
- constructor(name) { this.name = name; }
1734
+ _toggle = false;
1735
+ _formComponents = null;
873
1736
  static make(name) { return configured(new Filter(name)); }
874
- label(label) { this._label = label; return this; }
875
1737
  /** 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; }
1738
+ schema(components) { this._components = components; this._formComponents = null; return this; }
1739
+ /** Form fields of a filter without `schema()`: a toggle instead of a checkbox. */
1740
+ toggle(condition = true) { this._toggle = condition; this._formComponents = null; return this; }
1741
+ checkbox() { return this.toggle(false); }
1742
+ hasSchema() { return this._components.length > 0; }
1743
+ getFormComponents() {
1744
+ if (this.hasSchema())
1745
+ return this._components;
1746
+ return (this._formComponents ??= [(this._toggle ? Toggle : Checkbox).make('isActive').label(this.getLabel())]);
1747
+ }
1748
+ fields() { return this.getFormComponents().filter((c) => typeof c?.getName === 'function'); }
1749
+ getResetState() { return this.hasSchema() ? Object.fromEntries(this.fields().map((c) => [String(c.getName()), null])) : { isActive: false }; }
1750
+ getDefaultState() {
1751
+ if (!this.hasSchema())
1752
+ return { isActive: this._default === true };
1753
+ return Object.fromEntries(this.fields().map((c) => {
1754
+ const value = typeof c.getDefaultState === 'function' ? c.getDefaultState() : null;
1755
+ return [String(c.getName()), filterFieldValue(value)];
1756
+ }));
1757
+ }
886
1758
  sanitizeState(state) {
887
1759
  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]; }));
1760
+ if (!this.hasSchema())
1761
+ return { isActive: received.isActive === true || received.isActive === 1 || received.isActive === '1' };
1762
+ return Object.fromEntries(this.fields().map((c) => { const name = String(c.getName()); const value = received[name]; return [name, filterFieldValue(value)]; }));
889
1763
  }
890
- isActive(state) { return Object.values(this.sanitizeState(state)).some((value) => value !== null); }
1764
+ /** A field left empty, or an unchecked checkbox, does not filter. */
1765
+ isActive(state) { return Object.values(this.sanitizeState(state)).some((value) => value !== null && value !== false); }
891
1766
  apply(query, state) {
892
1767
  const data = this.sanitizeState(state);
893
1768
  if (this._query && this.isActive(data))
894
1769
  (query.modifiers ??= []).push((q) => { this._query(q, data); });
895
1770
  }
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 }));
1771
+ defaultIndicators(data) {
1772
+ if (!this.isActive(data))
1773
+ return null;
1774
+ const indicator = this.getIndicator();
1775
+ return [indicator];
1776
+ }
1777
+ removeIndicator(state, field) { return field === '' ? this.getResetState() : { ...this.sanitizeState(state), [field]: null }; }
1778
+ }
1779
+ /** Value of a custom filter field: a scalar, or a list of scalars (multiple select, checkbox list, 100 at most); anything else, empty strings and empty lists become `null`. */
1780
+ function filterFieldValue(value) {
1781
+ if (Array.isArray(value)) {
1782
+ const items = [...new Set(value.filter((item) => (typeof item === 'string' && item !== '') || typeof item === 'number').map(String))].slice(0, 100);
1783
+ return items.length ? items : null;
901
1784
  }
902
- removeIndicator(state, field) { return field === '' ? this.getDefaultState() : { ...this.sanitizeState(state), [field]: null }; }
1785
+ return ['string', 'number', 'boolean'].includes(typeof value) && value !== '' ? value : null;
903
1786
  }
1787
+ /** Look of a filters action (trigger, apply, reset), changed with `filtersTriggerAction()`, `filtersApplyAction()` and `filtersResetAction()`. */
1788
+ export class FiltersAction {
1789
+ _label = null;
1790
+ _icon = undefined;
1791
+ _color = null;
1792
+ _view = null;
1793
+ _hidden = false;
1794
+ label(label) { this._label = label; return this; }
1795
+ icon(icon) { this._icon = icon; return this; }
1796
+ color(color) { this._color = color; return this; }
1797
+ /** Labeled button. */
1798
+ button() { this._view = 'button'; return this; }
1799
+ link() { this._view = 'link'; return this; }
1800
+ /** Icon only, the label becomes the tooltip and accessible name. */
1801
+ iconButton() { this._view = 'icon-button'; return this; }
1802
+ hidden(condition = true) { this._hidden = condition; return this; }
1803
+ resolve(defaults) {
1804
+ return { label: this._label ?? defaults.label, icon: this._icon === undefined ? defaults.icon : this._icon, color: this._color ?? defaults.color, view: this._view ?? defaults.view, hidden: this._hidden };
1805
+ }
1806
+ }
1807
+ /** Bulk actions grouped in the "Bulk actions" dropdown of the toolbar (`toolbarActions([BulkActionGroup.make([...])])`). */
1808
+ export class BulkActionGroup {
1809
+ _actions;
1810
+ constructor(actions) { this._actions = actions; }
1811
+ static make(actions) { return configured(new BulkActionGroup(actions)); }
1812
+ getActions() { return this._actions; }
1813
+ }
1814
+ /** Upper bound on the rows loaded by an unpaginated table or the `'all'` page option. */
1815
+ export const MAX_UNPAGINATED_RECORDS = 10000;
904
1816
  export class Table {
1817
+ /** Default setup for every table, e.g. `Table.configureUsing((table) => table.paginationPageOptions([10, 25]))`. */
1818
+ static configureUsing(configure) { configureUsing(Table, configure); }
1819
+ // Global settings apply first: options set in `table()` override them.
1820
+ constructor() { configured(this); }
905
1821
  _columns = [];
906
1822
  _recordAction = null;
1823
+ _pageSummary = true;
1824
+ _allTableSummary = true;
1825
+ /** Which summary rows to show: "this page" (`pageCondition`) and the whole table (`allTableCondition`); group summaries are not affected. */
1826
+ summaries(pageCondition = true, allTableCondition = true) { this._pageSummary = pageCondition; this._allTableSummary = allTableCondition; return this; }
1827
+ hasPageSummary() { return this._pageSummary; }
1828
+ hasAllTableSummary() { return this._allTableSummary; }
907
1829
  _recordActions = [];
908
1830
  _heading = null;
909
1831
  _headerActions = [];
@@ -914,6 +1836,12 @@ export class Table {
914
1836
  _defaultSort = null;
915
1837
  _recordUrl = null;
916
1838
  _pageOptions = [5, 10, 25, 50];
1839
+ _paginated = true;
1840
+ _emptyStateHeading = null;
1841
+ _emptyStateDescription = null;
1842
+ _emptyStateIcon = null;
1843
+ _emptyStateActions = [];
1844
+ _emptyState = null;
917
1845
  _stackedOnMobile = false;
918
1846
  _label = 'records';
919
1847
  _groups = [];
@@ -965,8 +1893,17 @@ export class Table {
965
1893
  this._columnsLayout = columns.some((c) => c instanceof ColumnLayout) ? columns.filter((c) => !(c instanceof ColumnGroup)) : null;
966
1894
  return this;
967
1895
  }
968
- /** Column layout, or `null` for a regular table. */
969
- getColumnsLayout() { return this._columnsLayout; }
1896
+ /** Column layout, or `null` for a regular table. The collapsible component is left out (see `getCollapsibleColumnsLayout()`). */
1897
+ getColumnsLayout() {
1898
+ const collapsible = this.getCollapsibleColumnsLayout();
1899
+ return this._columnsLayout && collapsible ? this._columnsLayout.filter((node) => node !== collapsible) : this._columnsLayout;
1900
+ }
1901
+ /** The first top-level layout component marked `collapsible()`, shown behind a toggle button. */
1902
+ getCollapsibleColumnsLayout() { return this._columnsLayout?.find((node) => node instanceof ColumnLayout && node.isCollapsible()) ?? null; }
1903
+ _contentGrid = null;
1904
+ /** Arranges the record cards of a column layout in a grid: `contentGrid({ md: 2, xl: 3 })`. */
1905
+ contentGrid(columns) { this._contentGrid = columns === null ? null : typeof columns === 'number' ? { lg: columns } : columns; return this; }
1906
+ getContentGrid() { return this._contentGrid; }
970
1907
  /** Action mounted when a row is clicked (e.g. `'edit'`), instead of a URL. */
971
1908
  recordAction(name) { this._recordAction = name; return this; }
972
1909
  getRecordAction() { return this._recordAction; }
@@ -978,26 +1915,154 @@ export class Table {
978
1915
  getHeaderActions() { return this._headerActions; }
979
1916
  /** Bulk actions ("Bulk actions" menu); if never called, the host provides its default ones. */
980
1917
  groupedBulkActions(actions) { this._bulkActions = actions; return this; }
981
- getBulkActions() { return this._bulkActions; }
1918
+ /** Bulk actions: those of `groupedBulkActions()`, then those of the `BulkActionGroup`s of `toolbarActions()`; `null` when none is declared. */
1919
+ getBulkActions() {
1920
+ const groups = this._toolbarActions.filter((action) => action instanceof BulkActionGroup);
1921
+ if (this._bulkActions === null && !groups.length)
1922
+ return null;
1923
+ return [...(this._bulkActions ?? []), ...groups.flatMap((group) => group.getActions())];
1924
+ }
1925
+ _toolbarActions = [];
1926
+ /** Actions in the toolbar, left of the search field: buttons, and a `BulkActionGroup` for the bulk actions dropdown. */
1927
+ toolbarActions(actions) { this._toolbarActions = actions; return this; }
1928
+ /** Toolbar actions other than bulk action groups. */
1929
+ getToolbarActions() { return this._toolbarActions.filter((action) => !(action instanceof BulkActionGroup)); }
982
1930
  /** Row actions: `Action`s or an `ActionGroup` (dropdown). */
983
1931
  recordActions(actions) { this._recordActions = actions; return this; }
984
1932
  getRecordActions() { return this._recordActions; }
1933
+ /** Actions given to columns with `action()` (not those named after a record action). */
1934
+ getColumnActions() { return this.getColumns().map((column) => column.getAction()).filter((action) => action !== null); }
985
1935
  /** CSS classes added to a row depending on its record. */
986
1936
  recordClasses(resolver) { this._recordClasses = resolver; return this; }
987
1937
  getRecordClasses(record) { return this._recordClasses ? this._recordClasses(record) : null; }
988
1938
  filters(filters, options = {}) { this._filters = filters; if (options.layout)
989
- this._filtersLayout = options.layout; return this; }
990
- filtersLayout(layout) { this._filtersLayout = layout; return this; }
1939
+ this.filtersLayout(options.layout); return this; }
1940
+ filtersLayout(layout) { if (!FILTERS_LAYOUTS.includes(layout))
1941
+ throw new Error(`Unknown filters layout: ${layout}`); this._filtersLayout = layout; return this; }
991
1942
  getFiltersLayout() { return this._filtersLayout; }
992
- /** No-op kept for API compatibility: filters always wait for the "Apply" button. */
993
- deferFilters() { return this; }
1943
+ _deferFilters = true;
1944
+ _filtersFormColumns = null;
1945
+ _filtersFormWidth = null;
1946
+ _filtersFormMaxHeight = null;
1947
+ _filtersResetPosition = 'header';
1948
+ _filterIndicatorsHidden = false;
1949
+ _persist = { filters: false, search: false, columnSearches: false, sort: false };
1950
+ _recordActionsPosition = 'after-columns';
1951
+ _recordActionsAlignment = null;
1952
+ /**
1953
+ * Where row actions go: after the columns (default), before the columns (after the checkbox), or before the checkbox
1954
+ * (`before-cells`). `after-cells` is the same as `after-columns`.
1955
+ */
1956
+ recordActionsPosition(position) { if (['before-columns', 'before-cells', 'after-columns', 'after-cells'].includes(position))
1957
+ this._recordActionsPosition = position; return this; }
1958
+ getRecordActionsPosition() { return this._recordActionsPosition; }
1959
+ /** Alignment of row actions in their cell. */
1960
+ recordActionsAlignment(alignment) { this._recordActionsAlignment = alignment; return this; }
1961
+ getRecordActionsAlignment() { return this._recordActionsAlignment; }
1962
+ _isRecordSelectable = null;
1963
+ _maxSelectableRecords = null;
1964
+ _selectsCurrentPageOnly = false;
1965
+ /** Records for which this returns `false` have no checkbox, and are ignored by bulk actions even if the browser sends them. */
1966
+ checkIfRecordIsSelectableUsing(callback) { this._isRecordSelectable = callback; return this; }
1967
+ hasRecordSelectabilityCheck() { return this._isRecordSelectable !== null; }
1968
+ async isRecordSelectable(record) { return this._isRecordSelectable ? Boolean(await this._isRecordSelectable(record)) : true; }
1969
+ /** Maximum number of records the user may select at once (also enforced on the server). */
1970
+ maxSelectableRecords(count) { this._maxSelectableRecords = count !== null && count > 0 ? Math.floor(count) : null; return this; }
1971
+ getMaxSelectableRecords() { return this._maxSelectableRecords; }
1972
+ /** "Select all" only selects the records of the current page. */
1973
+ selectCurrentPageOnly(condition = true) { this._selectsCurrentPageOnly = condition; return this; }
1974
+ selectsCurrentPageOnly() { return this._selectsCurrentPageOnly; }
1975
+ /** `false`: filters apply as soon as a field changes, without the "Apply" button. */
1976
+ deferFilters(condition = true) { this._deferFilters = condition; return this; }
1977
+ _filterActions = {};
1978
+ /** Customizes the button that opens the filters: `filtersTriggerAction((action) => action.button().label('Filter'))`. */
1979
+ filtersTriggerAction(configure) { this._filterActions.trigger = configure; return this; }
1980
+ /** Customizes the "Apply" button of deferred filters. */
1981
+ filtersApplyAction(configure) { this._filterActions.apply = configure; return this; }
1982
+ /** Customizes the "Reset" button (`hidden()` removes it). */
1983
+ filtersResetAction(configure) { this._filterActions.reset = configure; return this; }
1984
+ getFiltersActionView(kind) {
1985
+ const defaults = {
1986
+ trigger: { label: t('filament-tables::table.actions.filter.label'), icon: 'm-funnel', color: 'gray', view: 'icon-button' },
1987
+ apply: { label: t('filament-tables::table.filters.actions.apply.label'), icon: null, color: 'primary', view: 'button' },
1988
+ reset: { label: t('filament-tables::table.filters.actions.reset.label'), icon: null, color: this._filtersResetPosition === 'footer' ? 'gray' : 'danger', view: this._filtersResetPosition === 'footer' ? 'button' : 'link' },
1989
+ }[kind];
1990
+ const configure = this._filterActions[kind];
1991
+ if (!configure)
1992
+ return { ...defaults, hidden: false, customized: false };
1993
+ const action = new FiltersAction();
1994
+ return { ...(configure(action) ?? action).resolve(defaults), customized: true };
1995
+ }
1996
+ _filtersFormSchema = null;
1997
+ /**
1998
+ * Arranges the filters form yourself: the function receives each filter's form group, keyed by filter name, and returns
1999
+ * the form components (e.g. sections). Filters left out are not shown.
2000
+ */
2001
+ filtersFormSchema(schema) { this._filtersFormSchema = schema; return this; }
2002
+ getFiltersFormSchema() { return this._filtersFormSchema; }
2003
+ hasDeferredFilters() { return this._deferFilters; }
2004
+ /** Columns of the filters form grid (1 in a dropdown or modal, up to 5 above or below the content by default). */
2005
+ filtersFormColumns(columns) { this._filtersFormColumns = columns; return this; }
2006
+ getFiltersFormColumns() {
2007
+ if (this._filtersFormColumns !== null)
2008
+ return this._filtersFormColumns;
2009
+ return ['above-content', 'above-content-collapsible', 'below-content'].includes(this._filtersLayout) ? { sm: 2, lg: 3, xl: 4, '2xl': 5 } : 1;
2010
+ }
2011
+ /** Width of the filters dropdown, modal or side panel: `xs` to `7xl`, `full`, `screen`... */
2012
+ filtersFormWidth(width) { this._filtersFormWidth = width === null || FILTERS_WIDTHS.includes(width) ? width : null; return this; }
2013
+ getFiltersFormWidth() { return this._filtersFormWidth; }
2014
+ /** Maximum height of the filters dropdown, as a CSS length; it scrolls beyond. */
2015
+ filtersFormMaxHeight(height) { this._filtersFormMaxHeight = height !== null && /^[0-9.]+(rem|px|em|vh|%)$/.test(height) ? height : null; return this; }
2016
+ getFiltersFormMaxHeight() { return this._filtersFormMaxHeight; }
2017
+ /** `footer`: the "Reset" button goes next to "Apply" instead of the filters heading. */
2018
+ filtersResetActionPosition(position) { this._filtersResetPosition = position === 'footer' ? 'footer' : 'header'; return this; }
2019
+ getFiltersResetActionPosition() { return this._filtersResetPosition; }
2020
+ /** Hides the active filter badges above the table. */
2021
+ hiddenFilterIndicators(condition = true) { this._filterIndicatorsHidden = condition; return this; }
2022
+ areFilterIndicatorsHidden() { return this._filterIndicatorsHidden; }
2023
+ /** Keeps the applied filters for the user's browser session (restored when the page is opened again). */
2024
+ persistFiltersInSession(condition = true) { this._persist.filters = condition; return this; }
2025
+ persistSearchInSession(condition = true) { this._persist.search = condition; return this; }
2026
+ persistColumnSearchesInSession(condition = true) { this._persist.columnSearches = condition; return this; }
2027
+ persistSortInSession(condition = true) { this._persist.sort = condition; return this; }
2028
+ _persistGrouping = false;
2029
+ persistGroupingInSession(condition = true) { this._persistGrouping = condition; return this; }
2030
+ persistsGroupingInSession() { return this._persistGrouping; }
2031
+ persistsFiltersInSession() { return this._persist.filters; }
2032
+ persistsSearchInSession() { return this._persist.search; }
2033
+ persistsColumnSearchesInSession() { return this._persist.columnSearches; }
2034
+ persistsSortInSession() { return this._persist.sort; }
994
2035
  defaultSort(column, direction = 'asc') { this._defaultSort = { column, direction }; return this; }
995
2036
  recordUrl(resolver) { this._recordUrl = resolver; return this; }
996
2037
  _reorderColumn = null;
997
2038
  /** Lets users reorder rows by hand; `column` stores the position. */
998
2039
  reorderable(column) { this._reorderColumn = column; return this; }
999
2040
  getReorderColumn() { return this._reorderColumn; }
1000
- paginationPageOptions(options) { this._pageOptions = options; return this; }
2041
+ /** Page sizes offered; `'all'` shows every record (up to `MAX_UNPAGINATED_RECORDS`). */
2042
+ paginationPageOptions(options) { this._pageOptions = options.filter((option) => option === 'all' || (Number.isInteger(option) && option > 0)); return this; }
2043
+ /** `false`: no pagination, every record is shown (up to `MAX_UNPAGINATED_RECORDS`). An array sets the page options. */
2044
+ paginated(condition = true) {
2045
+ if (Array.isArray(condition)) {
2046
+ this.paginationPageOptions(condition);
2047
+ condition = true;
2048
+ }
2049
+ this._paginated = condition;
2050
+ return this;
2051
+ }
2052
+ isPaginated() { return this._paginated && !this._groupsOnly; }
2053
+ /** Heading of the empty state, instead of "No <records>". */
2054
+ emptyStateHeading(heading) { this._emptyStateHeading = heading; return this; }
2055
+ emptyStateDescription(description) { this._emptyStateDescription = description; return this; }
2056
+ emptyStateIcon(icon) { this._emptyStateIcon = icon; return this; }
2057
+ /** Actions shown in the empty state, e.g. a create action. They are mounted like table header actions. */
2058
+ emptyStateActions(actions) { this._emptyStateActions = actions; return this; }
2059
+ getEmptyStateActions() { return this._emptyStateActions; }
2060
+ /** Replaces the whole empty state with your HTML (inserted as it is: escape your data with `e()`). */
2061
+ emptyState(html) { this._emptyState = html; return this; }
2062
+ getEmptyStateHeading() { const heading = typeof this._emptyStateHeading === 'function' ? this._emptyStateHeading() : this._emptyStateHeading; return heading ?? t('filament-tables::table.empty.heading', { model: this.getPluralModelLabel() }); }
2063
+ getEmptyStateDescription() { return typeof this._emptyStateDescription === 'function' ? this._emptyStateDescription() : this._emptyStateDescription; }
2064
+ getEmptyStateIcon() { return this._emptyStateIcon ?? 'o-x-mark'; }
2065
+ async getCustomEmptyState() { return typeof this._emptyState === 'function' ? this._emptyState() : this._emptyState; }
1001
2066
  _defaultPageOption = null;
1002
2067
  /** Rows per page when the table opens. */
1003
2068
  defaultPaginationPageOption(option) { this._defaultPageOption = option; return this; }
@@ -1008,18 +2073,18 @@ export class Table {
1008
2073
  getPaginationMode() { return this._paginationMode; }
1009
2074
  stackedOnMobile(condition = true) { this._stackedOnMobile = condition; return this; }
1010
2075
  pluralModelLabel(label) { this._label = label; return this; }
1011
- getColumns() { return this._columns; }
1012
- getColumn(name) { return this._columns.find((c) => c.name === name); }
2076
+ getColumns() { return this._columns.filter((column) => !column.isHidden()); }
2077
+ getColumn(name) { return this.getColumns().find((c) => c.name === name); }
1013
2078
  getFilters() { return this._filters; }
1014
2079
  getDefaultSort() { return this._defaultSort; }
1015
2080
  getRecordUrl(record) { return this._recordUrl ? this._recordUrl(record) : null; }
1016
2081
  getPageOptions() { return this._pageOptions; }
1017
2082
  isStackedOnMobile() { return this._stackedOnMobile; }
1018
2083
  getPluralModelLabel() { return this._label; }
1019
- hasToggleableColumns() { return this._columns.some((c) => c.isToggleable()); }
2084
+ hasToggleableColumns() { return this.getColumns().some((c) => c.isToggleable()); }
1020
2085
  /** Default column manager state. */
1021
2086
  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 }));
2087
+ return this.getColumns().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
2088
  }
1024
2089
  getDefaultFilterState() { return Object.fromEntries(this._filters.map((f) => [f.name, f.getDefaultState()])); }
1025
2090
  }
@@ -1048,7 +2113,7 @@ export function getTableQuery(table, host) {
1048
2113
  };
1049
2114
  const grouping = getTableGrouping(table, host);
1050
2115
  if (grouping)
1051
- query.groupSort = { column: grouping.group.getColumn(), direction: grouping.direction };
2116
+ query.groupSort = { column: grouping.group.getColumn(), direction: grouping.direction, ...(grouping.group.getOrderQueryUsing() ? { modify: grouping.group.getOrderQueryUsing() } : {}) };
1052
2117
  // The host can restrict the query (active list tab).
1053
2118
  host.modifyTableQuery?.(query);
1054
2119
  for (const filter of table.getFilters()) {
@@ -1081,7 +2146,42 @@ export function countActiveFilters(table, host) {
1081
2146
  }
1082
2147
  /** Indicators of the applied filters (only filters that declare some have them). */
1083
2148
  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 })) : []; });
2149
+ 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, removable: indicator.removable !== false, color: indicator.color ?? null })) : []; });
2150
+ }
2151
+ /** Whether an applied filter can be removed: none of its indicators is marked `removable(false)`. */
2152
+ export async function isTableFilterRemovable(table, host, filterName) {
2153
+ const filter = table.getFilters().find((candidate) => candidate.name === filterName);
2154
+ if (!filter)
2155
+ return false;
2156
+ const state = host.tableFilters?.[filter.name];
2157
+ if (!state || typeof state !== 'object' || !filter.getIndicators)
2158
+ return true;
2159
+ await filter.loadIndicatorData?.(state, host);
2160
+ return filter.getIndicators(state).every((indicator) => indicator.removable !== false);
2161
+ }
2162
+ /** Markup of a customized filters action: an icon button, a button or a link, with an optional badge. */
2163
+ function filterActionHtml(view, attributes, badge) {
2164
+ const colors = (component) => (view.color && view.color !== 'gray' ? (colorManager?.getComponentClasses(component, view.color) ?? []) : []);
2165
+ const iconHtml = view.icon ? icon(view.icon) : '';
2166
+ if (view.view === 'icon-button')
2167
+ return `<button title="${e(view.label)}" aria-label="${e(view.label)}" ${attributes} class="${cls(['fi-force-enabled', 'fi-ac-icon-btn-action', 'fi-icon-btn', 'fi-size-md', ...colors('icon-button')])}">${iconHtml}${badge !== null ? `<div class="fi-icon-btn-badge-ctn">${badge}</div>` : ''}</button>`;
2168
+ if (view.view === 'link')
2169
+ return `<button ${attributes} class="${cls(['fi-ac-link-action', 'fi-link', 'fi-size-md', ...colors('link')])}">${iconHtml}<span class="fi-link-label">${e(view.label)}</span>${badge !== null ? `<div class="fi-link-badge-ctn">${badge}</div>` : ''}</button>`;
2170
+ return `<button ${attributes} class="${cls(['fi-ac-btn-action', 'fi-btn', 'fi-size-md', ...colors('button')])}">${iconHtml}${e(view.label)}${badge !== null ? `<div class="fi-btn-badge-ctn">${badge}</div>` : ''}</button>`;
2171
+ }
2172
+ /** Keys whose record passes `checkIfRecordIsSelectableUsing()`, loaded in chunks. Without a check, the keys are returned as they are. */
2173
+ export async function filterSelectableKeys(table, adapter, keys, chunkSize = 100) {
2174
+ if (!table.hasRecordSelectabilityCheck())
2175
+ return keys;
2176
+ const kept = [];
2177
+ for (let index = 0; index < keys.length; index += chunkSize) {
2178
+ const chunk = keys.slice(index, index + chunkSize);
2179
+ const records = await Promise.all(chunk.map((key) => adapter.find(key)));
2180
+ for (let position = 0; position < chunk.length; position++)
2181
+ if (records[position] && (await table.isRecordSelectable(records[position])))
2182
+ kept.push(chunk[position]);
2183
+ }
2184
+ return kept;
1085
2185
  }
1086
2186
  /** Cycles a column's sort: none → asc → desc → none. */
1087
2187
  export function sortTable(host, column = null, direction = null) {
@@ -1096,8 +2196,12 @@ export function sortTable(host, column = null, direction = null) {
1096
2196
  /** Loads the page of records matching the host's state. */
1097
2197
  export async function getTableRecords(table, host, adapter) {
1098
2198
  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 });
2199
+ // The page size comes from the browser: only a declared option is accepted.
2200
+ const requested = host.tableRecordsPerPage === 'all' || host.tableRecordsPerPage === 'all' ? 'all' : Number(host.tableRecordsPerPage);
2201
+ const option = options.includes(requested) ? requested : options[0];
2202
+ if (!table.isPaginated() || option === 'all')
2203
+ return adapter.paginate({ ...getTableQuery(table, host), page: 1, perPage: MAX_UNPAGINATED_RECORDS });
2204
+ return adapter.paginate({ ...getTableQuery(table, host), page: Number(host.paginators.page) || 1, perPage: option });
1101
2205
  }
1102
2206
  /** Pagination elements: the first and last pages and a window around the current page, with `...` gaps. */
1103
2207
  export function paginationElements(current, last, onEachSide = 0) {
@@ -1117,11 +2221,16 @@ export const useColorManager = (manager) => { colorManager = manager; };
1117
2221
  export const REORDER_LIMIT = 1000;
1118
2222
  /** Summary row; with `groupsOnly`, a leading label cell, then one cell per column. */
1119
2223
  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('');
2224
+ const items = (cell) => cell.items.map((item) => {
2225
+ const label = item.label !== null && item.label !== '' ? `<span class="fi-ta-${item.type}-summary-label${item.labelHidden ? ' fi-sr-only' : ''}">${e(item.label)}</span>` : '';
2226
+ if (item.type === 'values')
2227
+ return `<div class="fi-ta-values-summary">${label}${item.values?.length ? `<ul${item.bulleted ? ' class="fi-bulleted"' : ''}>${item.values.map((value) => `<li>${e(value)}</li>`).join('')}</ul>` : ''}</div>`;
2228
+ return `<div class="fi-ta-${item.type}-summary">${label}<span>${e(item.value)}</span></div>`;
2229
+ }).join('');
1121
2230
  const heading = groupsOnly
1122
2231
  ? `<th scope="row" class="fi-ta-cell fi-ta-summary-row-heading-cell fi-align-start">${e(row.heading)}</th>`
1123
2232
  : `<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>`;
2233
+ return `<tr class="fi-ta-row fi-ta-summary-row${row.striped ? ' fi-striped' : ''}">${placeholders.selection ? '<td></td>' : ''}${placeholders.actionsBefore ? '<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
2234
  }
1126
2235
  /**
1127
2236
  * Table summaries: a single "Summary" row without pagination; with several pages, a header then "This page" and "All …" rows.
@@ -1130,7 +2239,7 @@ function summaryRowHtml(row, span, groupsOnly, placeholders) {
1130
2239
  async function summarize(table, host, adapter, visible, page, options) {
1131
2240
  // Groups only: the grouped column has no summary cell (its label already leads the row).
1132
2241
  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() : []));
2242
+ const lists = columns.map((c) => (c instanceof TextColumn ? c.getSummarizers().filter((summarizer) => !summarizer.isHidden()) : []));
1134
2243
  if (!lists.some((list) => list.length) || !page.records.length)
1135
2244
  return null;
1136
2245
  // The first cell holds the row label and spans up to the first summarized column after it.
@@ -1149,28 +2258,50 @@ async function summarize(table, host, adapter, visible, page, options) {
1149
2258
  min: Object.fromEntries(names('range').map((name) => [name, present(name).map(String).sort()[0] ?? null])),
1150
2259
  max: Object.fromEntries(names('range').map((name) => [name, present(name).map(String).sort().pop() ?? null])),
1151
2260
  };
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
- }));
2261
+ // `using()` summarizers get the records of the summarized dataset, loaded once per row.
2262
+ const loader = (dataset) => { let cache = null; return () => (cache ??= Array.isArray(dataset) ? Promise.resolve(dataset) : adapter.paginate({ ...dataset, page: 1, perPage: MAX_UNPAGINATED_RECORDS }).then((result) => result.records)); };
2263
+ // The displayed page as a query (its record keys), for summarizers scoped with `query()`.
2264
+ const pageQuery = { ...query, keys: page.records.map((record) => adapter.getKey(record)) };
2265
+ const cells = async (values, dataset) => {
2266
+ const records = loader(dataset);
2267
+ const datasetQuery = Array.isArray(dataset) ? pageQuery : dataset;
2268
+ return Promise.all(columns.slice(span).map(async (c, i) => ({
2269
+ alignment: c.getAlignment() ?? 'start',
2270
+ items: (await Promise.all(lists[i + span].map(async (s) => {
2271
+ const scope = s.getQueryScope();
2272
+ if (!scope)
2273
+ return s.summarize(columnOf(c), values, s.hasCustomCalculation() ? await s.compute({ column: c.name, records }) : undefined);
2274
+ // Scoped summarizer: its own aggregates and records, on the dataset restricted by its query.
2275
+ const scoped = { ...datasetQuery, modifiers: [...(datasetQuery.modifiers ?? []), (q) => { scope(q); }] };
2276
+ return s.summarize(columnOf(c), await aggregate(scoped), s.hasCustomCalculation() ? await s.compute({ column: c.name, records: loader(scoped) }) : undefined);
2277
+ }))).filter((item) => item !== null),
2278
+ })));
2279
+ };
1156
2280
  const label = table.getPluralModelLabel();
1157
- const hasPageSummary = !options.groupsOnly && page.lastPage > 1;
2281
+ const showPage = table.hasPageSummary(), showAll = table.hasAllTableSummary();
2282
+ const isMultiPage = !options.groupsOnly && page.lastPage > 1;
2283
+ // Several pages: "this page" and "all" rows under a heading row; a single page: one "Summary" row.
2284
+ const hasPageSummary = isMultiPage && (showPage || showAll);
1158
2285
  const rowHtml = (row) => summaryRowHtml(row, span, options.groupsOnly, options.placeholders);
1159
2286
  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) => {
2287
+ ? `<tr class="fi-ta-row fi-ta-summary-header-row fi-striped">${options.placeholders.selection ? '<td></td>' : ''}${options.placeholders.actionsBefore ? '<td></td>' : ''}${columns.map((c, i) => {
1161
2288
  const heading = i === 0 ? t('filament-tables::table.summary.heading', { label }) : lists[i].length ? c.getLabel() : null;
1162
2289
  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
2290
  }).join('')}${options.placeholders.actions ? '<td></td>' : ''}</tr>`
1164
2291
  : '';
1165
2292
  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 }];
2293
+ ? [
2294
+ ...(showPage ? [{ heading: t('filament-tables::table.summary.subheadings.page', { label }), cells: await cells(onPage, page.records), striped: false }] : []),
2295
+ ...(showAll ? [{ heading: t('filament-tables::table.summary.subheadings.all', { label }), cells: await cells(all, query), striped: false }] : []),
2296
+ ]
2297
+ : isMultiPage || !(showPage || showAll) ? [] : [{ heading: t('filament-tables::table.summary.heading', { label }), cells: await cells(all, query), striped: true }];
1168
2298
  return {
1169
2299
  html: header + rows.map(rowHtml).join(''),
1170
2300
  /** Group summary row: aggregates over all the group's records (not only those on the page). */
1171
2301
  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 });
2302
+ const groupQuery = { ...query, modifiers: [...(query.modifiers ?? []), (q) => group.scope(q, key)] };
2303
+ const values = await aggregate(groupQuery);
2304
+ return rowHtml({ heading: options.groupsOnly ? (title ?? '') : t('filament-tables::table.summary.subheadings.group', { group: title ?? '', label }), cells: await cells(values, groupQuery), striped: false });
1174
2305
  },
1175
2306
  };
1176
2307
  }
@@ -1224,7 +2355,7 @@ export async function renderTable(table, host, adapter, options = {}) {
1224
2355
  const cells = [];
1225
2356
  if (!groupsOnly)
1226
2357
  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() });
2358
+ cells.push({ name: column.name, cssName: column.getCssName(), alignment: column.getAlignment(), responsive: column.getResponsiveClasses(), label: column.getLabel(), url: column.getUrl(record), html: await column.toHtml(record, key), plain: column.isClickDisabled(), mountExpression: !reordering && column.getActionName() !== null && !column.isClickDisabled() ? `mountAction(${js(column.getActionName())}, {}, ${js({ table: true, recordKey: key })})` : null });
1228
2359
  const recordAction = table.getRecordAction();
1229
2360
  if (reordering)
1230
2361
  for (const cell of cells)
@@ -1235,8 +2366,11 @@ export async function renderTable(table, host, adapter, options = {}) {
1235
2366
  for (const node of columnsLayout)
1236
2367
  contentHtml += await renderColumnLayout(node, record, key, visibleSet);
1237
2368
  }
2369
+ const collapsibleLayout = columnsLayout && !groupsOnly && !reordering ? table.getCollapsibleColumnsLayout() : null;
2370
+ const collapsibleHtml = collapsibleLayout && !collapsibleLayout.isHidden() ? await renderColumnLayout(collapsibleLayout, record, key, visibleSet) : null;
1238
2371
  rows.push({
1239
- contentHtml, summaryBefore, groupSummaryHtml: '',
2372
+ selectable: selectable && !reordering ? await table.isRecordSelectable(record) : true,
2373
+ contentHtml, collapsibleHtml, collapsed: collapsibleLayout?.isCollapsed() ?? false, summaryBefore, groupSummaryHtml: '',
1240
2374
  groupHeader, groupKey, collapsedExpression: grouping?.group.isCollapsible() ? `isGroupCollapsed(${js(groupTitle)})` : null,
1241
2375
  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
2376
  wasClickable: reordering && (recordAction !== null || table.getRecordUrl(record) !== null),
@@ -1247,8 +2381,9 @@ export async function renderTable(table, host, adapter, options = {}) {
1247
2381
  });
1248
2382
  }
1249
2383
  const hasRowActions = rows.some((row) => row.actionsHtml !== null || row.actions.length > 0);
2384
+ const actionsBefore = table.getRecordActionsPosition().startsWith('before');
1250
2385
  // 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 } });
2386
+ const summary = reordering ? null : await summarize(table, host, adapter, visible, page, { group: grouping?.group ?? null, groupsOnly, placeholders: { selection: selectable, actions: hasRowActions && !actionsBefore, actionsBefore: hasRowActions && actionsBefore } });
1252
2387
  let trailingGroupSummary = '';
1253
2388
  if (summary && grouping) {
1254
2389
  for (const row of rows)
@@ -1271,23 +2406,28 @@ export async function renderTable(table, host, adapter, options = {}) {
1271
2406
  }
1272
2407
  const scopes = host.getRenderHookScopes?.() ?? [];
1273
2408
  return views.render('filament-tables::index', {
1274
- contentLayout: columnsLayout !== null,
2409
+ contentLayout: columnsLayout !== null, contentGrid: table.getContentGrid(),
1275
2410
  hook: (name, data = {}) => renderHook(name, scopes, data), hasHook: (name) => hasRenderHook(name, scopes),
1276
2411
  visibleColumns: visible, isReordering: reordering,
1277
2412
  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 },
2413
+ grouping: { active: grouping !== null, directionHidden: table.isGroupingDirectionSettingHidden(), inDropdownOnDesktop: table.areGroupingSettingsInDropdownOnDesktop(), collapsedByDefault: table.areGroupsCollapsedByDefault(), groupsOnly },
1279
2414
  id: host.id, targets: LOADING_TARGETS, table, rows, page, number,
1280
2415
  reorder: reorderColumn !== null ? { active: reordering } : null,
1281
2416
  // No checkboxes without bulk actions (or in groups-only mode), no actions column without row actions.
1282
- selectable, hasRowActions,
2417
+ toolbarActions: reordering ? [] : (options.toolbarActions ?? []),
2418
+ selectable, hasRowActions, actionsPosition: table.getRecordActionsPosition(), actionsAlignment: table.getRecordActionsAlignment() ?? (actionsBefore ? 'start' : null),
1283
2419
  // The key changes on every render to force the checkbox to be replaced.
1284
2420
  randomKey: () => Array.from(randomBytes(16), (b) => 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'[b % 62]).join(''),
1285
2421
  // "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'),
2422
+ allCount: table.getPaginationMode() === 'simple' ? '' : table.selectsCurrentPageOnly() ? page.records.length : table.hasRecordSelectabilityCheck() && selectable ? (await filterSelectableKeys(table, adapter, await adapter.listKeys(getTableQuery(table, host)))).length : page.total,
2423
+ maxSelectableRecords: table.getMaxSelectableRecords(), selectsCurrentPageOnly: table.selectsCurrentPageOnly(), resultsCount: table.getPaginationMode() === 'simple' ? rows.length : page.total,
2424
+ columns: visible.map((c) => ({ name: c.name, grouped: c._group !== null, cssName: c.getCssName(), alignment: c.getAlignment(), responsive: c.getResponsiveClasses(), width: c.getWidth(), growable: c.getWidth() === null && c.canGrow(), wrapHeader: c.canHeaderWrap(), label: c.getLabel(), sortable: !reordering && c.isSortable(), individuallySearchable: !reordering && c.isIndividuallySearchable(), sort: sortColumn === c.name ? (sortDirection === 'desc' ? 'descending' : 'ascending') : 'none' })),
2425
+ linkClasses: classes('link', 'primary'), dangerLinkClasses: classes('link', 'danger'), primaryButtonClasses: classes('button', 'primary'), badgeClasses: classes('badge', 'primary'), indicatorBadgeClasses: classes('badge', 'primary'), grayButtonClasses: classes('button', 'gray'), indicatorColorClasses: (color) => classes('badge', color),
1289
2426
  bulkActions: options.bulkActions ?? [],
1290
- filtersForm: options.filtersForm ?? null, activeFiltersCount: countActiveFilters(table, host), filtersLayout: table.getFiltersLayout(), filterIndicators: getFilterIndicators(table, host),
2427
+ filtersForm: options.filtersForm ?? null, activeFiltersCount: countActiveFilters(table, host), filtersLayout: table.getFiltersLayout(), filterIndicators: table.areFilterIndicatorsHidden() ? [] : getFilterIndicators(table, host),
2428
+ filterActions: { trigger: table.getFiltersActionView('trigger'), apply: table.getFiltersActionView('apply'), reset: table.getFiltersActionView('reset') },
2429
+ filterActionHtml: (view, attributes, badge = null) => filterActionHtml(view, attributes, badge),
2430
+ filtersDeferred: table.hasDeferredFilters(), filtersResetPosition: table.getFiltersResetActionPosition(), filtersFormWidth: table.getFiltersFormWidth(), filtersFormMaxHeight: table.getFiltersFormMaxHeight(), modal,
1291
2431
  hasColumnManager: !reordering && table.hasToggleableColumns(),
1292
2432
  afterContainer: options.after ?? '', heading: table.getHeading(), headerActions: options.headerActions ?? [], summaryHtml: summary?.html ?? '', trailingGroupSummary,
1293
2433
  // Column group header row: one cell per ungrouped column, one merged cell per run of columns in the same group.
@@ -1300,7 +2440,8 @@ export async function renderTable(table, host, adapter, options = {}) {
1300
2440
  return runs;
1301
2441
  }, []) : null,
1302
2442
  isSearchable: !reordering && table.getColumns().some((c) => c.isGloballySearchable()),
1303
- pagination: reordering || groupsOnly ? '' : views.render('filament::components.pagination', {
2443
+ emptyState: { custom: await table.getCustomEmptyState(), heading: table.getEmptyStateHeading(), description: table.getEmptyStateDescription(), icon: table.getEmptyStateIcon(), actions: options.emptyStateActions ?? [] },
2444
+ pagination: reordering || groupsOnly || !table.isPaginated() ? '' : views.render('filament::components.pagination', {
1304
2445
  id: host.id, targets: LOADING_TARGETS + ',paginators', page, number, pageOptions: table.getPageOptions(), elements: paginationElements(page.page, page.lastPage), simple: table.getPaginationMode() === 'simple',
1305
2446
  attrs: (o) => new Attributes(o),
1306
2447
  }),