pentatrion-design 0.0.4 → 0.0.6
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/README.md +18 -29
- package/components/autocomplete/Autocomplete.stories.tsx +63 -0
- package/components/autocomplete/Autocomplete.tsx +334 -0
- package/components/autocomplete/AutocompleteOption.tsx +35 -0
- package/components/autocomplete/LazyAutocomplete.tsx +132 -0
- package/components/autocomplete/SimpleAutocomplete.tsx +74 -0
- package/components/autocomplete/_fixtures.tsx +27 -0
- package/components/autocomplete/index.ts +6 -0
- package/components/autocomplete/useAutocompleteContext.ts +20 -0
- package/components/autocomplete/util.ts +19 -0
- package/components/badge/Badge.stories.tsx +72 -0
- package/components/badge/Badge.tsx +73 -0
- package/components/badge/index.ts +1 -0
- package/components/button/Button.stories.tsx +190 -0
- package/components/button/Button.test.tsx +20 -0
- package/components/button/Button.tsx +209 -0
- package/components/button/ButtonGroup.stories.tsx +46 -0
- package/components/button/ButtonGroup.tsx +29 -0
- package/components/button/LinkButton.tsx +89 -0
- package/components/button/index.ts +3 -0
- package/components/card/Card.stories.tsx +21 -0
- package/components/card/config.ts +4 -0
- package/components/card/index.ts +1 -0
- package/components/code/Code.tsx +15 -0
- package/components/code/index.ts +1 -0
- package/components/context-menu/ContextMenu.stories.tsx +22 -0
- package/components/context-menu/ContextMenu.tsx +185 -0
- package/components/context-menu/ContextMenuItem.tsx +32 -0
- package/components/context-menu/index.ts +2 -0
- package/components/dialog/Dialog.stories.tsx +270 -0
- package/components/dialog/Dialog.tsx +46 -0
- package/components/dialog/index.ts +1 -0
- package/components/dialog/util.ts +29 -0
- package/components/dropdown-menu/DropdownMenu.stories.tsx +19 -0
- package/components/dropdown-menu/DropdownMenu.tsx +12 -0
- package/components/dropdown-menu/DropdownMenuContent.tsx +52 -0
- package/components/dropdown-menu/DropdownMenuItem.tsx +33 -0
- package/components/dropdown-menu/DropdownMenuTrigger.tsx +40 -0
- package/components/dropdown-menu/index.ts +7 -0
- package/components/dropdown-menu/interface.d.ts +13 -0
- package/components/dropdown-menu/useDropdownMenu.tsx +115 -0
- package/components/dropdown-menu/useDropdownMenuContext.ts +16 -0
- package/components/dropdown-menu-nested/DropdownMenuNested.stories.tsx +33 -0
- package/components/dropdown-menu-nested/DropdownMenuNested.tsx +17 -0
- package/components/dropdown-menu-nested/MenuContext.ts +15 -0
- package/components/dropdown-menu-nested/MenuItem.tsx +42 -0
- package/components/dropdown-menu-nested/MenuItemWithChildren.tsx +202 -0
- package/components/dropdown-menu-nested/index.ts +3 -0
- package/components/flash/Flash.stories.tsx +34 -0
- package/components/flash/Flash.tsx +24 -0
- package/components/flash/index.ts +1 -0
- package/components/highlight/Highlight.stories.tsx +142 -0
- package/components/highlight/Highlight.tsx +29 -0
- package/components/highlight/index.ts +1 -0
- package/components/href/Href.stories.tsx +56 -0
- package/components/href/Href.tsx +21 -0
- package/components/href/index.ts +1 -0
- package/components/input/Checkbox.stories.tsx +56 -0
- package/components/input/Checkbox.tsx +52 -0
- package/components/input/Input.stories.tsx +103 -0
- package/components/input/Input.test.tsx +10 -0
- package/components/input/Input.tsx +58 -0
- package/components/input/RadioGroup.stories.tsx +81 -0
- package/components/input/RadioGroup.tsx +84 -0
- package/components/input/Range.stories.tsx +102 -0
- package/components/input/Range.tsx +135 -0
- package/components/input/Toggle.stories.tsx +29 -0
- package/components/input/Toggle.tsx +48 -0
- package/components/input/index.ts +5 -0
- package/components/input-field/InputField.stories.tsx +253 -0
- package/components/input-field/InputField.tsx +68 -0
- package/components/input-field/index.ts +1 -0
- package/components/loader/Loader.stories.tsx +37 -0
- package/components/loader/Loader.tsx +54 -0
- package/components/loader/index.ts +1 -0
- package/components/modal/Components.tsx +75 -0
- package/components/modal/Modal.stories.tsx +113 -0
- package/components/modal/Modal.tsx +13 -0
- package/components/modal/ModalContent.tsx +51 -0
- package/components/modal/ModalTrigger.tsx +39 -0
- package/components/modal/index.ts +7 -0
- package/components/modal/interface.d.ts +10 -0
- package/components/modal/useModal.ts +48 -0
- package/components/modal/useModalContext.ts +16 -0
- package/components/notification/Notification.stories.tsx +51 -0
- package/components/notification/NotificationsContext.ts +79 -0
- package/components/notification/NotificationsProvider-ssr.tsx +73 -0
- package/components/notification/NotificationsProvider.tsx +66 -0
- package/components/notification/index.ts +4 -0
- package/components/notification/useContextNotifications.tsx +13 -0
- package/components/notification/useFetch.ts +21 -0
- package/components/popover/Components.tsx +61 -0
- package/components/popover/Popover.stories.tsx +46 -0
- package/components/popover/Popover.test.tsx +21 -0
- package/components/popover/Popover.tsx +12 -0
- package/components/popover/PopoverContent.tsx +49 -0
- package/components/popover/PopoverTrigger.tsx +40 -0
- package/components/popover/index.ts +7 -0
- package/components/popover/interface.d.ts +13 -0
- package/components/popover/usePopover.tsx +80 -0
- package/components/popover/usePopoverContext.ts +16 -0
- package/components/portal/Portal.tsx +10 -0
- package/components/portal/index.ts +1 -0
- package/components/quick-settings/QuickSettings.stories.tsx +182 -0
- package/components/resize-area/ResizeArea.stories.css +40 -0
- package/components/resize-area/ResizeArea.stories.tsx +30 -0
- package/components/resize-area/ResizeArea.tsx +98 -0
- package/components/resize-area/index.ts +1 -0
- package/components/scroll/Scroll.stories.tsx +81 -0
- package/components/scroll/Scroll.tsx +111 -0
- package/components/scroll/index.ts +1 -0
- package/components/select/Select.stories.tsx +170 -0
- package/components/select/Select.test.tsx +19 -0
- package/components/select/Select.tsx +364 -0
- package/components/select/SelectOption.tsx +29 -0
- package/components/select/SelectSelection.tsx +13 -0
- package/components/select/_fixtures.tsx +89 -0
- package/components/select/index.ts +5 -0
- package/components/select/interface.d.ts +35 -0
- package/components/select/useSelectContext.ts +19 -0
- package/components/select/util.ts +12 -0
- package/components/snack/Snack.stories.tsx +33 -0
- package/components/snack/Snack.tsx +51 -0
- package/components/snack/index.ts +1 -0
- package/components/sortable/Sortable.stories.tsx +98 -0
- package/components/sortable/Sortable.tsx +37 -0
- package/components/sortable/index.ts +2 -0
- package/components/sortable/types.d.ts +12 -0
- package/components/steps/Step.tsx +41 -0
- package/components/steps/Steps.stories.tsx +140 -0
- package/components/steps/Steps.tsx +49 -0
- package/components/steps/index.ts +3 -0
- package/components/steps/util.ts +3 -0
- package/components/table/Table.stories.tsx +36 -0
- package/components/table/Table.tsx +13 -0
- package/components/table/TableBody.tsx +9 -0
- package/components/table/TableCell.tsx +16 -0
- package/components/table/TableFooter.tsx +9 -0
- package/components/table/TableHeader.tsx +14 -0
- package/components/table/TableHeaderCell.tsx +14 -0
- package/components/table/TableRow.tsx +14 -0
- package/components/table/index.ts +7 -0
- package/components/tabs/Tabs.stories.tsx +66 -0
- package/components/tabs/Tabs.tsx +93 -0
- package/components/tabs/index.ts +1 -0
- package/components/tooltip/SimpleTooltip.tsx +17 -0
- package/components/tooltip/Tooltip.stories.tsx +128 -0
- package/components/tooltip/Tooltip.test.tsx +20 -0
- package/components/tooltip/Tooltip.tsx +12 -0
- package/components/tooltip/TooltipContent.tsx +45 -0
- package/components/tooltip/TooltipTrigger.tsx +34 -0
- package/components/tooltip/index.ts +6 -0
- package/components/tooltip/interface.d.ts +15 -0
- package/components/tooltip/useTooltip.ts +87 -0
- package/components/tooltip/useTooltipContext.ts +16 -0
- package/dist/index.d.ts +20 -3
- package/dist/index.js +309 -229
- package/hooks/index.ts +11 -0
- package/hooks/useCombinedRefs.test.tsx +42 -0
- package/hooks/useCombinedRefs.ts +21 -0
- package/hooks/useCopyToClipboard.ts +28 -0
- package/hooks/useDebounce.ts +59 -0
- package/hooks/useEventCallback.ts +13 -0
- package/hooks/useEventListener.ts +86 -0
- package/hooks/useIsClosing.ts +25 -0
- package/hooks/useIsMounted.ts +16 -0
- package/hooks/useIsomorphicLayoutEffect.ts +4 -0
- package/hooks/useOnClickOutside.ts +35 -0
- package/hooks/usePrevious.ts +9 -0
- package/hooks/useRipple.test.tsx +52 -0
- package/hooks/useRipple.tsx +79 -0
- package/lib/arrUtil.ts +8 -0
- package/lib/error.ts +18 -0
- package/lib/fetch.test.ts +107 -0
- package/lib/fetch.ts +115 -0
- package/lib/index.ts +4 -0
- package/lib/tailwindVariants.ts +35 -0
- package/package.json +8 -2
- package/redux/index.ts +1 -0
- package/redux/notification/NotificationConsumer.tsx +52 -0
- package/redux/notification/errorCatcherMiddleware.ts +17 -0
- package/redux/notification/index.ts +5 -0
- package/redux/notification/notificationSlice.ts +68 -0
- package/redux/notification/useReduxNotifications.ts +26 -0
- package/styles/Animation.stories.tsx +47 -0
- package/styles/Content.tsx +340 -0
- package/styles/Icons.stories.tsx +69 -0
- package/styles/Shadow.stories.tsx +43 -0
- package/styles/Themes.stories.tsx +31 -0
- package/styles/Typography.stories.tsx +89 -0
- package/tailwind/base.ts +72 -0
- package/tailwind/components.ts +315 -0
- package/tailwind/index.ts +2 -0
- package/tailwind/keyframes.ts +69 -0
- package/tailwind/legacy/base.css +189 -0
- package/tailwind/legacy/components-input-outline.css +62 -0
- package/tailwind/legacy/components-resize-area.css +112 -0
- package/tailwind/legacy/components-step.css +161 -0
- package/tailwind/legacy/components.css +59 -0
- package/tailwind/legacy/utilities-dialog.css +101 -0
- package/tailwind/legacy/utilities.css +3 -0
- package/tailwind/pentatrionTw.ts +216 -0
- package/tailwind/utilities.ts +78 -0
- package/tailwind/vars.ts +125 -0
package/README.md
CHANGED
|
@@ -41,18 +41,17 @@ Créer un fichier `tailwind.config.js` et `postcss.config.js`.
|
|
|
41
41
|
// tailwind.config.js
|
|
42
42
|
|
|
43
43
|
/** @type {import('tailwindcss').Config} */
|
|
44
|
-
|
|
45
|
-
content: [
|
|
46
|
-
"./index.html",
|
|
47
|
-
"./src/**/*.{js,ts,jsx,tsx}",
|
|
48
|
-
],
|
|
44
|
+
const config = {
|
|
45
|
+
content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"],
|
|
49
46
|
darkMode: ["class"],
|
|
50
47
|
};
|
|
51
48
|
|
|
49
|
+
export default config;
|
|
50
|
+
|
|
52
51
|
|
|
53
52
|
// postcss.config.js
|
|
54
53
|
/** @type {import("postcss-load-config").Config} */
|
|
55
|
-
|
|
54
|
+
const config = {
|
|
56
55
|
plugins: {
|
|
57
56
|
"tailwindcss/nesting": {},
|
|
58
57
|
tailwindcss: {},
|
|
@@ -62,11 +61,18 @@ export default {
|
|
|
62
61
|
"postcss-input-range": {}
|
|
63
62
|
},
|
|
64
63
|
};
|
|
64
|
+
export default config;
|
|
65
65
|
```
|
|
66
66
|
|
|
67
67
|
Mettre à jour le fichier `src/index.css`
|
|
68
68
|
```css
|
|
69
|
-
|
|
69
|
+
@tailwind base;
|
|
70
|
+
@tailwind components;
|
|
71
|
+
@tailwind utilities;
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* ou bien on utilisera des imports pour faciliter l'intégration du design système
|
|
75
|
+
*/
|
|
70
76
|
@import "tailwindcss/base";
|
|
71
77
|
@import "tailwindcss/components";
|
|
72
78
|
@import "tailwindcss/utilities";
|
|
@@ -86,10 +92,11 @@ Mettre à jour la configuration de tailwind.
|
|
|
86
92
|
+ import { pentatrionTw } from "pentatrion-design/tailwind";
|
|
87
93
|
|
|
88
94
|
/** @type {import('tailwindcss').Config} */
|
|
89
|
-
|
|
95
|
+
const config = {
|
|
90
96
|
content: [
|
|
91
97
|
"./index.html",
|
|
92
98
|
"./src/**/*.{js,ts,jsx,tsx}",
|
|
99
|
+
+ "./node_modules/pentatrion-design/lib/**/*.{ts,tsx}",
|
|
93
100
|
+ "./node_modules/pentatrion-design/components/**/*.{ts,tsx}",
|
|
94
101
|
+ "./node_modules/pentatrion-design/hooks/**/*.{ts,tsx}",
|
|
95
102
|
+ "./node_modules/pentatrion-design/redux/**/*.{ts,tsx}",
|
|
@@ -97,6 +104,9 @@ export default {
|
|
|
97
104
|
darkMode: ["class"],
|
|
98
105
|
+ plugins: [pentatrionTw],
|
|
99
106
|
};
|
|
107
|
+
|
|
108
|
+
export default config;
|
|
109
|
+
|
|
100
110
|
```
|
|
101
111
|
|
|
102
112
|
La dépendance `pentatrion-fonts` est optionnelle.
|
|
@@ -128,27 +138,6 @@ export default App
|
|
|
128
138
|
|
|
129
139
|
L'import minimal `import { Button } from "pentatrion-design/components/button";` est plus contraignant et n'est pas nécessaire si votre projet utilise toutes les dépendances de `pentatrion-design`. si par contre votre projet n'utilise que certains composants et n'utilise pas certains dépendances comme `react-sortablejs` il vaut mieux utiliser l'import `pentatrion-design/components/button`. Cela allégera le build (que notre compilateur fasse du tree shaking ou non).
|
|
130
140
|
|
|
131
|
-
Mettre à jour `src/index.css`
|
|
132
|
-
```diff
|
|
133
|
-
/* index.css */
|
|
134
|
-
@import "tailwindcss/base";
|
|
135
|
-
+ @import "pentatrion-design/tailwind/base.css" layer(base);
|
|
136
|
-
|
|
137
|
-
@import "tailwindcss/components";
|
|
138
|
-
+ @import "pentatrion-design/tailwind/components.css" layer(components);
|
|
139
|
-
+ @import "pentatrion-design/tailwind/components-input-outline.css" layer(components);
|
|
140
|
-
+ @import "pentatrion-design/tailwind/components-resize-area.css" layer(components);
|
|
141
|
-
+ @import "pentatrion-design/tailwind/components-step.css" layer(components);
|
|
142
|
-
|
|
143
|
-
@import "tailwindcss/utilities";
|
|
144
|
-
+ @import "pentatrion-design/tailwind/utilities.css" layer(utilities);
|
|
145
|
-
+ @import "pentatrion-design/tailwind/utilities-dialog.css" layer(utilities);
|
|
146
|
-
|
|
147
|
-
/* Optionel */
|
|
148
|
-
+ @import "pentatrion-fonts/fontello-lonlat";
|
|
149
|
-
```
|
|
150
|
-
|
|
151
|
-
|
|
152
141
|
## projet sans TypeScript
|
|
153
142
|
|
|
154
143
|
pour faciliter l'expérience de développement, `pentatrion-design` fait référence par défaut aux fichiers TypeScript non compilés. Si votre projet n'utilise pas TypeScript il faudra faire référence au dossier `dist`
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { Meta, ReactRenderer } from "@storybook/react";
|
|
2
|
+
|
|
3
|
+
import { useState } from "react";
|
|
4
|
+
import { NotificationsProvider } from "../notification";
|
|
5
|
+
import { Button } from "../button";
|
|
6
|
+
import { SimpleAutocomplete, Autocomplete } from ".";
|
|
7
|
+
import { Option } from "../select";
|
|
8
|
+
import { PartialStoryFn } from "@storybook/types";
|
|
9
|
+
import { options } from "./_fixtures";
|
|
10
|
+
|
|
11
|
+
const meta = {
|
|
12
|
+
title: "Components/Autocomplete",
|
|
13
|
+
component: Autocomplete,
|
|
14
|
+
decorators: [
|
|
15
|
+
(Story) => (
|
|
16
|
+
<NotificationsProvider>
|
|
17
|
+
<Story />
|
|
18
|
+
</NotificationsProvider>
|
|
19
|
+
),
|
|
20
|
+
] as ((story: PartialStoryFn<ReactRenderer, any>) => JSX.Element)[],
|
|
21
|
+
} satisfies Meta<typeof Autocomplete>;
|
|
22
|
+
export default meta;
|
|
23
|
+
|
|
24
|
+
export const Simple = () => {
|
|
25
|
+
const [selection, setSelection] = useState<Option | null>(null);
|
|
26
|
+
|
|
27
|
+
function handleClick(action: "random" | "unknown" | "unselect") {
|
|
28
|
+
switch (action) {
|
|
29
|
+
case "random":
|
|
30
|
+
setSelection(options[Math.floor(Math.random() * options.length)]);
|
|
31
|
+
break;
|
|
32
|
+
case "unknown":
|
|
33
|
+
setSelection({
|
|
34
|
+
value: "unknown",
|
|
35
|
+
label: "Unknown",
|
|
36
|
+
});
|
|
37
|
+
break;
|
|
38
|
+
case "unselect":
|
|
39
|
+
setSelection(null);
|
|
40
|
+
break;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return (
|
|
45
|
+
<>
|
|
46
|
+
<SimpleAutocomplete
|
|
47
|
+
options={options}
|
|
48
|
+
selection={selection}
|
|
49
|
+
onChangeSelection={setSelection}
|
|
50
|
+
/>
|
|
51
|
+
<div>sélection : {selection && selection.label}</div>
|
|
52
|
+
<div>
|
|
53
|
+
<Button onClick={() => handleClick("random")} className="mr-2">
|
|
54
|
+
select random
|
|
55
|
+
</Button>
|
|
56
|
+
<Button onClick={() => handleClick("unknown")} className="mr-2">
|
|
57
|
+
select Unknown
|
|
58
|
+
</Button>
|
|
59
|
+
<Button onClick={() => handleClick("unselect")}>unselect</Button>
|
|
60
|
+
</div>
|
|
61
|
+
</>
|
|
62
|
+
);
|
|
63
|
+
};
|
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ChangeEvent,
|
|
3
|
+
KeyboardEvent,
|
|
4
|
+
useCallback,
|
|
5
|
+
useMemo,
|
|
6
|
+
useRef,
|
|
7
|
+
useState,
|
|
8
|
+
forwardRef,
|
|
9
|
+
ForwardedRef,
|
|
10
|
+
ReactNode,
|
|
11
|
+
} from "react";
|
|
12
|
+
import type { OptionLike, Option } from "../select";
|
|
13
|
+
import AutocompleteOption, {
|
|
14
|
+
AutocompleteOptionProps,
|
|
15
|
+
} from "./AutocompleteOption.tsx";
|
|
16
|
+
import {
|
|
17
|
+
FloatingFocusManager,
|
|
18
|
+
FloatingList,
|
|
19
|
+
FloatingPortal,
|
|
20
|
+
Placement,
|
|
21
|
+
autoUpdate,
|
|
22
|
+
flip,
|
|
23
|
+
offset,
|
|
24
|
+
size,
|
|
25
|
+
useDismiss,
|
|
26
|
+
useFloating,
|
|
27
|
+
useInteractions,
|
|
28
|
+
useListNavigation,
|
|
29
|
+
useRole,
|
|
30
|
+
} from "@floating-ui/react";
|
|
31
|
+
import { AutocompleteContext } from "./useAutocompleteContext.ts";
|
|
32
|
+
import clsx from "clsx";
|
|
33
|
+
import { useEventCallback } from "../../hooks";
|
|
34
|
+
|
|
35
|
+
import { getOptionLabel, getOptionValue } from "./util.ts";
|
|
36
|
+
import { Button } from "../button";
|
|
37
|
+
import { Dialog } from "../dialog";
|
|
38
|
+
import { Loader } from "../loader";
|
|
39
|
+
import { inputConfig } from "../input/Input.tsx";
|
|
40
|
+
import { ThemeColor } from "../../types.d";
|
|
41
|
+
|
|
42
|
+
export interface AutocompleteProps<O extends OptionLike = Option> {
|
|
43
|
+
className?: string;
|
|
44
|
+
|
|
45
|
+
icon?: boolean | ReactNode;
|
|
46
|
+
|
|
47
|
+
color?: ThemeColor;
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* render suffix only if selection
|
|
51
|
+
* ex: add contextuel buttons associated to selection
|
|
52
|
+
*/
|
|
53
|
+
selectionSuffix?: ReactNode;
|
|
54
|
+
/**
|
|
55
|
+
* render suffix only if selection and searchValue is empty
|
|
56
|
+
*/
|
|
57
|
+
noSearchSuffix?: ReactNode;
|
|
58
|
+
|
|
59
|
+
placement?: Placement;
|
|
60
|
+
placeholder?: string;
|
|
61
|
+
/**
|
|
62
|
+
* search string used as value for <input>
|
|
63
|
+
*/
|
|
64
|
+
searchValue: string;
|
|
65
|
+
/**
|
|
66
|
+
* change handler when typing in the input
|
|
67
|
+
* or when clicking on an option (the label value is passed)
|
|
68
|
+
*/
|
|
69
|
+
onChangeSearchValue: (value: string, selectionLabel: boolean) => void;
|
|
70
|
+
|
|
71
|
+
selection?: O | null;
|
|
72
|
+
/**
|
|
73
|
+
* select handler for a selection from the autocomplete's dropdown
|
|
74
|
+
* the option object is passed when clicking on an option
|
|
75
|
+
* null value is passed when typing a new character in the input search.
|
|
76
|
+
*/
|
|
77
|
+
onChangeSelection: (option: O | null) => void;
|
|
78
|
+
options: O[];
|
|
79
|
+
|
|
80
|
+
autocompleteOptionComponent?: (
|
|
81
|
+
props: AutocompleteOptionProps<O>,
|
|
82
|
+
) => ReactNode;
|
|
83
|
+
|
|
84
|
+
loading?: boolean;
|
|
85
|
+
|
|
86
|
+
clearSearchButton?: boolean;
|
|
87
|
+
|
|
88
|
+
selectOnFocus?: boolean;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function Autocomplete<O extends OptionLike = Option>(
|
|
92
|
+
{
|
|
93
|
+
className,
|
|
94
|
+
icon = true,
|
|
95
|
+
color = "yellow",
|
|
96
|
+
selectionSuffix,
|
|
97
|
+
noSearchSuffix,
|
|
98
|
+
placement = "bottom",
|
|
99
|
+
placeholder = "Search...",
|
|
100
|
+
selection = null,
|
|
101
|
+
onChangeSelection,
|
|
102
|
+
autocompleteOptionComponent,
|
|
103
|
+
loading = false,
|
|
104
|
+
clearSearchButton = false,
|
|
105
|
+
searchValue,
|
|
106
|
+
onChangeSearchValue,
|
|
107
|
+
options,
|
|
108
|
+
selectOnFocus = true,
|
|
109
|
+
}: AutocompleteProps<O>,
|
|
110
|
+
inputRef: ForwardedRef<HTMLInputElement>,
|
|
111
|
+
) {
|
|
112
|
+
const [isOpen, setIsOpen] = useState(false);
|
|
113
|
+
const [activeIndex, setActiveIndex] = useState<number | null>(null);
|
|
114
|
+
|
|
115
|
+
const onChangeSearchValueStable = useEventCallback(onChangeSearchValue);
|
|
116
|
+
const onChangeSelectionStable = useEventCallback(onChangeSelection);
|
|
117
|
+
|
|
118
|
+
const OptionComponent = autocompleteOptionComponent ?? AutocompleteOption;
|
|
119
|
+
|
|
120
|
+
const listRef = useRef<Array<HTMLElement | null>>([]);
|
|
121
|
+
|
|
122
|
+
const { refs, floatingStyles, context } = useFloating({
|
|
123
|
+
placement,
|
|
124
|
+
open: isOpen,
|
|
125
|
+
onOpenChange: setIsOpen,
|
|
126
|
+
whileElementsMounted: autoUpdate,
|
|
127
|
+
middleware: [
|
|
128
|
+
offset(5),
|
|
129
|
+
flip({ padding: 10 }),
|
|
130
|
+
size({
|
|
131
|
+
apply({ rects, elements, availableHeight }) {
|
|
132
|
+
Object.assign(elements.floating.style, {
|
|
133
|
+
width: `${Math.max(130, rects.reference.width)}px`,
|
|
134
|
+
});
|
|
135
|
+
const firstChild = elements.floating.firstElementChild as HTMLElement;
|
|
136
|
+
if (firstChild) {
|
|
137
|
+
firstChild.style.maxHeight = `${Math.min(availableHeight, 288)}px`;
|
|
138
|
+
}
|
|
139
|
+
},
|
|
140
|
+
padding: 10,
|
|
141
|
+
}),
|
|
142
|
+
],
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
const role = useRole(context, { role: "listbox" });
|
|
146
|
+
const dismiss = useDismiss(context);
|
|
147
|
+
const listNav = useListNavigation(context, {
|
|
148
|
+
listRef,
|
|
149
|
+
activeIndex,
|
|
150
|
+
onNavigate: setActiveIndex,
|
|
151
|
+
/* we want the real focus to stay on the input, so focus associated to active items is simulated */
|
|
152
|
+
virtual: true,
|
|
153
|
+
loop: true,
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
const { getReferenceProps, getFloatingProps, getItemProps } = useInteractions(
|
|
157
|
+
[role, dismiss, listNav],
|
|
158
|
+
);
|
|
159
|
+
|
|
160
|
+
function handleChangeSearchValue(e: ChangeEvent<HTMLInputElement>) {
|
|
161
|
+
const value = e.target.value;
|
|
162
|
+
|
|
163
|
+
onChangeSearchValueStable(value, false);
|
|
164
|
+
|
|
165
|
+
if (value !== searchValue && selection !== null) {
|
|
166
|
+
onChangeSelectionStable(null);
|
|
167
|
+
setActiveIndex(null);
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (value) {
|
|
172
|
+
setIsOpen(true);
|
|
173
|
+
/**
|
|
174
|
+
* bug whith LazyAutocomplete activeIndex is set to 0 but the options array has immediately 0 elements
|
|
175
|
+
* so focus can't be set to the first Dom element.
|
|
176
|
+
*/
|
|
177
|
+
setActiveIndex(listRef.current[0] ? 0 : null);
|
|
178
|
+
} else {
|
|
179
|
+
setIsOpen(false);
|
|
180
|
+
setActiveIndex(null);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const handleSelect = useCallback(
|
|
185
|
+
(index: number | null) => {
|
|
186
|
+
if (index === null) {
|
|
187
|
+
onChangeSelectionStable(null);
|
|
188
|
+
} else if (options[index]) {
|
|
189
|
+
const newSelection = options[index];
|
|
190
|
+
onChangeSelectionStable(newSelection);
|
|
191
|
+
|
|
192
|
+
onChangeSearchValueStable(getOptionLabel(newSelection), true);
|
|
193
|
+
}
|
|
194
|
+
setActiveIndex(null);
|
|
195
|
+
setIsOpen(false);
|
|
196
|
+
},
|
|
197
|
+
|
|
198
|
+
[options, onChangeSelectionStable, onChangeSearchValueStable],
|
|
199
|
+
);
|
|
200
|
+
|
|
201
|
+
const autocompleteContext = useMemo(
|
|
202
|
+
() => ({
|
|
203
|
+
activeIndex,
|
|
204
|
+
selection,
|
|
205
|
+
getItemProps,
|
|
206
|
+
handleSelect,
|
|
207
|
+
}),
|
|
208
|
+
[activeIndex, selection, getItemProps, handleSelect],
|
|
209
|
+
);
|
|
210
|
+
|
|
211
|
+
return (
|
|
212
|
+
<div className={className}>
|
|
213
|
+
<div
|
|
214
|
+
className={clsx(inputConfig.container)}
|
|
215
|
+
ref={refs.setReference}
|
|
216
|
+
data-color={color}
|
|
217
|
+
>
|
|
218
|
+
{icon !== false && (
|
|
219
|
+
<div className="relative flex-center">
|
|
220
|
+
{loading && (
|
|
221
|
+
<Loader
|
|
222
|
+
size="medium"
|
|
223
|
+
color="gray"
|
|
224
|
+
className="absolute left-0 top-0"
|
|
225
|
+
/>
|
|
226
|
+
)}
|
|
227
|
+
{icon === true ? (
|
|
228
|
+
<span className="h-8 w-8 flex-center">
|
|
229
|
+
<i className="fe-search"></i>
|
|
230
|
+
</span>
|
|
231
|
+
) : (
|
|
232
|
+
icon
|
|
233
|
+
)}
|
|
234
|
+
</div>
|
|
235
|
+
)}
|
|
236
|
+
<input
|
|
237
|
+
spellCheck="false"
|
|
238
|
+
className={clsx(inputConfig.input, icon === false && "pl-4")}
|
|
239
|
+
ref={inputRef}
|
|
240
|
+
type="search"
|
|
241
|
+
value={searchValue}
|
|
242
|
+
placeholder={placeholder}
|
|
243
|
+
aria-autocomplete="list"
|
|
244
|
+
{...getReferenceProps({
|
|
245
|
+
onFocus() {
|
|
246
|
+
if (selectOnFocus) {
|
|
247
|
+
(document.activeElement as HTMLInputElement)?.select();
|
|
248
|
+
}
|
|
249
|
+
},
|
|
250
|
+
onChange: handleChangeSearchValue,
|
|
251
|
+
onMouseDown() {
|
|
252
|
+
if (options.length === 0) {
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
/**
|
|
256
|
+
* There is a already selection
|
|
257
|
+
* and the only element available is the selection,
|
|
258
|
+
* no need to display the dropdown.
|
|
259
|
+
*/
|
|
260
|
+
// if (options.length === 1 && options[0].value === selection?.value) {
|
|
261
|
+
// return;
|
|
262
|
+
// }
|
|
263
|
+
setIsOpen((isOpen) => !isOpen);
|
|
264
|
+
},
|
|
265
|
+
onKeyDown(e: KeyboardEvent<HTMLInputElement>) {
|
|
266
|
+
if (e.key === "Enter" && activeIndex != null) {
|
|
267
|
+
handleSelect(activeIndex);
|
|
268
|
+
}
|
|
269
|
+
},
|
|
270
|
+
})}
|
|
271
|
+
/>
|
|
272
|
+
<div className="relative flex-center">
|
|
273
|
+
{selection && selectionSuffix}
|
|
274
|
+
{clearSearchButton &&
|
|
275
|
+
(searchValue.trim() !== "" || selection !== null) && (
|
|
276
|
+
<Button
|
|
277
|
+
withRipple={false}
|
|
278
|
+
icon
|
|
279
|
+
color="gray"
|
|
280
|
+
variant="text"
|
|
281
|
+
onClick={() => {
|
|
282
|
+
setIsOpen(false);
|
|
283
|
+
onChangeSelectionStable(null);
|
|
284
|
+
setActiveIndex(null);
|
|
285
|
+
onChangeSearchValueStable("", true);
|
|
286
|
+
}}
|
|
287
|
+
>
|
|
288
|
+
<i className="fe-cancel"></i>
|
|
289
|
+
</Button>
|
|
290
|
+
)}
|
|
291
|
+
{icon === false && loading && <Loader size="medium" color="gray" />}
|
|
292
|
+
{!selection && searchValue === "" && noSearchSuffix}
|
|
293
|
+
</div>
|
|
294
|
+
</div>
|
|
295
|
+
<FloatingPortal>
|
|
296
|
+
<AutocompleteContext.Provider value={autocompleteContext}>
|
|
297
|
+
{isOpen && (
|
|
298
|
+
<FloatingFocusManager
|
|
299
|
+
disabled={selectOnFocus}
|
|
300
|
+
context={context}
|
|
301
|
+
initialFocus={-1}
|
|
302
|
+
visuallyHiddenDismiss
|
|
303
|
+
>
|
|
304
|
+
<div
|
|
305
|
+
className="z-dialog outline-none"
|
|
306
|
+
ref={refs.setFloating}
|
|
307
|
+
style={floatingStyles}
|
|
308
|
+
{...getFloatingProps()}
|
|
309
|
+
>
|
|
310
|
+
<Dialog
|
|
311
|
+
placement={context.placement}
|
|
312
|
+
className="overflow-auto motion-safe:animate-fade-in-list"
|
|
313
|
+
>
|
|
314
|
+
<FloatingList elementsRef={listRef}>
|
|
315
|
+
{options.map((option) => {
|
|
316
|
+
return (
|
|
317
|
+
<OptionComponent
|
|
318
|
+
{...option}
|
|
319
|
+
key={getOptionValue(option)}
|
|
320
|
+
/>
|
|
321
|
+
);
|
|
322
|
+
})}
|
|
323
|
+
</FloatingList>
|
|
324
|
+
</Dialog>
|
|
325
|
+
</div>
|
|
326
|
+
</FloatingFocusManager>
|
|
327
|
+
)}
|
|
328
|
+
</AutocompleteContext.Provider>
|
|
329
|
+
</FloatingPortal>
|
|
330
|
+
</div>
|
|
331
|
+
);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
export default forwardRef(Autocomplete);
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { memo, useId } from "react";
|
|
2
|
+
import { OptionLike } from "../select";
|
|
3
|
+
import { useListItem } from "@floating-ui/react";
|
|
4
|
+
import { useAutocomplete } from ".";
|
|
5
|
+
import clsx from "clsx";
|
|
6
|
+
import { getOptionLabel, getOptionValue } from "./util";
|
|
7
|
+
|
|
8
|
+
export type AutocompleteOptionProps<O extends OptionLike> = O;
|
|
9
|
+
|
|
10
|
+
function AutocompleteOption<O extends OptionLike>(props: AutocompleteOptionProps<O>) {
|
|
11
|
+
const label = getOptionLabel(props);
|
|
12
|
+
|
|
13
|
+
const id = useId();
|
|
14
|
+
const { activeIndex, selection, getItemProps, handleSelect } = useAutocomplete();
|
|
15
|
+
const { ref, index } = useListItem({ label });
|
|
16
|
+
const isActive = activeIndex === index;
|
|
17
|
+
const isSelected = selection ? getOptionValue(selection) === getOptionValue(props) : false;
|
|
18
|
+
|
|
19
|
+
return (
|
|
20
|
+
<div
|
|
21
|
+
className={clsx("option", isSelected ? "bg-gray-2" : isActive && "bg-gray-1")}
|
|
22
|
+
ref={ref}
|
|
23
|
+
role="option"
|
|
24
|
+
id={id}
|
|
25
|
+
aria-selected={isActive}
|
|
26
|
+
{...getItemProps({
|
|
27
|
+
onClick: () => handleSelect(index),
|
|
28
|
+
})}
|
|
29
|
+
>
|
|
30
|
+
{label}
|
|
31
|
+
</div>
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export default memo(AutocompleteOption);
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
|
|
2
|
+
import { Autocomplete, AutocompleteProps } from ".";
|
|
3
|
+
import { OptionLike, Option } from "../select";
|
|
4
|
+
import { getOptionLabel } from "./util";
|
|
5
|
+
import { useStateDebounce, useEventCallback } from "../../hooks";
|
|
6
|
+
|
|
7
|
+
interface Props<O extends OptionLike = Option>
|
|
8
|
+
// Omit "searchValue" | "onChangeSearchValue" | "options"
|
|
9
|
+
extends Pick<
|
|
10
|
+
AutocompleteProps<O>,
|
|
11
|
+
| "className"
|
|
12
|
+
| "icon"
|
|
13
|
+
| "color"
|
|
14
|
+
| "selectionSuffix"
|
|
15
|
+
| "noSearchSuffix"
|
|
16
|
+
| "placement"
|
|
17
|
+
| "placeholder"
|
|
18
|
+
| "selection"
|
|
19
|
+
| "onChangeSelection"
|
|
20
|
+
| "autocompleteOptionComponent"
|
|
21
|
+
| "loading"
|
|
22
|
+
| "clearSearchButton"
|
|
23
|
+
> {
|
|
24
|
+
onChangeSearchValueCallback: (searchValue: string) => Promise<O[]>;
|
|
25
|
+
debounce?: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function LazyAutocomplete<O extends OptionLike = Option>({
|
|
29
|
+
onChangeSearchValueCallback,
|
|
30
|
+
debounce = 5000,
|
|
31
|
+
selection = null,
|
|
32
|
+
onChangeSelection,
|
|
33
|
+
...rest
|
|
34
|
+
}: Props<O>) {
|
|
35
|
+
const onChangeSelectionStable = useEventCallback(onChangeSelection);
|
|
36
|
+
const onChangeSearchValueCallbackStable = useEventCallback(onChangeSearchValueCallback);
|
|
37
|
+
|
|
38
|
+
const [searchValue, searchValueDebounced, setSearchValue] = useStateDebounce(
|
|
39
|
+
selection ? getOptionLabel(selection) : "",
|
|
40
|
+
debounce,
|
|
41
|
+
);
|
|
42
|
+
// console.log(searchValue, searchValueDebounced);
|
|
43
|
+
|
|
44
|
+
const searchValueRef = useRef(searchValue);
|
|
45
|
+
searchValueRef.current = searchValue;
|
|
46
|
+
|
|
47
|
+
const inputRef = useRef<HTMLInputElement>(null!);
|
|
48
|
+
|
|
49
|
+
const [loading, setLoading] = useState(false);
|
|
50
|
+
|
|
51
|
+
const [options, setOptions] = useState<O[]>([]);
|
|
52
|
+
|
|
53
|
+
const handleChangeSelection = useCallback(
|
|
54
|
+
(selection: O | null) => {
|
|
55
|
+
onChangeSelectionStable(selection);
|
|
56
|
+
if (selection) {
|
|
57
|
+
setOptions([]);
|
|
58
|
+
}
|
|
59
|
+
},
|
|
60
|
+
[onChangeSelectionStable],
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
// side effect, update the searchValue <input /> value when
|
|
64
|
+
// selection come from outside
|
|
65
|
+
useLayoutEffect(() => {
|
|
66
|
+
if (selection === null) {
|
|
67
|
+
if (document.activeElement !== inputRef.current) {
|
|
68
|
+
setSearchValue("", true);
|
|
69
|
+
}
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// we need the fresh value of searchValue but we don't
|
|
74
|
+
// want searchValue as useEffect dependency
|
|
75
|
+
const selectionLabel = getOptionLabel(selection);
|
|
76
|
+
if (searchValueRef.current !== selectionLabel) {
|
|
77
|
+
setSearchValue(selectionLabel, true);
|
|
78
|
+
}
|
|
79
|
+
}, [selection, setSearchValue]);
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* set new options when searchValueDebounced change
|
|
83
|
+
*/
|
|
84
|
+
useEffect(() => {
|
|
85
|
+
let abort = false;
|
|
86
|
+
|
|
87
|
+
// when we're selecting option we change searchValue to be the complete label
|
|
88
|
+
// value but we don't want to fetch new research
|
|
89
|
+
if (selection) {
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (searchValueDebounced.trim() === "") {
|
|
94
|
+
setOptions([]);
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
setLoading(true);
|
|
99
|
+
|
|
100
|
+
onChangeSearchValueCallbackStable(searchValueDebounced)
|
|
101
|
+
?.then((newOptions) => {
|
|
102
|
+
setLoading(false);
|
|
103
|
+
|
|
104
|
+
if (!abort) {
|
|
105
|
+
setOptions(newOptions);
|
|
106
|
+
}
|
|
107
|
+
})
|
|
108
|
+
.catch(() => {
|
|
109
|
+
setLoading(false);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
return () => {
|
|
113
|
+
setLoading(false);
|
|
114
|
+
abort = true;
|
|
115
|
+
};
|
|
116
|
+
}, [selection, searchValueDebounced, onChangeSearchValueCallbackStable]);
|
|
117
|
+
|
|
118
|
+
return (
|
|
119
|
+
<Autocomplete
|
|
120
|
+
ref={inputRef}
|
|
121
|
+
searchValue={searchValue}
|
|
122
|
+
onChangeSearchValue={(newValue, immediate) => {
|
|
123
|
+
setSearchValue(newValue, immediate || newValue === "" ? true : false);
|
|
124
|
+
}}
|
|
125
|
+
selection={selection}
|
|
126
|
+
onChangeSelection={handleChangeSelection}
|
|
127
|
+
options={options}
|
|
128
|
+
loading={loading}
|
|
129
|
+
{...rest}
|
|
130
|
+
/>
|
|
131
|
+
);
|
|
132
|
+
}
|