Skip to content

Commit

Permalink
feat(TreeSelect): added TreeSelect unstable component and new list ho…
Browse files Browse the repository at this point in the history
…oks (#1090)

Co-authored-by: Alexandr Isaev <[email protected]>
  • Loading branch information
2 people authored and amje committed Feb 1, 2024
1 parent 7f1cee0 commit 7c7811a
Show file tree
Hide file tree
Showing 70 changed files with 4,395 additions and 6 deletions.
8 changes: 8 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,11 @@
"require": "./build/cjs/components/utils/addComponentKeysets.js",
"import": "./build/esm/components/utils/addComponentKeysets.js"
},
"./unstable": {
"types": "./build/esm/unstable.d.ts",
"require": "./build/cjs/unstable.js",
"import": "./build/esm/unstable.js"
},
"./styles/*": "./styles/*"
},
"main": "./build/cjs/index.js",
Expand All @@ -46,6 +51,9 @@
],
"i18n": [
"./build/esm/components/utils/addComponentKeysets.d.ts"
],
"unstable": [
"./build/esm/unstable.d.ts"
]
}
},
Expand Down
2 changes: 1 addition & 1 deletion src/components/Select/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ export type SelectRenderClearArgs = {
export type SelectRenderControlProps = {
onClear: () => void;
onClick: () => void;
onKeyDown: (e: React.KeyboardEvent<HTMLElement>) => void;
onKeyDown?: (e: React.KeyboardEvent<HTMLElement>) => void;
renderClear?: (args: SelectRenderClearArgs) => React.ReactNode;
ref: React.Ref<HTMLElement>;
open: boolean;
Expand Down
18 changes: 18 additions & 0 deletions src/components/TreeSelect/TreeSelect.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
@use '../variables';

$block: '.#{variables.$ns}tree-select';

#{$block} {
max-width: 100%;

&_width_max {
width: 100%;
}

&__popup {
padding: 4px 0;
border-radius: 6px;
overflow: hidden;
min-width: 300px;
}
}
306 changes: 306 additions & 0 deletions src/components/TreeSelect/TreeSelect.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,306 @@
import React from 'react';

import {useForkRef, useUniqId} from '../../hooks';
import {SelectControl} from '../Select/components';
import {SelectPopup} from '../Select/components/SelectPopup/SelectPopup';
import {Flex} from '../layout';
import {useMobile} from '../mobile';
import {
type ListItemId,
getItemRenderState,
isKnownStructureGuard,
scrollToListItem,
useList,
useListKeydown,
useListState,
} from '../useList';
import {type CnMods, block} from '../utils/cn';

import {TreeSelectItem} from './TreeSelectItem';
import {TreeListContainer} from './components/TreeListContainer/TreeListContainer';
import {useTreeSelectSelection, useValue} from './hooks/useTreeSelectSelection';
import type {RenderControlProps, TreeSelectProps} from './types';

import './TreeSelect.scss';

const b = block('tree-select');

export const TreeSelect = React.forwardRef(function TreeSelect<T>(
props: TreeSelectProps<T>,
ref: React.Ref<HTMLButtonElement>,
) {
const {
id,
slotBeforeListBody,
slotAfterListBody,
size = 'm',
items,
defaultOpen,
className,
width,
popupClassName,
open: propsOpen,
multiple,
popupWidth,
expandedById,
disabledById,
activeItemId,
defaultValue,
popupDisablePortal,
groupsBehavior = 'expandable',
value: propsValue,
onClose,
onUpdate,
getId,
onOpenChange,
renderControl,
renderItem,
renderContainer: RenderContainer = TreeListContainer,
onItemClick,
} = props;

const [mobile] = useMobile();
const uniqId = useUniqId();
const treeSelectId = id ?? uniqId;

const controlWrapRef = React.useRef<HTMLDivElement>(null);
const controlRef = React.useRef<HTMLElement>(null);
const containerRef = React.useRef<HTMLDivElement>(null);
const handleControlRef = useForkRef(ref, controlRef);

const {value, setInnerValue, selected} = useValue({
value: propsValue,
defaultValue,
});

const listState = useListState({
expandedById,
disabledById,
activeItemId,
selectedById: selected,
});

const listParsedState = useList({
items,
getId,
...listState,
});

const wrappedOnUpdate = React.useCallback(
(ids: ListItemId[]) =>
onUpdate?.(
ids,
ids.map((id) => listParsedState.itemsById[id]),
),
[listParsedState.itemsById, onUpdate],
);

const {open, toggleOpen, handleClearValue, handleMultipleSelection, handleSingleSelection} =
useTreeSelectSelection({
setInnerValue,
value,
onUpdate: wrappedOnUpdate,
defaultOpen,
open: propsOpen,
onClose,
onOpenChange,
});

const handleItemClick = React.useCallback(
(id: ListItemId) => {
// onItemClick = disabled - switch off default click behavior
if (onItemClick === 'disabled') return undefined;

const defaultHandleClick = () => {
if (listState.disabledById[id]) return;

// always activate selected item
listState.setActiveItemId(id);

const isGroup = id in listParsedState.groupsState;

if (isGroup && groupsBehavior === 'expandable') {
listState.setExpanded((state) => ({
...state,
// toggle expanded state by id, by default all groups expanded
[id]: typeof state[id] === 'boolean' ? !state[id] : false,
}));
} else if (multiple) {
handleMultipleSelection(id);
} else {
handleSingleSelection(id);
toggleOpen(false);
}
};

if (onItemClick) {
return onItemClick(defaultHandleClick, {
id,
isGroup: id in listParsedState.groupsState,
isLastItem:
listParsedState.visibleFlattenIds[
listParsedState.visibleFlattenIds.length - 1
] === id,
disabled: listState.disabledById[id],
});
}

return defaultHandleClick();
},
[
onItemClick,
listState,
listParsedState.groupsState,
listParsedState.visibleFlattenIds,
groupsBehavior,
multiple,
handleMultipleSelection,
handleSingleSelection,
toggleOpen,
],
);

// restoring focus when popup opens
React.useLayoutEffect(() => {
if (open) {
const lastSelectedItemId = value[value.length - 1];
containerRef.current?.focus();

const firstItemId = listParsedState.visibleFlattenIds[0];

listState.setActiveItemId(lastSelectedItemId ?? firstItemId);

if (lastSelectedItemId) {
scrollToListItem(lastSelectedItemId, containerRef.current);
}
}
// subscribe only in open event
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open]);

useListKeydown({
containerRef,
onItemClick: handleItemClick,
...listParsedState,
...listState,
});

const handleClose = React.useCallback(() => toggleOpen(false), [toggleOpen]);

const controlProps: RenderControlProps = {
open,
toggleOpen,
clearValue: handleClearValue,
ref: handleControlRef,
size,
value,
id: treeSelectId,
activeItemId: listState.activeItemId,
};

const togglerNode = renderControl ? (
renderControl(controlProps)
) : (
<SelectControl
{...controlProps}
selectedOptionsContent={React.Children.toArray(
value.map((id) => {
if ('renderControlContent' in props) {
return props.renderControlContent(listParsedState.itemsById[id]).title;
}

const items = listParsedState.itemsById[id];

if (isKnownStructureGuard(items)) {
return items.title;
}

return items as string;
}),
).join(', ')}
view="normal"
pin="round-round"
popupId={`tree-select-popup-${treeSelectId}`}
selectId={`tree-select-${treeSelectId}`}
/>
);

const mods: CnMods = {
...(width === 'max' && {width}),
};

const inlineStyles: React.CSSProperties = {};

if (typeof width === 'number') {
inlineStyles.width = width;
}

return (
<Flex
direction="column"
gap="5"
ref={controlWrapRef}
className={b(mods, className)}
style={inlineStyles}
>
{togglerNode}
<SelectPopup
ref={controlWrapRef}
className={b('popup', popupClassName)}
controlRef={controlRef}
width={popupWidth}
open={open}
handleClose={handleClose}
disablePortal={popupDisablePortal}
mobile={mobile}
id={`tree-select-popup-${treeSelectId}`}
>
{slotBeforeListBody}
<RenderContainer
size={size}
containerRef={containerRef}
id={`list-${treeSelectId}`}
{...listParsedState}
{...listState}
renderItem={(id, renderContextProps) => {
const renderState = getItemRenderState({
id,
size,
onItemClick: handleItemClick,
...listParsedState,
...listState,
});

// assign components scope logic
renderState.props.hasSelectionIcon = Boolean(multiple);

if (renderItem) {
return renderItem(
renderState.data,
renderState.props,
renderState.context,
renderContextProps,
);
}

const itemData = listParsedState.itemsById[id];

return (
<TreeSelectItem
{...renderState.props}
// eslint-disable-next-line no-nested-ternary
{...('renderControlContent' in props
? props.renderControlContent(itemData)
: isKnownStructureGuard(itemData)
? itemData
: {title: itemData as string})}
{...renderContextProps}
/>
);
}}
/>
{slotAfterListBody}
</SelectPopup>
</Flex>
);
}) as <T>(props: TreeSelectProps<T> & {ref?: React.Ref<HTMLDivElement>}) => React.ReactElement;
7 changes: 7 additions & 0 deletions src/components/TreeSelect/TreeSelectItem/TreeSelectItem.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
@use '../../variables';

$block: '.#{variables.$ns}tree-select-item';

#{$block} {
padding: 0 4px;
}
26 changes: 26 additions & 0 deletions src/components/TreeSelect/TreeSelectItem/TreeSelectItem.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import React from 'react';

import {ListItemView, ListItemViewProps} from '../../useList';
import {block} from '../../utils/cn';

import './TreeSelectItem.scss';

const b = block('tree-select-item');

export interface TreeSelectItemProps extends Omit<ListItemViewProps, 'as'> {
as?: 'div' | 'li';
itemClassName?: string;
}

export const TreeSelectItem = React.forwardRef(function TreeSelectItem(
{as = 'div', className, itemClassName, ...props}: TreeSelectItemProps,
ref?: any,
) {
const Tag: React.ElementType = as;

return (
<Tag ref={ref} className={b(null, className)}>
<ListItemView as={as} {...props} className={itemClassName} />
</Tag>
);
});
1 change: 1 addition & 0 deletions src/components/TreeSelect/TreeSelectItem/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './TreeSelectItem';
Loading

0 comments on commit 7c7811a

Please sign in to comment.