@navette-js/infolists 0.0.0-stage → 1.0.0-beta.1
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 +21 -0
- package/README.md +8 -2
- package/lib/index.d.ts +163 -0
- package/lib/index.js +319 -0
- package/package.json +46 -4
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
|
-
#
|
|
1
|
+
# @navette-js/infolists
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Infolist entries for Navette, the Node.js port of Filament.
|
|
4
|
+
|
|
5
|
+
Part of [Navette](https://github.com/NavetteJS/navette), a Node.js port of [Filament](https://filamentphp.com). Install the whole 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,163 @@
|
|
|
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 des entrées : libellé, valeur lue dans l'enregistrement (chemin pointé accepté) ou calculée. */
|
|
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
|
+
/** Libellé gardé pour les lecteurs d'écran seulement. */
|
|
13
|
+
hiddenLabel(condition?: boolean): this;
|
|
14
|
+
/** Valeur calculée ; sans cela, l'attribut du même nom de l'enregistrement est affiché. */
|
|
15
|
+
state(resolver: (ctx: ComponentContext) => unknown | Promise<unknown>): this;
|
|
16
|
+
protected getState(): Promise<unknown>;
|
|
17
|
+
/** Balisage de Entry::wrapEmbeddedHtml(). */
|
|
18
|
+
protected wrap(content: string): string;
|
|
19
|
+
}
|
|
20
|
+
/** Filtre appliqué au HTML qu'une entrée affiche tel quel (`markdown()`). Sans filtre, le contenu est échappé. */
|
|
21
|
+
export declare const useHtmlSanitizer: (sanitizer: ((html: string) => string) | null) => void;
|
|
22
|
+
export declare class TextEntry extends Entry {
|
|
23
|
+
private _badge;
|
|
24
|
+
private _color;
|
|
25
|
+
private _icon;
|
|
26
|
+
private _format;
|
|
27
|
+
private _money;
|
|
28
|
+
private _date;
|
|
29
|
+
private _placeholder;
|
|
30
|
+
static make(name: string): TextEntry;
|
|
31
|
+
badge(condition?: boolean): this;
|
|
32
|
+
color(color: string | ((record: any) => string)): this;
|
|
33
|
+
icon(resolver: (record: any) => string | null): this;
|
|
34
|
+
formatStateUsing(formatter: (ctx: {
|
|
35
|
+
state: unknown;
|
|
36
|
+
record: any;
|
|
37
|
+
}) => string): this;
|
|
38
|
+
money(currency?: string): this;
|
|
39
|
+
date(condition?: boolean): this;
|
|
40
|
+
placeholder(text: string | null): this;
|
|
41
|
+
private _dateTime;
|
|
42
|
+
private _time;
|
|
43
|
+
private _suffix;
|
|
44
|
+
dateTime(condition?: boolean): this;
|
|
45
|
+
time(format?: string): this;
|
|
46
|
+
suffix(text: string | null): this;
|
|
47
|
+
private _prose;
|
|
48
|
+
private _markdown;
|
|
49
|
+
/** Mise en forme typographique du contenu (`fi-prose`). */
|
|
50
|
+
prose(condition?: boolean): this;
|
|
51
|
+
/**
|
|
52
|
+
* Le contenu est du texte enrichi : il est rendu en HTML après nettoyage par le filtre posé avec `useHtmlSanitizer()`.
|
|
53
|
+
* Port partiel de `markdown()` : le HTML déjà présent est conservé (nettoyé), le texte brut devient des paragraphes ; la syntaxe Markdown n'est pas convertie.
|
|
54
|
+
*/
|
|
55
|
+
markdown(condition?: boolean): this;
|
|
56
|
+
private _copyable;
|
|
57
|
+
private _numeric;
|
|
58
|
+
/** Un clic copie la valeur dans le presse-papiers. */
|
|
59
|
+
copyable(condition?: boolean): this;
|
|
60
|
+
/** Nombre formaté (séparateurs de milliers), aligné comme un nombre. */
|
|
61
|
+
numeric(condition?: boolean): this;
|
|
62
|
+
toHtml(): Promise<string>;
|
|
63
|
+
}
|
|
64
|
+
/** Pastille de couleur (port de ColorEntry) : seules les couleurs hexadécimales ou fonctionnelles simples sont injectées dans le style. */
|
|
65
|
+
export declare class ColorEntry extends Entry {
|
|
66
|
+
private _placeholder;
|
|
67
|
+
static make(name: string): ColorEntry;
|
|
68
|
+
placeholder(text: string | null): this;
|
|
69
|
+
toHtml(): Promise<string>;
|
|
70
|
+
}
|
|
71
|
+
/** Image en lecture seule (port de ImageEntry) : seules une adresse du site (`/…`) ou une adresse http(s) sont affichées. */
|
|
72
|
+
export declare class ImageEntry extends Entry {
|
|
73
|
+
private _height;
|
|
74
|
+
private _alt;
|
|
75
|
+
private _circular;
|
|
76
|
+
static make(name: string): ImageEntry;
|
|
77
|
+
/** Hauteur CSS de l'image (`8rem` par défaut, comme Filament). */
|
|
78
|
+
imageHeight(height: string): this;
|
|
79
|
+
alt(text: string | null): this;
|
|
80
|
+
circular(condition?: boolean): this;
|
|
81
|
+
toHtml(): Promise<string>;
|
|
82
|
+
}
|
|
83
|
+
/** Icône selon la valeur ; `boolean()` affiche une coche verte ou une croix rouge (port partiel de IconEntry). */
|
|
84
|
+
export declare class IconEntry extends Entry {
|
|
85
|
+
private _boolean;
|
|
86
|
+
static make(name: string): IconEntry;
|
|
87
|
+
boolean(condition?: boolean): this;
|
|
88
|
+
toHtml(): Promise<string>;
|
|
89
|
+
}
|
|
90
|
+
/** Paires clé / valeur en table (port de KeyValueEntry) : l'état est un objet (ou sa forme JSON) ; une valeur non scalaire est montrée en JSON. */
|
|
91
|
+
export declare class KeyValueEntry extends Entry {
|
|
92
|
+
private _keyLabel;
|
|
93
|
+
private _valueLabel;
|
|
94
|
+
private _placeholder;
|
|
95
|
+
static make(name: string): KeyValueEntry;
|
|
96
|
+
keyLabel(label: string | null): this;
|
|
97
|
+
valueLabel(label: string | null): this;
|
|
98
|
+
/** Texte affiché sans aucune paire (« No entries » par défaut). */
|
|
99
|
+
placeholder(text: string | null): this;
|
|
100
|
+
/** @deprecated Utilisez `placeholder()`. */
|
|
101
|
+
emptyMessage(text: string | null): this;
|
|
102
|
+
toHtml(): Promise<string>;
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Code en lecture seule (port de CodeEntry). Même balisage `<pre class="phiki …">` que Filament, mais SANS coloration syntaxique :
|
|
106
|
+
* le surligneur Phiki n'est pas porté, chaque ligne est un seul jeton de texte échappé. Un tableau ou un objet est montré en JSON indenté.
|
|
107
|
+
*/
|
|
108
|
+
export declare class CodeEntry extends Entry {
|
|
109
|
+
private _grammar;
|
|
110
|
+
private _lightTheme;
|
|
111
|
+
private _darkTheme;
|
|
112
|
+
private _placeholder;
|
|
113
|
+
private _copyable;
|
|
114
|
+
private _copyMessage;
|
|
115
|
+
private _copyMessageDuration;
|
|
116
|
+
static make(name: string): CodeEntry;
|
|
117
|
+
/** Langage (`php`, `json`, `javascript`…) : nom donné à la classe `language-…` ; `html` par défaut, `json` pour un tableau. */
|
|
118
|
+
grammar(grammar: string | null): this;
|
|
119
|
+
/** Thèmes (noms de classes seulement : sans Phiki, les couleurs restent celles des thèmes par défaut). */
|
|
120
|
+
lightTheme(theme: string | null): this;
|
|
121
|
+
darkTheme(theme: string | null): this;
|
|
122
|
+
placeholder(text: string | null): this;
|
|
123
|
+
/** Un clic copie le code dans le presse-papiers. */
|
|
124
|
+
copyable(condition?: boolean): this;
|
|
125
|
+
copyMessage(message: string | null): this;
|
|
126
|
+
copyMessageDuration(milliseconds: number): this;
|
|
127
|
+
toHtml(): Promise<string>;
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Entrée au rendu libre (port de ViewEntry) : la fonction fournie reçoit l'état, l'enregistrement et le contexte, et rend le contenu
|
|
131
|
+
* placé dans l'enveloppe d'entrée. Ce HTML est inséré TEL QUEL : échappez toute valeur venue d'un utilisateur ou de la base avec `e()`.
|
|
132
|
+
*/
|
|
133
|
+
export declare class ViewEntry extends Entry {
|
|
134
|
+
private _view;
|
|
135
|
+
private _wrapped;
|
|
136
|
+
static make(name: string): ViewEntry;
|
|
137
|
+
/** Fonction de rendu du contenu (équivalent de la vue Blade de Filament). */
|
|
138
|
+
view(renderer: ((ctx: ComponentContext & {
|
|
139
|
+
state: unknown;
|
|
140
|
+
entry: ViewEntry;
|
|
141
|
+
}) => string | Promise<string>) | null): this;
|
|
142
|
+
/** `false` : la fonction rend toute l'entrée, sans l'enveloppe (libellé) habituelle. */
|
|
143
|
+
wrapped(condition?: boolean): this;
|
|
144
|
+
toHtml(): Promise<string>;
|
|
145
|
+
}
|
|
146
|
+
/** En-tête de colonne d'une entrée répétée en table (même forme que `TableColumn` de @navette-js/forms). */
|
|
147
|
+
export type RepeatableColumn = {
|
|
148
|
+
label: string;
|
|
149
|
+
getWidth(): string | null;
|
|
150
|
+
};
|
|
151
|
+
/**
|
|
152
|
+
* Liste d'éléments en lecture seule (port partiel de RepeatableEntry, en mode table) : une ligne par élément du tableau lu dans
|
|
153
|
+
* l'enregistrement, une cellule par entrée du schéma.
|
|
154
|
+
*/
|
|
155
|
+
export declare class RepeatableEntry extends Entry {
|
|
156
|
+
private _columns;
|
|
157
|
+
private _schema;
|
|
158
|
+
static make(name: string): RepeatableEntry;
|
|
159
|
+
table(columns: RepeatableColumn[]): this;
|
|
160
|
+
schema(entries: Entry[] | (() => Entry[])): this;
|
|
161
|
+
toHtml(): Promise<string>;
|
|
162
|
+
}
|
|
163
|
+
export {};
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
// @navette-js/infolists — port de filament/infolists : entrées en lecture seule (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
|
+
/** Libellé par défaut : pour `department.name`, c'est « Department » (comme Filament). */
|
|
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 des entrées : libellé, valeur lue dans l'enregistrement (chemin pointé accepté) ou calculée. */
|
|
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
|
+
/** Libellé gardé pour les lecteurs d'écran seulement. */
|
|
19
|
+
hiddenLabel(condition = true) { this._hiddenLabel = condition; return this; }
|
|
20
|
+
/** Valeur calculée ; sans cela, l'attribut du même nom de l'enregistrement est affiché. */
|
|
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` : attribut à plat `department_name` (colonne calculée par l'adaptateur), sinon chemin dans l'objet.
|
|
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
|
+
/** Balisage de Entry::wrapEmbeddedHtml(). */
|
|
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
|
+
/** Texte en lecture seule (port partiel de TextEntry) : pastille, couleur, icône, montant, date, texte de remplacement. */
|
|
40
|
+
let htmlSanitizer = null;
|
|
41
|
+
const TIGHT_ITEM = new RegExp('<li><p>((?:(?!</?p>|</?li>)[^])*)</p></li>', 'g');
|
|
42
|
+
/** Filtre appliqué au HTML qu'une entrée affiche tel quel (`markdown()`). Sans filtre, le contenu est échappé. */
|
|
43
|
+
export const useHtmlSanitizer = (sanitizer) => { htmlSanitizer = sanitizer; };
|
|
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
|
+
/** Mise en forme typographique du contenu (`fi-prose`). */
|
|
69
|
+
prose(condition = true) { this._prose = condition; return this; }
|
|
70
|
+
/**
|
|
71
|
+
* Le contenu est du texte enrichi : il est rendu en HTML après nettoyage par le filtre posé avec `useHtmlSanitizer()`.
|
|
72
|
+
* Port partiel de `markdown()` : le HTML déjà présent est conservé (nettoyé), le texte brut devient des paragraphes ; la syntaxe Markdown n'est pas convertie.
|
|
73
|
+
*/
|
|
74
|
+
markdown(condition = true) { this._markdown = condition; return this; }
|
|
75
|
+
_copyable = false;
|
|
76
|
+
_numeric = false;
|
|
77
|
+
/** Un clic copie la valeur dans le presse-papiers. */
|
|
78
|
+
copyable(condition = true) { this._copyable = condition; return this; }
|
|
79
|
+
/** Nombre formaté (séparateurs de milliers), aligné comme un nombre. */
|
|
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
|
+
// Valeur vide : conteneur nu (avec `fi-numeric` pour un montant), comme Filament.
|
|
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()` : « M j, Y H:i:s », le format par défaut de Filament.
|
|
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')` ou `time()` (« H:i:s ») : l'heure d'une valeur `H:i:s` ou `Y-m-d H:i:s`.
|
|
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
|
+
// Listes « serrées », comme le rendu Markdown de Filament : le paragraphe unique d'une puce est retiré.
|
|
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
|
+
// Plusieurs valeurs : une liste de pastilles (étiquettes, par exemple).
|
|
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
|
+
/** Pastille de couleur (port de ColorEntry) : seules les couleurs hexadécimales ou fonctionnelles simples sont injectées dans le style. */
|
|
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
|
+
/** Image en lecture seule (port de ImageEntry) : seules une adresse du site (`/…`) ou une adresse http(s) sont affichées. */
|
|
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
|
+
/** Hauteur CSS de l'image (`8rem` par défaut, comme Filament). */
|
|
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
|
+
/** Icône selon la valeur ; `boolean()` affiche une coche verte ou une croix rouge (port partiel de IconEntry). */
|
|
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
|
+
/** Paires clé / valeur en table (port de KeyValueEntry) : l'état est un objet (ou sa forme JSON) ; une valeur non scalaire est montrée en 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
|
+
/** Texte affiché sans aucune paire (« No entries » par défaut). */
|
|
195
|
+
placeholder(text) { this._placeholder = text; return this; }
|
|
196
|
+
/** @deprecated Utilisez `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
|
+
/** `json_encode($value, JSON_PRETTY_PRINT)` de PHP : quatre espaces, `/` et caractères non ASCII échappés. */
|
|
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
|
+
/** Couleurs de fond et de texte des thèmes par défaut de Phiki (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
|
+
* Code en lecture seule (port de CodeEntry). Même balisage `<pre class="phiki …">` que Filament, mais SANS coloration syntaxique :
|
|
222
|
+
* le surligneur Phiki n'est pas porté, chaque ligne est un seul jeton de texte échappé. Un tableau ou un objet est montré en JSON indenté.
|
|
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
|
+
/** Langage (`php`, `json`, `javascript`…) : nom donné à la classe `language-…` ; `html` par défaut, `json` pour un tableau. */
|
|
234
|
+
grammar(grammar) { this._grammar = grammar; return this; }
|
|
235
|
+
/** Thèmes (noms de classes seulement : sans Phiki, les couleurs restent celles des thèmes par défaut). */
|
|
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
|
+
/** Un clic copie le code dans le presse-papiers. */
|
|
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
|
+
// Phiki : une ligne par `<span class="line">`, le saut de ligne étant le dernier jeton de chaque ligne.
|
|
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
|
+
* Entrée au rendu libre (port de ViewEntry) : la fonction fournie reçoit l'état, l'enregistrement et le contexte, et rend le contenu
|
|
276
|
+
* placé dans l'enveloppe d'entrée. Ce HTML est inséré TEL QUEL : échappez toute valeur venue d'un utilisateur ou de la base avec `e()`.
|
|
277
|
+
*/
|
|
278
|
+
export class ViewEntry extends Entry {
|
|
279
|
+
_view = null;
|
|
280
|
+
_wrapped = true;
|
|
281
|
+
static make(name) { return configured(new ViewEntry(name)); }
|
|
282
|
+
/** Fonction de rendu du contenu (équivalent de la vue Blade de Filament). */
|
|
283
|
+
view(renderer) { this._view = renderer; return this; }
|
|
284
|
+
/** `false` : la fonction rend toute l'entrée, sans l'enveloppe (libellé) habituelle. */
|
|
285
|
+
wrapped(condition = true) { this._wrapped = condition; return this; }
|
|
286
|
+
async toHtml() {
|
|
287
|
+
if (!this._view)
|
|
288
|
+
throw new Error(`ViewEntry [${this._name}] : view() est requis.`);
|
|
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
|
+
* Liste d'éléments en lecture seule (port partiel de RepeatableEntry, en mode table) : une ligne par élément du tableau lu dans
|
|
295
|
+
* l'enregistrement, une cellule par entrée du schéma.
|
|
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,48 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@navette-js/infolists",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "1.0.0-beta.1",
|
|
4
|
+
"description": "Infolist entries for Navette, the Node.js port of Filament.",
|
|
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
|
+
"filament",
|
|
34
|
+
"admin",
|
|
35
|
+
"panel",
|
|
36
|
+
"livewire",
|
|
37
|
+
"express",
|
|
38
|
+
"node"
|
|
39
|
+
],
|
|
40
|
+
"dependencies": {
|
|
41
|
+
"@navette-js/support": "1.0.0-beta.1",
|
|
42
|
+
"@navette-js/schemas": "1.0.0-beta.1"
|
|
43
|
+
},
|
|
44
|
+
"publishConfig": {
|
|
45
|
+
"access": "public",
|
|
46
|
+
"tag": "beta"
|
|
47
|
+
}
|
|
48
|
+
}
|