jbpenrath
2025-01-07 23:28:47 +01:00
committed by Jean-Baptiste PENRATH
parent 0f6a8dfa72
commit 56d9ed88f0
27 changed files with 1497 additions and 1546 deletions

View File

@@ -0,0 +1,5 @@
---
"@openfun/cunningham-react": major
---
Upgrade to React 19

View File

@@ -2,80 +2,76 @@ import React, {
AnchorHTMLAttributes, AnchorHTMLAttributes,
ButtonHTMLAttributes, ButtonHTMLAttributes,
createElement, createElement,
forwardRef,
ReactNode, ReactNode,
RefAttributes,
} from "react"; } from "react";
type DomProps = ButtonHTMLAttributes<HTMLButtonElement> & type DomProps = ButtonHTMLAttributes<HTMLButtonElement> &
AnchorHTMLAttributes<HTMLAnchorElement>; AnchorHTMLAttributes<HTMLAnchorElement>;
export type ButtonProps = Omit<DomProps, "color"> & {
size?: "medium" | "small" | "nano";
color?:
| "primary"
| "primary-text"
| "secondary"
| "tertiary"
| "tertiary-text"
| "danger";
icon?: ReactNode;
iconPosition?: "left" | "right";
active?: boolean;
fullWidth?: boolean;
};
export type ButtonElement = HTMLButtonElement & HTMLAnchorElement; export type ButtonElement = HTMLButtonElement & HTMLAnchorElement;
export type ButtonProps = Omit<DomProps, "color"> &
RefAttributes<ButtonElement> & {
size?: "medium" | "small" | "nano";
color?:
| "primary"
| "primary-text"
| "secondary"
| "tertiary"
| "tertiary-text"
| "danger";
icon?: ReactNode;
iconPosition?: "left" | "right";
active?: boolean;
fullWidth?: boolean;
};
export const Button = forwardRef<ButtonElement, ButtonProps>( export const Button = ({
( children,
color = "primary",
size = "medium",
iconPosition = "left",
icon,
active,
className,
fullWidth,
ref,
...props
}: ButtonProps) => {
const classes = [
"c__button",
"c__button--" + color,
"c__button--" + size,
className,
];
if (icon && children) {
classes.push("c__button--with-icon--" + iconPosition);
}
if (icon && !children) {
classes.push("c__button--icon-only");
}
if (active) {
classes.push("c__button--active");
}
if (fullWidth) {
classes.push("c__button--full-width");
}
if (["primary-text", "tertiary-text"].includes(color)) {
classes.push("c__button--text");
}
const iconElement = <span className="c__button__icon">{icon}</span>;
const tagName = props.href ? "a" : "button";
return createElement(
tagName,
{ {
children, className: classes.join(" "),
color = "primary", ...props,
size = "medium", ref,
iconPosition = "left",
icon,
active,
className,
fullWidth,
...props
}, },
ref, <>
) => { {!!icon && iconPosition === "left" && iconElement}
const classes = [ {children}
"c__button", {!!icon && iconPosition === "right" && iconElement}
"c__button--" + color, </>,
"c__button--" + size, );
className, };
];
if (icon && children) {
classes.push("c__button--with-icon--" + iconPosition);
}
if (icon && !children) {
classes.push("c__button--icon-only");
}
if (active) {
classes.push("c__button--active");
}
if (fullWidth) {
classes.push("c__button--full-width");
}
if (["primary-text", "tertiary-text"].includes(color)) {
classes.push("c__button--text");
}
const iconElement = <span className="c__button__icon">{icon}</span>;
const tagName = props.href ? "a" : "button";
return createElement(
tagName,
{
className: classes.join(" "),
...props,
ref,
},
<>
{!!icon && iconPosition === "left" && iconElement}
{children}
{!!icon && iconPosition === "right" && iconElement}
</>,
);
},
);

View File

