@sio-group/ui-datatable 0.1.0

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 (45) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/README.md +429 -0
  3. package/dist/index.cjs +647 -0
  4. package/dist/index.d.cts +83 -0
  5. package/dist/index.d.ts +83 -0
  6. package/dist/index.js +620 -0
  7. package/dist/styles/index.css +154 -0
  8. package/dist/styles/index.css.map +1 -0
  9. package/package.json +44 -0
  10. package/src/assets/scss/index.scss +170 -0
  11. package/src/assets/scss/tokens/_color.scss +19 -0
  12. package/src/assets/scss/tokens/_datatable.scss +10 -0
  13. package/src/components/ActionCell.tsx +88 -0
  14. package/src/components/DataTable.tsx +85 -0
  15. package/src/components/DataTableBody.tsx +34 -0
  16. package/src/components/DataTableControls.tsx +35 -0
  17. package/src/components/DataTableHeader.tsx +59 -0
  18. package/src/components/DefaultSortIcon.tsx +13 -0
  19. package/src/components/TableCell.tsx +17 -0
  20. package/src/components/cell-types/BooleanCell.tsx +29 -0
  21. package/src/components/cell-types/DateCell.tsx +28 -0
  22. package/src/components/cell-types/EmptyCell.tsx +3 -0
  23. package/src/components/cell-types/InlineInputCell.tsx +129 -0
  24. package/src/hooks/useDataTable.ts +113 -0
  25. package/src/index.ts +14 -0
  26. package/src/types/action-cell-props.d.ts +9 -0
  27. package/src/types/action-menu.d.ts +15 -0
  28. package/src/types/column.d.ts +10 -0
  29. package/src/types/data-table-body-props.d.ts +16 -0
  30. package/src/types/data-table-header-props.d.ts +11 -0
  31. package/src/types/data-table-props.d.ts +32 -0
  32. package/src/types/entity.d.ts +4 -0
  33. package/src/types/form-field.d.ts +8 -0
  34. package/src/types/index.ts +11 -0
  35. package/src/types/pagination-meta.d.ts +7 -0
  36. package/src/types/sort-state.d.ts +6 -0
  37. package/src/types/table-cell-props.d.ts +9 -0
  38. package/src/types/use-data-table-props.d.ts +14 -0
  39. package/src/types/use-data-table-return.d.ts +14 -0
  40. package/src/utils/is-pill-value.ts +7 -0
  41. package/src/utils/render-object.tsx +18 -0
  42. package/src/utils/render-value.tsx +89 -0
  43. package/tsconfig.json +17 -0
  44. package/tsup.config.ts +8 -0
  45. package/vitest.config.ts +9 -0
