@navette-js/infolists 1.0.0-beta.3 → 1.0.0-beta.4

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.
Files changed (3) hide show
  1. package/lib/index.d.ts +334 -56
  2. package/lib/index.js +891 -137
  3. package/package.json +3 -3
package/lib/index.js CHANGED
@@ -1,198 +1,890 @@
1
1
  // @navette-js/infolists — read-only entries (TextEntry, IconEntry…).
2
- import { Component, Schema } from '@navette-js/schemas';
3
- import { Attributes, e, icon, js, configured } from '@navette-js/support';
4
- import { t } from '@navette-js/support/i18n';
2
+ import { Component, Schema, Text, renderInlineItems } from '@navette-js/schemas';
3
+ import { Attributes, HtmlString, cls, e, evaluate, findShade, icon, isSafeUrl, js, markdownToHtml, sanitizeHtml, configured } from '@navette-js/support';
4
+ import { diffForHumans, t, tc } from '@navette-js/support/i18n';
5
5
  const headline = (s) => s.replace(/([a-z0-9])([A-Z])/g, '$1 $2').replace(/[_-]+/g, ' ').toLowerCase().replace(/^./, (c) => c.toUpperCase());
6
6
  /** Default label: "Department" for `department.name`. */
7
7
  const defaultLabel = (name) => headline(name.includes('.') ? name.split('.').slice(-2, -1)[0] : name);
8
8
  let colorManager = null;
9
9
  export const useColorManager = (manager) => { colorManager = manager; };
10
+ /** Filament-style merge: the defaults come first (and keep their position), `class` and `style` are concatenated defaults-first. */
11
+ function mergeAttrs(defaults, attributes) {
12
+ const out = {};
13
+ for (const [key, value] of Object.entries(defaults))
14
+ if (value !== null && value !== undefined && value !== false)
15
+ out[key] = value;
16
+ for (const [key, value] of Object.entries(attributes)) {
17
+ if (value === null || value === undefined || value === false)
18
+ continue;
19
+ out[key] = (key === 'class' || key === 'style') && out[key] ? [out[key], value].filter((part) => part !== '').join(' ') : value;
20
+ }
21
+ return out;
22
+ }
23
+ /** Appends classes (the attribute keeps its position, or goes last). */
24
+ function withClass(attributes, list) {
25
+ const added = cls(list);
26
+ return added ? { ...attributes, class: attributes.class ? `${attributes.class} ${added}` : added } : attributes;
27
+ }
28
+ function withStyle(attributes, styles) {
29
+ const added = styles.filter(Boolean).join('; ');
30
+ if (!added)
31
+ return attributes;
32
+ const existing = String(attributes.style ?? '').replace(/[;\s]+$/, '');
33
+ return { ...attributes, style: `${existing ? `${existing}; ` : ''}${added};` };
34
+ }
35
+ /** Values are expected to be escaped already; only `"` is escaped again. */
36
+ const renderAttrs = (attributes) => new Attributes(attributes).toHtml();
37
+ const ATTRIBUTE_NAME = /^[a-zA-Z_:@][a-zA-Z0-9_:.@-]*$/;
38
+ /** Developer-supplied attributes: valid names only, values escaped. */
39
+ const escapeAttributes = (attributes) => Object.fromEntries(Object.entries(attributes)
40
+ .filter(([name, value]) => ATTRIBUTE_NAME.test(name) && value !== null && value !== undefined && value !== false)
41
+ .map(([name, value]) => [name, value === true ? true : e(value)]));
42
+ const isBlank = (value) => value === null || value === undefined || (typeof value === 'string' && value.trim() === '') || (Array.isArray(value) && value.length === 0);
43
+ const ALIGNMENTS = new Set(['start', 'center', 'end', 'left', 'right', 'justify', 'between']);
44
+ const alignmentClass = (alignment) => (alignment && ALIGNMENTS.has(alignment) ? `fi-align-${alignment}` : null);
45
+ const safeName = (value) => value.replace(/[^a-zA-Z0-9_-]/g, '');
46
+ const ICON_NAME = /^[a-zA-Z0-9][a-zA-Z0-9-]*$/;
47
+ const renderIcon = (name, options = {}) => (typeof name === 'string' && ICON_NAME.test(name) ? icon(name, options) : '');
48
+ const tooltipJs = (text) => (text === null || text === undefined || text === '' ? null : `{ content: ${js(String(text))}, theme: $store.theme, allowHTML: false }`);
49
+ const decodeEntities = (html) => html.replace(/&nbsp;/g, ' ').replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&quot;/g, '"').replace(/&#0?39;/g, "'").replace(/&amp;/g, '&');
50
+ const stripTags = (html) => decodeEntities(html.replace(/<[^>]*>/g, ''));
51
+ const isNumericValue = (value) => (typeof value === 'number' && Number.isFinite(value)) || (typeof value === 'string' && value.trim() !== '' && Number.isFinite(Number(value)));
52
+ /** Simple CSS length (`40px`, `2.5rem`, `50%`, `auto`); a number is a length in pixels. Anything else is rejected. */
53
+ const cssLength = (value) => {
54
+ if (typeof value === 'number')
55
+ return Number.isFinite(value) && value >= 0 ? `${value}px` : null;
56
+ if (typeof value !== 'string')
57
+ return null;
58
+ const trimmed = value.trim();
59
+ return /^(\d+(\.\d+)?|\.\d+)(px|rem|em|%|vh|vw|ch)$/.test(trimmed) || trimmed === 'auto' ? trimmed : null;
60
+ };
61
+ /** Image sources: same-site paths (`/…`) and http(s) URLs only. */
62
+ const isImageUrl = (value) => typeof value === 'string' && /^(\/(?!\/)|https?:\/\/)/i.test(value.trim());
63
+ const resolveItem = (option, record, state) => (typeof option === 'function' ? option(record, state) : option);
64
+ const resolveMap = (option, record, state) => {
65
+ if (option !== null && typeof option === 'object')
66
+ return option[String(state)] ?? null;
67
+ return resolveItem(option, record, state);
68
+ };
69
+ const TEXT_SIZES = new Set(['xs', 'sm', 'md', 'lg']);
70
+ const textSize = (size) => (size === 'base' ? 'md' : size && TEXT_SIZES.has(size) ? size : size ? safeName(size) || 'sm' : 'sm');
71
+ const FONT_WEIGHTS = new Set(['thin', 'extralight', 'light', 'normal', 'medium', 'semibold', 'bold', 'extrabold', 'black']);
72
+ const FONT_FAMILIES = new Set(['sans', 'serif', 'mono']);
73
+ const fontClass = (value, allowed) => (!value ? null : allowed.has(value) ? `fi-font-${value}` : safeName(value) || null);
74
+ /** Text color classes of a text entry item (Filament's `TextEntryComponent\ItemComponent` color map). */
75
+ const itemColorCache = new Map();
76
+ function itemColorClasses(color) {
77
+ if (!color)
78
+ return [];
79
+ const base = ['fi-color', `fi-color-${safeName(color)}`];
80
+ const palette = colorManager?.getColor(color), gray = colorManager?.getColor('gray');
81
+ if (!palette || !gray)
82
+ return base;
83
+ const key = `${color}|${Object.values(palette).join(',')}|${gray[800]}`;
84
+ let classes = itemColorCache.get(key);
85
+ if (!classes) {
86
+ const text = findShade(palette, 'oklch(1 0 0)') ?? 900;
87
+ const dark = findShade(palette, gray[800], { maxShade: 600, fromDarkest: true }) ?? 200;
88
+ classes = [...base, `fi-text-color-${text}`, `dark:fi-text-color-${dark}`];
89
+ itemColorCache.set(key, classes);
90
+ }
91
+ return classes;
92
+ }
93
+ const badgeColorClasses = (color) => (color && color !== 'gray' ? (colorManager?.getComponentClasses('badge', color) ?? []) : []);
94
+ /** Icon entries: 3:1 contrast shades; gray is the default and gets no classes. */
95
+ const iconColorClasses = (color) => (color && color !== 'gray' ? (colorManager?.getComponentClasses('icon-column', color) ?? []) : []);
96
+ // ------------------------------------------------------------------ dates
97
+ const MONTHS = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];
98
+ const DAYS = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
99
+ const pad = (value, length = 2) => String(Math.abs(value)).padStart(length, '0');
100
+ const ordinal = (value) => (value % 10 === 1 && value % 100 !== 11 ? 'st' : value % 10 === 2 && value % 100 !== 12 ? 'nd' : value % 10 === 3 && value % 100 !== 13 ? 'rd' : 'th');
101
+ /** Date state: a `Date`, a timestamp in milliseconds, or a string. Strings without a time zone (SQL values) are read as UTC; a time alone is today's. */
102
+ function parseDate(value) {
103
+ if (value instanceof Date)
104
+ return Number.isNaN(value.getTime()) ? null : value;
105
+ if (typeof value === 'number')
106
+ return Number.isFinite(value) ? new Date(value) : null;
107
+ if (typeof value !== 'string' || value.trim() === '')
108
+ return null;
109
+ let text = value.trim();
110
+ const time = /^(\d{1,2}):(\d{2})(?::(\d{2}))?(\.\d+)?$/.exec(text);
111
+ if (time)
112
+ text = `${new Date().toISOString().slice(0, 10)}T${pad(Number(time[1]))}:${time[2]}:${time[3] ?? '00'}Z`;
113
+ else if (/^\d{4}-\d{2}-\d{2}$/.test(text))
114
+ text = `${text}T00:00:00Z`;
115
+ else if (!/[zZ]$|[+-]\d\d:?\d\d$/.test(text))
116
+ text = `${text.replace(' ', 'T').replace(/(\.\d{3})\d+/, '$1')}Z`;
117
+ const date = new Date(text);
118
+ return Number.isNaN(date.getTime()) ? null : date;
119
+ }
120
+ const isTimeZone = (zone) => { try {
121
+ new Intl.DateTimeFormat('en-US', { timeZone: zone });
122
+ return true;
123
+ }
124
+ catch {
125
+ return false;
126
+ } };
127
+ function dateParts(date, timeZone) {
128
+ const zone = isTimeZone(timeZone) ? timeZone : 'UTC';
129
+ const parts = Object.fromEntries(new Intl.DateTimeFormat('en-US', { timeZone: zone, year: 'numeric', month: 'numeric', day: 'numeric', hour: 'numeric', minute: 'numeric', second: 'numeric', hourCycle: 'h23', timeZoneName: 'short' }).formatToParts(date).map((part) => [part.type, part.value]));
130
+ const year = Number(parts.year), month = Number(parts.month), day = Number(parts.day), hour = Number(parts.hour) % 24, minute = Number(parts.minute), second = Number(parts.second);
131
+ const offset = Math.round((Date.UTC(year, month - 1, day, hour, minute, second) - Math.floor(date.getTime() / 1000) * 1000) / 60000);
132
+ return { year, month, day, hour, minute, second, ms: date.getUTCMilliseconds(), weekday: new Date(Date.UTC(year, month - 1, day)).getUTCDay(), offset, zone: parts.timeZoneName ?? zone, timestamp: Math.floor(date.getTime() / 1000) };
133
+ }
134
+ const offsetText = (minutes, colon) => `${minutes < 0 ? '-' : '+'}${pad(Math.floor(Math.abs(minutes) / 60))}${colon ? ':' : ''}${pad(Math.abs(minutes) % 60)}`;
135
+ const isLeapYear = (year) => (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0;
136
+ /** PHP `date()` format (`M j, Y`, `d/m/Y H:i`…), with English month and day names. A backslash escapes a character. */
137
+ export function formatPhpDate(date, format, timeZone = 'UTC') {
138
+ const p = dateParts(date, timeZone);
139
+ const hour12 = p.hour % 12 || 12;
140
+ const tokens = {
141
+ d: () => pad(p.day), D: () => DAYS[p.weekday].slice(0, 3), j: () => String(p.day), l: () => DAYS[p.weekday], N: () => String(p.weekday || 7), S: () => ordinal(p.day), w: () => String(p.weekday),
142
+ z: () => String(Math.round((Date.UTC(p.year, p.month - 1, p.day) - Date.UTC(p.year, 0, 1)) / 86400000)),
143
+ F: () => MONTHS[p.month - 1], M: () => MONTHS[p.month - 1].slice(0, 3), m: () => pad(p.month), n: () => String(p.month), t: () => String(new Date(Date.UTC(p.year, p.month, 0)).getUTCDate()),
144
+ L: () => (isLeapYear(p.year) ? '1' : '0'), Y: () => String(p.year), y: () => pad(p.year % 100),
145
+ a: () => (p.hour < 12 ? 'am' : 'pm'), A: () => (p.hour < 12 ? 'AM' : 'PM'), g: () => String(hour12), G: () => String(p.hour), h: () => pad(hour12), H: () => pad(p.hour),
146
+ i: () => pad(p.minute), s: () => pad(p.second), u: () => pad(p.ms * 1000, 6), v: () => pad(p.ms, 3),
147
+ e: () => (isTimeZone(timeZone) ? timeZone : 'UTC'), T: () => p.zone, P: () => offsetText(p.offset, true), O: () => offsetText(p.offset, false), p: () => (p.offset === 0 ? 'Z' : offsetText(p.offset, true)), Z: () => String(p.offset * 60),
148
+ U: () => String(p.timestamp),
149
+ c: () => `${p.year}-${pad(p.month)}-${pad(p.day)}T${pad(p.hour)}:${pad(p.minute)}:${pad(p.second)}${offsetText(p.offset, true)}`,
150
+ r: () => `${DAYS[p.weekday].slice(0, 3)}, ${pad(p.day)} ${MONTHS[p.month - 1].slice(0, 3)} ${p.year} ${pad(p.hour)}:${pad(p.minute)}:${pad(p.second)} ${offsetText(p.offset, false)}`,
151
+ };
152
+ let out = '';
153
+ for (let index = 0; index < format.length; index++) {
154
+ const character = format[index];
155
+ if (character === '\\' && index + 1 < format.length) {
156
+ out += format[++index];
157
+ continue;
158
+ }
159
+ out += Object.hasOwn(tokens, character) ? tokens[character]() : character;
160
+ }
161
+ return out;
162
+ }
163
+ /** English macro formats of Carbon's `isoFormat()`. */
164
+ const ISO_MACROS = {
165
+ LT: 'h:mm A', LTS: 'h:mm:ss A', L: 'MM/DD/YYYY', LL: 'MMMM D, YYYY', LLL: 'MMMM D, YYYY h:mm A', LLLL: 'dddd, MMMM D, YYYY h:mm A',
166
+ l: 'M/D/YYYY', ll: 'MMM D, YYYY', lll: 'MMM D, YYYY h:mm A', llll: 'ddd, MMM D, YYYY h:mm A',
167
+ };
168
+ const ISO_TOKEN = /\[[^\]]*\]|LTS|LT|LLLL|LLL|LL|L|llll|lll|ll|l|YYYY|YY|Qo|Q|MMMM|MMM|MM|Mo|M|DDDD|DDDo|DDD|Do|DD|D|dddd|ddd|dd|do|d|E|e|HH|H|hh|h|kk|k|mm|m|ss|s|SSS|SS|S|A|a|ZZ|Z|X|x|zz|z/g;
169
+ /** Carbon `isoFormat()` format (`L`, `LLL`, `dddd D MMMM YYYY`…), in English. Text in `[brackets]` is kept as is. */
170
+ export function formatIsoDate(date, format, timeZone = 'UTC') {
171
+ const p = dateParts(date, timeZone);
172
+ const hour12 = p.hour % 12 || 12;
173
+ const dayOfYear = Math.round((Date.UTC(p.year, p.month - 1, p.day) - Date.UTC(p.year, 0, 1)) / 86400000) + 1;
174
+ const tokens = {
175
+ YYYY: () => String(p.year), YY: () => pad(p.year % 100), Q: () => String(Math.ceil(p.month / 3)), Qo: () => `${Math.ceil(p.month / 3)}${ordinal(Math.ceil(p.month / 3))}`,
176
+ MMMM: () => MONTHS[p.month - 1], MMM: () => MONTHS[p.month - 1].slice(0, 3), MM: () => pad(p.month), Mo: () => `${p.month}${ordinal(p.month)}`, M: () => String(p.month),
177
+ DDDD: () => pad(dayOfYear, 3), DDDo: () => `${dayOfYear}${ordinal(dayOfYear)}`, DDD: () => String(dayOfYear), Do: () => `${p.day}${ordinal(p.day)}`, DD: () => pad(p.day), D: () => String(p.day),
178
+ dddd: () => DAYS[p.weekday], ddd: () => DAYS[p.weekday].slice(0, 3), dd: () => DAYS[p.weekday].slice(0, 2), do: () => `${p.weekday}${ordinal(p.weekday)}`, d: () => String(p.weekday), e: () => String(p.weekday), E: () => String(p.weekday || 7),
179
+ HH: () => pad(p.hour), H: () => String(p.hour), hh: () => pad(hour12), h: () => String(hour12), kk: () => pad(p.hour || 24), k: () => String(p.hour || 24),
180
+ mm: () => pad(p.minute), m: () => String(p.minute), ss: () => pad(p.second), s: () => String(p.second),
181
+ SSS: () => pad(p.ms, 3), SS: () => pad(Math.floor(p.ms / 10)), S: () => String(Math.floor(p.ms / 100)),
182
+ A: () => (p.hour < 12 ? 'AM' : 'PM'), a: () => (p.hour < 12 ? 'am' : 'pm'), Z: () => offsetText(p.offset, true), ZZ: () => offsetText(p.offset, false),
183
+ X: () => String(p.timestamp), x: () => String(date.getTime()), z: () => p.zone, zz: () => p.zone,
184
+ };
185
+ return format.replace(ISO_TOKEN, (token) => {
186
+ if (token.startsWith('['))
187
+ return token.slice(1, -1);
188
+ if (Object.hasOwn(ISO_MACROS, token))
189
+ return formatIsoDate(date, ISO_MACROS[token], timeZone);
190
+ return tokens[token]();
191
+ });
192
+ }
193
+ /** Relative date: "2 days ago", or "in 3 hours" style for a future date ("3 hours from now"). */
194
+ function since(date, now = new Date()) {
195
+ if (date.getTime() <= now.getTime())
196
+ return diffForHumans(date, now) ?? '';
197
+ return (diffForHumans(now, date) ?? '').replace(/ ago$/, ' from now');
198
+ }
199
+ // ------------------------------------------------------------------ numbers
200
+ /** PHP `number_format()`: rounded to `decimals`, with the given separators. */
201
+ function numberFormat(value, decimals, decimalSeparator, thousandsSeparator) {
202
+ const fixed = Math.abs(value).toFixed(Math.max(0, Math.min(20, Math.floor(decimals))));
203
+ const [integer, fraction] = fixed.split('.');
204
+ const sign = value < 0 && Number(fixed) !== 0 ? '-' : '';
205
+ return `${sign}${integer.replace(/\B(?=(\d{3})+(?!\d))/g, thousandsSeparator)}${fraction ? `${decimalSeparator}${fraction}` : ''}`;
206
+ }
207
+ const fractionDigits = (digits) => (typeof digits === 'number' && Number.isFinite(digits) ? Math.max(0, Math.min(20, Math.floor(digits))) : null);
10
208
  /** Base entry: a label and a value read from the record (dotted paths allowed) or computed. */
