kelt-ui-kit-react 2.1.8 → 2.1.9

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 (47) hide show
  1. package/dist/damier/damierCell/damierCell.interface.d.ts +3 -0
  2. package/dist/damier/damierEditor/DamierEditor.d.ts +33 -0
  3. package/dist/damier/damierEditor/damierEditorCell/DamierEditorCell.d.ts +16 -0
  4. package/dist/damier/damierEditor/damierEditorToolbar/DamierEditorToolbar.d.ts +10 -0
  5. package/dist/damier/damierEditor/damierEditorTree/DamierEditorTree.d.ts +13 -0
  6. package/dist/damier/damierEditor/damierEditorTree/damierEditorTreeNode/DamierEditorTreeNode.d.ts +11 -0
  7. package/dist/damier/damierEditor/damierEditorTree/damierTreeNode.interface.d.ts +5 -0
  8. package/dist/damier/damierEditor/maybePromise.interface.d.ts +1 -0
  9. package/dist/damier/damierPage/damierPage.interface.d.ts +8 -0
  10. package/dist/damier/utils/buildDamierTree.d.ts +3 -0
  11. package/dist/damier/utils/createDefaultDamierPage.d.ts +2 -0
  12. package/dist/damier/utils/damierDefaultSize.d.ts +1 -0
  13. package/dist/damier/utils/findDamierTreePath.d.ts +2 -0
  14. package/dist/damier/utils/getFirstFreePosition.d.ts +2 -0
  15. package/dist/damier/utils/moveDamierCell.d.ts +2 -0
  16. package/dist/form/form.interface.d.ts +2 -0
  17. package/dist/form/getFieldError.d.ts +2 -0
  18. package/dist/index.js +881 -851
  19. package/dist/style.css +1 -1
  20. package/package.json +1 -1
  21. package/src/damier/Damier.tsx +1 -1
  22. package/src/damier/Damier.view.tsx +46 -0
  23. package/src/damier/damierCell/DamierCell.tsx +1 -1
  24. package/src/damier/damierCell/damierCell.interface.tsx +3 -0
  25. package/src/damier/damierEditor/DamierEditor.tsx +346 -0
  26. package/src/damier/damierEditor/damierEditor.css +255 -0
  27. package/src/damier/damierEditor/damierEditorCell/DamierEditorCell.tsx +91 -0
  28. package/src/damier/damierEditor/damierEditorToolbar/DamierEditorToolbar.tsx +71 -0
  29. package/src/damier/damierEditor/damierEditorTree/DamierEditorTree.tsx +134 -0
  30. package/src/damier/damierEditor/damierEditorTree/damierEditorTreeNode/DamierEditorTreeNode.tsx +78 -0
  31. package/src/damier/damierEditor/damierEditorTree/damierTreeNode.interface.ts +6 -0
  32. package/src/damier/damierEditor/maybePromise.interface.ts +1 -0
  33. package/src/damier/damierPage/damierPage.interface.tsx +9 -0
  34. package/src/damier/utils/buildDamierTree.ts +31 -0
  35. package/src/damier/utils/createDefaultDamierPage.ts +10 -0
  36. package/src/damier/utils/damierDefaultSize.ts +1 -0
  37. package/src/damier/utils/findDamierTreePath.ts +14 -0
  38. package/src/damier/utils/getFirstFreePosition.ts +12 -0
  39. package/src/damier/utils/moveDamierCell.ts +13 -0
  40. package/src/dataTable/DataTable.tsx +2 -2
  41. package/src/docs/catalog.ts +1 -1
  42. package/src/form/Form.tsx +68 -8
  43. package/src/form/form.css +9 -0
  44. package/src/form/form.interface.tsx +2 -0
  45. package/src/form/getFieldError.ts +14 -0
  46. package/src/select/select.css +11 -1
  47. package/tsconfig.tsbuildinfo +1 -1
