diff --git a/dummy-data/data-grid.tsx b/dummy-data/data-grid.tsx new file mode 100644 index 00000000..6fe119a7 --- /dev/null +++ b/dummy-data/data-grid.tsx @@ -0,0 +1,20945 @@ +import { Badge, DataGridContext, DataGridTypes, IconLib, Select, Text, View, getKey, useEvent, waitForRender } from '@fold-dev/core' +import React, { FunctionComponent, useContext, useEffect, useRef, useState } from 'react' + +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 && ( + + 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..dee84e41 --- /dev/null +++ b/packages/core/src/data-grid/data-grid-cell.tsx @@ -0,0 +1,176 @@ +import React, { FunctionComponent, useContext, useEffect, useLayoutEffect, useRef, useState } from 'react' +import { + CommonProps, + ContextMenuContext, + DataGridCellComponent, + DataGridContext, + classNames, + dispatchDataGridEvent, + getKey, + stopEvent, + waitForRender, +} 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..bca8f250 --- /dev/null +++ b/packages/core/src/data-grid/data-grid-default-cell-component.tsx @@ -0,0 +1,13 @@ +import { CommonProps, View } from '../' +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..bdd07544 --- /dev/null +++ b/packages/core/src/data-grid/data-grid-header-cell-component.tsx @@ -0,0 +1,69 @@ +import { CommonProps, ContextMenuContext, IconLib, Text } from '../' +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..fec3c72d --- /dev/null +++ b/packages/core/src/data-grid/data-grid-header-cell.tsx @@ -0,0 +1,132 @@ +import React, { FunctionComponent, ReactNode, useContext, useLayoutEffect, useRef } from 'react' +import { + CommonProps, + ContextMenuContext, + ResizableRail, + classNames, + getBoundingClientRect, + useDrag, + windowObject, +} from '../' +import { FOLD_DATA_GRID_DRAG } from './data-grid' +import { DataGridHeaderCellComponent } from './data-grid-header-cell-component' +import { DataGridContext } from './data-grid.provider' + +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..61f63890 --- /dev/null +++ b/packages/core/src/data-grid/data-grid-header.tsx @@ -0,0 +1,85 @@ +import { Checkbox, CoreViewProps, View, classNames } from '../' +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..9bd912d6 --- /dev/null +++ b/packages/core/src/data-grid/data-grid-row.tsx @@ -0,0 +1,140 @@ +import { Checkbox, CommonProps, classNames, useDrag, windowObject } from '../' +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.provider.tsx b/packages/core/src/data-grid/data-grid.provider.tsx new file mode 100644 index 00000000..f6da2953 --- /dev/null +++ b/packages/core/src/data-grid/data-grid.provider.tsx @@ -0,0 +1,271 @@ +import { documentObject, getKey, useEvent, useId, windowObject } from '../' +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..6d950a6f --- /dev/null +++ b/packages/core/src/data-grid/data-grid.stories.tsx @@ -0,0 +1,276 @@ +import { + Button, + DataGrid, DataGridHeader, DataGridProvider, DataGridTypes, + FIBin, + Icon, + Menu, + MenuProvider, + MenuSection, + Portal, + Text, + View, + dataGridState, dispatchDataGridEvent, + useDialog +} from '@fold-dev/core' +import React, { useState } from 'react' +import * as data from '../../../../dummy-data' + +export default { + title: 'Components/DataGrid', + component: <>, + excludeStories: 'docs', +} + +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, +} + +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..eed1f5ef --- /dev/null +++ b/packages/core/src/data-grid/data-grid.tsx @@ -0,0 +1,258 @@ +import { + CoreViewProps, + View, + classNames, + documentObject, + positionDOMElement, + useDrag, + useEvent, +} from '../' +import { globalCursor, windowObject } from '../helpers' +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..d2869208 --- /dev/null +++ b/packages/core/src/data-grid/data-grid.util.ts @@ -0,0 +1,124 @@ +import { useEffect } from 'react' +import { arrayMove, documentObject } from '../' +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..41574e5d --- /dev/null +++ b/packages/core/src/data-grid/index.ts @@ -0,0 +1,11 @@ +export * from './data-grid-cell-component' +export * from './data-grid-cell' +export * from './data-grid-default-cell-component' +export * from './data-grid-header-cell-component' +export * from './data-grid-header-cell' +export * from './data-grid-header' +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..4623eed4 --- /dev/null +++ b/packages/core/src/date-picker/date-cell.tsx @@ -0,0 +1,45 @@ +import React from 'react' +import { CommonProps, classNames } from '../' + +export type DateCellProps = { + disabled?: boolean + unavailable?: boolean + selected?: boolean + today?: boolean + pending?: boolean + pendingStart?: boolean + pendingEnd?: boolean + weekend?: boolean + start?: boolean + end?: boolean + onClick?: (e) => void + onMouseOver?: (e) => void +} & 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..edcc7238 --- /dev/null +++ b/packages/core/src/date-picker/date-picker-month.tsx @@ -0,0 +1,142 @@ +import React, { useContext, useMemo } from 'react' +import { CoreViewProps, Size, View, classNames } from '../' +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..e8c42654 --- /dev/null +++ b/packages/core/src/date-picker/date-picker-weekdays.tsx @@ -0,0 +1,33 @@ +import { CoreViewProps, Size, Text, View, classNames } from '../' +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..b5665765 --- /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) !important; +} + +.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.stories.tsx b/packages/core/src/date-picker/date-picker.stories.tsx new file mode 100644 index 00000000..c6b461e8 --- /dev/null +++ b/packages/core/src/date-picker/date-picker.stories.tsx @@ -0,0 +1,579 @@ +import { + Badge, + Button, + Card, + DateCell, + DatePicker, + DatePickerMonth, + DatePickerProvider, + DatePickerWeekdays, + Heading, + IconLib, + Input, + InputControl, + InputPopover, + InputSuffix, + MonthPicker, + pad, + ScrollingDatePicker, + Stack, + Text, + TimePicker, + useScrollingDatePicker, + useVisibility, + View, + YearPicker +} from '@fold-dev/core' +import React, { useMemo, useRef, useState } from 'react' + +export default { + title: 'Components/DatePicker', + component: DatePicker, + excludeStories: 'docs', +} + +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.', +} + +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..dbf211f9 --- /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 '../' +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..d94d2f26 --- /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.context' +export * from './date-picker' +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..729e689a --- /dev/null +++ b/packages/core/src/date-picker/month-picker.tsx @@ -0,0 +1,124 @@ +import { CoreViewProps, Size, View, classNames, getKey } from '../' +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..42dffc79 --- /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 '../' +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..aa4ee7fa --- /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 '../' +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..09294871 --- /dev/null +++ b/packages/core/src/date-picker/time-picker-time.tsx @@ -0,0 +1,28 @@ +import { CoreViewProps, Size, Text, classNames } from '../' +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..df0fe0b3 --- /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 '../' +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..a06c22d0 --- /dev/null +++ b/packages/core/src/date-picker/year-picker.tsx @@ -0,0 +1,111 @@ +import { CoreViewProps, Size, View, classNames } from '../' +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/drag/drag-area.tsx b/packages/core/src/drag/drag-area.tsx index ff1cddd8..3262d6b1 100644 --- a/packages/core/src/drag/drag-area.tsx +++ b/packages/core/src/drag/drag-area.tsx @@ -341,7 +341,8 @@ export const DragArea = forwardRef((props: DragAreaProps, ref) => { const isDragged = origin.index == index && origin.areaId == id const showFocused = target.focus && isTargetArea && index == target.index const showFirstPlaceholder = (isLinedFocus || isLined) && isTargetArea && index == target.index - const showLastPlaceholder = (isLinedFocus || isLined) && isTargetArea && index + 1 == target.index && isLast + const showLastPlaceholder = + (isLinedFocus || isLined) && isTargetArea && index + 1 == target.index && isLast const noDrag = !!child.props['data-nodrag'] return ( diff --git a/packages/core/src/drag/drag-manager.tsx b/packages/core/src/drag/drag-manager.tsx index 8766ddbf..6a388fda 100644 --- a/packages/core/src/drag/drag-manager.tsx +++ b/packages/core/src/drag/drag-manager.tsx @@ -110,7 +110,7 @@ export const DragManager = (props: DragManagerProps) => { // stop if there is no direction at all // and if there is no indentation movements // && !shouldIndent && !shouldOutdent - if (!!moveDirection) { + if (!!moveDirection) { const element = documentObject.elementFromPoint(mouseX, mouseY) // only process valid elements (non-offscreen) @@ -134,7 +134,11 @@ export const DragManager = (props: DragManagerProps) => { // if the move direction doesn't correlate with the layout // then force the moveDirection in an appropriate direction // if (elementParentDirection == 'horizontal' && (moveDirection == 'up' || moveDirection == 'down')) moveDirection = 'left' - if (elementParentDirection == 'vertical' && (moveDirection == 'left' || moveDirection == 'right') && isDifferentArea) { + if ( + elementParentDirection == 'vertical' && + (moveDirection == 'left' || moveDirection == 'right') && + isDifferentArea + ) { moveDirection = 'up' } @@ -143,8 +147,8 @@ export const DragManager = (props: DragManagerProps) => { elementParentDirection == 'vertical' ? moveDirection == 'down' || moveDirection == 'up' : elementParentDirection == 'horizontal' - ? moveDirection == 'left' || moveDirection == 'right' - : false + ? moveDirection == 'left' || moveDirection == 'right' + : false // see above if (shouldActivate) { @@ -155,13 +159,14 @@ export const DragManager = (props: DragManagerProps) => { const elementIndent = element.dataset.indent ? +element.dataset.indent : 0 const elementNotFromTop = element.parentNode.dataset.notfromtop const elementParentVariant: DragVariant = origin.targetVariant[elementParentGroup] - + // this calculates where the cursor falls on the target element // if it's just focus - then there is no regionSize because we want all of the area // TODO: extend to accommodate vertical directions if ((elementParentVariant == 'lined-focus' || elementParentVariant == 'focus') && isFocus) { const box = element.getBoundingClientRect() - const regionSize = elementParentVariant == 'focus' ? 0 : Math.round(box.height / linedRegionThreshold) + const regionSize = + elementParentVariant == 'focus' ? 0 : Math.round(box.height / linedRegionThreshold) focus = mouseY >= box.top + regionSize && mouseY <= box.bottom - regionSize } @@ -171,21 +176,21 @@ export const DragManager = (props: DragManagerProps) => { let targetIndex = focus ? elementIndex : elementParentDirection == 'vertical' - ? moveDirection == 'down' - ? elementIndex + 1 - : elementIndex - : moveDirection == 'right' - ? elementIndex + 1 - : elementIndex + ? moveDirection == 'down' + ? elementIndex + 1 + : elementIndex + : moveDirection == 'right' + ? elementIndex + 1 + : elementIndex // TODO: find a non-hacky way to do this // we don't bother for horizontal element because the affect is less pronounced - const isFirstElement = + const isFirstElement = elementParentDirection == 'vertical' ? element.offsetTop == 0 : elementParentDirection == 'horizontal' - ? false - : false + ? false + : false // if its the 1st element & from coming in outside the area // cache now() + animation time - 10ms minimum (buffer) @@ -195,10 +200,10 @@ export const DragManager = (props: DragManagerProps) => { // if it's the first element, then always make sure to handle // indexes normally only after the animation has timed out // manually set mouse direction & target index - if (!elementNotFromTop && isFirstElement && (now < cache.time)) { + if (!elementNotFromTop && isFirstElement && now < cache.time) { targetIndex = elementIndex moveDirection = elementParentDirection == 'vertical' ? 'up' : 'left' - } + } // default indent is one from the target index/element let targetIndent = elementIndent @@ -209,7 +214,8 @@ export const DragManager = (props: DragManagerProps) => { // get this from the cache and use it if there is one // this will get set in updateTargetIndent() above - const indentIsCached = cache.indent.index == targetIndex && cache.indent.areaId == elementAreaId + const indentIsCached = + cache.indent.index == targetIndex && cache.indent.areaId == elementAreaId // if it's cached then update the target with the cached level if (indentIsCached) { 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' diff --git a/packages/core/src/helpers/util.ts b/packages/core/src/helpers/util.ts index 37164656..6d5a22a2 100644 --- a/packages/core/src/helpers/util.ts +++ b/packages/core/src/helpers/util.ts @@ -1,5 +1,12 @@ import React, { ReactElement } from 'react' +export const plural = (number, str) => (number == 1 ? str : str + 's') + +export const stopEvent = (e) => { + e.preventDefault() + e.stopPropagation() +} + export const isRightMouseButton = (e) => { return e.which === 3 || e.button === 2 } diff --git a/packages/core/src/hooks/focus.hook.ts b/packages/core/src/hooks/focus.hook.ts index f0a903c8..e6cac929 100644 --- a/packages/core/src/hooks/focus.hook.ts +++ b/packages/core/src/hooks/focus.hook.ts @@ -9,7 +9,7 @@ export const FOCUSABLE = [ 'select:not([disabled])', 'textarea:not([disabled])', '[tabindex]:not([tabindex="-1"])', - '[contenteditable]' + '[contenteditable]', ] export type FocusTrapProps = { @@ -34,13 +34,13 @@ export const useFocus = (focusable = FOCUSABLE, arrowNavigation = false) => { if (isShift && documentObject.activeElement === firstFocusableEl.current) { e.preventDefault() lastFocusableEl.current?.focus({ preventScroll }) - } - + } + if (!isShift && documentObject.activeElement === lastFocusableEl.current) { e.preventDefault() firstFocusableEl.current?.focus({ preventScroll }) } - } + } } const trapFocus = (el, preventScroll = false) => { diff --git a/packages/core/src/hooks/hooks.stories.tsx b/packages/core/src/hooks/hooks.stories.tsx index 5e0c6624..4c6605e9 100644 --- a/packages/core/src/hooks/hooks.stories.tsx +++ b/packages/core/src/hooks/hooks.stories.tsx @@ -111,10 +111,10 @@ export const Focus = () => { const { trapFocus } = useFocus() return ( - - + + = new Set([ export const usePreventScrolling = (shouldPreventScrolling) => { useEffect(() => { if (!shouldPreventScrolling) return - + const preventDefault = (e: Event) => e.preventDefault() const preventDefaultOnScrollKeys = (e: KeyboardEvent) => { if (scrollKeys.has(e.key)) e.preventDefault() @@ -45,7 +45,7 @@ export const usePreventScrolling = (shouldPreventScrolling) => { function preventScroll() { windowObject.addEventListener('DOMMouseScroll', preventDefault, false) windowObject.addEventListener(wheelEvent, preventDefault, wheelOpt) - windowObject.addEventListener('touchmove', preventDefault, wheelOpt) + windowObject.addEventListener('touchmove', preventDefault, wheelOpt) windowObject.addEventListener('keydown', preventDefaultOnScrollKeys, false) } diff --git a/packages/core/src/icon/icons.tsx b/packages/core/src/icon/icons.tsx index 244f9e7a..052678e4 100644 --- a/packages/core/src/icon/icons.tsx +++ b/packages/core/src/icon/icons.tsx @@ -7,10 +7,10 @@ export const FIRepeat = (props: any) => ( viewBox="0 0 24 24" stroke="currentColor" {...props}> - ) 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/input/input-popover.tsx b/packages/core/src/input/input-popover.tsx index 862527ae..22d91cab 100644 --- a/packages/core/src/input/input-popover.tsx +++ b/packages/core/src/input/input-popover.tsx @@ -9,21 +9,21 @@ export type InputPopoverProps = { defaultVisibility?: boolean addTabIndexToChild?: boolean popoverProps?: PopoverProps - children: ReactNode + children: ReactNode content: ReactNode focusTrap?: boolean } export const InputPopover = (props: InputPopoverProps) => { - const { + const { __openDelay = 100, openOnFocus, - id, + id, addTabIndexToChild = true, firstTimeFocusOpen = true, - defaultVisibility = false, - popoverProps = {}, - children, + defaultVisibility = false, + popoverProps = {}, + children, content, focusTrap = true, } = props diff --git a/packages/core/src/menu/menu.tsx b/packages/core/src/menu/menu.tsx index bd0bfd04..115efbe8 100644 --- a/packages/core/src/menu/menu.tsx +++ b/packages/core/src/menu/menu.tsx @@ -8,8 +8,27 @@ import React, { useRef, useState, } from 'react' -import { Button, ButtonProps, Heading, HeadingProps, Popover, PopoverProps, Text, usePreventScrolling, useVisibility, View } from '../' -import { classNames, documentObject, getBoundingClientRect, getKey, isOffScreen, renderChildren, renderWithProps } from '../helpers' +import { + Button, + ButtonProps, + Heading, + HeadingProps, + Popover, + PopoverProps, + Text, + usePreventScrolling, + useVisibility, + View, +} from '../' +import { + classNames, + documentObject, + getBoundingClientRect, + getKey, + isOffScreen, + renderChildren, + renderWithProps, +} from '../helpers' import { IconLib } from '../icon' import { CoreViewProps, Size } from '../types' @@ -148,7 +167,7 @@ export const Menu = (props: MenuProps) => { [props.className] ) - const closeFromMenu = () => closeFromParentMenuItem ? closeFromParentMenuItem() : null + const closeFromMenu = () => (closeFromParentMenuItem ? closeFromParentMenuItem() : null) const firstMenuItem = () => menuItemRefs.current[0] @@ -519,9 +538,7 @@ export const MenuProvider = (props: MenuProviderProps) => { fixPosition={{ left: position.x, top: position.y }} content={menu({ data, dismiss })} onDismiss={() => setPosition({ x: 0, y: 0 })}> - - {children} - + {children} ) } diff --git a/packages/core/src/modal/modal.tsx b/packages/core/src/modal/modal.tsx index 45f4f191..b49e0d98 100644 --- a/packages/core/src/modal/modal.tsx +++ b/packages/core/src/modal/modal.tsx @@ -121,9 +121,27 @@ export const Modal = forwardRef((props: ModalProps, ref) => { tabIndex={0} onKeyDown={handleKeyDown} ref={mergeRefs([contentRef, ref])}> - {header &&
{header}
} - {props.children &&
{props.children}
} - {footer &&
{footer}
} + {header && ( +
+ {header} +
+ )} + {props.children && ( +
+ {props.children} +
+ )} + {footer && ( +
+ {footer} +
+ )}
) diff --git a/packages/core/src/option/option.stories.tsx b/packages/core/src/option/option.stories.tsx index bc030973..3683c8f8 100644 --- a/packages/core/src/option/option.stories.tsx +++ b/packages/core/src/option/option.stories.tsx @@ -18,7 +18,10 @@ export const Usage = () => { const [option, setOption] = useState(1) return ( - + @@ -38,7 +41,10 @@ export const States = () => { const [option, setOption] = useState(1) return ( - + { const [option, setOption] = useState(1) return ( - + @@ -124,7 +133,10 @@ export const WithPrefixAndSuffix = () => { const [option, setOption] = useState(1) return ( - + { const [page, setPage] = useState(1) return ( - ( // -- export const Sizes = () => ( - + React VueJS Svelte @@ -44,7 +46,9 @@ export const Sizes = () => ( // -- export const WithPrefixAndSuffix = () => ( - + ( // -- export const Styles = () => ( - + React VueJS Svelte @@ -115,7 +121,9 @@ export const Styles = () => ( * The Pill component takes a hex-code value for the color. */ export const Color = () => ( - + React { const { isEscape } = getKey(e) if (isEscape && onDismiss) dismissPopover(e, false) } - } const handleKeyDown = (e) => { @@ -108,7 +115,6 @@ export const Popover = forwardRef((props: PopoverProps, ref) => { } } - const handleClick = (e) => { if (containerRef.current) { if (!containerRef.current?.contains(e.target)) { @@ -126,7 +132,7 @@ export const Popover = forwardRef((props: PopoverProps, ref) => { transform: `translate(${box.left}px, ${box.top}px)`, width: box.width, height: box.height, - position: isFixed || !!portal ? 'fixed' : 'absolute', + position: isFixed || !!portal ? 'fixed' : 'absolute', }} {...anchorProps}> { }) })} - {(showPopover && !portal) && renderPopover()} + {showPopover && !portal && renderPopover()} - {(showPopover && !!portal) && ( - - {renderPopover()} - - )} + {showPopover && !!portal && {renderPopover()}} ) }) - - diff --git a/packages/core/src/progress/progress.stories.tsx b/packages/core/src/progress/progress.stories.tsx index d1a45b9a..958ffc82 100644 --- a/packages/core/src/progress/progress.stories.tsx +++ b/packages/core/src/progress/progress.stories.tsx @@ -156,7 +156,9 @@ export const States = () => ( // -- export const Circle = () => ( - + ( // -- export const Pie = () => ( - + { onKeyDown={handleKeyDownInput} onClick={handleClick} className="f-select" - render={render} + render={render} {...tagInputProps}> { /> {suffix && {suffix}} - )} {visible && ( diff --git a/packages/core/src/styles/styles.css b/packages/core/src/styles/styles.css index e4badb45..2a385e48 100644 --- a/packages/core/src/styles/styles.css +++ b/packages/core/src/styles/styles.css @@ -44,7 +44,6 @@ we don't want to use !important @import '../cropper/cropper.css'; @import '../dialog/dialog.css'; @import '../divider/divider.css'; -@import '../drag/drag.css'; @import '../drawer/drawer.css'; @import '../editable/editable.css'; @import '../form/form.css'; @@ -90,4 +89,10 @@ we don't want to use !important @import '../view/view.css'; @import '../virtual/virtual.css'; +/* more complex components load last */ + +@import '../drag/drag.css'; +@import '../data-grid/data-grid.css'; +@import '../date-picker/date-picker.css'; + diff --git a/packages/core/src/text/text.stories.tsx b/packages/core/src/text/text.stories.tsx index 8ce1d10f..f46a5379 100644 --- a/packages/core/src/text/text.stories.tsx +++ b/packages/core/src/text/text.stories.tsx @@ -78,15 +78,14 @@ export const Variants = () => ( // -- export const ShowLessOrMore = () => ( - + ) // -- export const Highlight = () => ( - Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. + Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem + aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. ) diff --git a/packages/core/src/text/text.tsx b/packages/core/src/text/text.tsx index 04c3cfa3..247ac896 100644 --- a/packages/core/src/text/text.tsx +++ b/packages/core/src/text/text.tsx @@ -59,7 +59,7 @@ export const HighlightText = forwardRef((props: { highlight?: string } & TextPro title={title} target={target} ref={ref} - dangerouslySetInnerHTML={{ __html: text }} + dangerouslySetInnerHTML={{ __html: text }} /> ) }) @@ -84,7 +84,7 @@ export const Text = forwardRef((props: TextProps, ref) => { href={href} title={title} target={target} - ref={ref} + ref={ref} /> ) }) @@ -117,13 +117,7 @@ export type LimitedTextProps = { } & TextProps export const LimitedText = forwardRef((props: LimitedTextProps, ref) => { - const { - limit = 200, - html, - showLess = 'show less', - showMore = 'show more', - ...rest - } = props + const { limit = 200, html, showLess = 'show less', showMore = 'show more', ...rest } = props const { visible, show, hide } = useVisibility(false) const { text, showButton } = useMemo(() => { const el = documentObject.createElement('span') @@ -147,11 +141,12 @@ export const LimitedText = forwardRef((props: LimitedTextProps, ref) => { {...rest} as="p" ref={ref}> - + {showButton && ( - <> ... + {' '} + ...{' '} + {visible ? showLess : showMore} diff --git a/packages/core/src/virtual/virtual.tsx b/packages/core/src/virtual/virtual.tsx index b0e87c77..61b8378e 100644 --- a/packages/core/src/virtual/virtual.tsx +++ b/packages/core/src/virtual/virtual.tsx @@ -11,15 +11,7 @@ export type VirtualProps = { } & CoreViewProps export const Virtual = (props: any) => { - const { - numItems, - watch, - itemHeight = 35, - maxHeight = 300, - render, - className = '', - ...rest - } = props + const { numItems, watch, itemHeight = 35, maxHeight = 300, render, className = '', ...rest } = props const scrollRef = useRef(null) const [scrollTop, setScrollTop] = useState(0) const innerHeight = numItems * itemHeight @@ -61,16 +53,7 @@ export const Virtual = (props: any) => { } export const VirtualExperimental = (props: any) => { - const { - numItems, - watch, - itemHeight, - render, - maxHeight = 400, - width = '100%', - loadPrevious, - loadNext - } = props + const { numItems, watch, itemHeight, render, maxHeight = 400, width = '100%', loadPrevious, loadNext } = props const changeRef = useRef(null) const scrollRef = useRef(null) const [scrollTop, setScrollTop] = useState(0)