Skip to content

Commit 115590b

Browse files
authored
Feature / updated Select component (#64)
* added placeholder, options and updated stories * update Select with Option typesafe * update Select stories * more readable example with typed option values * fixed mistake in controlled exampe of Select * WIP: added typesafe value / defaultValue, need rework on Select stories * updated Select stories according to new type * fix displayName
1 parent 8aac304 commit 115590b

File tree

2 files changed

+210
-156
lines changed

2 files changed

+210
-156
lines changed

src/Select.tsx

Lines changed: 113 additions & 79 deletions
Original file line numberDiff line numberDiff line change
@@ -1,113 +1,147 @@
11
"use client";
22

3-
import React, { memo, forwardRef, ReactNode, useId, type CSSProperties } from "react";
3+
import React, { memo, forwardRef, ReactNode, useId, type CSSProperties, ForwardedRef } from "react";
44
import { symToStr } from "tsafe/symToStr";
55
import { assert } from "tsafe/assert";
66
import type { Equals } from "tsafe";
77
import { fr } from "./fr";
88
import { cx } from "./tools/cx";
99

10-
export type SelectProps = {
10+
export type SelectProps<Options extends GenericOption<DefaultOptionValue>[]> = {
11+
options: Options;
1112
className?: string;
1213
label: ReactNode;
1314
hint?: ReactNode;
14-
nativeSelectProps: React.DetailedHTMLProps<
15-
React.SelectHTMLAttributes<HTMLSelectElement>,
16-
HTMLSelectElement
17-
>;
18-
children: ReactNode;
15+
nativeSelectProps?: Omit<
16+
React.DetailedHTMLProps<React.SelectHTMLAttributes<HTMLSelectElement>, HTMLSelectElement>,
17+
"value" | "defaultValue"
18+
> & {
19+
// Overriding the type of value and defaultValue to only accept the value type of the options
20+
value?: Options[number]["value"];
21+
defaultValue?: Options[number]["value"];
22+
};
1923
/** Default: false */
2024
disabled?: boolean;
2125
/** Default: "default" */
2226
state?: "success" | "error" | "default";
2327
/** The message won't be displayed if state is "default" */
2428
stateRelatedMessage?: ReactNode;
2529
style?: CSSProperties;
30+
placeholder?: string;
2631
};
32+
export type GenericOption<OptionValue> = {
33+
value: OptionValue;
34+
label: string;
35+
disabled?: boolean;
36+
hidden?: boolean;
37+
selected?: boolean;
38+
};
39+
40+
type DefaultOptionValue = string | number | readonly string[] | undefined;
2741

2842
/**
2943
* @see <https://react-dsfr-components.etalab.studio/?path=/docs/components-select>
3044
* */
31-
export const Select = memo(
32-
forwardRef<HTMLDivElement, SelectProps>((props, ref) => {
33-
const {
34-
className,
35-
label,
36-
hint,
37-
nativeSelectProps,
38-
disabled = false,
39-
children,
40-
state = "default",
41-
stateRelatedMessage,
42-
style,
43-
...rest
44-
} = props;
45-
46-
assert<Equals<keyof typeof rest, never>>();
47-
48-
const selectId = `select-${useId()}`;
49-
const stateDescriptionId = `select-${useId()}-desc`;
45+
export const Select = <T extends GenericOption<DefaultOptionValue>[]>(
46+
props: SelectProps<T>,
47+
ref: React.LegacyRef<HTMLDivElement>
48+
) => {
49+
const {
50+
className,
51+
label,
52+
hint,
53+
nativeSelectProps,
54+
disabled = false,
55+
options,
56+
state = "default",
57+
stateRelatedMessage,
58+
placeholder,
59+
style,
60+
...rest
61+
} = props;
5062

51-
return (
52-
<div
53-
className={cx(
54-
fr.cx(
55-
"fr-select-group",
56-
disabled && "fr-select-group--disabled",
63+
assert<Equals<keyof typeof rest, never>>();
64+
const elementId = nativeSelectProps?.id || useId();
65+
const selectId = `select-${elementId}`;
66+
const stateDescriptionId = `select-${elementId}-desc`;
67+
const displayedOptions = placeholder
68+
? [
69+
{
70+
label: placeholder,
71+
value: "",
72+
disabled: true
73+
},
74+
...options
75+
]
76+
: options;
77+
return (
78+
<div
79+
className={cx(
80+
fr.cx(
81+
"fr-select-group",
82+
disabled && "fr-select-group--disabled",
83+
(() => {
84+
switch (state) {
85+
case "error":
86+
return "fr-select-group--error";
87+
case "success":
88+
return "fr-select-group--valid";
89+
case "default":
90+
return undefined;
91+
}
92+
assert<Equals<typeof state, never>>(false);
93+
})()
94+
),
95+
className
96+
)}
97+
ref={ref}
98+
style={style}
99+
{...rest}
100+
>
101+
<label className={fr.cx("fr-label")} htmlFor={selectId}>
102+
{label}
103+
{hint !== undefined && <span className={fr.cx("fr-hint-text")}>{hint}</span>}
104+
</label>
105+
<select
106+
{...nativeSelectProps}
107+
className={cx(fr.cx("fr-select"), nativeSelectProps?.className)}
108+
id={selectId}
109+
aria-describedby={stateDescriptionId}
110+
disabled={disabled}
111+
>
112+
{displayedOptions.map(option => (
113+
<option {...option}>{option.label}</option>
114+
))}
115+
</select>
116+
{state !== "default" && (
117+
<p
118+
id={stateDescriptionId}
119+
className={fr.cx(
57120
(() => {
58121
switch (state) {
59122
case "error":
60-
return "fr-select-group--error";
123+
return "fr-error-text";
61124
case "success":
62-
return "fr-select-group--valid";
63-
case "default":
64-
return undefined;
125+
return "fr-valid-text";
65126
}
66127
assert<Equals<typeof state, never>>(false);
67128
})()
68-
),
69-
className
70-
)}
71-
ref={ref}
72-
style={style}
73-
{...rest}
74-
>
75-
<label className={fr.cx("fr-label")} htmlFor={selectId}>
76-
{label}
77-
{hint !== undefined && <span className={fr.cx("fr-hint-text")}>{hint}</span>}
78-
</label>
79-
<select
80-
{...nativeSelectProps}
81-
className={cx(fr.cx("fr-select"), nativeSelectProps.className)}
82-
id={selectId}
83-
aria-describedby={stateDescriptionId}
84-
disabled={disabled}
129+
)}
85130
>
86-
{children}
87-
</select>
88-
{state !== "default" && (
89-
<p
90-
id={stateDescriptionId}
91-
className={fr.cx(
92-
(() => {
93-
switch (state) {
94-
case "error":
95-
return "fr-error-text";
96-
case "success":
97-
return "fr-valid-text";
98-
}
99-
assert<Equals<typeof state, never>>(false);
100-
})()
101-
)}
102-
>
103-
{stateRelatedMessage}
104-
</p>
105-
)}
106-
</div>
107-
);
108-
})
109-
);
131+
{stateRelatedMessage}
132+
</p>
133+
)}
134+
</div>
135+
);
136+
};
110137

111-
Select.displayName = symToStr({ Select });
138+
const ForwardedSelect = forwardRef(Select) as <T extends GenericOption<DefaultOptionValue>[]>(
139+
props: SelectProps<T> & { ref?: ForwardedRef<HTMLDivElement> }
140+
) => ReturnType<typeof Select>;
141+
142+
const MemoizedSelect = memo(ForwardedSelect) as typeof ForwardedSelect & {
143+
displayName: string;
144+
};
145+
MemoizedSelect.displayName = symToStr({ Select });
112146

113-
export default Select;
147+
export default MemoizedSelect;

0 commit comments

Comments
 (0)