Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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