pentatrion-design 0.0.1
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/.storybook/main.ts +33 -0
- package/.storybook/manager.ts +8 -0
- package/.storybook/preview-head.html +2 -0
- package/.storybook/preview.ts +26 -0
- package/.storybook/public/bg-map.jpg +0 -0
- package/.storybook/public/favicon-32x32.png +0 -0
- package/.storybook/public/favicon.svg +28 -0
- package/.storybook/public/movies.json +2153 -0
- package/.storybook/public/population.json +1178 -0
- package/.storybook/public/town-74.geojson +8995 -0
- package/.storybook/public/town-74.json +4062 -0
- package/.storybook/theme.ts +14 -0
- package/Makefile +14 -0
- package/README.md +40 -0
- package/TODO.md +5 -0
- package/dist/index.d.ts +1110 -0
- package/dist/index.js +3707 -0
- package/dist/redux/index.d.ts +43 -0
- package/dist/redux/index.js +583 -0
- package/dist/types.d-DlHa_IaW.d.ts +14 -0
- package/images/logo-shadow.svg +173 -0
- package/images/logo.svg +76 -0
- package/package.json +85 -0
- package/postcss.config.js +7 -0
- package/src/components/autocomplete/Autocomplete.stories.tsx +89 -0
- package/src/components/autocomplete/Autocomplete.tsx +316 -0
- package/src/components/autocomplete/AutocompleteOption.tsx +35 -0
- package/src/components/autocomplete/LazyAutocomplete.tsx +130 -0
- package/src/components/autocomplete/SimpleAutocomplete.tsx +74 -0
- package/src/components/autocomplete/index.ts +6 -0
- package/src/components/autocomplete/useAutocompleteContext.ts +20 -0
- package/src/components/autocomplete/util.ts +19 -0
- package/src/components/badge/Badge.stories.tsx +72 -0
- package/src/components/badge/Badge.tsx +70 -0
- package/src/components/button/Button.stories.tsx +189 -0
- package/src/components/button/Button.test.tsx +20 -0
- package/src/components/button/Button.tsx +200 -0
- package/src/components/button/ButtonGroup.stories.tsx +46 -0
- package/src/components/button/ButtonGroup.tsx +29 -0
- package/src/components/button/index.ts +2 -0
- package/src/components/card/Card.stories.tsx +21 -0
- package/src/components/card/config.ts +4 -0
- package/src/components/card/index.ts +1 -0
- package/src/components/code/Code.tsx +15 -0
- package/src/components/context-menu/ContextMenu.stories.tsx +22 -0
- package/src/components/context-menu/ContextMenu.tsx +175 -0
- package/src/components/context-menu/ContextMenuItem.tsx +34 -0
- package/src/components/context-menu/index.ts +2 -0
- package/src/components/dialog/Dialog.stories.tsx +270 -0
- package/src/components/dialog/Dialog.tsx +46 -0
- package/src/components/dialog/index.ts +1 -0
- package/src/components/dialog/util.ts +29 -0
- package/src/components/dropdown-menu/DropdownMenu.stories.tsx +19 -0
- package/src/components/dropdown-menu/DropdownMenu.tsx +12 -0
- package/src/components/dropdown-menu/DropdownMenuContent.tsx +46 -0
- package/src/components/dropdown-menu/DropdownMenuItem.tsx +33 -0
- package/src/components/dropdown-menu/DropdownMenuTrigger.tsx +42 -0
- package/src/components/dropdown-menu/index.ts +7 -0
- package/src/components/dropdown-menu/interface.d.ts +13 -0
- package/src/components/dropdown-menu/useDropdownMenu.tsx +115 -0
- package/src/components/dropdown-menu/useDropdownMenuContext.ts +16 -0
- package/src/components/dropdown-menu-nested/DropdownMenuNested.stories.tsx +33 -0
- package/src/components/dropdown-menu-nested/DropdownMenuNested.tsx +19 -0
- package/src/components/dropdown-menu-nested/MenuContext.ts +15 -0
- package/src/components/dropdown-menu-nested/MenuItem.tsx +43 -0
- package/src/components/dropdown-menu-nested/MenuItemWithChildren.tsx +204 -0
- package/src/components/dropdown-menu-nested/index.ts +3 -0
- package/src/components/flash/Flash.stories.tsx +34 -0
- package/src/components/flash/Flash.tsx +24 -0
- package/src/components/highlight/Highlight.stories.tsx +128 -0
- package/src/components/highlight/Highlight.tsx +29 -0
- package/src/components/href/Href.stories.tsx +55 -0
- package/src/components/href/Href.tsx +18 -0
- package/src/components/input/Checkbox.stories.tsx +52 -0
- package/src/components/input/Checkbox.tsx +41 -0
- package/src/components/input/Input.stories.tsx +103 -0
- package/src/components/input/Input.test.tsx +10 -0
- package/src/components/input/Input.tsx +60 -0
- package/src/components/input/RadioGroup.stories.tsx +76 -0
- package/src/components/input/RadioGroup.tsx +84 -0
- package/src/components/input/Toggle.stories.tsx +29 -0
- package/src/components/input/Toggle.tsx +35 -0
- package/src/components/input/index.ts +4 -0
- package/src/components/input-field/InputField.stories.tsx +227 -0
- package/src/components/input-field/InputField.tsx +62 -0
- package/src/components/loader/Loader.stories.tsx +37 -0
- package/src/components/loader/Loader.tsx +54 -0
- package/src/components/modal/Components.tsx +58 -0
- package/src/components/modal/Modal.stories.tsx +113 -0
- package/src/components/modal/Modal.tsx +13 -0
- package/src/components/modal/ModalContent.tsx +46 -0
- package/src/components/modal/ModalTrigger.tsx +41 -0
- package/src/components/modal/index.ts +7 -0
- package/src/components/modal/interface.d.ts +10 -0
- package/src/components/modal/useModal.ts +48 -0
- package/src/components/modal/useModalContext.ts +16 -0
- package/src/components/notification/Notification.stories.tsx +51 -0
- package/src/components/notification/NotificationsContext.ts +79 -0
- package/src/components/notification/NotificationsProvider.tsx +58 -0
- package/src/components/notification/index.ts +2 -0
- package/src/components/popover/Components.tsx +45 -0
- package/src/components/popover/Popover.stories.tsx +46 -0
- package/src/components/popover/Popover.test.tsx +21 -0
- package/src/components/popover/Popover.tsx +12 -0
- package/src/components/popover/PopoverContent.tsx +46 -0
- package/src/components/popover/PopoverTrigger.tsx +42 -0
- package/src/components/popover/index.ts +7 -0
- package/src/components/popover/interface.d.ts +13 -0
- package/src/components/popover/usePopover.tsx +80 -0
- package/src/components/popover/usePopoverContext.ts +16 -0
- package/src/components/portal/Portal.tsx +12 -0
- package/src/components/quick-settings/QuickSettings.stories.tsx +182 -0
- package/src/components/resize-area/ResizeArea.stories.css +40 -0
- package/src/components/resize-area/ResizeArea.stories.tsx +30 -0
- package/src/components/resize-area/ResizeArea.tsx +79 -0
- package/src/components/scroll/Scroll.stories.tsx +81 -0
- package/src/components/scroll/Scroll.tsx +111 -0
- package/src/components/select/Select.stories.tsx +248 -0
- package/src/components/select/Select.tsx +362 -0
- package/src/components/select/SelectOption.tsx +29 -0
- package/src/components/select/SelectSelection.tsx +13 -0
- package/src/components/select/index.ts +5 -0
- package/src/components/select/interface.d.ts +36 -0
- package/src/components/select/useSelectContext.ts +19 -0
- package/src/components/select/util.ts +12 -0
- package/src/components/snack/Snack.stories.tsx +33 -0
- package/src/components/snack/Snack.tsx +47 -0
- package/src/components/sortable/Sortable.stories.tsx +98 -0
- package/src/components/sortable/Sortable.tsx +37 -0
- package/src/components/sortable/index.ts +2 -0
- package/src/components/sortable/types.d.ts +12 -0
- package/src/components/steps/Step.tsx +41 -0
- package/src/components/steps/Steps.stories.tsx +141 -0
- package/src/components/steps/Steps.tsx +49 -0
- package/src/components/steps/index.ts +3 -0
- package/src/components/steps/util.ts +3 -0
- package/src/components/table/Table.stories.tsx +36 -0
- package/src/components/table/Table.tsx +13 -0
- package/src/components/table/TableBody.tsx +9 -0
- package/src/components/table/TableCell.tsx +16 -0
- package/src/components/table/TableFooter.tsx +9 -0
- package/src/components/table/TableHeader.tsx +14 -0
- package/src/components/table/TableHeaderCell.tsx +14 -0
- package/src/components/table/TableRow.tsx +14 -0
- package/src/components/table/index.ts +7 -0
- package/src/components/tabs/Tabs.stories.tsx +66 -0
- package/src/components/tabs/Tabs.tsx +80 -0
- package/src/components/tooltip/SimpleTooltip.tsx +17 -0
- package/src/components/tooltip/Tooltip.stories.tsx +129 -0
- package/src/components/tooltip/Tooltip.test.tsx +18 -0
- package/src/components/tooltip/Tooltip.tsx +13 -0
- package/src/components/tooltip/TooltipContent.tsx +41 -0
- package/src/components/tooltip/TooltipTrigger.tsx +36 -0
- package/src/components/tooltip/index.ts +6 -0
- package/src/components/tooltip/interface.d.ts +15 -0
- package/src/components/tooltip/useTooltip.ts +87 -0
- package/src/components/tooltip/useTooltipContext.ts +16 -0
- package/src/env.d.ts +27 -0
- package/src/hooks/index.ts +13 -0
- package/src/hooks/useCombinedRefs.test.tsx +42 -0
- package/src/hooks/useCombinedRefs.ts +21 -0
- package/src/hooks/useCopyToClipboard.ts +28 -0
- package/src/hooks/useDebounce.ts +59 -0
- package/src/hooks/useEventCallback.ts +13 -0
- package/src/hooks/useEventListener.ts +86 -0
- package/src/hooks/useFetch.tsx +21 -0
- package/src/hooks/useIsClosing.ts +25 -0
- package/src/hooks/useIsMounted.tsx +16 -0
- package/src/hooks/useIsomorphicLayoutEffect.ts +4 -0
- package/src/hooks/useNotifications.tsx +13 -0
- package/src/hooks/useOnClickOutside.ts +35 -0
- package/src/hooks/usePrevious.ts +9 -0
- package/src/hooks/useRipple.test.tsx +52 -0
- package/src/hooks/useRipple.tsx +79 -0
- package/src/index.css +14 -0
- package/src/index.ts +39 -0
- package/src/lib/arrUtil.ts +8 -0
- package/src/lib/error.ts +18 -0
- package/src/lib/fetch.test.ts +107 -0
- package/src/lib/fetch.ts +115 -0
- package/src/lib/index.ts +3 -0
- package/src/lib/tailwindVariants.ts +35 -0
- package/src/redux/index.ts +1 -0
- package/src/redux/notification/components/NotificationConsumer.tsx +52 -0
- package/src/redux/notification/errorCatcherMiddleware.ts +17 -0
- package/src/redux/notification/hooks/useNotification.ts +26 -0
- package/src/redux/notification/index.ts +5 -0
- package/src/redux/notification/notificationSlice.ts +68 -0
- package/src/styles/Animation.stories.tsx +43 -0
- package/src/styles/Shadow.stories.tsx +43 -0
- package/src/styles/Themes.stories.tsx +31 -0
- package/src/styles/Typography.stories.tsx +101 -0
- package/src/tailwind/base.css +188 -0
- package/src/tailwind/components-input-outline.css +62 -0
- package/src/tailwind/components-resize-area.css +112 -0
- package/src/tailwind/components-step.css +161 -0
- package/src/tailwind/components.css +51 -0
- package/src/tailwind/keyframes.ts +69 -0
- package/src/tailwind/pentatrionTw.ts +147 -0
- package/src/tailwind/utilities-dialog.css +103 -0
- package/src/tailwind/utilities.css +3 -0
- package/src/types.d.ts +12 -0
- package/tailwind.config.js +18 -0
- package/tests/README.md +32 -0
- package/tests/setup.ts +1 -0
- package/tsconfig.json +14 -0
- package/tsup.config.ts +23 -0
- package/vite.config.ts +24 -0
|
@@ -0,0 +1,316 @@
|
|
|
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, { AutocompleteOptionProps } from "./AutocompleteOption.tsx";
|
|
14
|
+
import {
|
|
15
|
+
FloatingFocusManager,
|
|
16
|
+
FloatingList,
|
|
17
|
+
FloatingPortal,
|
|
18
|
+
Placement,
|
|
19
|
+
autoUpdate,
|
|
20
|
+
flip,
|
|
21
|
+
offset,
|
|
22
|
+
size,
|
|
23
|
+
useDismiss,
|
|
24
|
+
useFloating,
|
|
25
|
+
useInteractions,
|
|
26
|
+
useListNavigation,
|
|
27
|
+
useRole,
|
|
28
|
+
} from "@floating-ui/react";
|
|
29
|
+
import { AutocompleteContext } from "./useAutocompleteContext.ts";
|
|
30
|
+
import clsx from "clsx";
|
|
31
|
+
import { useEventCallback } from "~/hooks";
|
|
32
|
+
|
|
33
|
+
import { getOptionLabel, getOptionValue } from "./util.ts";
|
|
34
|
+
import { Button } from "../button";
|
|
35
|
+
import { Dialog } from "../dialog";
|
|
36
|
+
import Loader from "../loader/Loader";
|
|
37
|
+
import { inputConfig } from "../input/Input.tsx";
|
|
38
|
+
import { ThemeColor } from "~/types.d";
|
|
39
|
+
|
|
40
|
+
export interface AutocompleteProps<O extends OptionLike = Option> {
|
|
41
|
+
className?: string;
|
|
42
|
+
|
|
43
|
+
icon?: boolean | ReactNode;
|
|
44
|
+
|
|
45
|
+
color?: ThemeColor;
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* render suffix only if selection
|
|
49
|
+
* ex: add contextuel buttons associated to selection
|
|
50
|
+
*/
|
|
51
|
+
selectionSuffix?: ReactNode;
|
|
52
|
+
/**
|
|
53
|
+
* render suffix only if selection and searchValue is empty
|
|
54
|
+
*/
|
|
55
|
+
noSearchSuffix?: ReactNode;
|
|
56
|
+
|
|
57
|
+
placement?: Placement;
|
|
58
|
+
placeholder?: string;
|
|
59
|
+
/**
|
|
60
|
+
* search string used as value for <input>
|
|
61
|
+
*/
|
|
62
|
+
searchValue: string;
|
|
63
|
+
/**
|
|
64
|
+
* change handler when typing in the input
|
|
65
|
+
* or when clicking on an option (the label value is passed)
|
|
66
|
+
*/
|
|
67
|
+
onChangeSearchValue: (value: string, selectionLabel: boolean) => void;
|
|
68
|
+
|
|
69
|
+
selection?: O | null;
|
|
70
|
+
/**
|
|
71
|
+
* select handler for a selection from the autocomplete's dropdown
|
|
72
|
+
* the option object is passed when clicking on an option
|
|
73
|
+
* null value is passed when typing a new character in the input search.
|
|
74
|
+
*/
|
|
75
|
+
onChangeSelection: (option: O | null) => void;
|
|
76
|
+
options: O[];
|
|
77
|
+
|
|
78
|
+
autocompleteOptionComponent?: (props: AutocompleteOptionProps<O>) => ReactNode;
|
|
79
|
+
|
|
80
|
+
loading?: boolean;
|
|
81
|
+
|
|
82
|
+
clearSearchButton?: boolean;
|
|
83
|
+
|
|
84
|
+
selectOnFocus?: boolean;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function Autocomplete<O extends OptionLike = Option>(
|
|
88
|
+
{
|
|
89
|
+
className,
|
|
90
|
+
icon = true,
|
|
91
|
+
color = "yellow",
|
|
92
|
+
selectionSuffix,
|
|
93
|
+
noSearchSuffix,
|
|
94
|
+
placement = "bottom",
|
|
95
|
+
placeholder = "Search...",
|
|
96
|
+
selection = null,
|
|
97
|
+
onChangeSelection,
|
|
98
|
+
autocompleteOptionComponent,
|
|
99
|
+
loading = false,
|
|
100
|
+
clearSearchButton = false,
|
|
101
|
+
searchValue,
|
|
102
|
+
onChangeSearchValue,
|
|
103
|
+
options,
|
|
104
|
+
selectOnFocus = true,
|
|
105
|
+
}: AutocompleteProps<O>,
|
|
106
|
+
inputRef: ForwardedRef<HTMLInputElement>,
|
|
107
|
+
) {
|
|
108
|
+
const [isOpen, setIsOpen] = useState(false);
|
|
109
|
+
const [activeIndex, setActiveIndex] = useState<number | null>(null);
|
|
110
|
+
|
|
111
|
+
const onChangeSearchValueStable = useEventCallback(onChangeSearchValue);
|
|
112
|
+
const onChangeSelectionStable = useEventCallback(onChangeSelection);
|
|
113
|
+
|
|
114
|
+
const OptionComponent = autocompleteOptionComponent ?? AutocompleteOption;
|
|
115
|
+
|
|
116
|
+
const listRef = useRef<Array<HTMLElement | null>>([]);
|
|
117
|
+
|
|
118
|
+
const { refs, floatingStyles, context } = useFloating({
|
|
119
|
+
placement,
|
|
120
|
+
open: isOpen,
|
|
121
|
+
onOpenChange: setIsOpen,
|
|
122
|
+
whileElementsMounted: autoUpdate,
|
|
123
|
+
middleware: [
|
|
124
|
+
offset(5),
|
|
125
|
+
flip({ padding: 10 }),
|
|
126
|
+
size({
|
|
127
|
+
apply({ rects, elements, availableHeight }) {
|
|
128
|
+
Object.assign(elements.floating.style, {
|
|
129
|
+
width: `${Math.max(130, rects.reference.width)}px`,
|
|
130
|
+
});
|
|
131
|
+
const firstChild = elements.floating.firstElementChild as HTMLElement;
|
|
132
|
+
if (firstChild) {
|
|
133
|
+
firstChild.style.maxHeight = `${Math.min(availableHeight, 288)}px`;
|
|
134
|
+
}
|
|
135
|
+
},
|
|
136
|
+
padding: 10,
|
|
137
|
+
}),
|
|
138
|
+
],
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
const role = useRole(context, { role: "listbox" });
|
|
142
|
+
const dismiss = useDismiss(context);
|
|
143
|
+
const listNav = useListNavigation(context, {
|
|
144
|
+
listRef,
|
|
145
|
+
activeIndex,
|
|
146
|
+
onNavigate: setActiveIndex,
|
|
147
|
+
/* we want the real focus to stay on the input, so focus associated to active items is simulated */
|
|
148
|
+
virtual: true,
|
|
149
|
+
loop: true,
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
const { getReferenceProps, getFloatingProps, getItemProps } = useInteractions([
|
|
153
|
+
role,
|
|
154
|
+
dismiss,
|
|
155
|
+
listNav,
|
|
156
|
+
]);
|
|
157
|
+
|
|
158
|
+
function handleChangeSearchValue(e: ChangeEvent<HTMLInputElement>) {
|
|
159
|
+
const value = e.target.value;
|
|
160
|
+
|
|
161
|
+
onChangeSearchValueStable(value, false);
|
|
162
|
+
|
|
163
|
+
if (value !== searchValue && selection !== null) {
|
|
164
|
+
onChangeSelectionStable(null);
|
|
165
|
+
setActiveIndex(null);
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if (value) {
|
|
170
|
+
setIsOpen(true);
|
|
171
|
+
/**
|
|
172
|
+
* bug whith LazyAutocomplete activeIndex is set to 0 but the options array has immediately 0 elements
|
|
173
|
+
* so focus can't be set to the first Dom element.
|
|
174
|
+
*/
|
|
175
|
+
setActiveIndex(listRef.current[0] ? 0 : null);
|
|
176
|
+
} else {
|
|
177
|
+
setIsOpen(false);
|
|
178
|
+
setActiveIndex(null);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const handleSelect = useCallback(
|
|
183
|
+
(index: number | null) => {
|
|
184
|
+
if (index === null) {
|
|
185
|
+
onChangeSelectionStable(null);
|
|
186
|
+
} else if (options[index]) {
|
|
187
|
+
const newSelection = options[index];
|
|
188
|
+
onChangeSelectionStable(newSelection);
|
|
189
|
+
|
|
190
|
+
onChangeSearchValueStable(getOptionLabel(newSelection), true);
|
|
191
|
+
}
|
|
192
|
+
setActiveIndex(null);
|
|
193
|
+
setIsOpen(false);
|
|
194
|
+
},
|
|
195
|
+
|
|
196
|
+
[options, onChangeSelectionStable, onChangeSearchValueStable],
|
|
197
|
+
);
|
|
198
|
+
|
|
199
|
+
const autocompleteContext = useMemo(
|
|
200
|
+
() => ({
|
|
201
|
+
activeIndex,
|
|
202
|
+
selection,
|
|
203
|
+
getItemProps,
|
|
204
|
+
handleSelect,
|
|
205
|
+
}),
|
|
206
|
+
[activeIndex, selection, getItemProps, handleSelect],
|
|
207
|
+
);
|
|
208
|
+
|
|
209
|
+
return (
|
|
210
|
+
<div className={className}>
|
|
211
|
+
<div className={clsx(inputConfig.container)} ref={refs.setReference} data-color={color}>
|
|
212
|
+
{icon !== false && (
|
|
213
|
+
<div className="flex-center relative">
|
|
214
|
+
{loading && <Loader size="medium" color="gray" className="absolute top-0 left-0" />}
|
|
215
|
+
{icon === true ? (
|
|
216
|
+
<span className="w-8 h-8 flex-center">
|
|
217
|
+
<i className="fe-search"></i>
|
|
218
|
+
</span>
|
|
219
|
+
) : (
|
|
220
|
+
icon
|
|
221
|
+
)}
|
|
222
|
+
</div>
|
|
223
|
+
)}
|
|
224
|
+
<input
|
|
225
|
+
spellCheck="false"
|
|
226
|
+
className={clsx(inputConfig.input, icon === false && "pl-4")}
|
|
227
|
+
ref={inputRef}
|
|
228
|
+
type="search"
|
|
229
|
+
value={searchValue}
|
|
230
|
+
placeholder={placeholder}
|
|
231
|
+
aria-autocomplete="list"
|
|
232
|
+
{...getReferenceProps({
|
|
233
|
+
onFocus() {
|
|
234
|
+
if (selectOnFocus) {
|
|
235
|
+
(document.activeElement as HTMLInputElement)?.select();
|
|
236
|
+
}
|
|
237
|
+
},
|
|
238
|
+
onChange: handleChangeSearchValue,
|
|
239
|
+
onMouseDown() {
|
|
240
|
+
if (options.length === 0) {
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
/**
|
|
244
|
+
* There is a already selection
|
|
245
|
+
* and the only element available is the selection,
|
|
246
|
+
* no need to display the dropdown.
|
|
247
|
+
*/
|
|
248
|
+
// if (options.length === 1 && options[0].value === selection?.value) {
|
|
249
|
+
// return;
|
|
250
|
+
// }
|
|
251
|
+
setIsOpen((isOpen) => !isOpen);
|
|
252
|
+
},
|
|
253
|
+
onKeyDown(e: KeyboardEvent<HTMLInputElement>) {
|
|
254
|
+
if (e.key === "Enter" && activeIndex != null) {
|
|
255
|
+
handleSelect(activeIndex);
|
|
256
|
+
}
|
|
257
|
+
},
|
|
258
|
+
})}
|
|
259
|
+
/>
|
|
260
|
+
<div className="flex-center relative">
|
|
261
|
+
{selection && selectionSuffix}
|
|
262
|
+
{clearSearchButton && (searchValue.trim() !== "" || selection !== null) && (
|
|
263
|
+
<Button
|
|
264
|
+
withRipple={false}
|
|
265
|
+
icon
|
|
266
|
+
color="gray"
|
|
267
|
+
variant="text"
|
|
268
|
+
onClick={() => {
|
|
269
|
+
setIsOpen(false);
|
|
270
|
+
onChangeSelectionStable(null);
|
|
271
|
+
setActiveIndex(null);
|
|
272
|
+
onChangeSearchValueStable("", true);
|
|
273
|
+
}}
|
|
274
|
+
>
|
|
275
|
+
<i className="fe-cancel"></i>
|
|
276
|
+
</Button>
|
|
277
|
+
)}
|
|
278
|
+
{icon === false && loading && <Loader size="medium" color="gray" />}
|
|
279
|
+
{!selection && searchValue === "" && noSearchSuffix}
|
|
280
|
+
</div>
|
|
281
|
+
</div>
|
|
282
|
+
<FloatingPortal>
|
|
283
|
+
<AutocompleteContext.Provider value={autocompleteContext}>
|
|
284
|
+
{isOpen && (
|
|
285
|
+
<FloatingFocusManager
|
|
286
|
+
disabled={selectOnFocus}
|
|
287
|
+
context={context}
|
|
288
|
+
initialFocus={-1}
|
|
289
|
+
visuallyHiddenDismiss
|
|
290
|
+
>
|
|
291
|
+
<div
|
|
292
|
+
className="z-dialog"
|
|
293
|
+
ref={refs.setFloating}
|
|
294
|
+
style={floatingStyles}
|
|
295
|
+
{...getFloatingProps()}
|
|
296
|
+
>
|
|
297
|
+
<Dialog
|
|
298
|
+
placement={context.placement}
|
|
299
|
+
className="animate-fade-in-list overflow-auto"
|
|
300
|
+
>
|
|
301
|
+
<FloatingList elementsRef={listRef}>
|
|
302
|
+
{options.map((option) => {
|
|
303
|
+
return <OptionComponent {...option} key={getOptionValue(option)} />;
|
|
304
|
+
})}
|
|
305
|
+
</FloatingList>
|
|
306
|
+
</Dialog>
|
|
307
|
+
</div>
|
|
308
|
+
</FloatingFocusManager>
|
|
309
|
+
)}
|
|
310
|
+
</AutocompleteContext.Provider>
|
|
311
|
+
</FloatingPortal>
|
|
312
|
+
</div>
|
|
313
|
+
);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
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 "./useAutocompleteContext";
|
|
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,130 @@
|
|
|
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 default function LazyAutocomplete<O extends OptionLike = Option>({
|
|
29
|
+
onChangeSearchValueCallback,
|
|
30
|
+
debounce = 500,
|
|
31
|
+
selection = null,
|
|
32
|
+
onChangeSelection,
|
|
33
|
+
...rest
|
|
34
|
+
}: Props<O>) {
|
|
35
|
+
const onChangeSelectionStable = useEventCallback(onChangeSelection);
|
|
36
|
+
|
|
37
|
+
const [searchValue, searchValueDebounced, setSearchValue] = useStateDebounce(
|
|
38
|
+
selection ? getOptionLabel(selection) : "",
|
|
39
|
+
debounce,
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
const searchValueRef = useRef(searchValue);
|
|
43
|
+
searchValueRef.current = searchValue;
|
|
44
|
+
|
|
45
|
+
const inputRef = useRef<HTMLInputElement>(null!);
|
|
46
|
+
|
|
47
|
+
const [loading, setLoading] = useState(false);
|
|
48
|
+
|
|
49
|
+
const [options, setOptions] = useState<O[]>([]);
|
|
50
|
+
|
|
51
|
+
const handleChangeSelection = useCallback(
|
|
52
|
+
(selection: O | null) => {
|
|
53
|
+
onChangeSelectionStable(selection);
|
|
54
|
+
if (selection) {
|
|
55
|
+
setOptions([]);
|
|
56
|
+
}
|
|
57
|
+
},
|
|
58
|
+
[onChangeSelectionStable],
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
// side effect, update the searchValue <input /> value when
|
|
62
|
+
// selection come from outside
|
|
63
|
+
useLayoutEffect(() => {
|
|
64
|
+
if (selection === null) {
|
|
65
|
+
if (document.activeElement !== inputRef.current) {
|
|
66
|
+
setSearchValue("");
|
|
67
|
+
}
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// we need the fresh value of searchValue but we don't
|
|
72
|
+
// want searchValue as useEffect dependency
|
|
73
|
+
const selectionLabel = getOptionLabel(selection);
|
|
74
|
+
if (searchValueRef.current !== selectionLabel) {
|
|
75
|
+
setSearchValue(selectionLabel, true);
|
|
76
|
+
}
|
|
77
|
+
}, [selection, setSearchValue]);
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* set new options when searchValueDebounced change
|
|
81
|
+
*/
|
|
82
|
+
useEffect(() => {
|
|
83
|
+
let abort = false;
|
|
84
|
+
|
|
85
|
+
// when we're selecting option we change searchValue to be the complete label
|
|
86
|
+
// value but we don't want to fetch new research
|
|
87
|
+
if (selection) {
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (searchValueDebounced.trim() === "") {
|
|
92
|
+
setOptions([]);
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
setLoading(true);
|
|
97
|
+
|
|
98
|
+
onChangeSearchValueCallback(searchValueDebounced)
|
|
99
|
+
.then((newOptions) => {
|
|
100
|
+
setLoading(false);
|
|
101
|
+
|
|
102
|
+
if (!abort) {
|
|
103
|
+
setOptions(newOptions);
|
|
104
|
+
}
|
|
105
|
+
})
|
|
106
|
+
.catch(() => {
|
|
107
|
+
setLoading(false);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
return () => {
|
|
111
|
+
setLoading(false);
|
|
112
|
+
abort = true;
|
|
113
|
+
};
|
|
114
|
+
}, [selection, searchValueDebounced, onChangeSearchValueCallback]);
|
|
115
|
+
|
|
116
|
+
return (
|
|
117
|
+
<Autocomplete
|
|
118
|
+
ref={inputRef}
|
|
119
|
+
searchValue={searchValue}
|
|
120
|
+
onChangeSearchValue={(newValue, immediate) => {
|
|
121
|
+
setSearchValue(newValue, immediate || newValue === "" ? true : false);
|
|
122
|
+
}}
|
|
123
|
+
selection={selection}
|
|
124
|
+
onChangeSelection={handleChangeSelection}
|
|
125
|
+
options={options}
|
|
126
|
+
loading={loading}
|
|
127
|
+
{...rest}
|
|
128
|
+
/>
|
|
129
|
+
);
|
|
130
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { useLayoutEffect, useRef, useState } from "react";
|
|
2
|
+
import { Autocomplete, AutocompleteProps } from ".";
|
|
3
|
+
import { Option, OptionLike } from "../select";
|
|
4
|
+
import { useEventCallback } from "~/hooks";
|
|
5
|
+
import { getOptionLabel } from "./util";
|
|
6
|
+
|
|
7
|
+
interface Props<O extends OptionLike = Option>
|
|
8
|
+
extends Pick<
|
|
9
|
+
AutocompleteProps<O>,
|
|
10
|
+
| "className"
|
|
11
|
+
| "icon"
|
|
12
|
+
| "color"
|
|
13
|
+
| "placement"
|
|
14
|
+
| "placeholder"
|
|
15
|
+
| "selection"
|
|
16
|
+
| "onChangeSelection"
|
|
17
|
+
| "autocompleteOptionComponent"
|
|
18
|
+
| "options"
|
|
19
|
+
| "selection"
|
|
20
|
+
> {}
|
|
21
|
+
|
|
22
|
+
export default function SimpleAutocomplete<O extends OptionLike = Option>({
|
|
23
|
+
options,
|
|
24
|
+
selection = null,
|
|
25
|
+
onChangeSelection,
|
|
26
|
+
...rest
|
|
27
|
+
}: Props<O>) {
|
|
28
|
+
const onChangeSelectionStable = useEventCallback(onChangeSelection);
|
|
29
|
+
|
|
30
|
+
const [searchValue, setSearchValue] = useState(selection ? getOptionLabel(selection) : "");
|
|
31
|
+
|
|
32
|
+
const searchValueRef = useRef(searchValue);
|
|
33
|
+
searchValueRef.current = searchValue;
|
|
34
|
+
|
|
35
|
+
const inputRef = useRef<HTMLInputElement>(null!);
|
|
36
|
+
|
|
37
|
+
// side effect, update the searchValue <input /> value when
|
|
38
|
+
// selection come from outside
|
|
39
|
+
useLayoutEffect(() => {
|
|
40
|
+
if (selection === null) {
|
|
41
|
+
if (document.activeElement !== inputRef.current) {
|
|
42
|
+
setSearchValue("");
|
|
43
|
+
}
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// we need the fresh value of searchValue but we don't
|
|
48
|
+
// want searchValue as useEffect dependency
|
|
49
|
+
const selectionLabel = getOptionLabel(selection);
|
|
50
|
+
if (searchValueRef.current !== getOptionLabel(selection)) {
|
|
51
|
+
setSearchValue(selectionLabel);
|
|
52
|
+
}
|
|
53
|
+
}, [selection, setSearchValue]);
|
|
54
|
+
|
|
55
|
+
const filteredOptions =
|
|
56
|
+
selection !== null
|
|
57
|
+
? []
|
|
58
|
+
: options.filter((option) => {
|
|
59
|
+
const optionLabel = getOptionLabel(option);
|
|
60
|
+
return optionLabel.toLowerCase().startsWith(searchValue.toLowerCase());
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
return (
|
|
64
|
+
<Autocomplete
|
|
65
|
+
ref={inputRef}
|
|
66
|
+
searchValue={searchValue}
|
|
67
|
+
onChangeSearchValue={setSearchValue}
|
|
68
|
+
selection={selection}
|
|
69
|
+
onChangeSelection={onChangeSelectionStable}
|
|
70
|
+
options={filteredOptions}
|
|
71
|
+
{...rest}
|
|
72
|
+
/>
|
|
73
|
+
);
|
|
74
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export { default as Autocomplete, type AutocompleteProps } from "./Autocomplete";
|
|
2
|
+
export { default as SimpleAutocomplete } from "./SimpleAutocomplete";
|
|
3
|
+
export { default as AutocompleteOption } from "./AutocompleteOption";
|
|
4
|
+
export { default as LazyAutocomplete } from "./LazyAutocomplete";
|
|
5
|
+
export { default as useAutocomplete } from "./useAutocompleteContext";
|
|
6
|
+
export { getOptionValue, getOptionLabel } from "./util";
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { useInteractions } from "@floating-ui/react";
|
|
2
|
+
import { createContext, useContext } from "react";
|
|
3
|
+
import { OptionLike } from "../select";
|
|
4
|
+
|
|
5
|
+
interface AutocompleteContext {
|
|
6
|
+
activeIndex: number | null;
|
|
7
|
+
selection: OptionLike | null;
|
|
8
|
+
getItemProps: ReturnType<typeof useInteractions>["getItemProps"];
|
|
9
|
+
handleSelect: (index: number | null) => void;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export const AutocompleteContext = createContext<AutocompleteContext | null>(null);
|
|
13
|
+
|
|
14
|
+
export default function useAutocomplete() {
|
|
15
|
+
const context = useContext(AutocompleteContext);
|
|
16
|
+
if (context === null) {
|
|
17
|
+
throw new Error("Autocomplete components must be wrapped in <Autocomplete>");
|
|
18
|
+
}
|
|
19
|
+
return context;
|
|
20
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { OptionLike } from "../select";
|
|
2
|
+
|
|
3
|
+
export function getOptionLabel(option: OptionLike) {
|
|
4
|
+
switch (option.type) {
|
|
5
|
+
case "Feature":
|
|
6
|
+
return option.properties.label;
|
|
7
|
+
default:
|
|
8
|
+
return option.label;
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function getOptionValue(option: OptionLike) {
|
|
13
|
+
switch (option.type) {
|
|
14
|
+
case "Feature":
|
|
15
|
+
return option.properties.id;
|
|
16
|
+
default:
|
|
17
|
+
return option.value;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import Badge from "./Badge";
|
|
2
|
+
import { Meta, StoryObj } from "@storybook/react";
|
|
3
|
+
import { action } from "@storybook/addon-actions";
|
|
4
|
+
|
|
5
|
+
const onClickAction = action("onClick");
|
|
6
|
+
const onRemoveAction = action("onRemove");
|
|
7
|
+
|
|
8
|
+
const meta = {
|
|
9
|
+
title: "Components/Badge",
|
|
10
|
+
component: Badge,
|
|
11
|
+
argTypes: {
|
|
12
|
+
color: {
|
|
13
|
+
control: {
|
|
14
|
+
type: "select",
|
|
15
|
+
},
|
|
16
|
+
options: ["yellow", "gray", "red", "orange", "green", "blue"],
|
|
17
|
+
},
|
|
18
|
+
},
|
|
19
|
+
} satisfies Meta<typeof Badge>;
|
|
20
|
+
export default meta;
|
|
21
|
+
|
|
22
|
+
type Story = StoryObj<typeof meta>;
|
|
23
|
+
|
|
24
|
+
export const Playbook: Story = {
|
|
25
|
+
args: {
|
|
26
|
+
tooltip: "More infos...",
|
|
27
|
+
url: "https://lonlat.org",
|
|
28
|
+
color: "yellow",
|
|
29
|
+
children: "Lonlat",
|
|
30
|
+
},
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export const Context = () => {
|
|
34
|
+
return (
|
|
35
|
+
<div className="flex flex-col gap-2">
|
|
36
|
+
<Badge color="yellow">Primary</Badge>
|
|
37
|
+
<Badge color="orange">Warning</Badge>
|
|
38
|
+
<Badge color="green">Success</Badge>
|
|
39
|
+
<Badge color="red">Danger</Badge>
|
|
40
|
+
<Badge color="blue">Info</Badge>
|
|
41
|
+
<Badge color="gray">Weak</Badge>
|
|
42
|
+
<Badge color="yellow" onClick={onClickAction}>
|
|
43
|
+
Primary clickable
|
|
44
|
+
</Badge>
|
|
45
|
+
<Badge color="orange" onClick={onClickAction}>
|
|
46
|
+
Warning clickable
|
|
47
|
+
</Badge>
|
|
48
|
+
<Badge color="green" onClick={onClickAction}>
|
|
49
|
+
Success clickable
|
|
50
|
+
</Badge>
|
|
51
|
+
<Badge color="red" onClick={onClickAction}>
|
|
52
|
+
Danger clickable
|
|
53
|
+
</Badge>
|
|
54
|
+
<Badge color="blue" onClick={onClickAction}>
|
|
55
|
+
Info clickable
|
|
56
|
+
</Badge>
|
|
57
|
+
<Badge color="gray" onClick={onClickAction}>
|
|
58
|
+
Weak clickable
|
|
59
|
+
</Badge>
|
|
60
|
+
<Badge color="yellow" onRemove={onRemoveAction}>
|
|
61
|
+
Primary removable
|
|
62
|
+
</Badge>
|
|
63
|
+
<Badge color="yellow" onClick={onClickAction} onRemove={onRemoveAction}>
|
|
64
|
+
Primary clickable removable
|
|
65
|
+
</Badge>
|
|
66
|
+
|
|
67
|
+
<Badge color="yellow" tooltip="More infos...">
|
|
68
|
+
With tooltip !
|
|
69
|
+
</Badge>
|
|
70
|
+
</div>
|
|
71
|
+
);
|
|
72
|
+
};
|