kelt-ui-kit-react 2.1.1 → 2.1.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/src/App.menu.tsx CHANGED
@@ -6,6 +6,7 @@ import { AddressSearchView } from "./addressSearch/AddressSearch.view";
6
6
  import { BadgeView } from "./badge/Badge.view";
7
7
  import { ButtonView } from "./button/Button.view";
8
8
  import { ButtonFileView } from "./button/buttonFile/ButtonFile.view";
9
+ import { ButtonImportView } from "./button/buttonImport/ButtonImport.view";
9
10
  import { CardView } from "./card/Card.view";
10
11
  import { CarouselView } from "./carousel/Carousel.view";
11
12
  import { DamierView } from "./damier/Damier.view";
@@ -251,6 +252,13 @@ export const MenuList: MenuProps[] = [
251
252
  children: <NavLink to="/address-search">Address search</NavLink>,
252
253
  element: <AddressSearchView />,
253
254
  },
255
+ {
256
+ id: 31,
257
+ name: "Button Import",
258
+ link: "/button-import",
259
+ children: <NavLink to="/button-import">Button Import</NavLink>,
260
+ element: <ButtonImportView />,
261
+ },
254
262
  ];
255
263
 
256
264
  export const AppMenu = (): JSX.Element => {
@@ -31,6 +31,9 @@ export const ButtonFile = ({
31
31
 
32
32
  reader.readAsArrayBuffer(file);
33
33
  }
34
+
35
+ // Permet de resélectionner le même fichier (sinon onChange ne se déclenche pas)
36
+ e.target.value = "";
34
37
  };
35
38
 
36
39
  const handleButtonClick = () => {
@@ -43,7 +46,7 @@ export const ButtonFile = ({
43
46
  type="file"
44
47
  ref={fileInputRef}
45
48
  onChange={handleFileChange}
46
- className="hidden"
49
+ style={{ display: "none" }}
47
50
  accept={accept}
48
51
  />
49
52
  <Button
@@ -0,0 +1,99 @@
1
+ import { ChangeEvent, useRef, useState } from "react";
2
+ import { Icon } from "../../icon/Icon";
3
+ import { IconSizeEnum } from "../../icon/iconSize.enum";
4
+ import { Modal } from "../../modal/Modal";
5
+ import { Button } from "../Button";
6
+ import "./buttonImport.css";
7
+
8
+ interface ButtonImportProps {
9
+ onValidate: (file: File) => void | Promise<void>;
10
+ accept?: string;
11
+ buttonText?: string;
12
+ buttonClassName?: string;
13
+ buttonClassIcon?: string;
14
+ title?: string;
15
+ description?: string;
16
+ classIcon?: string;
17
+ validateText?: string;
18
+ }
19
+
20
+ export const ButtonImport = ({
21
+ onValidate,
22
+ accept = "*",
23
+ buttonText = "Importer",
24
+ buttonClassName,
25
+ buttonClassIcon = "",
26
+ title = "Importer un fichier",
27
+ description = "Sélectionner un fichier à importer.",
28
+ classIcon = "bi-file-earmark-arrow-up",
29
+ validateText = "Valider",
30
+ }: ButtonImportProps) => {
31
+ const fileInputRef = useRef<HTMLInputElement>(null);
32
+ const [isOpen, setIsOpen] = useState<boolean>(false);
33
+ const [file, setFile] = useState<File | null>(null);
34
+ const [loading, setLoading] = useState<boolean>(false);
35
+
36
+ const handleClose = () => {
37
+ if (loading) return;
38
+ setIsOpen(false);
39
+ setFile(null);
40
+ };
41
+
42
+ const handleFileChange = (e: ChangeEvent<HTMLInputElement>) => {
43
+ setFile(e.target.files?.[0] ?? null);
44
+ // Permet de resélectionner le même fichier
45
+ e.target.value = "";
46
+ };
47
+
48
+ const handleValidate = async () => {
49
+ if (!file) return;
50
+ setLoading(true);
51
+ try {
52
+ await onValidate(file);
53
+ setIsOpen(false);
54
+ setFile(null);
55
+ } catch {
56
+ // L'appelant gère l'erreur, la modal reste ouverte pour réessayer
57
+ } finally {
58
+ setLoading(false);
59
+ }
60
+ };
61
+
62
+ return (
63
+ <>
64
+ <Button
65
+ className={buttonClassName}
66
+ classIcon={buttonClassIcon}
67
+ title={buttonText}
68
+ onClick={() => setIsOpen(true)}
69
+ />
70
+ <Modal isOpen={isOpen} onClose={handleClose} title={title}>
71
+ <input
72
+ type="file"
73
+ ref={fileInputRef}
74
+ onChange={handleFileChange}
75
+ style={{ display: "none" }}
76
+ accept={accept}
77
+ />
78
+ <div
79
+ className="button-import-zone"
80
+ onClick={() => fileInputRef.current?.click()}
81
+ >
82
+ <div>{description}</div>
83
+ <Icon classIcon={classIcon} size={IconSizeEnum.LARGE} />
84
+ <div className="button-import-file mt-2">
85
+ {file ? file.name : "Aucun fichier sélectionné"}
86
+ </div>
87
+ </div>
88
+ <div className="d-flex justify-content-end mt-2">
89
+
90
+ <Button
91
+ title={validateText}
92
+ disabled={!file || loading}
93
+ onClick={handleValidate}
94
+ />
95
+ </div>
96
+ </Modal>
97
+ </>
98
+ );
99
+ };
@@ -0,0 +1,17 @@
1
+ import { ButtonImport } from "./ButtonImport";
2
+
3
+ export const ButtonImportView = () => {
4
+ return (
5
+ <div className="d-flex">
6
+ <ButtonImport
7
+ accept=".csv,text/csv"
8
+ buttonText="Importer CSV"
9
+ title="Import des stocks"
10
+ description="Sélectionner un fichier CSV afin de mettre à jour les stocks."
11
+ onValidate={(file: File) => {
12
+ console.log("Fichier validé:", file.name, file.size);
13
+ }}
14
+ />
15
+ </div>
16
+ );
17
+ };
@@ -0,0 +1,38 @@
1
+ .button-import-zone {
2
+ display: flex;
3
+ align-items: center;
4
+ justify-content: center;
5
+ width: 100%;
6
+ flex-direction: column;
7
+ text-align: center;
8
+ padding: 1rem 0;
9
+ cursor: pointer;
10
+
11
+ > div {
12
+ margin-bottom: 1rem;
13
+ color: #8f8888;
14
+ }
15
+
16
+ .bi {
17
+ border-radius: 50%;
18
+ border: 1px solid #d5d5d5;
19
+ padding: 2rem;
20
+ color: #d5d5d5;
21
+ width: 5rem;
22
+ height: 5rem;
23
+ display: flex;
24
+ align-items: center;
25
+ justify-content: center;
26
+ overflow: hidden;
27
+ }
28
+
29
+ &:hover .bi {
30
+ border-color: #8f8888;
31
+ color: #8f8888;
32
+ }
33
+
34
+ .button-import-file {
35
+ font-weight: 600;
36
+ word-break: break-all;
37
+ }
38
+ }
@@ -12,12 +12,13 @@ import {
12
12
  dataTableColumnsInterface,
13
13
  } from "./dataTable.interface";
14
14
  import { DataTableAction } from "./dataTableActions/DataTableAction";
15
+ import { DataTableActionMenu } from "./dataTableActions/DataTableActionMenu";
15
16
  import { DataTableColumn } from "./dataTableColumn/DataTableColumn";
16
17
  import { DataTableLoading } from "./dataTableLoading/DataTableLoading";
17
18
  import { DataTableRow } from "./dataTableRow/DataTableRow";
18
19
  import { DataTableFilters } from "./dataTableFilters/DataTableFilters";
19
20
  import { useDataTableState } from "./useDataTableState";
20
- import { DataTableFilterState, DataTableSortState, flattenColumns, getActionsWidth } from "./dataTable.utils";
21
+ import { DataTableFilterState, DataTableSortState, flattenColumns, getActionsWidth, isMenuAction } from "./dataTable.utils";
21
22
  export type {
22
23
  DataTableDateRangeFilterValue, DataTableFilterState, DataTableSortDirection,
23
24
  DataTableSortState, FilterValue,
@@ -92,6 +93,8 @@ export function DataTable<T>(props: DataTableProps<T>) {
92
93
  const tableState = useDataTableState(id, groupedColumns, sourceData, onFiltersChange, onSortChange);
93
94
  const { flatColumns: columns, sortedData: data } = tableState;
94
95
  const actionsColumnWidth = getActionsWidth(actions, explicitActionsWidth);
96
+ const inlineActions = useMemo(() => actions.filter((action) => !isMenuAction(action)), [actions]);
97
+ const menuActions = useMemo(() => actions.filter(isMenuAction), [actions]);
95
98
  const groupRowRef = useRef<HTMLTableRowElement>(null);
96
99
  const [groupHeight, setGroupHeight] = useState(0);
97
100
  const hasGroups = groupedColumns.some((column) => column.children?.length);
@@ -604,7 +607,7 @@ export function DataTable<T>(props: DataTableProps<T>) {
604
607
  ))}
605
608
  {actions.length > 0 && (
606
609
  <div className="data-table-mobile-card__actions card-actions">
607
- {actions.map((action, actionIndex) => (
610
+ {inlineActions.map((action, actionIndex) => (
608
611
  <DataTableAction
609
612
  key={action.id}
610
613
  action={action}
@@ -614,6 +617,12 @@ export function DataTable<T>(props: DataTableProps<T>) {
614
617
  onFocusNextRow={focusNextRow}
615
618
  />
616
619
  ))}
620
+ <DataTableActionMenu
621
+ actions={menuActions}
622
+ row={row}
623
+ rowIndex={rowIndex}
624
+ onFocusNextRow={focusNextRow}
625
+ />
617
626
  </div>
618
627
  )}
619
628
  </div>
@@ -766,7 +775,7 @@ export function DataTable<T>(props: DataTableProps<T>) {
766
775
  onClick={(e) => e.stopPropagation()}
767
776
  >
768
777
  <div className="d-flex align-items-center justify-content-end">
769
- {actions.map((action, index) => (
778
+ {inlineActions.map((action, index) => (
770
779
  <DataTableAction
771
780
  key={index}
772
781
  action={action}
@@ -776,6 +785,12 @@ export function DataTable<T>(props: DataTableProps<T>) {
776
785
  onFocusNextRow={focusNextRow}
777
786
  />
778
787
  ))}
788
+ <DataTableActionMenu
789
+ actions={menuActions}
790
+ row={row}
791
+ rowIndex={rowIndex}
792
+ onFocusNextRow={focusNextRow}
793
+ />
779
794
  </div>
780
795
  </td>
781
796
  )}
@@ -64,10 +64,23 @@ export const DataTableView = () => {
64
64
  console.log("Edit action clicked for row:", row);
65
65
  },
66
66
  },
67
+ {
68
+ id: "duplicate",
69
+ label: "Dupliquer",
70
+ type: "button",
71
+ classIcon: "bi-copy",
72
+ display: "menu",
73
+ onClick: (row: any) => {
74
+ console.log("Duplicate action clicked for row:", row);
75
+ },
76
+ },
67
77
  {
68
78
  id: "delete",
69
79
  label: "Delete",
70
80
  type: "button",
81
+ classIcon: "bi-trash",
82
+ display: "menu",
83
+ shouldDisplay: (row) => row.id !== 1,
71
84
  onClick: (row: any) => {
72
85
  console.log("Delete action clicked for row:", row);
73
86
  },
@@ -134,7 +134,12 @@
134
134
  background-color: #fff;
135
135
  color: var(--tertiary-color);
136
136
  min-width: 100px;
137
- padding: .5rem;
137
+ padding: .5rem 0;
138
+
139
+ &:first-child,
140
+ &:last-child {
141
+ padding: .5rem;
142
+ }
138
143
 
139
144
  .data-table-column-filter,
140
145
  .data-table-column-sort {
@@ -152,6 +157,7 @@
152
157
 
153
158
  &:hover,
154
159
  &:focus-within {
160
+
155
161
  .data-table-column-filter,
156
162
  .data-table-column-sort {
157
163
  visibility: visible;
@@ -302,20 +308,50 @@
302
308
  }
303
309
  }
304
310
  }
311
+
305
312
  .data-table-action-icon {
306
313
  cursor: pointer;
307
314
  display: flex;
308
315
  align-items: center;
309
316
  }
317
+
318
+ .data-table-action-menu-toggle {
319
+ cursor: pointer;
320
+ display: flex;
321
+ align-items: center;
322
+ justify-content: center;
323
+ width: 32px;
324
+ height: 32px;
325
+ border-radius: 4px;
326
+
327
+ &:hover,
328
+ &[aria-expanded="true"] {
329
+ background: rgba(0, 0, 0, 0.06);
330
+ }
331
+ }
332
+
333
+ .data-table-action-menu {
334
+ min-width: 150px;
335
+ max-width: 300px;
336
+ padding: 0.25rem;
337
+
338
+ button {
339
+ font-size: 0.85rem;
340
+ width: 100%;
341
+ padding: 1rem 0rem !important;
342
+ }
343
+ }
344
+
310
345
  .data-table-column-heading {
311
346
  display: flex;
312
347
  align-items: center;
313
348
  gap: 4px;
314
349
 
315
- > .flex-1 {
350
+ >.flex-1 {
316
351
  flex: 1;
317
352
  }
318
353
  }
354
+
319
355
  .data-table-filter-popover {
320
356
  z-index: 1200;
321
357
  padding: 8px;
@@ -325,18 +361,21 @@
325
361
  background: #fff;
326
362
  box-shadow: 0 6px 18px rgba(20, 40, 50, 0.16);
327
363
  }
364
+
328
365
  .data-table-group-row th {
329
366
  height: 40px;
330
367
  box-sizing: border-box;
331
368
  }
369
+
332
370
  .data-table-action-link {
333
371
  min-width: 0;
334
372
  background: transparent;
335
373
  color: var(--primary-color);
336
374
  text-decoration: underline;
337
375
  }
376
+
338
377
  .data-table-mobile-controls {
339
378
  display: grid;
340
379
  gap: 12px;
341
380
  margin-bottom: 16px;
342
- }
381
+ }
@@ -63,4 +63,6 @@ export interface dataTableActionsInterface<T> {
63
63
  width?: string | number;
64
64
  minWidth?: string | number;
65
65
  focusNextRow?: boolean;
66
+ /** "inline" (défaut) : affichée dans la ligne ; "menu" : regroupée dans le menu « ⋮ » en fin de ligne */
67
+ display?: "inline" | "menu";
66
68
  }
@@ -19,10 +19,23 @@ export function getFilterKey<T>(column: dataTableColumnsInterface<T>) {
19
19
  return column.name ?? column.id;
20
20
  }
21
21
 
22
+ export const ACTION_MENU_WIDTH = 40;
23
+
24
+ export function isMenuAction<T>(action: dataTableActionsInterface<T>) {
25
+ return action.display === "menu";
26
+ }
27
+
28
+ export function isActionDisplayed<T>(action: dataTableActionsInterface<T>, row: T) {
29
+ return action.visible !== false && (action.shouldDisplay ? action.shouldDisplay(row) : true);
30
+ }
31
+
22
32
  export function getActionsWidth<T>(actions: dataTableActionsInterface<T>[], explicitWidth?: number) {
23
33
  if (explicitWidth !== undefined) return explicitWidth;
24
- if (!actions.some((action) => typeof action.width === "number")) return 200;
25
- return actions.reduce((total, action) => total + (typeof action.width === "number" ? action.width : 0), 0);
34
+ const inlineActions = actions.filter((action) => !isMenuAction(action));
35
+ const menuWidth = inlineActions.length < actions.length ? ACTION_MENU_WIDTH : 0;
36
+ if (inlineActions.length === 0 && menuWidth > 0) return menuWidth;
37
+ if (!inlineActions.some((action) => typeof action.width === "number")) return 200;
38
+ return inlineActions.reduce((total, action) => total + (typeof action.width === "number" ? action.width : 0), menuWidth);
26
39
  }
27
40
 
28
41
  export function sortRows<T>(data: T[], columns: dataTableColumnsInterface<T>[], sort: DataTableSortState | null): T[] {
@@ -1,6 +1,7 @@
1
1
  import { Button } from "../../button/Button";
2
2
  import { Icon } from "../../icon/Icon";
3
3
  import { dataTableActionsInterface } from "../dataTable.interface";
4
+ import { isActionDisplayed } from "../dataTable.utils";
4
5
  type DataTableActionProps = {
5
6
  action: dataTableActionsInterface<any>;
6
7
  row: any;
@@ -16,7 +17,7 @@ export const DataTableAction = ({
16
17
  onFocusNextRow,
17
18
  }: DataTableActionProps) => {
18
19
  let title = action.label;
19
- const display = action.visible !== false && (action.shouldDisplay ? action.shouldDisplay(row) : true);
20
+ const display = isActionDisplayed(action, row);
20
21
  if (!display) return null;
21
22
  if (action.labelButton) {
22
23
  title = action.labelButton ? action.labelButton(row) : action.label;
@@ -0,0 +1,95 @@
1
+ import { useMemo, useRef, useState } from "react";
2
+ import { Button } from "../../button/Button";
3
+ import { Icon } from "../../icon/Icon";
4
+ import { IconSizeEnum } from "../../icon/iconSize.enum";
5
+ import { MenuInterface } from "../../menus/menu/menu.interface";
6
+ import { Menus } from "../../menus/Menus";
7
+ import { OverlayPanel } from "../../overlayPanel/OverlayPanel";
8
+ import { dataTableActionsInterface } from "../dataTable.interface";
9
+ import { isActionDisplayed } from "../dataTable.utils";
10
+
11
+ type DataTableActionMenuProps = {
12
+ actions: dataTableActionsInterface<any>[];
13
+ row: any;
14
+ rowIndex?: number;
15
+ onFocusNextRow?: (rowIndex: number) => void;
16
+ };
17
+
18
+ export const DataTableActionMenu = ({
19
+ actions,
20
+ row,
21
+ rowIndex,
22
+ onFocusNextRow,
23
+ }: DataTableActionMenuProps) => {
24
+ const refButton = useRef<HTMLDivElement>(null);
25
+ const [open, setOpen] = useState(false);
26
+ const visibleActions = actions.filter((action) => isActionDisplayed(action, row));
27
+
28
+ const items: MenuInterface[] = useMemo(
29
+ () =>
30
+ visibleActions.map((action, index) => ({
31
+ id: index,
32
+ icon: action.classIcon,
33
+ name: action.label,
34
+ disabled: action.disabled,
35
+ children: (
36
+ <Button
37
+ key={action.id}
38
+ classIcon={action.classIcon}
39
+ sizeIcon={IconSizeEnum.LARGE}
40
+ disabled={action.disabled}
41
+ title={action.labelButton ? action.labelButton(row) : action.label}
42
+ onClick={(e) => {
43
+ e.stopPropagation();
44
+ if (action.disabled) return;
45
+ setOpen(false);
46
+ action.onClick(row, rowIndex);
47
+ if (action.focusNextRow && rowIndex !== undefined) {
48
+ onFocusNextRow?.(rowIndex);
49
+ }
50
+ }}
51
+ />
52
+ ),
53
+ })),
54
+ [visibleActions, row, rowIndex, onFocusNextRow],
55
+ );
56
+
57
+ if (visibleActions.length === 0) return null;
58
+
59
+ return (
60
+ <>
61
+ <div
62
+ ref={refButton}
63
+ className="data-table-action-menu-toggle"
64
+ title="Actions"
65
+ role="button"
66
+ tabIndex={0}
67
+ aria-haspopup="menu"
68
+ aria-expanded={open}
69
+ onClick={(e) => {
70
+ e.stopPropagation();
71
+ setOpen(!open);
72
+ }}
73
+ onKeyDown={(e) => {
74
+ if (e.key === "Enter" || e.key === " ") {
75
+ e.preventDefault();
76
+ e.stopPropagation();
77
+ setOpen(!open);
78
+ }
79
+ }}
80
+ >
81
+ <Icon size={IconSizeEnum.MEDIUM} classIcon="bi-three-dots-vertical" />
82
+ </div>
83
+ {open && refButton.current && (
84
+ <OverlayPanel
85
+ show={open}
86
+ position="bottomRight"
87
+ referenceElement={refButton.current}
88
+ closeOverlay={() => setOpen(false)}
89
+ >
90
+ <Menus className="data-table-action-menu" items={items} />
91
+ </OverlayPanel>
92
+ )}
93
+ </>
94
+ );
95
+ };
@@ -5,6 +5,7 @@ export const catalog: Record<string, { category: Category; description: string;
5
5
  "/buttons": { category: "Actions", icon: "cursor", description: "Des actions claires, avec icones, badges et menus contextuels." },
6
6
  "/action": { category: "Actions", icon: "lightning", description: "Une action compacte et ses interactions." },
7
7
  "/button-file": { category: "Actions", icon: "upload", description: "Selectionner un fichier depuis un bouton dedie." },
8
+ "/button-import": { category: "Actions", icon: "file-earmark-arrow-up", description: "Ouvrir une modal pour choisir un fichier puis valider l'import." },
8
9
  "/form": { category: "Formulaires", icon: "ui-checks", description: "Composer des formulaires dynamiques, sections et champs responsives." },
9
10
  "/address-search": { category: "Formulaires", icon: "geo-alt", description: "Rechercher une adresse et exploiter ses donnees geographiques." },
10
11
  "/select": { category: "Formulaires", icon: "chevron-expand", description: "Choisir une valeur parmi des options." },
package/src/index.ts CHANGED
@@ -26,6 +26,7 @@ export { Badge } from "./badge/Badge";
26
26
  export { Button } from "./button/Button";
27
27
  export { ButtonActions } from "./button/buttonActions/ButtonActions";
28
28
  export { ButtonFile } from "./button/buttonFile/ButtonFile";
29
+ export { ButtonImport } from "./button/buttonImport/ButtonImport";
29
30
  export { ButtonFilters } from "./button/buttonFilters/ButtonFilters";
30
31
  export { Card } from "./card/Card";
31
32
  export { Carousel } from "./carousel/Carousel";
package/src/tabs/tabs.css CHANGED
@@ -27,4 +27,10 @@
27
27
  }
28
28
  }
29
29
  }
30
- }
30
+ }
31
+
32
+ /* Un panel inactif (attribut hidden) ne doit jamais occuper de place,
33
+ même si un panelClassName lui donne un display (flex, grid...) */
34
+ .tabs-root [role="tabpanel"][hidden] {
35
+ display: none !important;
36
+ }
@@ -50,6 +50,15 @@ test("action widths preserve Hub sums and Kelt explicit column widths", () => {
50
50
  assert.equal(utils.getActionsWidth([]), 200);
51
51
  });
52
52
 
53
+ test("menu actions are grouped behind a single three-dots toggle", () => {
54
+ assert.equal(utils.getActionsWidth([{ width: 48 }, { width: 100, display: "menu" }]), 48 + utils.ACTION_MENU_WIDTH);
55
+ assert.equal(utils.getActionsWidth([{ display: "menu" }, { display: "menu" }]), utils.ACTION_MENU_WIDTH);
56
+ assert.equal(utils.getActionsWidth([{ type: "button" }, { display: "menu" }]), 200);
57
+ assert.equal(utils.isActionDisplayed({ visible: false }, {}), false);
58
+ assert.equal(utils.isActionDisplayed({ shouldDisplay: (row) => row.id === 2 }, { id: 1 }), false);
59
+ assert.equal(utils.isActionDisplayed({}, { id: 1 }), true);
60
+ });
61
+
53
62
  test("sorting is stable, immutable, and supports Hub names and dates", () => {
54
63
  const rows = [{ name: "Dossier 10", date: "2026-10-12" }, { name: "Dossier 2", date: "2026-01-02" }];
55
64
  const columns = [{ id: "name", name: "dossier", type: "text" }, { id: "date", type: "date" }];
@@ -27,7 +27,7 @@ test("every registered demo has generated API documentation and its exact source
27
27
  ts.forEachChild(node, visit);
28
28
  }
29
29
  visit(source);
30
- assert.equal(routes.length, 30);
30
+ assert.equal(routes.length, 31);
31
31
  assert.deepEqual(Object.keys(docs).sort(), routes.sort());
32
32
  for (const route of routes) {
33
33
  assert.ok(docs[route].components.length > 0, route);