@@ -0,0 +1,91 @@
1
+ import { DragEvent } from "react";
2
+ import { Icon } from "../../../icon/Icon";
3
+ import { DamierCellInterface } from "../../damierCell/damierCell.interface";
4
+
5
+ interface DamierEditorCellProps {
6
+ position: number;
7
+ cell?: DamierCellInterface;
8
+ dragging?: boolean;
9
+ dragOver?: boolean;
10
+ disabled?: boolean;
11
+ onDragStart: (position: number) => void;
12
+ onDragEnd: () => void;
13
+ onDragOver: (position: number) => void;
14
+ onDrop: (from: number, to: number) => void;
15
+ onClick: (position: number, cell?: DamierCellInterface) => void;
16
+ onDelete?: (cell: DamierCellInterface) => void;
17
+ }
18
+
19
+ // Emplacement du damier en édition : cible de dépôt, et source de glisser si une case l'occupe
20
+ export const DamierEditorCell = ({
21
+ position,
22
+ cell,
23
+ dragging = false,
24
+ dragOver = false,
25
+ disabled = false,
26
+ onDragStart,
27
+ onDragEnd,
28
+ onDragOver,
29
+ onDrop,
30
+ onClick,
31
+ onDelete,
32
+ }: DamierEditorCellProps) => {
33
+ const handleDragStart = (e: DragEvent<HTMLDivElement>) => {
34
+ e.dataTransfer.setData("text/plain", String(position));
35
+ e.dataTransfer.effectAllowed = "move";
36
+ onDragStart(position);
37
+ };
38
+ const handleDragOver = (e: DragEvent<HTMLDivElement>) => {
39
+ if (disabled) return;
40
+ e.preventDefault();
41
+ e.dataTransfer.dropEffect = "move";
42
+ onDragOver(position);
43
+ };
44
+ const handleDrop = (e: DragEvent<HTMLDivElement>) => {
45
+ e.preventDefault();
46
+ const from = Number(e.dataTransfer.getData("text/plain"));
47
+ if (from) onDrop(from, position);
48
+ };
49
+ const classNames = [
50
+ "damier-editor-cell",
51
+ cell ? "" : "damier-editor-cell--empty",
52
+ dragging ? "damier-editor-cell--dragging" : "",
53
+ dragOver ? "damier-editor-cell--over" : "",
54
+ ].join(" ");
55
+
56
+ return (
57
+ <div
58
+ className={classNames}
59
+ draggable={!!cell && !disabled}
60
+ title={cell ? cell.label : "Ajouter une case"}
61
+ onDragStart={handleDragStart}
62
+ onDragEnd={onDragEnd}
63
+ onDragOver={handleDragOver}
64
+ onDrop={handleDrop}
65
+ onClick={() => !disabled && onClick(position, cell)}
66
+ >
67
+ {cell ? (
68
+ <>
69
+ <div className="damier-editor-cell-content">
70
+ {cell.child ?? <span>{cell.label}</span>}
71
+ </div>
72
+ {onDelete && !disabled && (
73
+ <button
74
+ type="button"
75
+ className="damier-editor-cell-delete"
76
+ aria-label="Supprimer la case"
77
+ onClick={(e) => {
78
+ e.stopPropagation();
79
+ onDelete(cell);
80
+ }}
81
+ >
82
+ <Icon classIcon="bi-x-lg" />
83
+ </button>
84
+ )}
85
+ </>
86
+ ) : (
87
+ <Icon classIcon="bi-plus-lg damier-editor-cell-add" />
88
+ )}
89
+ </div>
90
+ );
91
+ };
@@ -0,0 +1,71 @@
1
+ import { useEffect, useState } from "react";
2
+ import { Button } from "../../../button/Button";
3
+ import { TypeInputEnum } from "../../../form/form.enum";
4
+ import { Input } from "../../../form/input/Input";
5
+ import { DamierPageInterface } from "../../damierPage/damierPage.interface";
6
+
7
+ interface DamierEditorToolbarProps {
8
+ page: DamierPageInterface;
9
+ disabled?: boolean;
10
+ canAddCell: boolean;
11
+ onChangeDimension: (cols: number, rows: number) => void;
12
+ onAddCell: () => void;
13
+ }
14
+
15
+ // Dimension de la page courante (colonnes x lignes) et ajout rapide d'une case
16
+ export const DamierEditorToolbar = ({
17
+ page,
18
+ disabled = false,
19
+ canAddCell,
20
+ onChangeDimension,
21
+ onAddCell,
22
+ }: DamierEditorToolbarProps) => {
23
+ const [cols, setCols] = useState(page.cols);
24
+ const [rows, setRows] = useState(page.rows);
25
+ useEffect(() => {
26
+ setCols(page.cols);
27
+ setRows(page.rows);
28
+ }, [page.id, page.cols, page.rows]);
29
+ const changed = cols !== page.cols || rows !== page.rows;
30
+
31
+ return (
32
+ <div className="damier-editor-toolbar">
33
+ <label className="damier-editor-dimension">
34
+ Colonnes
35
+ <Input
36
+ id="damier-editor-cols"
37
+ name="damier-editor-cols"
38
+ type={TypeInputEnum.NUMBER}
39
+ value={cols}
40
+ disabled={disabled}
41
+ disabledDebounce
42
+ onChange={(e) => setCols(Number(e.target.value))}
43
+ />
44
+ </label>
45
+ <label className="damier-editor-dimension">
46
+ Lignes
47
+ <Input
48
+ id="damier-editor-rows"
49
+ name="damier-editor-rows"
50
+ type={TypeInputEnum.NUMBER}
51
+ value={rows}
52
+ disabled={disabled}
53
+ disabledDebounce
54
+ onChange={(e) => setRows(Number(e.target.value))}
55
+ />
56
+ </label>
57
+ <Button
58
+ title="Appliquer la dimension"
59
+ classIcon="bi-aspect-ratio"
60
+ disabled={disabled || !changed}
61
+ onClick={() => onChangeDimension(cols, rows)}
62
+ />
63
+ <Button
64
+ title="Ajouter une case"
65
+ classIcon="bi-plus-square"
66
+ disabled={disabled || !canAddCell}
67
+ onClick={onAddCell}
68
+ />
69
+ </div>
70
+ );
71
+ };
@@ -0,0 +1,134 @@
1
+ import { useMemo, useState } from "react";
2
+ import { Button } from "../../../button/Button";
3
+ import { Input } from "../../../form/input/Input";
4
+ import { DamierPageInterface } from "../../damierPage/damierPage.interface";
5
+ import { buildDamierTree } from "../../utils/buildDamierTree";
6
+ import { findDamierTreePath } from "../../utils/findDamierTreePath";
7
+ import { DamierEditorTreeNode } from "./damierEditorTreeNode/DamierEditorTreeNode";
8
+
9
+ interface DamierEditorTreeProps {
10
+ pages: DamierPageInterface[];
11
+ currentPageId: number;
12
+ rootPageId?: number;
13
+ disabled?: boolean;
14
+ onSelect: (page: DamierPageInterface) => void;
15
+ /** Sans callback, le champ d'ajout de page est masqué */
16
+ onAdd?: (title: string) => void;
17
+ onDelete?: (page: DamierPageInterface) => void;
18
+ }
19
+
20
+ // Arborescence des pages (à gauche du damier) + recherche, ajout et suppression de page
21
+ export const DamierEditorTree = ({
22
+ pages,
23
+ currentPageId,
24
+ rootPageId,
25
+ disabled = false,
26
+ onSelect,
27
+ onAdd,
28
+ onDelete,
29
+ }: DamierEditorTreeProps) => {
30
+ const [title, setTitle] = useState("");
31
+ const [search, setSearch] = useState("");
32
+ const tree = useMemo(
33
+ () => buildDamierTree(pages, rootPageId),
34
+ [pages, rootPageId],
35
+ );
36
+ const activePath = useMemo(
37
+ () => findDamierTreePath(tree, currentPageId),
38
+ [tree, currentPageId],
39
+ );
40
+ const term = search.trim().toLocaleLowerCase();
41
+ const results = term
42
+ ? pages.filter((page) => page.title.toLocaleLowerCase().includes(term))
43
+ : [];
44
+ const currentPage = pages.find((page) => page.id === currentPageId);
45
+ const add = () => {
46
+ if (!title.trim() || !onAdd) return;
47
+ onAdd(title.trim());
48
+ setTitle("");
49
+ };
50
+
51
+ return (
52
+ <nav className="damier-tree" aria-label="Pages du damier">
53
+ <div className="damier-tree-actions">
54
+ {onAdd && (
55
+ <>
56
+ <Input
57
+ id="damier-editor-page-title"
58
+ name="damier-editor-page-title"
59
+ placeholder="Nouvelle page"
60
+ value={title}
61
+ disabled={disabled}
62
+ disabledDebounce
63
+ onChange={(e) => setTitle(e.target.value)}
64
+ handleKeyDown={(e) => e.key === "Enter" && add()}
65
+ />
66
+ <Button
67
+ title=""
68
+ classIcon="bi-plus-lg"
69
+ disabled={disabled || !title.trim()}
70
+ onClick={add}
71
+ />
72
+ </>
73
+ )}
74
+ {onDelete && currentPage && pages.length > 1 && (
75
+ <Button
76
+ title=""
77
+ classIcon="bi-trash"
78
+ className="damier-editor-danger"
79
+ disabled={disabled}
80
+ onClick={() => onDelete(currentPage)}
81
+ />
82
+ )}
83
+ </div>
84
+ <Input
85
+ id="damier-editor-page-search"
86
+ name="damier-editor-page-search"
87
+ placeholder="Rechercher une page"
88
+ value={search}
89
+ disabledDebounce
90
+ showClearButton
91
+ onChange={(e) => setSearch(e.target.value)}
92
+ />
93
+ <div className="damier-tree-list">
94
+ {term ? (
95
+ <ul>
96
+ {results.map((page) => (
97
+ <li key={page.id}>
98
+ <div
99
+ className={`damier-tree-item ${
100
+ page.id === currentPageId ? "damier-tree-item--active" : ""
101
+ }`}
102
+ >
103
+ <button
104
+ type="button"
105
+ className="damier-tree-label"
106
+ title={page.title}
107
+ onClick={() => onSelect(page)}
108
+ >
109
+ {page.title}
110
+ </button>
111
+ </div>
112
+ </li>
113
+ ))}
114
+ {!results.length && (
115
+ <li className="damier-tree-empty">Aucune page</li>
116
+ )}
117
+ </ul>
118
+ ) : (
119
+ <ul role="tree">
120
+ {tree.map((node) => (
121
+ <DamierEditorTreeNode
122
+ key={node.page.id}
123
+ node={node}
124
+ currentPageId={currentPageId}
125
+ activePath={activePath}
126
+ onSelect={onSelect}
127
+ />
128
+ ))}
129
+ </ul>
130
+ )}
131
+ </div>
132
+ </nav>
133
+ );
134
+ };
@@ -0,0 +1,78 @@
1
+ import { useEffect, useState } from "react";
2
+ import { Icon } from "../../../../icon/Icon";
3
+ import { DamierPageInterface } from "../../../damierPage/damierPage.interface";
4
+ import { DamierTreeNodeInterface } from "../damierTreeNode.interface";
5
+
6
+ interface DamierEditorTreeNodeProps {
7
+ node: DamierTreeNodeInterface;
8
+ currentPageId: number;
9
+ activePath: number[];
10
+ depth?: number;
11
+ onSelect: (page: DamierPageInterface) => void;
12
+ }
13
+
14
+ export const DamierEditorTreeNode = ({
15
+ node,
16
+ currentPageId,
17
+ activePath,
18
+ depth = 0,
19
+ onSelect,
20
+ }: DamierEditorTreeNodeProps) => {
21
+ const inActivePath = activePath.includes(node.page.id);
22
+ const [expanded, setExpanded] = useState(depth === 0 || inActivePath);
23
+ // On déplie automatiquement le chemin vers la page sélectionnée
24
+ useEffect(() => {
25
+ if (inActivePath) setExpanded(true);
26
+ }, [inActivePath]);
27
+ const hasChildren = node.children.length > 0;
28
+ const active = node.page.id === currentPageId;
29
+
30
+ return (
31
+ <li
32
+ role="treeitem"
33
+ aria-expanded={hasChildren ? expanded : undefined}
34
+ aria-selected={active}
35
+ >
36
+ <div
37
+ className={`damier-tree-item ${active ? "damier-tree-item--active" : ""}`}
38
+ style={{ paddingLeft: `${depth * 0.9}rem` }}
39
+ >
40
+ <button
41
+ type="button"
42
+ className="damier-tree-toggle"
43
+ aria-label={expanded ? "Replier" : "Déplier"}
44
+ disabled={!hasChildren}
45
+ onClick={() => setExpanded(!expanded)}
46
+ >
47
+ {hasChildren && (
48
+ <Icon
49
+ classIcon={expanded ? "bi-chevron-down" : "bi-chevron-right"}
50
+ />
51
+ )}
52
+ </button>
53
+ <button
54
+ type="button"
55
+ className="damier-tree-label"
56
+ title={node.page.title}
57
+ onClick={() => onSelect(node.page)}
58
+ >
59
+ {node.page.title}
60
+ </button>
61
+ </div>
62
+ {hasChildren && expanded && (
63
+ <ul role="group">
64
+ {node.children.map((child) => (
65
+ <DamierEditorTreeNode
66
+ key={child.page.id}
67
+ node={child}
68
+ currentPageId={currentPageId}
69
+ activePath={activePath}
70
+ depth={depth + 1}
71
+ onSelect={onSelect}
72
+ />
73
+ ))}
74
+ </ul>
75
+ )}
76
+ </li>
77
+ );
78
+ };
@@ -0,0 +1,6 @@
1
+ import { DamierPageInterface } from "../../damierPage/damierPage.interface";
2
+
3
+ export interface DamierTreeNodeInterface {
4
+ page: DamierPageInterface;
5
+ children: DamierTreeNodeInterface[];
6
+ }
@@ -0,0 +1 @@
1
+ export type MaybePromise<T> = T | Promise<T>;
@@ -0,0 +1,9 @@
1
+ import { DamierCellInterface } from "../damierCell/damierCell.interface";
2
+
3
+ export interface DamierPageInterface {
4
+ id: number;
5
+ title: string;
6
+ cols: number;
7
+ rows: number;
8
+ cells: DamierCellInterface[];
9
+ }
@@ -0,0 +1,31 @@
1
+ import { DamierTreeNodeInterface } from "../damierEditor/damierEditorTree/damierTreeNode.interface";
2
+ import { DamierPageInterface } from "../damierPage/damierPage.interface";
3
+
4
+ // Arborescence des pages à partir des liens des cases (ordre des positions).
5
+ // Chaque page n'apparaît qu'une fois (premier chemin trouvé, pas de cycle) ;
6
+ // les pages non atteignables depuis la racine deviennent des racines à leur tour.
7
+ export const buildDamierTree = (
8
+ pages: DamierPageInterface[],
9
+ rootPageId?: number,
10
+ ): DamierTreeNodeInterface[] => {
11
+ const byId = new Map(pages.map((page) => [page.id, page]));
12
+ const visited = new Set<number>();
13
+ const build = (page: DamierPageInterface): DamierTreeNodeInterface => {
14
+ visited.add(page.id);
15
+ const children: DamierPageInterface[] = [];
16
+ [...page.cells]
17
+ .sort((a, b) => (a.position ?? 0) - (b.position ?? 0))
18
+ .forEach((cell) => {
19
+ const child = cell.linkPageId ? byId.get(cell.linkPageId) : undefined;
20
+ if (child && !visited.has(child.id)) {
21
+ visited.add(child.id);
22
+ children.push(child);
23
+ }
24
+ });
25
+ return { page, children: children.map(build) };
26
+ };
27
+ const root =
28
+ (rootPageId !== undefined ? byId.get(rootPageId) : undefined) ?? pages[0];
29
+ const ordered = root ? [root, ...pages.filter((p) => p !== root)] : pages;
30
+ return ordered.flatMap((page) => (visited.has(page.id) ? [] : [build(page)]));
31
+ };
@@ -0,0 +1,10 @@
1
+ import { DamierPageInterface } from "../damierPage/damierPage.interface";
2
+ import { DAMIER_DEFAULT_SIZE } from "./damierDefaultSize";
3
+
4
+ // Page initiale utilisée quand aucun damier n'est fourni au composant
5
+ export const createDefaultDamierPage = (
6
+ cols = DAMIER_DEFAULT_SIZE,
7
+ rows = DAMIER_DEFAULT_SIZE,
8
+ id = -1,
9
+ title = "Sommaire",
10
+ ): DamierPageInterface => ({ id, title, cols, rows, cells: [] });
@@ -0,0 +1 @@
1
+ export const DAMIER_DEFAULT_SIZE = 9;
@@ -0,0 +1,14 @@
1
+ import { DamierTreeNodeInterface } from "../damierEditor/damierEditorTree/damierTreeNode.interface";
2
+
3
+ // Ids des pages ancêtres (incluse) menant à `pageId`, vide si absente de l'arbre
4
+ export const findDamierTreePath = (
5
+ nodes: DamierTreeNodeInterface[],
6
+ pageId: number,
7
+ ): number[] => {
8
+ for (const node of nodes) {
9
+ if (node.page.id === pageId) return [node.page.id];
10
+ const path = findDamierTreePath(node.children, pageId);
11
+ if (path.length) return [node.page.id, ...path];
12
+ }
13
+ return [];
14
+ };
@@ -0,0 +1,12 @@
1
+ import { DamierPageInterface } from "../damierPage/damierPage.interface";
2
+
3
+ // Première position libre (base 1) de la page, undefined si la page est pleine
4
+ export const getFirstFreePosition = (
5
+ page: DamierPageInterface,
6
+ ): number | undefined => {
7
+ const taken = new Set(page.cells.map((cell) => cell.position));
8
+ for (let position = 1; position <= page.cols * page.rows; position++) {
9
+ if (!taken.has(position)) return position;
10
+ }
11
+ return undefined;
12
+ };
@@ -0,0 +1,13 @@
1
+ import { DamierCellInterface } from "../damierCell/damierCell.interface";
2
+
3
+ // Déplace la case de `from` vers `to` ; si `to` est occupée, les deux cases sont échangées.
4
+ export const moveDamierCell = (
5
+ cells: DamierCellInterface[],
6
+ from: number,
7
+ to: number,
8
+ ): DamierCellInterface[] =>
9
+ cells.map((cell) => {
10
+ if (cell.position === from) return { ...cell, position: to };
11
+ if (cell.position === to) return { ...cell, position: from };
12
+ return cell;
13
+ });
@@ -521,9 +521,9 @@ export function DataTable<T>(props: DataTableProps<T>) {
521
521
  {name && <h2 className="mb-2">{name}</h2>}
522
522
  {element}
523
523
  <DataTableFilters currentFilters={tableState.filters} setCurrentFilters={tableState.setFilters} />
524
- {showMobileCardView && columns.some((column) => column.filterable || column.sortable) && (
524
+ {showMobileCardView && columns.some((column) => column.filterable) && (
525
525
  <div className="data-table-mobile-controls">
526
- {columns.filter((column) => column.filterable || column.sortable).map((column, idx) => (
526
+ {columns.filter((column) => column.filterable).map((column, idx) => (
527
527
  <div key={column.id}>
528
528
  <table><thead><tr><DataTableColumn column={column} idx={idx}
529
529
  isFilterVisible={tableState.visibleFilters.has(column.name ?? column.id)}
@@ -23,7 +23,7 @@ export const catalog: Record<string, { category: Category; description: string;
23
23
  "/expands": { category: "Affichage", icon: "arrows-expand", description: "Deplier des sections et organiser des contenus denses." },
24
24
  "/grid": { category: "Affichage", icon: "grid-3x3", description: "Construire une mise en page avec des colonnes responsives." },
25
25
  "/card": { category: "Affichage", icon: "card-heading", description: "Regrouper contenu, informations et actions dans une carte." },
26
- "/damier": { category: "Affichage", icon: "grid", description: "Presenter des elements dans une grille de cellules." },
26
+ "/damier": { category: "Affichage", icon: "grid", description: "Presenter des elements dans une grille de cellules, et l'editer (pages, cases, glisser-deposer)." },
27
27
  "/carousel": { category: "Affichage", icon: "images", description: "Parcourir une collection de contenus." },
28
28
  "/badge": { category: "Affichage", icon: "patch-check", description: "Mettre en valeur un statut ou une information courte." },
29
29
  "/data-table": { category: "Affichage", icon: "table", description: "Afficher des donnees, selections, colonnes et actions." },
package/src/form/Form.tsx CHANGED
@@ -23,6 +23,7 @@ import { Row } from "../grid/row/Row";
23
23
  import { GridBreakpoints, GridResponsive } from "../grid/grid.interface";
24
24
  import { TypeInputEnum } from "./form.enum";
25
25
  import { AddressSearch } from "../addressSearch/AddressSearch";
26
+ import { getFieldError } from "./getFieldError";
26
27
 
27
28
  const euroFormatter = new Intl.NumberFormat("fr-FR", {
28
29
  style: "currency",
@@ -107,8 +108,34 @@ export const DynamicForm = forwardRef(
107
108
  return initialValues;
108
109
  });
109
110
 
111
+ const [errors, setErrors] = useState<Record<string, string | undefined>>(
112
+ {}
113
+ );
114
+
110
115
  const inputRefs = useRef<{ [key: string]: HTMLInputElement | null }>({});
111
116
 
117
+ /** Revalide le champ pendant la saisie uniquement s'il est déjà en erreur */
118
+ const revalidateField = useCallback(
119
+ (field: FormInterface, value: unknown) => {
120
+ setErrors((prevErrors) =>
121
+ prevErrors[field.name]
122
+ ? { ...prevErrors, [field.name]: getFieldError(field, value) }
123
+ : prevErrors
124
+ );
125
+ },
126
+ []
127
+ );
128
+
129
+ const validateField = useCallback(
130
+ (field: FormInterface, value: unknown) => {
131
+ setErrors((prevErrors) => ({
132
+ ...prevErrors,
133
+ [field.name]: getFieldError(field, value),
134
+ }));
135
+ },
136
+ []
137
+ );
138
+
112
139
  /** Gestion des <input> */
113
140
  const handleChangeInput = useCallback(
114
141
  (
@@ -116,11 +143,15 @@ export const DynamicForm = forwardRef(
116
143
  forminterface: FormInterface
117
144
  ) => {
118
145
  const { name, type, checked, value } = event.target;
119
- const newValue = type === "checkbox" ? checked : value;
146
+ const newValue =
147
+ type === "checkbox"
148
+ ? checked
149
+ : (forminterface.formatOnChange?.(value) ?? value);
120
150
 
121
151
  if (forminterface.onChange) {
122
152
  forminterface.onChange(newValue, name);
123
153
  }
154
+ revalidateField(forminterface, newValue);
124
155
 
125
156
  setValues((prevValues) => {
126
157
  const updatedValues = { ...prevValues, [name]: newValue };
@@ -130,13 +161,14 @@ export const DynamicForm = forwardRef(
130
161
  return updatedValues;
131
162
  });
132
163
  },
133
- [onChange]
164
+ [onChange, revalidateField]
134
165
  );
135
166
  const handleChangeTextArea = useCallback(
136
167
  (name: string, value: string, forminterface: FormInterface) => {
137
168
  if (forminterface.onChange) {
138
169
  forminterface.onChange(value, name);
139
170
  }
171
+ revalidateField(forminterface, value);
140
172
 
141
173
  setValues((prevValues) => {
142
174
  const updatedValues = { ...prevValues, [name]: value };
@@ -146,7 +178,7 @@ export const DynamicForm = forwardRef(
146
178
  return updatedValues;
147
179
  });
148
180
  },
149
- [onChange]
181
+ [onChange, revalidateField]
150
182
  );
151
183
  /** Gestion des <select> */
152
184
  const handleChangeSelect = useCallback(
@@ -171,14 +203,31 @@ export const DynamicForm = forwardRef(
171
203
  const handleSubmit = useCallback(
172
204
  (e: React.FormEvent<HTMLFormElement>) => {
173
205
  e.preventDefault();
206
+ const submitErrors = formFields.reduce<
207
+ Record<string, string | undefined>
208
+ >((fieldErrors, field) => {
209
+ fieldErrors[field.name] = getFieldError(field, values[field.name]);
210
+ return fieldErrors;
211
+ }, {});
212
+ setErrors(submitErrors);
213
+ if (Object.values(submitErrors).some(Boolean)) {
214
+ const firstErrorField = formFields.find(
215
+ (field) => submitErrors[field.name]
216
+ );
217
+ if (firstErrorField) {
218
+ inputRefs.current[firstErrorField.name]?.focus();
219
+ }
220
+ return;
221
+ }
174
222
  if (onSubmit) {
175
223
  onSubmit(values);
176
224
  }
177
225
  },
178
- [onSubmit, values]
226
+ [onSubmit, values, formFields]
179
227
  );
180
228
 
181
229
  const resetForm = () => {
230
+ setErrors({});
182
231
  setValues(() => {
183
232
  const resetValues: FormValuesInterface<T> =
184
233
  {} as FormValuesInterface<T>;
@@ -320,7 +369,8 @@ export const DynamicForm = forwardRef(
320
369
  >
321
370
  <div
322
371
  className={`d-flex flex-column ${v.className ?? ""
323
- } form-group form-group-${v.type}`}
372
+ } form-group form-group-${v.type} ${errors[v.name] ? "form-group--error" : ""
373
+ }`}
324
374
  >
325
375
  {v.label && <label>{v.label}</label>}
326
376
  {v.icon && <Icon classIcon={v.icon} />}
@@ -351,12 +401,16 @@ export const DynamicForm = forwardRef(
351
401
  required={required}
352
402
  allowManualEntry
353
403
  manualEntryLabel={v.addressSearch?.manualEntryLabel}
354
- onChange={(value) =>
355
- updateValues({ [v.name]: value } as FormValuesInterface<T>)
356
- }
404
+ onChange={(value) => {
405
+ revalidateField(v, value);
406
+ updateValues({ [v.name]: value } as FormValuesInterface<T>);
407
+ }}
357
408
  onSelect={(address) => {
358
409
  const mappedValues =
359
410
  v.addressSearch?.mapAddress?.(address) ?? {};
411
+ if (v.name in mappedValues) {
412
+ revalidateField(v, mappedValues[v.name]);
413
+ }
360
414
  updateValues(mappedValues as FormValuesInterface<T>);
361
415
  v.addressSearch?.onSelect?.(address);
362
416
  }}
@@ -431,9 +485,15 @@ export const DynamicForm = forwardRef(
431
485
  onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
432
486
  handleChangeInput(e, v)
433
487
  }
488
+ onBlur={(e: React.FocusEvent<HTMLInputElement>) =>
489
+ validateField(v, e.target.value)
490
+ }
434
491
  onInvalid={v.onInvalid ? v.onInvalid : undefined}
435
492
  />
436
493
  )}
494
+ {mode !== "read" && errors[v.name] && (
495
+ <small className="form-error">{errors[v.name]}</small>
496
+ )}
437
497
  </div>
438
498
  </FormFieldContainer>
439
499
  );