@@ -1,11 +1,11 @@
import React, { import React, {
InputHTMLAttributes, InputHTMLAttributes,
PropsWithChildren, PropsWithChildren,
forwardRef,
useEffect, useEffect,
useRef, useRef,
useState, useState,
ReactNode, ReactNode,
RefAttributes,
} from "react"; } from "react";
import classNames from "classnames"; import classNames from "classnames";
import { Field, FieldProps } from ":/components/Forms/Field"; import { Field, FieldProps } from ":/components/Forms/Field";
@@ -16,72 +16,75 @@ export type CheckboxOnlyProps = {
}; };
export type CheckboxProps = InputHTMLAttributes<HTMLInputElement> & export type CheckboxProps = InputHTMLAttributes<HTMLInputElement> &
RefAttributes<HTMLInputElement> &
FieldProps & FieldProps &
CheckboxOnlyProps; CheckboxOnlyProps;
export const Checkbox = forwardRef<HTMLInputElement, CheckboxProps>( export const Checkbox = ({
( indeterminate,
{ indeterminate, className = "", checked, label, ...props }: CheckboxProps, className = "",
ref, checked,
) => { label,
const inputRef = useRef<HTMLInputElement>(); ref,
const [value, setValue] = useState<boolean>(!!checked); ...props
}: CheckboxProps) => {
const inputRef = useRef<HTMLInputElement>(null);
const [value, setValue] = useState<boolean>(!!checked);
useEffect(() => { useEffect(() => {
setValue(!!checked); setValue(!!checked);
}, [checked]); }, [checked]);
useEffect(() => { useEffect(() => {
if (inputRef.current) { if (inputRef.current) {
inputRef.current.indeterminate = !!indeterminate; inputRef.current.indeterminate = !!indeterminate;
} }
}, [indeterminate]); }, [indeterminate]);
const { const {
compact, compact,
fullWidth, fullWidth,
rightText, rightText,
state, state,
text, text,
textItems, textItems,
...inputProps ...inputProps
} = props; } = props;
return ( return (
<label <label
className={classNames("c__checkbox", className, { className={classNames("c__checkbox", className, {
"c__checkbox--disabled": props.disabled, "c__checkbox--disabled": props.disabled,
"c__checkbox--full-width": props.fullWidth, "c__checkbox--full-width": props.fullWidth,
})} })}
> >
<Field compact={true} {...props}> <Field compact={true} {...props}>
<div className="c__checkbox__container"> <div className="c__checkbox__container">
<div className="c__checkbox__wrapper"> <div className="c__checkbox__wrapper">
<input <input
type="checkbox" type="checkbox"
{...inputProps} {...inputProps}
onChange={(e) => { onChange={(e) => {
setValue(e.target.checked); setValue(e.target.checked);
props.onChange?.(e); props.onChange?.(e);
}} }}
checked={value} checked={value}
ref={(checkboxRef) => { ref={(checkboxRef) => {
if (typeof ref === "function") { if (typeof ref === "function") {
ref(checkboxRef); ref(checkboxRef);
} }
inputRef.current = checkboxRef || undefined; inputRef.current = checkboxRef || null;
}} }}
/> />
<Indeterminate /> <Indeterminate />
<Checkmark /> <Checkmark />
</div>
{label && <div className="c__checkbox__label">{label}</div>}
</div> </div>
</Field> {label && <div className="c__checkbox__label">{label}</div>}
</label> </div>
); </Field>
}, </label>
); );
};
export const CheckboxGroup = ({ export const CheckboxGroup = ({
children, children,

View File

@@ -1,4 +1,4 @@
import React, { forwardRef, Ref, useMemo, useRef, useState } from "react"; import React, { RefAttributes, useMemo, useRef, useState } from "react";
import { import {
CalendarDate, CalendarDate,
createCalendar, createCalendar,
@@ -83,280 +83,276 @@ const DropdownValues = ({ options, downShift }: DropdownValuesProps) => (
</div> </div>
); );
interface CalendarAuxProps extends CalendarAria { type CalendarAuxProps = CalendarAria &
minYear?: number; RefAttributes<HTMLDivElement> & {
maxYear?: number; minYear?: number;
state: RangeCalendarState | CalendarState; maxYear?: number;
} state: RangeCalendarState | CalendarState;
};
const CalendarAux = forwardRef( const CalendarAux = ({
( state,
{ minYear = 1900, // in gregorian calendar.
state, maxYear = 2050, // in gregorian calendar.
minYear = 1900, // in gregorian calendar. prevButtonProps,
maxYear = 2050, // in gregorian calendar. nextButtonProps,
prevButtonProps, calendarProps,
nextButtonProps, ref,
calendarProps, }: CalendarAuxProps) => {
}: CalendarAuxProps, const { t } = useCunningham();
ref: Ref<HTMLDivElement>,
) => {
const { t } = useCunningham();
const useTimeZoneFormatter = (formatOptions: DateFormatterOptions) => { const useTimeZoneFormatter = (formatOptions: DateFormatterOptions) => {
return useDateFormatter({ return useDateFormatter({
...formatOptions, ...formatOptions,
timeZone: state.timeZone, timeZone: state.timeZone,
});
};
const monthItemsFormatter = useTimeZoneFormatter({ month: "long" });
const selectedMonthItemFormatter = useTimeZoneFormatter({ month: "short" });
const yearItemsFormatter = useTimeZoneFormatter({ year: "numeric" });
const [showGrid, setShowGrid] = useState(true);
const monthItems: Array<Option> = useMemo(() => {
// Note that in some calendar systems, such as the Hebrew, the number of months may differ between years.
const numberOfMonths = state.focusedDate.calendar.getMonthsInYear(
state.focusedDate,
);
return range(1, numberOfMonths).map((monthNumber) => {
const date = state.focusedDate.set({ month: monthNumber });
return {
value: monthNumber,
label: monthItemsFormatter.format(date.toDate(state.timeZone)),
disabled:
(!!state.minValue && state.minValue.month > monthNumber) ||
(!!state.maxValue && state.maxValue.month < monthNumber),
};
});
}, [state.maxValue, state.minValue, state.focusedDate.year]);
const yearItems: Array<Option> = useMemo(() => {
const calendarCurrentUser = createCalendar(
new Intl.DateTimeFormat().resolvedOptions().calendar,
);
const minDate = toCalendar(
new CalendarDate(new GregorianCalendar(), minYear, 1, 1),
calendarCurrentUser,
);
const maxDate = toCalendar(
new CalendarDate(new GregorianCalendar(), maxYear, 12, 31),
calendarCurrentUser,
);
return range(minDate.year, maxDate.year).map((yearNumber) => {
const date = state.focusedDate.set({ year: yearNumber });
return {
value: yearNumber,
label: yearItemsFormatter.format(date.toDate(state.timeZone)),
disabled:
(!!state.minValue && state.minValue.year > yearNumber) ||
(!!state.maxValue && state.maxValue.year < yearNumber),
};
});
}, [state.focusedDate, state.timeZone, state.maxValue, state.minValue]);
const useDownshiftSelect = (
key: string,
items: Array<Option>,
): UseSelectReturnValue<Option> => {
return useSelect({
items,
itemToString: optionToString,
onSelectedItemChange: (e: UseSelectStateChange<Option>) => {
const updatedFocusedDate = state.focusedDate.set({
[key]: e?.selectedItem?.value,
});
/**
* We need to hide the grid before updated the focused date because if the mouse hovers a cell it will
* automatically internally call the focusCell method which sets the focused date to the hovered cell date.
*
* (Current year = 2024) The steps are:
* 1 - Select year 2050 in the dropdown.
* 2 - Hide the dropdown
* 3 - state.setFocusedDate(2050)
* 3 - Mouse hovers a cell in the grid before the state takes into account the new focused date ( 2050 ).
* 4 - focusCell is called with the current year (2024) overriding the previous call with year=2050
*
* The resulting bug will be the year 2024 being selected in the grid instead of 2050.
*
* So instead why first hide the grid, wait for the state to be updated, set the focused date to 2050, and
* then show the grid again. This way we will prevent the mouse from hovering a cell before the state is updated.
*/
setShowGrid(false);
setTimeout(() => {
state.setFocusedDate(updatedFocusedDate);
setTimeout(() => {
setShowGrid(true);
});
});
},
});
};
const downshiftMonth = useDownshiftSelect("month", monthItems);
const downshiftYear = useDownshiftSelect("year", yearItems);
// isDisabled, onPress and onFocusChange props don't exist on the <Button /> component.
// remove them to avoid any warning.
const {
isDisabled: isPrevButtonDisabled,
onPress: onPressPrev,
onFocusChange: onFocusChangePrev,
...prevButtonOtherProps
} = prevButtonProps;
const {
isDisabled: isNextButtonDisabled,
onPress: onPressNext,
onFocusChange: onFocusChangeNext,
...nextButtonOtherProps
} = nextButtonProps;
const getToggleButtonProps = (
key: string,
items: Array<Option>,
downshift: UseSelectReturnValue<Option>,
) => ({
...downshift.getToggleButtonProps(),
onClick: () => {
const selectedItem = items.find(
(item) => item.value === state.focusedDate[key as keyof CalendarDate],
);
if (selectedItem) {
downshift.selectItem(selectedItem);
}
downshift.toggleMenu();
},
"aria-label": t(
`components.forms.date_picker.${key}_select_button_aria_label`,
),
}); });
};
return ( const monthItemsFormatter = useTimeZoneFormatter({ month: "long" });
<div className="c__calendar"> const selectedMonthItemFormatter = useTimeZoneFormatter({ month: "short" });
<div const yearItemsFormatter = useTimeZoneFormatter({ year: "numeric" });
ref={ref} const [showGrid, setShowGrid] = useState(true);
{...calendarProps}
// We need to remove the id from the calendar when the dropdowns are open to avoid having the following bug: const monthItems: Array<Option> = useMemo(() => {
// 1 - Open the calendar // Note that in some calendar systems, such as the Hebrew, the number of months may differ between years.
// 2 - Select a start date const numberOfMonths = state.focusedDate.calendar.getMonthsInYear(
// 3 - Click on the dropdown to select another year state.focusedDate,
// 4 - BUG: The calendar closes abruptly.
//
// This way caused by this internal call from Spectrum: https://github.com/adobe/react-spectrum/blob/cdb2fe21213d9e8b03d782d82bda07742ab3afbd/packages/%40react-aria/calendar/src/useRangeCalendar.ts#L59
//
// So instead we decided to remove the id of the calendar when the dropdowns are open and add it back when
// the dropdowns are closed in order to make this condition fail: https://github.com/adobe/react-spectrum/blob/cdb2fe21213d9e8b03d782d82bda07742ab3afbd/packages/%40react-aria/calendar/src/useRangeCalendar.ts#L55
// This way the `body` variable will never be found.
id={
!downshiftMonth.isOpen && !downshiftYear.isOpen
? calendarProps.id
: ""
}
className={classNames("c__calendar__wrapper", {
"c__calendar__wrapper--opened":
!downshiftMonth.isOpen && !downshiftYear.isOpen,
})}
>
<div className="c__calendar__wrapper__header">
<div className="c__calendar__wrapper__header__actions">
<Button
color="tertiary-text"
size="small"
icon={<span className="material-icons">navigate_before</span>}
{...{
...prevButtonOtherProps,
"aria-label": t(
"components.forms.date_picker.previous_month_button_aria_label",
),
}}
disabled={isPrevButtonDisabled}
onClick={() => state.focusPreviousSection()}
type="button"
/>
<Button
className="c__calendar__wrapper__header__actions__dropdown"
color="tertiary-text"
size="small"
iconPosition="right"
icon={<span className="material-icons">arrow_drop_down</span>}
type="button"
{...getToggleButtonProps("month", monthItems, downshiftMonth)}
>
{selectedMonthItemFormatter.format(
state.focusedDate.toDate(state.timeZone),
)}
</Button>
<Button
color="tertiary-text"
size="small"
icon={<span className="material-icons">navigate_next</span>}
type="button"
{...{
...nextButtonOtherProps,
"aria-label": t(
"components.forms.date_picker.next_month_button_aria_label",
),
}}
disabled={isNextButtonDisabled}
onClick={() => state.focusNextSection()}
/>
</div>
<div className="c__calendar__wrapper__header__actions">
<Button
color="tertiary-text"
size="small"
icon={<span className="material-icons">navigate_before</span>}
onClick={() => state.focusPreviousSection(true)}
disabled={
!!state.minValue &&
state.minValue.year >
state.focusedDate.add({ years: -1 }).year
}
aria-label={t(
"components.forms.date_picker.previous_year_button_aria_label",
)}
type="button"
/>
<Button
className="c__calendar__wrapper__header__actions__dropdown"
color="tertiary-text"
size="small"
iconPosition="right"
icon={<span className="material-icons">arrow_drop_down</span>}
type="button"
{...getToggleButtonProps("year", yearItems, downshiftYear)}
>
{yearItemsFormatter.format(
state.focusedDate.toDate(state.timeZone),
)}
</Button>
<Button
color="tertiary-text"
size="small"
icon={<span className="material-icons">navigate_next</span>}
onClick={() => state.focusNextSection(true)}
disabled={
!!state.maxValue &&
state.maxValue.year < state.focusedDate.add({ years: 1 }).year
}
aria-label={t(
"components.forms.date_picker.next_year_button_aria_label",
)}
type="button"
/>
</div>
</div>
{!downshiftMonth.isOpen && !downshiftYear.isOpen && showGrid && (
<CalendarGrid state={state} />
)}
</div>
<DropdownValues options={monthItems} downShift={downshiftMonth} />
<DropdownValues options={yearItems} downShift={downshiftYear} />
</div>
); );
}, return range(1, numberOfMonths).map((monthNumber) => {
); const date = state.focusedDate.set({ month: monthNumber });
return {
value: monthNumber,
label: monthItemsFormatter.format(date.toDate(state.timeZone)),
disabled:
(!!state.minValue && state.minValue.month > monthNumber) ||
(!!state.maxValue && state.maxValue.month < monthNumber),
};
});
}, [state.maxValue, state.minValue, state.focusedDate.year]);
const yearItems: Array<Option> = useMemo(() => {
const calendarCurrentUser = createCalendar(
new Intl.DateTimeFormat().resolvedOptions().calendar,
);
const minDate = toCalendar(
new CalendarDate(new GregorianCalendar(), minYear, 1, 1),
calendarCurrentUser,
);
const maxDate = toCalendar(
new CalendarDate(new GregorianCalendar(), maxYear, 12, 31),
calendarCurrentUser,
);
return range(minDate.year, maxDate.year).map((yearNumber) => {
const date = state.focusedDate.set({ year: yearNumber });
return {
value: yearNumber,
label: yearItemsFormatter.format(date.toDate(state.timeZone)),
disabled:
(!!state.minValue && state.minValue.year > yearNumber) ||
(!!state.maxValue && state.maxValue.year < yearNumber),
};
});
}, [state.focusedDate, state.timeZone, state.maxValue, state.minValue]);
const useDownshiftSelect = (
key: string,
items: Array<Option>,
): UseSelectReturnValue<Option> => {
return useSelect({
items,
itemToString: optionToString,
onSelectedItemChange: (e: UseSelectStateChange<Option>) => {
const updatedFocusedDate = state.focusedDate.set({
[key]: e?.selectedItem?.value,
});
/**
* We need to hide the grid before updated the focused date because if the mouse hovers a cell it will
* automatically internally call the focusCell method which sets the focused date to the hovered cell date.
*
* (Current year = 2024) The steps are:
* 1 - Select year 2050 in the dropdown.
* 2 - Hide the dropdown
* 3 - state.setFocusedDate(2050)
* 3 - Mouse hovers a cell in the grid before the state takes into account the new focused date ( 2050 ).
* 4 - focusCell is called with the current year (2024) overriding the previous call with year=2050
*
* The resulting bug will be the year 2024 being selected in the grid instead of 2050.
*
* So instead why first hide the grid, wait for the state to be updated, set the focused date to 2050, and
* then show the grid again. This way we will prevent the mouse from hovering a cell before the state is updated.
*/
setShowGrid(false);
setTimeout(() => {
state.setFocusedDate(updatedFocusedDate);
setTimeout(() => {
setShowGrid(true);
});
});
},
});
};
const downshiftMonth = useDownshiftSelect("month", monthItems);
const downshiftYear = useDownshiftSelect("year", yearItems);
// isDisabled, onPress and onFocusChange props don't exist on the <Button /> component.
// remove them to avoid any warning.
const {
isDisabled: isPrevButtonDisabled,
onPress: onPressPrev,
onFocusChange: onFocusChangePrev,
...prevButtonOtherProps
} = prevButtonProps;
const {
isDisabled: isNextButtonDisabled,
onPress: onPressNext,
onFocusChange: onFocusChangeNext,
...nextButtonOtherProps
} = nextButtonProps;
const getToggleButtonProps = (
key: string,
items: Array<Option>,
downshift: UseSelectReturnValue<Option>,
) => ({
...downshift.getToggleButtonProps(),
onClick: () => {
const selectedItem = items.find(
(item) => item.value === state.focusedDate[key as keyof CalendarDate],
);
if (selectedItem) {
downshift.selectItem(selectedItem);
}
downshift.toggleMenu();
},
"aria-label": t(
`components.forms.date_picker.${key}_select_button_aria_label`,
),
});
return (
<div className="c__calendar">
<div
ref={ref}
{...calendarProps}
// We need to remove the id from the calendar when the dropdowns are open to avoid having the following bug:
// 1 - Open the calendar
// 2 - Select a start date
// 3 - Click on the dropdown to select another year
// 4 - BUG: The calendar closes abruptly.
//
// This way caused by this internal call from Spectrum: https://github.com/adobe/react-spectrum/blob/cdb2fe21213d9e8b03d782d82bda07742ab3afbd/packages/%40react-aria/calendar/src/useRangeCalendar.ts#L59
//
// So instead we decided to remove the id of the calendar when the dropdowns are open and add it back when
// the dropdowns are closed in order to make this condition fail: https://github.com/adobe/react-spectrum/blob/cdb2fe21213d9e8b03d782d82bda07742ab3afbd/packages/%40react-aria/calendar/src/useRangeCalendar.ts#L55
// This way the `body` variable will never be found.
id={
!downshiftMonth.isOpen && !downshiftYear.isOpen
? calendarProps.id
: ""
}
className={classNames("c__calendar__wrapper", {
"c__calendar__wrapper--opened":
!downshiftMonth.isOpen && !downshiftYear.isOpen,
})}
>
<div className="c__calendar__wrapper__header">
<div className="c__calendar__wrapper__header__actions">
<Button
color="tertiary-text"
size="small"
icon={<span className="material-icons">navigate_before</span>}
{...{
...prevButtonOtherProps,
"aria-label": t(
"components.forms.date_picker.previous_month_button_aria_label",
),
}}
disabled={isPrevButtonDisabled}
onClick={() => state.focusPreviousSection()}
type="button"
/>
<Button
className="c__calendar__wrapper__header__actions__dropdown"
color="tertiary-text"
size="small"
iconPosition="right"
icon={<span className="material-icons">arrow_drop_down</span>}
type="button"
{...getToggleButtonProps("month", monthItems, downshiftMonth)}
>
{selectedMonthItemFormatter.format(
state.focusedDate.toDate(state.timeZone),
)}
</Button>
<Button
color="tertiary-text"
size="small"
icon={<span className="material-icons">navigate_next</span>}
type="button"
{...{
...nextButtonOtherProps,
"aria-label": t(
"components.forms.date_picker.next_month_button_aria_label",
),
}}
disabled={isNextButtonDisabled}
onClick={() => state.focusNextSection()}
/>
</div>
<div className="c__calendar__wrapper__header__actions">
<Button
color="tertiary-text"
size="small"
icon={<span className="material-icons">navigate_before</span>}
onClick={() => state.focusPreviousSection(true)}
disabled={
!!state.minValue &&
state.minValue.year > state.focusedDate.add({ years: -1 }).year
}
aria-label={t(
"components.forms.date_picker.previous_year_button_aria_label",
)}
type="button"
/>
<Button
className="c__calendar__wrapper__header__actions__dropdown"
color="tertiary-text"
size="small"
iconPosition="right"
icon={<span className="material-icons">arrow_drop_down</span>}
type="button"
{...getToggleButtonProps("year", yearItems, downshiftYear)}
>
{yearItemsFormatter.format(
state.focusedDate.toDate(state.timeZone),
)}
</Button>
<Button
color="tertiary-text"
size="small"
icon={<span className="material-icons">navigate_next</span>}
onClick={() => state.focusNextSection(true)}
disabled={
!!state.maxValue &&
state.maxValue.year < state.focusedDate.add({ years: 1 }).year
}
aria-label={t(
"components.forms.date_picker.next_year_button_aria_label",
)}
type="button"
/>
</div>
</div>
{!downshiftMonth.isOpen && !downshiftYear.isOpen && showGrid && (
<CalendarGrid state={state} />
)}
</div>
<DropdownValues options={monthItems} downShift={downshiftMonth} />
<DropdownValues options={yearItems} downShift={downshiftYear} />
</div>
);
};
export const Calendar = (props: CalendarProps<DateValue>) => { export const Calendar = (props: CalendarProps<DateValue>) => {
const { locale } = useLocale(); const { locale } = useLocale();

View File

@@ -1,9 +1,8 @@
import React, { import React, {
forwardRef,
PropsWithChildren, PropsWithChildren,
Ref,
useMemo, useMemo,
useRef, useRef,
RefAttributes,
} from "react"; } from "react";
import { import {
DateRangePickerState, DateRangePickerState,
@@ -36,7 +35,8 @@ export type DatePickerAuxSubProps = FieldProps & {
}; };
export type DatePickerAuxProps = PropsWithChildren & export type DatePickerAuxProps = PropsWithChildren &
DatePickerAuxSubProps & { DatePickerAuxSubProps &
RefAttributes<HTMLDivElement> & {
pickerState: DateRangePickerState | DatePickerState; pickerState: DateRangePickerState | DatePickerState;
pickerProps: Pick< pickerProps: Pick<
DateRangePickerAria | DatePickerAria, DateRangePickerAria | DatePickerAria,
@@ -54,154 +54,147 @@ export type DatePickerAuxProps = PropsWithChildren &
* This component is used by date and date range picker components. * This component is used by date and date range picker components.
* It contains the common logic between the two. * It contains the common logic between the two.
*/ */
const DatePickerAux = forwardRef( const DatePickerAux = ({
( className,
{ pickerState,
className, pickerProps,
pickerState, onClear,
pickerProps, isFocused,
onClear, labelAsPlaceholder,
isFocused, calendar,
labelAsPlaceholder, children,
calendar, name,
children, locale,
name, disabled = false,
locale, optionalClassName,
disabled = false, isRange,
optionalClassName, ref,
isRange, ...props
...props }: DatePickerAuxProps) => {
}: DatePickerAuxProps, const { t, currentLocale } = useCunningham();
ref: Ref<HTMLDivElement>, const pickerRef = useRef<HTMLDivElement>(null);
) => {
const { t, currentLocale } = useCunningham();
const pickerRef = useRef<HTMLDivElement>(null);
const isDateInvalid = useMemo( const isDateInvalid = useMemo(
() => () => pickerState.validationState === "invalid" || props.state === "error",
pickerState.validationState === "invalid" || props.state === "error", [pickerState.validationState, props.state],
[pickerState.validationState, props.state], );
);
return ( return (
<I18nProvider locale={locale || currentLocale}> <I18nProvider locale={locale || currentLocale}>
<Field <Field
{...props} {...props}
className={classNames(className, { className={classNames(className, {
"c__date-picker__range__container": isRange, "c__date-picker__range__container": isRange,
})}
>
<div
ref={pickerRef}
className={classNames(["c__date-picker", optionalClassName], {
"c__date-picker--disabled": disabled,
"c__date-picker--invalid": isDateInvalid,
"c__date-picker--success": props.state === "success",
"c__date-picker--focused":
!isDateInvalid && !disabled && (pickerState.isOpen || isFocused),
})} })}
> >
<div <div
ref={pickerRef} className={classNames("c__date-picker__wrapper", {
className={classNames(["c__date-picker", optionalClassName], { "c__date-picker__wrapper--clickable": labelAsPlaceholder,
"c__date-picker--disabled": disabled,
"c__date-picker--invalid": isDateInvalid,
"c__date-picker--success": props.state === "success",
"c__date-picker--focused":
!isDateInvalid &&
!disabled &&
(pickerState.isOpen || isFocused),
})} })}
ref={ref}
{...pickerProps.groupProps}
role="button"
tabIndex={0}
onClick={() => !pickerState.isOpen && pickerState.open()}
> >
<div {"dateRange" in pickerState ? (
className={classNames("c__date-picker__wrapper", { <>
"c__date-picker__wrapper--clickable": labelAsPlaceholder,
})}
ref={ref}
{...pickerProps.groupProps}
role="button"
tabIndex={0}
onClick={() => !pickerState.isOpen && pickerState.open()}
>
{"dateRange" in pickerState ? (
<>
<input
type="hidden"
name={name && `${name}_start`}
value={convertDateValueToString(
pickerState.value?.start ?? null,
props.timezone,
)}
/>
<input
type="hidden"
name={name && `${name}_end`}
value={convertDateValueToString(
pickerState.value?.end ?? null,
props.timezone,
)}
/>
</>
) : (
<input <input
type="hidden" type="hidden"
name={name} name={name && `${name}_start`}
value={convertDateValueToString( value={convertDateValueToString(
pickerState.value, pickerState.value?.start ?? null,
props.timezone, props.timezone,
)} )}
/> />
)} <input
<div className="c__date-picker__wrapper__icon"> type="hidden"
<Button name={name && `${name}_end`}
type="button" value={convertDateValueToString(
onKeyDown={(e) => { pickerState.value?.end ?? null,
if (e.key === "Enter") { props.timezone,
pickerState.toggle();
}
}}
onClick={pickerState.toggle}
aria-label={t(
pickerState.isOpen
? "components.forms.date_picker.toggle_button_aria_label_close"
: "components.forms.date_picker.toggle_button_aria_label_open",
)} )}
color="tertiary-text"
size="small"
className="c__date-picker__wrapper__toggle"
icon={
<span className="material-icons icon">calendar_today</span>
}
disabled={disabled}
/> />
</div> </>
{children} ) : (
<input
type="hidden"
name={name}
value={convertDateValueToString(
pickerState.value,
props.timezone,
)}
/>
)}
<div className="c__date-picker__wrapper__icon">
<Button <Button
className={classNames("c__date-picker__inner__action", { type="button"
"c__date-picker__inner__action--empty": !pickerState.value,
"c__date-picker__inner__action--hidden":
labelAsPlaceholder || disabled,
})}
color="tertiary-text"
size="nano"
icon={<span className="material-icons">close</span>}
onClick={onClear}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === "Enter") { if (e.key === "Enter") {
onClear(); pickerState.toggle();
} }
}} }}
onClick={pickerState.toggle}
aria-label={t( aria-label={t(
"components.forms.date_picker.clear_button_aria_label", pickerState.isOpen
? "components.forms.date_picker.toggle_button_aria_label_close"
: "components.forms.date_picker.toggle_button_aria_label_open",
)} )}
color="tertiary-text"
size="small"
className="c__date-picker__wrapper__toggle"
icon={
<span className="material-icons icon">calendar_today</span>
}
disabled={disabled} disabled={disabled}
type="button"
/> />
</div> </div>
{pickerState.isOpen && ( {children}
<Popover <Button
parentRef={pickerRef} className={classNames("c__date-picker__inner__action", {
onClickOutside={pickerState.close} "c__date-picker__inner__action--empty": !pickerState.value,
borderless "c__date-picker__inner__action--hidden":
> labelAsPlaceholder || disabled,
{calendar} })}
</Popover> color="tertiary-text"
)} size="nano"
icon={<span className="material-icons">close</span>}
onClick={onClear}
onKeyDown={(e) => {
if (e.key === "Enter") {
onClear();
}
}}
aria-label={t(
"components.forms.date_picker.clear_button_aria_label",
)}
disabled={disabled}
type="button"
/>
</div> </div>
</Field> {pickerState.isOpen && (
</I18nProvider> <Popover
); parentRef={pickerRef}
}, onClickOutside={pickerState.close}
); borderless
>
{calendar}
</Popover>
)}
</div>
</Field>
</I18nProvider>
);
};
export default DatePickerAux; export default DatePickerAux;

View File

@@ -1,6 +1,6 @@
import React, { import React, {
forwardRef,
PropsWithChildren, PropsWithChildren,
RefAttributes,
useEffect, useEffect,
useImperativeHandle, useImperativeHandle,
useRef, useRef,
@@ -15,147 +15,141 @@ import {
FileUploaderRefType, FileUploaderRefType,
} from ":/components/Forms/FileUploader/index"; } from ":/components/Forms/FileUploader/index";
interface DropZoneProps extends FileUploaderProps, PropsWithChildren { type DropZoneProps = FileUploaderProps &
files: File[]; RefAttributes<FileUploaderRefType> &
} PropsWithChildren<{
files: File[];
}>;
export const DropZone = forwardRef<FileUploaderRefType, DropZoneProps>( export const DropZone = ({
( multiple,
{ name,
multiple, state,
name, icon,
state, animateIcon,
icon, successIcon,
animateIcon, uploadingIcon,
successIcon, text,
uploadingIcon, bigText,
text, files,
bigText, onFilesChange,
files, children,
onFilesChange, ref,
children, ...props
...props }: DropZoneProps) => {
}: DropZoneProps, const [dragActive, setDragActive] = useState(false);
ref, const container = useRef<HTMLLabelElement>(null);
) => { const inputRef = useRef<HTMLInputElement>(null);
const [dragActive, setDragActive] = useState(false); const { t } = useCunningham();
const container = useRef<HTMLLabelElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const { t } = useCunningham();
useImperativeHandle(ref, () => ({ useImperativeHandle(ref, () => ({
get input() { get input() {
return inputRef.current; return inputRef.current;
}, },
reset() { reset() {
onFilesChange?.({ target: { value: [] } }); onFilesChange?.({ target: { value: [] } });
}, },
})); }));
useEffect(() => { useEffect(() => {
if (!inputRef.current) { if (!inputRef.current) {
return; return;
} }
replaceInputFilters(inputRef.current, files); replaceInputFilters(inputRef.current, files);
}, [files]); }, [files]);
useEffect(() => { useEffect(() => {
onFilesChange?.({ target: { value: files ?? [] } }); onFilesChange?.({ target: { value: files ?? [] } });
}, [files]); }, [files]);
const renderIcon = () => { const renderIcon = () => {
if (state === "success") { if (state === "success") {
return successIcon ?? <span className="material-icons">done</span>; return successIcon ?? <span className="material-icons">done</span>;
} }
if (state === "uploading") { if (state === "uploading") {
return React.cloneElement(uploadingIcon ?? <Loader size="small" />, { return React.cloneElement(uploadingIcon ?? <Loader size="small" />, {
"aria-label": t("components.forms.file_uploader.uploading"), "aria-label": t("components.forms.file_uploader.uploading"),
}); });
} }
return icon ?? <span className="material-icons">upload</span>; return icon ?? <span className="material-icons">upload</span>;
}; };
const renderCaption = () => {
if (state === "uploading") {
return t("components.forms.file_uploader.uploading");
}
if (bigText) {
return bigText;
}
return (
<>
{t("components.forms.file_uploader.caption")}
<span>{t("components.forms.file_uploader.browse_files")}</span>
</>
);
};
const renderCaption = () => {
if (state === "uploading") {
return t("components.forms.file_uploader.uploading");
}
if (bigText) {
return bigText;
}
return ( return (
<label <>
className={classNames( {t("components.forms.file_uploader.caption")}
"c__file-uploader", <span>{t("components.forms.file_uploader.browse_files")}</span>
"c__file-uploader--" + state, </>
{
"c__file-uploader--active": dragActive,
"c__file-uploader--animate-icon": animateIcon,
},
)}
onDragEnter={() => {
setDragActive(true);
}}
onDragLeave={(e) => {
/**
* This condition is important because onDragLeave is called when the cursor goes over
* a child of the current node, which is not intuitive. So here we need to make sure that
* the relatedTarget is not a child of the current node.
*/
if (!container.current!.contains(e.relatedTarget as Node)) {
setDragActive(false);
}
}}
onDragOver={(e) => {
e.preventDefault();
}}
onDrop={(e) => {
// To prevent a new tab to open.
e.preventDefault();
const newFiles = Array.from(e.dataTransfer.files);
if (inputRef.current) {
inputRef.current.files = e.dataTransfer.files;
onFilesChange?.({ target: { value: [...newFiles] } });
}
setDragActive(false);
}}
ref={container}
>
<div className="c__file-uploader__inner">
<div className="c__file-uploader__inner__icon">{renderIcon()}</div>
{children ?? (
<>
<div className="c__file-uploader__inner__caption">
{renderCaption()}
</div>
{text && (
<div className="c__file-uploader__inner__text">{text}</div>
)}
</>
)}
<input
type="file"
name={name}
ref={inputRef}
onChange={(e) => {
if (e.target.files) {
onFilesChange?.({ target: { value: [...e.target.files] } });
} else {
onFilesChange?.({ target: { value: [] } });
}
}}
multiple={multiple}
{...props}
/>
</div>
</label>
); );
}, };
);
return (
<label
className={classNames("c__file-uploader", "c__file-uploader--" + state, {
"c__file-uploader--active": dragActive,
"c__file-uploader--animate-icon": animateIcon,
})}
onDragEnter={() => {
setDragActive(true);
}}
onDragLeave={(e) => {
/**
* This condition is important because onDragLeave is called when the cursor goes over
* a child of the current node, which is not intuitive. So here we need to make sure that
* the relatedTarget is not a child of the current node.
*/
if (!container.current!.contains(e.relatedTarget as Node)) {
setDragActive(false);
}
}}
onDragOver={(e) => {
e.preventDefault();
}}
onDrop={(e) => {
// To prevent a new tab to open.
e.preventDefault();
const newFiles = Array.from(e.dataTransfer.files);
if (inputRef.current) {
inputRef.current.files = e.dataTransfer.files;
onFilesChange?.({ target: { value: [...newFiles] } });
}
setDragActive(false);
}}
ref={container}
>
<div className="c__file-uploader__inner">
<div className="c__file-uploader__inner__icon">{renderIcon()}</div>
{children ?? (
<>
<div className="c__file-uploader__inner__caption">
{renderCaption()}
</div>
{text && (
<div className="c__file-uploader__inner__text">{text}</div>
)}
</>
)}
<input
type="file"
name={name}
ref={inputRef}
onChange={(e) => {
if (e.target.files) {
onFilesChange?.({ target: { value: [...e.target.files] } });
} else {
onFilesChange?.({ target: { value: [] } });
}
}}
multiple={multiple}
{...props}
/>
</div>
</label>
);
};

View File

@@ -1,16 +1,14 @@
import React, { forwardRef, useEffect, useMemo, useState } from "react"; import React, { useEffect, useMemo, useState } from "react";
import { useCunningham } from ":/components/Provider"; import { useCunningham } from ":/components/Provider";
import { Button } from ":/components/Button"; import { Button } from ":/components/Button";
import { import { FileUploaderProps } from ":/components/Forms/FileUploader/index";
FileUploaderProps,
FileUploaderRefType,
} from ":/components/Forms/FileUploader/index";
import { DropZone } from ":/components/Forms/FileUploader/DropZone"; import { DropZone } from ":/components/Forms/FileUploader/DropZone";
export const FileUploaderMono = forwardRef< export const FileUploaderMono = ({
FileUploaderRefType, fakeDefaultFiles,
FileUploaderProps ref,
>(({ fakeDefaultFiles, ...props }, ref) => { ...props
}: FileUploaderProps) => {
const { t } = useCunningham(); const { t } = useCunningham();
const [file, setFile] = useState<File | undefined>( const [file, setFile] = useState<File | undefined>(
fakeDefaultFiles && fakeDefaultFiles.length > 0 fakeDefaultFiles && fakeDefaultFiles.length > 0
@@ -91,4 +89,4 @@ export const FileUploaderMono = forwardRef<
)} )}
</> </>
); );
}); };

