Skip to content

Commit 458d11d

Browse files
committed
Add undocumented Select
1 parent 149460f commit 458d11d

File tree

4 files changed

+182
-40
lines changed

4 files changed

+182
-40
lines changed

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,7 @@
138138
"./Summary": "./dist/Summary.js",
139139
"./Stepper": "./dist/Stepper.js",
140140
"./SkipLinks": "./dist/SkipLinks.js",
141+
"./Select": "./dist/Select.js",
141142
"./SearchBar": "./dist/SearchBar.js",
142143
"./Quote": "./dist/Quote.js",
143144
"./Pagination": "./dist/Pagination.js",

src/Input.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,11 @@ export const Input = memo(
108108
ref={ref}
109109
{...rest}
110110
>
111-
<label className={cx(fr.cx("fr-label"), classes.label)} htmlFor={inputId}>
111+
<label
112+
className={cx(fr.cx("fr-label"), classes.label)}
113+
htmlFor={inputId}
114+
aria-describedby="select-valid-desc-valid"
115+
>
112116
{label}
113117
{hintText !== undefined && <span className="fr-hint-text">{hintText}</span>}
114118
</label>

src/Select.tsx

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
"use client";
2+
3+
import React, { memo, forwardRef, ReactNode, useId } from "react";
4+
import { symToStr } from "tsafe/symToStr";
5+
import { assert } from "tsafe/assert";
6+
import type { Equals } from "tsafe";
7+
import { fr } from "./fr";
8+
import { cx } from "./tools/cx";
9+
10+
export type SelectProps = {
11+
className?: string;
12+
label: ReactNode;
13+
nativeSelectProps: React.DetailedHTMLProps<
14+
React.SelectHTMLAttributes<HTMLSelectElement>,
15+
HTMLSelectElement
16+
>;
17+
children: ReactNode;
18+
/** Default: false */
19+
disabled?: boolean;
20+
/** Default: "default" */
21+
state?: "success" | "error" | "default";
22+
/** The message won't be displayed if state is "default" */
23+
stateRelatedMessage?: ReactNode;
24+
};
25+
26+
/**
27+
* @see <https://react-dsfr-components.etalab.studio/?path=/docs/components-select>
28+
* */
29+
export const Select = memo(
30+
forwardRef<HTMLDivElement, SelectProps>((props, ref) => {
31+
const {
32+
className,
33+
label,
34+
nativeSelectProps,
35+
disabled = false,
36+
children,
37+
state = "default",
38+
stateRelatedMessage,
39+
...rest
40+
} = props;
41+
42+
assert<Equals<keyof typeof rest, never>>();
43+
44+
const selectId = `select-${useId()}`;
45+
const stateDescriptionId = `select-${useId()}-desc`;
46+
47+
return (
48+
<div
49+
className={cx(
50+
fr.cx(
51+
"fr-select-group",
52+
disabled && "fr-select-group--disabled",
53+
(() => {
54+
switch (state) {
55+
case "error":
56+
return "fr-select-group--error";
57+
case "success":
58+
return "fr-select-group--valid";
59+
case "default":
60+
return undefined;
61+
}
62+
assert<Equals<typeof state, never>>(false);
63+
})()
64+
),
65+
className
66+
)}
67+
ref={ref}
68+
{...rest}
69+
>
70+
<label className={fr.cx("fr-label")} htmlFor={selectId}>
71+
{label}
72+
</label>
73+
<select
74+
{...nativeSelectProps}
75+
className={cx(fr.cx("fr-select"), nativeSelectProps.className)}
76+
id={selectId}
77+
aria-describedby={stateDescriptionId}
78+
>
79+
{children}
80+
</select>
81+
{state !== "default" && (
82+
<p
83+
id={stateDescriptionId}
84+
className={fr.cx(
85+
"fr-valid-text",
86+
(() => {
87+
switch (state) {
88+
case "error":
89+
return "fr-error-text";
90+
case "success":
91+
return "fr-valid-text";
92+
}
93+
assert<Equals<typeof state, never>>(false);
94+
})()
95+
)}
96+
>
97+
{stateRelatedMessage}
98+
</p>
99+
)}
100+
</div>
101+
);
102+
})
103+
);
104+
105+
Select.displayName = symToStr({ Select });
106+
107+
export default Select;

stories/ColorHelper/Search.tsx

Lines changed: 69 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,9 @@ export function Search(props: Props) {
2020
const [searchBarWrapperElement, setSearchBarWrapperElement] = useState<HTMLDivElement | null>(
2121
null
2222
);
23+
const [filtersWrapperDivElement, setFiltersWrapperDivElement] = useState<HTMLDivElement | null>(
24+
null
25+
);
2326

2427
useEvt(
2528
ctx => {
@@ -38,50 +41,77 @@ export function Search(props: Props) {
3841
[evtAction, inputElement, searchBarWrapperElement]
3942
);
4043

41-
const { classes, cx } = useStyles();
44+
const [areFiltersOpen, setAreFiltersOpen] = useState(false);
4245

43-
const [isFiltersOpen, setIsFiltersOpen] = useState(false);
46+
const { classes, cx } = useStyles({
47+
"filterWrapperMaxHeight": areFiltersOpen ? filtersWrapperDivElement?.scrollHeight ?? 0 : 0
48+
});
4449

4550
return (
46-
<div
47-
className={cx(classes.root, className)}
48-
ref={searchBarWrapperElement => setSearchBarWrapperElement(searchBarWrapperElement)}
49-
>
50-
<SearchBar
51-
className={classes.searchBar}
52-
label="Filter by color code (e.g. #c9191e), CSS variable name (e.g. --text-active-red-marianne) or something else (e.g. marianne)..."
53-
nativeInputProps={{
54-
"ref": inputElement => setInputElement(inputElement),
55-
"value": search,
56-
"onChange": event => onSearchChange(event.target.value)
57-
}}
58-
/>
59-
<Button
60-
className={classes.filterButton}
61-
iconId={isFiltersOpen ? "ri-arrow-up-s-fill" : "ri-arrow-down-s-fill"}
62-
iconPosition="right"
63-
onClick={() => setIsFiltersOpen(!isFiltersOpen)}
51+
<>
52+
<div
53+
className={cx(classes.root, className)}
54+
ref={searchBarWrapperElement => setSearchBarWrapperElement(searchBarWrapperElement)}
6455
>
65-
Filters
66-
</Button>
67-
</div>
56+
<SearchBar
57+
className={classes.searchBar}
58+
label="Filter by color code (e.g. #c9191e), CSS variable name (e.g. --text-active-red-marianne) or something else (e.g. marianne)..."
59+
nativeInputProps={{
60+
"ref": inputElement => setInputElement(inputElement),
61+
"value": search,
62+
"onChange": event => onSearchChange(event.target.value)
63+
}}
64+
/>
65+
66+
<Button
67+
className={classes.filterButton}
68+
iconId={areFiltersOpen ? "ri-arrow-down-s-fill" : "ri-arrow-up-s-fill"}
69+
iconPosition="right"
70+
onClick={() => setAreFiltersOpen(!areFiltersOpen)}
71+
>
72+
Filters
73+
</Button>
74+
</div>
75+
<div
76+
ref={filtersWrapperDivElement =>
77+
setFiltersWrapperDivElement(filtersWrapperDivElement)
78+
}
79+
className={classes.filtersWrapper}
80+
>
81+
{/*
82+
<p>Ok</p>
83+
<p>Ok</p>
84+
<p>Ok</p>
85+
<p>Ok</p>
86+
<p>Ok</p>
87+
*/}
88+
</div>
89+
</>
6890
);
6991
}
7092

71-
const useStyles = makeStyles({ "name": { Search } })(theme => ({
72-
"root": {
73-
"display": "flex",
74-
"paddingTop": fr.spacing("6v")
75-
},
76-
"searchBar": {
77-
"flex": 1
78-
},
79-
"filterButton": {
80-
"backgroundColor": theme.decisions.background.actionLow.blueFrance.default,
81-
"&&&:hover": {
82-
"backgroundColor": theme.decisions.background.actionLow.blueFrance.hover
93+
const useStyles = makeStyles<{ filterWrapperMaxHeight: number }>({ "name": { Search } })(
94+
(theme, { filterWrapperMaxHeight }) => ({
95+
"root": {
96+
"display": "flex",
97+
"paddingTop": fr.spacing("6v")
98+
},
99+
"searchBar": {
100+
"flex": 1
101+
},
102+
"filterButton": {
103+
"backgroundColor": theme.decisions.background.actionLow.blueFrance.default,
104+
"&&&:hover": {
105+
"backgroundColor": theme.decisions.background.actionLow.blueFrance.hover
106+
},
107+
"color": theme.decisions.text.actionHigh.blueFrance.default,
108+
"marginLeft": fr.spacing("4v"),
109+
"display": "none"
83110
},
84-
"color": theme.decisions.text.actionHigh.blueFrance.default,
85-
"marginLeft": fr.spacing("4v")
86-
}
87-
}));
111+
"filtersWrapper": {
112+
"transition": "max-height 0.2s ease-out",
113+
"maxHeight": filterWrapperMaxHeight,
114+
"overflow": "hidden"
115+
}
116+
})
117+
);

0 commit comments

Comments
 (0)