Skip to content

Commit 93e031e

Browse files
author
Morgan Merzouk
committed
RichSelect n'utilise plus radix (qui causait des rerenders en boucle)
1 parent 60c20ca commit 93e031e

2 files changed

Lines changed: 457 additions & 185 deletions

File tree

src/components/ui/RichSelect.tsx

Lines changed: 204 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
1-
import * as Select from '@radix-ui/react-select';
21
import Image from 'next/image';
3-
import { useId } from 'react';
2+
import { type KeyboardEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react';
43

54
import FieldWrapper from '@/components/form/dsfr/FieldWrapper';
65
import { trackPostHogEvent } from '@/modules/analytics/client';
@@ -48,55 +47,212 @@ export default function RichSelect<T extends string, Event extends PostHogEvent
4847
postHogEventKey,
4948
postHogEventProps,
5049
}: RichSelectProps<T, Event>) {
51-
const id = useId();
50+
const containerRef = useRef<HTMLDivElement>(null);
51+
const [isOpen, setIsOpen] = useState(false);
52+
const [activeOptionIndex, setActiveOptionIndex] = useState(-1);
5253

53-
const radixValue = value ?? '';
54+
const selectedOption = useMemo(() => options.find((option) => option.value === value), [options, value]);
55+
const selectedOptionIndex = useMemo(() => options.findIndex((option) => option.value === value), [options, value]);
56+
const activeOption = options[activeOptionIndex];
57+
58+
const openSelect = useCallback(() => {
59+
if (disabled) {
60+
return;
61+
}
62+
63+
setActiveOptionIndex(getInitialActiveOptionIndex(selectedOptionIndex, options.length));
64+
setIsOpen(true);
65+
}, [disabled, options.length, selectedOptionIndex]);
66+
67+
const closeSelect = useCallback(() => {
68+
setIsOpen(false);
69+
}, []);
70+
71+
const selectOption = useCallback(
72+
(nextValue: T) => {
73+
if (nextValue !== value) {
74+
onChange(nextValue);
75+
trackPostHogEvent(postHogEventKey, getPostHogEventProps(postHogEventProps, nextValue));
76+
}
77+
78+
closeSelect();
79+
},
80+
[closeSelect, onChange, postHogEventKey, postHogEventProps, value]
81+
);
82+
83+
const moveActiveOption = useCallback(
84+
(offset: number) => {
85+
setActiveOptionIndex((currentActiveOptionIndex) => getNextActiveOptionIndex(currentActiveOptionIndex, offset, options.length));
86+
},
87+
[options.length]
88+
);
89+
90+
const handleTriggerClick = useCallback(() => {
91+
if (isOpen) {
92+
closeSelect();
93+
return;
94+
}
95+
96+
openSelect();
97+
}, [closeSelect, isOpen, openSelect]);
98+
99+
const handleTriggerKeyDown = useCallback(
100+
(event: KeyboardEvent<HTMLButtonElement>) => {
101+
if (disabled) {
102+
return;
103+
}
104+
105+
if (event.key === 'ArrowDown') {
106+
event.preventDefault();
107+
if (isOpen) {
108+
moveActiveOption(1);
109+
return;
110+
}
111+
112+
openSelect();
113+
return;
114+
}
115+
116+
if (event.key === 'ArrowUp') {
117+
event.preventDefault();
118+
if (isOpen) {
119+
moveActiveOption(-1);
120+
return;
121+
}
122+
123+
openSelect();
124+
return;
125+
}
126+
127+
if (event.key === 'Home') {
128+
event.preventDefault();
129+
setActiveOptionIndex(getInitialActiveOptionIndex(-1, options.length));
130+
setIsOpen(true);
131+
return;
132+
}
133+
134+
if (event.key === 'End') {
135+
event.preventDefault();
136+
setActiveOptionIndex(options.length - 1);
137+
setIsOpen(true);
138+
return;
139+
}
140+
141+
if (event.key === 'Escape') {
142+
event.preventDefault();
143+
closeSelect();
144+
return;
145+
}
146+
147+
if (event.key === 'Enter' || event.key === ' ') {
148+
event.preventDefault();
149+
150+
if (!isOpen) {
151+
openSelect();
152+
return;
153+
}
154+
155+
if (activeOption) {
156+
selectOption(activeOption.value);
157+
}
158+
}
159+
},
160+
[activeOption, closeSelect, disabled, isOpen, moveActiveOption, openSelect, options.length, selectOption]
161+
);
162+
163+
useEffect(() => {
164+
if (!isOpen) {
165+
return;
166+
}
167+
168+
const handleDocumentPointerDown = (event: PointerEvent) => {
169+
if (event.target instanceof Node && !containerRef.current?.contains(event.target)) {
170+
closeSelect();
171+
}
172+
};
173+
174+
document.addEventListener('pointerdown', handleDocumentPointerDown);
175+
176+
return () => {
177+
document.removeEventListener('pointerdown', handleDocumentPointerDown);
178+
};
179+
}, [closeSelect, isOpen]);
54180

55181
return (
56-
<FieldWrapper fieldId={id} label={label || ''} className={className}>
57-
<Select.Root
58-
value={radixValue}
59-
disabled={disabled}
60-
onValueChange={(v) => {
61-
if (v) {
62-
const nextValue = v as T;
63-
onChange(nextValue);
64-
trackPostHogEvent(postHogEventKey, getPostHogEventProps(postHogEventProps, nextValue));
65-
}
66-
}}
67-
>
68-
<Select.Trigger
69-
className={cx(
70-
'fr-select w-full data-disabled:cursor-not-allowed data-disabled:bg-gray-100 data-disabled:text-gray-500',
71-
className
182+
<div ref={containerRef}>
183+
<FieldWrapper label={label || ''} className={className}>
184+
<div className="relative">
185+
<button
186+
type="button"
187+
className="fr-select w-full cursor-pointer text-left disabled:cursor-not-allowed disabled:bg-gray-100 disabled:text-gray-500"
188+
aria-expanded={isOpen}
189+
aria-haspopup="listbox"
190+
disabled={disabled}
191+
role="combobox"
192+
onClick={handleTriggerClick}
193+
onKeyDown={handleTriggerKeyDown}
194+
>
195+
<span className="block truncate overflow-hidden whitespace-nowrap text-left">{selectedOption?.label ?? placeholder}</span>
196+
</button>
197+
198+
{isOpen && (
199+
<RichSelectOptionList activeOptionIndex={activeOptionIndex} onSelect={selectOption} options={options} selectedValue={value} />
72200
)}
73-
>
74-
<span className="block truncate whitespace-nowrap overflow-hidden text-left">
75-
<Select.Value placeholder={<span className="block truncate whitespace-nowrap overflow-hidden">{placeholder}</span>} />
76-
</span>
77-
</Select.Trigger>
78-
<Select.Portal>
79-
<Select.Content className="bg-white shadow-lg" position="popper">
80-
<Select.Viewport>
81-
{options.map((option) => (
82-
<Select.Item
83-
key={option.value}
84-
value={option.value}
85-
className={cx('cursor-pointer select-none px-4 py-3 outline-none', 'data-highlighted:bg-blue-50')}
86-
>
87-
<Select.ItemText>
88-
<div className="flex">
89-
{option.icone && <Image src={option.icone} alt="icone" width="16" height="16" className="mr-1" />}
90-
<span>{option.label}</span>
91-
</div>
92-
</Select.ItemText>
93-
{option.description && <div className="text-xs text-slate-500">{option.description}</div>}
94-
</Select.Item>
95-
))}
96-
</Select.Viewport>
97-
</Select.Content>
98-
</Select.Portal>
99-
</Select.Root>
100-
</FieldWrapper>
201+
</div>
202+
</FieldWrapper>
203+
</div>
204+
);
205+
}
206+
207+
type RichSelectOptionListProps<T extends string> = {
208+
activeOptionIndex: number;
209+
onSelect: (value: T) => void;
210+
options: RichSelectOption<T>[];
211+
selectedValue?: T;
212+
};
213+
214+
function RichSelectOptionList<T extends string>({ activeOptionIndex, onSelect, options, selectedValue }: RichSelectOptionListProps<T>) {
215+
return (
216+
<ul
217+
className="absolute top-full left-0 z-50 mt-1 max-h-64 min-w-full w-max overflow-y-auto border border-gray-200 bg-white p-0 shadow-lg"
218+
role="listbox"
219+
>
220+
{options.map((option, optionIndex) => {
221+
const isActive = optionIndex === activeOptionIndex;
222+
const isSelected = option.value === selectedValue;
223+
224+
return (
225+
<li
226+
key={option.value}
227+
className={cx('cursor-pointer select-none px-4 py-3 whitespace-nowrap outline-none', isActive && 'bg-blue-50')}
228+
aria-selected={isSelected}
229+
role="option"
230+
tabIndex={-1}
231+
onClick={() => onSelect(option.value)}
232+
onMouseDown={(event) => event.preventDefault()}
233+
>
234+
<span className="flex">
235+
{option.icone && <Image src={option.icone} alt="" width="16" height="16" className="mr-1" />}
236+
<span>{option.label}</span>
237+
</span>
238+
{option.description && <span className="block whitespace-nowrap text-xs text-slate-500">{option.description}</span>}
239+
</li>
240+
);
241+
})}
242+
</ul>
101243
);
102244
}
245+
246+
function getInitialActiveOptionIndex(selectedOptionIndex: number, optionsCount: number) {
247+
return selectedOptionIndex >= 0 ? selectedOptionIndex : Math.min(optionsCount - 1, 0);
248+
}
249+
250+
function getNextActiveOptionIndex(currentActiveOptionIndex: number, offset: number, optionsCount: number) {
251+
if (optionsCount === 0) {
252+
return -1;
253+
}
254+
255+
const normalizedActiveOptionIndex = currentActiveOptionIndex >= 0 ? currentActiveOptionIndex : 0;
256+
257+
return (normalizedActiveOptionIndex + offset + optionsCount) % optionsCount;
258+
}

0 commit comments

Comments
 (0)