From 4d995b17471106e1c5913570a50285debb803c66 Mon Sep 17 00:00:00 2001 From: GermanVor Date: Thu, 8 Feb 2024 16:44:57 +0100 Subject: [PATCH] wip --- src/components/Button/Button.tsx | 3 + .../Table/__stories__/Table.stories.tsx | 20 ++ .../TableColumnSetup/TableColumnSetup.tsx | 250 +++++++----------- .../withTableSettings/withTableSettings.tsx | 169 +++++------- src/components/TreeSelect/DndTreeSelect.tsx | 99 +++---- src/components/TreeSelect/TreeSelect.tsx | 2 + .../components/WithDndListExample.tsx | 14 +- .../components/ListItemView/ListItemView.tsx | 7 +- 8 files changed, 256 insertions(+), 308 deletions(-) diff --git a/src/components/Button/Button.tsx b/src/components/Button/Button.tsx index babe0860ba..8f81023262 100644 --- a/src/components/Button/Button.tsx +++ b/src/components/Button/Button.tsx @@ -78,6 +78,7 @@ export interface ButtonProps extends DOMProps, QAProps { onMouseLeave?: React.MouseEventHandler; onFocus?: React.FocusEventHandler; onBlur?: React.FocusEventHandler; + onKeyDown?: React.KeyboardEventHandler /** Button content. You can mix button text with `` component */ children?: React.ReactNode; } @@ -106,6 +107,7 @@ const ButtonWithHandlers = React.forwardRef(function B onMouseLeave, onFocus, onBlur, + onKeyDown, children, id, style, @@ -138,6 +140,7 @@ const ButtonWithHandlers = React.forwardRef(function B onMouseLeave, onFocus, onBlur, + onKeyDown, id, style, className: b( diff --git a/src/components/Table/__stories__/Table.stories.tsx b/src/components/Table/__stories__/Table.stories.tsx index 762032e99e..35aa7cee6f 100644 --- a/src/components/Table/__stories__/Table.stories.tsx +++ b/src/components/Table/__stories__/Table.stories.tsx @@ -178,9 +178,29 @@ const WithTableSettingsTemplate: StoryFn> = (args, context) } }; export const HOCWithTableSettings = WithTableSettingsTemplate.bind({}); +HOCWithTableSettings.parameters = { + // Strict mode ruins sortable list due to this react-beautiful-dnd issue + // https://github.com/atlassian/react-beautiful-dnd/issues/2350 + disableStrictMode: true, +}; +const columnsWithSettings = _cloneDeep(columns); +const COLUMN_IDX = 2; +columnsWithSettings[COLUMN_IDX].meta = columnsWithSettings[COLUMN_IDX].meta || {}; +columnsWithSettings[COLUMN_IDX].meta.selectedAlways = true; + +columnsWithSettings[3].meta = columnsWithSettings[3].meta || {}; +columnsWithSettings[3].meta.selectedAlways = true; +HOCWithTableSettings.args = { + columns: columnsWithSettings, +}; + export const HOCWithTableSettingsFactory = WithTableSettingsTemplate.bind({}); HOCWithTableSettingsFactory.parameters = { isFactory: true, + + // Strict mode ruins sortable list due to this react-beautiful-dnd issue + // https://github.com/atlassian/react-beautiful-dnd/issues/2350 + disableStrictMode: true, }; // --------------------------------- diff --git a/src/components/Table/hoc/withTableSettings/TableColumnSetup/TableColumnSetup.tsx b/src/components/Table/hoc/withTableSettings/TableColumnSetup/TableColumnSetup.tsx index dbaf284b23..ee51204355 100644 --- a/src/components/Table/hoc/withTableSettings/TableColumnSetup/TableColumnSetup.tsx +++ b/src/components/Table/hoc/withTableSettings/TableColumnSetup/TableColumnSetup.tsx @@ -1,13 +1,16 @@ import React from 'react'; -import {Check, Gear, Lock} from '@gravity-ui/icons'; +import {Gear, Lock} from '@gravity-ui/icons'; import type {PopperPlacement} from '../../../../../hooks/private'; -import {useActionHandlers} from '../../../../../hooks/useActionHandlers'; import {Button} from '../../../../Button'; import {Icon} from '../../../../Icon'; -import {List} from '../../../../List'; -import {Popup} from '../../../../Popup'; +import type {TreeSelectProps} from '../../../../TreeSelect'; +import {DndTreeSelect} from '../../../../TreeSelect/DndTreeSelect'; +import type { + DndTreeSelectItemType, + RenderDndContainerType, +} from '../../../../TreeSelect/DndTreeSelect'; import {block} from '../../../../utils/cn'; import type {TableColumnSetupItem} from '../withTableSettings'; @@ -17,199 +20,130 @@ import './TableColumnSetup.scss'; const b = block('table-column-setup'); -type Item = TableColumnSetupItem; - interface SwitcherProps { onKeyDown: React.KeyboardEventHandler; onClick: React.MouseEventHandler; } +type Item = TableColumnSetupItem & DndTreeSelectItemType; +const prepareItem = (tableColumnItem: TableColumnSetupItem): Item => { + const hasSelectionIcon = tableColumnItem.isRequired === false; + + return { + ...tableColumnItem, + startSlot: hasSelectionIcon ? undefined : , + hasSelectionIcon, + hasSelectionBackground: false, + }; +}; + +const prepareItems = (tableColumnItem: TableColumnSetupItem[]): Item[] => { + return tableColumnItem.map(prepareItem); +}; + export interface TableColumnSetupProps { renderSwitcher?: (props: SwitcherProps) => React.ReactElement | undefined; // for List - items: Item[]; + items: TableColumnSetupItem[]; sortable?: boolean; - onUpdate: (updated: Item[]) => void; + onUpdate: (updated: TableColumnSetupItem[]) => void; popupWidth?: number | string; popupPlacement?: PopperPlacement; } export const TableColumnSetup = (props: TableColumnSetupProps) => { - const {renderSwitcher, popupWidth, popupPlacement, items: propsItems, sortable = true} = props; - - const [focused, setFocused] = React.useState(false); - const [items, setItems] = React.useState([]); - const [currentItems, setCurrentItems] = React.useState([]); - const [requiredItems, setRequiredItems] = React.useState([]); + const { + renderSwitcher, + // popupWidth, + popupPlacement, + items: propsItems, + onUpdate: propsOnUpdate, + } = props; - const refControl = React.useRef(null); + const [open, setOpen] = React.useState(false); - const LIST_ITEM_HEIGHT = 36; - - const getRequiredItems = (list: Item[]) => - list - .filter(({required}) => required) - .map((column) => ({ - ...column, - disabled: true, - })); - - const getConfigurableItems = (list: Item[]) => list.filter(({required}) => !required); + const [items, setItems] = React.useState(prepareItems(propsItems)); React.useEffect(() => { - if (propsItems !== items) { - setItems(propsItems); - setRequiredItems(getRequiredItems(propsItems)); - setCurrentItems(getConfigurableItems(propsItems)); - } - }, [items, propsItems]); - - const setInitialState = () => { - setFocused(false); - setRequiredItems(getRequiredItems(items)); - setCurrentItems(getConfigurableItems(items)); - }; - - const getListHeight = (list: Item[]) => { - return Math.min(5, list.length) * LIST_ITEM_HEIGHT + LIST_ITEM_HEIGHT / 2; - }; - - const getRequiredListHeight = (list: Item[]) => { - return list.length * LIST_ITEM_HEIGHT; - }; - - const makeOnSortEnd = - (list: Item[]) => - ({oldIndex, newIndex}: {oldIndex: number; newIndex: number}) => { - setCurrentItems(List.moveListElement(list.slice(), oldIndex, newIndex)); - }; + const newItems = prepareItems(propsItems); + setItems(newItems); + }, [propsItems]); - const handleUpdate = (value: Item[]) => setCurrentItems(value); - - const handleClosePopup = () => setInitialState(); - - const handleControlClick = React.useCallback(() => { - setFocused(!focused); - setRequiredItems(getRequiredItems(items)); - setCurrentItems(getConfigurableItems(items)); - }, [focused, items]); - - const handleApplyClick = () => { - setInitialState(); - - const newItems = requiredItems.concat(currentItems); - - if (items !== newItems) { - props.onUpdate(newItems); - } + const onApply = () => { + propsOnUpdate(items); + setOpen(false); }; - const handleItemClick = (value: Item) => { - const newItems = currentItems.map((item) => - item === value ? {...item, selected: !item.selected} : item, + const renderContainer: RenderDndContainerType = ({renderList}) => { + return ( + + {renderList()} + + ); - handleUpdate(newItems); }; - const renderItem = (item: Item) => { + const renderControl: TreeSelectProps['renderControl'] = ({toggleOpen}) => { return ( -
- {item.required ? ( -
- -
- ) : ( -
- -
- )} -
{item.title}
-
+ renderSwitcher?.({onClick: toggleOpen, onKeyDown: toggleOpen}) || ( + + ) ); }; - const renderRequiredColumns = () => { - const hasRequiredColumns = requiredItems.length; + const onOpenChange = (open: boolean) => { + setOpen(open); - if (!hasRequiredColumns) { - return null; + if (open === false) { + const initialItems = prepareItems(propsItems); + setItems(initialItems); } - - return ( - - ); }; - const renderConfigurableColumns = () => { - return ( - - ); - }; + const onUpdate = React.useCallback((selectedItemsIds: string[]) => { + setItems((prevItems) => { + return prevItems.map((item) => ({ + ...item, + isSelected: selectedItemsIds.includes(item.id), + })); + }); + }, []); - const {onKeyDown: handleControlKeyDown} = useActionHandlers(handleControlClick); + const value = React.useMemo(() => { + const selectedIds: string[] = []; - const switcherProps = React.useMemo( - () => ({ - onClick: handleControlClick, - onKeyDown: handleControlKeyDown, - }), - [handleControlClick, handleControlKeyDown], - ); + items.forEach(({id, isSelected}) => { + if (isSelected) { + selectedIds.push(id); + } + }); + + return selectedIds; + }, [items]); return (
- {/* FIXME remove switcher prop and this wrapper */} - {/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */} -
- {renderSwitcher?.(switcherProps) || ( - - )} -
- - {renderRequiredColumns()} - {renderConfigurableColumns()} -
- -
-
+
); }; diff --git a/src/components/Table/hoc/withTableSettings/withTableSettings.tsx b/src/components/Table/hoc/withTableSettings/withTableSettings.tsx index 634b87a66c..98a3b7bf47 100644 --- a/src/components/Table/hoc/withTableSettings/withTableSettings.tsx +++ b/src/components/Table/hoc/withTableSettings/withTableSettings.tsx @@ -18,22 +18,17 @@ import i18n from './i18n'; import './withTableSettings.scss'; -interface SortableItem { +export type TableSetting = { id: string; - title: React.ReactNode; isSelected?: boolean; - isProtected?: boolean; -} -export interface TableColumnSetupItem { - id: string; +}; + +export type TableSettingsData = TableSetting[]; + +export type TableColumnSetupItem = TableSetting & { title: React.ReactNode; - selected?: boolean; - required?: boolean; -} -export type TableSettingsData = Array<{ - id: string; - isSelected?: boolean; -}>; + isRequired?: boolean; +}; export function filterColumns( columns: TableColumnConfig[], @@ -72,47 +67,42 @@ export function getColumnStringTitle(column: TableColumnConfig) { export function getActualItems( columns: TableColumnConfig[], settings: TableSettingsData, -): SortableItem[] { - const newColumnSettings = columns - .filter( - ({id}) => - id !== actionsColumnId && - id !== selectionColumnId && - settings.every((setting) => setting.id !== id), - ) - .map((column) => ({ - id: column.id, - isSelected: column.meta?.selectedByDefault !== false, - })); - return settings - .filter(({id}) => columns.some((column) => id === column.id)) - .concat(newColumnSettings) - .map(({id, isSelected}) => { - const foundColumn = columns.find((column) => column.id === id); - const isProtected = Boolean(foundColumn?.meta?.selectedAlways); - return { - id, - isSelected: isProtected ? true : isSelected, - isProtected, - title: foundColumn ? getColumnStringTitle(foundColumn) : id, - }; - }); -} - -function prepareColumnSetupItems(items: SortableItem[]): TableColumnSetupItem[] { - return items.map(({id, title, isSelected, isProtected}) => ({ - id, - title, - selected: isSelected, - required: isProtected, - })); -} +): TableColumnSetupItem[] { + const getTableColumnSetupItem = ( + id: string, + isSelected: boolean | undefined, + column = columns.find((column) => column.id === id), + ): TableColumnSetupItem => { + const isProtected = Boolean(column?.meta?.selectedAlways); + + return { + id, + isSelected: isProtected ? true : isSelected, + isRequired: isProtected, + title: column ? getColumnStringTitle(column) : id, + }; + }; + + const sortableItems: TableColumnSetupItem[] = []; + + settings.forEach(({id, isSelected}) => { + if (columns.some((column) => id === column.id)) { + sortableItems.push(getTableColumnSetupItem(id, isSelected)); + } + }); + + columns.forEach((column) => { + if ( + column.id !== actionsColumnId && + column.id !== selectionColumnId && + settings.every((setting) => setting.id !== column.id) + ) { + const isSelected = column.meta?.selectedByDefault !== false; + sortableItems.push(getTableColumnSetupItem(column.id, isSelected, column)); + } + }); -function prepareUpdateSettings(items: TableColumnSetupItem[]): TableSettingsData { - return items.map(({id, selected}) => ({ - id, - isSelected: selected, - })); + return sortableItems; } export interface WithTableSettingsOptions { @@ -125,6 +115,7 @@ export interface WithTableSettingsProps { * @deprecated Use factory notation: "withTableSettings({width: })(Table)" */ settingsPopupWidth?: number | string; + settings: TableSettingsData; updateSettings: (data: TableSettingsData) => void; } @@ -159,51 +150,33 @@ export function withTableSettings( settingsPopupWidth, ...restTableProps }: TableProps & WithTableSettingsProps & E) { - const actualItems = React.useMemo( - () => getActualItems(columns, settings || []), - [columns, settings], - ); - - const onUpdateColumns = React.useCallback( - (newItems: TableColumnSetupItem[]) => { - updateSettings(prepareUpdateSettings(newItems)); - }, - [updateSettings], - ); - - const columnSetupItems = React.useMemo( - () => prepareColumnSetupItems(actualItems), - [actualItems], - ); - - const enhancedColumns = React.useMemo( - () => - enhanceSystemColumn(filterColumns(columns, actualItems), (systemColumn) => { - // eslint-disable-next-line react/display-name - systemColumn.name = () => ( -
- ( - - )} - /> -
- ); - }), - [actualItems, columnSetupItems, columns, onUpdateColumns, settingsPopupWidth], - ); + const enhancedColumns = React.useMemo(() => { + const actualItems = getActualItems(columns, settings || []); + + return enhanceSystemColumn(filterColumns(columns, actualItems), (systemColumn) => { + systemColumn.name = () => ( +
+ ( + + )} + /> +
+ ); + }); + }, [columns, settings, updateSettings, settingsPopupWidth]); return ( diff --git a/src/components/TreeSelect/DndTreeSelect.tsx b/src/components/TreeSelect/DndTreeSelect.tsx index 63c01838d0..007cf99a79 100644 --- a/src/components/TreeSelect/DndTreeSelect.tsx +++ b/src/components/TreeSelect/DndTreeSelect.tsx @@ -6,51 +6,15 @@ import type {OnDragEndResponder} from 'react-beautiful-dnd'; import {Icon} from '../Icon'; import {ListContainerView} from '../useList'; -import type {ListItemType} from '../useList'; import {reorderArray} from '../useList/__stories__/utils/reorderArray'; import {TreeSelect} from './TreeSelect'; import {TreeSelectItem} from './TreeSelectItem'; +import type {TreeSelectItemProps} from './TreeSelectItem'; import type {RenderContainerType, RenderItem, TreeSelectProps} from './types'; -const renderDndItem: RenderItem = (item, state, _, idx) => { - const commonProps = { - ...state, - title: item.content, - endSlot: , - }; - - return ( - - {(provided, snapshot) => { - const style: React.CSSProperties = { - ...provided.draggableProps.style, - }; - - // not expected offset appears, one way to fix - remove this offsets explicitly - if (snapshot.isDragging) { - style.left = undefined; - style.top = undefined; - } - - return ( - - ); - }} - - ); -}; - -export type DndTreeSelectItemType = { +export type DndTreeSelectItemType = TreeSelectItemProps & { id: string; - content: React.ReactNode; }; export type RenderDndContainerType = (container: { @@ -61,27 +25,27 @@ const DEFAULT_RENDER_CONTAINER: RenderDndContainerType = ({renderList}) => rende let dndTreeSelectCount = 0; -export type DndTreeSelectProps = Omit< +export type DndTreeSelectProps = Omit< TreeSelectProps, 'items' | 'renderContainer' | 'renderItem' > & { - items: ListItemType[]; - setItems: (_: ListItemType[]) => void; + items: T[]; + setItems: (_: T[]) => void; renderContainer?: RenderDndContainerType; initialDroppableId?: string; }; -export function DndTreeSelect({ +export function DndTreeSelect({ items, setItems, initialDroppableId, renderContainer: propsRenderContainer = DEFAULT_RENDER_CONTAINER, ...treeSelectNativeProps -}: DndTreeSelectProps) { +}: DndTreeSelectProps) { const [droppableId] = React.useState( () => initialDroppableId || `default-droppable-id-${dndTreeSelectCount++}`, ); - const renderContainer = React.useCallback>( + const renderContainer = React.useCallback>( ({renderItem, visibleFlattenIds, items: _items, containerRef, id}) => { const handleDrugEnd: OnDragEndResponder = ({destination, source}) => { if (destination?.index !== undefined && destination?.index !== source.index) { @@ -117,11 +81,54 @@ export function DndTreeSelect({ return propsRenderContainer({renderList}); }, - [items, setItems, propsRenderContainer], + [items, setItems, propsRenderContainer, droppableId], ); + const renderDndItem = React.useCallback>((item, state, _, idx) => { + const endSlot = + item.endSlot ?? (item.disabled ? undefined : ); + + const commonProps = { + ...state, + ...item, + endSlot, + }; + + return ( + + {(provided, snapshot) => { + const style: React.CSSProperties = { + ...provided.draggableProps.style, + }; + + // not expected offset appears, one way to fix - remove this offsets explicitly + if (snapshot.isDragging) { + style.left = undefined; + style.top = undefined; + } + + return ( + + ); + }} + + ); + }, []); + return ( - + ( renderItem, renderContainer: RenderContainer = TreeListContainer, onItemClick, + placement, } = props; const mobile = useMobile(); @@ -250,6 +251,7 @@ export const TreeSelect = React.forwardRef(function TreeSelect( className={b('popup', popupClassName)} controlRef={controlRef} width={popupWidth} + placement={placement} open={open} handleClose={handleClose} disablePortal={popupDisablePortal} diff --git a/src/components/TreeSelect/__stories__/components/WithDndListExample.tsx b/src/components/TreeSelect/__stories__/components/WithDndListExample.tsx index d74520b7d3..519b95dfe6 100644 --- a/src/components/TreeSelect/__stories__/components/WithDndListExample.tsx +++ b/src/components/TreeSelect/__stories__/components/WithDndListExample.tsx @@ -15,13 +15,17 @@ export interface WithDndListExampleProps let i = 0; export const WithDndListExample = (props: WithDndListExampleProps) => { - const [items, setItems] = React.useState(() => - createRandomizedData({ + const [items, setItems] = React.useState(() => { + const _items: DndTreeSelectItemType[] = []; + + createRandomizedData({ num: 10, depth: 0, - getData: (title) => ({id: `id-${i++}`, content: title}), - }), - ); + getData: (title) => _items.push({id: `id-${i++}`, title}), + }); + + return _items; + }); return ( diff --git a/src/components/useList/components/ListItemView/ListItemView.tsx b/src/components/useList/components/ListItemView/ListItemView.tsx index 90be600114..7e1af9eba4 100644 --- a/src/components/useList/components/ListItemView/ListItemView.tsx +++ b/src/components/useList/components/ListItemView/ListItemView.tsx @@ -36,6 +36,10 @@ export interface ListItemViewProps extends QAProps { * Build in indentation component to render nested views structure */ indentation?: number; + /** + * Show selected background if selected + */ + hasSelectionBackground?: boolean; /** * Show selected icon if selected and reserve space for this icon */ @@ -97,6 +101,7 @@ export const ListItemView = React.forwardRef( activeOnHover = true, className, hasSelectionIcon = true, + hasSelectionBackground = true, indentation, startSlot, subtitle, @@ -122,7 +127,7 @@ export const ListItemView = React.forwardRef( className={b( { active, - selected: selected && !hasSelectionIcon, + selected: selected && hasSelectionBackground && !hasSelectionIcon, activeOnHover, radius: size, clickable: Boolean(onClick),