@linagora/twake-mui 17.0.0 → 17.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +7 -0
- package/dist/components/NestedSelect/ItemRow.d.ts +13 -0
- package/dist/components/NestedSelect/ItemRow.js +28 -0
- package/dist/components/NestedSelect/NestedSelect.stories.d.ts +19 -0
- package/dist/components/NestedSelect/NestedSelect.stories.js +149 -0
- package/dist/components/NestedSelect/NestedSelectBottomSheet.d.ts +12 -0
- package/dist/components/NestedSelect/NestedSelectBottomSheet.js +27 -0
- package/dist/components/NestedSelect/NestedSelectModal.d.ts +9 -0
- package/dist/components/NestedSelect/NestedSelectModal.js +36 -0
- package/dist/components/NestedSelect/NestedSelectResponsive.d.ts +6 -0
- package/dist/components/NestedSelect/NestedSelectResponsive.js +13 -0
- package/dist/components/NestedSelect/helpers.d.ts +3 -0
- package/dist/components/NestedSelect/helpers.js +22 -0
- package/dist/components/NestedSelect/index.d.ts +8 -0
- package/dist/components/NestedSelect/index.js +71 -0
- package/dist/components/NestedSelect/types.d.ts +72 -0
- package/dist/components/NestedSelect/types.js +1 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +4 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,10 @@
|
|
|
1
|
+
# @linagora/twake-mui [17.1.0](https://github.com/linagora/twake-ui/compare/@linagora/twake-mui@17.0.0...@linagora/twake-mui@17.1.0) (2026-10-22)
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
### Features
|
|
5
|
+
|
|
6
|
+
* **twake-mui:** Migrate NestedSelect from cozy-ui ([112d694](https://github.com/linagora/twake-ui/commit/112d69461cab48248b088c1da2c722b18d2dbffc))
|
|
7
|
+
|
|
1
8
|
# @linagora/twake-mui [17.0.0](https://github.com/linagora/twake-ui/compare/@linagora/twake-mui@16.3.0...@linagora/twake-mui@17.0.0) (2026-10-22)
|
|
2
9
|
|
|
3
10
|
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import type { NestedSelectItem, NestedSelectRadioPosition } from './types';
|
|
3
|
+
export interface ItemRowProps {
|
|
4
|
+
item: NestedSelectItem;
|
|
5
|
+
onClick: (item: NestedSelectItem) => void;
|
|
6
|
+
isSelected: boolean;
|
|
7
|
+
radioPosition?: NestedSelectRadioPosition;
|
|
8
|
+
isLast?: boolean;
|
|
9
|
+
ellipsis?: boolean;
|
|
10
|
+
noDivider?: boolean;
|
|
11
|
+
}
|
|
12
|
+
declare const ItemRow: ({ item, onClick, isSelected, radioPosition, isLast, ellipsis, noDivider }: ItemRowProps) => React.ReactElement;
|
|
13
|
+
export default ItemRow;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { Icon, Right } from '@linagora/twake-icons';
|
|
2
|
+
import { Divider, ListItemIcon, Radio, Typography } from '@mui/material';
|
|
3
|
+
import { useTheme } from '@mui/material/styles';
|
|
4
|
+
import React from 'react';
|
|
5
|
+
import { useBreakpoints } from '../../hooks/useBreakpoints';
|
|
6
|
+
import ListItemButton from '../ListItemButton';
|
|
7
|
+
import ListItemText from '../ListItemText';
|
|
8
|
+
const ItemRow = ({ item, onClick, isSelected, radioPosition, isLast, ellipsis, noDivider }) => {
|
|
9
|
+
const { isDesktop } = useBreakpoints();
|
|
10
|
+
const theme = useTheme();
|
|
11
|
+
const hasChildren = (item.children?.length ?? 0) > 0;
|
|
12
|
+
return (React.createElement(React.Fragment, null,
|
|
13
|
+
React.createElement(ListItemButton, { gutters: isDesktop ? 'double' : 'default', ellipsis: ellipsis, onClick: () => onClick(item) },
|
|
14
|
+
radioPosition === 'left' && (React.createElement(ListItemIcon, null,
|
|
15
|
+
React.createElement(Radio, { readOnly: true, checked: isSelected }))),
|
|
16
|
+
item.icon && React.createElement(ListItemIcon, null, item.icon),
|
|
17
|
+
React.createElement(ListItemText, { primary: item.title, secondary: item.description }),
|
|
18
|
+
hasChildren && (React.createElement(ListItemIcon, null,
|
|
19
|
+
React.createElement(Icon, { icon: Right, color: theme.vars?.palette.text.secondary }))),
|
|
20
|
+
radioPosition === 'right' && !hasChildren && (React.createElement("div", { className: "u-flex u-flex-items-center" },
|
|
21
|
+
item.info && (React.createElement(Typography, { variant: "body2", color: "textSecondary" }, item.info)),
|
|
22
|
+
React.createElement(ListItemIcon, null,
|
|
23
|
+
React.createElement(Radio, { readOnly: true, edge: "end", checked: isSelected })))),
|
|
24
|
+
radioPosition === 'right' && item.action && (React.createElement(Divider, { orientation: "vertical", flexItem: true })),
|
|
25
|
+
item.action?.Component({ item, ...item.action.props })),
|
|
26
|
+
!noDivider && !isLast && React.createElement(Divider, null)));
|
|
27
|
+
};
|
|
28
|
+
export default ItemRow;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react-vite';
|
|
2
|
+
import React from 'react';
|
|
3
|
+
interface ExampleArgs {
|
|
4
|
+
noTitle: boolean;
|
|
5
|
+
noDivider: boolean;
|
|
6
|
+
leftRadio: boolean;
|
|
7
|
+
withSearch: boolean;
|
|
8
|
+
withEllipsis: boolean;
|
|
9
|
+
withHeaders: boolean;
|
|
10
|
+
canSelectParent: boolean;
|
|
11
|
+
withFocus: boolean;
|
|
12
|
+
}
|
|
13
|
+
declare const Example: ({ noTitle, noDivider, leftRadio, withSearch, withEllipsis, withHeaders, canSelectParent, withFocus }: ExampleArgs) => React.ReactElement;
|
|
14
|
+
declare const meta: Meta<typeof Example>;
|
|
15
|
+
export default meta;
|
|
16
|
+
type Story = StoryObj<typeof meta>;
|
|
17
|
+
export declare const Default: Story;
|
|
18
|
+
export declare const Screenshot: Story;
|
|
19
|
+
export declare const ResponsiveScreenshot: Story;
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { Icon, Setting } from '@linagora/twake-icons';
|
|
2
|
+
import { Alert, Button, IconButton, ListItemSecondaryAction, Paper, Stack, Typography } from '@mui/material';
|
|
3
|
+
import React, { useState } from 'react';
|
|
4
|
+
import NestedSelectResponsive from './NestedSelectResponsive';
|
|
5
|
+
import NestedSelect from './index';
|
|
6
|
+
import { useBreakpoints } from '../../hooks/useBreakpoints';
|
|
7
|
+
import Avatar from '../Avatar';
|
|
8
|
+
const LONG_TEXT = 'Augusta Ada King-Noel, Countess of Lovelace, was an English mathematician and writer, chiefly known for her work on Charles Babbage proposed mechanical general-purpose computer.';
|
|
9
|
+
const letterOption = (letter, description, key) => ({
|
|
10
|
+
id: key ? `${letter}_${key}` : letter,
|
|
11
|
+
key,
|
|
12
|
+
title: letter,
|
|
13
|
+
description,
|
|
14
|
+
icon: React.createElement(Avatar, { color: "sugarCoral" }, letter[0])
|
|
15
|
+
});
|
|
16
|
+
const SettingAction = ({ item, onClick }) => (React.createElement(ListItemSecondaryAction, null,
|
|
17
|
+
React.createElement(IconButton, { "aria-label": "Settings", onClick: () => onClick(item) },
|
|
18
|
+
React.createElement(Icon, { icon: Setting }))));
|
|
19
|
+
const makeOptions = ({ withHeaders = false, focusedId } = {}) => ({
|
|
20
|
+
focusedId,
|
|
21
|
+
header: withHeaders && (React.createElement(Alert, { className: "u-mt-1 u-mh-1", icon: false }, "This is a header for options")),
|
|
22
|
+
childrenHeader: withHeaders
|
|
23
|
+
? (level) => level === 1 ? (React.createElement(Alert, { className: "u-mt-1 u-mh-1" }, "This is specific option header for first children")) : (React.createElement(Alert, { className: "u-mt-1 u-mh-1", icon: false, severity: "secondary" }, "This is specific option header for other children"))
|
|
24
|
+
: undefined,
|
|
25
|
+
children: [
|
|
26
|
+
{ ...letterOption('A', 'is for Apple'), info: 'info' },
|
|
27
|
+
{
|
|
28
|
+
...letterOption('B', 'is for Balloon'),
|
|
29
|
+
children: [
|
|
30
|
+
{
|
|
31
|
+
...letterOption('B1'),
|
|
32
|
+
children: [letterOption('B1a'), letterOption('B1b')]
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
...letterOption('B2'),
|
|
36
|
+
header: withHeaders && (React.createElement(Alert, { className: "u-mt-1 u-mh-1", icon: false, severity: "success" }, "This is overriden specific option!")),
|
|
37
|
+
children: [letterOption('B2a'), letterOption('B2b')]
|
|
38
|
+
},
|
|
39
|
+
letterOption('B3')
|
|
40
|
+
]
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
...letterOption('C', 'is for crayon'),
|
|
44
|
+
action: {
|
|
45
|
+
Component: SettingAction,
|
|
46
|
+
props: { onClick: () => undefined }
|
|
47
|
+
}
|
|
48
|
+
},
|
|
49
|
+
letterOption('D', 'is for Drums !'),
|
|
50
|
+
letterOption(`E. A very long option, that should be ellipsed. ${LONG_TEXT}`, `Its description is also very long and will be ellipsed. ${LONG_TEXT}`),
|
|
51
|
+
letterOption('F'),
|
|
52
|
+
letterOption('G'),
|
|
53
|
+
letterOption('H', 'for hero', 'hero'),
|
|
54
|
+
letterOption('H', 'for helicopter', 'helicopter'),
|
|
55
|
+
letterOption('I'),
|
|
56
|
+
letterOption('J'),
|
|
57
|
+
letterOption('K'),
|
|
58
|
+
letterOption('L'),
|
|
59
|
+
letterOption('M'),
|
|
60
|
+
letterOption('N')
|
|
61
|
+
]
|
|
62
|
+
});
|
|
63
|
+
const makeSearchOptions = () => ({
|
|
64
|
+
placeholderSearch: 'Placeholder Search',
|
|
65
|
+
noDataLabel: 'No Data Found',
|
|
66
|
+
onSearch: value => (makeOptions().children ?? []).filter(option => typeof option.description === 'string' &&
|
|
67
|
+
option.description.toLowerCase().includes(value.toLowerCase()))
|
|
68
|
+
});
|
|
69
|
+
const transformParentItem = (item) => ({
|
|
70
|
+
...item,
|
|
71
|
+
title: React.createElement(React.Fragment, null,
|
|
72
|
+
"All of ",
|
|
73
|
+
item.title)
|
|
74
|
+
});
|
|
75
|
+
const Example = ({ noTitle, noDivider, leftRadio, withSearch, withEllipsis, withHeaders, canSelectParent, withFocus }) => {
|
|
76
|
+
const [isOpen, setIsOpen] = useState(false);
|
|
77
|
+
const [selectedItem, setSelectedItem] = useState(letterOption('A'));
|
|
78
|
+
const handleSelect = (item) => {
|
|
79
|
+
setSelectedItem(item);
|
|
80
|
+
setIsOpen(false);
|
|
81
|
+
};
|
|
82
|
+
return (React.createElement(React.Fragment, null,
|
|
83
|
+
React.createElement(Typography, { className: "u-mb-1" },
|
|
84
|
+
"Selected item: ",
|
|
85
|
+
selectedItem.title,
|
|
86
|
+
" ",
|
|
87
|
+
selectedItem.description),
|
|
88
|
+
React.createElement(Button, { onClick: () => setIsOpen(true) }, "Select"),
|
|
89
|
+
isOpen && (React.createElement(NestedSelectResponsive, { canSelectParent: canSelectParent, onSelect: handleSelect, onClose: () => setIsOpen(false), isSelected: item => item.id === selectedItem.id, options: makeOptions({
|
|
90
|
+
withHeaders,
|
|
91
|
+
focusedId: withFocus ? selectedItem.id : undefined
|
|
92
|
+
}), radioPosition: leftRadio ? 'left' : 'right', title: noTitle ? undefined : 'Please select letter', transformParentItem: transformParentItem, searchOptions: withSearch ? makeSearchOptions() : undefined, ellipsis: withEllipsis, noDivider: noDivider }))));
|
|
93
|
+
};
|
|
94
|
+
const meta = {
|
|
95
|
+
title: 'NestedSelect',
|
|
96
|
+
component: Example,
|
|
97
|
+
tags: ['autodocs'],
|
|
98
|
+
args: {
|
|
99
|
+
noTitle: false,
|
|
100
|
+
noDivider: false,
|
|
101
|
+
leftRadio: false,
|
|
102
|
+
withSearch: false,
|
|
103
|
+
withEllipsis: true,
|
|
104
|
+
withHeaders: false,
|
|
105
|
+
canSelectParent: true,
|
|
106
|
+
withFocus: false
|
|
107
|
+
},
|
|
108
|
+
argTypes: {
|
|
109
|
+
noTitle: { control: 'boolean' },
|
|
110
|
+
noDivider: { control: 'boolean' },
|
|
111
|
+
leftRadio: { control: 'boolean' },
|
|
112
|
+
withSearch: { control: 'boolean' },
|
|
113
|
+
withEllipsis: { control: 'boolean' },
|
|
114
|
+
withHeaders: { control: 'boolean' },
|
|
115
|
+
canSelectParent: { control: 'boolean' },
|
|
116
|
+
withFocus: { control: 'boolean' }
|
|
117
|
+
}
|
|
118
|
+
};
|
|
119
|
+
export default meta;
|
|
120
|
+
export const Default = {};
|
|
121
|
+
const Section = ({ label, ...props }) => (React.createElement("section", null,
|
|
122
|
+
React.createElement("h3", null, label),
|
|
123
|
+
React.createElement(Paper, { variant: "outlined" },
|
|
124
|
+
React.createElement(NestedSelect, { onSelect: () => undefined, isSelected: item => item.title === 'A' || item.title === 'B1a', options: {
|
|
125
|
+
...makeOptions(),
|
|
126
|
+
children: makeOptions().children?.slice(0, 5)
|
|
127
|
+
}, title: "Please select letter", transformParentItem: transformParentItem, ...props }))));
|
|
128
|
+
// Visual Regression - Combined view for Argos testing
|
|
129
|
+
export const Screenshot = {
|
|
130
|
+
tags: ['argos', '!autodocs'],
|
|
131
|
+
render: () => (React.createElement(Stack, { spacing: 4 },
|
|
132
|
+
React.createElement(Section, { label: "Default" }),
|
|
133
|
+
React.createElement(Section, { label: "Left radio", radioPosition: "left" }),
|
|
134
|
+
React.createElement(Section, { label: "No divider", noDivider: true }),
|
|
135
|
+
React.createElement(Section, { label: "No ellipsis", ellipsis: false }),
|
|
136
|
+
React.createElement(Section, { label: "With search", searchOptions: makeSearchOptions() }),
|
|
137
|
+
React.createElement(Section, { label: "With headers", options: makeOptions({ withHeaders: true }) }),
|
|
138
|
+
React.createElement(Section, { label: "Focused on B1a, with selectable parent", canSelectParent: true, options: makeOptions({ withHeaders: true, focusedId: 'B1a' }) })))
|
|
139
|
+
};
|
|
140
|
+
const ResponsiveExample = () => {
|
|
141
|
+
const { isMobile } = useBreakpoints();
|
|
142
|
+
return (React.createElement(NestedSelectResponsive, { radioPosition: isMobile ? 'left' : 'right', canSelectParent: true, onSelect: () => undefined, isSelected: item => item.title === 'C', options: makeOptions(), title: "Please select letter", transformParentItem: transformParentItem, componentsProps: { bottomsheet: { skipAnimation: true } }, onClose: () => undefined }));
|
|
143
|
+
};
|
|
144
|
+
// Visual Regression - dialog on desktop, bottom sheet on mobile
|
|
145
|
+
export const ResponsiveScreenshot = {
|
|
146
|
+
tags: ['argos', '!autodocs'],
|
|
147
|
+
parameters: { layout: 'fullscreen' },
|
|
148
|
+
render: () => React.createElement(ResponsiveExample, null)
|
|
149
|
+
};
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import type { NestedSelectProps } from './types';
|
|
3
|
+
import type { BottomSheetProps } from '../BottomSheet/types';
|
|
4
|
+
export interface NestedSelectBottomSheetProps extends NestedSelectProps {
|
|
5
|
+
onClose?: () => void;
|
|
6
|
+
componentsProps?: {
|
|
7
|
+
bottomsheet?: Omit<BottomSheetProps, 'backdrop' | 'onClose' | 'children'>;
|
|
8
|
+
};
|
|
9
|
+
}
|
|
10
|
+
/** NestedSelect in a bottom sheet with a backdrop */
|
|
11
|
+
declare const NestedSelectBottomSheet: ({ onClose, componentsProps, ...props }: NestedSelectBottomSheetProps) => React.ReactElement;
|
|
12
|
+
export default NestedSelectBottomSheet;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { ArrowUp, Icon } from '@linagora/twake-icons';
|
|
2
|
+
import { Divider, IconButton, Typography } from '@mui/material';
|
|
3
|
+
import cx from 'classnames';
|
|
4
|
+
import React from 'react';
|
|
5
|
+
import { useExtendI18n, useI18n } from 'twake-i18n';
|
|
6
|
+
import NestedSelect from './index';
|
|
7
|
+
import BottomSheet from '../BottomSheet';
|
|
8
|
+
import BottomSheetHeader from '../BottomSheet/BottomSheetHeader';
|
|
9
|
+
import BottomSheetItem from '../BottomSheet/BottomSheetItem';
|
|
10
|
+
import { locales } from '../Dialogs/locales';
|
|
11
|
+
const NestedSelectSheetHeader = ({ title, showBack, onClickBack }) => {
|
|
12
|
+
useExtendI18n(locales);
|
|
13
|
+
const { t } = useI18n();
|
|
14
|
+
if (!title)
|
|
15
|
+
return null;
|
|
16
|
+
return (React.createElement(React.Fragment, null,
|
|
17
|
+
React.createElement(BottomSheetHeader, { className: cx('u-h-3', { 'u-pl-half u-pr-3': showBack }) },
|
|
18
|
+
showBack && (React.createElement(IconButton, { "aria-label": t('Dialogs.backButton'), onClick: onClickBack },
|
|
19
|
+
React.createElement(Icon, { icon: ArrowUp, rotate: -90 }))),
|
|
20
|
+
React.createElement(Typography, { className: "u-w-100", variant: "h6", align: "center" }, title)),
|
|
21
|
+
React.createElement(Divider, null)));
|
|
22
|
+
};
|
|
23
|
+
/** NestedSelect in a bottom sheet with a backdrop */
|
|
24
|
+
const NestedSelectBottomSheet = ({ onClose, componentsProps, ...props }) => (React.createElement(BottomSheet, { ...componentsProps?.bottomsheet, backdrop: true, onClose: onClose },
|
|
25
|
+
React.createElement(BottomSheetItem, { disableGutters: true },
|
|
26
|
+
React.createElement(NestedSelect, { HeaderComponent: NestedSelectSheetHeader, ...props }))));
|
|
27
|
+
export default NestedSelectBottomSheet;
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import type { NestedSelectProps } from './types';
|
|
3
|
+
import { DialogLayoutProps } from '../Dialogs/useDialogLayout';
|
|
4
|
+
export interface NestedSelectModalProps extends NestedSelectProps {
|
|
5
|
+
onClose?: DialogLayoutProps['onClose'];
|
|
6
|
+
}
|
|
7
|
+
/** NestedSelect in a dialog, with a back button to the parent list */
|
|
8
|
+
declare const NestedSelectModal: ({ onClose, ...props }: NestedSelectModalProps) => React.ReactElement;
|
|
9
|
+
export default NestedSelectModal;
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { DialogContent, DialogTitle, List } from '@mui/material';
|
|
2
|
+
import cx from 'classnames';
|
|
3
|
+
import React, { createContext, useContext } from 'react';
|
|
4
|
+
import NestedSelect from './index';
|
|
5
|
+
import Dialog from '../Dialog';
|
|
6
|
+
import DialogNavButtons from '../Dialogs/DialogNavButtons';
|
|
7
|
+
import { useDialogLayout } from '../Dialogs/useDialogLayout';
|
|
8
|
+
// NestedSelect renders the header and the content as components, so they read the layout here
|
|
9
|
+
const DialogLayoutContext = createContext(null);
|
|
10
|
+
const useDialogLayoutContext = () => {
|
|
11
|
+
const layout = useContext(DialogLayoutContext);
|
|
12
|
+
if (!layout)
|
|
13
|
+
throw new Error('Rendered outside NestedSelectModal');
|
|
14
|
+
return layout;
|
|
15
|
+
};
|
|
16
|
+
const NestedSelectDialogHeader = ({ title, showBack, onClickBack }) => {
|
|
17
|
+
const { id, fullScreen, onClose, dialogTitleProps } = useDialogLayoutContext();
|
|
18
|
+
return (React.createElement(React.Fragment, null,
|
|
19
|
+
React.createElement(DialogNavButtons, { id: id, fullScreen: fullScreen, onClose: onClose, onBack: showBack ? onClickBack : undefined }),
|
|
20
|
+
title && (React.createElement(DialogTitle, { ...dialogTitleProps, className: cx(dialogTitleProps.className, {
|
|
21
|
+
dialogTitleWithBack: showBack
|
|
22
|
+
}) }, title))));
|
|
23
|
+
};
|
|
24
|
+
const NestedSelectDialogContent = ({ children }) => {
|
|
25
|
+
const { dialogContentProps } = useDialogLayoutContext();
|
|
26
|
+
return (React.createElement(DialogContent, { ...dialogContentProps },
|
|
27
|
+
React.createElement(List, null, children)));
|
|
28
|
+
};
|
|
29
|
+
/** NestedSelect in a dialog, with a back button to the parent list */
|
|
30
|
+
const NestedSelectModal = ({ onClose, ...props }) => {
|
|
31
|
+
const layout = useDialogLayout({ open: true, onClose, disableGutters: true });
|
|
32
|
+
return (React.createElement(Dialog, { ...layout.dialogProps },
|
|
33
|
+
React.createElement(DialogLayoutContext.Provider, { value: { ...layout, onClose } },
|
|
34
|
+
React.createElement(NestedSelect, { HeaderComponent: NestedSelectDialogHeader, ContentComponent: NestedSelectDialogContent, ...props }))));
|
|
35
|
+
};
|
|
36
|
+
export default NestedSelectModal;
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { NestedSelectBottomSheetProps } from './NestedSelectBottomSheet';
|
|
3
|
+
export type NestedSelectResponsiveProps = NestedSelectBottomSheetProps;
|
|
4
|
+
/** NestedSelect in a bottom sheet on mobile, in a dialog otherwise */
|
|
5
|
+
declare const NestedSelectResponsive: (props: NestedSelectResponsiveProps) => React.ReactElement;
|
|
6
|
+
export default NestedSelectResponsive;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import NestedSelectBottomSheet from './NestedSelectBottomSheet';
|
|
3
|
+
import NestedSelectModal from './NestedSelectModal';
|
|
4
|
+
import { useBreakpoints } from '../../hooks/useBreakpoints';
|
|
5
|
+
/** NestedSelect in a bottom sheet on mobile, in a dialog otherwise */
|
|
6
|
+
const NestedSelectResponsive = (props) => {
|
|
7
|
+
const { isMobile } = useBreakpoints();
|
|
8
|
+
if (isMobile)
|
|
9
|
+
return React.createElement(NestedSelectBottomSheet, { ...props });
|
|
10
|
+
const { componentsProps: _componentsProps, ...modalProps } = props;
|
|
11
|
+
return React.createElement(NestedSelectModal, { ...modalProps });
|
|
12
|
+
};
|
|
13
|
+
export default NestedSelectResponsive;
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
import type { NestedSelectItem, NestedSelectOptions } from './types';
|
|
2
|
+
/** Path from the item matching `options.focusedId` up to `options`, deepest first */
|
|
3
|
+
export declare const makeHistory: (options: NestedSelectOptions | undefined, canSelectParent?: boolean) => NestedSelectItem[];
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/** Path from the item matching `options.focusedId` up to `options`, deepest first */
|
|
2
|
+
export const makeHistory = (options, canSelectParent = false) => {
|
|
3
|
+
if (!options)
|
|
4
|
+
return [];
|
|
5
|
+
const focusedId = options.focusedId ?? null;
|
|
6
|
+
if (!focusedId)
|
|
7
|
+
return [options];
|
|
8
|
+
const history = [];
|
|
9
|
+
const findFocusedItem = (item) => {
|
|
10
|
+
const isFound = item.children?.some(child => child.id === focusedId || findFocusedItem(child)) ?? false;
|
|
11
|
+
if (isFound) {
|
|
12
|
+
if (canSelectParent) {
|
|
13
|
+
const focusedItem = item.children?.find(child => child.id === focusedId);
|
|
14
|
+
if (focusedItem?.children)
|
|
15
|
+
history.push(focusedItem);
|
|
16
|
+
}
|
|
17
|
+
history.push(item);
|
|
18
|
+
}
|
|
19
|
+
return isFound;
|
|
20
|
+
};
|
|
21
|
+
return findFocusedItem(options) ? history : [options];
|
|
22
|
+
};
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import type { NestedSelectProps } from './types';
|
|
3
|
+
/**
|
|
4
|
+
* Select to choose an item among a list. Clicking an item that has children
|
|
5
|
+
* shows its children instead of selecting it.
|
|
6
|
+
*/
|
|
7
|
+
declare const NestedSelect: ({ onSelect, isSelected, options, title, canSelectParent, transformParentItem, radioPosition, searchOptions, ellipsis, noDivider, ContentComponent, HeaderComponent }: NestedSelectProps) => React.ReactElement;
|
|
8
|
+
export default NestedSelect;
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { List, Typography } from '@mui/material';
|
|
2
|
+
import { styled } from '@mui/material/styles';
|
|
3
|
+
import cx from 'classnames';
|
|
4
|
+
import React, { useState } from 'react';
|
|
5
|
+
import { useDebounce } from 'rooks';
|
|
6
|
+
import ItemRow from './ItemRow';
|
|
7
|
+
import { makeHistory } from './helpers';
|
|
8
|
+
import SearchBar from '../SearchBar';
|
|
9
|
+
const SEARCH_DELAY = 250;
|
|
10
|
+
const NestedSelectRoot = styled('div', {
|
|
11
|
+
shouldForwardProp: prop => prop !== 'hasTitle'
|
|
12
|
+
})({
|
|
13
|
+
variants: [{ props: { hasTitle: false }, style: { marginTop: '4rem' } }]
|
|
14
|
+
});
|
|
15
|
+
const keepItem = (item) => item;
|
|
16
|
+
const getItemKey = (item, index) => item.key ?? (typeof item.title === 'string' ? item.title : index);
|
|
17
|
+
/**
|
|
18
|
+
* Select to choose an item among a list. Clicking an item that has children
|
|
19
|
+
* shows its children instead of selecting it.
|
|
20
|
+
*/
|
|
21
|
+
const NestedSelect = ({ onSelect, isSelected, options, title, canSelectParent, transformParentItem = keepItem, radioPosition = 'right', searchOptions, ellipsis, noDivider, ContentComponent = List, HeaderComponent = null }) => {
|
|
22
|
+
const [history, setHistory] = useState(() => makeHistory(options, canSelectParent));
|
|
23
|
+
const [searchValue, setSearchValue] = useState('');
|
|
24
|
+
const [searchResult, setSearchResult] = useState(null);
|
|
25
|
+
const setSearchResultLater = useDebounce(setSearchResult, SEARCH_DELAY);
|
|
26
|
+
const current = history[0];
|
|
27
|
+
const level = history.length - 1;
|
|
28
|
+
const currentTitle = current.title ?? title;
|
|
29
|
+
const { children: _children, ...currentWithoutChildren } = current;
|
|
30
|
+
const parentItem = transformParentItem(currentWithoutChildren);
|
|
31
|
+
const items = searchResult ?? current.children ?? [];
|
|
32
|
+
const handleBack = () => {
|
|
33
|
+
setHistory(([, ...parents]) => parents);
|
|
34
|
+
};
|
|
35
|
+
const handleClickItem = (item) => {
|
|
36
|
+
if (item.children && item.children.length > 0) {
|
|
37
|
+
setHistory(previous => [item, ...previous]);
|
|
38
|
+
}
|
|
39
|
+
else {
|
|
40
|
+
onSelect(item);
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
const handleSearchChange = (event) => {
|
|
44
|
+
if (!searchOptions)
|
|
45
|
+
return;
|
|
46
|
+
setSearchValue(event.target.value);
|
|
47
|
+
setSearchResultLater(searchOptions.onSearch(event.target.value));
|
|
48
|
+
};
|
|
49
|
+
const handleSearchClear = () => {
|
|
50
|
+
setSearchValue('');
|
|
51
|
+
setSearchResultLater(null);
|
|
52
|
+
};
|
|
53
|
+
const renderItemRow = (item, key, isLast) => (React.createElement(ItemRow, { key: key, item: item, onClick: handleClickItem, isSelected: isSelected(item, level), radioPosition: radioPosition, isLast: isLast, ellipsis: ellipsis, noDivider: noDivider }));
|
|
54
|
+
const header = level === 0
|
|
55
|
+
? options.header
|
|
56
|
+
: (current.header ??
|
|
57
|
+
(typeof options.childrenHeader === 'function'
|
|
58
|
+
? options.childrenHeader(level)
|
|
59
|
+
: options.childrenHeader));
|
|
60
|
+
return (React.createElement(NestedSelectRoot, { hasTitle: Boolean(currentTitle) },
|
|
61
|
+
HeaderComponent && (React.createElement(HeaderComponent, { title: currentTitle, showBack: level > 0, onClickBack: handleBack })),
|
|
62
|
+
header,
|
|
63
|
+
React.createElement(ContentComponent, null,
|
|
64
|
+
canSelectParent &&
|
|
65
|
+
level > 0 &&
|
|
66
|
+
renderItemRow(parentItem, 'parent', false),
|
|
67
|
+
searchOptions && level === 0 && (React.createElement("div", { className: cx('u-mh-1 u-mb-half', { 'u-mt-half': !currentTitle }) },
|
|
68
|
+
React.createElement(SearchBar, { placeholder: searchOptions.placeholderSearch, value: searchValue, onChange: handleSearchChange, onClear: handleSearchClear }))),
|
|
69
|
+
searchResult?.length === 0 ? (React.createElement(Typography, { variant: "body1", className: "u-flex u-flex-justify-center u-mb-1" }, searchOptions?.noDataLabel)) : (items.map((item, index) => renderItemRow(item, getItemKey(item, index), index === items.length - 1))))));
|
|
70
|
+
};
|
|
71
|
+
export default NestedSelect;
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import type React from 'react';
|
|
2
|
+
export interface NestedSelectItemAction {
|
|
3
|
+
/**
|
|
4
|
+
* Called as a function with the item and `props`, so that a returned
|
|
5
|
+
* `ListItemSecondaryAction` is placed outside the row button
|
|
6
|
+
*/
|
|
7
|
+
Component(props: {
|
|
8
|
+
item: NestedSelectItem;
|
|
9
|
+
}): React.ReactNode;
|
|
10
|
+
props?: object;
|
|
11
|
+
}
|
|
12
|
+
export interface NestedSelectItem {
|
|
13
|
+
/** Matched against `focusedId` to open the select on this item */
|
|
14
|
+
id?: string;
|
|
15
|
+
/** React key of the row, `title` is used without it */
|
|
16
|
+
key?: string;
|
|
17
|
+
title?: React.ReactNode;
|
|
18
|
+
description?: React.ReactNode;
|
|
19
|
+
/** Shown on the left of the row */
|
|
20
|
+
icon?: React.ReactElement;
|
|
21
|
+
/** Shown before the radio */
|
|
22
|
+
info?: React.ReactNode;
|
|
23
|
+
/** Shown above the list of this item's children */
|
|
24
|
+
header?: React.ReactNode;
|
|
25
|
+
/** Items shown instead of selecting this one */
|
|
26
|
+
children?: NestedSelectItem[];
|
|
27
|
+
/** Shown on the right of the row */
|
|
28
|
+
action?: NestedSelectItemAction;
|
|
29
|
+
}
|
|
30
|
+
export interface NestedSelectOptions extends NestedSelectItem {
|
|
31
|
+
/** Opens the select on the item with this `id` */
|
|
32
|
+
focusedId?: string | null;
|
|
33
|
+
/** Shown above the children of any item, unless the item has its own `header` */
|
|
34
|
+
childrenHeader?: React.ReactNode | ((level: number) => React.ReactNode);
|
|
35
|
+
}
|
|
36
|
+
export interface NestedSelectSearchOptions {
|
|
37
|
+
placeholderSearch: string;
|
|
38
|
+
noDataLabel: string;
|
|
39
|
+
onSearch: (value: string) => NestedSelectItem[];
|
|
40
|
+
}
|
|
41
|
+
export interface NestedSelectHeaderProps {
|
|
42
|
+
title?: React.ReactNode;
|
|
43
|
+
showBack: boolean;
|
|
44
|
+
onClickBack: () => void;
|
|
45
|
+
}
|
|
46
|
+
export type NestedSelectRadioPosition = 'left' | 'right';
|
|
47
|
+
export interface NestedSelectProps {
|
|
48
|
+
/** Called with the chosen item, unless it has children */
|
|
49
|
+
onSelect: (item: NestedSelectItem) => void;
|
|
50
|
+
/** Whether the row looks selected, `level` being the depth of the shown list */
|
|
51
|
+
isSelected: (item: NestedSelectItem, level: number) => boolean;
|
|
52
|
+
options: NestedSelectOptions;
|
|
53
|
+
/** Shown when the current item has no title */
|
|
54
|
+
title?: React.ReactNode;
|
|
55
|
+
/** Shows the parent item at the top of its children, to select it */
|
|
56
|
+
canSelectParent?: boolean;
|
|
57
|
+
/** Changes the parent item before it is shown at the top of its children */
|
|
58
|
+
transformParentItem?: (item: NestedSelectItem) => NestedSelectItem;
|
|
59
|
+
radioPosition?: NestedSelectRadioPosition;
|
|
60
|
+
/** Shows a search bar above the first level */
|
|
61
|
+
searchOptions?: NestedSelectSearchOptions;
|
|
62
|
+
/** Ellipses the row texts, `true` by default */
|
|
63
|
+
ellipsis?: boolean;
|
|
64
|
+
/** Removes the divider between rows */
|
|
65
|
+
noDivider?: boolean;
|
|
66
|
+
/** Wraps the rows */
|
|
67
|
+
ContentComponent?: React.ElementType<{
|
|
68
|
+
children?: React.ReactNode;
|
|
69
|
+
}>;
|
|
70
|
+
/** Shows the title and the back button */
|
|
71
|
+
HeaderComponent?: React.ComponentType<NestedSelectHeaderProps> | null;
|
|
72
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/index.d.ts
CHANGED
|
@@ -73,6 +73,10 @@ export { default as MidEllipsis } from './components/MidEllipsis';
|
|
|
73
73
|
export { default as NavigationList } from './components/NavigationList';
|
|
74
74
|
export { default as NavigationListHeader } from './components/NavigationList/NavigationListHeader';
|
|
75
75
|
export { default as NavigationListSection } from './components/NavigationList/NavigationListSection';
|
|
76
|
+
export { default as NestedSelect } from './components/NestedSelect';
|
|
77
|
+
export { default as NestedSelectModal } from './components/NestedSelect/NestedSelectModal';
|
|
78
|
+
export { default as NestedSelectBottomSheet } from './components/NestedSelect/NestedSelectBottomSheet';
|
|
79
|
+
export { default as NestedSelectResponsive } from './components/NestedSelect/NestedSelectResponsive';
|
|
76
80
|
export { default as PointerAlert } from './components/PointerAlert';
|
|
77
81
|
export type { AlertProviderProps } from './providers/AlertProvider';
|
|
78
82
|
export type { AlertContextValue, ShowAlert, ShowAlertOptions } from './providers/AlertProvider/useAlert';
|
|
@@ -176,6 +180,10 @@ export type { MidEllipsisProps } from './components/MidEllipsis';
|
|
|
176
180
|
export type { NavigationListProps } from './components/NavigationList';
|
|
177
181
|
export type { NavigationListHeaderProps } from './components/NavigationList/NavigationListHeader';
|
|
178
182
|
export type { NavigationListSectionProps } from './components/NavigationList/NavigationListSection';
|
|
183
|
+
export type { NestedSelectHeaderProps, NestedSelectItem, NestedSelectItemAction, NestedSelectOptions, NestedSelectProps, NestedSelectRadioPosition, NestedSelectSearchOptions } from './components/NestedSelect/types';
|
|
184
|
+
export type { NestedSelectModalProps } from './components/NestedSelect/NestedSelectModal';
|
|
185
|
+
export type { NestedSelectBottomSheetProps } from './components/NestedSelect/NestedSelectBottomSheet';
|
|
186
|
+
export type { NestedSelectResponsiveProps } from './components/NestedSelect/NestedSelectResponsive';
|
|
179
187
|
export type { TabsProps } from './components/Tabs';
|
|
180
188
|
export type { SwitchProps } from './components/Switch';
|
|
181
189
|
export type { SearchBarProps } from './components/SearchBar/types';
|
package/dist/index.js
CHANGED
|
@@ -76,6 +76,10 @@ export { default as MidEllipsis } from './components/MidEllipsis';
|
|
|
76
76
|
export { default as NavigationList } from './components/NavigationList';
|
|
77
77
|
export { default as NavigationListHeader } from './components/NavigationList/NavigationListHeader';
|
|
78
78
|
export { default as NavigationListSection } from './components/NavigationList/NavigationListSection';
|
|
79
|
+
export { default as NestedSelect } from './components/NestedSelect';
|
|
80
|
+
export { default as NestedSelectModal } from './components/NestedSelect/NestedSelectModal';
|
|
81
|
+
export { default as NestedSelectBottomSheet } from './components/NestedSelect/NestedSelectBottomSheet';
|
|
82
|
+
export { default as NestedSelectResponsive } from './components/NestedSelect/NestedSelectResponsive';
|
|
79
83
|
export { default as PointerAlert } from './components/PointerAlert';
|
|
80
84
|
export { default as AlertProvider } from './providers/AlertProvider';
|
|
81
85
|
export { useAlert } from './providers/AlertProvider/useAlert';
|