Skip to content

Commit f1ce7ad

Browse files
authored
feat: introduce StatelessSelect (#7976)
* feat: introduce StatelessSelect * refactor: review updates * chore: review updates * fix: use conditional rendering for select value display
1 parent 8194265 commit f1ce7ad

File tree

9 files changed

+229
-18
lines changed

9 files changed

+229
-18
lines changed

apps/site/components/Downloads/Release/VersionDropdown.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
'use client';
22

3-
import Select from '@node-core/ui-components/Common/Select';
3+
import WithNoScriptSelect from '@node-core/ui-components/Common/Select/NoScriptSelect';
44
import { useLocale, useTranslations } from 'next-intl';
55
import type { FC } from 'react';
66
import { useContext } from 'react';
@@ -51,7 +51,7 @@ const VersionDropdown: FC = () => {
5151
};
5252

5353
return (
54-
<Select
54+
<WithNoScriptSelect
5555
ariaLabel={t('layouts.download.dropdown.version')}
5656
values={releases.map(({ status, versionWithPrefix }) => ({
5757
value: versionWithPrefix,
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { useId } from 'react';
2+
3+
import Select from '#ui/Common/Select';
4+
import type { StatelessSelectProps } from '#ui/Common/Select/StatelessSelect';
5+
import StatelessSelect from '#ui/Common/Select/StatelessSelect';
6+
7+
const WithNoScriptSelect = <T extends string>({
8+
as,
9+
...props
10+
}: StatelessSelectProps<T>) => {
11+
const id = useId();
12+
const selectId = `select-${id.replace(/[^a-zA-Z0-9]/g, '')}`;
13+
14+
return (
15+
<>
16+
<Select {...props} fallbackClass={selectId} />
17+
<noscript>
18+
<style>{`.${selectId} { display: none!important; }`}</style>
19+
<StatelessSelect {...props} as={as} />
20+
</noscript>
21+
</>
22+
);
23+
};
24+
25+
export default WithNoScriptSelect;
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
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 type { LinkLike } from '#ui/types';
7+
import { isStringArray, isValuesArray } from '#ui/util/array';
8+
9+
import styles from '../index.module.css';
10+
11+
type StatelessSelectConfig = {
12+
as?: LinkLike | 'div';
13+
};
14+
15+
export type StatelessSelectProps<T extends string> = SelectProps<T> &
16+
StatelessSelectConfig;
17+
18+
const StatelessSelect = <T extends string>({
19+
values = [],
20+
defaultValue,
21+
placeholder,
22+
label,
23+
inline,
24+
className,
25+
ariaLabel,
26+
disabled = false,
27+
as: Component = 'div',
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]) as Array<SelectGroup<T>>;
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+
<div
59+
className={classNames(
60+
styles.select,
61+
styles.noscript,
62+
{ [styles.inline]: inline },
63+
className
64+
)}
65+
>
66+
{label && (
67+
<label className={styles.label} htmlFor={id}>
68+
{label}
69+
</label>
70+
)}
71+
72+
<details className={styles.trigger} id={id}>
73+
<summary
74+
className={styles.summary}
75+
aria-label={ariaLabel}
76+
aria-disabled={disabled}
77+
>
78+
{currentItem && (
79+
<span className={styles.selectedValue}>
80+
{currentItem.iconImage}
81+
<span>{currentItem.label}</span>
82+
</span>
83+
)}
84+
{!currentItem && (
85+
<span className={styles.placeholder}>{placeholder}</span>
86+
)}
87+
<ChevronDownIcon className={styles.icon} />
88+
</summary>
89+
90+
<div
91+
className={classNames(styles.dropdown, { [styles.inline]: inline })}
92+
>
93+
{mappedValues.map(({ label: groupLabel, items }, groupKey) => (
94+
<div
95+
key={groupLabel?.toString() ?? groupKey}
96+
className={styles.group}
97+
>
98+
{groupLabel && (
99+
<div className={classNames(styles.item, styles.label)}>
100+
{groupLabel}
101+
</div>
102+
)}
103+
104+
{items.map(
105+
({ value, label, iconImage, disabled: itemDisabled }) => (
106+
<Component
107+
key={value}
108+
href={value}
109+
className={classNames(styles.item, styles.text, {
110+
[styles.disabled]: itemDisabled || disabled,
111+
[styles.selected]: value === defaultValue,
112+
})}
113+
aria-disabled={itemDisabled || disabled}
114+
>
115+
{iconImage}
116+
<span>{label}</span>
117+
</Component>
118+
)
119+
)}
120+
</div>
121+
))}
122+
</div>
123+
</details>
124+
</div>
125+
);
126+
};
127+
128+
export default StatelessSelect;

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

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

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

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { Meta as MetaObj, StoryObj } from '@storybook/react';
22

33
import Select from '#ui/Common/Select';
4+
import StatelessSelect from '#ui/Common/Select/StatelessSelect';
45
import * as OSIcons from '#ui/Icons/OperatingSystem';
56

67
type Story = StoryObj<typeof Select>;
@@ -108,4 +109,13 @@ export const InlineSelect: Story = {
108109
},
109110
};
110111

112+
export const WithNoScriptSelect: Story = {
113+
render: () => (
114+
<StatelessSelect
115+
values={Array.from({ length: 100 }, (_, i) => `Item ${i}`)}
116+
defaultValue="Item 50"
117+
/>
118+
),
119+
};
120+
111121
export default { component: Select } as Meta;

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

Lines changed: 10 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@ import { useEffect, useId, useMemo, useState } from 'react';
77
import type { ReactElement, ReactNode } from 'react';
88

99
import Skeleton from '#ui/Common/Skeleton';
10-
import type { FormattedMessage } from '#ui/types';
10+
import type { FormattedMessage, LinkLike } from '#ui/types';
11+
import { isStringArray, isValuesArray } from '#ui/util/array';
1112

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

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

26-
const isStringArray = (values: Array<unknown>): values is Array<string> =>
27-
Boolean(values[0] && typeof values[0] === 'string');
28-
29-
const isValuesArray = <T extends string>(
30-
values: Array<unknown>
31-
): values is Array<SelectValue<T>> =>
32-
Boolean(values[0] && typeof values[0] === 'object' && 'value' in values[0]);
33-
34-
type SelectProps<T extends string> = {
27+
export type SelectProps<T extends string> = {
3528
values: Array<SelectGroup<T>> | Array<T> | Array<SelectValue<T>>;
3629
defaultValue?: T;
3730
placeholder?: string;
@@ -48,6 +41,8 @@ type SelectProps<T extends string> = {
4841
ariaLabel?: string;
4942
loading?: boolean;
5043
disabled?: boolean;
44+
fallbackClass?: string;
45+
as?: LinkLike | 'div';
5146
};
5247

5348
const Select = <T extends string>({
@@ -62,6 +57,7 @@ const Select = <T extends string>({
6257
ariaLabel,
6358
loading = false,
6459
disabled = false,
60+
fallbackClass = '',
6561
}: SelectProps<T>): ReactNode => {
6662
const id = useId();
6763
const [value, setValue] = useState(defaultValue);
@@ -82,8 +78,8 @@ const Select = <T extends string>({
8278
return [{ items: mappedValues }];
8379
}
8480

85-
return mappedValues as Array<SelectGroup<T>>;
86-
}, [values]);
81+
return mappedValues;
82+
}, [values]) as Array<SelectGroup<T>>;
8783

8884
// We render the actual item slotted to fix/prevent the issue
8985
// of the tirgger flashing on the initial render
@@ -140,7 +136,8 @@ const Select = <T extends string>({
140136
className={classNames(
141137
styles.select,
142138
{ [styles.inline]: inline },
143-
className
139+
className,
140+
fallbackClass
144141
)}
145142
>
146143
{label && (

packages/ui-components/src/Containers/Sidebar/index.module.css

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,12 @@
55
w-full
66
flex-col
77
gap-8
8-
overflow-auto
98
border-r-0
109
border-neutral-200
1110
bg-white
1211
px-4
1312
py-6
13+
sm:overflow-auto
1414
sm:border-r
1515
md:max-w-xs
1616
lg:px-6

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

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import type { ComponentProps, FC, PropsWithChildren } from 'react';
22

3-
import Select from '#ui/Common/Select';
3+
import WithNoScriptSelect from '#ui/Common/Select/NoScriptSelect';
44
import SidebarGroup from '#ui/Containers/Sidebar/SidebarGroup';
55
import type { LinkLike } from '#ui/types';
66

@@ -42,13 +42,14 @@ const SideBar: FC<PropsWithChildren<SidebarProps>> = ({
4242
{children}
4343

4444
{selectItems.length > 0 && (
45-
<Select
45+
<WithNoScriptSelect
4646
label={title}
4747
values={selectItems}
4848
defaultValue={currentItem?.value}
4949
placeholder={placeholder}
5050
onChange={onSelect}
5151
className={styles.mobileSelect}
52+
as={as}
5253
/>
5354
)}
5455

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
export const isStringArray = (
2+
values: Array<unknown>
3+
): values is Array<string> =>
4+
Boolean(values[0] && typeof values[0] === 'string');
5+
6+
export const isValuesArray = <T>(values: Array<unknown>): values is Array<T> =>
7+
Boolean(values[0] && typeof values[0] === 'object' && 'value' in values[0]);

0 commit comments

Comments
 (0)