View File

@@ -1,17 +1,16 @@
import React, { forwardRef, useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import { useCunningham } from ":/components/Provider"; import { useCunningham } from ":/components/Provider";
import { formatBytes } from ":/components/Forms/FileUploader/utils"; import { formatBytes } from ":/components/Forms/FileUploader/utils";
import { Button } from ":/components/Button"; import { Button } from ":/components/Button";
import { import { FileUploaderProps } from ":/components/Forms/FileUploader/index";
FileUploaderProps,
FileUploaderRefType,
} from ":/components/Forms/FileUploader/index";
import { DropZone } from ":/components/Forms/FileUploader/DropZone"; import { DropZone } from ":/components/Forms/FileUploader/DropZone";
export const FileUploaderMulti = forwardRef< export const FileUploaderMulti = ({
FileUploaderRefType, fullWidth,
FileUploaderProps fakeDefaultFiles,
>(({ fullWidth, fakeDefaultFiles, ...props }, ref) => { ref,
...props
}: FileUploaderProps) => {
const { t } = useCunningham(); const { t } = useCunningham();
const [files, setFiles] = useState<File[]>(fakeDefaultFiles || []); const [files, setFiles] = useState<File[]>(fakeDefaultFiles || []);
@@ -59,4 +58,4 @@ export const FileUploaderMulti = forwardRef<
)} )}
</> </>
); );
}); };

