From 978a7fbc97a0c60a14f67290eb6e3a2cdecf2c9f Mon Sep 17 00:00:00 2001 From: Jo du Plessis Date: Mon, 22 Jul 2024 12:00:04 +0200 Subject: [PATCH 1/5] add datagrid & datepicker --- .../data-grid/data-grid-cell-component.tsx | 90 +++ .../core/src/data-grid/data-grid-cell.tsx | 167 +++++ .../data-grid-default-cell-component.tsx | 13 + .../data-grid-header-cell-component.tsx | 69 +++ .../src/data-grid/data-grid-header-cell.tsx | 132 ++++ .../core/src/data-grid/data-grid-header.tsx | 85 +++ packages/core/src/data-grid/data-grid-row.tsx | 140 +++++ packages/core/src/data-grid/data-grid.css | 347 +++++++++++ packages/core/src/data-grid/data-grid.docs.ts | 8 + .../core/src/data-grid/data-grid.provider.tsx | 271 ++++++++ .../core/src/data-grid/data-grid.stories.tsx | 271 ++++++++ packages/core/src/data-grid/data-grid.tsx | 258 ++++++++ .../core/src/data-grid/data-grid.types.ts | 39 ++ packages/core/src/data-grid/data-grid.util.ts | 124 ++++ packages/core/src/data-grid/index.ts | 11 + packages/core/src/date-picker/date-cell.tsx | 43 ++ .../src/date-picker/date-picker-month.tsx | 143 +++++ .../src/date-picker/date-picker-weekdays.tsx | 33 + .../src/date-picker/date-picker.context.tsx | 28 + packages/core/src/date-picker/date-picker.css | 363 +++++++++++ .../core/src/date-picker/date-picker.docs.ts | 7 + .../src/date-picker/date-picker.stories.tsx | 576 ++++++++++++++++++ packages/core/src/date-picker/date-picker.tsx | 221 +++++++ packages/core/src/date-picker/index.ts | 11 + .../core/src/date-picker/month-picker.tsx | 125 ++++ .../src/date-picker/scrolling-date-picker.tsx | 141 +++++ .../src/date-picker/time-picker-column.tsx | 67 ++ .../core/src/date-picker/time-picker-time.tsx | 28 + packages/core/src/date-picker/time-picker.tsx | 172 ++++++ packages/core/src/date-picker/year-picker.tsx | 112 ++++ packages/core/src/index.ts | 2 + packages/core/src/styles/styles.css | 2 + 32 files changed, 4099 insertions(+) create mode 100644 packages/core/src/data-grid/data-grid-cell-component.tsx create mode 100644 packages/core/src/data-grid/data-grid-cell.tsx create mode 100644 packages/core/src/data-grid/data-grid-default-cell-component.tsx create mode 100644 packages/core/src/data-grid/data-grid-header-cell-component.tsx create mode 100644 packages/core/src/data-grid/data-grid-header-cell.tsx create mode 100644 packages/core/src/data-grid/data-grid-header.tsx create mode 100644 packages/core/src/data-grid/data-grid-row.tsx create mode 100644 packages/core/src/data-grid/data-grid.css create mode 100644 packages/core/src/data-grid/data-grid.docs.ts create mode 100644 packages/core/src/data-grid/data-grid.provider.tsx create mode 100644 packages/core/src/data-grid/data-grid.stories.tsx create mode 100644 packages/core/src/data-grid/data-grid.tsx create mode 100644 packages/core/src/data-grid/data-grid.types.ts create mode 100644 packages/core/src/data-grid/data-grid.util.ts create mode 100644 packages/core/src/data-grid/index.ts create mode 100644 packages/core/src/date-picker/date-cell.tsx create mode 100644 packages/core/src/date-picker/date-picker-month.tsx create mode 100644 packages/core/src/date-picker/date-picker-weekdays.tsx create mode 100644 packages/core/src/date-picker/date-picker.context.tsx create mode 100644 packages/core/src/date-picker/date-picker.css create mode 100644 packages/core/src/date-picker/date-picker.docs.ts create mode 100644 packages/core/src/date-picker/date-picker.stories.tsx create mode 100644 packages/core/src/date-picker/date-picker.tsx create mode 100644 packages/core/src/date-picker/index.ts create mode 100644 packages/core/src/date-picker/month-picker.tsx create mode 100644 packages/core/src/date-picker/scrolling-date-picker.tsx create mode 100644 packages/core/src/date-picker/time-picker-column.tsx create mode 100644 packages/core/src/date-picker/time-picker-time.tsx create mode 100644 packages/core/src/date-picker/time-picker.tsx create mode 100644 packages/core/src/date-picker/year-picker.tsx diff --git a/packages/core/src/data-grid/data-grid-cell-component.tsx b/packages/core/src/data-grid/data-grid-cell-component.tsx new file mode 100644 index 00000000..f7f893ab --- /dev/null +++ b/packages/core/src/data-grid/data-grid-cell-component.tsx @@ -0,0 +1,90 @@ +import { + IconLib, + Text, + addAlpha, + classNames, + cleanObject, + getForegroundColor, + getKey, + shadeColor, +} from '@fold-dev/core' +import React, { useEffect, useMemo, useRef, useState } from 'react' + +export type DataGridCellComponentProps = { + value: string | number + options?: any + icon?: string + color?: string + edit?: boolean + onEdit: (value) => void + onCancel: () => void +} + +export const DataGridCellComponent = (props: DataGridCellComponentProps) => { + const { value, options = {}, icon, color, edit, onEdit, onCancel } = props + const ref = useRef(null) + const inputRef = useRef(null) + const [text, setText] = useState(value) + const styles = useMemo(() => { + const foregroundColor = color ? color : null + const backgroundColor = color ? addAlpha(color, 0.1) : null + return cleanObject({ + color: foregroundColor, + backgroundColor, + }) + }, [color]) + const className = classNames({ + 'f-data-grid-cell-component': true, + 'f-row': true, + 'is-edit': edit, + 'is-color': !!color, + }) + + const handleKeyDown = (e: any) => { + const { isEnter, isEscape } = getKey(e) + if (isEnter) onEdit(text) + if (isEscape) onCancel() + } + + useEffect(() => { + setText(value) + }, [value]) + + useEffect(() => { + inputRef.current.select() + }, [edit]) + + return ( +
+ {!edit && ( + <> + + {value} + + + {!!icon && ( + + )} + + )} + + onEdit(text)} + onKeyDown={handleKeyDown} + onChange={(e) => setText(e.target.value)} + /> +
+ ) +} diff --git a/packages/core/src/data-grid/data-grid-cell.tsx b/packages/core/src/data-grid/data-grid-cell.tsx new file mode 100644 index 00000000..ed29bec6 --- /dev/null +++ b/packages/core/src/data-grid/data-grid-cell.tsx @@ -0,0 +1,167 @@ +import { CommonProps, ContextMenuContext, classNames, getKey, waitForRender } from '@fold-dev/core' +import React, { FunctionComponent, useContext, useEffect, useLayoutEffect, useRef, useState } from 'react' +import { DataGridCellComponent, DataGridContext, dispatchDataGridEvent, stopEvent } from '../' + +export type DataGridCellProps = { + value?: string | number + id: string + index: number + disabled?: boolean + edit?: boolean + color?: string + icon?: string + options?: any + disableSelect?: boolean + disableEdit?: boolean + component: FunctionComponent + selected?: boolean + onSelect: (value) => void +} & CommonProps + +export const DataGridCell = (props: DataGridCellProps) => { + const { id, index, disabled, color, icon, options, component = DataGridCellComponent, selected, onSelect } = props + const disableSelect = props.disableSelect || disabled + const disableEdit = props.disableEdit || disabled + const ref = useRef(null) + const { setMenu } = useContext(ContextMenuContext) + const { dragCol, setCellSelection, instanceId, columnTypes } = useContext(DataGridContext) + const Component: any = columnTypes ? columnTypes[index - 1] || component : component // -1 to compensate for gutter + const [value, setValue] = useState(props.value) + const [edit, setEdit] = useState(!!props.edit) + const cache = useRef(null) + const className = classNames({ + 'f-data-grid-cell': true, + 'is-disabled': disabled, + 'is-selected': selected && !disableSelect, + 'is-col-dragged': index == dragCol, + }) + + const refocus = () => { + // focus will return to the grid container + // so we put it back here + waitForRender(() => ref.current?.focus()) + } + + const editCell = () => { + if (!disableEdit) { + setEdit(true) + cache.current = value + } + } + + const handleKeyDown = (e) => { + const { isEnter, isSpace, isBackspace, isEscape, isRight, isLeft, isUp, isDown, isShift } = getKey(e) + const isDirection = isRight || isLeft || isUp || isDown + const [_, row, col] = id.split('-') + + if (selected) { + if (isEnter) { + if (edit) { + setEdit(false) + refocus() + } else { + stopEvent(e) + editCell() + } + } else if (isSpace) { + if (!edit) { + stopEvent(e) + editCell() + } + } else if (isBackspace) { + // we wait for state here rather + // TODO: optimistic update? + // setEdit(false) + // setValue('') + dispatchDataGridEvent('delete-cell', { instanceId, row: +row, col: +col }) + } else if (isEscape) { + setEdit(false) + refocus() + } else if (isDirection) { + // TODO: directional selection + // if (edit) stopEvent(e) + } else if (isShift) { + // TODO: shift/multi selection + } else { + editCell() + } + } + } + + const handleClick = (e) => { + if (!disableSelect) { + onSelect({ [id]: true }) + } + } + + const handleBlur = (e) => { + // TODO: should this deselect? + // setCellSelection({}) + // setEdit(false) + // setValue(value) + } + + const handleDoubleClick = (e) => { + editCell() + } + + const handleCancel = (e) => { + refocus() + setEdit(false) + setValue(cache.current) + } + + const handleEdit = (value) => { + const [_, row, col] = id.split('-') + setEdit(false) + refocus() + // NB: This is an optimistic update + setValue(value) + dispatchDataGridEvent('update-cell', { instanceId, value, row: +row, col: +col }) + } + + const handleContextMenuClick = (e) => { + e.preventDefault() + e.stopPropagation() + setMenu({ target: 'grid-cell', payload: props }, { x: e.clientX, y: e.clientY }) + } + + useEffect(() => { + if (selected) refocus() + }, [selected]) + + useEffect(() => { + setValue(props.value) + }, [props.value]) + + useLayoutEffect(() => { + const el: any = ref.current + if (el) el.addEventListener('contextmenu', handleContextMenuClick) + return () => el.removeEventListener('contextmenu', handleContextMenuClick) + }) + + return ( +
+ + {props.children} + +
+ ) +} diff --git a/packages/core/src/data-grid/data-grid-default-cell-component.tsx b/packages/core/src/data-grid/data-grid-default-cell-component.tsx new file mode 100644 index 00000000..bd794ac1 --- /dev/null +++ b/packages/core/src/data-grid/data-grid-default-cell-component.tsx @@ -0,0 +1,13 @@ +import { CommonProps, View } from '@fold-dev/core' +import React from 'react' + +export type DataGridDefaultCellComponentProps = {} & CommonProps + +export const DataGridDefaultCellComponent = (props: DataGridDefaultCellComponentProps) => ( + + {props.children} + +) diff --git a/packages/core/src/data-grid/data-grid-header-cell-component.tsx b/packages/core/src/data-grid/data-grid-header-cell-component.tsx new file mode 100644 index 00000000..e09fbfa5 --- /dev/null +++ b/packages/core/src/data-grid/data-grid-header-cell-component.tsx @@ -0,0 +1,69 @@ +import { CommonProps, ContextMenuContext, IconLib, Text } from '@fold-dev/core' +import React, { ReactNode, useContext } from 'react' + +export type DataGridHeaderCellComponentProps = { + id: string + label: string + sortOrder?: 'ASC' | 'DESC' + menu?: boolean + prefix?: ReactNode + suffix?: ReactNode + align?: 'left' | 'right' | 'center' + onClick: (value) => void +} & CommonProps + +export const DataGridHeaderCellComponent = (props: DataGridHeaderCellComponentProps) => { + const { id, label, sortOrder, menu, prefix, suffix, align = 'left', onClick } = props + const { setMenu } = useContext(ContextMenuContext) + + const handleMenuClick = (e) => { + e.preventDefault() + e.stopPropagation() + + setMenu( + { + target: 'column', + payload: { id, label, sortOrder }, + }, + { x: e.clientX, y: e.clientY } + ) + } + + return ( +
+ {prefix} + + {!!sortOrder && ( + + )} + +
+
+ {label} +
+
+ + {menu && ( + + )} + + {suffix} +
+ ) +} diff --git a/packages/core/src/data-grid/data-grid-header-cell.tsx b/packages/core/src/data-grid/data-grid-header-cell.tsx new file mode 100644 index 00000000..10fa4b74 --- /dev/null +++ b/packages/core/src/data-grid/data-grid-header-cell.tsx @@ -0,0 +1,132 @@ +import { + CommonProps, + ContextMenuContext, + ResizableRail, + classNames, + getBoundingClientRect, + useDrag, + windowObject, +} from '@fold-dev/core' +import React, { FunctionComponent, ReactElement, ReactNode, useContext, useLayoutEffect, useRef } from 'react' +import { DataGridContext } from './data-grid.provider' +import { DataGridHeaderCellComponent } from './data-grid-header-cell-component' +import { FOLD_DATA_GRID_DRAG, FOLD_DATA_GRID_GHOST } from './data-grid' + +export type DataGridHeaderCellProps = { + index?: number + id?: string + label?: string + disabled?: boolean + menu?: boolean + align?: 'left' | 'right' | 'center' + prefix?: ReactNode + suffix?: ReactNode + sortOrder?: 'ASC' | 'DESC' + component?: FunctionComponent + onClick?: (value) => void + onWidthChange?: (value) => void + disableWidthChange?: boolean + disableDrag?: boolean +} & CommonProps + +export const DataGridHeaderCell = (props: DataGridHeaderCellProps) => { + const ref = useRef(null) + const { setMenu } = useContext(ContextMenuContext) + const { setDragCol, dragCol, draggableColumns } = useContext(DataGridContext) + const { + index, + id, + label, + disabled, + menu, + align, + prefix, + suffix, + sortOrder, + component = DataGridHeaderCellComponent, + onClick, + onWidthChange, + disableWidthChange, + disableDrag, + } = props + const Component: any = component + const { setGhostElement, setCustomGhostElementRotation } = useDrag() + const timeoutRef = useRef(null) + const className = classNames({ + 'f-data-grid-header-cell': true, + 'is-disabled': disabled, + 'is-selected': false, + 'is-col-dragged': index == dragCol, + }) + + const handleChange = ({ x, y }) => { + const box = getBoundingClientRect(ref.current) + const value = x - box.left + const width = Math.min(Math.max(value, 50), 10000) + onWidthChange(width + 'px') + } + + const handleContextMenuClick = (e) => { + e.preventDefault() + e.stopPropagation() + setMenu({ target: 'header-cell', payload: props }, { x: e.clientX, y: e.clientY }) + } + + const handleMouseUp = (e) => { + clearTimeout(timeoutRef.current) + } + + const handleMouseDown = (e) => { + timeoutRef.current = setTimeout(() => { + if (draggableColumns && !disableDrag) { + windowObject[FOLD_DATA_GRID_DRAG] = index - 1 + setDragCol(index) + setCustomGhostElementRotation('0deg') + setGhostElement(` +
+ + ${label} + +
+ `) + } + }, 150) + } + + useLayoutEffect(() => { + const el: any = ref.current + if (el) el.addEventListener('contextmenu', handleContextMenuClick) + return () => el.removeEventListener('contextmenu', handleContextMenuClick) + }) + + return ( +
+ + {props.children} + + + {!disableWidthChange && ( + } + /> + )} +
+ ) +} diff --git a/packages/core/src/data-grid/data-grid-header.tsx b/packages/core/src/data-grid/data-grid-header.tsx new file mode 100644 index 00000000..2825b3bf --- /dev/null +++ b/packages/core/src/data-grid/data-grid-header.tsx @@ -0,0 +1,85 @@ +import { Checkbox, CoreViewProps, View, classNames } from '@fold-dev/core' +import React, { FunctionComponent, ReactNode, useContext, useEffect, useMemo, useState } from 'react' +import { DataGridContext } from './data-grid.provider' +import { DataGridHeaderCell } from './data-grid-header-cell' +import { DataGridDefaultCellComponent } from './data-grid-default-cell-component' +import { DataGridTypes } from './data-grid.types' + +export type DataGridHeaderProps = { + sticky?: boolean + columns: DataGridTypes.Column[] + hideCheckbox?: boolean + component?: FunctionComponent + resizableColumns?: boolean + onWidthChange?: (index, value) => void + onColumnClick?: (index, value) => void +} & CoreViewProps + +export const DataGridHeader = (props: DataGridHeaderProps) => { + const { columns, onWidthChange, hideCheckbox, component, resizableColumns, onColumnClick, ...rest } = props + const { + rowSelection, + setRowSelection, + cellSelection, + setCellSelection, + rowSelectionIndeterminate, + allRowsSelected, + selectAllRows, + maxRowsSelectable, + singleRowSelect, + dragCol, + setTotalColumns, + setWidths, + } = useContext(DataGridContext) + const id = `hr` + const selected = !!rowSelection[id] + const checked = useMemo( + () => allRowsSelected || rowSelectionIndeterminate, + [allRowsSelected, rowSelectionIndeterminate] + ) + const showCheckbox = useMemo(() => { + if (hideCheckbox) return false + return !singleRowSelect + }, [singleRowSelect, hideCheckbox]) + const className = classNames({ + 'f-data-grid-row': true, + 'is-header': true, + 'is-selected': selected, + }) + + useEffect(() => { + setTotalColumns(columns.length) + }, [columns.length]) + + return ( + + + {showCheckbox && ( + + )} + + + {columns.map((column: DataGridTypes.Column, index) => ( + onWidthChange(index, width)} + onClick={(e) => onColumnClick(index, column)} + component={component} + {...column} + /> + ))} + + ) +} diff --git a/packages/core/src/data-grid/data-grid-row.tsx b/packages/core/src/data-grid/data-grid-row.tsx new file mode 100644 index 00000000..6ea7f95f --- /dev/null +++ b/packages/core/src/data-grid/data-grid-row.tsx @@ -0,0 +1,140 @@ +import { Checkbox, CommonProps, classNames, useDrag, windowObject } from '@fold-dev/core' +import React, { useContext, useMemo, useRef } from 'react' +import { DataGridContext } from './data-grid.provider' +import { dispatchDataGridEvent } from './data-grid.util' +import { DataGridCell } from './data-grid-cell' +import { DataGridDefaultCellComponent } from './data-grid-default-cell-component' +import { DataGridTypes, FOLD_DATA_GRID_DRAG, FOLD_DATA_GRID_GHOST } from '../' + +export type DataGridRowProps = { + sticky?: boolean + columns: DataGridTypes.Cell[] + index: number + hideCheckbox?: boolean +} & CommonProps + +export const DataGridRow = (props: any) => { + const { columns, index, hideCheckbox, ...rest } = props + const { + rowSelection, + setRowSelection, + cellSelection, + setCellSelection, + rowSelectionIndeterminate, + allRowsSelected, + selectAllRows, + maxRowsSelectable, + singleRowSelect, + dragCol, + setDragRow, + dragRow, + draggableRows, + instanceId, + } = useContext(DataGridContext) + const id = `r-${index}` + const ref = useRef(null) + const timeoutRef = useRef(null) + const { setGhostElement, setCustomGhostElementRotation } = useDrag() + const selected = useMemo(() => !!rowSelection[id], [rowSelection]) + const showCheckbox = useMemo(() => !hideCheckbox, [hideCheckbox]) + const className = classNames({ + 'f-data-grid-row': true, + 'is-selected': selected, + 'is-draggable': draggableRows, + 'is-row-dragged': index == dragRow, + }) + + const handleCellSelection = (selection) => { + setCellSelection(selection) + } + + const updateRowSelection = (selection) => { + setRowSelection(selection) + dispatchDataGridEvent('row-selection', { instanceId, selection }) + } + + const handleRowSelect = (e) => { + if (selected) { + const updatedRowSelection = { ...rowSelection } + delete updatedRowSelection[id] + updateRowSelection(updatedRowSelection) + } else { + // if there is max rows specified + // + max rows must be <= current selection + const numRowsSelected = Object.keys(rowSelection).length + if (!!maxRowsSelectable && maxRowsSelectable <= numRowsSelected) return + + if (singleRowSelect) { + updateRowSelection({ [id]: true }) + } else { + updateRowSelection({ ...rowSelection, [id]: true }) + } + } + } + + const handleMouseUp = (e) => { + clearTimeout(timeoutRef.current) + } + + const handleMouseDown = (e) => { + if (e.target != e.currentTarget) return + + timeoutRef.current = setTimeout(() => { + if (draggableRows) { + windowObject[FOLD_DATA_GRID_DRAG] = index + setDragRow(index) + setCustomGhostElementRotation('0deg') + setGhostElement(` +
+ + ${columns[0].value} + +
+ `) + } + }, 150) + } + + return ( +
+ null} + component={DataGridDefaultCellComponent}> + {index + 1} + + {showCheckbox && ( + + )} + + + {columns.map((cell, index1) => { + const id = `c-${index}-${index1}` + const selected = !!cellSelection[id] + + return ( + + ) + })} +
+ ) +} diff --git a/packages/core/src/data-grid/data-grid.css b/packages/core/src/data-grid/data-grid.css new file mode 100644 index 00000000..24383ff7 --- /dev/null +++ b/packages/core/src/data-grid/data-grid.css @@ -0,0 +1,347 @@ +:root { + --f-data-grid-gutter-width: 100px; +} + +:root { + --f-data-grid-selected-width: 0.2rem; + --f-data-grid-border: none; + --f-data-grid-background: var(--f-color-surface); + --f-data-grid-columns: var(--f-data-grid-gutter-width) 300px 200px 200px 200px 200px 200px 200px 150px 150px 150px 150px 150px; + --f-data-grid-cell-height: 40px; + --f-data-grid-border-bottom: 1px solid var(--f-color-surface-stronger); + --f-data-grid-border-right: 1px solid var(--f-color-surface-stronger); + --f-data-grid-background-selected: var(--f-color-surface-stronger); + --f-data-grid-background-hover: var(--f-color-surface-strong); + --f-data-grid-background-header: var(--f-color-surface-strong); + --f-data-grid-background-header-hover: var(--f-color-surface-stronger); +} + +.f-data-grid-container { + width: 100%; + position: relative; +} + +.f-data-grid { + width: 100%; + background: var(--f-data-grid-background); + position: relative; + border: var(--f-data-grid-border); + padding: 0px; +} + +.f-data-grid.is-default { + overflow: auto; +} + +.f-data-grid__list { + padding-right: 0px; + position: relative; + overflow: auto; + scroll-behavior: smooth !important; +} + +.f-data-grid__scroll-spacer { + position: relative; + width: fit-content; + min-width: 100%; +} + +/* row */ +/* ----------------------------------------------- */ + +:root { + --f-data-grid-row-padding-left: 1rem; + --f-data-grid-row-padding-right: 5rem; +} + +.f-data-grid-row { + display: grid; + padding-left: var(--f-data-grid-row-padding-left); + padding-right: var(--f-data-grid-row-padding-right); + grid-template-columns: var(--f-data-grid-columns); + transition: grid-template-columns 0.1s ease-out; + border-bottom: var(--f-data-grid-border-bottom); + min-width: 100%; + width: fit-content; + height: var(--f-data-grid-cell-height); + position: relative; +} + +.f-data-grid-row.is-draggable { + cursor: grab; +} + +.f-data-grid-row:last-child { + border-bottom: none; +} + +.f-data-grid-row:focus-within { + outline: none; +} + +.f-data-grid-row.is-selected, +.f-data-grid-row.is-selected > .f-data-grid-cell { + background: var(--f-data-grid-background-selected); +} + +.f-data-grid:not(.is-dragging) .f-data-grid-row:hover { + background: var(--f-data-grid-background-hover); +} + +.f-data-grid:not(.is-dragging) .f-data-grid-row:hover .f-data-grid-cell, +.f-data-grid:not(.is-dragging) .f-data-grid-row:hover .f-data-grid-cell.is-selected { + background: var(--f-data-grid-background-hover); +} + +.f-data-grid-row.is-header, +.f-data-grid-row.is-header:hover { + background: var(--f-data-grid-background-header) !important; + z-index: 20; + position: sticky; + top: 0px; +} + +/* cell */ +/* ----------------------------------------------- */ + +.f-data-grid-cell { + position: relative; + user-select: none; + cursor: pointer; + border-right: var(--f-data-grid-border-right); +} + +.f-data-grid-row .f-data-grid-header-cell:last-child, +.f-data-grid-row .f-data-grid-cell:last-child { + border-right: none; +} + +.f-data-grid-cell:focus-within { + outline: none; +} + +/* states */ +/* ----------------------------------------------- */ + +.f-data-grid-cell.is-disabled { + cursor: not-allowed; +} + +.f-data-grid-cell.is-disabled > * { + opacity: 0.5; +} + +.f-data-grid-cell.is-selected::after { + content: ' '; + display: block; + position: absolute; + width: 100%; + height: 100%; + top: 0; + left: 0; + z-index: 1; + pointer-events: none; + border: var(--f-data-grid-selected-width) solid var(--f-color-accent); +} + +/* pin first */ +/* ----------------------------------------------- */ + +.f-data-grid.pin-first .f-data-grid-row .f-data-grid-cell:nth-child(2) { + position: sticky !important; + left: 0; + background: var(--f-data-grid-background); + z-index: 10; +} + +.f-data-grid.pin-first .f-data-grid-row .f-data-grid-cell.is-selected:nth-child(2) { + z-index: 11; +} + +.f-data-grid.pin-first .f-data-grid-row .f-data-grid-header-cell:nth-child(2) { + position: sticky !important; + left: 0; + background: var(--f-data-grid-background-header); + z-index: 2; +} + +.f-data-grid.pin-first .f-data-grid-row.is-selected .f-data-grid-cell:nth-child(2) { + background: var(--f-data-grid-background-selected); +} + +.f-data-grid.pin-first:not(.is-dragging) .f-data-grid-row:hover .f-data-grid-cell:nth-child(2) { + background: var(--f-data-grid-background-hover); +} + +/* pin last */ +/* ----------------------------------------------- */ + +.f-data-grid.pin-last .f-data-grid-row .f-data-grid-header-cell:nth-last-child(1), +.f-data-grid.pin-last .f-data-grid-row .f-data-grid-cell:nth-last-child(1) { + position: sticky; + right: 0px; + background: var(--f-data-grid-background); + z-index: 10; + border-left: var(--f-data-grid-border-right); + margin-left: -1px; +} + +.f-data-grid.pin-last .f-data-grid-row .f-data-grid-cell.is-selected:nth-last-child(1) { + z-index: 11; +} + +.f-data-grid.pin-last .f-data-grid-row .f-data-grid-header-cell:nth-last-child(1) { + background: var(--f-data-grid-background-header); +} + +.f-data-grid.pin-last .f-data-grid-row.is-selected .f-data-grid-cell:nth-last-child(1) { + background: var(--f-data-grid-background-selected); +} + +.f-data-grid.pin-last:not(.is-dragging) .f-data-grid-row:hover .f-data-grid-cell:nth-last-child(1) { + background: var(--f-data-grid-background-hover); +} + +/* gutter */ + +:root { + --f-data-grid-gutter-number-display: block; + --f-data-grid-gutter-number-left: 0px; + --f-data-grid-gutter-number-size: var(--f-font-size-md); +} + +.f-data-grid-row__gutter-number { + display: var(--f-data-grid-gutter-number-display); + position: absolute; + left: var(--f-data-grid-gutter-number-left); + top: 50%; + transform: translateY(-50%); + pointer-events: none; + user-select: none; + font-size: var(--f-data-grid-gutter-number-size); + color: var(--f-color-text-weakest); +} + +.f-data-grid-row > .f-data-grid-cell:first-child { + pointer-events: none; + user-select: none; +} + +.f-data-grid-row > .f-data-grid-cell:first-child .f-checkbox { + pointer-events: all; +} + +/* header cell */ +/* ----------------------------------------------- */ + +.f-data-grid-header-cell { + position: relative; + user-select: none; + border-right: var(--f-data-grid-border-right); + background: var(--f-data-grid-background-header); +} + +/* default header cell */ +/* ----------------------------------------------- */ + +.f-data-grid-header-cell-component { + position: absolute; + cursor: pointer; + inset: 0; + justify-content: flex-start; + padding: 0 var(--f-space-3); + gap: var(--f-space-2); +} + +.f-data-grid-header-cell-component:hover { + background: var(--f-data-grid-background-header-hover); +} + +.f-data-grid-header-cell-component .f-icon { + color: var(--f-color-text-weak); +} + +.f-data-grid-header-cell-component .f-data-grid-header-cell-component__sort-icon { + stroke-width: 3px; + color: var(--f-color-text-weak); +} + +.f-data-grid-header-cell-component__text { + flex: 1; + justify-content: flex-start; + overflow: hidden; + white-space: nowrap; + position: relative; +} + +/* default text cell */ +/* ----------------------------------------------- */ + +.f-data-grid-cell-component { + position: absolute; + inset: 0; + justify-content: flex-start; + padding: 0 var(--f-space-3); + background: transparent; + overflow: hidden; +} + +.f-data-grid-cell-component.is-edit input { + padding: 0 var(--f-space-3); + border: 0; + position: absolute; + background: transparent; + inset: 0; + font-family: var(--f-font-body); + color: var(--f-color-text); + outline: none; +} + +.f-data-grid-cell-component.is-edit { +} + +/* colors */ + +.f-data-grid-cell-component.is-color, +.f-data-grid-cell-component.is-color.is-edit > *, +.f-data-grid-cell-component.is-color > * { + color: inherit; +} + +/* dragging */ +/* ----------------------------------------------- */ + +.f-data-grid.is-dragging .f-data-grid-cell > *, +.f-data-grid.is-dragging .f-data-grid-header-cell > * { + pointer-events: none; +} + +.f-data-grid.is-dragging, +.f-data-grid.is-dragging * { + cursor: grabbing !important; +} + +.f-data-grid-row .f-data-grid-cell.is-col-dragged::before, +.f-data-grid-row .f-data-grid-header-cell.is-col-dragged::before { + content: " "; + position: absolute; + pointer-events: none; + background: var(--f-color-accent); + z-index: 1000; + top: 0px; + height: calc(100% + 1px); + right: -1px; + width: 0.2rem; +} + +.f-data-grid-row.is-row-dragged::before { + content: " "; + position: absolute; + pointer-events: none; + background: var(--f-color-accent); + z-index: 1000; + top: -1px; + height: 0.2rem; + right: 0px; + width: 100%; +} diff --git a/packages/core/src/data-grid/data-grid.docs.ts b/packages/core/src/data-grid/data-grid.docs.ts new file mode 100644 index 00000000..0e473dfb --- /dev/null +++ b/packages/core/src/data-grid/data-grid.docs.ts @@ -0,0 +1,8 @@ +export const docs = { + title: 'Data Grid', + subtitle: 'A robust & flexible Data Grid component engineered to handle diverse datasets with ease.', + description: + 'The Data Grid component enables you to customize & extend virtually every part of it, enabling you accommodate a wide variety of data types.', + experimental: true, + pro: true, +} diff --git a/packages/core/src/data-grid/data-grid.provider.tsx b/packages/core/src/data-grid/data-grid.provider.tsx new file mode 100644 index 00000000..b58c11cb --- /dev/null +++ b/packages/core/src/data-grid/data-grid.provider.tsx @@ -0,0 +1,271 @@ +import { documentObject, getKey, useEvent, useId, windowObject } from '@fold-dev/core' +import React, { + FunctionComponent, + ReactElement, + ReactNode, + createContext, + useEffect, + useMemo, + useRef, + useState, +} from 'react' +import { dispatchDataGridEvent, useDataGridEvent } from './data-grid.util' +import { FOLD_DATA_GRID_ROW_HEIGHT } from './data-grid' + +export type DataGridProviderProps = { + id?: string + columnWidths: any[] + columnTypes?: (FunctionComponent | undefined)[] + defaultCellSelection?: any + defaultRowSelection?: any + draggableColumns?: boolean + draggableRows?: boolean + maxRowsSelectable?: number + singleRowSelect?: boolean + onSelect?: (value) => void + children?: ReactNode +} + +export const DataGridContext = createContext({}) + +export const DataGridProvider = (props: DataGridProviderProps) => { + const { + id, + children, + defaultCellSelection = {}, + defaultRowSelection = {}, + draggableColumns, + draggableRows, + maxRowsSelectable = 0, + singleRowSelect = false, + columnWidths, + columnTypes, + onSelect, + } = props + const ref = useRef(null) + const instanceId = useId(id) + const selectionLockRef = useRef(false) + const iid = instanceId + const cellSelectionCache = useRef(null) + const [isShift, setIsShift] = useState(false) + const [dragCol, setDragCol] = useState(-1) + const [dragRow, setDragRow] = useState(-1) + const [cellSelection, setCellSelection] = useState(defaultCellSelection) + const [rowSelection, setRowSelection] = useState(defaultRowSelection) + const [totalRows, setTotalRows] = useState(0) + const [totalColumns, setTotalColumns] = useState(0) + const { allRowsSelected, rowSelectionIndeterminate } = useMemo(() => { + const totalSelected = Object.keys(rowSelection).length + const allRowsSelected = totalSelected == totalRows + const rowSelectionIndeterminate = totalSelected > 0 && totalSelected != totalRows + return { allRowsSelected, rowSelectionIndeterminate } + }, [rowSelection, totalRows]) + const styles: any = useMemo(() => { + return { '--f-data-grid-columns': `var(--f-data-grid-gutter-width) ${columnWidths.join(' ')}` } + }, [columnWidths]) + + const selectionLock = (locked) => { + selectionLockRef.current = locked + } + + const refocus = () => { + ref.current.focus() + } + + const handleKeyUp = (e) => { + setIsShift(false) + } + + const handleBlur = (e) => { + cellSelectionCache.current = cellSelection + } + + const handleDocumentClick = (e) => { + if (!ref.current.contains(e?.target)) { + setCellSelection({}) + } + } + + const handleFocus = (e) => { + if (cellSelectionCache.current) { + setCellSelection(cellSelectionCache.current) + cellSelectionCache.current = null + } + } + + const handleKeyDown = (e) => { + const { isShift, isEscape } = getKey(e) + setIsShift(isShift) + if (isEscape) { + // TODO: is this correct UX? + // setCellSelection({}) + } + } + + const scrollIntoView = (row, col) => { + const rowHeight = windowObject[FOLD_DATA_GRID_ROW_HEIGHT] + const id = instanceId + '-scrollview' + + // only virtual lists will have row height + // the default list will be scrolled used the focus() logic on each cell + if (!!rowHeight) { + const container = documentObject.getElementById(id) + const amount = container.offsetHeight / 2 + const top = row * rowHeight + + // scroll up + // add rowHeight to make it go right to the edge + if (top + rowHeight < container.scrollTop) { + container.scrollTo({ + top: top - amount, + behaviour: 'smooth', + }) + } + + // scroll down + if (top > container.scrollTop + container.offsetHeight) { + container.scrollTo({ + top: top + amount, + behaviour: 'smooth', + }) + } + } + } + + const handleKeyDownContainer = (e) => { + const { isLeft, isRight, isUp, isDown } = getKey(e) + + if ((isLeft || isRight || isUp || isDown) && selectionLockRef.current == false) { + e.preventDefault() + e.stopPropagation() + + const selection = Object.keys(cellSelection) + + if (selection[0]) { + const parts = selection[0].split('-') + const row = +parts[1] + const col = +parts[2] + + if (isDown) { + const nextRow = row + 1 > totalRows ? 0 : row + 1 + setCellSelection({ [`c-${nextRow}-${col}`]: true }) + scrollIntoView(nextRow, col) + } + + if (isUp) { + const prevRow = row - 1 < 0 ? totalRows - 1 : row - 1 + setCellSelection({ [`c-${prevRow}-${col}`]: true }) + scrollIntoView(prevRow, col) + } + + if (isRight) { + const nextCol = col + 1 > totalColumns ? 0 : col + 1 + setCellSelection({ [`c-${row}-${nextCol}`]: true }) + scrollIntoView(row, nextCol) + } + + if (isLeft) { + const prevCol = col - 1 < 0 ? totalColumns - 1 : col - 1 + setCellSelection({ [`c-${row}-${prevCol}`]: true }) + scrollIntoView(row, prevCol) + } + } else if (cellSelectionCache.current) { + setCellSelection(cellSelectionCache.current) + } else { + setCellSelection({ 'c-0-0': true }) + } + } + } + + const handleCellSelection = (selection) => { + setCellSelection(isShift ? { ...cellSelection, ...selection } : selection) + } + + const updateRowSelection = (selection) => { + setRowSelection(selection) + dispatchDataGridEvent('row-selection', { instanceId, selection }) + } + + const selectAllRows = () => { + if (allRowsSelected) { + updateRowSelection({}) + } else { + // if there is max rows specified + // + max rows must = total rows + if (!!maxRowsSelectable && maxRowsSelectable != totalRows) return + const ids = {} + new Array(totalRows).fill(null).map((_, index) => { + const id = `r-${index}` + ids[id] = true + }) + updateRowSelection(ids) + } + } + + const handleClearRowSelection = ({ detail: { instanceId } }) => { + if (iid == instanceId) setRowSelection({}) + } + + const handleSelectRows = ({ detail: { instanceId, ...rest } }) => { + if (iid == instanceId) setRowSelection({ ...rest }) + } + + const handleSelectCells = ({ detail: { instanceId, ...rest } }) => { + if (iid == instanceId) setCellSelection({ ...rest }) + } + + useDataGridEvent('clear-row-selection', handleClearRowSelection) + useDataGridEvent('select-rows', handleSelectRows) + useDataGridEvent('select-cells', handleSelectCells) + + useEvent('keydown', handleKeyDown) + useEvent('keyup', handleKeyUp) + useEvent('click', handleDocumentClick) + + useEffect(() => { + if (onSelect) { + onSelect({ + rows: rowSelection, + cells: cellSelection, + }) + } + }, [rowSelection, cellSelection]) + + return ( + +
+ {children} +
+
+ ) +} diff --git a/packages/core/src/data-grid/data-grid.stories.tsx b/packages/core/src/data-grid/data-grid.stories.tsx new file mode 100644 index 00000000..3f582b58 --- /dev/null +++ b/packages/core/src/data-grid/data-grid.stories.tsx @@ -0,0 +1,271 @@ +import { + Button, + FIBin, + FIX, + Icon, + Menu, + MenuProvider, + MenuSection, + Portal, + Text, + View, + arrayMove, + useDialog, +} from '@fold-dev/core' +import React, { useLayoutEffect, useState } from 'react' +import { DataGrid, DataGridHeader, DataGridProvider, DataGridTypes, dataGridState, dispatchDataGridEvent } from '../' +import * as data from '../../dummy-data' +import '../common/common.css' +import './data-grid.css' + +export default { + title: 'Pro/DataGrid', + component: <>, + excludeStories: 'docs', +} + +export const Usage = () => { + const [columnWidths, setColumnWidths] = useState(data.widths) + const [columns, setColumns] = useState(data.columns) + const [footerColumns, setFooterColumns] = useState(data.footer) + const [columnTypes, setColumnTypes] = useState(data.columnTypes) + //const [rows, setRows] = useState(data.rows) + const [rows, setRows] = useState(data.lessRows) + const { setDialog, closeDialog } = useDialog() + + const handleColumnMove = ({ origin, target }) => { + dataGridState({ + columnWidths, + setColumnWidths, + columnTypes, + setColumnTypes, + columns, + setColumns, + footerColumns, + setFooterColumns, + rows, + setRows, + }).handleColumnMove({ origin, target }) + } + + const handleRowMove = ({ origin, target }) => { + dataGridState({ + columnWidths, + setColumnWidths, + columnTypes, + setColumnTypes, + columns, + setColumns, + footerColumns, + setFooterColumns, + rows, + setRows, + }).handleRowMove({ origin, target }) + } + + const handleColumnClick = (index, column: DataGridTypes.Column) => { + dataGridState({ + columnWidths, + setColumnWidths, + columnTypes, + setColumnTypes, + columns, + setColumns, + footerColumns, + setFooterColumns, + rows, + setRows, + }).handleColumnClick(index, column) + } + + const handleCellUpdate = ({ value, row, col }) => { + dataGridState({ + columnWidths, + setColumnWidths, + columnTypes, + setColumnTypes, + columns, + setColumns, + footerColumns, + setFooterColumns, + rows, + setRows, + }).handleCellUpdate({ value, row, col }) + } + + const handleCellDelete = ({ row, col }) => { + dataGridState({ + columnWidths, + setColumnWidths, + columnTypes, + setColumnTypes, + columns, + setColumns, + footerColumns, + setFooterColumns, + rows, + setRows, + }).handleCellDelete({ row, col }) + } + + return ( + <> + + ( + + Menu for: {target} + + )}> + null}> + + setColumnWidths(columnWidths.map((w, i) => (i == index ? width : w))) + } + /> + } + footer={ + + } + pinFirst + pinLast + onCellUpdate={handleCellUpdate} + onCellDelete={handleCellDelete} + onColumnMove={handleColumnMove} + onRowMove={handleRowMove} + onScroll={(e) => null} + toolbar={({ rowSelection, cellSelection }) => ( + + + {Object.values(rowSelection).length}{' '} + {Object.values(rowSelection).length == 1 ? 'row' : 'rows'} selected + + { + setDialog({ + title: 'Are you sure?', + description: 'This action cannot be undone.', + portal: Portal, + footer: ( + + + + + ), + }) + }} + /> + + )} + /> + + + + ) +} diff --git a/packages/core/src/data-grid/data-grid.tsx b/packages/core/src/data-grid/data-grid.tsx new file mode 100644 index 00000000..91b84892 --- /dev/null +++ b/packages/core/src/data-grid/data-grid.tsx @@ -0,0 +1,258 @@ +import { + CoreViewProps, + View, + classNames, + documentObject, + positionDOMElement, + useDrag, + useEvent, + windowObject, +} from '@fold-dev/core' +import React, { ReactNode, useContext, useEffect, useMemo, useRef, useState } from 'react' +import { DataGridRow } from './data-grid-row' +import { DataGridContext } from './data-grid.provider' +import { DataGridTypes } from './data-grid.types' +import { useDataGridEvent } from './data-grid.util' + +export type DataGridProps = { + variant?: 'virtual' | 'default' + virtual?: { + rows: number + rowHeight: number + paddingTop: number + paddingBottom: number + } + hideCheckbox?: boolean + useFoldScroll?: boolean + rows: DataGridTypes.Cell[][] + footer?: ReactNode + header?: ReactNode + pinFirst?: boolean + pinLast?: boolean + onCellUpdate?: (value) => void + onCellDelete?: (value) => void + onColumnMove?: (value) => void + onRowMove?: (value) => void + onScroll?: (value) => void + toolbar?: (selection: any) => ReactNode +} & CoreViewProps + +let xCache = 0 +let yCache = 0 +let moveThreshold = 2 + +export const FOLD_DATA_GRID_GHOST = 'FOLD_DATA_GRID_GHOST' +export const FOLD_DATA_GRID_DRAG = 'FOLD_DATA_GRID_DRAG' +export const FOLD_DATA_GRID_ROW_HEIGHT = 'FOLD_DATA_GRID_ROW_HEIGHT' + +windowObject[FOLD_DATA_GRID_GHOST] = null +windowObject[FOLD_DATA_GRID_DRAG] = null +windowObject[FOLD_DATA_GRID_ROW_HEIGHT] = null + +export const DataGrid = (props: DataGridProps) => { + const { + variant = 'default', + virtual = { + rows: 10, + rowHeight: 40, + paddingTop: 40, + paddingBottom: 0, + }, + hideCheckbox, + useFoldScroll = true, + rows = [], + header, + footer, + pinFirst, + pinLast, + onCellUpdate, + onCellDelete, + onColumnMove, + onRowMove, + onScroll, + toolbar, + style = {}, + ...rest + } = props + const { + cellSelection, + setCellSelection, + rowSelection, + setRowSelection, + dragRow, + setDragRow, + dragCol, + setDragCol, + setTotalRows, + instanceId, + } = useContext(DataGridContext) + const iid = instanceId + const isDefault = variant == 'default' + const isVirtual = variant == 'virtual' + const scrollRef = useRef(null) + const { getGhostElement } = useDrag() + const [scrollTop, setScrollTop] = useState(0) + const isDragging = useMemo(() => dragCol != -1 || dragRow != -1, [dragRow, dragCol]) + const className = classNames( + { + 'f-data-grid': true, + 'f-scrollbar': useFoldScroll, + 'is-default': isDefault, + 'is-dragging': isDragging, + 'pin-first': pinFirst, + 'pin-last': pinLast, + }, + [props.className] + ) + + const handleVirtualScroll = (e) => { + setScrollTop(e.currentTarget.scrollTop) + if (onScroll) onScroll(e) + } + + const handleMouseUp = (e) => { + const origin = windowObject[FOLD_DATA_GRID_DRAG] + + if (dragCol != -1) { + onColumnMove({ + origin, + target: origin < dragCol ? dragCol - 1 : dragCol, + }) + } + if (dragRow != -1) { + onRowMove({ + origin, + target: origin < dragRow ? dragRow - 1 : dragRow, + }) + } + + // reset drag parameters + setDragCol(-1) + setDragRow(-1) + const ghostElement = getGhostElement() + ghostElement.style.display = 'none' + } + + const handleMouseMove = (e) => { + if (!isDragging) return + + const rowDragged = dragRow != -1 + const colDragged = dragCol != -1 + let direction: 'right' | 'left' | 'up' | 'down' = undefined + + const mouseY = e.clientY + const mouseX = e.clientX + + if (mouseX < xCache - moveThreshold) direction = 'left' + if (mouseX > xCache - moveThreshold) direction = 'right' + if (mouseY < yCache - moveThreshold) direction = 'up' + if (mouseY > yCache - moveThreshold) direction = 'down' + + xCache = e.pageX + yCache = e.pageY + + const element = documentObject.elementFromPoint(mouseX, mouseY) + const index = rowDragged ? +element.dataset.row : colDragged ? +element.dataset.col : -1 + + if (colDragged) setDragCol(index) + if (rowDragged) setDragRow(index) + + const ghostElement = getGhostElement() + ghostElement.style.display = 'block' + ghostElement.style.opacity = '1' + + positionDOMElement(mouseX, mouseY, ghostElement, () => {}) + } + + const handleCellDelete = ({ detail: { instanceId, ...rest } }) => { + if (iid == instanceId) onCellDelete({ ...rest }) + } + + const handleCellUpdate = ({ detail: { instanceId, ...rest } }) => { + if (iid == instanceId) onCellUpdate({ ...rest }) + } + + const numItems = rows.length + const itemHeight = virtual.rowHeight + const maxHeight = virtual.rowHeight * (virtual.rows + 1) + const innerHeight = numItems * itemHeight + virtual.paddingTop + const height = (innerHeight < maxHeight ? innerHeight : maxHeight) + virtual.paddingBottom + const startIndex = Math.floor(scrollTop / itemHeight) + const endIndex = Math.min(numItems - 1, Math.floor((scrollTop + height) / itemHeight)) + const items: any = useMemo(() => { + const items: any = [] + + for (let index = startIndex; index <= endIndex; index++) { + items.push( + + ) + } + + return items + }, [startIndex, endIndex, numItems, itemHeight, rows]) + + useEvent('mousemove', handleMouseMove) + useEvent('mouseup', handleMouseUp) + + useDataGridEvent('delete-cell', handleCellDelete) + useDataGridEvent('update-cell', handleCellUpdate) + + useEffect(() => { + if (variant == 'virtual') windowObject[FOLD_DATA_GRID_ROW_HEIGHT] = virtual.rowHeight + }, [variant]) + + useEffect(() => { + setTotalRows(rows.length) + }, [rows.length]) + + return ( + <> + + {isVirtual && ( +
+
+ {header} + {items} +
+ {footer} +
+ )} + + {isDefault && ( + <> + {header} + {rows.map((columns, index) => ( + + ))} + {footer} + + )} +
+ + {toolbar ? toolbar({ rowSelection, cellSelection }) : null} + + ) +} diff --git a/packages/core/src/data-grid/data-grid.types.ts b/packages/core/src/data-grid/data-grid.types.ts new file mode 100644 index 00000000..a85be957 --- /dev/null +++ b/packages/core/src/data-grid/data-grid.types.ts @@ -0,0 +1,39 @@ +import { FunctionComponent, ReactNode } from 'react' + +export namespace DataGridTypes { + export type Column = { + index?: number + id: string + label: string + disabled?: boolean + menu?: boolean + align?: 'left' | 'right' | 'center' + prefix?: ReactNode + suffix?: ReactNode + sortOrder?: 'ASC' | 'DESC' + sortFunction?: (index: number) => (a, b) => number + component?: FunctionComponent + sticky?: boolean + onClick?: (value) => void + onWidthChange?: (value) => void + disableWidthChange?: boolean + disableDrag?: boolean + } + + export type Cell = { + value?: string | number + id?: string + index?: number + disabled?: boolean + edit?: boolean + sticky?: boolean + color?: string + icon?: string + options?: any + disableSelect?: boolean + disableEdit?: boolean + component?: FunctionComponent + selected?: boolean + onSelect?: (value) => void + } +} diff --git a/packages/core/src/data-grid/data-grid.util.ts b/packages/core/src/data-grid/data-grid.util.ts new file mode 100644 index 00000000..a83cffe8 --- /dev/null +++ b/packages/core/src/data-grid/data-grid.util.ts @@ -0,0 +1,124 @@ +import { arrayMove, documentObject, windowObject } from '@fold-dev/core' +import { FunctionComponent, useEffect } from 'react' +import { DataGridTypes } from './data-grid.types' + +export type DataGridEventName = + | 'row-selection' + | 'clear-row-selection' + | 'update-cell' + | 'delete-cell' + | 'select-rows' + | 'select-cells' + +export const dispatchDataGridEvent = (eventName: DataGridEventName, data: any = {}) => + documentObject.dispatchEvent(new CustomEvent('data-grid' + eventName, { detail: data })) + +export const useDataGridEvent = (event: DataGridEventName, handler, passive = false) => { + useEffect(() => { + documentObject.addEventListener('data-grid' + event, handler, passive) + return () => documentObject.removeEventListener('data-grid' + event, handler) + }) +} + +const sortFunc = (index) => (a, b) => { + if (typeof a[index].value == 'number') { + return a[index].value - b[index].value + } else { + return a[index].value.localeCompare(b[index].value) + } +} + +export const dataGridState: any = ({ + columnWidths, + setColumnWidths, + columnTypes, + setColumnTypes, + columns, + setColumns, + footerColumns, + setFooterColumns, + rows, + setRows, +}) => ({ + handleColumnMove: ({ origin, target }) => { + // this isn't present in all examples + if (columnWidths) setColumnWidths(arrayMove(columnWidths, origin, target)) + if (columnTypes) setColumnTypes(arrayMove(columnTypes, origin, target)) + setColumns(arrayMove(columns, origin, target)) + setFooterColumns(arrayMove(footerColumns, origin, target)) + setRows(rows.map((row) => arrayMove(row, origin, target))) + }, + + handleRowMove: ({ origin, target }) => { + setRows(arrayMove(rows, origin, target)) + }, + + handleColumnClick: (index, column: DataGridTypes.Column) => { + const { sortFunction } = column + + // if ASC is already set, then we want to reverse it for DESC + // otherwise if it's DESC or undefined, we simply sort it normally + if (column.sortOrder == 'ASC') { + setRows([...rows].reverse()) + } else { + if (!!sortFunction) { + setRows([...rows].sort(sortFunction(index))) + } else { + setRows([...rows].sort(sortFunc(index))) + } + } + + // update sort order + setColumns( + columns.map((c: any, i) => { + if (index == i) { + return { + ...c, + sortOrder: c.sortOrder == 'ASC' ? 'DESC' : 'ASC', + } + } else { + return { + ...c, + sortOrder: undefined, + } + } + }) + ) + }, + + handleCellUpdate: ({ value, row, col }) => { + setRows( + rows.map((r, i1) => { + if (i1 == row) { + return r.map((c, i2) => { + if (i2 == col) { + return { ...c, value } + } else { + return c + } + }) + } else { + return r + } + }) + ) + }, + + handleCellDelete: ({ row, col }) => { + setRows( + rows.map((r, i1) => { + if (i1 == row) { + return r.map((c, i2) => { + if (i2 == col) { + return { ...c, value: '' } + } else { + return c + } + }) + } else { + return r + } + }) + ) + }, +}) diff --git a/packages/core/src/data-grid/index.ts b/packages/core/src/data-grid/index.ts new file mode 100644 index 00000000..54102ded --- /dev/null +++ b/packages/core/src/data-grid/index.ts @@ -0,0 +1,11 @@ +export * from './data-grid-cell' +export * from './data-grid-cell-component' +export * from './data-grid-default-cell-component' +export * from './data-grid-header' +export * from './data-grid-header-cell' +export * from './data-grid-header-cell-component' +export * from './data-grid-row' +export * from './data-grid.provider' +export * from './data-grid' +export * from './data-grid.types' +export * from './data-grid.util' diff --git a/packages/core/src/date-picker/date-cell.tsx b/packages/core/src/date-picker/date-cell.tsx new file mode 100644 index 00000000..825ccb82 --- /dev/null +++ b/packages/core/src/date-picker/date-cell.tsx @@ -0,0 +1,43 @@ +import { CommonProps, Text, TextProps, classNames } from '@fold-dev/core' +import React from 'react' + +export type DateCellProps = { + disabled?: boolean + unavailable?: boolean + selected?: boolean + today?: boolean + pending?: boolean + pendingStart?: boolean + pendingEnd?: boolean + weekend?: boolean + start?: boolean + end?: boolean +} & CommonProps + +export const DateCell = (props: DateCellProps) => { + const { disabled, selected, weekend, unavailable, pending, today, start, end, ...rest } = props + const className = classNames( + { + 'f-date-cell': true, + 'f-row': true, + 'f-buttonize': true, + 'is-selected': selected && !unavailable, + 'is-unavailable': unavailable, + 'is-weekend': weekend, + 'is-pending': pending, + 'is-disabled': disabled, + 'is-today': today, + 'is-start': start, + 'is-end': end, + }, + [props.className] + ) + + return ( + + ) +} diff --git a/packages/core/src/date-picker/date-picker-month.tsx b/packages/core/src/date-picker/date-picker-month.tsx new file mode 100644 index 00000000..bb919e42 --- /dev/null +++ b/packages/core/src/date-picker/date-picker-month.tsx @@ -0,0 +1,143 @@ +import { CoreViewProps, Size, View, classNames } from '@fold-dev/core' +import React, { useContext, useMemo } from 'react' +import { FDate, getStartAndEndOfWeek, isDayInsideRange } from '../helpers' +import { DateCell, DateCellProps } from './date-cell' +import { DateSelection } from './date-picker' +import { DatePickerContext } from './date-picker.context' + +export type DatePickerMonthProps = { + size?: Size + selectWeek?: boolean + selection?: DateSelection[] + disabled?: DateSelection[] + weekendDays?: number[] + date?: Date + offsetDays?: number + renderDay?: any + onChange?: any + dateCellProps?: DateCellProps +} & Omit + +export const DatePickerMonth = (props: DatePickerMonthProps) => { + const { + selectWeek, + size, + renderDay, + selection = [], + disabled = [], + weekendDays = [0, 6], + date = new Date(), + offsetDays = 0, + onChange, + dateCellProps = {}, + ...rest + } = props + const { dateRangeSelection, setDateRangeSelection, pendingRowSelection, setPendingRowSelection } = + useContext(DatePickerContext) + const className = classNames( + { + 'f-month': true, + }, + [props.className] + ) + + const days = useMemo(() => { + const monthDay = new Date(date.getFullYear(), date.getMonth(), 1) + const weekdayOfFirstDay = monthDay.getDay() - offsetDays + const days = [] + + for (let dayNumber = 0; dayNumber < 42; dayNumber++) { + if (dayNumber === 0 && weekdayOfFirstDay === 0) { + monthDay.setDate(monthDay.getDate() - 7) + } else if (dayNumber === 0) { + monthDay.setDate(monthDay.getDate() + (dayNumber - weekdayOfFirstDay)) + } else { + monthDay.setDate(monthDay.getDate() + 1) + } + + const day = new Date(monthDay) + const isDisabled = disabled.reduce((acc, val) => acc || isDayInsideRange(day, val), false) + const isSelected = selection.reduce((acc, val) => acc || isDayInsideRange(day, val), false) + + // calculates whether a user is selecting a date range + const isPending = selectWeek + ? day >= pendingRowSelection[0] && day <= pendingRowSelection[1] + : selection.reduce((acc, val) => { + const dateRange = val || new Date() + const selectionStart = dateRange[0] + return acc || (!dateRange[1] && selectionStart) + ? (day >= selectionStart && day <= dateRangeSelection) || + (day <= selectionStart && day >= dateRangeSelection) + : false + }, false) + + // get start and end booleans for selection + const isStart = selection.reduce((acc, val) => acc || FDate(day).isSame(val[0]), false) + const isEnd = selection.reduce((acc, val) => acc || FDate(day).isSame(val[1] || val[0]), false) + + days.push({ + date: day, + today: FDate(day).isSame(new Date()), + weekend: weekendDays.includes(day.getDay()), + disabled: isDisabled, + unavailable: day.getMonth() !== date.getMonth(), + pending: isPending && day.getMonth() === date.getMonth(), + selected: isSelected, + start: isStart, + end: isEnd, + }) + } + + return days + }, [selection, disabled, date, dateRangeSelection, pendingRowSelection]) + + const handleMouseLeave = (e) => { + setPendingRowSelection([]) + setDateRangeSelection(selection[0] || null) + } + + const handleChange = (day) => { + const isUnavailable = day.date.getMonth() !== date.getMonth() + const isDisabled = day.disabled + if (!isUnavailable && !isDisabled && onChange) { + onChange(selectWeek ? [...pendingRowSelection] : day.date) + } + } + + const handleSelection = (day, index) => { + if (selectWeek) { + const { start, end } = getStartAndEndOfWeek(index) + setPendingRowSelection([new Date(days[start].date), new Date(days[end].date)]) + } else { + setDateRangeSelection(day.date) + } + } + + return ( + + {days.map((day, index) => { + return ( + handleChange(day)} + onMouseOver={() => handleSelection(day, index)} + {...dateCellProps}> + {renderDay ? renderDay(day.date) : day.date.getDate()} + + ) + })} + + ) +} diff --git a/packages/core/src/date-picker/date-picker-weekdays.tsx b/packages/core/src/date-picker/date-picker-weekdays.tsx new file mode 100644 index 00000000..ee43ebc9 --- /dev/null +++ b/packages/core/src/date-picker/date-picker-weekdays.tsx @@ -0,0 +1,33 @@ +import { CoreViewProps, Size, Text, View, classNames } from '@fold-dev/core' +import React from 'react' + +export type DatePickerWeekdaysProps = { + size?: Size + weekdays?: string[] +} & CoreViewProps + +export const DatePickerWeekdays = (props: DatePickerWeekdaysProps) => { + const { size, weekdays = ['Sun', 'Mon', 'Tues', 'Wed', 'Thur', 'Fri', 'Sat'], ...rest } = props + const className = classNames( + { + 'f-weekdays': true, + 'f-row': true, + }, + [props.className] + ) + + return ( + + {weekdays.map((weekday, index) => ( + + {weekday.slice(0, 3)} + + ))} + + ) +} diff --git a/packages/core/src/date-picker/date-picker.context.tsx b/packages/core/src/date-picker/date-picker.context.tsx new file mode 100644 index 00000000..ec81f431 --- /dev/null +++ b/packages/core/src/date-picker/date-picker.context.tsx @@ -0,0 +1,28 @@ +import React, { ReactElement, ReactNode, createContext, useState } from 'react' + +export type DateRangeSelection = Date + +export type PendingRowSelection = Date[] + +export type DatePickerProviderProps = { + children: ReactNode +} + +export const DatePickerContext = createContext({}) + +export const DatePickerProvider = (props: DatePickerProviderProps) => { + const [dateRangeSelection, setDateRangeSelection] = useState(new Date()) + const [pendingRowSelection, setPendingRowSelection] = useState([new Date(), new Date()]) + + return ( + + {props.children} + + ) +} diff --git a/packages/core/src/date-picker/date-picker.css b/packages/core/src/date-picker/date-picker.css new file mode 100644 index 00000000..6d3bc98b --- /dev/null +++ b/packages/core/src/date-picker/date-picker.css @@ -0,0 +1,363 @@ +/* date cell */ + +:root { + --f-date-cell-color: var(--f-color-text); + --f-date-cell-font-size: var(--f-font-size-md); + --f-date-cell-font-weight: var(--f-font-weight-medium); + --f-date-cell-weekend: var(--f-color-text); + --f-date-cell-weekend-background: var(--f-color-surface-strong); + --f-date-cell-unavailable: var(--f-color-text-weakest); + --f-date-cell-disabled: var(--f-color-text-weakest); + --f-date-cell-disabled-background: var(--f-color-surface-strong); + --f-date-cell-pending: var(--f-color-accent); + --f-date-cell-pending-background: var(--f-color-accent-weak); + --f-date-cell-edge-selected: var(--f-color-accent-weak); + --f-date-cell-edge-selected-background: var(--f-color-accent); + --f-date-cell-selected: var(--f-color-accent); + --f-date-cell-selected-background: var(--f-color-accent-weak); + --f-date-cell-selected-space: 0px; + --f-date-cell-today: var(--f-color-accent); + --f-date-cell-pending-opacity: 0.25; +} + +.f-date-cell { + height: auto; + flex-grow: 1; + font-weight: 500; + position: relative; + cursor: pointer; + user-select: none; + position: relative; + font-family: var(--f-font-body); + color: var(--f-date-cell-color); + font-size: var(--f-date-cell-font-size); + font-weight: var(--f-date-cell-font-weight); + border: none; + background: var(--f-color-surface); +} + +.f-date-cell:focus { + background: var(--f-date-cell-weekend-background) !important; + outline: none; + border-radius: var(--f-radius-full); +} + +.f-date-cell.is-unavailable { + color: var(--f-date-cell-unavailable); +} + +.f-date-cell.is-weekend { + color: var(--f-date-cell-weekend); + background: var(--f-date-cell-weekend-background); +} + +.f-date-cell.is-unavailable.is-weekend { + color: var(--f-date-cell-disabled); +} + +.f-date-cell.is-disabled { + color: var(--f-date-cell-disabled); + cursor: not-allowed; +} + +.f-date-cell:not(.is-disabled,.is-unavailable).is-today { + color: var(--f-date-cell-today); + font-weight: bold; +} + +/* pending */ + +.f-date-cell.is-pending { + color: var(--f-date-cell-pending) !important; +} + +.f-date-cell.is-pending::after { + content: ' '; + display: block; + pointer-events: none; + position: absolute; + width: calc(100% - var(--f-date-cell-selected-space)); + height: calc(100% - var(--f-date-cell-selected-space)); + top: var(--f-date-cell-selected-space); + left: 0; + z-index: -2; + background: var(--f-date-cell-pending-background); + border-radius: var(--f-radius-full); + opacity: var(--f-date-cell-pending-opacity); /* instead of adding a accent-weaker token */ + animation-name: f-date-cell-selection-fadein; + animation-duration: 0.2s; + animation-timing-function: ease-in; +} + +@keyframes f-date-cell-selection-fadein { + 0% { + opacity: 0; + } + + 100% { + opacity: var(--f-date-cell-pending-opacity); + } +} + +/* selection */ + +.f-date-cell.is-selected { + position: relative; + color: var(--f-date-cell-selected); +} + +.f-date-cell.is-selected.is-start, +.f-date-cell.is-selected.is-end { + color: var(--f-date-cell-edge-selected); +} + +.f-date-cell.is-selected::before { + content: ' '; + display: block; + pointer-events: none; + position: absolute; + width: 100%; + height: calc(100% - var(--f-date-cell-selected-space)); + top: var(--f-date-cell-selected-space); + left: 0; + z-index: -1; + background: var(--f-date-cell-selected-background); + animation-name: f-date-cell-selected-fadein; + animation-duration: 0.05s; + animation-timing-function: ease-in; +} + +.f-date-cell:not(.is-unavailable).is-start::after, +.f-date-cell:not(.is-unavailable).is-end::after { + border-radius: var(--f-radius-full); + content: ' '; + display: block; + pointer-events: none; + position: absolute; + width: 100%; + height: calc(100% - var(--f-date-cell-selected-space)); + top: var(--f-date-cell-selected-space); + left: 0; + z-index: -1; + background: var(--f-date-cell-edge-selected-background); + animation-name: f-date-cell-selected-fadein; + animation-duration: 0.05s; + animation-timing-function: ease-in; +} + +.f-date-cell.is-start::before { + border-top-left-radius: var(--f-radius-full); + border-bottom-left-radius: var(--f-radius-full); +} + +.f-date-cell.is-end::before { + border-top-right-radius: var(--f-radius-full); + border-bottom-right-radius: var(--f-radius-full); +} + +@keyframes f-date-cell-selected-fadein { + 0% { + opacity: 0; + } + + 100% { + opacity: 1; + } +} + +/* weekdays */ + +:root { + --f-month-weekday-padding: var(--f-space-5) 0; + --f-month-weekdays-background: transparent; +} + +.f-weekdays { + align-items: stretch; + width: 100%; + background: var(--f-month-weekdays-background); +} + +.f-weekdays > * { + flex: 1; + font-weight: 600; + text-align: center; + padding: var(--f-month-weekday-padding); + color: var(--f-color-text); +} + +/* month */ + +:root { + --f-month-day-width: calc(100%/7); +} + +.f-month { + flex: 1; + display: flex; + flex-wrap: wrap; + justify-content: stretch; + align-items: stretch; + align-content: stretch; + width: 100%; +} + +.f-month > * { + width: var(--f-month-day-width); +} + +/* months */ + +:root { + --f-months-month-width: calc(100%/12*3); +} + +.f-months { + width: 100%; + height: 100%; + flex: 1; + display: flex; + flex-wrap: wrap; + justify-content: stretch; + align-items: stretch; + align-content: stretch; +} + +.f-months > * { + width: var(--f-months-month-width); +} + +/* years */ + +:root { + --f-years-year-width: calc(100%/12*3); +} + +.f-years { + width: 100%; + height: 100%; + flex: 1; + display: flex; + flex-wrap: wrap; + justify-content: stretch; + align-items: stretch; + align-content: stretch; +} + +.f-years > * { + width: var(--f-years-year-width); +} + +/* date picker */ + +:root { + --f-date-picker-height: 300px; +} + +.f-date-picker { + justify-content: stretch; + align-items: stretch; + align-content: stretch; + min-height: var(--f-date-picker-height); +} + +.f-date-picker__panels { + flex: 1; + display: flex; + flex-direction: row; + justify-content: stretch; + align-items: stretch; + align-content: stretch; + gap: var(--f-space-2); +} + +.f-date-picker__titles { + gap: var(--f-space-1); + margin-bottom: 4px; +} + +.f-date-picker__panel { + flex: 1; + justify-content: stretch; + align-items: stretch; + align-content: stretch; +} + +.f-date-picker__titles > .f-date-picker__title { + flex: 1; +} + +.f-date-picker__titles > .f-date-picker__title .f-button__label .f-text { + width: 100%; + display: flex; + justify-content: stretch; + align-items: stretch; + align-content: stretch; + flex-direction: row; +} + +.f-date-picker__titles > .f-date-picker__title .f-date-picker__title-text { + flex: 1; + display: block; +} + +.f-date-picker__titles > .f-date-picker__title .f-date-picker__title-text:last-child { +} + +/* scrolling */ + +:root { + --f-scrolling-date-picker-radius: 0; +} + +.f-scrolling-date-picker { + position: relative; + overflow-y: auto; + border-radius: var(--f-scrolling-date-picker-radius); +} + +/* time picker */ + +:root { + --f-time-picker-background-color-hover: var(--f-color-surface-strong); + --f-time-picker-background-color-active: var(--f-color-surface-strong); + --f-time-picker-color: var(--f-color-text-weaker); + --f-time-picker-color-hover: var(--f-color-text-weak); + --f-time-picker-color-active: var(--f-color-accent); + --f-time-picker-padding: var(--f-space-2) var(--f-space-5); + --f-time-picker-ampm-padding: 0 var(--f-space-5); +} + +.f-time-picker { + position: relative; +} + +.f-time-picker-column { + overflow-y: auto; + height: 100%; + flex: 1; +} + +.f-time-picker-column:focus { + outline: var(--f-focus); + outline-offset: 1px; +} + +.f-time-picker-time { + padding: var(--f-time-picker-padding); + color: var(--f-time-picker-color); +} + +.f-time-picker-time:hover { + background: var(--f-time-picker-background-color-hover); + color: var(--f-time-picker-color-hover); +} + +.f-time-picker-time.is-selected { + background-color: var(--f-time-picker-background-color-active); + color: var(--f-time-picker-color-active); +} + +.f-time-picker__ampm { + padding: var(--f-time-picker-ampm-padding); +} diff --git a/packages/core/src/date-picker/date-picker.docs.ts b/packages/core/src/date-picker/date-picker.docs.ts new file mode 100644 index 00000000..e7010494 --- /dev/null +++ b/packages/core/src/date-picker/date-picker.docs.ts @@ -0,0 +1,7 @@ +export const docs = { + title: 'Date & Time Pickers', + subtitle: 'Date Time components offer highly versatile options for displaying and inputting dates.', + description: + 'Date and Time components serve as fundamental elements for creating a wide range of date input controls, adapting to various requirements. While date elements can function on their own, their value is enhanced when incorporated within a DateRange context.', + pro: true, +} diff --git a/packages/core/src/date-picker/date-picker.stories.tsx b/packages/core/src/date-picker/date-picker.stories.tsx new file mode 100644 index 00000000..a49a8004 --- /dev/null +++ b/packages/core/src/date-picker/date-picker.stories.tsx @@ -0,0 +1,576 @@ +import { + Badge, + Button, + Card, + Heading, + IconLib, + Input, + InputControl, + InputPopover, + InputSuffix, + pad, + Popover, + Stack, + Text, + useVisibility, + View, +} from '@fold-dev/core' +import React, { useEffect, useMemo, useRef, useState } from 'react' +import { + DateCell, + DatePicker, + DatePickerMonth, + DatePickerProvider, + MonthPicker, + ScrollingDatePicker, + TimePicker, + useScrollingDatePicker, + YearPicker, + DatePickerWeekdays, +} from '../' +import './date-picker.css' + +export default { + title: 'Pro/DatePicker', + component: DatePicker, + excludeStories: 'docs', +} + +export const Usage = () => { + const [date, setDate] = useState(new Date()) + const { today, tomorrow } = useMemo(() => { + const today = new Date() + const tomorrow = new Date(today.getTime() + 4 * 24 * 60 * 60 * 1000) + + return { today, tomorrow } + }, []) + const [selection, setSelection] = useState([[today, tomorrow]]) + + const handleSelection = (date: Date) => { + if (selection.length == 0) { + setSelection([[date, null]]) + } else { + const selected = selection[0] + if (!selected[0]) return setSelection([date, null]) + if (!!selected[0] && !!selected[1]) return setSelection([[date, null]]) + if (!!selected[0] && !selected[1]) + return setSelection(selected[0] > date ? [[date, selected[0]]] : [[selected[0], date]]) + } + } + + return ( + + + + + + ) +} + +// -- + +export const SelectWeek = () => { + const [date, setDate] = useState(new Date()) + const { today, tomorrow } = useMemo(() => { + const today = new Date() + const tomorrow = new Date(today.getTime() + 1 * 24 * 60 * 60 * 1000) + + return { today, tomorrow } + }, []) + const [selection, setSelection] = useState([]) + + const handleWeekSelection = (dates: Date[]) => { + setSelection([...selection, dates]) + } + + return ( + + + + ) +} + +// -- + +export const MonthDisplay = () => { + const today = new Date() + const month = today.getMonth() + const year = today.getFullYear() + const date = new Date(year, month, 1) + const selected = new Date(year, month, 16) + const disabled = new Date(year, month, 14) + + return ( + + ) +} + +// -- + +export const MonthAndWeekdays = () => { + const today = new Date() + const month = today.getMonth() + const year = today.getFullYear() + const date = new Date(year, month, 1) + const start = new Date(year, month, 16) + const end = new Date(year, month, 19) + const disabled = new Date(year, month, 13) + + return ( + + + + + ) +} + +// -- + +export const MonthCustomDayRender = () => { + const today = new Date() + const month = today.getMonth() + const year = today.getFullYear() + const date = new Date(year, month, 1) + const start = new Date(year, month, 16) + const end = new Date(year, month, 19) + const disabled = new Date(year, month, 13) + + return ( + + + { + if (day.getDate() == 11 && day.getMonth() == date.getMonth()) { + return ( + + ) + } else if (day.getDate() == 7 && day.getMonth() == date.getMonth()) { + return ( + <> + {day.getDate()} + + + ) + } else { + return day.getDate() + } + }} + /> + + ) +} + +// -- + +export const CustomWeekend = () => { + const today = new Date() + const month = today.getMonth() + const year = today.getFullYear() + const date = new Date(year, month, 1) + const start = new Date(year, month, 16) + const end = new Date(year, month, 20) + const disabled = new Date(year, month, 13) + + return ( + + + + + ) +} + +// -- + +export const MonthsDisplay = () => { + const today = new Date() + const month = today.getMonth() + const year = today.getFullYear() + const date = new Date(year, month, 1) + const selectedStart = new Date(year, month, 16) + const selectedEnd = new Date(year, month + 3, 16) + const disabled = new Date(year, month + 7, 14) + + return ( + + ) +} + +// -- + +export const YearsDisplay = () => { + const today = new Date() + const month = today.getMonth() + const year = today.getFullYear() + const date = new Date(year, month, 1) + const selectedStart = new Date(year - 1, month, 16) + const selectedEnd = new Date(year + 1, month, 16) + const disabled = new Date(year + 4, month, 14) + + return ( + + ) +} + +// -- + +/** + * The DateCell inherits directly from Text, enabling regular Text props to be used + */ +export const DateCellVariants = () => ( + + 19 + 8 + 31 + June + 2023 + 2022 + + 13 + + + +) + +// -- + +export const MultipleMonths = () => { + const date = new Date() + const { today, tomorrow, disabled1, disabled2 } = useMemo(() => { + const today = new Date() + const tomorrow = new Date(today.getTime() + 5 * 24 * 60 * 60 * 1000) + const disabled1 = new Date(today.getTime() - 6 * 24 * 60 * 60 * 1000) + const disabled2 = new Date(today.getTime() - 3 * 24 * 60 * 60 * 1000) + return { + today, + tomorrow, + disabled1, + disabled2, + } + }, []) + const [selection, setSelection] = useState([[today, tomorrow]]) + + const handleSelection = (date: Date) => { + if (selection.length == 0) { + setSelection([[date, null]]) + } else { + const selected = selection[0] + if (!selected[0]) return setSelection([date, null]) + if (!!selected[0] && !!selected[1]) return setSelection([[date, null]]) + if (!!selected[0] && !selected[1]) + return setSelection(selected[0] > date ? [[date, selected[0]]] : [[selected[0], date]]) + } + } + + return ( + + + + + + + + + + + + + + + + ) +} + +// -- + +export const DateInputs = () => { + const [text, setText] = useState('') + const [date, setDate] = useState(new Date()) + const [selection, setSelection] = useState([]) + const dateModal = useVisibility(false) + const timeModal = useVisibility(false) + + const getTimeFormat = (date: Date) => { + const hours = date.getHours() + const minutes = date.getMinutes() + const seconds = date.getSeconds() + return `${pad(hours)}:${pad(minutes)}:${pad(seconds)}` + } + + const getDateFormat = (date) => { + const day = date.getDate() + const month = date.getMonth() + 1 + const year = date.getFullYear() + return `${pad(year)}-${pad(month)}-${pad(day)}` + } + + const handleSelection = (ndate: Date) => { + setSelection([[ndate, ndate]]) + const d = new Date(date) + d.setFullYear(ndate.getFullYear()) + d.setMonth(ndate.getMonth()) + d.setDate(ndate.getDate()) + setDate(new Date(d)) + dateModal.hide() + } + + return ( + + + + + + }> + + setText(e.target.value)} + placeholder="Type here - datetimelocal" + value={getDateFormat(date)} + type="date" + /> + + + + + + + + setDate(date)} + /> + + }> + + setText(e.target.value)} + placeholder="Type here - datetimelocal" + value={getTimeFormat(date)} + type="time" + /> + + + + + + + + ) +} + +// -- + +export const ScrollingPicker = () => { + const { goToToday } = useScrollingDatePicker() + const ref = useRef(null) + const monthNames = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'] + const { today, tomorrow, start, end } = useMemo(() => { + const today = new Date() + const tomorrow = new Date(today.getTime() + 1 * 24 * 60 * 60 * 1000) + const start = new Date(today.getTime() + 1 * 24 * 60 * 60 * 1000) + const end = new Date(today.getTime() + 6 * 24 * 60 * 60 * 1000) + return { + today, + tomorrow, + start, + end, + } + }, []) + const [selection, setSelection] = useState([[start, end]]) + + const handleSelection = (date: Date) => { + if (selection.length == 0) { + setSelection([[date, null]]) + } else { + const selected = selection[0] + if (!selected[0]) return setSelection([date, null]) + if (!!selected[0] && !!selected[1]) return setSelection([[date, null]]) + if (!!selected[0] && !selected[1]) + return setSelection(selected[0] > date ? [[date, selected[0]]] : [[selected[0], date]]) + } + } + + const handleTodayClick = (e) => { + goToToday(ref.current) + } + + return ( + + + + ( + + {monthNames[date.getMonth()]} / {date.getFullYear()} + + )} + /> + + + ) +} + +// -- + +export const Time = () => { + const [date, setDate] = useState(new Date('2024-06-20 10:15:25')) + + return ( + + + + 12 hours + + + + + + 24 hours + + + + + ) +} diff --git a/packages/core/src/date-picker/date-picker.tsx b/packages/core/src/date-picker/date-picker.tsx new file mode 100644 index 00000000..21fb6b6d --- /dev/null +++ b/packages/core/src/date-picker/date-picker.tsx @@ -0,0 +1,221 @@ +import { Button, CoreViewProps, IconLib, Size, View, classNames, useFocus, waitForRender } from '@fold-dev/core' +import React, { useEffect, useMemo, useRef, useState } from 'react' +import { FDate } from '../helpers' +import { DateCellProps } from './date-cell' +import { DatePickerMonth, DatePickerMonthProps } from './date-picker-month' +import { DatePickerWeekdays, DatePickerWeekdaysProps } from './date-picker-weekdays' +import { MonthPicker, MonthPickerProps } from './month-picker' +import { YearPicker, YearPickerProps } from './year-picker' + +export type DateSelection = Date[] + +export type DatePickerProps = { + autoFocus?: boolean + size?: Size + selectWeek?: boolean + selection?: DateSelection[] + disabled?: DateSelection[] + weekendDays?: number[] + weekdays?: string[] + defaultDate: Date + offsetDays?: number + renderDay?: any + onChange?: any + dateCellProps?: DateCellProps + yearAmount?: number + defaultLevel?: 'days' | 'months' | 'years' + lockLevel?: boolean + panels?: number + weekdaysProps?: DatePickerWeekdaysProps + monthProps?: Omit + monthsProps?: Omit + yearsProps?: Omit +} & Omit + +export const DatePicker = (props: DatePickerProps) => { + const { + autoFocus = true, + selectWeek, + size, + renderDay, + selection = [], + disabled = [], + weekendDays = [0, 6], + weekdays = ['Sun', 'Mon', 'Tues', 'Wed', 'Thur', 'Fri', 'Sat'], + defaultDate = new Date(), + offsetDays = 0, + onChange, + dateCellProps = {}, + yearAmount = 12, + defaultLevel = 'days', + lockLevel, + panels = 1, + weekdaysProps = {}, + monthProps = {}, + monthsProps = {}, + yearsProps = {}, + ...rest + } = props + const containerRef = useRef(null) + const { trapFocus } = useFocus() + const [level, setLevel] = useState<'days' | 'months' | 'years'>(defaultLevel) + const [date, setDate] = useState(defaultDate) + const dates = useMemo(() => { + return new Array(panels).fill(date).map((d, i) => { + const index = panels == 1 ? i : i - 1 + return FDate(d).add(index, 'month') + }) + }, [date, panels]) + const className = classNames( + { + 'f-date-picker': true, + 'f-col': true, + }, + [props.className] + ) + + const handleForwardClick = () => { + switch (level) { + case 'days': + return setDate(FDate(date).add(1, 'month')) + case 'months': + return setDate(FDate(date).add(1, 'year')) + case 'years': + return setDate(FDate(date).add(yearAmount, 'year')) + } + } + + const handleBackwardClick = () => { + switch (level) { + case 'days': + return setDate(FDate(date).subtract(1, 'month')) + case 'months': + return setDate(FDate(date).subtract(1, 'year')) + case 'years': + return setDate(FDate(date).subtract(yearAmount, 'year')) + } + } + + const getTitleText = (date: Date) => { + switch (level) { + case 'days': + return `${date.toLocaleString('default', { month: 'long' })} ${date.getFullYear()}` + case 'months': + return date.getFullYear() + case 'years': + return 'Years' + } + } + + const handleMonthChange = (date: Date) => { + setDate(date) + setLevel('days') + } + + const handleYearChange = (date: Date) => { + setDate(date) + setLevel('months') + } + + const handleTitleClick = () => { + if (lockLevel) return + switch (level) { + case 'days': + return setLevel('months') + case 'months': + return setLevel('years') + default: + return null + } + } + + return ( + + + + + + + + + {level == 'days' && + dates.map((date1: Date, index: number) => ( + + + + + ))} + + {level == 'months' && ( + + )} + + {level == 'years' && ( + + )} + + + ) +} diff --git a/packages/core/src/date-picker/index.ts b/packages/core/src/date-picker/index.ts new file mode 100644 index 00000000..dfac7fb8 --- /dev/null +++ b/packages/core/src/date-picker/index.ts @@ -0,0 +1,11 @@ +export * from './date-cell' +export * from './date-picker-month' +export * from './date-picker-weekdays' +export * from './date-picker' +export * from './date-picker.context' +export * from './month-picker' +export * from './scrolling-date-picker' +export * from './time-picker-column' +export * from './time-picker-time' +export * from './time-picker' +export * from './year-picker' diff --git a/packages/core/src/date-picker/month-picker.tsx b/packages/core/src/date-picker/month-picker.tsx new file mode 100644 index 00000000..62d3a867 --- /dev/null +++ b/packages/core/src/date-picker/month-picker.tsx @@ -0,0 +1,125 @@ +import { CoreViewProps, Size, View, classNames, getKey } from '@fold-dev/core' +import React, { useContext, useMemo } from 'react' +import { isMonthInsideRange } from '../helpers' +import { DateCell, DateCellProps } from './date-cell' +import { DateSelection } from './date-picker' +import { DatePickerContext } from './date-picker.context' + +export type MonthPickerProps = { + size?: Size + selection?: DateSelection[] + disabled?: DateSelection[] + date: Date + renderMonth?: any + monthNames?: any[] + onChange?: any + dateCellProps?: DateCellProps +} & Omit + +export const MonthPicker = (props: MonthPickerProps) => { + const { + size, + selection = [], + disabled = [], + date = new Date(), + renderMonth, + monthNames = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'], + onChange, + dateCellProps = {}, + ...rest + } = props + const { dateRangeSelection, setDateRangeSelection } = useContext(DatePickerContext) + const className = classNames( + { + 'f-months': true, + }, + [props.className] + ) + + const months = useMemo(() => { + return monthNames.map((monthName: string, index: number) => { + const month = new Date(date.getFullYear(), index, date.getDate()) + const isToday = new Date().getMonth() == month.getMonth() && new Date().getFullYear() == month.getFullYear() + const isDisabled = disabled.reduce((acc, val) => acc || isMonthInsideRange(month, val), false) + const isSelected = selection.reduce((acc, val) => acc || isMonthInsideRange(month, val), false) + const isPending = selection.reduce((acc, val) => { + const dateRange = val || new Date() + const selectionStart = dateRange[0] + return acc || (!dateRange[1] && selectionStart) + ? (month >= selectionStart && month <= dateRangeSelection) || + (month <= selectionStart && month >= dateRangeSelection) + : false + }, false) + + // get start and end booleans for selection + const isStart = selection.reduce( + (acc, val) => + acc || (month.getMonth() === val[0].getMonth() && month.getFullYear() === val[0].getFullYear()), + false + ) + const isEnd = selection.reduce( + (acc, val) => + acc || (month.getMonth() === val[1].getMonth() && month.getFullYear() === val[1].getFullYear()), + false + ) + + return { + date: month, + today: isToday, + disabled: isDisabled, + pending: isPending, + selected: isSelected, + start: isStart, + end: isEnd, + name: monthName, + month: index, + } + }) + }, [monthNames, selection, disabled, dateRangeSelection, date]) + + const handleMouseLeave = (e) => { + setDateRangeSelection(selection[0] || null) + } + + const handleChange = (month) => { + if (!month.disabled && onChange) onChange(month.date) + } + + const handleSelection = (month) => { + if (!month.disabled) setDateRangeSelection(month.date) + } + + const handleKeyDown = (e) => { + console.log('here', e.key) + } + + return ( + + {months.map((month, index) => { + return ( + handleChange(month)} + onMouseOver={() => handleSelection(month)} + {...dateCellProps}> + {renderMonth ? renderMonth(month.date) : month.name} + + ) + })} + + ) +} diff --git a/packages/core/src/date-picker/scrolling-date-picker.tsx b/packages/core/src/date-picker/scrolling-date-picker.tsx new file mode 100644 index 00000000..caa64832 --- /dev/null +++ b/packages/core/src/date-picker/scrolling-date-picker.tsx @@ -0,0 +1,141 @@ +import { CoreViewProps, Size, View, classNames, mergeRefs, useFocus, waitForRender } from '@fold-dev/core' +import React, { ReactElement, forwardRef, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' +import { FDate } from '../helpers' +import { DateCellProps } from './date-cell' +import { DateSelection } from './date-picker' +import { DatePickerMonth, DatePickerMonthProps } from './date-picker-month' +import { DatePickerWeekdaysProps } from './date-picker-weekdays' + +export type ScrollingDatePickerProps = { + autoFocus?: boolean + width?: number + height?: number + scrollThreshold?: number + size?: Size + selection?: DateSelection[] + disabled?: DateSelection[] + defaultDate?: Date + onChange?: any + renderDay?: any + dateCellProps?: DateCellProps + weekdaysProps?: DatePickerWeekdaysProps + monthProps?: Omit + monthNames?: string[] + monthTitle: (date: Date) => ReactElement +} & Omit + +export const useScrollingDatePicker = () => { + const goToToday = (el) => { + const { parentNode } = el.querySelector('*[data-today="true"]') + if (!parentNode) return + const { offsetTop } = parentNode + el.scrollTo({ top: offsetTop }) + } + + return { goToToday } +} + +export const ScrollingDatePicker = forwardRef((props: ScrollingDatePickerProps, ref) => { + const { + autoFocus = true, + width = '100%', + height = 250, + scrollThreshold = 100, + size, + selection = [], + disabled = [], + defaultDate = new Date(), + onChange, + renderDay, + dateCellProps = {}, + weekdaysProps = {}, + monthProps = {}, + monthNames = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'], + monthTitle, + ...rest + } = props + const { trapFocus } = useFocus() + const date = useMemo(() => new Date(defaultDate.getFullYear(), defaultDate.getMonth(), 15), [defaultDate]) + const lockedRef = useRef(null) + const containerRef = useRef(null) + const [months, setMonths] = useState([ + FDate(date).subtract(1, 'month'), + new Date(date), + FDate(date).add(1, 'month'), + ]) + const className = classNames( + { + 'f-scrolling-date-picker': true, + 'f-scrollbar': true, + }, + [props.className] + ) + + const handleScroll = (e) => { + if (lockedRef.current) return + + const offsetHeight = containerRef.current.scrollHeight - containerRef.current.scrollTop + const loadTop = containerRef.current.scrollTop <= scrollThreshold + const loadBottom = containerRef.current.offsetHeight >= offsetHeight - scrollThreshold + + if (loadTop) { + const earliestMonth: Date = months[0] + const previousMonth = FDate(earliestMonth).subtract(1, 'month') + setMonths([previousMonth, ...months]) + } + + if (loadBottom) { + const latestMonth = months[months.length - 1] + const nextMonth = FDate(latestMonth).add(1, 'month') + setMonths([...months, nextMonth]) + } + } + + useLayoutEffect(() => { + setMonths([FDate(date).subtract(1, 'month'), new Date(date), FDate(date).add(1, 'month')]) + waitForRender(() => { + const { offsetHeight } = containerRef.current + containerRef.current.scrollTo({ top: offsetHeight }) + setTimeout(() => (lockedRef.current = false), 100) + }) + }, [date]) + + return ( + + {months.map((month: Date, index: number) => { + const uuid = month.getMonth() + '-' + month.getFullYear() + const today = + new Date().getMonth() == month.getMonth() && new Date().getFullYear() == month.getFullYear() + + return ( +
+ {monthTitle(month)} + +
+ ) + })} +
+ ) +}) diff --git a/packages/core/src/date-picker/time-picker-column.tsx b/packages/core/src/date-picker/time-picker-column.tsx new file mode 100644 index 00000000..0119a921 --- /dev/null +++ b/packages/core/src/date-picker/time-picker-column.tsx @@ -0,0 +1,67 @@ +import { CoreViewProps, Size, View, classNames, getKey, scrollToCenter, scrollToTop, useResize } from '@fold-dev/core' +import React, { useEffect, useLayoutEffect, useRef, useState } from 'react' +import { TimePickerTime } from './time-picker-time' + +export type TimePickerColumnProps = { + size?: Size + items: number[] + selected: number + onSelect: any +} & CoreViewProps + +export const TimePickerColumn = (props: TimePickerColumnProps) => { + const { size = 'md', items, selected, onSelect, style = {}, ...rest } = props + const ref = useRef(null) + const dimensions = useResize(ref.current) + const [buffer, setBuffer] = useState(0) + const className = classNames({ + 'f-time-picker-column': true, + 'f-scrollbar': true, + }) + + const handleKeyDown = (e) => { + const { isUp, isDown, isEnter } = getKey(e) + + if (isUp || isDown || isEnter) { + e.preventDefault() + e.stopPropagation() + + const selectedIndex = items.findIndex((i) => i == selected) + + if (isUp) onSelect(selectedIndex == 0 ? items[items.length - 1] : items[selectedIndex - 1]) + if (isDown) onSelect(selectedIndex == items.length - 1 ? items[0] : items[selectedIndex + 1]) + } + } + + useEffect(() => { + const selectedIndex = items.findIndex((i) => i == selected) + scrollToTop(ref.current.children[selectedIndex]) + }, [items, dimensions, selected]) + + useLayoutEffect(() => { + const { firstChild } = ref.current + setBuffer(ref.current.offsetHeight - firstChild.offsetHeight) + }, [items, dimensions]) + + return ( + + {items.map((item, index) => { + return ( + onSelect(item)} + /> + ) + })} + + ) +} diff --git a/packages/core/src/date-picker/time-picker-time.tsx b/packages/core/src/date-picker/time-picker-time.tsx new file mode 100644 index 00000000..15aa4169 --- /dev/null +++ b/packages/core/src/date-picker/time-picker-time.tsx @@ -0,0 +1,28 @@ +import { CoreViewProps, Size, Text, classNames } from '@fold-dev/core' +import React from 'react' + +export type TimePickerTimeProps = { + size: Size + value: number + selected: boolean + onSelect: any +} & CoreViewProps + +export const TimePickerTime = (props: TimePickerTimeProps) => { + const { size, value, selected, onSelect, ...rest } = props + const className = classNames({ + 'f-time-picker-time': true, + 'f-row': true, + 'f-buttonize': true, + 'is-selected': selected, + }) + + return ( +
+ {value} +
+ ) +} diff --git a/packages/core/src/date-picker/time-picker.tsx b/packages/core/src/date-picker/time-picker.tsx new file mode 100644 index 00000000..a42756ad --- /dev/null +++ b/packages/core/src/date-picker/time-picker.tsx @@ -0,0 +1,172 @@ +import { + Button, + ButtonGroup, + CoreViewProps, + Size, + View, + classNames, + getNumberArray, + useFocus, + waitForRender, +} from '@fold-dev/core' +import React, { useEffect, useMemo, useRef } from 'react' +import { TimePickerColumn } from './time-picker-column' + +export type TimePickerProps = { + autoFocus?: boolean + twelveHours?: boolean + customHours?: number + minutesDivider?: number + secondsDivider?: number + showHours?: boolean + showMinutes?: boolean + showSeconds?: boolean + showAmPm?: boolean + date?: Date + size?: Size + footer?: any + onChange: (date: Date) => void +} & Omit + +export const TimePicker = (props: TimePickerProps) => { + const { + autoFocus = true, + twelveHours, + customHours = 24, + minutesDivider = 5, + secondsDivider = 5, + showHours = true, + showMinutes = true, + showSeconds = true, + showAmPm = true, + size = 'md', + date = new Date(), + onChange, + footer, + ...rest + } = props + const containerRef = useRef(null) + const { trapFocus } = useFocus() + const { am, pm } = useMemo(() => { + if (date.getHours() < 12) { + return { am: true, pm: false } + } else { + return { am: false, pm: true } + } + }, [date]) + const { hours, minutes, seconds } = useMemo(() => { + return { + minutes: getNumberArray(60, minutesDivider), + seconds: getNumberArray(60, secondsDivider), + hours: twelveHours ? [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12] : getNumberArray(customHours), + } + }, [twelveHours]) + const { hour, minute, second } = useMemo(() => { + return { + hour: date.getHours(), + minute: date.getMinutes(), + second: date.getSeconds(), + } + }, [date]) + const className = classNames( + { + 'f-time-picker': true, + 'f-col': true, + }, + [size, props.className] + ) + + const get12HoursSelected = () => { + if (twelveHours && hour == 12) { + return 12 + } + if (twelveHours && hour == 0) { + return 12 + } else { + return twelveHours && pm ? hour - 12 : hour + } + } + + const handleTimeChange = (part: 'hour' | 'minute' | 'second', value) => { + switch (part) { + case 'hour': + return onChange(new Date(date.setHours(value))) + case 'minute': + return onChange(new Date(date.setMinutes(value))) + case 'second': + return onChange(new Date(date.setSeconds(value))) + } + } + + const handleAmChange = (e) => { + e.preventDefault() + const hours = date.getHours() + if (hours >= 12) handleTimeChange('hour', hours - 12) + } + + const handlePmChange = (e) => { + e.preventDefault() + const hours = date.getHours() + if (hours < 12) handleTimeChange('hour', hours + 12) + } + + useEffect(() => { + waitForRender(() => trapFocus(containerRef.current), 10) + }, [autoFocus]) + + return ( + + {showHours && ( + handleTimeChange('hour', twelveHours && pm ? value + 12 : value)} + /> + )} + + {showMinutes && ( + handleTimeChange('minute', value)} + /> + )} + + {showSeconds && ( + handleTimeChange('second', value)} + /> + )} + + {showAmPm && ( +
+ + + + + {footer} +
+ )} +
+ ) +} diff --git a/packages/core/src/date-picker/year-picker.tsx b/packages/core/src/date-picker/year-picker.tsx new file mode 100644 index 00000000..e36a42da --- /dev/null +++ b/packages/core/src/date-picker/year-picker.tsx @@ -0,0 +1,112 @@ +import { CoreViewProps, Size, View, classNames } from '@fold-dev/core' +import React, { useContext, useMemo } from 'react' +import { isYearInsideRange } from '../helpers' +import { DateCell, DateCellProps } from './date-cell' +import { DateSelection } from './date-picker' +import { DatePickerContext } from './date-picker.context' + +export type YearPickerProps = { + size?: Size + selection?: DateSelection[] + disabled?: DateSelection[] + date: Date + renderYear?: any + yearAmount?: number + onChange?: any + dateCellProps?: DateCellProps +} & Omit + +export const YearPicker = (props: YearPickerProps) => { + const { + size, + yearAmount = 12, + selection = [], + disabled = [], + date = new Date(), + renderYear, + onChange, + dateCellProps = {}, + ...rest + } = props + const { dateRangeSelection, setDateRangeSelection } = useContext(DatePickerContext) + const className = classNames( + { + 'f-months': true, + }, + [props.className] + ) + + const years = useMemo(() => { + return new Array(yearAmount).fill(null).map((_, index: number) => { + const yearNumber = date.getFullYear() - yearAmount / 2 + index + const year = new Date(yearNumber, date.getMonth(), date.getDate()) + const isToday = new Date().getFullYear() == yearNumber + const isDisabled = disabled.reduce((acc, val) => acc || isYearInsideRange(year, val), false) + const isSelected = selection.reduce((acc, val) => acc || isYearInsideRange(year, val), false) + const isPending = selection.reduce((acc, val) => { + const dateRange = val || new Date() + const selectionStart = dateRange[0] + return acc || (!dateRange[1] && selectionStart) + ? (year >= selectionStart && year <= dateRangeSelection) || + (year <= selectionStart && year >= dateRangeSelection) + : false + }, false) + + // get start and end booleans for selection + const isStart = selection.reduce((acc, val) => acc || year.getFullYear() === val[0].getFullYear(), false) + const isEnd = selection.reduce((acc, val) => acc || year.getFullYear() === val[1].getFullYear(), false) + + // TODO: add border radii (like days) + return { + date: year, + today: isToday, + disabled: isDisabled, + pending: isPending, + selected: isSelected, + start: isStart, + end: isEnd, + year: yearNumber, + } + }) + }, [selection, disabled, dateRangeSelection, date]) + + const handleMouseLeave = (e) => { + setDateRangeSelection(selection[0] || null) + } + + const handleChange = (year) => { + if (!year.disabled && onChange) onChange(year.date) + } + + const handleSelection = (year) => { + if (!year.disabled) setDateRangeSelection(year.date) + } + + return ( + + {years.map((year, index) => { + return ( + handleChange(year)} + onMouseOver={() => handleSelection(year)} + {...dateCellProps}> + {renderYear ? renderYear(year.date) : year.year} + + ) + })} + + ) +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index a1db179b..e87b1dbb 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -17,6 +17,8 @@ export * from './contexts' export * from './cookie' export * from './copy' export * from './cropper' +export * from './data-grid' +export * from './date-picker' export * from './dialog' export * from './divider' export * from './drag' diff --git a/packages/core/src/styles/styles.css b/packages/core/src/styles/styles.css index e4badb45..bb550b17 100644 --- a/packages/core/src/styles/styles.css +++ b/packages/core/src/styles/styles.css @@ -42,6 +42,8 @@ we don't want to use !important @import '../cookie/cookie.css'; @import '../copy/copy.css'; @import '../cropper/cropper.css'; +@import '../data-grid/data-grid.css'; +@import '../date-picker/date-picker.css'; @import '../dialog/dialog.css'; @import '../divider/divider.css'; @import '../drag/drag.css'; From 29e8705e60a3ee6656afdd332ced600d4bda0fd4 Mon Sep 17 00:00:00 2001 From: Jo du Plessis Date: Mon, 22 Jul 2024 12:03:06 +0200 Subject: [PATCH 2/5] import color method & date --- packages/core/src/helpers/color.ts | 25 +++ packages/core/src/helpers/date.ts | 277 +++++++++++++++++++++++++++++ packages/core/src/helpers/index.ts | 1 + 3 files changed, 303 insertions(+) create mode 100644 packages/core/src/helpers/date.ts diff --git a/packages/core/src/helpers/color.ts b/packages/core/src/helpers/color.ts index c422625e..5bc776cd 100644 --- a/packages/core/src/helpers/color.ts +++ b/packages/core/src/helpers/color.ts @@ -112,3 +112,28 @@ export function shadeColor(col, amt) { return `#${rr}${gg}${bb}` } + +export const lightenedHex = (hex, percent) => { + hex = hex.replace(/^#/, '') + + if (hex.length === 3) { + hex = hex + .split('') + .map(function (hexDigit) { + return hexDigit + hexDigit + }) + .join('') + } + + let r = parseInt(hex.substring(0, 2), 16) + let g = parseInt(hex.substring(2, 4), 16) + let b = parseInt(hex.substring(4, 6), 16) + + r = Math.min(255, Math.floor(r + (255 - r) * (percent / 100))) + g = Math.min(255, Math.floor(g + (255 - g) * (percent / 100))) + b = Math.min(255, Math.floor(b + (255 - b) * (percent / 100))) + + let lightenedHex = '#' + ((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1).toUpperCase() + + return lightenedHex +} diff --git a/packages/core/src/helpers/date.ts b/packages/core/src/helpers/date.ts new file mode 100644 index 00000000..bf448c1a --- /dev/null +++ b/packages/core/src/helpers/date.ts @@ -0,0 +1,277 @@ +import { plural } from './util' + +export const FDate = (date: Date) => { + const isBefore = (beforeDate: Date) => { + return beforeDate > date + } + + const isAfter = (afterDate: Date) => { + return afterDate < date + } + + const isSame = (sameDate: Date) => { + return ( + date.getFullYear() === sameDate.getFullYear() && + date.getMonth() === sameDate.getMonth() && + date.getDate() === sameDate.getDate() + ) + } + + const isToday = () => { + const today = new Date() + today.setHours(0, 0, 0, 0) + date.setHours(0, 0, 0, 0) + return date.getTime() === today.getTime() + } + + const isTomorrow = () => { + const today = new Date() + today.setHours(0, 0, 0, 0) + const tomorrow = new Date(today) + tomorrow.setDate(today.getDate() + 1) + date.setHours(0, 0, 0, 0) + return date.getTime() === tomorrow.getTime() + } + + const isYesterday = () => { + const today = new Date() + today.setHours(0, 0, 0, 0) + const yesterday = new Date(today) + yesterday.setDate(today.getDate() - 1) + date.setHours(0, 0, 0, 0) + return date.getTime() === yesterday.getTime() + } + + const startOf = (prop: 'month' | 'week' | 'day'): Date => { + const dateObj = new Date(date) + + switch (prop) { + case 'month': + return new Date(dateObj.getFullYear(), dateObj.getMonth(), 1) + case 'week': + const day = dateObj.getDay() + const diff = dateObj.getDate() - day + (day == 0 ? -6 : 1) + return new Date(dateObj.setDate(diff)) + case 'day': + return new Date(dateObj.setHours(0, 0, 0, 0)) + default: + return null + } + } + + const endOf = (prop: 'month' | 'week' | 'day'): Date => { + const dateObj = new Date(date) + + switch (prop) { + case 'month': + return new Date(dateObj.getFullYear(), dateObj.getMonth() + 1, 0) + case 'week': + const day = dateObj.getDay() + const diff = dateObj.getDate() - day + (day == 0 ? -6 : 1) + const start = new Date(dateObj.setDate(diff)) + return new Date(start.getDate() + 6) + case 'day': + return new Date(dateObj.setHours(23, 59, 59, 999)) + default: + return null + } + } + + const isLastDayOfMonth = (date) => { + const test = new Date(date) + return date.getMonth() !== new Date().setDate(test.getDate() + 1) + } + + const add = (value, prop: 'year' | 'month' | 'month-strict' | 'week' | 'day'): Date => { + const dateObj = new Date(date) + + switch (prop) { + case 'year': + return new Date(dateObj.setFullYear(dateObj.getFullYear() + value)) + case 'month-strict': + return new Date(dateObj.setMonth(new Date(dateObj).getMonth() + value)) + case 'month': + if (isLastDayOfMonth(dateObj)) { + return new Date( + new Date(new Date(new Date(date).setDate(1)).setMonth(dateObj.getMonth() + value + 1)).setDate( + 0 + ) + ) + } else { + return new Date(dateObj.setMonth(new Date(dateObj).getMonth() + value)) + } + case 'week': + return new Date(dateObj.setDate(dateObj.getDate() + value * 6)) + case 'day': + return new Date(dateObj.setDate(dateObj.getDate() + value)) + default: + return null + } + } + + const subtract = (value, prop: 'year' | 'month' | 'month-strict' | 'week' | 'day'): Date => { + const dateObj = new Date(date) + + switch (prop) { + case 'year': + return new Date(dateObj.setFullYear(dateObj.getFullYear() - value)) + case 'month-strict': + return new Date(dateObj.setMonth(new Date(dateObj).getMonth() - value)) + case 'month': + if (isLastDayOfMonth(dateObj)) { + return new Date( + new Date(new Date(new Date(date).setDate(1)).setMonth(dateObj.getMonth() - value + 1)).setDate( + 0 + ) + ) + } else { + return new Date(dateObj.setMonth(new Date(dateObj).getMonth() + value)) + } + case 'week': + return new Date(dateObj.setDate(dateObj.getDate() - value * 6)) + case 'day': + return new Date(dateObj.setDate(dateObj.getDate() - value)) + default: + return null + } + } + + const fromNow = () => { + const isPast = new Date().getTime() - date.getTime() > 0 + const timeDifference = isPast ? new Date().getTime() - date.getTime() : date.getTime() - new Date().getTime() + const seconds = Math.floor(timeDifference / 1000) + const years = Math.floor(seconds / 31536000) + const months = Math.floor(seconds / 2592000) + const days = Math.floor(seconds / 86400) + const hours = Math.floor(seconds / 3600) + const minutes = Math.floor(seconds / 60) + + if (days > 548) { + return isPast ? years + ' years ago' : 'in ' + years + plural(years, ' year') + } + + if (days >= 320 && days <= 547) { + return isPast ? 'a year ago' : 'in a year' + } + + if (days >= 45 && days <= 319) { + return isPast ? months + ' months ago' : 'in ' + months + plural(months, ' month') + } + + if (days >= 26 && days <= 45) { + return isPast ? 'a month ago' : 'in a month' + } + + if (hours >= 36 && days <= 25) { + return isPast ? days + ' days ago' : 'in ' + days + plural(days, ' day') + } + + if (hours >= 22 && hours <= 35) { + return isPast ? 'a day ago' : 'in a day' + } + + if (minutes >= 90 && hours <= 21) { + return isPast ? hours + ' hours ago' : 'in ' + hours + plural(hours, ' hour') + } + + if (minutes >= 45 && minutes <= 89) { + return isPast ? 'an hour ago' : 'in an hour' + } + + if (seconds >= 90 && minutes <= 44) { + return isPast ? minutes + ' minutes ago' : 'in ' + minutes + plural(minutes, ' minute') + } + + if (seconds >= 45 && seconds <= 89) { + return isPast ? 'a minute ago' : 'in a minute' + } + + if (seconds >= 0 && seconds <= 45) { + return isPast ? 'a few seconds ago' : 'in a few seconds' + } + } + + return { + isBefore, + isAfter, + isSame, + isToday, + isTomorrow, + isYesterday, + startOf, + endOf, + add, + subtract, + isLastDayOfMonth, + fromNow, + } +} + +export const roundToDay = (ts) => Math.round(ts / 1000 / 60 / 60 / 24) + +export const isDayInsideRange = (day, range) => { + const start = range ? range[0] : null + const end = start ? range[1] : null + + if (start) { + if (FDate(day).isSame(start)) return true + } + + if (end) { + if (FDate(day).isSame(end)) return true + } + + if (start && end) { + if (day >= start && day <= end) return true + } + + return false +} + +export const isMonthInsideRange = (month, range) => { + const start = range ? range[0] : null + const end = start ? range[1] : null + + if (start) { + if (month.getMonth() == start.getMonth() && month.getFullYear() == start.getFullYear()) return true + } + + if (end) { + if (month.getMonth() == end.getMonth() && month.getFullYear() == end.getFullYear()) return true + } + + if (start && end) { + if (month >= start && month <= end) return true + } + + return false +} + +export const isYearInsideRange = (year, range) => { + const start = range ? range[0] : null + const end = start ? range[1] : null + + if (start) { + if (year.getFullYear() == start.getFullYear()) return true + } + + if (end) { + if (year.getFullYear() == end.getFullYear()) return true + } + + if (start && end) { + if (year >= start && year <= end) return true + } + + return false +} + +export const getStartAndEndOfWeek = (index) => { + const start = Math.floor(index / 7) * 7 + const end = start + 7 - 1 + return { start, end } +} + +export const sameDay = (d1, d2) => { + return d1.getFullYear() === d2.getFullYear() && d1.getMonth() === d2.getMonth() && d1.getDate() === d2.getDate() +} diff --git a/packages/core/src/helpers/index.ts b/packages/core/src/helpers/index.ts index 80b9ba7f..dcf84d40 100644 --- a/packages/core/src/helpers/index.ts +++ b/packages/core/src/helpers/index.ts @@ -1,4 +1,5 @@ export * from './array' export * from './color' +export * from './date' export * from './state' export * from './util' From c712c32f7897664af85ea4ac0f3b3ed8b793f3a3 Mon Sep 17 00:00:00 2001 From: Jo du Plessis Date: Mon, 22 Jul 2024 12:17:00 +0200 Subject: [PATCH 3/5] fix imports --- dummy-data/data-grid.tsx | 20946 ++++++++++++++++ dummy-data/index.ts | 1 + .../data-grid/data-grid-cell-component.tsx | 2 +- .../core/src/data-grid/data-grid-cell.tsx | 3 +- .../data-grid-default-cell-component.tsx | 2 +- .../data-grid-header-cell-component.tsx | 2 +- .../src/data-grid/data-grid-header-cell.tsx | 8 +- .../core/src/data-grid/data-grid-header.tsx | 2 +- packages/core/src/data-grid/data-grid-row.tsx | 2 +- packages/core/src/data-grid/data-grid.docs.ts | 8 - .../core/src/data-grid/data-grid.provider.tsx | 2 +- .../core/src/data-grid/data-grid.stories.tsx | 13 +- packages/core/src/data-grid/data-grid.tsx | 2 +- packages/core/src/data-grid/data-grid.util.ts | 4 +- packages/core/src/date-picker/date-cell.tsx | 4 +- .../src/date-picker/date-picker-month.tsx | 3 +- .../src/date-picker/date-picker-weekdays.tsx | 2 +- .../core/src/date-picker/date-picker.docs.ts | 7 - .../src/date-picker/date-picker.stories.tsx | 9 +- packages/core/src/date-picker/date-picker.tsx | 2 +- .../core/src/date-picker/month-picker.tsx | 3 +- .../src/date-picker/scrolling-date-picker.tsx | 2 +- .../src/date-picker/time-picker-column.tsx | 2 +- .../core/src/date-picker/time-picker-time.tsx | 2 +- packages/core/src/date-picker/time-picker.tsx | 2 +- packages/core/src/date-picker/year-picker.tsx | 3 +- packages/core/src/helpers/util.ts | 7 + 27 files changed, 20998 insertions(+), 47 deletions(-) create mode 100644 dummy-data/data-grid.tsx create mode 100644 dummy-data/index.ts delete mode 100644 packages/core/src/data-grid/data-grid.docs.ts delete mode 100644 packages/core/src/date-picker/date-picker.docs.ts diff --git a/dummy-data/data-grid.tsx b/dummy-data/data-grid.tsx new file mode 100644 index 00000000..66b2f1fd --- /dev/null +++ b/dummy-data/data-grid.tsx @@ -0,0 +1,20946 @@ +import { Badge, IconLib, Select, Text, View, getKey, useEvent, waitForRender, addElementToArray } from '@fold-dev/core' +import React, { FunctionComponent, useContext, useEffect, useRef, useState } from 'react' +import { DataGridContext, DataGridTypes } from '../src' + +const countries = [ + 'United States', + 'Canada', + 'Brazil', + 'Mexico', + 'United Kingdom', + 'France', + 'Germany', + 'Italy', + 'Spain', + 'Australia', + 'Japan', + 'China', + 'India', + 'Russia', + 'South Korea', + 'South Africa', + 'Turkey', + 'Saudi Arabia', + 'Argentina', + 'Netherlands', + 'Switzerland', + 'Sweden', + 'Norway', + 'Denmark', + 'Belgium', + 'Austria', + 'Ireland', + 'Portugal', + 'Greece', + 'Thailand', + 'Indonesia', + 'Malaysia', + 'Philippines', + 'Vietnam', + 'Egypt', + 'Israel', + 'United Arab Emirates', + 'Singapore', + 'New Zealand', + 'Finland', + 'Poland', + 'Czech Republic', + 'Hungary', + 'Romania', + 'Chile', + 'Colombia', + 'Peru', + 'Venezuela', + 'Ukraine', + 'Nigeria', +] + +const colorPoints = [ + '#6200FF', + '#7100FF', + '#8000FF', + '#8F00FF', + '#9E00FF', + '#AD00FF', + '#BC00FF', + '#CB00FF', + '#DA00FF', + '#E900FF', + '#FF00FF', +] + +export const Delta = (props: any) => { + const { id, edit, value, options, error, warning, onEdit, onCancel } = props + + return ( +
+ {value >= 0 && ( +
+ )} + + {value < 0 && ( +
+ )} +
+ ) +} + +export const GradientSelect = (props: any) => { + const { id, edit, value, options, error, warning, onEdit, onCancel } = props + const { refocus, selectionLock } = useContext(DataGridContext) + const ref = useRef(null) + const [range, setRange] = useState(0) + + const handleMouseDown = (e) => { + if (ref.current) { + if (!ref.current?.contains(e.target)) { + onEdit(range) + selectionLock(false) + refocus() + } + } + } + + const handleKeyDown = (e) => { + const { isEnter, isEscape } = getKey(e) + if (isEnter || isEscape) { + e.preventDefault() + e.stopPropagation() + if (isEnter) { + onEdit(range) + selectionLock(false) + refocus() + } else { + onCancel() + selectionLock(false) + refocus() + } + } + } + + useEvent('mousedown', handleMouseDown) + + useEffect(() => { + if (edit) { + selectionLock(true) + setRange(value) + waitForRender(() => ref.current.querySelector(':scope input').focus(), 10) + } + }, [edit]) + + return ( + <> + {edit && ( + + setRange(Number(e.target.value))} + /> + + )} + + {!edit && ( +
+ )} + + ) +} + +export const CountrySelect = (props: any) => { + const { id, edit, value, options, error, warning, onEdit, onCancel } = props + const { refocus, selectionLock } = useContext(DataGridContext) + const [selected, setSelected] = useState([value]) + const ref = useRef(null) + + const handleClose = () => { + onCancel() + selectionLock(false) + refocus() // unnecessary + } + + const handleSelect = (option, dismiss) => { + setSelected([option.key]) + onEdit(option.key) + selectionLock(false) + refocus() // unnecessary + } + + const handleFilter = (text: string) => { + // do an API call to get more options + } + + useEffect(() => { + if (edit) { + // manually click on the select input after React renders + selectionLock(true) + waitForRender(() => ref.current.querySelector(':scope input').focus(), 10) + } + }, [edit]) + + return ( + <> + {edit && ( + +