11
209
  class Entry extends Component {
12
210
  _label = null;
13
211
  _state = null;
212
+ _default = null;
213
+ _hiddenLabel = false;
214
+ _placeholder = null;
215
+ _url = null;
216
+ _openUrlInNewTab = false;
217
+ _tooltip = null;
218
+ _emptyTooltip = null;
219
+ _inlineLabel = false;
220
+ _alignment = null;
221
+ _hiddenJs = null;
222
+ _visibleJs = null;
223
+ _extraEntryWrapperAttributes = {};
224
+ _slots = {};
14
225
  constructor(name) { super(); this._name = name; }
15
226
  hasStatePath() { return true; }
227
+ /** Label: a string or a function of the context. Escaped when rendered. */
16
228
  label(label) { this._label = label; return this; }
17
- _hiddenLabel = false;
18
229
  /** Keeps the label for screen readers only. */
19
230
  hiddenLabel(condition = true) { this._hiddenLabel = condition; return this; }
20
231
  /** Computed value; without it, the record attribute of the same name is shown. */
21
232
  state(resolver) { this._state = resolver; return this; }
233
+ /** State used when the real state is `null` or `undefined`; it is displayed as if it were real. */
234
+ default(state) { this._default = state; return this; }
235
+ /** Gray text shown when the state is empty. */
236
+ placeholder(text) { this._placeholder = text; return this; }
237
+ /** Makes the content a link. Only `http(s)`, `mailto` and relative URLs are rendered: any other scheme is ignored. */
238
+ url(url, openInNewTab = null) {
239
+ this._url = url;
240
+ if (openInNewTab !== null)
241
+ this._openUrlInNewTab = openInNewTab;
242
+ return this;
243
+ }
244
+ openUrlInNewTab(condition = true) { this._openUrlInNewTab = condition; return this; }
245
+ /** Tooltip on the content: a string, or a function of the record and the state item. */
246
+ tooltip(tooltip) { this._tooltip = tooltip; return this; }
247
+ /** Tooltip shown on the content when the state is empty. */
248
+ emptyTooltip(tooltip) { this._emptyTooltip = tooltip; return this; }
249
+ /** Label next to the content instead of above it (from the `sm` breakpoint). */
250
+ inlineLabel(condition = true) { this._inlineLabel = condition; return this; }
251
+ /** Content alignment: `start`, `center`, `end`, `left`, `right`, `justify` or `between`. */
252
+ alignment(alignment) { this._alignment = alignment; return this; }
253
+ alignStart() { return this.alignment('start'); }
254
+ alignCenter() { return this.alignment('center'); }
255
+ alignEnd() { return this.alignment('end'); }
256
+ alignLeft() { return this.alignment('left'); }
257
+ alignRight() { return this.alignment('right'); }
258
+ alignJustify() { return this.alignment('justify'); }
259
+ /** Hides the entry in the browser while this JavaScript expression is true (`$get('role') !== 'staff'`). Written by the developer, never from user input. */
260
+ hiddenJs(expression) { this._hiddenJs = expression; return this; }
261
+ /** Shows the entry in the browser only while this JavaScript expression is true. */
262
+ visibleJs(expression) { this._visibleJs = expression; return this; }
263
+ /** Attributes of the outer element of the entry wrapper (label and content). Values are escaped. */
264
+ extraEntryWrapperAttributes(attributes) { Object.assign(this._extraEntryWrapperAttributes, attributes); return this; }
265
+ aboveLabel(content) { this._slots.aboveLabel = content; return this; }
266
+ belowLabel(content) { this._slots.belowLabel = content; return this; }
267
+ beforeLabel(content) { this._slots.beforeLabel = content; return this; }
268
+ /** Content after the label, aligned to the end. */
269
+ afterLabel(content) { this._slots.afterLabel = content; return this; }
270
+ aboveContent(content) { this._slots.aboveContent = content; return this; }
271
+ belowContent(content) { this._slots.belowContent = content; return this; }
272
+ beforeContent(content) { this._slots.beforeContent = content; return this; }
273
+ /** Content after the entry content, aligned to the end. */
274
+ afterContent(content) { this._slots.afterContent = content; return this; }
275
+ async getLabel() { return (await evaluate(this._label, this.context())) ?? defaultLabel(this._name); }
22
276
  async getState() {
23
277
  const ctx = this.context();
278
+ let state;
24
279
  if (this._state)
25
- return this._state(ctx);
26
- const record = ctx.record;
27
- // `department.name`: flat `department_name` attribute (column computed by the adapter), otherwise a path into the object.
28
- if (record && this._name.includes('.'))
29
- return record[this._name.replace(/\./g, '_')] ?? this._name.split('.').reduce((node, key) => node?.[key], record);
30
- return record?.[this._name];
280
+ state = await this._state(ctx);
281
+ else {
282
+ const record = ctx.record;
283
+ // `department.name`: flat `department_name` attribute (column computed by the adapter), otherwise a path into the object.
284
+ if (record && this._name.includes('.'))
285
+ state = record[this._name.replace(/\./g, '_')] ?? this._name.split('.').reduce((node, key) => node?.[key], record);
286
+ else
287
+ state = record?.[this._name];
288
+ }
289
+ return state === null || state === undefined ? (this._default ?? state) : state;
290
+ }
291
+ getExtraAttributesMap() { return escapeAttributes(this._extraAttributes); }
292
+ async getAlignment() { const alignment = await evaluate(this._alignment, this.context()); return alignment && ALIGNMENTS.has(alignment) ? alignment : null; }
293
+ getEmptyAttributes(base) { return mergeAttrs({ 'x-tooltip': tooltipJs(this._emptyTooltip) }, withClass(this.getExtraAttributesMap(), base)); }
294
+ emptyHtml(base) {
295
+ return `<div ${renderAttrs(this.getEmptyAttributes(base))}>${this._placeholder !== null ? `<p class="fi-in-placeholder">${e(this._placeholder)}</p>` : ''}</div>`;
296
+ }
297
+ /** Actions placed in the slots, so that they can be mounted (`schemaComponent` = entry key). */
298
+ getActions() {
299
+ const actions = [];
300
+ for (const slot of Object.values(this._slots)) {
301
+ let value = slot;
302
+ if (typeof value === 'function') {
303
+ try {
304
+ value = slot(this.context());
305
+ }
306
+ catch {
307
+ continue;
308
+ }
309
+ if (value instanceof Promise) {
310
+ value.catch(() => { });
311
+ continue;
312
+ }
313
+ }
314
+ for (const item of [].concat(value ?? [])) {
315
+ if (!item || typeof item !== 'object' || item instanceof Component || item instanceof HtmlString)
316
+ continue;
317
+ actions.push(...(typeof item.getActions === 'function' ? item.getActions() : [item]));
318
+ }
319
+ }
320
+ return actions;
321
+ }
322
+ async renderSlot(name) {
323
+ const slot = this._slots[name];
324
+ if (slot === undefined || slot === null)
325
+ return null;
326
+ const value = await evaluate(slot, this.context());
327
+ const items = [].concat(value ?? []).map((item) => (typeof item === 'string' || item instanceof HtmlString ? Text.make(item) : item));
328
+ return renderInlineItems(this, items, { alignment: name === 'afterLabel' || name === 'afterContent' ? 'end' : null, scope: { path: this.getStatePath(), containerPath: this.container.getStatePath() }, mountKey: this.getKey(), actionView: 'link' });
31
329
  }
32
- /** Wraps the content with the entry label. */
33
- wrap(content) {
34
- if (this._hiddenLabel)
35
- return `<div class="fi-in-entry"><div class="fi-in-entry-label fi-sr-only" role="term">${e(this._label ?? defaultLabel(this._name))}</div><div class="fi-in-entry-content-col"><div class="fi-in-entry-content-ctn" role="definition"><div class="fi-in-entry-content">${content}</div></div></div></div>`;
36
- return `<div class="fi-in-entry"><div class="fi-in-entry-label-col"><div class="fi-in-entry-label-ctn"><div class="fi-in-entry-label" role="term">${e(this._label ?? defaultLabel(this._name))}</div></div></div><div class="fi-in-entry-content-col"><div class="fi-in-entry-content-ctn" role="definition"><div class="fi-in-entry-content">${content}</div></div></div></div>`;
330
+ /** Places the content in the entry wrapper: label column, content column, slots and link. */
331
+ async wrapEmbeddedHtml(content) {
332
+ const ctx = this.context();
333
+ const label = await this.getLabel();
334
+ const hasLabel = label !== '';
335
+ const isLabelHidden = Boolean(await evaluate(this._hiddenLabel, ctx));
336
+ const hasInlineLabel = Boolean(await evaluate(this._inlineLabel, ctx));
337
+ const alignment = await this.getAlignment();
338
+ const url = await evaluate(this._url, ctx);
339
+ const href = typeof url === 'string' && url.trim() !== '' && isSafeUrl(url) ? url : null;
340
+ const [aboveLabel, belowLabel, beforeLabel, afterLabel, aboveContent, beforeContent, afterContent, belowContent] = await Promise.all(['aboveLabel', 'belowLabel', 'beforeLabel', 'afterLabel', 'aboveContent', 'beforeContent', 'afterContent', 'belowContent'].map((name) => this.renderSlot(name)));
341
+ const attributes = withClass(escapeAttributes(this._extraEntryWrapperAttributes), ['fi-in-entry', { 'fi-in-entry-has-inline-label': hasInlineLabel }]);
342
+ let html = `<div ${renderAttrs(attributes)}>`;
343
+ if (hasLabel && isLabelHidden)
344
+ html += `<div class="fi-in-entry-label fi-sr-only" role="term">${e(label)}</div>`;
345
+ if ((hasLabel && !isLabelHidden) || hasInlineLabel || aboveLabel || belowLabel || beforeLabel || afterLabel) {
346
+ html += `<div class="fi-in-entry-label-col">${aboveLabel ?? ''}`;
347
+ if ((hasLabel && !isLabelHidden) || beforeLabel || afterLabel) {
348
+ html += `<div class="fi-in-entry-label-ctn">${beforeLabel ?? ''}${hasLabel && !isLabelHidden ? `<div class="fi-in-entry-label" role="term">${e(label)}</div>` : ''}${afterLabel ?? ''}</div>`;
349
+ }
350
+ html += `${belowLabel ?? ''}</div>`;
351
+ }
352
+ html += '<div class="fi-in-entry-content-col">';
353
+ if (aboveContent !== null)
354
+ html += `<div class="fi-in-entry-content-slot">${aboveContent}</div>`;
355
+ html += '<div class="fi-in-entry-content-ctn" role="definition">';
356
+ if (beforeContent !== null)
357
+ html += `<div class="fi-in-entry-content-slot">${beforeContent}</div>`;
358
+ const contentClass = cls(['fi-in-entry-content', alignmentClass(alignment)]);
359
+ html += href !== null
360
+ ? `<a href="${e(href)}"${(await evaluate(this._openUrlInNewTab, ctx)) ? ' target="_blank"' : ''} class="${contentClass}">${content}</a>`
361
+ : `<div class="${contentClass}">${content}</div>`;
362
+ html += `${afterContent ?? ''}</div>${belowContent ?? ''}</div></div>`;
363
+ return html;
364
+ }
365
+ /** `hiddenJs()` / `visibleJs()`: the grid wrapper becomes a `<fieldset>` toggled by Alpine, as in Filament. */
366
+ async toSchemaHtml(isVisible) {
367
+ const html = await super.toSchemaHtml(isVisible);
368
+ const hidden = this._hiddenJs?.trim() || null, visible = this._visibleJs?.trim() || null;
369
+ const visibility = hidden && visible ? `(! (${hidden})) && (${visible})` : hidden ? `! (${hidden})` : visible;
370
+ const start = html.indexOf('<div');
371
+ const end = /><div class="fi-sc-component[ "]/.exec(html)?.index;
372
+ if (!isVisible || visibility === null || start < 0 || end === undefined || html.slice(start, end).includes('fi-sc-visibility-fieldset'))
373
+ return html;
374
+ let tag = html.slice(start, end).replace(/^<div/, '<fieldset disabled role="none"');
375
+ const toggle = ` x-bind:class="${e(`{ 'fi-hidden': ! (${visibility}) }`)}" x-bind:disabled="${e(`! (${visibility})`)}" x-cloak`;
376
+ const dataStart = tag.indexOf(' x-data="');
377
+ if (dataStart >= 0) {
378
+ const dataEnd = tag.indexOf('"', dataStart + 9);
379
+ tag = `${tag.slice(0, dataEnd + 1)}${toggle}${tag.slice(dataEnd + 1)}`;
380
+ }
381
+ else
382
+ tag += toggle;
383
+ tag = /\sclass="/.test(tag) ? tag.replace(/(\sclass="[^"]*)"/, '$1 fi-sc-visibility-fieldset"') : `${tag} class="fi-sc-visibility-fieldset"`;
384
+ const close = html.lastIndexOf('</div>');
385
+ return `${html.slice(0, start)}${tag}>${html.slice(end + 1, close)}</fieldset>${html.slice(close + '</div>'.length)}`;
37
386
  }
38
387
  }