View File

@@ -1,11 +1,12 @@
import React, { forwardRef, InputHTMLAttributes, ReactElement } from "react"; import React, { InputHTMLAttributes, ReactElement, RefAttributes } from "react";
import { Field, FieldProps, FieldState } from ":/components/Forms/Field"; import { Field, FieldProps, FieldState } from ":/components/Forms/Field";
import { FileUploaderMulti } from ":/components/Forms/FileUploader/FileUploaderMulti"; import { FileUploaderMulti } from ":/components/Forms/FileUploader/FileUploaderMulti";
import { FileUploaderMono } from ":/components/Forms/FileUploader/FileUploaderMono"; import { FileUploaderMono } from ":/components/Forms/FileUploader/FileUploaderMono";
export interface FileUploaderProps export interface FileUploaderProps
extends Omit<FieldProps, "state">, extends Omit<FieldProps, "state">,
InputHTMLAttributes<HTMLInputElement> { InputHTMLAttributes<HTMLInputElement>,
RefAttributes<FileUploaderRefType> {
state?: FieldState | "uploading" | undefined; state?: FieldState | "uploading" | undefined;
multiple?: boolean; multiple?: boolean;
icon?: ReactElement; icon?: ReactElement;
@@ -25,16 +26,18 @@ export interface FileUploaderRefType {
reset: () => void; reset: () => void;
} }
export const FileUploader = forwardRef<FileUploaderRefType, FileUploaderProps>( export const FileUploader = ({
({ fullWidth, ...props }, ref) => { fullWidth,
return ( ref,
<Field fullWidth={fullWidth} className={props.className}> ...props
{props.multiple ? ( }: FileUploaderProps) => {
<FileUploaderMulti {...props} ref={ref} /> return (
) : ( <Field fullWidth={fullWidth} className={props.className}>
<FileUploaderMono {...props} ref={ref} /> {props.multiple ? (
)} <FileUploaderMulti {...props} ref={ref} />
</Field> ) : (
); <FileUploaderMono {...props} ref={ref} />
}, )}
); </Field>
);
};

View File

@@ -1,12 +1,9 @@
import React, { forwardRef } from "react"; import React from "react";
import { Input, InputProps } from ":/components/Forms/Input/index"; import { Input, InputProps } from ":/components/Forms/Input/index";
import { Button } from ":/components/Button"; import { Button } from ":/components/Button";
import { useCunningham } from ":/components/Provider"; import { useCunningham } from ":/components/Provider";
export const InputPassword = forwardRef< export const InputPassword = (props: Omit<InputProps, "rightIcon">) => {
HTMLInputElement,
Omit<InputProps, "rightIcon">
>((props: InputProps, ref) => {
const [showPassword, setShowPassword] = React.useState(false); const [showPassword, setShowPassword] = React.useState(false);
const { className, ...otherProps } = props; const { className, ...otherProps } = props;
const customClassName = "c__input--password"; const customClassName = "c__input--password";
@@ -14,7 +11,6 @@ export const InputPassword = forwardRef<
return ( return (
<Input <Input
{...otherProps} {...otherProps}
ref={ref}
className={className + " " + customClassName} className={className + " " + customClassName}
type={showPassword ? "text" : "password"} type={showPassword ? "text" : "password"}
rightIcon={ rightIcon={
@@ -38,4 +34,4 @@ export const InputPassword = forwardRef<
} }
/> />
); );
}); };

View File

@@ -1,7 +1,7 @@
import React, { import React, {
forwardRef,
InputHTMLAttributes, InputHTMLAttributes,
ReactNode, ReactNode,
RefAttributes,
useEffect, useEffect,
useRef, useRef,
useState, useState,
@@ -20,123 +20,118 @@ export type InputOnlyProps = {
}; };
export type InputProps = InputHTMLAttributes<HTMLInputElement> & export type InputProps = InputHTMLAttributes<HTMLInputElement> &
RefAttributes<HTMLInputElement> &
FieldProps & FieldProps &
InputOnlyProps; InputOnlyProps;
export const Input = forwardRef<HTMLInputElement, InputProps>( export const Input = ({
( className,
{ defaultValue,
className, label,
defaultValue, id,
label, icon,
id, rightIcon,
icon, charCounter,
rightIcon, charCounterMax,
charCounter, ref,
charCounterMax, ...props
...props }: InputProps) => {
}: InputProps, const classes = ["c__input"];
ref, const inputRef = useRef<HTMLInputElement | null>(null);
) => { const [inputFocus, setInputFocus] = useState(false);
const classes = ["c__input"]; const [value, setValue] = useState(defaultValue || props.value || "");
const inputRef = useRef<HTMLInputElement | null>(null); const [labelAsPlaceholder, setLabelAsPlaceholder] = useState(!value);
const [inputFocus, setInputFocus] = useState(false); const idToUse = useRef(id || randomString());
const [value, setValue] = useState(defaultValue || props.value || ""); const rightTextToUse = charCounter
const [labelAsPlaceholder, setLabelAsPlaceholder] = useState(!value); ? `${value.toString().length}/${charCounterMax}`
const idToUse = useRef(id || randomString()); : props.rightText;
const rightTextToUse = charCounter
? `${value.toString().length}/${charCounterMax}`
: props.rightText;
const updateLabel = () => { const updateLabel = () => {
if (inputFocus) { if (inputFocus) {
setLabelAsPlaceholder(false); setLabelAsPlaceholder(false);
return; return;
} }
setLabelAsPlaceholder(!value); setLabelAsPlaceholder(!value);
}; };
useEffect(() => { useEffect(() => {
updateLabel(); updateLabel();
}, [inputFocus, value]); }, [inputFocus, value]);
// If the input is used as a controlled component, we need to update the local value. // If the input is used as a controlled component, we need to update the local value.
useEffect(() => { useEffect(() => {
if (defaultValue !== undefined) { if (defaultValue !== undefined) {
return; return;
} }
setValue(props.value || ""); setValue(props.value || "");
}, [props.value]); }, [props.value]);
const { const {
compact, compact,
fullWidth, fullWidth,
rightText, rightText,
state, state,
text, text,
textItems, textItems,
...inputProps ...inputProps
} = props; } = props;
return ( return (
<Field {...props} rightText={rightTextToUse} className={className}> <Field {...props} rightText={rightTextToUse} className={className}>
{/* We disabled linting for this specific line because we consider that the onClick props is only used for */} {/* We disabled linting for this specific line because we consider that the onClick props is only used for */}
{/* mouse users, so this do not engender any issue for accessibility. */} {/* mouse users, so this do not engender any issue for accessibility. */}
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */} {/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */}
<div <div
className={classNames( className={classNames(
"c__input__wrapper", "c__input__wrapper",
props.state && "c__input__wrapper--" + props.state, props.state && "c__input__wrapper--" + props.state,
{ {
"c__input__wrapper--disabled": props.disabled, "c__input__wrapper--disabled": props.disabled,
}, },
)} )}
onClick={() => { onClick={() => {
inputRef.current?.focus(); inputRef.current?.focus();
}} }}
>
{!!icon && <div className="c__input__icon-left">{icon}</div>}
<LabelledBox
label={label}
htmlFor={idToUse.current}
labelAsPlaceholder={labelAsPlaceholder}
disabled={props.disabled}
> >
{!!icon && <div className="c__input__icon-left">{icon}</div>} <input
<LabelledBox type="text"
label={label} className={classes.join(" ")}
htmlFor={idToUse.current} {...inputProps}
labelAsPlaceholder={labelAsPlaceholder} id={idToUse.current}
disabled={props.disabled} value={value}
> onFocus={(e) => {
<input setInputFocus(true);
type="text" props.onFocus?.(e);
className={classes.join(" ")} }}
{...inputProps} onBlur={(e) => {
id={idToUse.current} setInputFocus(false);
value={value} props.onBlur?.(e);
onFocus={(e) => { }}
setInputFocus(true); onChange={(e) => {
props.onFocus?.(e); setValue(e.target.value);
}} props.onChange?.(e);
onBlur={(e) => { }}
setInputFocus(false); ref={(inputTextRef) => {
props.onBlur?.(e); if (ref) {
}} if (typeof ref === "function") {
onChange={(e) => { ref(inputTextRef);
setValue(e.target.value); } else {
props.onChange?.(e); ref.current = inputTextRef;
}}
ref={(inputTextRef) => {
if (ref) {
if (typeof ref === "function") {
ref(inputTextRef);
} else {
ref.current = inputTextRef;
}
} }
inputRef.current = inputTextRef; }
}} inputRef.current = inputTextRef;
/> }}
</LabelledBox> />
{!!rightIcon && ( </LabelledBox>
<div className="c__input__icon-right">{rightIcon}</div> {!!rightIcon && <div className="c__input__icon-right">{rightIcon}</div>}
)} </div>
</div> </Field>
</Field> );
); };
},
);

View File

@@ -1,8 +1,8 @@
import React, { import React, {
InputHTMLAttributes, InputHTMLAttributes,
PropsWithChildren, PropsWithChildren,
forwardRef,
ReactNode, ReactNode,
RefAttributes,
} from "react"; } from "react";
import classNames from "classnames"; import classNames from "classnames";
import { Field, FieldProps } from ":/components/Forms/Field"; import { Field, FieldProps } from ":/components/Forms/Field";
@@ -12,38 +12,37 @@ export type RadioOnlyProps = {
}; };
export type RadioProps = InputHTMLAttributes<HTMLInputElement> & export type RadioProps = InputHTMLAttributes<HTMLInputElement> &
RefAttributes<HTMLInputElement> &
FieldProps & FieldProps &
RadioOnlyProps; RadioOnlyProps;
export const Radio = forwardRef<HTMLInputElement, RadioProps>( export const Radio = ({ className, label, ref, ...props }: RadioProps) => {
({ className, label, ...props }: RadioProps, ref) => { const {
const { compact,
compact, fullWidth,
fullWidth, rightText,
rightText, state,
state, text,
text, textItems,
textItems, ...inputProps
...inputProps } = props;
} = props;
return ( return (
<label <label
className={classNames("c__checkbox", "c__radio", className, { className={classNames("c__checkbox", "c__radio", className, {
"c__checkbox--disabled": props.disabled, "c__checkbox--disabled": props.disabled,
"c__checkbox--full-width": props.fullWidth, "c__checkbox--full-width": props.fullWidth,
})} })}
> >
<Field compact={true} {...props}> <Field compact={true} {...props}>
<div className="c__checkbox__container"> <div className="c__checkbox__container">
<input type="radio" {...inputProps} ref={ref} /> <input type="radio" {...inputProps} ref={ref} />
{label && <div className="c__checkbox__label">{label}</div>} {label && <div className="c__checkbox__label">{label}</div>}
</div> </div>
</Field> </Field>
</label> </label>
); );
}, };
);
export const RadioGroup = ({ export const RadioGroup = ({
className, className,

View File

@@ -1,4 +1,4 @@
import React, { forwardRef, PropsWithChildren, ReactNode } from "react"; import React, { PropsWithChildren, ReactNode, RefAttributes } from "react";
import { SelectMulti } from ":/components/Forms/Select/multi"; import { SelectMulti } from ":/components/Forms/Select/multi";
import { SelectMono } from ":/components/Forms/Select/mono"; import { SelectMono } from ":/components/Forms/Select/mono";
import { FieldProps } from ":/components/Forms/Field"; import { FieldProps } from ":/components/Forms/Field";
@@ -28,6 +28,7 @@ export interface SelectHandle {
} }
export type SelectProps = PropsWithChildren & export type SelectProps = PropsWithChildren &
RefAttributes<SelectHandle> &
FieldProps & { FieldProps & {
label: string; label: string;
hideLabel?: boolean; hideLabel?: boolean;
@@ -53,16 +54,12 @@ export type SelectProps = PropsWithChildren &
target: { value: string | undefined }; target: { value: string | undefined };
}) => void; }) => void;
}; };
export const Select = forwardRef<SelectHandle, SelectProps>((props, ref) => { export const Select = (props: SelectProps) => {
if (props.defaultValue && props.value) { if (props.defaultValue && props.value) {
throw new Error( throw new Error(
"You cannot use both defaultValue and value props on Select component", "You cannot use both defaultValue and value props on Select component",
); );
} }
return props.multi ? ( return props.multi ? <SelectMulti {...props} /> : <SelectMono {...props} />;
<SelectMulti {...props} ref={ref} /> };
) : (
<SelectMono {...props} ref={ref} />
);
});

