Skip to content

Commit efe445f

Browse files
committed
feat: introduce StatelessSelect
1 parent cc36077 commit efe445f

File tree

4 files changed

+192
-5
lines changed

4 files changed

+192
-5
lines changed
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
import { ChevronDownIcon } from '@heroicons/react/24/solid';
2+
import classNames from 'classnames';
3+
import { useId, useMemo } from 'react';
4+
5+
import type { SelectGroup, SelectProps } from '#ui/Common/Select';
6+
import { isStringArray, isValuesArray } from '#ui/Common/Select';
7+
8+
import styles from '../index.module.css';
9+
10+
export type StatelessSelectProps<T extends string> = Omit<
11+
SelectProps<T>,
12+
'onChange' | 'loading'
13+
> & {
14+
targetElement: string;
15+
};
16+
17+
const StatelessSelect = <T extends string>({
18+
values = [],
19+
defaultValue,
20+
placeholder,
21+
label,
22+
inline,
23+
className,
24+
ariaLabel,
25+
disabled = false,
26+
as: Component = 'div',
27+
targetElement,
28+
}: StatelessSelectProps<T>) => {
29+
const id = useId();
30+
31+
const mappedValues = useMemo(() => {
32+
let mappedValues = values;
33+
34+
if (isStringArray(mappedValues)) {
35+
mappedValues = mappedValues.map(value => ({
36+
label: value,
37+
value: value,
38+
}));
39+
}
40+
41+
if (isValuesArray(mappedValues)) {
42+
return [{ items: mappedValues }];
43+
}
44+
45+
return mappedValues as Array<SelectGroup<T>>;
46+
}, [values]);
47+
48+
// Find the current/default item to display in summary
49+
const currentItem = useMemo(
50+
() =>
51+
mappedValues
52+
.flatMap(({ items }) => items)
53+
.find(item => item.value === defaultValue),
54+
[mappedValues, defaultValue]
55+
);
56+
57+
return (
58+
<noscript>
59+
<style>{`.${targetElement} { display: none!important; }`}</style>
60+
<div
61+
className={classNames(
62+
styles.select,
63+
styles.noscript,
64+
{ [styles.inline]: inline },
65+
className
66+
)}
67+
>
68+
{label && (
69+
<label className={styles.label} htmlFor={id}>
70+
{label}
71+
</label>
72+
)}
73+
74+
<details className={styles.trigger} id={id}>
75+
<summary
76+
className={styles.summary}
77+
aria-label={ariaLabel}
78+
aria-disabled={disabled}
79+
>
80+
{currentItem ? (
81+
<span className={styles.selectedValue}>
82+
{currentItem.iconImage}
83+
<span>{currentItem.label}</span>
84+
</span>
85+
) : (
86+
<span className={styles.placeholder}>{placeholder}</span>
87+
)}
88+
<ChevronDownIcon className={styles.icon} />
89+
</summary>
90+
91+
<div
92+
className={classNames(styles.dropdown, { [styles.inline]: inline })}
93+
>
94+
{mappedValues.map(({ label: groupLabel, items }, groupKey) => (
95+
<div
96+
key={groupLabel?.toString() ?? groupKey}
97+
className={styles.group}
98+
>
99+
{groupLabel && (
100+
<div className={classNames(styles.item, styles.label)}>
101+
{groupLabel}
102+
</div>
103+
)}
104+
105+
{items.map(
106+
({ value, label, iconImage, disabled: itemDisabled }) => (
107+
<Component
108+
key={value}
109+
href={value}
110+
className={classNames(styles.item, styles.text, {
111+
[styles.disabled]: itemDisabled || disabled,
112+
[styles.selected]: value === defaultValue,
113+
})}
114+
aria-disabled={itemDisabled || disabled}
115+
>
116+
{iconImage}
117+
<span>{label}</span>
118+
</Component>
119+
)
120+
)}
121+
</div>
122+
))}
123+
</div>
124+
</details>
125+
</div>
126+
</noscript>
127+
);
128+
};
129+
130+
export default StatelessSelect;