39
388
  let htmlSanitizer = null;
40
389
  const TIGHT_ITEM = new RegExp('<li><p>((?:(?!</?p>|</?li>)[^])*)</p></li>', 'g');
41
- /** Sanitizer applied to HTML an entry renders as is (`markdown()`). Without one, the content is escaped. */
390
+ /**
391
+ * Sanitizer applied to the HTML an entry renders as is (`html()`, `markdown()`, `prose()`).
392
+ * Without one, `sanitizeHtml()` from `@navette-js/support` is used.
393
+ */
42
394
  export const useHtmlSanitizer = (sanitizer) => { htmlSanitizer = sanitizer; };
43
- /** Read-only text: badge, color, icon, money, date, placeholder. */
395
+ const sanitize = (html) => (htmlSanitizer ?? sanitizeHtml)(html);
396
+ /** Read-only text: badge, color, icon, money, number, date, list, Markdown and HTML. */
44
397
  export class TextEntry extends Entry {
45
398
  _badge = false;
46
399
  _color = null;
47
400
  _icon = null;
401
+ _iconColor = null;
402
+ _iconPosition = 'before';
48
403
  _format = null;
49
- _money = null;
50
- _date = false;
51
- _placeholder = null;
404
+ _formatter = null;
405
+ _formatterKind = null;
406
+ _isDate = false;
407
+ _isMoney = false;
408
+ _isNumeric = false;
409
+ _timezone = null;
410
+ _prefix = null;
411
+ _suffix = null;
412
+ _prose = false;
413
+ _markdown = false;
414
+ _html = false;
415
+ _characterLimit = null;
416
+ _wordLimit = null;
417
+ _listWithLineBreaks = false;
418
+ _bulleted = false;
419
+ _listLimit = null;
420
+ _expandableLimitedList = false;
421
+ _separator = null;
422
+ _copyable = false;
423
+ _copyableState = null;
424
+ _copyMessage = null;
425
+ _copyMessageDuration = 2000;
426
+ _size = null;
427
+ _weight = null;
428
+ _fontFamily = null;
429
+ _lineClamp = null;
430
+ _wrap = true;
52
431
  static make(name) { return configured(new TextEntry(name)); }
53
432
  badge(condition = true) { this._badge = condition; return this; }
433
+ /** Text (or badge) color: a name, or a function of the record and the state item. */
54
434
  color(color) { this._color = color; return this; }
55
- icon(resolver) { this._icon = resolver; return this; }
435
+ /** Heroicon before the text: a name, or a function of the record and the state item returning a name or `null`. */
436
+ icon(icon) { this._icon = icon; return this; }
437
+ iconColor(color) { this._iconColor = color; return this; }
438
+ iconPosition(position) { this._iconPosition = position === 'after' ? 'after' : 'before'; return this; }
439
+ /** Displayed text computed from the raw state; it takes precedence over date, number and money formatting. */
56
440
  formatStateUsing(formatter) { this._format = formatter; return this; }
57
- money(currency = 'usd') { this._money = currency.toUpperCase(); return this; }
58
- date(condition = true) { this._date = condition; return this; }
59
- placeholder(text) { this._placeholder = text; return this; }
60
- _dateTime = false;
61
- _time = null;
62
- _suffix = null;
63
- dateTime(condition = true) { this._dateTime = condition; return this; }
64
- time(format = 'H:i:s') { this._time = format; return this; }
65
- suffix(text) { this._suffix = text; return this; }
66
- _prose = false;
67
- _markdown = false;
68
- /** Typographic styling of the content (`fi-prose`). */
69
- prose(condition = true) { this._prose = condition; return this; }
441
+ setFormatter(kind, formatter) {
442
+ if (formatter) {
443
+ this._formatter = formatter;
444
+ this._formatterKind = kind;
445
+ }
446
+ else if (this._formatterKind === kind) {
447
+ this._formatter = null;
448
+ this._formatterKind = null;
449
+ }
450
+ return this;
451
+ }
452
+ dateFormatter(kind, format, timezone) {
453
+ return (state) => {
454
+ const date = parseDate(state);
455
+ if (!date)
456
+ return String(state);
457
+ const zone = timezone ?? this._timezone ?? 'UTC';
458
+ return kind === 'since' ? since(date) : kind === 'iso' ? formatIsoDate(date, format, zone) : formatPhpDate(date, format, zone);
459
+ };
460
+ }
461
+ /** Date in a PHP format, `M j, Y` by default ("Mar 5, 2024"). `false` turns date formatting off. */
462
+ date(format = null, timezone = null) {
463
+ this._isDate = format !== false;
464
+ return this.setFormatter('date', format === false ? null : this.dateFormatter('php', typeof format === 'string' ? format : 'M j, Y', timezone));
465
+ }
466
+ /** Date and time, `M j, Y H:i:s` by default. */
467
+ dateTime(format = null, timezone = null) {
468
+ return this.setFormatter('date', format === false ? null : this.dateFormatter('php', typeof format === 'string' ? format : 'M j, Y H:i:s', timezone));
469
+ }
470
+ /** Time, `H:i:s` by default. */
471
+ time(format = null, timezone = null) { return this.setFormatter('date', this.dateFormatter('php', format ?? 'H:i:s', timezone)); }
472
+ /** Date in a Carbon `isoFormat()` format, `L` by default ("03/05/2024"). */
473
+ isoDate(format = null, timezone = null) { return this.setFormatter('date', this.dateFormatter('iso', format ?? 'L', timezone)); }
474
+ /** Date and time in an `isoFormat()` format, `LLL` by default ("March 5, 2024 10:11 AM"). */
475
+ isoDateTime(format = null, timezone = null) { return this.setFormatter('date', this.dateFormatter('iso', format ?? 'LLL', timezone)); }
476
+ /** Time in an `isoFormat()` format, `LT` by default ("10:11 AM"). */
477
+ isoTime(format = null, timezone = null) { return this.setFormatter('date', this.dateFormatter('iso', format ?? 'LT', timezone)); }
478
+ /** Relative date: "2 days ago". */
479
+ since(timezone = null) { return this.setFormatter('date', this.dateFormatter('since', '', timezone)); }
480
+ dateTooltipFor(kind, format, timezone) {
481
+ const formatter = this.dateFormatter(kind, format, timezone);
482
+ return this.tooltip((_record, state) => (isBlank(state) ? null : formatter(state)));
483
+ }
484
+ /** Tooltip with the date in a PHP format (`M j, Y` by default). */
485
+ dateTooltip(format = null, timezone = null) { return this.dateTooltipFor('php', format ?? 'M j, Y', timezone); }
486
+ dateTimeTooltip(format = null, timezone = null) { return this.dateTooltipFor('php', format ?? 'M j, Y H:i:s', timezone); }
487
+ timeTooltip(format = null, timezone = null) { return this.dateTooltipFor('php', format ?? 'H:i:s', timezone); }
488
+ isoDateTooltip(format = null, timezone = null) { return this.dateTooltipFor('iso', format ?? 'L', timezone); }
489
+ isoDateTimeTooltip(format = null, timezone = null) { return this.dateTooltipFor('iso', format ?? 'LLL', timezone); }
490
+ isoTimeTooltip(format = null, timezone = null) { return this.dateTooltipFor('iso', format ?? 'LT', timezone); }
491
+ sinceTooltip(timezone = null) { return this.dateTooltipFor('since', '', timezone); }
492
+ /** IANA time zone in which dates are displayed (`UTC` by default). */
493
+ timezone(timezone) { this._timezone = timezone; return this; }
494
+ /**
495
+ * Formatted number. `numeric(2)`: two decimal places. Options: `decimalPlaces`, `maxDecimalPlaces`, `locale` (`en-US` by default),
496
+ * or `decimalSeparator` / `thousandsSeparator` for fixed separators. `false` turns number formatting off.
497
+ */
498
+ numeric(options = true) {
499
+ this._isNumeric = options !== false;
500
+ if (options === false)
501
+ return this.setFormatter('numeric', null);
502
+ const settings = typeof options === 'number' ? { decimalPlaces: options } : options && typeof options === 'object' ? options : {};
503
+ return this.setFormatter('numeric', (state) => {
504
+ if (!isNumericValue(state))
505
+ return String(state);
506
+ const value = Number(state);
507
+ const decimals = fractionDigits(settings.decimalPlaces);
508
+ if ((settings.decimalSeparator ?? null) !== null || (settings.thousandsSeparator ?? null) !== null)
509
+ return numberFormat(value, decimals ?? 0, settings.decimalSeparator ?? '.', settings.thousandsSeparator ?? ',');
510
+ const max = fractionDigits(settings.maxDecimalPlaces);
511
+ const digits = max !== null ? { maximumFractionDigits: max } : decimals !== null ? { minimumFractionDigits: decimals, maximumFractionDigits: decimals } : {};
512
+ return new Intl.NumberFormat(settings.locale ?? 'en-US', digits).format(value);
513
+ });
514
+ }
70
515
  /**
71
- * Rich text content, rendered as HTML after the sanitizer set with `useHtmlSanitizer()`.
72
- * Existing HTML is kept (sanitized) and plain text becomes paragraphs; Markdown syntax itself is not converted.
516
+ * Amount of money in a currency (`usd` by default). `divideBy`: divides the state first (`100` for cents);
517
+ * `locale` (`en-US` by default) and `decimalPlaces` may be passed after it, or in an options object.
73
518
  */
519
+ money(currency = 'usd', divideBy = 0, locale = null, decimalPlaces = null) {
520
+ this._isMoney = true;
521
+ const settings = divideBy !== null && typeof divideBy === 'object' ? divideBy : { divideBy: divideBy, locale, decimalPlaces };
522
+ return this.setFormatter('money', (state, record) => {
523
+ if (!isNumericValue(state))
524
+ return String(state);
525
+ let value = Number(state);
526
+ if (settings.divideBy)
527
+ value /= settings.divideBy;
528
+ const code = (typeof currency === 'function' ? currency(record) : currency) || 'usd';
529
+ const decimals = fractionDigits(settings.decimalPlaces);
530
+ return new Intl.NumberFormat(settings.locale ?? 'en-US', { style: 'currency', currency: code.toUpperCase(), ...(decimals !== null ? { minimumFractionDigits: decimals, maximumFractionDigits: decimals } : {}) }).format(value);
531
+ });
532
+ }
533
+ /** Text before the formatted state. A string is escaped; an `HtmlString` is inserted as is. */
534
+ prefix(text) { this._prefix = text; return this; }
535
+ /** Text after the formatted state. A string is escaped; an `HtmlString` is inserted as is. */
536
+ suffix(text) { this._suffix = text; return this; }
537
+ /** Truncates the text to `length` characters, followed by `end`. */
538
+ limit(length = 100, end = '...') { this._characterLimit = length && length > 0 ? { length: Math.floor(length), end } : null; return this; }
539
+ /** Truncates the text to a number of words, followed by `end`. */
540
+ words(words = 100, end = '...') { this._wordLimit = words && words > 0 ? { words: Math.floor(words), end } : null; return this; }
541
+ /** Clamps the text to a number of lines. */
542
+ lineClamp(lines) { this._lineClamp = lines && lines > 0 ? Math.floor(lines) : null; return this; }
543
+ /** `false`: long text does not wrap onto several lines. */
544
+ wrap(condition = true) { this._wrap = condition; return this; }
545
+ /** Text size: `xs`, `sm` (default), `md` (or `base`) or `lg`. */
546
+ size(size) { this._size = size; return this; }
547
+ /** Font weight: `thin`, `extralight`, `light`, `normal`, `medium`, `semibold`, `bold`, `extrabold` or `black`. */
548
+ weight(weight) { this._weight = weight; return this; }
549
+ /** Font family: `sans`, `serif` or `mono`. */
550
+ fontFamily(family) { this._fontFamily = family; return this; }
551
+ /** Typographic styling of rich content (`fi-prose`); the state is rendered as sanitized HTML. */
552
+ prose(condition = true) { this._prose = condition; return this; }
553
+ /** Renders Markdown as HTML. HTML in the state (from a rich editor) is sanitized instead. */
74
554
  markdown(condition = true) { this._markdown = condition; return this; }
75
- _copyable = false;
76
- _numeric = false;
555
+ /** Renders the state as HTML, after it has been sanitized. */
556
+ html(condition = true) { this._html = condition; return this; }
557
+ /** Each value of a list on its own line. */
558
+ listWithLineBreaks(condition = true) { this._listWithLineBreaks = condition; return this; }
559
+ /** Bulleted list (implies line breaks). */
560
+ bulleted(condition = true) { this._bulleted = condition; return this; }
561
+ /** Shows only the first values of a list, followed by "and N more". */
562
+ limitList(limit = 3) { this._listLimit = limit && limit > 0 ? Math.floor(limit) : null; return this; }
563
+ /** With `limitList()` and line breaks: a link reveals the other values. */
564
+ expandableLimitedList(condition = true) { this._expandableLimitedList = condition; return this; }
565
+ /** Splits a string state into a list on this separator. */
566
+ separator(separator = ',') { this._separator = separator; return this; }
77
567
  /** Clicking copies the value to the clipboard. */
78
568
  copyable(condition = true) { this._copyable = condition; return this; }
79
- /** Formatted number (thousands separators), aligned as a number. */
80
- numeric(condition = true) { this._numeric = condition; return this; }
569
+ /** Value copied, when it differs from the displayed text. */
570
+ copyableState(state) { this._copyableState = state; return this; }
571
+ copyMessage(message) { this._copyMessage = message; return this; }
572
+ copyMessageDuration(milliseconds) { this._copyMessageDuration = Number.isFinite(milliseconds) ? Math.max(0, Math.floor(milliseconds)) : 2000; return this; }
573
+ /** Displayed text of a state item, and whether it is (sanitized) HTML. */
574
+ formatItem(item, record) {
575
+ let isHtml = this._html || this._markdown || this._prose;
576
+ const value = this._format ? this._format({ state: item, record }) : this._formatter && !isBlank(item) ? this._formatter(item, record) : item;
577
+ let text;
578
+ if (value instanceof HtmlString) {
579
+ isHtml = true;
580
+ text = value.html;
581
+ }
582
+ else {
583
+ text = value === null || value === undefined ? '' : value instanceof Date ? formatPhpDate(value, 'Y-m-d H:i:s') : typeof value === 'object' ? JSON.stringify(value) : String(value);
584
+ // Markdown: content starting with a tag is HTML (from a rich editor), sanitized; otherwise HTML in it is shown as text.
585
+ if (isHtml)
586
+ text = this._markdown ? (/^\s*<[a-z][^>]*>/i.test(text) ? sanitize(text).replace(TIGHT_ITEM, '<li>$1</li>') : markdownToHtml(text)) : sanitize(text);
587
+ }
588
+ if (!isHtml) {
589
+ if (this._characterLimit && [...text].length > this._characterLimit.length)
590
+ text = `${[...text].slice(0, this._characterLimit.length).join('').trimEnd()}${this._characterLimit.end}`;
591
+ if (this._wordLimit) {
592
+ const match = new RegExp(`^\\s*(?:\\S+\\s*){1,${this._wordLimit.words}}`, 'u').exec(text);
593
+ if (match && match[0].length !== text.length)
594
+ text = `${match[0].trimEnd()}${this._wordLimit.end}`;
595
+ }
596
+ }
597
+ if (!isHtml && (this._prefix instanceof HtmlString || this._suffix instanceof HtmlString)) {
598
+ isHtml = true;
599
+ text = e(text);
600
+ }
601
+ const affix = (value) => (value === null || value === '' ? '' : value instanceof HtmlString ? value.html : isHtml ? e(value) : value);
602
+ return { text: `${affix(this._prefix)}${text}${affix(this._suffix)}`, isHtml };
603
+ }
81
604
  async toHtml() {
82
605
  const record = this.context().record;
83
- const state = await this.getState();
84
- const empty = state === null || state === undefined || state === '';
85
- if (empty && this._placeholder !== null)
86
- return this.wrap(`<div class="fi-in-text"><p class="fi-in-placeholder">${e(this._placeholder)}</p></div>`);
87
- // Empty value: bare container (with `fi-numeric` for money).
88
- if (empty)
89
- return this._state || (!this._money && !this._date && !this._badge) ? this.wrap('<div class="fi-in-text-item fi-size-sm fi-in-text fi-wrapped"></div>') : this.wrap(`<div class="${['fi-in-text', this._money || this._numeric ? 'fi-numeric' : null].filter(Boolean).join(' ')}"></div>`);
90
- let shown = String(state);
91
- if (this._numeric)
92
- shown = new Intl.NumberFormat('en-US').format(Number(state));
93
- if (this._money)
94
- shown = new Intl.NumberFormat('en-US', { style: 'currency', currency: this._money }).format(Number(state));
95
- if (this._date)
96
- shown = new Intl.DateTimeFormat('en-US', { month: 'short', day: 'numeric', year: 'numeric', timeZone: 'UTC' }).format(new Date(`${shown.replace(' ', 'T').slice(0, 19)}Z`));
97
- // `dateTime()`: "Jan 5, 2024 13:45:00" (`M j, Y H:i:s`).
98
- if (this._dateTime)
99
- shown = `${new Intl.DateTimeFormat('en-US', { month: 'short', day: 'numeric', year: 'numeric', timeZone: 'UTC' }).format(new Date(`${shown.replace(' ', 'T').slice(0, 19)}Z`))} ${shown.slice(11, 19)}`;
100
- // `time('H:i')` or `time()` (`H:i:s`): the time part of an `H:i:s` or `Y-m-d H:i:s` value.
101
- if (this._time)
102
- shown = (shown.length > 8 ? shown.slice(11, 19) : shown).slice(0, this._time === 'H:i' ? 5 : 8);
103
- if (this._format)
104
- shown = this._format({ state, record });
105
- if (this._suffix !== null)
106
- shown += this._suffix;
107
- if (this._markdown) {
108
- const source = String(state);
109
- const html = /<[a-z][^>]*>/i.test(source)
110
- // Tight lists: a list item's single paragraph is unwrapped.
111
- ? (htmlSanitizer ? htmlSanitizer(source).replace(TIGHT_ITEM, '<li>$1</li>') : e(source))
112
- : source.split(/\n{2,}/).map((paragraph) => `<p>${e(paragraph.trim()).replace(/\n/g, '<br />')}</p>`).join('');
113
- return this.wrap(`<div class="${['fi-in-text-item', this._prose ? 'fi-prose' : null, 'fi-size-sm', 'fi-in-text', 'fi-wrapped'].filter(Boolean).join(' ')}">${html}</div>`);
606
+ let state = await this.getState();
607
+ if (typeof state === 'string' && this._separator) {
608
+ const parts = state.split(this._separator);
609
+ state = parts.length === 1 && parts[0].trim() === '' ? [] : parts;
114
610
  }
115
- if (this._badge && Array.isArray(state)) {
116
- // Several values: a list of badges (tags, for instance).
117
- const color = typeof this._color === 'function' ? this._color(record) : (this._color ?? 'primary');
118
- const classes = ['fi-badge', 'fi-size-sm', ...(color !== 'gray' ? (colorManager?.getComponentClasses('badge', color) ?? []) : [])].join(' ');
119
- return this.wrap(`<ul class="fi-in-text fi-in-text-has-badges fi-wrapped">${state.map((item) => `<li class="fi-in-text-item"><span class="${classes}">${e(String(item))}</span></li>`).join('')}</ul>`);
611
+ const isNumeric = this._isMoney || this._isNumeric;
612
+ if (isBlank(state)) {
613
+ // Without placeholder, Filament renders an empty item, except for money, date and badge entries reading the record.
614
+ if (this._placeholder === null && (this._state !== null || (!this._isMoney && !this._isDate && !this._badge)))
615
+ return this.wrapEmbeddedHtml(this.emptyHtml(['fi-in-text-item', 'fi-size-sm', 'fi-in-text', { 'fi-wrapped': this._wrap }]));
616
+ return this.wrapEmbeddedHtml(this.emptyHtml(['fi-in-text', { 'fi-numeric': isNumeric }]));
120
617
  }
121
- if (this._badge) {
122
- const color = typeof this._color === 'function' ? this._color(record) : (this._color ?? 'primary');
123
- const iconName = this._icon?.(record) ?? null;
124
- const classes = ['fi-badge', 'fi-size-sm', ...(color !== 'gray' ? (colorManager?.getComponentClasses('badge', color) ?? []) : [])].join(' ');
125
- return this.wrap(`<div class="fi-in-text-item fi-in-text fi-in-text-has-badges fi-wrapped"><span class="${classes}">${iconName ? icon(iconName, { size: 'sm' }) : ''}${e(shown)}</span></div>`);
618
+ let items = Array.isArray(state) ? state : [state];
619
+ const stateCount = items.length;
620
+ let count = stateCount;
621
+ const hasLineBreaks = this._listWithLineBreaks || this._bulleted;
622
+ const listLimit = this._listLimit ?? count;
623
+ const overLimit = listLimit && count > listLimit ? count - listLimit : 0;
624
+ if (overLimit && (!hasLineBreaks || !this._expandableLimitedList))
625
+ items = items.slice(0, listLimit);
626
+ // Several values without line breaks: one comma-separated item.
627
+ let collapsed = null;
628
+ if (count > 1 && !hasLineBreaks && !this._badge) {
629
+ collapsed = items.map((item) => { const formatted = this.formatItem(item, record); return formatted.isHtml ? formatted.text : e(formatted.text); }).join(', ');
630
+ items = [collapsed];
631
+ count = 1;
126
632
  }
633
+ const alignment = await this.getAlignment();
127
634
  const tr = (key, params) => t(`filament::components/copyable.${key}`, params);
128
- const content = this._copyable
129
- ? `<span x-data="filamentCopyable" x-bind="bindings" x-bind:aria-label="isStandalone ? ${e(js(tr('label', { label: shown })))} : null" x-on:click="if (isStandalone) {
130
- copy(${e(js(shown))}, ${e(js(tr('messages.copied')))}, 2000, ${e(js(tr('messages.failed')))})
131
- } else {
132
- window.navigator.clipboard.writeText(${e(js(shown))})
133
- $tooltip(${e(js(tr('messages.copied')))}, {
134
- theme: $store.theme,
135
- timeout: 2000,
136
- })
137
- }" class="fi-copyable">${e(shown)}</span>`
138
- : e(shown);
139
- return this.wrap(`<div class="${['fi-in-text-item', 'fi-size-sm', 'fi-in-text', this._money || this._numeric ? 'fi-numeric' : null, 'fi-wrapped'].filter(Boolean).join(' ')}">${content}</div>`);
635
+ const itemData = (item) => {
636
+ const formatted = collapsed !== null ? { text: collapsed, isHtml: true } : this.formatItem(item, record);
637
+ const plain = formatted.isHtml ? stripTags(formatted.text) : formatted.text;
638
+ const color = resolveItem(this._color, record, item) ?? (this._badge ? 'primary' : null);
639
+ const size = textSize(resolveItem(this._size, record, item));
640
+ const iconColor = resolveItem(this._iconColor, record, item);
641
+ const iconHtml = renderIcon(resolveItem(this._icon, record, item), { size: size === 'md' || size === 'lg' ? size : 'sm', attributes: new Attributes().class(iconColor && iconColor !== 'gray' ? ['fi-color', `fi-color-${safeName(iconColor)}`] : []) });
642
+ const isCopyable = Boolean(resolveItem(this._copyable, record, item));
643
+ const tooltip = resolveItem(this._tooltip, record, item);
644
+ let attributes = { class: cls(['fi-in-text-item', { 'fi-prose': this._prose || this._markdown }, fontClass(resolveItem(this._fontFamily, record, item), FONT_FAMILIES)]) };
645
+ if (!this._badge) {
646
+ attributes = withClass(attributes, [`fi-size-${size}`, fontClass(resolveItem(this._weight, record, item), FONT_WEIGHTS)]);
647
+ if (this._lineClamp)
648
+ attributes = withStyle(attributes, [`--line-clamp: ${this._lineClamp}`]);
649
+ attributes = withClass(attributes, itemColorClasses(color));
650
+ }
651
+ let content = null;
652
+ if (this._badge || isCopyable || !isBlank(tooltip)) {
653
+ let click = null, label = null;
654
+ if (isCopyable) {
655
+ const copied = js(resolveItem(this._copyableState, record, item) ?? formatted.text);
656
+ const message = js(this._copyMessage ?? tr('messages.copied'));
657
+ click = `if (isStandalone) {\n copy(${copied}, ${message}, ${this._copyMessageDuration}, ${js(tr('messages.failed'))})\n} else {\n window.navigator.clipboard.writeText(${copied})\n $tooltip(${message}, {\n theme: $store.theme,\n timeout: ${this._copyMessageDuration},\n })\n}`;
658
+ label = `isStandalone ? ${js(tr('label', { label: plain }))} : null`;
659
+ }
660
+ content = mergeAttrs({ 'x-data': isCopyable ? 'filamentCopyable' : null, 'x-bind': isCopyable ? 'bindings' : null, 'x-bind:aria-label': label, 'x-on:click': click, 'x-tooltip': tooltipJs(tooltip) }, {});
661
+ content = withClass(content, [{ 'fi-copyable': isCopyable }]);
662
+ if (this._badge)
663
+ content = withClass(content, ['fi-badge', `fi-size-${size}`, ...badgeColorClasses(color)]);
664
+ }
665
+ const html = formatted.isHtml ? formatted.text : e(formatted.text);
666
+ const inner = `${content ? `<span ${renderAttrs(content)}>` : ''}${this._iconPosition === 'before' ? iconHtml : ''}${html}${this._iconPosition === 'after' ? iconHtml : ''}${content ? '</span>' : ''}`;
667
+ return { attributes, inner };
668
+ };
669
+ let container = withClass(this.getExtraAttributesMap(), ['fi-in-text', { 'fi-numeric': isNumeric }]);
670
+ container = withClass(container, [{ 'fi-in-text-has-badges': this._badge }, { 'fi-wrapped': this._wrap }, alignmentClass(alignment)]);
671
+ if (count === 1 && !this._bulleted) {
672
+ const { attributes, inner } = itemData(items[0]);
673
+ return this.wrapEmbeddedHtml(`<div ${renderAttrs(mergeAttrs(attributes, container))}>${inner}</div>`);
674
+ }
675
+ container = withClass(container, [{ 'fi-bulleted': this._bulleted }, { 'fi-in-text-has-line-breaks': hasLineBreaks }]);
676
+ if (overLimit) {
677
+ const expandable = hasLineBreaks && this._expandableLimitedList;
678
+ container = withClass(mergeAttrs({ 'x-data': expandable ? '{ isLimited: true }' : null }, container), ['fi-in-text-list-limited']);
679
+ const listItems = items.map((item, index) => {
680
+ const { attributes, inner } = itemData(item);
681
+ return `<li ${index >= listLimit ? 'x-show="! isLimited" x-cloak x-transition ' : ''}${renderAttrs(attributes)}>${inner}</li>`;
682
+ }).join('');
683
+ const message = expandable
684
+ ? `<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-infolists::components.entries.text.actions.expand_list', 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-infolists::components.entries.text.actions.collapse_list', overLimit))}</div>`
685
+ : e(tc('filament-infolists::components.entries.text.more_list_items', overLimit));
686
+ return this.wrapEmbeddedHtml(`<div ${renderAttrs(container)}><ul>${listItems}</ul><div class="fi-in-text-list-limited-message">${message}</div></div>`);
687
+ }
688
+ const listItems = items.map((item) => { const { attributes, inner } = itemData(item); return `<li ${renderAttrs(attributes)}>${inner}</li>`; }).join('');
689
+ return this.wrapEmbeddedHtml(`<ul ${renderAttrs(container)}>${listItems}</ul>`);
140
690
  }
141
691
  }
142
- /** Color swatch: only hex or simple functional colors are injected into the style attribute. */
692
+ /** Only hex or simple functional colors are injected into the style attribute. */
693
+ 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);
694
+ /** Color swatch: only hex or simple functional colors are displayed. */
143
695
  export class ColorEntry extends Entry {
144
- _placeholder = null;
696
+ _copyable = false;
697
+ _copyableState = null;
698
+ _copyMessage = null;
699
+ _copyMessageDuration = 2000;
700
+ _wrap = true;
145
701
  static make(name) { return configured(new ColorEntry(name)); }
146
- placeholder(text) { this._placeholder = text; return this; }
702
+ /** Clicking the swatch copies the color to the clipboard. */
703
+ copyable(condition = true) { this._copyable = condition; return this; }
704
+ copyableState(state) { this._copyableState = state; return this; }
705
+ copyMessage(message) { this._copyMessage = message; return this; }
706
+ copyMessageDuration(milliseconds) { this._copyMessageDuration = Number.isFinite(milliseconds) ? Math.max(0, Math.floor(milliseconds)) : 2000; return this; }
707
+ wrap(condition = true) { this._wrap = condition; return this; }
147
708
  async toHtml() {
709
+ const record = this.context().record;
148
710
  const state = await this.getState();
149
- 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;
150
- if (color === null)
151
- return this.wrap(this._placeholder !== null ? `<div class="fi-in-color fi-wrapped"><p class="fi-in-placeholder">${e(this._placeholder)}</p></div>` : '<div class="fi-in-color fi-wrapped"></div>');
152
- return this.wrap(`<div class="fi-in-color fi-wrapped"><div role="img" aria-label="${e(color)}" class="fi-in-color-item" style="background-color: ${color};"></div></div>`);
711
+ const colors = (Array.isArray(state) ? state : [state]).map(sanitizeCssColor).filter((color) => color !== null);
712
+ if (!colors.length) {
713
+ // An empty or unsupported value: the wrapping container, with the placeholder if any.
714
+ return this.wrapEmbeddedHtml(this.emptyHtml(['fi-in-color', { 'fi-wrapped': this._wrap }]));
715
+ }
716
+ const alignment = await this.getAlignment();
717
+ const tr = (key, params) => t(`filament::components/copyable.${key}`, params);
718
+ const swatches = colors.map((color) => {
719
+ const isCopyable = Boolean(resolveItem(this._copyable, record, color));
720
+ const tooltip = resolveItem(this._tooltip, record, color);
721
+ const label = isCopyable ? null : !isBlank(tooltip) ? String(tooltip) : color;
722
+ const copied = js(resolveItem(this._copyableState, record, color) ?? color);
723
+ const message = js(this._copyMessage ?? tr('messages.copied'));
724
+ const attributes = withStyle(withClass(mergeAttrs({
725
+ role: label !== null ? 'img' : null,
726
+ 'aria-label': label !== null ? e(label) : null,
727
+ 'x-data': isCopyable ? 'filamentCopyable' : null,
728
+ 'x-bind': isCopyable ? 'bindings' : null,
729
+ 'x-bind:aria-label': isCopyable ? `isStandalone ? ${js(tr('label', { label: color }))} : null` : null,
730
+ 'x-on:click': isCopyable ? `if (isStandalone) {\n copy(${copied}, ${message}, ${this._copyMessageDuration}, ${js(tr('messages.failed'))})\n} else {\n window.navigator.clipboard.writeText(${copied})\n $tooltip(${message}, {\n theme: $store.theme,\n timeout: ${this._copyMessageDuration},\n })\n}` : null,
731
+ 'x-tooltip': tooltipJs(tooltip),
732
+ }, {}), ['fi-in-color-item', { 'fi-copyable': isCopyable }]), [`background-color: ${color}`]);
733
+ return `<div ${renderAttrs(attributes)}></div>`;
734
+ }).join('');
735
+ const container = withClass(this.getExtraAttributesMap(), ['fi-in-color', { 'fi-wrapped': this._wrap }, alignmentClass(alignment)]);
736
+ return this.wrapEmbeddedHtml(`<div ${renderAttrs(container)}>${swatches}</div>`);
153
737
  }
154
738
  }
155
- /** Read-only image: only same-site (`/…`) or http(s) URLs are rendered. */
739
+ /** `<img>` attributes allowed in `extraImgAttributes()`: no event handler, style or source. */
740
+ const IMG_ATTRIBUTES = new Set(['class', 'id', 'title', 'loading', 'decoding', 'fetchpriority', 'referrerpolicy', 'crossorigin', 'width', 'height', 'draggable', 'role']);
741
+ const isAllowedImgAttribute = (name) => IMG_ATTRIBUTES.has(name.toLowerCase()) || /^(data|aria)-[a-z0-9_.-]+$/i.test(name);
742
+ /** Read-only images: only same-site (`/…`) or http(s) URLs are rendered. */
156
743
  export class ImageEntry extends Entry {
157
- _height = '8rem';
744
+ _height = null;
745
+ _width = null;
158
746
  _alt = null;
159
747
  _circular = false;
748
+ _square = false;
749
+ _stacked = false;
750
+ _ring = null;
751
+ _overlap = null;
752
+ _limit = null;
753
+ _limitedRemainingText = false;
754
+ _limitedRemainingTextSize = null;
755
+ _defaultImageUrl = null;
756
+ _extraImgAttributes = {};
757
+ _wrap = true;
160
758
  static make(name) { return configured(new ImageEntry(name)); }
161
- /** CSS height of the image (`8rem` by default). */
162
- imageHeight(height) { if (/^[0-9.]+(rem|px|em)$/.test(height))
163
- this._height = height; return this; }
759
+ /** Height of the images: a CSS length (`40px`, `2.5rem`…) or a number of pixels; `8rem` by default. Any other value is ignored. */
760
+ imageHeight(height) { if (height === null)
761
+ this._height = null;
762
+ else
763
+ this._height = cssLength(height) ?? this._height; return this; }
764
+ /** Width of the images: a CSS length or a number of pixels. Any other value is ignored. */
765
+ imageWidth(width) { if (width === null)
766
+ this._width = null;
767
+ else
768
+ this._width = cssLength(width) ?? this._width; return this; }
769
+ imageSize(size) { return this.imageWidth(size).imageHeight(size); }
164
770
  alt(text) { this._alt = text; return this; }
165
771
  circular(condition = true) { this._circular = condition; return this; }
772
+ /** Square images: the width is the height. */
773
+ square(condition = true) { this._square = condition; return this; }
774
+ /** Overlapping images (`2.5rem` high by default). */
775
+ stacked(condition = true) { this._stacked = condition; return this; }
776
+ /** Ring width of stacked images, from 0 to 8. */
777
+ ring(width) { this._ring = width === null ? null : Math.max(0, Math.min(8, Math.floor(width))); return this; }
778
+ /** Overlap of stacked images, from 0 to 8 (2 by default). */
779
+ overlap(overlap) { this._overlap = overlap === null ? null : Math.max(0, Math.min(8, Math.floor(overlap))); return this; }
780
+ /** Maximum number of images displayed. */
781
+ limit(limit = 3) { this._limit = limit && limit > 0 ? Math.floor(limit) : null; return this; }
782
+ /** Shows "+N" after the limited images; `size`: `xs`, `sm` (default), `md` or `lg`. */
783
+ limitedRemainingText(condition = true, size = null) { this._limitedRemainingText = condition; this._limitedRemainingTextSize = size; return this; }
784
+ /** Image displayed when the state is empty. */
785
+ defaultImageUrl(url) { this._defaultImageUrl = url; return this; }
786
+ /** Extra `<img>` attributes (`loading`, `class`, `data-*`, `aria-*`…). Event handlers, `style` and sources are ignored; values are escaped. */
787
+ extraImgAttributes(attributes) { Object.assign(this._extraImgAttributes, attributes); return this; }
788
+ wrap(condition = true) { this._wrap = condition; return this; }
166
789
  async toHtml() {
790
+ const ctx = this.context();
167
791
  const state = await this.getState();
168
- const sources = (Array.isArray(state) ? state : [state]).filter((src) => typeof src === 'string' && /^(\/(?!\/)|https?:\/\/)/.test(src));
169
- return this.wrap(`<div class="fi-in-image fi-wrapped">${sources.map((src) => `<img alt="${e(this._alt ?? '')}" src="${e(src)}" style="height: ${this._height};"${this._circular ? ' class="fi-circular"' : ''} />`).join('')}</div>`);
792
+ const fallback = await evaluate(this._defaultImageUrl, ctx);
793
+ const defaultUrl = isImageUrl(fallback) ? fallback.trim() : null;
794
+ let sources = (isBlank(state) ? (defaultUrl ? [defaultUrl] : []) : Array.isArray(state) ? state : [state])
795
+ .map((source) => (isImageUrl(source) ? source.trim() : defaultUrl)).filter((source) => source !== null);
796
+ if (!sources.length)
797
+ return this.wrapEmbeddedHtml(this._placeholder !== null || this._emptyTooltip !== null ? this.emptyHtml(['fi-in-image']) : this.emptyHtml(['fi-in-image', { 'fi-wrapped': this._wrap }]));
798
+ const count = sources.length;
799
+ const overLimit = this._limit && count > this._limit ? count - this._limit : 0;
800
+ if (overLimit)
801
+ sources = sources.slice(0, this._limit);
802
+ const alignment = await this.getAlignment();
803
+ const height = this._height ?? (this._stacked ? '2.5rem' : '8rem');
804
+ const width = this._width ?? (this._circular || this._square ? height : null);
805
+ const size = [`height: ${height}`, width ? `width: ${width}` : null];
806
+ const extraImg = escapeAttributes(Object.fromEntries(Object.entries(this._extraImgAttributes).filter(([name]) => isAllowedImgAttribute(name))));
807
+ const images = sources.map((source) => {
808
+ const alt = resolveItem(this._alt, ctx.record, source);
809
+ return `<img ${renderAttrs(withStyle(mergeAttrs({ alt: e(alt ?? ''), src: e(source), 'x-tooltip': tooltipJs(resolveItem(this._tooltip, ctx.record, source)) }, extraImg), size))} />`;
810
+ }).join('');
811
+ const remaining = overLimit && this._limitedRemainingText
812
+ ? `<div ${renderAttrs(withStyle({ class: cls(['fi-in-image-limited-remaining-text', this._limitedRemainingTextSize ? `fi-size-${textSize(this._limitedRemainingTextSize)}` : null]) }, size))}>+${overLimit}</div>`
813
+ : '';
814
+ const container = withClass(this.getExtraAttributesMap(), [
815
+ 'fi-in-image', { 'fi-circular': this._circular, 'fi-wrapped': this._wrap, 'fi-stacked': this._stacked },
816
+ this._stacked && this._ring !== null ? `fi-in-image-ring fi-in-image-ring-${this._ring}` : null,
817
+ this._stacked && (this._overlap ?? 2) ? `fi-in-image-overlap-${this._overlap ?? 2}` : null,
818
+ alignmentClass(alignment),
819
+ ]);
820
+ return this.wrapEmbeddedHtml(`<div ${renderAttrs(container)}>${images}${remaining}</div>`);
170
821
  }
171
822
  }
172
- /** Icon based on the value; `boolean()` shows a green check or a red cross. */
823
+ const ICON_SIZES = new Set(['xs', 'sm', 'md', 'lg', 'xl', '2xl']);
824
+ /** Icons chosen from the state; `boolean()` shows a green check or a red cross. */
173
825
  export class IconEntry extends Entry {
174
826
  _boolean = false;
827
+ _icon = null;
828
+ _color = null;
829
+ _size = null;
830
+ _trueIcon = null;
831
+ _falseIcon = null;
832
+ _trueColor = null;
833
+ _falseColor = null;
834
+ _listWithLineBreaks = false;
835
+ _wrap = true;
175
836
  static make(name) { return configured(new IconEntry(name)); }
176
837
  boolean(condition = true) { this._boolean = condition; return this; }
838
+ /** Icon: a Heroicon name, a map of state values to names, or a function of the record and the state item. */
839
+ icon(icon) { this._icon = icon; return this; }
840
+ /** Color: a name, a map of state values to colors, or a function of the record and the state item. */
841
+ color(color) { this._color = color; return this; }
842
+ /** Icon size: `xs`, `sm`, `md`, `lg` (default), `xl` or `2xl`. */
843
+ size(size) { this._size = size; return this; }
844
+ /** Icon of a true state (`o-check-circle` by default); `false`: no icon. Implies `boolean()`. */
845
+ trueIcon(icon) { this._boolean = true; this._trueIcon = icon; return this; }
846
+ /** Icon of a false state (`o-x-circle` by default); `false`: no icon. Implies `boolean()`. */
847
+ falseIcon(icon) { this._boolean = true; this._falseIcon = icon; return this; }
848
+ /** Color of a true state (`success` by default). Implies `boolean()`. */
849
+ trueColor(color) { this._boolean = true; this._trueColor = color; return this; }
850
+ /** Color of a false state (`danger` by default). Implies `boolean()`. */
851
+ falseColor(color) { this._boolean = true; this._falseColor = color; return this; }
852
+ /** Each icon of a list on its own line. */
853
+ listWithLineBreaks(condition = true) { this._listWithLineBreaks = condition; return this; }
854
+ wrap(condition = true) { this._wrap = condition; return this; }
177
855
  async toHtml() {
856
+ const record = this.context().record;
178
857
  const state = await this.getState();
179
- if (!this._boolean || state === null || state === undefined)
180
- return this.wrap('<div class="fi-in-icon fi-wrapped"></div>');
181
- const on = state === true || state === 1 || state === '1';
182
- const classes = colorManager?.getComponentClasses('icon-column', on ? 'success' : 'danger') ?? [];
183
- return this.wrap(`<div class="fi-in-icon fi-wrapped">${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>`);
858
+ if (isBlank(state))
859
+ return this.wrapEmbeddedHtml(this._placeholder !== null || this._emptyTooltip !== null ? this.emptyHtml(['fi-in-icon']) : this.emptyHtml(['fi-in-icon', { 'fi-wrapped': this._wrap }]));
860
+ const alignment = await this.getAlignment();
861
+ const items = (Array.isArray(state) ? state : [state]).map((item) => {
862
+ const on = item === true || item === 1 || item === '1';
863
+ let name = resolveMap(this._icon, record, item);
864
+ if (!name && this._boolean && item !== null && item !== undefined)
865
+ name = on ? (this._trueIcon === false ? null : this._trueIcon ?? 'o-check-circle') : (this._falseIcon === false ? null : this._falseIcon ?? 'o-x-circle');
866
+ if (!name)
867
+ return '';
868
+ let color = resolveMap(this._color, record, item);
869
+ if (!color && this._boolean && item !== null && item !== undefined)
870
+ color = on ? this._trueColor ?? 'success' : this._falseColor ?? 'danger';
871
+ const size = resolveItem(this._size, record, item);
872
+ const tooltip = resolveItem(this._tooltip, record, item);
873
+ const attributes = new Attributes().merge({ 'x-tooltip': tooltipJs(tooltip) }).class(iconColorClasses(color));
874
+ const text = !isBlank(tooltip) ? String(tooltip) : this._boolean ? t(on ? 'filament-infolists::components.entries.icon.true' : 'filament-infolists::components.entries.icon.false') : ['string', 'number', 'boolean'].includes(typeof item) ? String(item) : '';
875
+ return `${renderIcon(name, { size: size && ICON_SIZES.has(size) ? size : 'lg', attributes })}${text !== '' ? `<span class="fi-sr-only">${e(text)}</span>` : ''}`;
876
+ }).join('');
877
+ const container = withClass(this.getExtraAttributesMap(), ['fi-in-icon', { 'fi-in-icon-has-line-breaks': this._listWithLineBreaks, 'fi-wrapped': this._wrap }, alignmentClass(alignment)]);
878
+ return this.wrapEmbeddedHtml(`<div ${renderAttrs(container)}>${items}</div>`);
184
879
  }
185
880
  }
186
881
  /** Key/value pairs as a table: the state is an object (or its JSON form); a non-scalar value is shown as JSON. */
187
882
  export class KeyValueEntry extends Entry {
188
883
  _keyLabel = null;
189
884
  _valueLabel = null;
190
- _placeholder = null;
191
885
  static make(name) { return configured(new KeyValueEntry(name)); }
192
886
  keyLabel(label) { this._keyLabel = label; return this; }
193
887
  valueLabel(label) { this._valueLabel = label; return this; }
194
- /** Text shown when there are no pairs ("No entries" by default). */
195
- placeholder(text) { this._placeholder = text; return this; }
196
888
  /** @deprecated Use `placeholder()`. */
197
889
  emptyMessage(text) { return this.placeholder(text); }
198
890
  async toHtml() {
@@ -210,7 +902,7 @@ export class KeyValueEntry extends Entry {
210
902
  const show = (value) => (value === null || value === undefined || ['string', 'number', 'boolean'].includes(typeof value) ? (value === true ? '1' : value === false ? '' : String(value ?? '')) : JSON.stringify(value));
211
903
  const rows = entries.map(([key, value]) => `<tr><th scope="row">${e(key)}</th><td>${e(show(value))}</td></tr>`).join('');
212
904
  const empty = entries.length ? '' : `<tr><td colspan="2" class="fi-in-placeholder">${e(this._placeholder ?? tr('placeholder'))}</td></tr>`;
213
- return this.wrap(`<table class="fi-in-key-value"><thead><tr><th scope="col">${e(this._keyLabel ?? tr('columns.key.label'))}</th><th scope="col">${e(this._valueLabel ?? tr('columns.value.label'))}</th></tr></thead><tbody>${rows}${empty}</tbody></table>`);
905
+ return this.wrapEmbeddedHtml(`<table ${renderAttrs(withClass(this.getExtraAttributesMap(), ['fi-in-key-value']))}><thead><tr><th scope="col">${e(this._keyLabel ?? tr('columns.key.label'))}</th><th scope="col">${e(this._valueLabel ?? tr('columns.value.label'))}</th></tr></thead><tbody>${rows}${empty}</tbody></table>`);
214
906
  }
215
907
  }
216
908
  /** Pretty-printed JSON: four-space indent, with `/` and non-ASCII characters escaped. */
@@ -225,7 +917,6 @@ export class CodeEntry extends Entry {
225
917
  _grammar = null;
226
918
  _lightTheme = null;
227
919
  _darkTheme = null;
228
- _placeholder = null;
229
920
  _copyable = false;
230
921
  _copyMessage = null;
231
922
  _copyMessageDuration = 2000;
@@ -235,7 +926,6 @@ export class CodeEntry extends Entry {
235
926
  /** Theme class names only: without a highlighter, colors stay those of the default themes. */
236
927
  lightTheme(theme) { this._lightTheme = theme; return this; }
237
928
  darkTheme(theme) { this._darkTheme = theme; return this; }
238
- placeholder(text) { this._placeholder = text; return this; }
239
929
  /** Clicking copies the code to the clipboard. */
240
930
  copyable(condition = true) { this._copyable = condition; return this; }
241
931
  copyMessage(message) { this._copyMessage = message; return this; }
@@ -243,7 +933,7 @@ export class CodeEntry extends Entry {
243
933
  async toHtml() {
244
934
  let state = await this.getState();
245
935
  if (state === null || state === undefined || (typeof state === 'string' && state.trim() === '') || (Array.isArray(state) && !state.length)) {
246
- return this.wrap(`<div class="fi-in-code">${this._placeholder !== null ? `<p class="fi-in-placeholder">${e(this._placeholder)}</p>` : ''}</div>`);
936
+ return this.wrapEmbeddedHtml(`<div class="fi-in-code">${this._placeholder !== null ? `<p class="fi-in-placeholder">${e(this._placeholder)}</p>` : ''}</div>`);
247
937
  }
248
938
  let grammar = this._grammar;
249
939
  if (typeof state === 'object') {
@@ -259,16 +949,16 @@ export class CodeEntry extends Entry {
259
949
  const linesHtml = lines.map((line, index) => `<span class="line">${line === '' ? '' : `<span class="token">${e(line)}</span>`}${index < lines.length - 1 ? '<span class="token">\n</span>' : ''}</span>`).filter((html, index) => !(index === lines.length - 1 && lines[index] === '' && lines.length > 1)).join('');
260
950
  const pre = `<pre class="phiki language-${language} ${light} phiki-themes ${dark}" data-language="${language}"${style}><code>${linesHtml}</code></pre>`;
261
951
  if (!this._copyable)
262
- return this.wrap(`<div class="fi-in-code">${pre}</div>`);
952
+ return this.wrapEmbeddedHtml(`<div ${renderAttrs(withClass(this.getExtraAttributesMap(), ['fi-in-code']))}>${pre}</div>`);
263
953
  const tr = (key, params) => t(`filament::components/copyable.${key}`, params);
264
954
  const message = js(this._copyMessage ?? tr('messages.copied'));
265
955
  const click = `if (isStandalone) {\n copy(${js(code)}, ${message}, ${this._copyMessageDuration}, ${js(tr('messages.failed'))})\n} else {\n window.navigator.clipboard.writeText(${js(code)})\n $tooltip(${message}, {\n theme: $store.theme,\n timeout: ${this._copyMessageDuration},\n })\n}`;
266
- const attributes = new Attributes().merge({
956
+ const attributes = new Attributes(this.getExtraAttributesMap()).merge({
267
957
  'x-data': 'filamentCopyable', 'x-bind': 'bindings', 'x-id': "['copyable-code']",
268
- 'x-bind:aria-label': `isStandalone ? ${js(tr('label', { label: this._label ?? defaultLabel(this._name) }))} : null`,
958
+ 'x-bind:aria-label': `isStandalone ? ${js(tr('label', { label: await this.getLabel() }))} : null`,
269
959
  'x-bind:aria-describedby': "isStandalone ? $id('copyable-code') : null", 'x-on:click': click,
270
960
  }).class(['fi-in-code', 'fi-copyable']);
271
- return this.wrap(`<div ${attributes.toHtml()}><div x-bind:id="$id('copyable-code')">${pre}</div></div>`);
961
+ return this.wrapEmbeddedHtml(`<div ${attributes.toHtml()}><div x-bind:id="$id('copyable-code')">${pre}</div></div>`);
272
962
  }
273
963
  }
274
964
  /**
@@ -283,37 +973,101 @@ export class ViewEntry extends Entry {
283
973
  view(renderer) { this._view = renderer; return this; }
284
974
  /** `false`: the function renders the whole entry, without the usual wrapper (label). */
285
975
  wrapped(condition = true) { this._wrapped = condition; return this; }
976
+ /** Places custom content in the entry wrapper (label, slots, link): for entry classes extending `ViewEntry`. */
977
+ wrap(content) { return this.wrapEmbeddedHtml(content); }
286
978
  async toHtml() {
287
979
  if (!this._view)
288
980
  throw new Error(`ViewEntry [${this._name}]: view() is required.`);
289
981
  const html = await this._view({ ...this.context(), state: await this.getState(), entry: this });
290
- return this._wrapped ? this.wrap(html) : html;
982
+ return this._wrapped ? this.wrapEmbeddedHtml(html) : html;
291
983
  }
292
984
  }
985
+ /** Column of a table repeatable entry: a header label, an optional width and alignment. */
986
+ export class TableColumn {
987
+ label;
988
+ _width = null;
989
+ _hiddenHeaderLabel = false;
990
+ _wrapHeader = false;
991
+ _alignment = null;
992
+ constructor(label) { this.label = label; }
993
+ static make(label) { return configured(new TableColumn(label)); }
994
+ /** Fixed width: a number of pixels, or a CSS length (`10rem`, `20%`). Any other value is ignored. */
995
+ width(width) { this._width = width === null ? null : cssLength(width) ?? this._width; return this; }
996
+ /** Keeps the header label for screen readers only. */
997
+ hiddenHeaderLabel(condition = true) { this._hiddenHeaderLabel = condition; return this; }
998
+ /** Lets a long header label wrap onto several lines. */
999
+ wrapHeader(condition = true) { this._wrapHeader = condition; return this; }
1000
+ /** Header alignment: `start`, `center` or `end`. */
1001
+ alignment(alignment) { this._alignment = alignment; return this; }
1002
+ alignStart() { return this.alignment('start'); }
1003
+ alignCenter() { return this.alignment('center'); }
1004
+ alignEnd() { return this.alignment('end'); }
1005
+ getWidth() { return this._width; }
1006
+ isHeaderLabelHidden() { return this._hiddenHeaderLabel; }
1007
+ canHeaderWrap() { return this._wrapHeader; }
1008
+ getAlignment() { return this._alignment; }
1009
+ }
293
1010
  /**
294
- * Read-only list of items, as a table: one row per item of the array read from
295
- * the record, one cell per schema entry.
1011
+ * Read-only list of items read from the record: cards in a grid (default), or a table (`table()`) with one row per item
1012
+ * and one cell per entry.
296
1013
  */
297
1014
  export class RepeatableEntry extends Entry {
298
- _columns = [];
1015
+ _tableColumns = null;
299
1016
  _schema = () => [];
1017
+ _grid = null;
1018
+ _itemColumns = null;
1019
+ _contained = true;
300
1020
  static make(name) { return configured(new RepeatableEntry(name)); }
301
- table(columns) { this._columns = columns; return this; }
302
- schema(entries) { this._schema = typeof entries === 'function' ? entries : () => entries; return this; }
1021
+ /** Renders the items as a table with these columns, in the order of the schema's entries. */
1022
+ table(columns) { this._tableColumns = columns; return this; }
1023
+ /** Components repeated for each item. A function returns fresh instances for each item. */
1024
+ schema(components) { this._schema = typeof components === 'function' ? components : () => components; return this; }
1025
+ /** Card layout: number of item columns (from `lg` for a number, per breakpoint for an object); 2 when called without argument. */
1026
+ grid(columns = 2) { this._grid = columns; return this; }
1027
+ /** Card layout: grid columns inside each item. */
1028
+ columns(columns) { this._itemColumns = columns; return this; }
1029
+ /** Card layout: `false` removes the card styling of the items. */
1030
+ contained(condition = true) { this._contained = condition; return this; }
1031
+ itemSchema(index, item) {
1032
+ const c = this.container;
1033
+ const components = this._schema();
1034
+ const schema = new Schema(c.livewire).key(`${this.getKey()}.${index}`).statePath(`${this.getStatePath()}.${index}`).operation(c.getOperation()).record(item).root(false).components(components);
1035
+ return { schema, components };
1036
+ }
303
1037
  async toHtml() {
304
1038
  const state = await this.getState();
305
1039
  const items = Array.isArray(state) ? state : [];
306
- const c = this.container;
1040
+ const columns = this._tableColumns ?? [];
1041
+ if (!columns.length) {
1042
+ if (!items.length)
1043
+ return this.wrapEmbeddedHtml(this.emptyHtml(['fi-in-repeatable', { 'fi-contained': this._contained }]));
1044
+ const list = new Attributes(this.getExtraAttributesMap()).class(['fi-in-repeatable', { 'fi-contained': this._contained }]).grid(this._grid);
1045
+ const cards = [];
1046
+ for (const [index, item] of items.entries()) {
1047
+ const { schema } = this.itemSchema(index, item);
1048
+ cards.push(`<li class="fi-in-repeatable-item">${await schema.columns(this._itemColumns ?? {}).toHtml()}</li>`);
1049
+ }
1050
+ return this.wrapEmbeddedHtml(`<ul ${list.toHtml()}>${cards.join('')}</ul>`);
1051
+ }
1052
+ if (!items.length)
1053
+ return this.wrapEmbeddedHtml(this.emptyHtml(['fi-in-table-repeatable']));
307
1054
  const rows = [];
308
1055
  for (const [index, item] of items.entries()) {
309
- const entries = this._schema();
310
- new Schema(c.livewire).key(`${this.getKey()}.${index}`).statePath(`${this.getStatePath()}.${index}`).operation(c.getOperation()).record(item).root(false).components(entries);
1056
+ const { components } = this.itemSchema(index, item);
311
1057
  const cells = [];
312
- for (const entry of entries)
313
- cells.push(`<td wire:key="${e(entry.getLivewireKey() ?? '')}" x-data="filamentSchemaComponent(${e(js(entry.getAlpineScopeConfiguration()))})">${await entry.toHtml()}</td>`);
1058
+ for (const component of components.slice(0, columns.length)) {
1059
+ cells.push(await component.isVisible()
1060
+ ? `<td wire:key="${e(component.getLivewireKey() ?? '')}" x-data="filamentSchemaComponent(${e(js(component.getAlpineScopeConfiguration()))})">${await component.toHtml()}</td>`
1061
+ : '<td class="fi-hidden"></td>');
1062
+ }
314
1063
  rows.push(`<tr>${cells.join('')}</tr>`);
315
1064
  }
316
- const head = this._columns.map((column) => `<th scope="col" class=""${column.getWidth() ? ` style="width: ${e(column.getWidth())}"` : ''}>${e(column.label)}</th>`).join('');
317
- return this.wrap(`<div class="fi-in-table-repeatable"><table><thead><tr>${head}</tr></thead><tbody>${rows.join('')}</tbody></table></div>`);
1065
+ const head = columns.map((column) => {
1066
+ const width = cssLength(column.getWidth());
1067
+ const classes = cls([{ 'fi-wrapped': column.canHeaderWrap?.() ?? false }, alignmentClass(column.getAlignment?.() ?? null)]);
1068
+ const label = column.isHeaderLabelHidden?.() ? `<span class="fi-sr-only">${e(column.label)}</span>` : e(column.label);
1069
+ return `<th scope="col" class="${classes}"${width ? ` style="width: ${e(width)}"` : ''}>${label}</th>`;
1070
+ }).join('');
1071
+ return this.wrapEmbeddedHtml(`<div ${renderAttrs(withClass(this.getExtraAttributesMap(), ['fi-in-table-repeatable']))}><table><thead><tr>${head}</tr></thead><tbody>${rows.join('')}</tbody></table></div>`);
318
1072
  }
319
1073
  }