View File

@@ -1,10 +1,4 @@
import React, { import React, { useEffect, useImperativeHandle, useRef, useState } from "react";
forwardRef,
useEffect,
useImperativeHandle,
useRef,
useState,
} from "react";
import { useCombobox } from "downshift"; import { useCombobox } from "downshift";
import classNames from "classnames"; import classNames from "classnames";
import { useCunningham } from ":/components/Provider"; import { useCunningham } from ":/components/Provider";
@@ -15,142 +9,143 @@ import {
SelectMonoAux, SelectMonoAux,
SubProps, SubProps,
} from ":/components/Forms/Select/mono-common"; } from ":/components/Forms/Select/mono-common";
import { SelectHandle } from ":/components/Forms/Select";
import { isOptionWithRender } from ":/components/Forms/Select/utils"; import { isOptionWithRender } from ":/components/Forms/Select/utils";
export const SelectMonoSearchable = forwardRef<SelectHandle, SubProps>( export const SelectMonoSearchable = ({
({ showLabelWhenSelected = true, ...props }, ref) => { showLabelWhenSelected = true,
const { t } = useCunningham(); ref,
const [optionsToDisplay, setOptionsToDisplay] = useState(props.options); ...props
const [hasInputFocused, setHasInputFocused] = useState(false); }: SubProps) => {
const [inputFilter, setInputFilter] = useState<string>(); const { t } = useCunningham();
const inputRef = useRef<HTMLInputElement>(null); const [optionsToDisplay, setOptionsToDisplay] = useState(props.options);
const downshiftReturn = useCombobox({ const [hasInputFocused, setHasInputFocused] = useState(false);
...props.downshiftProps, const [inputFilter, setInputFilter] = useState<string>();
items: optionsToDisplay, const inputRef = useRef<HTMLInputElement>(null);
itemToString: optionToString, const downshiftReturn = useCombobox({
onInputValueChange: (e) => { ...props.downshiftProps,
setInputFilter(e.inputValue); items: optionsToDisplay,
if (!e.inputValue) { itemToString: optionToString,
downshiftReturn.selectItem(null); onInputValueChange: (e) => {
} setInputFilter(e.inputValue);
}, if (!e.inputValue) {
}); downshiftReturn.selectItem(null);
const [labelAsPlaceholder, setLabelAsPlaceholder] = useState(
!downshiftReturn.selectedItem,
);
useEffect(() => {
if (hasInputFocused || downshiftReturn.inputValue) {
setLabelAsPlaceholder(false);
return;
} }
setLabelAsPlaceholder(!downshiftReturn.selectedItem); },
}, [ });
downshiftReturn.selectedItem, const [labelAsPlaceholder, setLabelAsPlaceholder] = useState(
hasInputFocused, !downshiftReturn.selectedItem,
downshiftReturn.inputValue, );
]); useEffect(() => {
if (hasInputFocused || downshiftReturn.inputValue) {
setLabelAsPlaceholder(false);
return;
}
setLabelAsPlaceholder(!downshiftReturn.selectedItem);
}, [
downshiftReturn.selectedItem,
hasInputFocused,
downshiftReturn.inputValue,
]);
// Similar to: useKeepSelectedItemInSyncWithOptions ( see docs ) // Similar to: useKeepSelectedItemInSyncWithOptions ( see docs )
// The only difference is that it does not apply when there is an inputFilter. ( See below why ) // The only difference is that it does not apply when there is an inputFilter. ( See below why )
useEffect(() => { useEffect(() => {
// If there is an inputFilter, using selectItem will trigger onInputValueChange that will sets inputFilter to // If there is an inputFilter, using selectItem will trigger onInputValueChange that will sets inputFilter to
// empty, and then ignoring the existing filter and displaying all options. // empty, and then ignoring the existing filter and displaying all options.
if (inputFilter) { if (inputFilter) {
return; return;
} }
const optionToSelect = props.options.find( const optionToSelect = props.options.find(
(option) => optionToValue(option) === props.value, (option) => optionToValue(option) === props.value,
);
downshiftReturn.selectItem(optionToSelect ?? null);
}, [props.value, props.options, inputFilter]);
// Even there is already a value selected, when opening the combobox menu we want to display all available choices.
useEffect(() => {
if (downshiftReturn.isOpen) {
setOptionsToDisplay(
inputFilter
? props.options.filter(getOptionsFilter(inputFilter))
: props.options,
); );
downshiftReturn.selectItem(optionToSelect ?? null); } else {
}, [props.value, props.options, inputFilter]); setInputFilter(undefined);
}
}, [downshiftReturn.isOpen, props.options, inputFilter]);
// Even there is already a value selected, when opening the combobox menu we want to display all available choices. useImperativeHandle(ref, () => ({
useEffect(() => { blur: () => {
if (downshiftReturn.isOpen) { downshiftReturn.closeMenu();
setOptionsToDisplay( inputRef.current?.blur();
inputFilter },
? props.options.filter(getOptionsFilter(inputFilter)) }));
: props.options,
);
} else {
setInputFilter(undefined);
}
}, [downshiftReturn.isOpen, props.options, inputFilter]);
useImperativeHandle(ref, () => ({ useEffect(() => {
blur: () => { props.onSearchInputChange?.({ target: { value: inputFilter } });
downshiftReturn.closeMenu(); }, [inputFilter]);
inputRef.current?.blur();
},
}));
useEffect(() => { const onInputBlur = () => {
props.onSearchInputChange?.({ target: { value: inputFilter } }); setHasInputFocused(false);
}, [inputFilter]); if (downshiftReturn.selectedItem) {
// Here the goal is to make sure that when the input in blurred then the input value
// has exactly the selectedItem label. Which is not the case by default.
downshiftReturn.selectItem(downshiftReturn.selectedItem);
} else {
// We want the input to be empty when no item is selected.
downshiftReturn.setInputValue("");
}
};
const onInputBlur = () => { const inputProps = downshiftReturn.getInputProps({
setHasInputFocused(false); ref: inputRef,
if (downshiftReturn.selectedItem) { disabled: props.disabled,
// Here the goal is to make sure that when the input in blurred then the input value });
// has exactly the selectedItem label. Which is not the case by default.
downshiftReturn.selectItem(downshiftReturn.selectedItem);
} else {
// We want the input to be empty when no item is selected.
downshiftReturn.setInputValue("");
}
};
const inputProps = downshiftReturn.getInputProps({ const renderCustomSelectedOption = !showLabelWhenSelected;
ref: inputRef,
disabled: props.disabled,
});
const renderCustomSelectedOption = !showLabelWhenSelected; return (
<SelectMonoAux
return ( {...props}
<SelectMonoAux downshiftReturn={{
{...props} ...downshiftReturn,
downshiftReturn={{ wrapperProps: {
...downshiftReturn, onClick: () => {
wrapperProps: { inputRef.current?.focus();
onClick: () => { // This is important because if we don't check that: when clicking on the toggle button
inputRef.current?.focus(); // when the menu is open, it will close and reopen immediately.
// This is important because if we don't check that: when clicking on the toggle button if (!downshiftReturn.isOpen) {
// when the menu is open, it will close and reopen immediately. downshiftReturn.openMenu();
if (!downshiftReturn.isOpen) { }
downshiftReturn.openMenu();
}
},
}, },
toggleButtonProps: downshiftReturn.getToggleButtonProps({ },
disabled: props.disabled, toggleButtonProps: downshiftReturn.getToggleButtonProps({
"aria-label": t("components.forms.select.toggle_button_aria_label"), disabled: props.disabled,
}), "aria-label": t("components.forms.select.toggle_button_aria_label"),
}),
}}
labelAsPlaceholder={labelAsPlaceholder}
options={optionsToDisplay}
>
<input
{...inputProps}
className={classNames({
"c__select__inner__value__input--hidden":
renderCustomSelectedOption && !hasInputFocused,
})}
onFocus={() => {
setHasInputFocused(true);
}} }}
labelAsPlaceholder={labelAsPlaceholder} onBlur={() => {
options={optionsToDisplay} onInputBlur();
> }}
<input />
{...inputProps}
className={classNames({
"c__select__inner__value__input--hidden":
renderCustomSelectedOption && !hasInputFocused,
})}
onFocus={() => {
setHasInputFocused(true);
}}
onBlur={() => {
onInputBlur();
}}
/>
{renderCustomSelectedOption && {renderCustomSelectedOption &&
!hasInputFocused && !hasInputFocused &&
downshiftReturn.selectedItem && downshiftReturn.selectedItem &&
isOptionWithRender(downshiftReturn.selectedItem) && isOptionWithRender(downshiftReturn.selectedItem) &&
downshiftReturn.selectedItem.render()} downshiftReturn.selectedItem.render()}
</SelectMonoAux> </SelectMonoAux>
); );
}, };
);

View File

