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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 NavetteJS
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,9 @@
1
- # Temporary Holding Version
1
+ # @navette-js/infolists
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Read-only infolist entries for Navette admin panels.
4
+
5
+ Part of [Navette](https://github.com/NavetteJS/navette), admin panels for Node.js. Start a project with `npm create navette@latest`, or install the framework with `npm install @navette-js/panels`.
6
+
7
+ **Beta:** the API may still change between beta releases.
8
+
9
+ License: MIT. Third-party notices: see `THIRD-PARTY-NOTICES.md` where present.
package/lib/index.d.ts ADDED
@@ -0,0 +1,164 @@
1
+ import { Component, type ComponentContext } from '@navette-js/schemas';
2
+ import { type ColorManager } from '@navette-js/support';
3
+ export declare const useColorManager: (manager: ColorManager) => void;
4
+ /** Base entry: a label and a value read from the record (dotted paths allowed) or computed. */
5
+ declare abstract class Entry extends Component {
6
+ protected _label: string | null;
7
+ protected _state: ((ctx: ComponentContext) => unknown | Promise<unknown>) | null;
8
+ constructor(name: string);
9
+ hasStatePath(): boolean;
10
+ label(label: string): this;
11
+ protected _hiddenLabel: boolean;
12
+ /** Keeps the label for screen readers only. */
13
+ hiddenLabel(condition?: boolean): this;
14
+ /** Computed value; without it, the record attribute of the same name is shown. */
15
+ state(resolver: (ctx: ComponentContext) => unknown | Promise<unknown>): this;
16
+ protected getState(): Promise<unknown>;
17
+ /** Wraps the content with the entry label. */
18
+ protected wrap(content: string): string;
19
+ }
20
+ /** Sanitizer applied to HTML an entry renders as is (`markdown()`). Without one, the content is escaped. */
21
+ export declare const useHtmlSanitizer: (sanitizer: ((html: string) => string) | null) => void;
22
+ /** Read-only text: badge, color, icon, money, date, placeholder. */
23
+ export declare class TextEntry extends Entry {
24
+ private _badge;
25
+ private _color;
26
+ private _icon;
27
+ private _format;
28
+ private _money;
29
+ private _date;
30
+ private _placeholder;
31
+ static make(name: string): TextEntry;
32
+ badge(condition?: boolean): this;
33
+ color(color: string | ((record: any) => string)): this;
34
+ icon(resolver: (record: any) => string | null): this;
35
+ formatStateUsing(formatter: (ctx: {
36
+ state: unknown;
37
+ record: any;
38
+ }) => string): this;
39
+ money(currency?: string): this;
40
+ date(condition?: boolean): this;
41
+ placeholder(text: string | null): this;
42
+ private _dateTime;
43
+ private _time;
44
+ private _suffix;
45
+ dateTime(condition?: boolean): this;
46
+ time(format?: string): this;
47
+ suffix(text: string | null): this;
48
+ private _prose;
49
+ private _markdown;
50
+ /** Typographic styling of the content (`fi-prose`). */
51
+ prose(condition?: boolean): this;
52
+ /**
53
+ * Rich text content, rendered as HTML after the sanitizer set with `useHtmlSanitizer()`.
54
+ * Existing HTML is kept (sanitized) and plain text becomes paragraphs; Markdown syntax itself is not converted.
55
+ */
56
+ markdown(condition?: boolean): this;
57
+ private _copyable;
58
+ private _numeric;
59
+ /** Clicking copies the value to the clipboard. */
60
+ copyable(condition?: boolean): this;
61
+ /** Formatted number (thousands separators), aligned as a number. */
62
+ numeric(condition?: boolean): this;
63
+ toHtml(): Promise<string>;
64
+ }
65
+ /** Color swatch: only hex or simple functional colors are injected into the style attribute. */
66
+ export declare class ColorEntry extends Entry {
67
+ private _placeholder;
68
+ static make(name: string): ColorEntry;
69
+ placeholder(text: string | null): this;
70
+ toHtml(): Promise<string>;
71
+ }
72
+ /** Read-only image: only same-site (`/…`) or http(s) URLs are rendered. */
73
+ export declare class ImageEntry extends Entry {
74
+ private _height;
75
+ private _alt;
76
+ private _circular;
77
+ static make(name: string): ImageEntry;
78
+ /** CSS height of the image (`8rem` by default). */
79
+ imageHeight(height: string): this;
80
+ alt(text: string | null): this;
81
+ circular(condition?: boolean): this;
82
+ toHtml(): Promise<string>;
83
+ }
84
+ /** Icon based on the value; `boolean()` shows a green check or a red cross. */
85
+ export declare class IconEntry extends Entry {
86
+ private _boolean;
87
+ static make(name: string): IconEntry;
88
+ boolean(condition?: boolean): this;
89
+ toHtml(): Promise<string>;
90
+ }
91
+ /** Key/value pairs as a table: the state is an object (or its JSON form); a non-scalar value is shown as JSON. */
92
+ export declare class KeyValueEntry extends Entry {
93
+ private _keyLabel;
94
+ private _valueLabel;
95
+ private _placeholder;
96
+ static make(name: string): KeyValueEntry;
97
+ keyLabel(label: string | null): this;
98
+ valueLabel(label: string | null): this;
99
+ /** Text shown when there are no pairs ("No entries" by default). */
100
+ placeholder(text: string | null): this;
101
+ /** @deprecated Use `placeholder()`. */
102
+ emptyMessage(text: string | null): this;
103
+ toHtml(): Promise<string>;
104
+ }
105
+ /**
106
+ * Read-only code, rendered as `<pre class="phiki …">` but WITHOUT syntax highlighting:
107
+ * each line is a single escaped text token. An array or object is shown as indented JSON.
108
+ */
109
+ export declare class CodeEntry extends Entry {
110
+ private _grammar;
111
+ private _lightTheme;
112
+ private _darkTheme;
113
+ private _placeholder;
114
+ private _copyable;
115
+ private _copyMessage;
116
+ private _copyMessageDuration;
117
+ static make(name: string): CodeEntry;
118
+ /** Language (`php`, `json`, `javascript`…) used in the `language-…` class; `html` by default, `json` for an array. */
119
+ grammar(grammar: string | null): this;
120
+ /** Theme class names only: without a highlighter, colors stay those of the default themes. */
121
+ lightTheme(theme: string | null): this;
122
+ darkTheme(theme: string | null): this;
123
+ placeholder(text: string | null): this;
124
+ /** Clicking copies the code to the clipboard. */
125
+ copyable(condition?: boolean): this;
126
+ copyMessage(message: string | null): this;
127
+ copyMessageDuration(milliseconds: number): this;
128
+ toHtml(): Promise<string>;
129
+ }
130
+ /**
131
+ * Custom-rendered entry: the given function receives the state, record and context, and returns the content
132
+ * placed in the entry wrapper. This HTML is inserted AS IS: escape any user- or database-supplied value with `e()`.
133
+ */
134
+ export declare class ViewEntry extends Entry {
135
+ private _view;
136
+ private _wrapped;
137
+ static make(name: string): ViewEntry;
138
+ /** Content render function. */
139
+ view(renderer: ((ctx: ComponentContext & {
140
+ state: unknown;
141
+ entry: ViewEntry;
142
+ }) => string | Promise<string>) | null): this;
143
+ /** `false`: the function renders the whole entry, without the usual wrapper (label). */
144
+ wrapped(condition?: boolean): this;
145
+ toHtml(): Promise<string>;
146
+ }
147
+ /** Column header of a table repeatable entry (same shape as `TableColumn` from @navette-js/forms). */
148
+ export type RepeatableColumn = {
149
+ label: string;
150
+ getWidth(): string | null;
151
+ };
152
+ /**
153
+ * Read-only list of items, as a table: one row per item of the array read from
154
+ * the record, one cell per schema entry.
155
+ */
156
+ export declare class RepeatableEntry extends Entry {
157
+ private _columns;
158
+ private _schema;
159
+ static make(name: string): RepeatableEntry;
160
+ table(columns: RepeatableColumn[]): this;
161
+ schema(entries: Entry[] | (() => Entry[])): this;
162
+ toHtml(): Promise<string>;
163
+ }
164
+ export {};
package/lib/index.js ADDED
@@ -0,0 +1,319 @@
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';
5
+ const headline = (s) => s.replace(/([a-z0-9])([A-Z])/g, '$1 $2').replace(/[_-]+/g, ' ').toLowerCase().replace(/^./, (c) => c.toUpperCase());
6
+ /** Default label: "Department" for `department.name`. */
7
+ const defaultLabel = (name) => headline(name.includes('.') ? name.split('.').slice(-2, -1)[0] : name);
8
+ let colorManager = null;
9
+ export const useColorManager = (manager) => { colorManager = manager; };
10
+ /** Base entry: a label and a value read from the record (dotted paths allowed) or computed. */
11
+ class Entry extends Component {
12
+ _label = null;
13
+ _state = null;
14
+ constructor(name) { super(); this._name = name; }
15
+ hasStatePath() { return true; }
16
+ label(label) { this._label = label; return this; }
17
+ _hiddenLabel = false;
18
+ /** Keeps the label for screen readers only. */
19
+ hiddenLabel(condition = true) { this._hiddenLabel = condition; return this; }
20
+ /** Computed value; without it, the record attribute of the same name is shown. */
21
+ state(resolver) { this._state = resolver; return this; }
22
+ async getState() {
23
+ const ctx = this.context();
24
+ 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];
31
+ }
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>`;
37
+ }
38
+ }
39
+ let htmlSanitizer = null;
40
+ 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. */
42
+ export const useHtmlSanitizer = (sanitizer) => { htmlSanitizer = sanitizer; };
43
+ /** Read-only text: badge, color, icon, money, date, placeholder. */
44
+ export class TextEntry extends Entry {
45
+ _badge = false;
46
+ _color = null;
47
+ _icon = null;
48
+ _format = null;
49
+ _money = null;
50
+ _date = false;
51
+ _placeholder = null;
52
+ static make(name) { return configured(new TextEntry(name)); }
53
+ badge(condition = true) { this._badge = condition; return this; }
54
+ color(color) { this._color = color; return this; }
55
+ icon(resolver) { this._icon = resolver; return this; }
56
+ 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; }
70
+ /**
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.
73
+ */
74
+ markdown(condition = true) { this._markdown = condition; return this; }
75
+ _copyable = false;
76
+ _numeric = false;
77
+ /** Clicking copies the value to the clipboard. */
78
+ 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; }
81
+ async toHtml() {
82
+ 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>`);
114
+ }
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>`);
120
+ }
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>`);
126
+ }
127
+ 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>`);
140
+ }
141
+ }
142
+ /** Color swatch: only hex or simple functional colors are injected into the style attribute. */
143
+ export class ColorEntry extends Entry {
144
+ _placeholder = null;
145
+ static make(name) { return configured(new ColorEntry(name)); }
146
+ placeholder(text) { this._placeholder = text; return this; }
147
+ async toHtml() {
148
+ 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>`);
153
+ }
154
+ }
155
+ /** Read-only image: only same-site (`/…`) or http(s) URLs are rendered. */
156
+ export class ImageEntry extends Entry {
157
+ _height = '8rem';
158
+ _alt = null;
159
+ _circular = false;
160
+ 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; }
164
+ alt(text) { this._alt = text; return this; }
165
+ circular(condition = true) { this._circular = condition; return this; }
166
+ async toHtml() {
167
+ 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>`);
170
+ }
171
+ }
172
+ /** Icon based on the value; `boolean()` shows a green check or a red cross. */
173
+ export class IconEntry extends Entry {
174
+ _boolean = false;
175
+ static make(name) { return configured(new IconEntry(name)); }
176
+ boolean(condition = true) { this._boolean = condition; return this; }
177
+ async toHtml() {
178
+ 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>`);
184
+ }
185
+ }
186
+ /** Key/value pairs as a table: the state is an object (or its JSON form); a non-scalar value is shown as JSON. */
187
+ export class KeyValueEntry extends Entry {
188
+ _keyLabel = null;
189
+ _valueLabel = null;
190
+ _placeholder = null;
191
+ static make(name) { return configured(new KeyValueEntry(name)); }
192
+ keyLabel(label) { this._keyLabel = label; return this; }
193
+ 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
+ /** @deprecated Use `placeholder()`. */
197
+ emptyMessage(text) { return this.placeholder(text); }
198
+ async toHtml() {
199
+ let state = await this.getState();
200
+ if (typeof state === 'string') {
201
+ try {
202
+ state = JSON.parse(state);
203
+ }
204
+ catch {
205
+ state = null;
206
+ }
207
+ }
208
+ const entries = state && typeof state === 'object' ? (state instanceof Map ? [...state.entries()] : Object.entries(state)) : [];
209
+ const tr = (key) => t(`filament-infolists::components.entries.key_value.${key}`);
210
+ const show = (value) => (value === null || value === undefined || ['string', 'number', 'boolean'].includes(typeof value) ? (value === true ? '1' : value === false ? '' : String(value ?? '')) : JSON.stringify(value));
211
+ const rows = entries.map(([key, value]) => `<tr><th scope="row">${e(key)}</th><td>${e(show(value))}</td></tr>`).join('');
212
+ 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>`);
214
+ }
215
+ }
216
+ /** Pretty-printed JSON: four-space indent, with `/` and non-ASCII characters escaped. */
217
+ const phpPrettyJson = (value) => (JSON.stringify(value, null, 4) ?? 'null').replace(/\//g, '\\/').replace(/[\u0080-￿]/g, (c) => `\\u${c.charCodeAt(0).toString(16).padStart(4, '0')}`);
218
+ /** Background and text colors of the default themes (github-light / github-dark-high-contrast). */
219
+ const PHIKI_DEFAULT_STYLE = 'background-color: #fff;color: #24292e;--phiki-dark-background-color: #0a0c10;--phiki-dark-color: #f0f3f6;';
220
+ /**
221
+ * Read-only code, rendered as `<pre class="phiki …">` but WITHOUT syntax highlighting:
222
+ * each line is a single escaped text token. An array or object is shown as indented JSON.
223
+ */
224
+ export class CodeEntry extends Entry {
225
+ _grammar = null;
226
+ _lightTheme = null;
227
+ _darkTheme = null;
228
+ _placeholder = null;
229
+ _copyable = false;
230
+ _copyMessage = null;
231
+ _copyMessageDuration = 2000;
232
+ static make(name) { return configured(new CodeEntry(name)); }
233
+ /** Language (`php`, `json`, `javascript`…) used in the `language-…` class; `html` by default, `json` for an array. */
234
+ grammar(grammar) { this._grammar = grammar; return this; }
235
+ /** Theme class names only: without a highlighter, colors stay those of the default themes. */
236
+ lightTheme(theme) { this._lightTheme = theme; return this; }
237
+ darkTheme(theme) { this._darkTheme = theme; return this; }
238
+ placeholder(text) { this._placeholder = text; return this; }
239
+ /** Clicking copies the code to the clipboard. */
240
+ copyable(condition = true) { this._copyable = condition; return this; }
241
+ copyMessage(message) { this._copyMessage = message; return this; }
242
+ copyMessageDuration(milliseconds) { this._copyMessageDuration = milliseconds; return this; }
243
+ async toHtml() {
244
+ let state = await this.getState();
245
+ 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>`);
247
+ }
248
+ let grammar = this._grammar;
249
+ if (typeof state === 'object') {
250
+ state = phpPrettyJson(state);
251
+ grammar ??= 'json';
252
+ }
253
+ const code = String(state);
254
+ const language = (grammar ?? 'html').replace(/[^a-zA-Z0-9_+#-]/g, '');
255
+ const light = (this._lightTheme ?? 'github-light').replace(/[^a-zA-Z0-9_-]/g, ''), dark = (this._darkTheme ?? 'github-dark-high-contrast').replace(/[^a-zA-Z0-9_-]/g, '');
256
+ const style = this._lightTheme === null && this._darkTheme === null ? ` style="${PHIKI_DEFAULT_STYLE}"` : '';
257
+ // One `<span class="line">` per line, the newline being the last token of each line.
258
+ const lines = code.split('\n');
259
+ 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
+ const pre = `<pre class="phiki language-${language} ${light} phiki-themes ${dark}" data-language="${language}"${style}><code>${linesHtml}</code></pre>`;
261
+ if (!this._copyable)
262
+ return this.wrap(`<div class="fi-in-code">${pre}</div>`);
263
+ const tr = (key, params) => t(`filament::components/copyable.${key}`, params);
264
+ const message = js(this._copyMessage ?? tr('messages.copied'));
265
+ 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({
267
+ '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`,
269
+ 'x-bind:aria-describedby': "isStandalone ? $id('copyable-code') : null", 'x-on:click': click,
270
+ }).class(['fi-in-code', 'fi-copyable']);
271
+ return this.wrap(`<div ${attributes.toHtml()}><div x-bind:id="$id('copyable-code')">${pre}</div></div>`);
272
+ }
273
+ }
274
+ /**
275
+ * Custom-rendered entry: the given function receives the state, record and context, and returns the content
276
+ * placed in the entry wrapper. This HTML is inserted AS IS: escape any user- or database-supplied value with `e()`.
277
+ */
278
+ export class ViewEntry extends Entry {
279
+ _view = null;
280
+ _wrapped = true;
281
+ static make(name) { return configured(new ViewEntry(name)); }
282
+ /** Content render function. */
283
+ view(renderer) { this._view = renderer; return this; }
284
+ /** `false`: the function renders the whole entry, without the usual wrapper (label). */
285
+ wrapped(condition = true) { this._wrapped = condition; return this; }
286
+ async toHtml() {
287
+ if (!this._view)
288
+ throw new Error(`ViewEntry [${this._name}]: view() is required.`);
289
+ const html = await this._view({ ...this.context(), state: await this.getState(), entry: this });
290
+ return this._wrapped ? this.wrap(html) : html;
291
+ }
292
+ }
293
+ /**
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.
296
+ */
297
+ export class RepeatableEntry extends Entry {
298
+ _columns = [];
299
+ _schema = () => [];
300
+ 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; }
303
+ async toHtml() {
304
+ const state = await this.getState();
305
+ const items = Array.isArray(state) ? state : [];
306
+ const c = this.container;
307
+ const rows = [];
308
+ 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);
311
+ 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>`);
314
+ rows.push(`<tr>${cells.join('')}</tr>`);
315
+ }
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>`);
318
+ }
319
+ }
package/package.json CHANGED
@@ -1,6 +1,47 @@
1
1
  {
2
2
  "name": "@navette-js/infolists",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "1.0.0-beta.2",
4
+ "description": "Read-only infolist entries for Navette admin panels.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "exports": {
8
+ ".": {
9
+ "types": "./lib/index.d.ts",
10
+ "default": "./lib/index.js"
11
+ }
12
+ },
13
+ "types": "./lib/index.d.ts",
14
+ "files": [
15
+ "lib",
16
+ "LICENSE",
17
+ "README.md"
18
+ ],
19
+ "engines": {
20
+ "node": ">=24"
21
+ },
22
+ "repository": {
23
+ "type": "git",
24
+ "url": "git+https://github.com/NavetteJS/navette.git",
25
+ "directory": "packages/infolists"
26
+ },
27
+ "homepage": "https://github.com/NavetteJS/navette#readme",
28
+ "bugs": {
29
+ "url": "https://github.com/NavetteJS/navette/issues"
30
+ },
31
+ "keywords": [
32
+ "navette",
33
+ "admin",
34
+ "panel",
35
+ "livewire",
36
+ "express",
37
+ "node"
38
+ ],
39
+ "dependencies": {
40
+ "@navette-js/support": "1.0.0-beta.2",
41
+ "@navette-js/schemas": "1.0.0-beta.2"
42
+ },
43
+ "publishConfig": {
44
+ "access": "public",
45
+ "tag": "beta"
46
+ }
47
+ }