From 6f074a40701baf7007549dd59499d13215b19ec4 Mon Sep 17 00:00:00 2001 From: Sven van de Scheur Date: Tue, 8 Oct 2024 17:48:32 +0200 Subject: [PATCH] :recycle: - refactor: split DataGrid component into various files --- src/components/data/datagrid/datagrid.scss | 1 + src/components/data/datagrid/datagrid.tsx | 866 +----------------- .../data/datagrid/datagridcontentcell.tsx | 187 ++++ .../data/datagrid/datagridfooter.tsx | 36 + .../data/datagrid/datagridheader.tsx | 21 + .../data/datagrid/datagridheadingcell.tsx | 57 ++ .../data/datagrid/datagridscrollpane.tsx | 48 + .../datagrid/datagridselectioncheckbox.tsx | 130 +++ .../data/datagrid/datagridtable.tsx | 26 + .../data/datagrid/datagridtbody.tsx | 59 ++ .../data/datagrid/datagridthead.tsx | 202 ++++ .../data/datagrid/datagridtoolbar.tsx | 160 ++++ 12 files changed, 941 insertions(+), 852 deletions(-) create mode 100644 src/components/data/datagrid/datagridcontentcell.tsx create mode 100644 src/components/data/datagrid/datagridfooter.tsx create mode 100644 src/components/data/datagrid/datagridheader.tsx create mode 100644 src/components/data/datagrid/datagridheadingcell.tsx create mode 100644 src/components/data/datagrid/datagridscrollpane.tsx create mode 100644 src/components/data/datagrid/datagridselectioncheckbox.tsx create mode 100644 src/components/data/datagrid/datagridtable.tsx create mode 100644 src/components/data/datagrid/datagridtbody.tsx create mode 100644 src/components/data/datagrid/datagridthead.tsx create mode 100644 src/components/data/datagrid/datagridtoolbar.tsx diff --git a/src/components/data/datagrid/datagrid.scss b/src/components/data/datagrid/datagrid.scss index 410060a..ebd31c1 100644 --- a/src/components/data/datagrid/datagrid.scss +++ b/src/components/data/datagrid/datagrid.scss @@ -63,6 +63,7 @@ gap: 0; } + .mykn-stackctx, .mykn-input, .mykn-select { width: 100%; diff --git a/src/components/data/datagrid/datagrid.tsx b/src/components/data/datagrid/datagrid.tsx index c079e9a..0b105a8 100644 --- a/src/components/data/datagrid/datagrid.tsx +++ b/src/components/data/datagrid/datagrid.tsx @@ -1,8 +1,5 @@ -import clsx from "clsx"; import React, { - CSSProperties, useCallback, - useContext, useEffect, useId, useMemo, @@ -11,37 +8,30 @@ import React, { } from "react"; import { - Attribute, DEFAULT_URL_FIELDS, Field, SerializedFormData, TypedField, filterAttributeDataArray, - formatMessage, - isPrimitive, - serializeForm, typedFieldByFields, - ucFirst, - useIntl, } from "../../../lib"; import { AttributeData, sortAttributeDataArray, } from "../../../lib/data/attributedata"; -import { getByDotSeparatedPath } from "../../../lib/data/getByDotSeparatedPath"; -import { field2Title, isLink } from "../../../lib/format/string"; import { BadgeProps } from "../../badge"; import { BoolProps } from "../../boolean"; -import { Button, ButtonProps } from "../../button"; -import { Checkbox, Form, FormControl } from "../../form"; -import { Outline } from "../../icon"; -import { Modal } from "../../modal"; -import { Toolbar, ToolbarItem } from "../../toolbar"; -import { AProps, Body, H2, H3, P, PProps } from "../../typography"; -import { Paginator, PaginatorProps } from "../paginator"; -import { Value } from "../value"; +import { ButtonProps } from "../../button"; +import { AProps, PProps } from "../../typography"; +import { PaginatorProps } from "../paginator"; import "./datagrid.scss"; -import { TRANSLATIONS } from "./translations"; +import { DataGridFooter } from "./datagridfooter"; +import { DataGridHeader } from "./datagridheader"; +import { DataGridScrollPane } from "./datagridscrollpane"; +import { DataGridTable } from "./datagridtable"; +import { DataGridTBody } from "./datagridtbody"; +import { DataGridTHead } from "./datagridthead"; +import { DataGridToolbar } from "./datagridtoolbar"; export type DataGridProps = { /** The object list (after pagination), only primitive types supported for now. */ @@ -224,9 +214,9 @@ export type DataGridProps = { onSort?: (sort: string) => Promise | void; } & PaginatorPropsAliases; -const dataGridRef = React.createRef(); -const toolbarRef = React.createRef(); -const scrollPaneRef = React.createRef(); +export const dataGridRef = React.createRef(); +export const toolbarRef = React.createRef(); +export const scrollPaneRef = React.createRef(); export type DataGridContextType = Omit< DataGridProps, @@ -261,7 +251,7 @@ export type DataGridContextType = Omit< onSort: (field: TypedField) => void; }; -const DataGridContext = React.createContext( +export const DataGridContext = React.createContext( {} as unknown as DataGridContextType, ); @@ -632,831 +622,3 @@ export const DataGrid: React.FC = (props) => { ); }; - -/** - * DataGrid header, shows title as either string or JSX. - */ -export const DataGridHeader: React.FC = () => { - const { title, titleId } = useContext(DataGridContext); - - return ( -
- {typeof title === "string" ? ( -

{title}

- ) : ( - title - )} -
- ); -}; - -/** - * DataGrid toolbar, shows selection actions and/or allows the user to select fields (columns). - */ -export const DataGridToolbar: React.FC = () => { - const { toolbarRef } = useContext(DataGridContext); - const intl = useIntl(); - const [selectFieldsModalState, setSelectFieldsModalState] = useState(false); - const [selectFieldsActiveState, setSelectFieldsActiveState] = useState< - Record - >({}); - - const { - allowSelectAll, - allowSelectAllPages, - fields, - fieldsSelectable, - labelSaveFieldSelection, - labelSelectFields, - selectable, - selectedRows, - selectionActions, - onFieldsChange, - } = useContext(DataGridContext); - - // Create map mapping `field.name` to active state. - useEffect(() => { - setSelectFieldsActiveState( - fields.reduce( - (acc, field) => ({ - ...acc, - [field.name]: field.active !== false, - }), - {}, - ), - ); - }, [fields]); - - const context = { - open: Boolean(selectFieldsModalState), - }; - - const _labelSelectFields = labelSelectFields - ? formatMessage(labelSelectFields, context) - : intl.formatMessage(TRANSLATIONS.LABEL_SELECT_FIELDS, context); - - const _labelSaveFieldSelection = labelSaveFieldSelection - ? formatMessage(labelSaveFieldSelection, context) - : intl.formatMessage(TRANSLATIONS.LABEL_SAVE_FIELD_SELECTION, context); - - const toolbarItems: ToolbarItem[] = [ - selectable && allowSelectAll ? ( - - ) : null, - - selectable && allowSelectAllPages ? ( - - ) : null, - - ...(selectionActions || []).map( - (buttonProps): ButtonProps => ({ - variant: "secondary", - ...buttonProps, - onClick: () => { - if (typeof buttonProps.onClick === "function") { - const customEvent = new CustomEvent("click", { - detail: selectedRows, - }); - buttonProps.onClick( - customEvent as unknown as React.MouseEvent, - ); - } - }, - }), - ), - - fieldsSelectable ? "spacer" : null, - fieldsSelectable - ? { - variant: "outline", - wrap: false, - onClick: () => setSelectFieldsModalState(true), - children: ( - <> - - {ucFirst(_labelSelectFields)} - - ), - } - : null, - ]; - - return ( -
- - - {ucFirst(_labelSelectFields)}} - onClose={() => setSelectFieldsModalState(false)} - > - -
({ - label: field2Title(f.name, { lowerCase: false }), - value: f.name, - selected: Boolean(selectFieldsActiveState[f.name]), - })), - type: "checkbox", - onChange: (e: React.ChangeEvent) => { - const name = e.target.value; - setSelectFieldsActiveState({ - ...selectFieldsActiveState, - [name]: !selectFieldsActiveState[name], - }); - }, - }, - ]} - labelSubmit={ucFirst(_labelSaveFieldSelection)} - onSubmit={(e) => { - const form = e.target as HTMLFormElement; - const data = serializeForm(form); - const selectedFields = (data.fields || []) as string[]; - const newTypedFieldsState = fields.map((f) => ({ - ...f, - active: selectedFields.includes(f.name), - })); - onFieldsChange?.(newTypedFieldsState); - setSelectFieldsModalState(false); - }} - /> - - -
- ); -}; - -/** - * Datagrid scroll pane, contains the scrollable content. - * @param children - * @constructor - */ -export const DataGridScrollPane: React.FC = ({ - children, -}) => { - const { allowOverflowX, scrollPaneRef } = useContext(DataGridContext); - - // Overflow detection - useEffect(() => { - detectOverflowX(); - window.addEventListener("resize", detectOverflowX); - window.addEventListener("scroll", detectOverflowX); - () => window.removeEventListener("resize", detectOverflowX); - }); - - /** - * Toggles "mykn-datagrid__scrollpane--overflow-x" to class list based on - * whether `allowOverflowX=true` and the contents are overflowing. - */ - const detectOverflowX = () => { - if (!scrollPaneRef?.current) { - return; - } - const node = scrollPaneRef.current; - - const hasOverflowX = node.scrollWidth > node.clientWidth; - const expX = allowOverflowX && hasOverflowX; - node.classList.toggle("mykn-datagrid__scrollpane--overflow-x", expX); - - const hasOverflowY = node.scrollHeight > node.clientHeight; - const expY = hasOverflowY; - node.classList.toggle("mykn-datagrid__scrollpane--overflow-y", expY); - }; - - return ( -
- {children} -
- ); - // return null; -}; - -/** - * DataGrid table, represents tabular: information presented in a two-dimensional table comprised of rows and columns - * (fields) of cells containing data. - */ -export const DataGridTable: React.FC = ({ - children, -}) => { - const { tableLayout, titleId } = useContext(DataGridContext); - - return ( - - {children} -
- ); -}; - -/** - * DataGrid table head, encapsulates a set of table rows, indicating that they - * comprise the head of a table with information about the table's columns. - */ -export const DataGridTHead: React.FC = () => { - const { toolbarRef, height } = useContext(DataGridContext); - const intl = useIntl(); - const onFilterTimeoutRef = useRef(); - const ref = useRef(null); - const [filterState, setFilterState] = useState(); - - const { - dataGridId, - filterable, - filterTransform, - labelFilterField, - onFilter, - renderableFields, - selectable, - } = useContext(DataGridContext); - - // Sticky fix - useEffect(() => { - stickyFix(); - window.addEventListener("resize", stickyFix); - window.addEventListener("scroll", stickyFix); - () => { - window.removeEventListener("resize", stickyFix); - window.addEventListener("scroll", stickyFix); - }; - }); - - /** - * Fixes sticky behaviour due to `overflow-x: auto;` not being compatible - * with native sticky in all cases. - */ - const stickyFix = () => { - if (!ref.current || !scrollPaneRef.current) { - return; - } - - const node = ref.current; - const scrollPaneNode = scrollPaneRef.current; - const indicator = "mykn-datagrid__scrollpane--overflow-x"; - - // No need for fallback implementation, native behaviour should work if height is set of no overflow is applied.. - if (height || !scrollPaneNode?.classList?.contains(indicator)) { - node.style.top = ""; - return; - } - - requestAnimationFrame(() => { - node.style.top = ""; - const computedStyle = getComputedStyle(node); - const cssTop = parseInt(computedStyle.top); - - const boundingClientRect = node.getBoundingClientRect(); - const boundingTop = boundingClientRect.top; - const compensation = boundingTop * -1 + cssTop * 2; - - node.style.top = compensation + "px"; - }); - }; - - // Debounce filter - useEffect(() => { - const handler = () => { - // No filter state. - if (filterState === undefined) { - return; - } - onFilter(filterState || {}); - }; - onFilterTimeoutRef.current && clearTimeout(onFilterTimeoutRef.current); - onFilterTimeoutRef.current = setTimeout(handler, 300); - }, [filterState]); - - return ( - - {/* Captions */} - - {selectable && ( - - )} - {renderableFields.map((field) => ( - - {field2Title(field.name, { lowerCase: false })} - - ))} - - - {/* Filters */} - {filterable && ( - - {selectable && ( - - )} - {renderableFields.map((field) => { - const placeholder = field2Title(field.name, { lowerCase: false }); - - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { options, valueTransform, ...context } = field; - - const _labelFilterField = labelFilterField - ? formatMessage(labelFilterField, context) - : intl.formatMessage(TRANSLATIONS.LABEL_FILTER_FIELD, context); - - return ( - - {field.filterable !== false && ( - } - form={`${dataGridId}-filter-form`} - name={field.filterLookup || field.name} - options={field.options} - min={ - !field.options && field.type === "number" ? 0 : undefined - } - placeholder={placeholder} - type={field.type} - value={field.filterValue} - onChange={( - e: React.ChangeEvent< - HTMLInputElement | HTMLSelectElement - >, - ) => { - e.preventDefault(); - const data = serializeForm( - e.target.form as HTMLFormElement, - ) as AttributeData; - const _data = filterTransform - ? filterTransform(data) - : data; - - // Reset page on filter (length of dataset may change). - setFilterState(_data); - }} - /> - )} - - ); - })} - - )} - - ); -}; - -export type DataGridHeadingCellProps = React.PropsWithChildren<{ - field: TypedField; -}>; -/** - * DataGrid (heading) cell - */ - -export const DataGridHeadingCell: React.FC = ({ - children, - field, -}) => { - const { sortField, sortable, sortDirection, onSort } = - useContext(DataGridContext); - const isSorted = sortField === field.name; - - return ( - - {sortable ? ( - - ) : ( -

- {children} -

- )} - - ); -}; - -/** - * DataGrid table body, encapsulates a set of table rows indicating that they - * comprise the body of a table's (main) data. - */ -export const DataGridTBody: React.FC = () => { - const { - dataGridId, - page, - renderableFields, - renderableRows, - selectable, - selectedRows, - equalityChecker = (item1: AttributeData, item2: AttributeData) => - item1 === item2, - sortDirection, - sortField, - } = useContext(DataGridContext); - - return ( - - {renderableRows.map((rowData, index) => ( - - equalityChecker(element, rowData), - ), - })} - > - {selectable && ( - - - - )} - {renderableFields.map((field) => ( - - ))} - - ))} - - ); -}; - -export type DataGridContentCellProps = { - field: TypedField; - rowData: AttributeData; -}; - -/** - * DataGrid (content) cell - */ -export const DataGridContentCell: React.FC = ({ - field, - rowData, -}) => { - const { - aProps, - badgeProps, - boolProps, - pProps, - dataGridId, - decorate, - editable, - editingFieldIndex, - editingRow, - renderableFields = [], - setEditingState, - urlFields = DEFAULT_URL_FIELDS, - onChange, - onEdit, - } = useContext(DataGridContext); - const [pristine, setPristine] = useState(true); - - const fieldEditable = - typeof field.editable === "boolean" ? field.editable : editable; - const fieldIndex = renderableFields.findIndex((f) => f.name === field.name); - const isEditingRow = editingRow === rowData; - const isEditingField = - isEditingRow && editingFieldIndex === renderableFields.indexOf(field); - const urlField = urlFields.find((f) => rowData[f]); - const rowUrl = urlField ? rowData[urlField] : null; - const resolvedValue = getByDotSeparatedPath( - rowData, - field.valueLookup || field.name, - ); - const value = field.valueTransform?.(rowData) || resolvedValue; - const valueIsPrimitive = isPrimitive(value); - - const isImplicitLink = rowUrl && fieldIndex === 0 && !isLink(String(value)); - const link = isImplicitLink ? String(rowUrl) : ""; - - /** - * Renders a button triggering the editing state. - */ - const renderButton = () => ( - - ); - - /** - * Renders a form control for editing the fields value. - */ - const renderFormControl = () => ( - e.preventDefault()} - > - { - setPristine(false); - onChange?.(e); - }} - onBlur={(e: React.FocusEvent) => { - const data = Object.assign( - rowData, - serializeForm(e.target.form as HTMLFormElement, true), - ); - !pristine && onEdit?.(data); - }} - /> - - ); - /** - * Renders a hidden input allowing the form to serialize this fields data. - */ - const renderHiddenInput = () => ( - - ); - - /** - * Renders the value according to Value component - */ - const renderValue = () => { - // Support label from select - const label = field.options?.find((o) => o.value === value)?.label; - - return ( - - ); - }; - - return ( - - {valueIsPrimitive && - isEditingRow && - !isEditingField && - renderHiddenInput()} - {valueIsPrimitive && isEditingField && renderFormControl()} - {valueIsPrimitive && !isEditingField && fieldEditable && renderButton()} - {!isEditingField && !fieldEditable && renderValue()} - - ); -}; - -export type DataGridSelectionCheckboxProps = { - rowData?: AttributeData; - selectAll?: false | "page" | "allPages"; -}; - -/** - * A select (all) checkbox - */ -export const DataGridSelectionCheckbox: React.FC< - DataGridSelectionCheckboxProps -> = ({ rowData, selectAll }) => { - const intl = useIntl(); - const { - allPagesSelected, - allPagesSelectedManaged, - amountSelected, - count, - equalityChecker, - labelSelect, - labelSelectAll, - labelSelectAllPages, - pages, - renderableRows, - selectedRows, - onSelect, - onSelectAll, - onSelectAllPages, - } = useContext(DataGridContext); - - const { checked, disabled, onChange, ariaLabel } = useMemo(() => { - let allSelected: boolean = false; - let checked: boolean = false; - let disabled: boolean = false; - let handleSelect: (() => void) | ((rows: AttributeData) => void); - let i18nContext; - let ariaLabel: string = ""; - - switch (selectAll) { - case "page": - allSelected = - selectedRows?.every((a) => renderableRows.includes(a)) && - renderableRows.every((a) => selectedRows.includes(a)); - checked = allSelected || false; - disabled = Boolean(allPagesSelectedManaged && allPagesSelected); - handleSelect = () => onSelectAll(!allSelected); - - i18nContext = { - count: count, - countPage: renderableRows.length, - pages: pages, - amountSelected: amountSelected, - selectAll: selectAll, - amountUnselected: (count || 0) - (amountSelected || 0), - amountUnselectedPage: renderableRows.length - (amountSelected || 0), - }; - ariaLabel = - labelSelectAll || - intl.formatMessage(TRANSLATIONS.LABEL_SELECT_ALL, i18nContext); - break; - - case "allPages": - allSelected = Boolean(allPagesSelected); - checked = allPagesSelected || false; - handleSelect = () => onSelectAllPages(!allSelected); - - i18nContext = { pages: count }; - ariaLabel = - labelSelectAllPages || - intl.formatMessage(TRANSLATIONS.LABEL_SELECT_ALL_PAGES, i18nContext); - break; - - default: - allSelected = false; - checked = - (rowData && - !!selectedRows.find((element) => - equalityChecker(element, rowData), - )) || - false; - disabled = Boolean(allPagesSelectedManaged && allPagesSelected); - handleSelect = onSelect; - - i18nContext = { - count: count, - countPage: renderableRows.length, - pages: pages, - amountSelected: amountSelected, - selectAll: selectAll, - amountUnselected: (count || 0) - (amountSelected || 0), - amountUnselectedPage: renderableRows.length - (amountSelected || 0), - ...rowData, - }; - ariaLabel = - labelSelect || - intl.formatMessage(TRANSLATIONS.LABEL_SELECT, i18nContext); - } - - const onChange = () => handleSelect(rowData || {}); - return { checked, disabled, onChange, ariaLabel }; - }, [ - allPagesSelected, - allPagesSelectedManaged, - amountSelected, - count, - labelSelect, - pages, - renderableRows, - rowData, - selectAll, - selectedRows, - ]); - - return ( - - {selectAll && ariaLabel} - - ); -}; - -/** - * DataGrid footer, shows paginator. - */ -export const DataGridFooter: React.FC = () => { - const { - count, - loading, - onPageChange, - onPageSizeChange, - page, - pageSize, - pageSizeOptions, - paginatorProps, - } = useContext(DataGridContext); - - return ( - - - - ); -}; diff --git a/src/components/data/datagrid/datagridcontentcell.tsx b/src/components/data/datagrid/datagridcontentcell.tsx new file mode 100644 index 0000000..b161dd9 --- /dev/null +++ b/src/components/data/datagrid/datagridcontentcell.tsx @@ -0,0 +1,187 @@ +import clsx from "clsx"; +import React, { useContext, useState } from "react"; + +import { + Attribute, + AttributeData, + DEFAULT_URL_FIELDS, + TypedField, + field2Title, + isLink, + isPrimitive, + serializeForm, +} from "../../../lib"; +import { getByDotSeparatedPath } from "../../../lib/data/getByDotSeparatedPath"; +import { BoolProps } from "../../boolean"; +import { Button } from "../../button"; +import { FormControl } from "../../form"; +import { Value } from "../value"; +import { DataGridContext } from "./datagrid"; + +export type DataGridContentCellProps = { + field: TypedField; + rowData: AttributeData; +}; + +/** + * DataGrid (content) cell + */ +export const DataGridContentCell: React.FC = ({ + field, + rowData, +}) => { + const { + aProps, + badgeProps, + boolProps, + pProps, + dataGridId, + decorate, + editable, + editingFieldIndex, + editingRow, + renderableFields = [], + setEditingState, + urlFields = DEFAULT_URL_FIELDS, + onChange, + onEdit, + } = useContext(DataGridContext); + const [pristine, setPristine] = useState(true); + + const fieldEditable = + typeof field.editable === "boolean" ? field.editable : editable; + const fieldIndex = renderableFields.findIndex((f) => f.name === field.name); + const isEditingRow = editingRow === rowData; + const isEditingField = + isEditingRow && editingFieldIndex === renderableFields.indexOf(field); + const urlField = urlFields.find((f) => rowData[f]); + const rowUrl = urlField ? rowData[urlField] : null; + const resolvedValue = getByDotSeparatedPath( + rowData, + field.valueLookup || field.name, + ); + const value = field.valueTransform?.(rowData) || resolvedValue; + const valueIsPrimitive = isPrimitive(value); + + const isImplicitLink = rowUrl && fieldIndex === 0 && !isLink(String(value)); + const link = isImplicitLink ? String(rowUrl) : ""; + + /** + * Renders a button triggering the editing state. + */ + const renderButton = () => ( + + ); + + /** + * Renders a form control for editing the fields value. + */ + const renderFormControl = () => ( +
e.preventDefault()} + > + { + setPristine(false); + onChange?.(e); + }} + onBlur={(e: React.FocusEvent) => { + const data = Object.assign( + rowData, + serializeForm(e.target.form as HTMLFormElement, true), + ); + !pristine && onEdit?.(data); + }} + /> + + ); + /** + * Renders a hidden input allowing the form to serialize this fields data. + */ + const renderHiddenInput = () => ( + + ); + + /** + * Renders the value according to Value component + */ + const renderValue = () => { + // Support label from select + const label = field.options?.find((o) => o.value === value)?.label; + + return ( + + ); + }; + + return ( + + {valueIsPrimitive && + isEditingRow && + !isEditingField && + renderHiddenInput()} + {valueIsPrimitive && isEditingField && renderFormControl()} + {valueIsPrimitive && !isEditingField && fieldEditable && renderButton()} + {!isEditingField && !fieldEditable && renderValue()} + + ); +}; diff --git a/src/components/data/datagrid/datagridfooter.tsx b/src/components/data/datagrid/datagridfooter.tsx new file mode 100644 index 0000000..0490716 --- /dev/null +++ b/src/components/data/datagrid/datagridfooter.tsx @@ -0,0 +1,36 @@ +import React, { useContext } from "react"; + +import { Toolbar } from "../../toolbar"; +import { Paginator } from "../paginator"; +import { DataGridContext } from "./datagrid"; + +/** + * DataGrid footer, shows paginator. + */ +export const DataGridFooter: React.FC = () => { + const { + count, + loading, + onPageChange, + onPageSizeChange, + page, + pageSize, + pageSizeOptions, + paginatorProps, + } = useContext(DataGridContext); + + return ( + + + + ); +}; diff --git a/src/components/data/datagrid/datagridheader.tsx b/src/components/data/datagrid/datagridheader.tsx new file mode 100644 index 0000000..1b2fcdf --- /dev/null +++ b/src/components/data/datagrid/datagridheader.tsx @@ -0,0 +1,21 @@ +import React, { useContext } from "react"; + +import { H2 } from "../../typography"; +import { DataGridContext } from "./datagrid"; + +/** + * DataGrid header, shows title as either string or JSX. + */ +export const DataGridHeader: React.FC = () => { + const { title, titleId } = useContext(DataGridContext); + + return ( +
+ {typeof title === "string" ? ( +

{title}

+ ) : ( + title + )} +
+ ); +}; diff --git a/src/components/data/datagrid/datagridheadingcell.tsx b/src/components/data/datagrid/datagridheadingcell.tsx new file mode 100644 index 0000000..d162f41 --- /dev/null +++ b/src/components/data/datagrid/datagridheadingcell.tsx @@ -0,0 +1,57 @@ +import clsx from "clsx"; +import React, { useContext } from "react"; + +import { TypedField } from "../../../lib"; +import { Button } from "../../button"; +import { Outline } from "../../icon"; +import { P } from "../../typography"; +import { DataGridContext } from "./datagrid"; + +export type DataGridHeadingCellProps = React.PropsWithChildren<{ + field: TypedField; +}>; + +/** + * DataGrid (heading) cell + */ +export const DataGridHeadingCell: React.FC = ({ + children, + field, +}) => { + const { sortField, sortable, sortDirection, onSort } = + useContext(DataGridContext); + const isSorted = sortField === field.name; + + return ( + + {sortable ? ( + + ) : ( +

+ {children} +

+ )} + + ); +}; diff --git a/src/components/data/datagrid/datagridscrollpane.tsx b/src/components/data/datagrid/datagridscrollpane.tsx new file mode 100644 index 0000000..ed18f30 --- /dev/null +++ b/src/components/data/datagrid/datagridscrollpane.tsx @@ -0,0 +1,48 @@ +import clsx from "clsx"; +import React, { useContext, useEffect } from "react"; + +import { DataGridContext } from "./datagrid"; + +/** + * DataGrid scroll pane, contains the scrollable content. + * @param children + * @constructor + */ +export const DataGridScrollPane: React.FC = ({ + children, +}) => { + const { allowOverflowX, scrollPaneRef } = useContext(DataGridContext); + + // Overflow detection + useEffect(() => { + detectOverflowX(); + window.addEventListener("resize", detectOverflowX); + window.addEventListener("scroll", detectOverflowX); + () => window.removeEventListener("resize", detectOverflowX); + }); + + /** + * Toggles "mykn-datagrid__scrollpane--overflow-x" to class list based on + * whether `allowOverflowX=true` and the contents are overflowing. + */ + const detectOverflowX = () => { + if (!scrollPaneRef?.current) { + return; + } + const node = scrollPaneRef.current; + + const hasOverflowX = node.scrollWidth > node.clientWidth; + const expX = allowOverflowX && hasOverflowX; + node.classList.toggle("mykn-datagrid__scrollpane--overflow-x", expX); + + const hasOverflowY = node.scrollHeight > node.clientHeight; + const expY = hasOverflowY; + node.classList.toggle("mykn-datagrid__scrollpane--overflow-y", expY); + }; + + return ( +
+ {children} +
+ ); +}; diff --git a/src/components/data/datagrid/datagridselectioncheckbox.tsx b/src/components/data/datagrid/datagridselectioncheckbox.tsx new file mode 100644 index 0000000..da09dfc --- /dev/null +++ b/src/components/data/datagrid/datagridselectioncheckbox.tsx @@ -0,0 +1,130 @@ +import React, { useContext, useMemo } from "react"; + +import { AttributeData, useIntl } from "../../../lib"; +import { Checkbox } from "../../form"; +import { DataGridContext } from "./datagrid"; +import { TRANSLATIONS } from "./translations"; + +export type DataGridSelectionCheckboxProps = { + rowData?: AttributeData; + selectAll?: false | "page" | "allPages"; +}; + +/** + * A select (all) checkbox + */ +export const DataGridSelectionCheckbox: React.FC< + DataGridSelectionCheckboxProps +> = ({ rowData, selectAll }) => { + const intl = useIntl(); + const { + allPagesSelected, + allPagesSelectedManaged, + amountSelected, + count, + equalityChecker, + labelSelect, + labelSelectAll, + labelSelectAllPages, + pages, + renderableRows, + selectedRows, + onSelect, + onSelectAll, + onSelectAllPages, + } = useContext(DataGridContext); + + const { checked, disabled, onChange, ariaLabel } = useMemo(() => { + let allSelected: boolean = false; + let checked: boolean = false; + let disabled: boolean = false; + let handleSelect: (() => void) | ((rows: AttributeData) => void); + let i18nContext; + let ariaLabel: string = ""; + + switch (selectAll) { + case "page": + allSelected = + selectedRows?.every((a) => renderableRows.includes(a)) && + renderableRows.every((a) => selectedRows.includes(a)); + checked = allSelected || false; + disabled = Boolean(allPagesSelectedManaged && allPagesSelected); + handleSelect = () => onSelectAll(!allSelected); + + i18nContext = { + count: count, + countPage: renderableRows.length, + pages: pages, + amountSelected: amountSelected, + selectAll: selectAll, + amountUnselected: (count || 0) - (amountSelected || 0), + amountUnselectedPage: renderableRows.length - (amountSelected || 0), + }; + ariaLabel = + labelSelectAll || + intl.formatMessage(TRANSLATIONS.LABEL_SELECT_ALL, i18nContext); + break; + + case "allPages": + allSelected = Boolean(allPagesSelected); + checked = allPagesSelected || false; + handleSelect = () => onSelectAllPages(!allSelected); + + i18nContext = { pages: count }; + ariaLabel = + labelSelectAllPages || + intl.formatMessage(TRANSLATIONS.LABEL_SELECT_ALL_PAGES, i18nContext); + break; + + default: + allSelected = false; + checked = + (rowData && + !!selectedRows.find((element) => + equalityChecker(element, rowData), + )) || + false; + disabled = Boolean(allPagesSelectedManaged && allPagesSelected); + handleSelect = onSelect; + + i18nContext = { + count: count, + countPage: renderableRows.length, + pages: pages, + amountSelected: amountSelected, + selectAll: selectAll, + amountUnselected: (count || 0) - (amountSelected || 0), + amountUnselectedPage: renderableRows.length - (amountSelected || 0), + ...rowData, + }; + ariaLabel = + labelSelect || + intl.formatMessage(TRANSLATIONS.LABEL_SELECT, i18nContext); + } + + const onChange = () => handleSelect(rowData || {}); + return { checked, disabled, onChange, ariaLabel }; + }, [ + allPagesSelected, + allPagesSelectedManaged, + amountSelected, + count, + labelSelect, + pages, + renderableRows, + rowData, + selectAll, + selectedRows, + ]); + + return ( + + {selectAll && ariaLabel} + + ); +}; diff --git a/src/components/data/datagrid/datagridtable.tsx b/src/components/data/datagrid/datagridtable.tsx new file mode 100644 index 0000000..6d04b81 --- /dev/null +++ b/src/components/data/datagrid/datagridtable.tsx @@ -0,0 +1,26 @@ +import clsx from "clsx"; +import React, { useContext } from "react"; + +import { DataGridContext } from "./datagrid"; + +/** + * DataGrid table, represents tabular: information presented in a two-dimensional table comprised of rows and columns + * (fields) of cells containing data. + */ +export const DataGridTable: React.FC = ({ + children, +}) => { + const { tableLayout, titleId } = useContext(DataGridContext); + + return ( + + {children} +
+ ); +}; diff --git a/src/components/data/datagrid/datagridtbody.tsx b/src/components/data/datagrid/datagridtbody.tsx new file mode 100644 index 0000000..7e4d1fd --- /dev/null +++ b/src/components/data/datagrid/datagridtbody.tsx @@ -0,0 +1,59 @@ +import clsx from "clsx"; +import React, { useContext } from "react"; + +import { AttributeData } from "../../../lib"; +import { DataGridContext } from "./datagrid"; +import { DataGridContentCell } from "./datagridcontentcell"; +import { DataGridSelectionCheckbox } from "./datagridselectioncheckbox"; + +/** + * DataGrid table body, encapsulates a set of table rows indicating that they + * comprise the body of a table's (main) data. + */ +export const DataGridTBody: React.FC = () => { + const { + dataGridId, + page, + renderableFields, + renderableRows, + selectable, + selectedRows, + equalityChecker = (item1: AttributeData, item2: AttributeData) => + item1 === item2, + sortDirection, + sortField, + } = useContext(DataGridContext); + + return ( + + {renderableRows.map((rowData, index) => ( + + equalityChecker(element, rowData), + ), + })} + > + {selectable && ( + + + + )} + {renderableFields.map((field) => ( + + ))} + + ))} + + ); +}; diff --git a/src/components/data/datagrid/datagridthead.tsx b/src/components/data/datagrid/datagridthead.tsx new file mode 100644 index 0000000..8e1e614 --- /dev/null +++ b/src/components/data/datagrid/datagridthead.tsx @@ -0,0 +1,202 @@ +import clsx from "clsx"; +import React, { + CSSProperties, + useContext, + useEffect, + useRef, + useState, +} from "react"; + +import { + AttributeData, + field2Title, + formatMessage, + serializeForm, + useIntl, +} from "../../../lib"; +import { FormControl } from "../../form"; +import { Outline } from "../../icon"; +import { DataGridContext, scrollPaneRef } from "./datagrid"; +import { DataGridHeadingCell } from "./datagridheadingcell"; +import { TRANSLATIONS } from "./translations"; + +/** + * DataGrid table head, encapsulates a set of table rows, indicating that they + * comprise the head of a table with information about the table's columns. + */ +export const DataGridTHead: React.FC = () => { + const intl = useIntl(); + const onFilterTimeoutRef = useRef(); + const ref = useRef(null); + const [filterState, setFilterState] = useState(); + + const { + dataGridId, + filterable, + filterTransform, + height, + labelFilterField, + onFilter, + renderableFields, + selectable, + toolbarRef, + } = useContext(DataGridContext); + + // Sticky fix + useEffect(() => { + stickyFix(); + window.addEventListener("resize", stickyFix); + window.addEventListener("scroll", stickyFix); + () => { + window.removeEventListener("resize", stickyFix); + window.addEventListener("scroll", stickyFix); + }; + }); + + /** + * Fixes sticky behaviour due to `overflow-x: auto;` not being compatible + * with native sticky in all cases. + */ + const stickyFix = () => { + if (!ref.current || !scrollPaneRef.current) { + return; + } + + const node = ref.current; + const scrollPaneNode = scrollPaneRef.current; + const indicator = "mykn-datagrid__scrollpane--overflow-x"; + + // No need for fallback implementation, native behaviour should work if height is set of no overflow is applied.. + if (height || !scrollPaneNode?.classList?.contains(indicator)) { + node.style.top = ""; + return; + } + + requestAnimationFrame(() => { + node.style.top = ""; + const computedStyle = getComputedStyle(node); + const cssTop = parseInt(computedStyle.top); + + const boundingClientRect = node.getBoundingClientRect(); + const boundingTop = boundingClientRect.top; + const compensation = boundingTop * -1 + cssTop * 2; + + node.style.top = compensation + "px"; + }); + }; + + // Debounce filter + useEffect(() => { + const handler = () => { + // No filter state. + if (filterState === undefined) { + return; + } + onFilter(filterState || {}); + }; + onFilterTimeoutRef.current && clearTimeout(onFilterTimeoutRef.current); + onFilterTimeoutRef.current = setTimeout(handler, 300); + }, [filterState]); + + return ( + + {/* Captions */} + + {selectable && ( + + )} + {renderableFields.map((field) => ( + + {field2Title(field.name, { lowerCase: false })} + + ))} + + + {/* Filters */} + {filterable && ( + + {selectable && ( + + )} + {renderableFields.map((field) => { + const placeholder = field2Title(field.name, { lowerCase: false }); + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { options, valueTransform, ...context } = field; + + const _labelFilterField = labelFilterField + ? formatMessage(labelFilterField, context) + : intl.formatMessage(TRANSLATIONS.LABEL_FILTER_FIELD, context); + + return ( + + {field.filterable !== false && ( + } + form={`${dataGridId}-filter-form`} + name={field.filterLookup || field.name} + options={field.options} + min={ + !field.options && field.type === "number" ? 0 : undefined + } + pad={field.type === "daterange" ? "v" : undefined} + placeholder={placeholder} + type={field.type} + value={field.filterValue} + onChange={( + e: React.ChangeEvent< + HTMLInputElement | HTMLSelectElement + >, + ) => { + e.preventDefault(); + const data = serializeForm( + e.target.form as HTMLFormElement, + ) as AttributeData; + const _data = filterTransform + ? filterTransform(data) + : data; + + // Reset page on filter (length of dataset may change). + setFilterState(_data); + }} + /> + )} + + ); + })} + + )} + + ); +}; diff --git a/src/components/data/datagrid/datagridtoolbar.tsx b/src/components/data/datagrid/datagridtoolbar.tsx new file mode 100644 index 0000000..fa28717 --- /dev/null +++ b/src/components/data/datagrid/datagridtoolbar.tsx @@ -0,0 +1,160 @@ +import { useContext, useEffect, useState } from "react"; +import React from "react"; + +import { + field2Title, + formatMessage, + serializeForm, + ucFirst, + useIntl, +} from "../../../lib"; +import { ButtonProps } from "../../button"; +import { Form } from "../../form"; +import { Outline } from "../../icon"; +import { Modal } from "../../modal"; +import { Toolbar, ToolbarItem } from "../../toolbar"; +import { Body, H3 } from "../../typography"; +import { DataGridContext } from "./datagrid"; +import { DataGridSelectionCheckbox } from "./datagridselectioncheckbox"; +import { TRANSLATIONS } from "./translations"; + +/** + * DataGrid toolbar, shows selection actions and/or allows the user to select fields (columns). + */ +export const DataGridToolbar: React.FC = () => { + const { toolbarRef } = useContext(DataGridContext); + const intl = useIntl(); + const [selectFieldsModalState, setSelectFieldsModalState] = useState(false); + const [selectFieldsActiveState, setSelectFieldsActiveState] = useState< + Record + >({}); + + const { + allowSelectAll, + allowSelectAllPages, + fields, + fieldsSelectable, + labelSaveFieldSelection, + labelSelectFields, + selectable, + selectedRows, + selectionActions, + onFieldsChange, + } = useContext(DataGridContext); + + // Create map mapping `field.name` to active state. + useEffect(() => { + setSelectFieldsActiveState( + fields.reduce( + (acc, field) => ({ + ...acc, + [field.name]: field.active !== false, + }), + {}, + ), + ); + }, [fields]); + + const context = { + open: Boolean(selectFieldsModalState), + }; + + const _labelSelectFields = labelSelectFields + ? formatMessage(labelSelectFields, context) + : intl.formatMessage(TRANSLATIONS.LABEL_SELECT_FIELDS, context); + + const _labelSaveFieldSelection = labelSaveFieldSelection + ? formatMessage(labelSaveFieldSelection, context) + : intl.formatMessage(TRANSLATIONS.LABEL_SAVE_FIELD_SELECTION, context); + + const toolbarItems: ToolbarItem[] = [ + selectable && allowSelectAll ? ( + + ) : null, + + selectable && allowSelectAllPages ? ( + + ) : null, + + ...(selectionActions || []).map( + (buttonProps): ButtonProps => ({ + variant: "secondary", + ...buttonProps, + onClick: () => { + if (typeof buttonProps.onClick === "function") { + const customEvent = new CustomEvent("click", { + detail: selectedRows, + }); + buttonProps.onClick( + customEvent as unknown as React.MouseEvent, + ); + } + }, + }), + ), + + fieldsSelectable ? "spacer" : null, + fieldsSelectable + ? { + variant: "outline", + wrap: false, + onClick: () => setSelectFieldsModalState(true), + children: ( + <> + + {ucFirst(_labelSelectFields)} + + ), + } + : null, + ]; + + return ( +
+ + + {ucFirst(_labelSelectFields)}} + onClose={() => setSelectFieldsModalState(false)} + > + +
({ + label: field2Title(f.name, { lowerCase: false }), + value: f.name, + selected: Boolean(selectFieldsActiveState[f.name]), + })), + type: "checkbox", + onChange: (e: React.ChangeEvent) => { + const name = e.target.value; + setSelectFieldsActiveState({ + ...selectFieldsActiveState, + [name]: !selectFieldsActiveState[name], + }); + }, + }, + ]} + labelSubmit={ucFirst(_labelSaveFieldSelection)} + onSubmit={(e) => { + const form = e.target as HTMLFormElement; + const data = serializeForm(form); + const selectedFields = (data.fields || []) as string[]; + const newTypedFieldsState = fields.map((f) => ({ + ...f, + active: selectedFields.includes(f.name), + })); + onFieldsChange?.(newTypedFieldsState); + setSelectFieldsModalState(false); + }} + /> + + +
+ ); +};