@@ -1,17 +1,12 @@
import { useSelect, UseSelectReturnValue } from "downshift"; import { useSelect, UseSelectReturnValue } from "downshift";
import React, { import React, { useEffect, useImperativeHandle, useRef } from "react";
forwardRef,
useEffect,
useImperativeHandle,
useRef,
} from "react";
import { import {
optionToString, optionToString,
optionToValue, optionToValue,
SelectMonoAux, SelectMonoAux,
SubProps, SubProps,
} from ":/components/Forms/Select/mono-common"; } from ":/components/Forms/Select/mono-common";
import { Option, SelectHandle, SelectProps } from ":/components/Forms/Select"; import { Option, SelectProps } from ":/components/Forms/Select";
import { SelectedOption } from ":/components/Forms/Select/utils"; import { SelectedOption } from ":/components/Forms/Select/utils";
/** /**
@@ -32,40 +27,38 @@ const useKeepSelectedItemInSyncWithOptions = (
}, [props.value, props.options]); }, [props.value, props.options]);
}; };
export const SelectMonoSimple = forwardRef<SelectHandle, SubProps>( export const SelectMonoSimple = ({ ref, ...props }: SubProps) => {
(props, ref) => { const downshiftReturn = useSelect({
const downshiftReturn = useSelect({ ...props.downshiftProps,
...props.downshiftProps, items: props.options,
items: props.options, itemToString: optionToString,
itemToString: optionToString, });
});
useKeepSelectedItemInSyncWithOptions(downshiftReturn, props); useKeepSelectedItemInSyncWithOptions(downshiftReturn, props);
const wrapperRef = useRef<HTMLElement>(null); const wrapperRef = useRef<HTMLElement>(null);
useImperativeHandle(ref, () => ({ useImperativeHandle(ref, () => ({
blur: () => { blur: () => {
downshiftReturn.closeMenu(); downshiftReturn.closeMenu();
wrapperRef.current?.blur(); wrapperRef.current?.blur();
}, },
})); }));
return ( return (
<SelectMonoAux <SelectMonoAux
{...props} {...props}
downshiftReturn={{ downshiftReturn={{
...downshiftReturn, ...downshiftReturn,
wrapperProps: downshiftReturn.getToggleButtonProps({ wrapperProps: downshiftReturn.getToggleButtonProps({
disabled: props.disabled, disabled: props.disabled,
ref: wrapperRef, ref: wrapperRef,
}), }),
toggleButtonProps: {}, toggleButtonProps: {},
}} }}
labelAsPlaceholder={!downshiftReturn.selectedItem} labelAsPlaceholder={!downshiftReturn.selectedItem}
> >
<SelectedOption option={downshiftReturn.selectedItem} {...props} /> <SelectedOption option={downshiftReturn.selectedItem} {...props} />
</SelectMonoAux> </SelectMonoAux>
); );
}, };
);

View File

@@ -1,67 +1,63 @@
import React, { forwardRef, useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import { UseSelectStateChange } from "downshift"; import { UseSelectStateChange } from "downshift";
import { optionToValue, SubProps } from ":/components/Forms/Select/mono-common"; import { optionToValue, SubProps } from ":/components/Forms/Select/mono-common";
import { SelectMonoSearchable } from ":/components/Forms/Select/mono-searchable"; import { SelectMonoSearchable } from ":/components/Forms/Select/mono-searchable";
import { SelectMonoSimple } from ":/components/Forms/Select/mono-simple"; import { SelectMonoSimple } from ":/components/Forms/Select/mono-simple";
import { Option, SelectHandle, SelectProps } from ":/components/Forms/Select"; import { Option, SelectProps } from ":/components/Forms/Select";
export const SelectMono = forwardRef<SelectHandle, SelectProps>( export const SelectMono = (props: SelectProps) => {
(props, ref) => { const defaultSelectedItem = props.defaultValue
const defaultSelectedItem = props.defaultValue ? props.options.find(
? props.options.find( (option) => optionToValue(option) === props.defaultValue,
(option) => optionToValue(option) === props.defaultValue, )
) : undefined;
: undefined; const [value, setValue] = useState(
const [value, setValue] = useState( defaultSelectedItem ? optionToValue(defaultSelectedItem) : props.value,
defaultSelectedItem ? optionToValue(defaultSelectedItem) : props.value, );
);
/** /**
* This useEffect is used to update the local value when the component is controlled. * This useEffect is used to update the local value when the component is controlled.
* The defaultValue is used only on first render. * The defaultValue is used only on first render.
*/ */
useEffect(() => { useEffect(() => {
if (props.defaultValue) { if (props.defaultValue) {
return; return;
}
setValue(props.value);
}, [props.value, props.defaultValue]);
const commonDownshiftProps: SubProps["downshiftProps"] = {
initialSelectedItem: defaultSelectedItem,
onSelectedItemChange: (e: UseSelectStateChange<Option>) => {
const eventCmp = e.selectedItem ? optionToValue(e.selectedItem) : null;
const valueCmp = value ?? null;
// We make sure to not trigger a onChange event if the value are not different.
// This could happen on first render when the component is controlled, the value will be
// set inside a useEffect down in SelectMonoSearchable or SelectMonoSimple. So that means the
// downshift component will always render empty the first time.
if (eventCmp !== valueCmp) {
setValue(eventCmp || undefined);
props.onChange?.({
target: {
value: e.selectedItem ? optionToValue(e.selectedItem) : undefined,
},
});
} }
setValue(props.value); },
}, [props.value, props.defaultValue]); isItemDisabled: (item) => !!item.disabled,
};
const commonDownshiftProps: SubProps["downshiftProps"] = { return props.searchable ? (
initialSelectedItem: defaultSelectedItem, <SelectMonoSearchable
onSelectedItemChange: (e: UseSelectStateChange<Option>) => { {...props}
const eventCmp = e.selectedItem ? optionToValue(e.selectedItem) : null; downshiftProps={commonDownshiftProps}
const valueCmp = value ?? null; value={value}
// We make sure to not trigger a onChange event if the value are not different. />
// This could happen on first render when the component is controlled, the value will be ) : (
// set inside a useEffect down in SelectMonoSearchable or SelectMonoSimple. So that means the <SelectMonoSimple
// downshift component will always render empty the first time. {...props}
if (eventCmp !== valueCmp) { downshiftProps={commonDownshiftProps}
setValue(eventCmp || undefined); value={value}
props.onChange?.({ />
target: { );
value: e.selectedItem ? optionToValue(e.selectedItem) : undefined, };
},
});
}
},
isItemDisabled: (item) => !!item.disabled,
};
return props.searchable ? (
<SelectMonoSearchable
{...props}
downshiftProps={commonDownshiftProps}
value={value}
ref={ref}
/>
) : (
<SelectMonoSimple
{...props}
downshiftProps={commonDownshiftProps}
value={value}
ref={ref}
/>
);
},
);

View File