@@ -0,0 +1,59 @@
1
+ import {Column, DataTableHeaderProps} from "../types";
2
+ import {DefaultSortIcon} from "./DefaultSortIcon";
3
+
4
+ export const DataTableHeader = <T extends { id: string | number }>({
5
+ columns,
6
+ onSort,
7
+ sortValue,
8
+ hasActionMenu,
9
+ renderSortIcon,
10
+ }: DataTableHeaderProps<T>) => {
11
+ const handleSort = (column: Column<T>) => {
12
+ if (!onSort) return;
13
+
14
+ if (!sortValue || sortValue.name !== column.name) {
15
+ onSort({ name: column.name, direction: 'asc' });
16
+ } else if (sortValue.direction === 'asc') {
17
+ onSort({ ...sortValue, direction: 'desc' });
18
+ } else {
19
+ onSort(null);
20
+ }
21
+ }
22
+
23
+ return (
24
+ <thead>
25
+ <tr>
26
+ {hasActionMenu && <th aria-label="Actions" />}
27
+ {columns.map((column: Column<T>) => (
28
+ <th
29
+ onClick={() => column.sort && handleSort(column)}
30
+ className={[column.className, column.sort ? 'sort' : null].filter(Boolean).join(' ')}
31
+ style={column.style}
32
+ key={String(column.name)}
33
+ >
34
+ <span>
35
+ <span className="label">{column.label}</span>
36
+ {column.sort && (
37
+ <span className="icons">
38
+ {renderSortIcon ? (
39
+ <>
40
+ {renderSortIcon('asc', sortValue?.name === column.name && sortValue?.direction === 'asc')}
41
+ {renderSortIcon('desc', sortValue?.name === column.name && sortValue?.direction === 'desc')}
42
+ </>
43
+ ) : (
44
+ <>
45
+ <DefaultSortIcon direction="asc"
46
+ active={sortValue?.name === column.name && sortValue?.direction === 'asc'}/>
47
+ <DefaultSortIcon direction="desc"
48
+ active={sortValue?.name === column.name && sortValue?.direction === 'desc'}/>
49
+ </>
50
+ )}
51
+ </span>
52
+ )}
53
+ </span>
54
+ </th>
55
+ ))}
56
+ </tr>
57
+ </thead>
58
+ )
59
+ }
@@ -0,0 +1,13 @@
1
+ import {SortDirection} from "../types";
2
+
3
+ export const DefaultSortIcon = ({
4
+ direction,
5
+ active,
6
+ }: { direction: SortDirection, active: boolean }) => (
7
+ <span
8
+ style={{ opacity: active ? 1 : 0.3, fontSize: '0.7em' }}
9
+ aria-hidden="true"
10
+ >
11
+ {direction === 'asc' ? '▲' : '▼'}
12
+ </span>
13
+ );
@@ -0,0 +1,17 @@
1
+ import {TableCellProps} from "../types";
2
+ import {renderValue} from "../utils/render-value";
3
+
4
+ export const TableCell = <T extends { id: number | string }> ({
5
+ column,
6
+ item,
7
+ formFields,
8
+ updateData,
9
+ }: TableCellProps<T>) => {
10
+ const cellValue: T[keyof T] = item[column.name];
11
+
12
+ return (
13
+ <td className={column.className ?? ''}>
14
+ {renderValue({value: cellValue, column, item, formFields, updateData})}
15
+ </td>
16
+ );
17
+ }
@@ -0,0 +1,29 @@
1
+ import {Column} from "../../types";
2
+ import {Button} from "@sio-group/ui-core";
3
+
4
+ interface BooleanCellProps<T extends { id: number | string }> {
5
+ item: T;
6
+ column: Column<T>;
7
+ value: T[keyof T];
8
+ updateData?: (id: string | number, values: Partial<T>) => void;
9
+ }
10
+
11
+ export const BooleanCell = <T extends { id: string | number }> ({
12
+ column,
13
+ value,
14
+ item,
15
+ updateData,
16
+ }: BooleanCellProps<T>) => (
17
+ <Button
18
+ color={value ? "success" : "error"}
19
+ variant={column.format === "button" ? "primary" : "link"}
20
+ onClick={() =>
21
+ updateData?.(item.id, {
22
+ [column.name]: !value
23
+ } as Partial<T>)
24
+ }
25
+ ariaLabel={String(column.name)}
26
+ >
27
+ {value ? '✓' : '✗'}
28
+ </Button>
29
+ );
@@ -0,0 +1,28 @@
1
+ import {Column} from "../../types";
2
+
3
+ interface DateCellProps<T extends { id: number | string }> {
4
+ column: Column<T>;
5
+ value: T[keyof T];
6
+ }
7
+
8
+ export const DateCell = <T extends { id: string | number }> ({
9
+ column,
10
+ value,
11
+ }: DateCellProps<T>) => (
12
+ new Date(value as string)
13
+ .toLocaleString("nl-BE", {
14
+ timeZone: "Europe/Brussels",
15
+ year: "numeric",
16
+ month: "2-digit",
17
+ day: "2-digit",
18
+ ...(column.format === "datetime"
19
+ ? {
20
+ hour: "2-digit",
21
+ minute: "2-digit",
22
+ second: "2-digit",
23
+ }
24
+ : {}),
25
+ hour12: false,
26
+ })
27
+ .replace(/\//g, "-")
28
+ );
@@ -0,0 +1,3 @@
1
+ export const EmptyCell = () => (
2
+ <span className="empty" aria-label="empty" />
3
+ );
@@ -0,0 +1,129 @@
1
+ import {KeyboardEventHandler, useEffect, useState} from "react";
2
+ import {Column, FormField} from "../../types";
3
+ import {Button} from "@sio-group/ui-core";
4
+
5
+ interface InlineInputCellProps<T extends { id: string | number }> {
6
+ column: Column<T>;
7
+ formField: FormField;
8
+ item: T;
9
+ value: T[keyof T];
10
+ updateData?: (id: string | number, values: Partial<T>) => void;
11
+ }
12
+
13
+ export const InlineInputCell = <T extends { id: string | number }>({
14
+ column,
15
+ formField,
16
+ item,
17
+ value,
18
+ updateData,
19
+ }: InlineInputCellProps<T>) => {
20
+ const [showEdit, setShowEdit] = useState(false);
21
+ const [fieldValue, setFieldValue] = useState(String(value));
22
+ const [isValid, setIsValid] = useState(true)
23
+
24
+ const handleCancel = () => {
25
+ setShowEdit(false);
26
+ setFieldValue(String(value));
27
+ };
28
+
29
+ const handleSave = async () => {
30
+ updateData?.(item.id, {[formField.name]: fieldValue} as Partial<T>);
31
+ setFieldValue(String(value));
32
+ setShowEdit(false);
33
+ };
34
+
35
+ useEffect(() => {
36
+ let bool: boolean = true;
37
+ if (formField.required) {
38
+ bool = fieldValue !== null && fieldValue !== '';
39
+ }
40
+
41
+ setIsValid(bool)
42
+ }, [fieldValue]);
43
+
44
+ useEffect(() => {
45
+ const handleEsc = (e: KeyboardEvent) => {
46
+ if (e.key === "Escape") setShowEdit(false);
47
+ };
48
+
49
+ document.addEventListener("keydown", handleEsc);
50
+ return () => document.removeEventListener("keydown", handleEsc);
51
+ }, [setShowEdit]);
52
+
53
+ return (
54
+ <>
55
+ {showEdit ? (
56
+ <form noValidate>
57
+ {formField.type === "select" || formField.type === "radio" ? (
58
+ <select
59
+ id={formField.name}
60
+ name={formField.name}
61
+ value={fieldValue}
62
+ onChange={(e) => setFieldValue(e.target.value)}
63
+ autoFocus={true}
64
+ >
65
+ {formField?.options?.map((option) => {
66
+ const val: string = typeof option === 'string' ? option : option.value;
67
+ const label: string = typeof option === 'string' ? option : option.label;
68
+
69
+ return <option value={val} key={val}>{label}</option>
70
+ })}
71
+ </select>
72
+ ) : (
73
+ <input
74
+ type="text"
75
+ id={formField.name}
76
+ name={formField.name}
77
+ value={fieldValue as string}
78
+ onChange={(e) => setFieldValue(e.target.value)}
79
+ autoFocus={true}
80
+ />
81
+ )}
82
+ <div className="btn-group">
83
+ <Button
84
+ type="submit"
85
+ variant="link"
86
+ color="success"
87
+ onClick={handleSave}
88
+ ariaLabel="inline edit field"
89
+ disabled={!isValid}
90
+ label="✓"
91
+ />
92
+ <Button
93
+ type="button"
94
+ variant="link"
95
+ color="error"
96
+ onClick={handleCancel}
97
+ ariaLabel="inline edit field"
98
+ label="✗"
99
+ />
100
+ </div>
101
+ </form>
102
+ ) : (
103
+ <>
104
+ {value}
105
+ <Button
106
+ variant="link"
107
+ onClick={() => setShowEdit(true)}
108
+ ariaLabel="inline edit field"
109
+ >
110
+ <svg
111
+ xmlns="http://www.w3.org/2000/svg"
112
+ width="16"
113
+ height="16"
114
+ viewBox="0 0 24 24"
115
+ fill="none"
116
+ stroke="currentColor"
117
+ strokeWidth="2"
118
+ strokeLinecap="round"
119
+ strokeLinejoin="round"
120
+ >
121
+ <path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
122
+ <path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
123
+ </svg>
124
+ </Button>
125
+ </>
126
+ )}
127
+ </>
128
+ )
129
+ }
@@ -0,0 +1,113 @@
1
+ import {useMemo, useState} from "react";
2
+ import {UseDataTableProps} from "../types/use-data-table-props";
3
+ import {UseDataTableReturn} from "../types/use-data-table-return";
4
+ import {SortState} from "../types";
5
+
6
+ export const useDataTable = <T extends { id: string | number }>({
7
+ data,
8
+ pagination,
9
+ onSearch,
10
+ onSort,
11
+ onPaginate,
12
+ searchValue,
13
+ sortValue,
14
+ clientPageSize,
15
+ clientSearchKeys
16
+ }: UseDataTableProps<T>): UseDataTableReturn<T> => {
17
+ const isControlled: boolean = pagination !== undefined;
18
+
19
+ const showPagination: boolean = isControlled ? !!onPaginate : !!clientPageSize;
20
+ const showSearch: boolean = isControlled ? !!onSearch : !!clientSearchKeys?.length;
21
+
22
+ const [clientSearch, setClientSearch] = useState('');
23
+ const [clientSort, setClientSort] = useState<SortState<T> | null>(null);
24
+ const [clientPage, setClientPage] = useState(1);
25
+
26
+ const handleSearch = (query: string) => {
27
+ if (isControlled) {
28
+ onSearch?.(query);
29
+ } else {
30
+ setClientSearch(query);
31
+ setClientPage(1);
32
+ }
33
+ }
34
+
35
+ const handleSort = (sort: SortState<T> | null) => {
36
+ if (isControlled) {
37
+ onSort?.(sort);
38
+ } else {
39
+ setClientSort(sort);
40
+ }
41
+ }
42
+
43
+ const handlePaginate = (page: number) => {
44
+ if (isControlled) {
45
+ onPaginate?.(page);
46
+ } else {
47
+ setClientPage(page);
48
+ }
49
+ }
50
+
51
+ const processedData = useMemo(() => {
52
+ if (isControlled) return data;
53
+
54
+ let result = [...data];
55
+
56
+ if (clientSearch && clientSearchKeys?.length) {
57
+ result = result.filter((item) =>
58
+ clientSearchKeys.some((key) =>
59
+ String(item[key]).toLowerCase().includes(clientSearch.toLowerCase())
60
+ )
61
+ );
62
+ }
63
+
64
+ if (clientSort) {
65
+ result.sort((a: T, b: T) => {
66
+ const aVal: string = String(a[clientSort.name]);
67
+ const bVal: string = String(b[clientSort.name]);
68
+ return clientSort.direction === 'asc'
69
+ ? aVal.localeCompare(bVal)
70
+ : bVal.localeCompare(aVal);
71
+ });
72
+ }
73
+
74
+ return result;
75
+ }, [data, clientSearch, clientSort, isControlled]);
76
+
77
+ const paginationMeta = useMemo(() => {
78
+ if (isControlled || !clientPageSize) return pagination;
79
+
80
+ const pageSize: number = clientPageSize;
81
+ const total: number = processedData.length;
82
+ const pageCount: number = Math.ceil(total / pageSize);
83
+ const from: number = (clientPage - 1) * pageSize;
84
+
85
+ return {
86
+ currentPage: clientPage,
87
+ pageCount,
88
+ total,
89
+ from: from + 1,
90
+ to: Math.min(from + pageSize, total)
91
+ };
92
+ }, [isControlled, pagination, processedData, clientPage, clientPageSize]);
93
+
94
+ const pagedData = useMemo(() => {
95
+ if (isControlled || !clientPageSize) return data;
96
+
97
+ const pageSize: number = clientPageSize;
98
+ const from: number = (clientPage - 1) * pageSize;
99
+ return processedData.slice(from, from + pageSize);
100
+ }, [isControlled, processedData, clientPage, clientPageSize]);
101
+
102
+ return {
103
+ pagedData,
104
+ paginationMeta,
105
+ showPagination,
106
+ showSearch,
107
+ handleSearch,
108
+ handleSort,
109
+ handlePaginate,
110
+ currentSort: isControlled ? sortValue : clientSort,
111
+ currentSearch: isControlled ? searchValue : clientSearch,
112
+ }
113
+ }
package/src/index.ts ADDED
@@ -0,0 +1,14 @@
1
+ export { DataTable } from "./components/DataTable";
2
+
3
+ export type {
4
+ DataTableProps,
5
+ Column,
6
+ Entity,
7
+ Action,
8
+ ActionMenu,
9
+ ActionMenuType,
10
+ FormField,
11
+ FormFieldType,
12
+ SortState,
13
+ SortDirection,
14
+ } from "./types";
@@ -0,0 +1,9 @@
1
+ import {ActionMenu} from "./action-menu";
2
+ import {Entity} from "./entity";
3
+ import {ReactNode} from "react";
4
+
5
+ export interface ActionCellProps <T extends { id: string | number }> {
6
+ actionMenu?: ActionMenu<T>;
7
+ item: T;
8
+ renderMenuIcon?: () => ReactNode;
9
+ }
@@ -0,0 +1,15 @@
1
+ import {ReactNode} from "react";
2
+
3
+ export type ActionMenuType = 'inline' | 'dropdown';
4
+
5
+ export interface Action <T extends { id: string | number }> {
6
+ name: string;
7
+ label: string;
8
+ icon?: ReactNode;
9
+ onClick: (item: T) => void
10
+ }
11
+
12
+ export interface ActionMenu <T extends { id: string | number }> {
13
+ type: ActionMenuType;
14
+ actions: Action<T>[];
15
+ }
@@ -0,0 +1,10 @@
1
+ import {CSSProperties} from "react";
2
+
3
+ export interface Column<T extends { id: number | string }> {
4
+ name: keyof T;
5
+ label: string;
6
+ className?: string;
7
+ style?: CSSProperties;
8
+ sort?: boolean;
9
+ format?: 'boolean' | 'button' | 'datetime' | 'date' | 'pill' | 'email' | { key: string };
10
+ }
@@ -0,0 +1,16 @@
1
+ import {Column} from "./column";
2
+ import {Entity} from "./entity";
3
+ import {ActionMenu} from "./action-menu";
4
+ import {FormField} from "./form-field";
5
+ import {ReactNode} from "react";
6
+
7
+
8
+ export interface DataTableBodyProps <T extends { id: number | string }> {
9
+ item: T;
10
+ columns: Column<T>[];
11
+ entity?: Entity;
12
+ actionMenu?: ActionMenu<T>;
13
+ formFields?: FormField[];
14
+ updateData?: (id: string | number, values: Partial<T>) => void;
15
+ renderMenuIcon?: () => ReactNode;
16
+ }
@@ -0,0 +1,11 @@
1
+ import {Column} from "./column";
2
+ import {SortDirection, SortState} from "./sort-state";
3
+ import {ReactNode} from "react";
4
+
5
+ export interface DataTableHeaderProps<T extends { id: string | number }>{
6
+ columns: Column<T>[];
7
+ onSort: (sort: SortState | null) => void;
8
+ sortValue?: SortState | null;
9
+ hasActionMenu: boolean;
10
+ renderSortIcon?: (direction: SortDirection, active: boolean) => ReactNode,
11
+ }
@@ -0,0 +1,32 @@
1
+ import {CSSProperties, ReactNode} from "react";
2
+ import {SortDirection, SortState} from "./sort-state";
3
+ import {Column} from "./column";
4
+ import {Entity} from "./entity";
5
+ import {ActionMenu} from "./action-menu";
6
+ import {FormField} from "./form-field";
7
+ import {PaginationMeta} from "./pagination-meta";
8
+
9
+ export type Color = 'default' | 'error' | 'success' | 'warning' | 'caution' | 'info';
10
+
11
+ export interface DataTableProps<T extends { id: string | number }> {
12
+ columns: Column<T>[],
13
+ data: T[],
14
+ pagination?: PaginationMeta,
15
+ onPaginate?: (page: number) => void,
16
+ onSearch?: (query: string) => void,
17
+ onSort?: (sort: SortState | null) => void,
18
+ searchValue?: string,
19
+ sortValue?: SortState | null,
20
+ clientPageSize?: number | null,
21
+ clientSearchKeys?: (keyof T)[],
22
+ entity?: Entity,
23
+ actionMenu?: ActionMenu<T>,
24
+ renderMenuIcon?: () => ReactNode,
25
+ onUpdate?: (id: string | number, values: Partial<T>) => void,
26
+ formFields?: FormField[],
27
+ renderSortIcon?: (direction: SortDirection, active: boolean) => ReactNode,
28
+ emptyMessage?: string,
29
+ striped?: boolean,
30
+ hover?: boolean,
31
+ style?: CSSProperties,
32
+ }
@@ -0,0 +1,4 @@
1
+ export interface Entity {
2
+ name: string;
3
+ label: string;
4
+ }
@@ -0,0 +1,8 @@
1
+ export type FormFieldType = 'text' | 'radio' | 'select';
2
+
3
+ export interface FormField {
4
+ name: string;
5
+ type: FormFieldType;
6
+ options?: { label: string; value: string }[] | string[];
7
+ required?: boolean;
8
+ }
@@ -0,0 +1,11 @@
1
+ export { DataTableProps } from "./data-table-props";
2
+ export { DataTableHeaderProps } from "./data-table-header-props";
3
+ export { ActionCellProps } from "./action-cell-props";
4
+ export { TableCellProps } from "./table-cell-props";
5
+ export { SortDirection, SortState } from "./sort-state";
6
+
7
+ export { ActionMenuType, Action, ActionMenu } from "./action-menu";
8
+ export { FormFieldType, FormField } from "./form-field";
9
+
10
+ export { Column } from "./column";
11
+ export { Entity } from "./entity";
@@ -0,0 +1,7 @@
1
+ export interface PaginationMeta {
2
+ currentPage: number;
3
+ pageCount: number;
4
+ total: number;
5
+ from: number;
6
+ to: number;
7
+ }
@@ -0,0 +1,6 @@
1
+ export type SortDirection = 'asc' | 'desc';
2
+
3
+ export interface SortState<T extends { id: string | number }> {
4
+ name: keyof T;
5
+ direction: SortDirection;
6
+ }
@@ -0,0 +1,9 @@
1
+ import {Column} from "./column";
2
+ import {FormField} from "./form-field";
3
+
4
+ export interface TableCellProps <T extends { id: string | number }> {
5
+ column: Column<T>;
6
+ item: T;
7
+ formFields?: FormField[];
8
+ updateData?: (id: string | number, values: Partial<T>) => void;
9
+ }
@@ -0,0 +1,14 @@
1
+ import {SortState} from "./sort-state";
2
+ import {PaginationMeta} from "./pagination-meta";
3
+
4
+ export interface UseDataTableProps <T extends { id: number | string }>{
5
+ data: T[],
6
+ pagination?: PaginationMeta,
7
+ onSearch?: (query: string) => void,
8
+ onSort?: (sort: SortState | null) => void,
9
+ onPaginate?: (page: number) => void,
10
+ searchValue?: string,
11
+ sortValue?: SortState | null,
12
+ clientPageSize?: number | null,
13
+ clientSearchKeys?: (keyof T)[],
14
+ }
@@ -0,0 +1,14 @@
1
+ import {SortState} from "./sort-state";
2
+ import {PaginationMeta} from "./pagination-meta";
3
+
4
+ export interface UseDataTableReturn <T extends { id: number | string }>{
5
+ pagedData: T[],
6
+ paginationMeta?: PaginationMeta,
7
+ showPagination: boolean,
8
+ showSearch: boolean,
9
+ handleSearch: (query: string) => void,
10
+ handleSort: (sort: SortState | null) => void,
11
+ handlePaginate: (page: number) => void,
12
+ currentSort?: SortState | null,
13
+ currentSearch?: string | null,
14
+ }
@@ -0,0 +1,7 @@
1
+ import {Color} from "../types/data-table-props";
2
+
3
+ export const isPillValue = (val: unknown): val is { label: string, status: Color } =>
4
+ typeof val === 'object' &&
5
+ val !== null &&
6
+ 'status' in val &&
7
+ 'label' in val;
@@ -0,0 +1,18 @@
1
+ import {EmptyCell} from "../components/cell-types/EmptyCell";
2
+
3
+ export const renderObject = (obj: Record<string, unknown>) => {
4
+ const entries = Object.entries(obj);
5
+
6
+ if (!entries.length) return <EmptyCell />;
7
+
8
+ return (
9
+ <>
10
+ {entries.map(([key, val]) => (
11
+ <div key={key}>
12
+ <span>{key}: </span>
13
+ <span>{String(val)}</span>
14
+ </div>
15
+ ))}
16
+ </>
17
+ )
18
+ }