packages/ui-components/src/Common/Select/index.module.css

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,3 +159,40 @@
159159
text-neutral-700
160160
dark:text-neutral-200;
161161
}
162+
163+
.noscript {
164+
summary {
165+
@apply flex
166+
w-full
167+
justify-between;
168+
}
169+
170+
.dropdown {
171+
@apply absolute
172+
left-4
173+
mt-6;
174+
}
175+
176+
.text {
177+
@apply hover:outline-hidden
178+
block
179+
whitespace-normal
180+
pl-4
181+
text-neutral-800
182+
hover:bg-green-500
183+
hover:text-white
184+
dark:text-neutral-200
185+
dark:hover:bg-green-600
186+
dark:hover:text-white;
187+
188+
span {
189+
@apply h-auto;
190+
}
191+
}
192+
193+
.inline {
194+
.text {
195+
@apply pl-2.5;
196+
}
197+
}
198+
}

packages/ui-components/src/Common/Select/index.tsx

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,9 @@ import classNames from 'classnames';
66
import { useEffect, useId, useMemo, useState } from 'react';
77
import type { ReactElement, ReactNode } from 'react';
88

9+
import StatelessSelect from '#ui/Common/Select/StatelessSelect';
910
import Skeleton from '#ui/Common/Skeleton';
10-
import type { FormattedMessage } from '#ui/types';
11+
import type { FormattedMessage, LinkLike } from '#ui/types';
1112

1213
import styles from './index.module.css';
1314

@@ -23,15 +24,17 @@ export type SelectGroup<T extends string> = {
2324
items: Array<SelectValue<T>>;
2425
};
2526

26-
const isStringArray = (values: Array<unknown>): values is Array<string> =>
27+
export const isStringArray = (
28+
values: Array<unknown>
29+
): values is Array<string> =>
2730
Boolean(values[0] && typeof values[0] === 'string');
2831

29-
const isValuesArray = <T extends string>(
32+
export const isValuesArray = <T extends string>(
3033
values: Array<unknown>
3134
): values is Array<SelectValue<T>> =>
3235
Boolean(values[0] && typeof values[0] === 'object' && 'value' in values[0]);
3336

34-
type SelectProps<T extends string> = {
37+
export type SelectProps<T extends string> = {
3538
values: Array<SelectGroup<T>> | Array<T> | Array<SelectValue<T>>;
3639
defaultValue?: T;
3740
placeholder?: string;
@@ -42,6 +45,7 @@ type SelectProps<T extends string> = {
4245
ariaLabel?: string;
4346
loading?: boolean;
4447
disabled?: boolean;
48+
as?: LinkLike | 'div';
4549
};
4650

4751
const Select = <T extends string>({
@@ -55,8 +59,10 @@ const Select = <T extends string>({
5559
ariaLabel,
5660
loading = false,
5761
disabled = false,
62+
as,
5863
}: SelectProps<T>): ReactNode => {
5964
const id = useId();
65+
const uniqueSelectClass = `select-${id.replace(/[^a-zA-Z0-9]/g, '')}`;
6066
const [value, setValue] = useState(defaultValue);
6167

6268
useEffect(() => setValue(defaultValue), [defaultValue]);
@@ -133,7 +139,8 @@ const Select = <T extends string>({
133139
className={classNames(
134140
styles.select,
135141
{ [styles.inline]: inline },
136-
className
142+
className,
143+
uniqueSelectClass
137144
)}
138145
>
139146
{label && (
@@ -183,6 +190,18 @@ const Select = <T extends string>({
183190
</SelectPrimitive.Portal>
184191
</SelectPrimitive.Root>
185192
</span>
193+
<StatelessSelect
194+
values={values}
195+
defaultValue={defaultValue}
196+
placeholder={placeholder}
197+
label={label}
198+
inline={inline}
199+
className={className}
200+
ariaLabel={ariaLabel}
201+
disabled={disabled}
202+
targetElement={uniqueSelectClass}
203+
as={as}
204+
/>
186205
</Skeleton>
187206
);
188207
};

packages/ui-components/src/Containers/Sidebar/index.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ const SideBar: FC<PropsWithChildren<SidebarProps>> = ({
4949
placeholder={placeholder}
5050
onChange={onSelect}
5151
className={styles.mobileSelect}
52+
as={as}
5253
/>
5354
)}
5455

0 commit comments

Comments
 (0)