@@ -12,7 +12,7 @@ import { SelectMenu } from ":/components/Forms/Select/select-menu";
export const SelectMultiMenu = ( export const SelectMultiMenu = (
props: SelectMultiAuxProps & { props: SelectMultiAuxProps & {
selectRef: React.RefObject<HTMLDivElement>; selectRef: React.RefObject<HTMLDivElement | null>;
}, },
) => { ) => {
const { t } = useCunningham(); const { t } = useCunningham();

View File

@@ -1,10 +1,4 @@
import React, { import React, { useEffect, useImperativeHandle, useRef, useState } from "react";
forwardRef,
useEffect,
useImperativeHandle,
useRef,
useState,
} from "react";
import { useCombobox, useMultipleSelection } from "downshift"; import { useCombobox, useMultipleSelection } from "downshift";
import { optionToString } from ":/components/Forms/Select/mono-common"; import { optionToString } from ":/components/Forms/Select/mono-common";
import { import {
@@ -12,162 +6,155 @@ import {
SelectMultiAux, SelectMultiAux,
SubProps, SubProps,
} from ":/components/Forms/Select/multi-common"; } from ":/components/Forms/Select/multi-common";
import { SelectHandle } from ":/components/Forms/Select/index";
export const SelectMultiSearchable = forwardRef<SelectHandle, SubProps>( export const SelectMultiSearchable = ({ ref, ...props }: SubProps) => {
(props, ref) => { const [inputValue, setInputValue] = React.useState<string>();
const [inputValue, setInputValue] = React.useState<string>(); const inputRef = useRef<HTMLInputElement>(null);
const inputRef = useRef<HTMLInputElement>(null); const options = React.useMemo(
const options = React.useMemo( () =>
() => props.options.filter(
props.options.filter( getMultiOptionsFilter(props.selectedItems, inputValue),
getMultiOptionsFilter(props.selectedItems, inputValue), ),
), [props.selectedItems, inputValue],
[props.selectedItems, inputValue], );
); const [hasInputFocused, setHasInputFocused] = useState(false);
const [hasInputFocused, setHasInputFocused] = useState(false); const useMultipleSelectionReturn = useMultipleSelection({
const useMultipleSelectionReturn = useMultipleSelection({ selectedItems: props.selectedItems,
selectedItems: props.selectedItems, onStateChange({ selectedItems: newSelectedItems, type }) {
onStateChange({ selectedItems: newSelectedItems, type }) { switch (type) {
switch (type) { case useMultipleSelection.stateChangeTypes.SelectedItemKeyDownBackspace:
case useMultipleSelection.stateChangeTypes case useMultipleSelection.stateChangeTypes.SelectedItemKeyDownDelete:
.SelectedItemKeyDownBackspace: case useMultipleSelection.stateChangeTypes.DropdownKeyDownBackspace:
case useMultipleSelection.stateChangeTypes.SelectedItemKeyDownDelete: case useMultipleSelection.stateChangeTypes.FunctionRemoveSelectedItem:
case useMultipleSelection.stateChangeTypes.DropdownKeyDownBackspace: props.onSelectedItemsChange(newSelectedItems ?? []);
case useMultipleSelection.stateChangeTypes.FunctionRemoveSelectedItem: break;
props.onSelectedItemsChange(newSelectedItems ?? []); default:
break; break;
default: }
break; },
} });
}, const downshiftReturn = useCombobox({
}); items: options,
const downshiftReturn = useCombobox({ itemToString: optionToString,
items: options, defaultHighlightedIndex: 0, // after selection, highlight the first item.
itemToString: optionToString, selectedItem: null, // Important, without this we are not able to re-select the last removed option.
defaultHighlightedIndex: 0, // after selection, highlight the first item. stateReducer: (state, actionAndChanges) => {
selectedItem: null, // Important, without this we are not able to re-select the last removed option. const { changes, type } = actionAndChanges;
stateReducer: (state, actionAndChanges) => { switch (type) {
const { changes, type } = actionAndChanges; case useCombobox.stateChangeTypes.InputKeyDownEnter:
switch (type) { case useCombobox.stateChangeTypes.ItemClick:
case useCombobox.stateChangeTypes.InputKeyDownEnter: return {
case useCombobox.stateChangeTypes.ItemClick: ...changes,
return { isOpen: true, // keep the menu open after selection.
...changes, highlightedIndex: 0, // with the first option highlighted.
isOpen: true, // keep the menu open after selection. };
highlightedIndex: 0, // with the first option highlighted. default:
}; return changes;
default: }
return changes; },
} onStateChange: ({
}, inputValue: newInputValue,
onStateChange: ({ type,
inputValue: newInputValue, selectedItem: newSelectedItem,
type, }) => {
selectedItem: newSelectedItem, switch (type) {
}) => { case useCombobox.stateChangeTypes.InputKeyDownEnter:
switch (type) { case useCombobox.stateChangeTypes.ItemClick:
case useCombobox.stateChangeTypes.InputKeyDownEnter: case useCombobox.stateChangeTypes.InputBlur:
case useCombobox.stateChangeTypes.ItemClick: if (newSelectedItem && !newSelectedItem.disabled) {
case useCombobox.stateChangeTypes.InputBlur: props.onSelectedItemsChange([
if (newSelectedItem && !newSelectedItem.disabled) { ...props.selectedItems,
props.onSelectedItemsChange([ newSelectedItem,
...props.selectedItems, ]);
newSelectedItem, setInputValue(undefined);
]);
setInputValue(undefined);
}
break;
case useCombobox.stateChangeTypes.InputChange:
setInputValue(newInputValue);
break;
default:
break;
}
},
isItemDisabled: (item) => !!item.disabled,
});
const inputProps = downshiftReturn.getInputProps({
...useMultipleSelectionReturn.getDropdownProps({
preventKeyAction: downshiftReturn.isOpen,
ref: inputRef,
disabled: props.disabled,
}),
value: inputValue,
});
// We want to extend the default behavior of the input onKeyDown.
const { onKeyDown } = inputProps;
inputProps.onKeyDown = (event: React.KeyboardEvent<HTMLElement>) => {
switch (event.code) {
case "Backspace":
if (!inputValue) {
props.onSelectedItemsChange(props.selectedItems.slice(0, -1));
} }
break;
case useCombobox.stateChangeTypes.InputChange:
setInputValue(newInputValue);
break;
default:
break;
} }
onKeyDown?.(event); },
}; isItemDisabled: (item) => !!item.disabled,
});
const [labelAsPlaceholder, setLabelAsPlaceholder] = useState(true); const inputProps = downshiftReturn.getInputProps({
useEffect(() => { ...useMultipleSelectionReturn.getDropdownProps({
if (hasInputFocused || inputValue) { preventKeyAction: downshiftReturn.isOpen,
setLabelAsPlaceholder(false); ref: inputRef,
return; disabled: props.disabled,
} }),
setLabelAsPlaceholder(props.selectedItems.length === 0); value: inputValue,
}, [props.selectedItems, hasInputFocused, inputValue]); });
// We want to extend the default behavior of the input onKeyDown.
const { onKeyDown } = inputProps;
inputProps.onKeyDown = (event: React.KeyboardEvent<HTMLElement>) => {
switch (event.code) {
case "Backspace":
if (!inputValue) {
props.onSelectedItemsChange(props.selectedItems.slice(0, -1));
}
}
onKeyDown?.(event);
};
useImperativeHandle(ref, () => ({ const [labelAsPlaceholder, setLabelAsPlaceholder] = useState(true);
blur: () => { useEffect(() => {
downshiftReturn.closeMenu(); if (hasInputFocused || inputValue) {
inputRef.current?.blur(); setLabelAsPlaceholder(false);
}, return;
})); }
setLabelAsPlaceholder(props.selectedItems.length === 0);
}, [props.selectedItems, hasInputFocused, inputValue]);
useEffect(() => { useImperativeHandle(ref, () => ({
props.onSearchInputChange?.({ target: { value: inputValue } }); blur: () => {
}, [inputValue]); downshiftReturn.closeMenu();
inputRef.current?.blur();
},
}));
return ( useEffect(() => {
<SelectMultiAux props.onSearchInputChange?.({ target: { value: inputValue } });
{...props} }, [inputValue]);
monoline={false}
options={options} return (
labelAsPlaceholder={labelAsPlaceholder} <SelectMultiAux
selectedItems={props.selectedItems} {...props}
downshiftReturn={{ monoline={false}
...downshiftReturn, options={options}
wrapperProps: { labelAsPlaceholder={labelAsPlaceholder}
onClick: () => { selectedItems={props.selectedItems}
inputRef.current?.focus(); downshiftReturn={{
// This is important because if we don't check that: when clicking on the toggle button ...downshiftReturn,
// when the menu is open, it will close and reopen immediately. wrapperProps: {
if (!downshiftReturn.isOpen) { onClick: () => {
downshiftReturn.openMenu(); inputRef.current?.focus();
} // This is important because if we don't check that: when clicking on the toggle button
}, // when the menu is open, it will close and reopen immediately.
}, if (!downshiftReturn.isOpen) {
toggleButtonProps: downshiftReturn.getToggleButtonProps(),
}}
useMultipleSelectionReturn={useMultipleSelectionReturn}
>
<span
className="c__select__inner__value__input"
data-value={inputValue}
>
<input
{...inputProps}
onFocus={() => {
setHasInputFocused(true);
downshiftReturn.openMenu(); downshiftReturn.openMenu();
}} }
onBlur={() => { },
setHasInputFocused(false); },
}} toggleButtonProps: downshiftReturn.getToggleButtonProps(),
size={4} }}
/> useMultipleSelectionReturn={useMultipleSelectionReturn}
</span> >
</SelectMultiAux> <span className="c__select__inner__value__input" data-value={inputValue}>
); <input
}, {...inputProps}
); onFocus={() => {
setHasInputFocused(true);
downshiftReturn.openMenu();
}}
onBlur={() => {
setHasInputFocused(false);
}}
size={4}
/>
</span>
</SelectMultiAux>
);
};

View File

@@ -1,4 +1,4 @@
import React, { forwardRef, useImperativeHandle, useRef } from "react"; import React, { useImperativeHandle, useRef } from "react";
import { useMultipleSelection, useSelect } from "downshift"; import { useMultipleSelection, useSelect } from "downshift";
import { import {
getMultiOptionsFilter, getMultiOptionsFilter,
@@ -9,127 +9,121 @@ import {
optionsEqual, optionsEqual,
optionToString, optionToString,
} from ":/components/Forms/Select/mono-common"; } from ":/components/Forms/Select/mono-common";
import { Option, SelectHandle } from ":/components/Forms/Select/index"; import { Option } from ":/components/Forms/Select/index";
export const SelectMultiSimple = forwardRef<SelectHandle, SubProps>( export const SelectMultiSimple = ({ ref, ...props }: SubProps) => {
(props, ref) => { const isSelected = (option: Option) =>
const isSelected = (option: Option) => !!props.selectedItems.find((selectedItem) =>
!!props.selectedItems.find((selectedItem) => optionsEqual(selectedItem, option),
optionsEqual(selectedItem, option),
);
const options = React.useMemo(() => {
if (props.monoline) {
return props.options.map((option) => ({
...option,
highlighted: isSelected(option),
}));
}
return props.options.filter(
getMultiOptionsFilter(props.selectedItems, ""),
);
}, [props.selectedItems]);
const useMultipleSelectionReturn = useMultipleSelection({
selectedItems: props.selectedItems,
onStateChange({ selectedItems: newSelectedItems, type }) {
switch (type) {
case useMultipleSelection.stateChangeTypes
.SelectedItemKeyDownBackspace:
case useMultipleSelection.stateChangeTypes.SelectedItemKeyDownDelete:
case useMultipleSelection.stateChangeTypes.DropdownKeyDownBackspace:
case useMultipleSelection.stateChangeTypes.FunctionRemoveSelectedItem:
props.onSelectedItemsChange(newSelectedItems ?? []);
break;
default:
break;
}
},
});
const downshiftReturn = useSelect({
items: options,
itemToString: optionToString,
selectedItem: null, // Important, without this we are not able to re-select the last removed option.
defaultHighlightedIndex: 0, // after selection, highlight the first item.
stateReducer: (state, actionAndChanges) => {
const { changes, type } = actionAndChanges;
switch (type) {
case useSelect.stateChangeTypes.ToggleButtonKeyDownEnter:
case useSelect.stateChangeTypes.ToggleButtonKeyDownSpaceButton:
case useSelect.stateChangeTypes.ItemClick:
return {
...changes,
isOpen: true, // keep the menu open after selection.
highlightedIndex: state.highlightedIndex, // avoid automatic scroll up on click.
};
}
return changes;
},
onStateChange: ({ type, selectedItem: newSelectedItem }) => {
switch (type) {
case useSelect.stateChangeTypes.ToggleButtonKeyDownEnter:
case useSelect.stateChangeTypes.ToggleButtonKeyDownSpaceButton:
case useSelect.stateChangeTypes.ItemClick:
if (!newSelectedItem) {
break;
}
if (isSelected(newSelectedItem)) {
// Remove the item if it is already selected.
props.onSelectedItemsChange(
props.selectedItems.filter(
(selectedItem) =>
!optionsEqual(selectedItem, newSelectedItem),
),
);
} else {
props.onSelectedItemsChange([
...props.selectedItems,
newSelectedItem,
]);
}
break;
default:
break;
}
},
isItemDisabled: (item) => !!item.disabled,
});
const toggleRef = useRef<HTMLElement>(null);
useImperativeHandle(ref, () => ({
blur: () => {
downshiftReturn.closeMenu();
toggleRef.current?.blur();
},
}));
return (
<SelectMultiAux
{...props}
options={options}
labelAsPlaceholder={props.selectedItems.length === 0}
selectedItems={props.selectedItems}
selectedItemsStyle={props.monoline ? "text" : "pills"}
menuOptionsStyle={props.monoline ? "checkbox" : "plain"}
downshiftReturn={{
...downshiftReturn,
toggleButtonProps: downshiftReturn.getToggleButtonProps({
...useMultipleSelectionReturn.getDropdownProps({
preventKeyAction: downshiftReturn.isOpen,
ref: toggleRef,
}),
disabled: props.disabled,
onClick: (e: React.MouseEvent): void => {
// As the wrapper also has an onClick handler, we need to stop the event propagation here on it will toggle
// twice the menu opening which will ... do nothing :).
e.stopPropagation();
},
}),
}}
useMultipleSelectionReturn={useMultipleSelectionReturn}
/>
); );
},
); const options = React.useMemo(() => {
if (props.monoline) {
return props.options.map((option) => ({
...option,
highlighted: isSelected(option),
}));
}
return props.options.filter(getMultiOptionsFilter(props.selectedItems, ""));
}, [props.selectedItems]);
const useMultipleSelectionReturn = useMultipleSelection({
selectedItems: props.selectedItems,
onStateChange({ selectedItems: newSelectedItems, type }) {
switch (type) {
case useMultipleSelection.stateChangeTypes.SelectedItemKeyDownBackspace:
case useMultipleSelection.stateChangeTypes.SelectedItemKeyDownDelete:
case useMultipleSelection.stateChangeTypes.DropdownKeyDownBackspace:
case useMultipleSelection.stateChangeTypes.FunctionRemoveSelectedItem:
props.onSelectedItemsChange(newSelectedItems ?? []);
break;
default:
break;
}
},
});
const downshiftReturn = useSelect({
items: options,
itemToString: optionToString,
selectedItem: null, // Important, without this we are not able to re-select the last removed option.
defaultHighlightedIndex: 0, // after selection, highlight the first item.
stateReducer: (state, actionAndChanges) => {
const { changes, type } = actionAndChanges;
switch (type) {
case useSelect.stateChangeTypes.ToggleButtonKeyDownEnter:
case useSelect.stateChangeTypes.ToggleButtonKeyDownSpaceButton:
case useSelect.stateChangeTypes.ItemClick:
return {
...changes,
isOpen: true, // keep the menu open after selection.
highlightedIndex: state.highlightedIndex, // avoid automatic scroll up on click.
};
}
return changes;
},
onStateChange: ({ type, selectedItem: newSelectedItem }) => {
switch (type) {
case useSelect.stateChangeTypes.ToggleButtonKeyDownEnter:
case useSelect.stateChangeTypes.ToggleButtonKeyDownSpaceButton:
case useSelect.stateChangeTypes.ItemClick:
if (!newSelectedItem) {
break;
}
if (isSelected(newSelectedItem)) {
// Remove the item if it is already selected.
props.onSelectedItemsChange(
props.selectedItems.filter(
(selectedItem) => !optionsEqual(selectedItem, newSelectedItem),
),
);
} else {
props.onSelectedItemsChange([
...props.selectedItems,
newSelectedItem,
]);
}
break;
default:
break;
}
},
isItemDisabled: (item) => !!item.disabled,
});
const toggleRef = useRef<HTMLElement>(null);
useImperativeHandle(ref, () => ({
blur: () => {
downshiftReturn.closeMenu();
toggleRef.current?.blur();
},
}));
return (
<SelectMultiAux
{...props}
options={options}
labelAsPlaceholder={props.selectedItems.length === 0}
selectedItems={props.selectedItems}
selectedItemsStyle={props.monoline ? "text" : "pills"}
menuOptionsStyle={props.monoline ? "checkbox" : "plain"}
downshiftReturn={{
...downshiftReturn,
toggleButtonProps: downshiftReturn.getToggleButtonProps({
...useMultipleSelectionReturn.getDropdownProps({
preventKeyAction: downshiftReturn.isOpen,
ref: toggleRef,
}),
disabled: props.disabled,
onClick: (e: React.MouseEvent): void => {
// As the wrapper also has an onClick handler, we need to stop the event propagation here on it will toggle
// twice the menu opening which will ... do nothing :).
e.stopPropagation();
},
}),
}}
useMultipleSelectionReturn={useMultipleSelectionReturn}
/>
);
};

View File

@@ -1,73 +1,65 @@
import React, { forwardRef, useEffect } from "react"; import React, { useEffect } from "react";
import { optionToValue } from ":/components/Forms/Select/mono-common"; import { optionToValue } from ":/components/Forms/Select/mono-common";
import { SelectMultiSearchable } from ":/components/Forms/Select/multi-searchable"; import { SelectMultiSearchable } from ":/components/Forms/Select/multi-searchable";
import { SelectMultiSimple } from ":/components/Forms/Select/multi-simple"; import { SelectMultiSimple } from ":/components/Forms/Select/multi-simple";
import { SubProps } from ":/components/Forms/Select/multi-common"; import { SubProps } from ":/components/Forms/Select/multi-common";
import { import { Option, SelectProps } from ":/components/Forms/Select/index";
Option,
SelectHandle,
SelectProps,
} from ":/components/Forms/Select/index";
export type SelectMultiProps = Omit<SelectProps, "onChange"> & { export type SelectMultiProps = Omit<SelectProps, "onChange"> & {
onChange?: (event: { target: { value: string[] } }) => void; onChange?: (event: { target: { value: string[] } }) => void;
}; };
export const SelectMulti = forwardRef<SelectHandle, SelectMultiProps>( export const SelectMulti = (props: SelectMultiProps) => {
(props, ref) => { const getSelectedItemsFromProps = () => {
const getSelectedItemsFromProps = () => { const valueToUse = props.defaultValue ?? props.value ?? [];
const valueToUse = props.defaultValue ?? props.value ?? []; return props.options.filter((option) =>
return props.options.filter((option) => (valueToUse as string[]).includes(optionToValue(option)),
(valueToUse as string[]).includes(optionToValue(option)),
);
};
const [selectedItems, setSelectedItems] = React.useState<Option[]>(
getSelectedItemsFromProps(),
); );
};
// If the component is used as a controlled component, we need to update the local value when the value prop changes. const [selectedItems, setSelectedItems] = React.useState<Option[]>(
useEffect(() => { getSelectedItemsFromProps(),
// Means it is not controlled. );
if (props.defaultValue !== undefined) {
return;
}
setSelectedItems(getSelectedItemsFromProps());
}, [JSON.stringify(props.value)]);
// If the component is used as an uncontrolled component, we need to update the parent value when the local value changes. // If the component is used as a controlled component, we need to update the local value when the value prop changes.
useEffect(() => { useEffect(() => {
props.onChange?.({ target: { value: selectedItems.map(optionToValue) } }); // Means it is not controlled.
}, [JSON.stringify(selectedItems)]); if (props.defaultValue !== undefined) {
return;
}
setSelectedItems(getSelectedItemsFromProps());
}, [JSON.stringify(props.value)]);
const onSelectedItemsChange: SubProps["onSelectedItemsChange"] = ( // If the component is used as an uncontrolled component, we need to update the parent value when the local value changes.
newSelectedItems, useEffect(() => {
) => { props.onChange?.({ target: { value: selectedItems.map(optionToValue) } });
setSelectedItems(newSelectedItems); }, [JSON.stringify(selectedItems)]);
};
const defaultProps: Partial<SelectMultiProps> = { const onSelectedItemsChange: SubProps["onSelectedItemsChange"] = (
selectedItemsStyle: "pills", newSelectedItems,
menuOptionsStyle: "plain", ) => {
clearable: true, setSelectedItems(newSelectedItems);
}; };
return props.searchable ? ( const defaultProps: Partial<SelectMultiProps> = {
<SelectMultiSearchable selectedItemsStyle: "pills",
{...defaultProps} menuOptionsStyle: "plain",
{...props} clearable: true,
selectedItems={selectedItems} };
onSelectedItemsChange={onSelectedItemsChange}
ref={ref} return props.searchable ? (
/> <SelectMultiSearchable
) : ( {...defaultProps}
<SelectMultiSimple {...props}
{...defaultProps} selectedItems={selectedItems}
{...props} onSelectedItemsChange={onSelectedItemsChange}
selectedItems={selectedItems} />
onSelectedItemsChange={onSelectedItemsChange} ) : (
ref={ref} <SelectMultiSimple
/> {...defaultProps}
); {...props}
}, selectedItems={selectedItems}
); onSelectedItemsChange={onSelectedItemsChange}
/>
);
};

View File

@@ -7,7 +7,7 @@ import { SelectMultiAuxProps } from ":/components/Forms/Select/multi-common";
export interface SelectDropdownProps extends PropsWithChildren { export interface SelectDropdownProps extends PropsWithChildren {
isOpen: boolean; isOpen: boolean;
selectRef: React.RefObject<HTMLDivElement>; selectRef: React.RefObject<HTMLDivElement | null>;
menuOptionsStyle?: SelectProps["menuOptionsStyle"]; menuOptionsStyle?: SelectProps["menuOptionsStyle"];
downshiftReturn: downshiftReturn:
| SelectAuxProps["downshiftReturn"] | SelectAuxProps["downshiftReturn"]

View File

@@ -1,4 +1,4 @@
import React, { InputHTMLAttributes, forwardRef } from "react"; import React, { InputHTMLAttributes, RefAttributes } from "react";
import classNames from "classnames"; import classNames from "classnames";
import { Field, FieldProps } from ":/components/Forms/Field"; import { Field, FieldProps } from ":/components/Forms/Field";
@@ -8,47 +8,51 @@ export type SwitchOnlyProps = {
}; };
export type SwitchProps = InputHTMLAttributes<HTMLInputElement> & export type SwitchProps = InputHTMLAttributes<HTMLInputElement> &
RefAttributes<HTMLInputElement> &
FieldProps & FieldProps &
SwitchOnlyProps; SwitchOnlyProps;
export const Switch = forwardRef<HTMLInputElement, SwitchProps>( export const Switch = ({
({ label, labelSide = "left", ...props }: SwitchProps, ref) => { label,
const { labelSide = "left",
compact, ref,
className, ...props
fullWidth, }: SwitchProps) => {
rightText, const {
state, compact,
text, className,
textItems, fullWidth,
...inputProps rightText,
} = props; state,
text,
textItems,
...inputProps
} = props;
const { className: excludeClassName, ...fieldProps } = props; const { className: excludeClassName, ...fieldProps } = props;
return ( return (
<label <label
className={classNames( className={classNames(
"c__checkbox", "c__checkbox",
"c__switch", "c__switch",
"c__switch--" + labelSide, "c__switch--" + labelSide,
className, className,
{ {
"c__checkbox--disabled": props.disabled, "c__checkbox--disabled": props.disabled,
"c__switch--full-width": props.fullWidth, "c__switch--full-width": props.fullWidth,
}, },
)} )}
> >
<Field compact={true} {...fieldProps}> <Field compact={true} {...fieldProps}>
<div className="c__checkbox__container"> <div className="c__checkbox__container">
{label && <div className="c__checkbox__label">{label}</div>} {label && <div className="c__checkbox__label">{label}</div>}
<div className="c__switch__rail__wrapper"> <div className="c__switch__rail__wrapper">
<input type="checkbox" {...inputProps} ref={ref} /> <input type="checkbox" {...inputProps} ref={ref} />
<div className="c__switch__rail" /> <div className="c__switch__rail" />
</div>
</div> </div>
</Field> </div>
</label> </Field>
); </label>
}, );
); };

View File

@@ -1,5 +1,5 @@
import React, { import React, {
forwardRef, RefAttributes,
TextareaHTMLAttributes, TextareaHTMLAttributes,
useEffect, useEffect,
useRef, useRef,
@@ -11,94 +11,101 @@ import { LabelledBox } from ":/components/Forms/LabelledBox";
import { randomString } from ":/utils"; import { randomString } from ":/utils";
export type TextAreaProps = TextareaHTMLAttributes<HTMLTextAreaElement> & export type TextAreaProps = TextareaHTMLAttributes<HTMLTextAreaElement> &
RefAttributes<HTMLTextAreaElement> &
FieldProps & { FieldProps & {
label?: string; label?: string;
charCounter?: boolean; charCounter?: boolean;
charCounterMax?: number; charCounterMax?: number;
}; };
export const TextArea = forwardRef<HTMLTextAreaElement, TextAreaProps>( export const TextArea = ({
({ label, id, defaultValue, charCounter, charCounterMax, ...props }, ref) => { label,
const areaRef = useRef<HTMLTextAreaElement | null>(null); id,
const [inputFocus, setInputFocus] = useState(false); defaultValue,
const [value, setValue] = useState(defaultValue || props.value || ""); charCounter,
const [labelAsPlaceholder, setLabelAsPlaceholder] = useState(!value); charCounterMax,
const idToUse = useRef(id || randomString()); ref,
const rightTextToUse = charCounter ...props
? `${value.toString().length}/${charCounterMax}` }: TextAreaProps) => {
: props.rightText; const areaRef = useRef<HTMLTextAreaElement | null>(null);
const [inputFocus, setInputFocus] = useState(false);
const [value, setValue] = useState(defaultValue || props.value || "");
const [labelAsPlaceholder, setLabelAsPlaceholder] = useState(!value);
const idToUse = useRef(id || randomString());
const rightTextToUse = charCounter
? `${value.toString().length}/${charCounterMax}`
: props.rightText;
useEffect(() => { useEffect(() => {
if (inputFocus) { if (inputFocus) {
setLabelAsPlaceholder(false); setLabelAsPlaceholder(false);
return; return;
} }
setLabelAsPlaceholder(!value); setLabelAsPlaceholder(!value);
}, [inputFocus, value]); }, [inputFocus, value]);
// If the input is used as a controlled component, we need to update the local value. // If the input is used as a controlled component, we need to update the local value.
useEffect(() => { useEffect(() => {
if (defaultValue !== undefined) { if (defaultValue !== undefined) {
return; return;
} }
setValue(props.value || ""); setValue(props.value || "");
}, [props.value]); }, [props.value]);
const { fullWidth, rightText, text, textItems, className, ...areaProps } = const { fullWidth, rightText, text, textItems, className, ...areaProps } =
props; props;
return ( return (
<Field <Field
{...props} {...props}
className={classNames("c__field--textarea", className)} className={classNames("c__field--textarea", className)}
rightText={rightTextToUse} rightText={rightTextToUse}
>
{/* We disabled linting for this specific line because we consider that the onClick props is only used for */}
{/* mouse users, so this do not engender any issue for accessibility. */}
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */}
<div
className={classNames("c__textarea__wrapper", {
"c__textarea__wrapper--disabled": props.disabled,
})}
onClick={() => areaRef.current?.focus()}
> >
{/* We disabled linting for this specific line because we consider that the onClick props is only used for */} <LabelledBox
{/* mouse users, so this do not engender any issue for accessibility. */} label={label}
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */} htmlFor={idToUse.current}
<div labelAsPlaceholder={labelAsPlaceholder}
className={classNames("c__textarea__wrapper", { disabled={props.disabled}
"c__textarea__wrapper--disabled": props.disabled,
})}
onClick={() => areaRef.current?.focus()}
> >
<LabelledBox <textarea
label={label} className="c__textarea"
htmlFor={idToUse.current} {...areaProps}
labelAsPlaceholder={labelAsPlaceholder} id={idToUse.current}
disabled={props.disabled} onFocus={(e) => {
> setInputFocus(true);
<textarea props.onFocus?.(e);
className="c__textarea" }}
{...areaProps} onBlur={(e) => {
id={idToUse.current} setInputFocus(false);
onFocus={(e) => { props.onBlur?.(e);
setInputFocus(true); }}
props.onFocus?.(e); value={value}
}} onChange={(e) => {
onBlur={(e) => { setValue(e.target.value);
setInputFocus(false); props.onChange?.(e);
props.onBlur?.(e); }}
}} ref={(nativeRef) => {
value={value} if (ref) {
onChange={(e) => { if (typeof ref === "function") {
setValue(e.target.value); ref(nativeRef);
props.onChange?.(e); } else {
}} ref.current = nativeRef;
ref={(nativeRef) => {
if (ref) {
if (typeof ref === "function") {
ref(nativeRef);
} else {
ref.current = nativeRef;
}
} }
areaRef.current = nativeRef; }
}} areaRef.current = nativeRef;
/> }}
</LabelledBox> />
</div> </LabelledBox>
</Field> </div>
); </Field>
}, );
); };

View File

@@ -9,7 +9,7 @@ import classNames from "classnames";
import { useHandleClickOutside } from ":/hooks/useHandleClickOutside"; import { useHandleClickOutside } from ":/hooks/useHandleClickOutside";
export type PopoverProps = PropsWithChildren & { export type PopoverProps = PropsWithChildren & {
parentRef: RefObject<HTMLDivElement>; parentRef: RefObject<HTMLDivElement | null>;
onClickOutside: () => void; onClickOutside: () => void;
borderless?: boolean; borderless?: boolean;
}; };
@@ -22,7 +22,7 @@ export const Popover = ({
}: PopoverProps) => { }: PopoverProps) => {
const popoverRef = useRef<HTMLDivElement>(null); const popoverRef = useRef<HTMLDivElement>(null);
useHandleClickOutside(parentRef, onClickOutside); useHandleClickOutside(parentRef, onClickOutside);
const timeout = useRef<ReturnType<typeof setTimeout>>(); const timeout = useRef<ReturnType<typeof setTimeout>>(null);
const [topPosition, setTopPosition] = useState<number | undefined>(); const [topPosition, setTopPosition] = useState<number | undefined>();
useLayoutEffect(() => { useLayoutEffect(() => {

View File

@@ -25,7 +25,7 @@ export interface ToastProps extends PropsWithChildren {
export const Toast = (props: ToastProps) => { export const Toast = (props: ToastProps) => {
const [animateDisappear, setAnimateDisappear] = React.useState(false); const [animateDisappear, setAnimateDisappear] = React.useState(false);
const container = useRef<HTMLDivElement>(null); const container = useRef<HTMLDivElement>(null);
const disappearTimeout = useRef<NodeJS.Timeout>(); const disappearTimeout = useRef<NodeJS.Timeout>(null);
// Register a timeout to remove the toast after the duration. // Register a timeout to remove the toast after the duration.
useEffect(() => { useEffect(() => {
@@ -34,7 +34,7 @@ export const Toast = (props: ToastProps) => {
} }
disappearTimeout.current = setTimeout(async () => { disappearTimeout.current = setTimeout(async () => {
setAnimateDisappear(true); setAnimateDisappear(true);
disappearTimeout.current = undefined; disappearTimeout.current = null;
}, props.duration); }, props.duration);
return () => { return () => {
if (disappearTimeout.current) { if (disappearTimeout.current) {

View File

@@ -1,4 +1,9 @@
import React, { PropsWithChildren, ReactElement, ReactNode } from "react"; import React, {
PropsWithChildren,
ReactElement,
ReactNode,
RefObject,
} from "react";
import { OverlayArrow } from "react-aria-components"; import { OverlayArrow } from "react-aria-components";
import { import {
mergeProps, mergeProps,
@@ -72,7 +77,11 @@ export const Tooltip = ({
return ( return (
<> <>
{React.cloneElement( {React.cloneElement(
React.Children.toArray(props.children)[0] as ReactElement, React.Children.toArray(props.children)[0] as ReactElement<
typeof useTooltipTriggerRes.triggerProps & {
ref: RefObject<ReactElement | null>;
}
>,
{ {
ref, ref,
...useTooltipTriggerRes.triggerProps, ...useTooltipTriggerRes.triggerProps,

View File

@@ -1,7 +1,7 @@
import { RefObject, useEffect } from "react"; import { RefObject, useEffect } from "react";
export const useHandleClickOutside = ( export const useHandleClickOutside = (
ref: RefObject<HTMLDivElement>, ref: RefObject<HTMLDivElement | null>,
onClickOutside: any, onClickOutside: any,
) => { ) => {
useEffect(() => { useEffect(() => {