Skip to content

feat: introduce StatelessSelect #7976

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions apps/site/components/Downloads/Release/VersionDropdown.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
'use client';

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

return (
<Select
<WithNoScriptSelect
ariaLabel={t('layouts.download.dropdown.version')}
values={releases.map(({ status, versionWithPrefix }) => ({
value: versionWithPrefix,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { useId } from 'react';

import Select from '#ui/Common/Select';
import type { StatelessSelectProps } from '#ui/Common/Select/StatelessSelect';
import StatelessSelect from '#ui/Common/Select/StatelessSelect';

const WithNoScriptSelect = <T extends string>({
as,
...props
}: StatelessSelectProps<T>) => {
const id = useId();
const selectId = `select-${id.replace(/[^a-zA-Z0-9]/g, '')}`;

return (
<>
<Select {...props} fallbackClass={selectId} />
<noscript>
<style>{`.${selectId} { display: none!important; }`}</style>
<StatelessSelect {...props} as={as} />
</noscript>
</>
);
};

export default WithNoScriptSelect;
127 changes: 127 additions & 0 deletions packages/ui-components/src/Common/Select/StatelessSelect/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
import { ChevronDownIcon } from '@heroicons/react/24/solid';
import classNames from 'classnames';
import { useId, useMemo } from 'react';

import type { SelectGroup, SelectProps } from '#ui/Common/Select';
import type { LinkLike } from '#ui/types';
import { isStringArray, isValuesArray } from '#ui/util/array';

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

type StatelessSelectConfig = {
as?: LinkLike | 'div';
};

export type StatelessSelectProps<T extends string> = SelectProps<T> &
StatelessSelectConfig;

const StatelessSelect = <T extends string>({
values = [],
defaultValue,
placeholder,
label,
inline,
className,
ariaLabel,
disabled = false,
as: Component = 'div',
}: StatelessSelectProps<T>) => {
const id = useId();

const mappedValues = useMemo(() => {
let mappedValues = values;

if (isStringArray(mappedValues)) {
mappedValues = mappedValues.map(value => ({
label: value,
value: value,
}));
}

if (isValuesArray(mappedValues)) {
return [{ items: mappedValues }];
}

return mappedValues as Array<SelectGroup<T>>;
}, [values]) as Array<SelectGroup<T>>;

// Find the current/default item to display in summary
const currentItem = useMemo(
() =>
mappedValues
.flatMap(({ items }) => items)
.find(item => item.value === defaultValue),
[mappedValues, defaultValue]
);

return (
<div
className={classNames(
styles.select,
styles.noscript,
{ [styles.inline]: inline },
className
)}
>
{label && (
<label className={styles.label} htmlFor={id}>
{label}
</label>
)}

<details className={styles.trigger} id={id}>
<summary
className={styles.summary}
aria-label={ariaLabel}
aria-disabled={disabled}
>
{currentItem ? (
<span className={styles.selectedValue}>
{currentItem.iconImage}
<span>{currentItem.label}</span>
</span>
) : (
<span className={styles.placeholder}>{placeholder}</span>
)}
<ChevronDownIcon className={styles.icon} />
</summary>

<div
className={classNames(styles.dropdown, { [styles.inline]: inline })}
>
{mappedValues.map(({ label: groupLabel, items }, groupKey) => (
<div
key={groupLabel?.toString() ?? groupKey}
className={styles.group}
>
{groupLabel && (
<div className={classNames(styles.item, styles.label)}>
{groupLabel}
</div>
)}

{items.map(
({ value, label, iconImage, disabled: itemDisabled }) => (
<Component
key={value}
href={value}
className={classNames(styles.item, styles.text, {
[styles.disabled]: itemDisabled || disabled,
[styles.selected]: value === defaultValue,
})}
aria-disabled={itemDisabled || disabled}
>
{iconImage}
<span>{label}</span>
</Component>
)
)}
</div>
))}
</div>
</details>
</div>
);
};

export default StatelessSelect;
43 changes: 43 additions & 0 deletions packages/ui-components/src/Common/Select/index.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -159,3 +159,46 @@
text-neutral-700
dark:text-neutral-200;
}

.noscript {
@apply relative;

summary {
@apply flex
w-full
justify-between;
}

.trigger {
@apply block;
}

.dropdown {
@apply absolute
left-0
mt-4;
}

.text {
@apply hover:outline-hidden
block
whitespace-normal
pl-4
text-neutral-800
hover:bg-green-500
hover:text-white
dark:text-neutral-200
dark:hover:bg-green-600
dark:hover:text-white;

span {
@apply h-auto;
}
}

.inline {
.text {
@apply pl-2.5;
}
}
}
10 changes: 10 additions & 0 deletions packages/ui-components/src/Common/Select/index.stories.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { Meta as MetaObj, StoryObj } from '@storybook/react';

import Select from '#ui/Common/Select';
import StatelessSelect from '#ui/Common/Select/StatelessSelect';
import * as OSIcons from '#ui/Icons/OperatingSystem';

type Story = StoryObj<typeof Select>;
Expand Down Expand Up @@ -108,4 +109,13 @@ export const InlineSelect: Story = {
},
};

export const WithNoScriptSelect: Story = {
render: () => (
<StatelessSelect
values={Array.from({ length: 100 }, (_, i) => `Item ${i}`)}
defaultValue="Item 50"
/>
),
};

export default { component: Select } as Meta;
23 changes: 10 additions & 13 deletions packages/ui-components/src/Common/Select/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@ import { useEffect, useId, useMemo, useState } from 'react';
import type { ReactElement, ReactNode } from 'react';

import Skeleton from '#ui/Common/Skeleton';
import type { FormattedMessage } from '#ui/types';
import type { FormattedMessage, LinkLike } from '#ui/types';
import { isStringArray, isValuesArray } from '#ui/util/array';

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

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

const isStringArray = (values: Array<unknown>): values is Array<string> =>
Boolean(values[0] && typeof values[0] === 'string');

const isValuesArray = <T extends string>(
values: Array<unknown>
): values is Array<SelectValue<T>> =>
Boolean(values[0] && typeof values[0] === 'object' && 'value' in values[0]);

type SelectProps<T extends string> = {
export type SelectProps<T extends string> = {
values: Array<SelectGroup<T>> | Array<T> | Array<SelectValue<T>>;
defaultValue?: T;
placeholder?: string;
Expand All @@ -42,6 +35,8 @@ type SelectProps<T extends string> = {
ariaLabel?: string;
loading?: boolean;
disabled?: boolean;
fallbackClass?: string;
as?: LinkLike | 'div';
};

const Select = <T extends string>({
Expand All @@ -55,6 +50,7 @@ const Select = <T extends string>({
ariaLabel,
loading = false,
disabled = false,
fallbackClass = '',
}: SelectProps<T>): ReactNode => {
const id = useId();
const [value, setValue] = useState(defaultValue);
Expand All @@ -75,8 +71,8 @@ const Select = <T extends string>({
return [{ items: mappedValues }];
}

return mappedValues as Array<SelectGroup<T>>;
}, [values]);
return mappedValues;
}, [values]) as Array<SelectGroup<T>>;

// We render the actual item slotted to fix/prevent the issue
// of the tirgger flashing on the initial render
Expand Down Expand Up @@ -133,7 +129,8 @@ const Select = <T extends string>({
className={classNames(
styles.select,
{ [styles.inline]: inline },
className
className,
fallbackClass
)}
>
{label && (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,12 @@
w-full
flex-col
gap-8
overflow-auto
border-r-0
border-neutral-200
bg-white
px-4
py-6
sm:overflow-auto
sm:border-r
md:max-w-xs
lg:px-6
Expand Down
5 changes: 3 additions & 2 deletions packages/ui-components/src/Containers/Sidebar/index.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { ComponentProps, FC, PropsWithChildren } from 'react';

import Select from '#ui/Common/Select';
import WithNoScriptSelect from '#ui/Common/Select/NoScriptSelect';
import SidebarGroup from '#ui/Containers/Sidebar/SidebarGroup';
import type { LinkLike } from '#ui/types';

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

{selectItems.length > 0 && (
<Select
<WithNoScriptSelect
label={title}
values={selectItems}
defaultValue={currentItem?.value}
placeholder={placeholder}
onChange={onSelect}
className={styles.mobileSelect}
as={as}
/>
)}

Expand Down
7 changes: 7 additions & 0 deletions packages/ui-components/src/util/array.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export const isStringArray = (
values: Array<unknown>
): values is Array<string> =>
Boolean(values[0] && typeof values[0] === 'string');

export const isValuesArray = <T>(values: Array<unknown>): values is Array<T> =>
Boolean(values[0] && typeof values[0] === 'object' && 'value' in values[0]);
Loading