Add Select component
Signed-off-by: Charles de Dreuille <charles.dedreuille@gmail.com>
This commit is contained in:
@@ -0,0 +1,307 @@
|
||||
/*
|
||||
* Copyright 2025 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
import React, { useState } from 'react';
|
||||
import type { Meta, StoryObj } from '@storybook/react';
|
||||
import { Select } from './Select';
|
||||
import { Form } from '@base-ui-components/react/form';
|
||||
import { Button } from '../Button';
|
||||
|
||||
const meta = {
|
||||
title: 'Components/Select',
|
||||
component: Select,
|
||||
decorators: [story => <div style={{ maxWidth: 400 }}>{story()}</div>],
|
||||
} satisfies Meta<typeof Select>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
const fontOptions = [
|
||||
{ value: 'sans', label: 'Sans-serif' },
|
||||
{ value: 'serif', label: 'Serif' },
|
||||
{ value: 'mono', label: 'Monospace' },
|
||||
{ value: 'cursive', label: 'Cursive' },
|
||||
];
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
label: 'Font Family',
|
||||
options: fontOptions,
|
||||
placeholder: 'Select a font',
|
||||
name: 'font',
|
||||
},
|
||||
};
|
||||
|
||||
export const WithDescription: Story = {
|
||||
args: {
|
||||
...Default.args,
|
||||
description: 'Choose a font family for your document',
|
||||
},
|
||||
};
|
||||
|
||||
export const Required: Story = {
|
||||
args: {
|
||||
...Default.args,
|
||||
required: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const Disabled: Story = {
|
||||
args: {
|
||||
...Default.args,
|
||||
disabled: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const DisabledOption: Story = {
|
||||
args: {
|
||||
...Default.args,
|
||||
options: [
|
||||
...fontOptions,
|
||||
{ value: 'comic-sans', label: 'Comic sans', disabled: true },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export const NoLabel: Story = {
|
||||
args: {
|
||||
...Default.args,
|
||||
label: undefined,
|
||||
},
|
||||
};
|
||||
|
||||
export const NoOptions: Story = {
|
||||
args: {
|
||||
...Default.args,
|
||||
options: undefined,
|
||||
},
|
||||
};
|
||||
|
||||
export const Small: Story = {
|
||||
args: {
|
||||
...Default.args,
|
||||
size: 'small',
|
||||
},
|
||||
};
|
||||
|
||||
export const WithValue: Story = {
|
||||
args: {
|
||||
...Default.args,
|
||||
value: 'mono',
|
||||
defaultValue: 'serif',
|
||||
},
|
||||
};
|
||||
|
||||
export const WithDefaultValue: Story = {
|
||||
args: {
|
||||
...Default.args,
|
||||
defaultValue: 'serif',
|
||||
options: fontOptions,
|
||||
name: 'font',
|
||||
},
|
||||
};
|
||||
|
||||
const generateOptions = (count = 100) => {
|
||||
const firstWords = [
|
||||
'Moon',
|
||||
'Sun',
|
||||
'Star',
|
||||
'Cosmic',
|
||||
'Globe',
|
||||
'Flux',
|
||||
'Nova',
|
||||
'Echo',
|
||||
'Pulse',
|
||||
'Vertex',
|
||||
'Nexus',
|
||||
'Orbit',
|
||||
'Prism',
|
||||
'Quantum',
|
||||
'Zenith',
|
||||
'Aura',
|
||||
'Crystal',
|
||||
'Shadow',
|
||||
'Phantom',
|
||||
'Azure',
|
||||
'Ember',
|
||||
'Frost',
|
||||
'Horizon',
|
||||
'Mystic',
|
||||
'Raven',
|
||||
'Solstice',
|
||||
'Tempest',
|
||||
'Vortex',
|
||||
'Whisper',
|
||||
'Zephyr',
|
||||
];
|
||||
|
||||
const secondWords = [
|
||||
'green',
|
||||
'blue',
|
||||
'red',
|
||||
'black',
|
||||
'white',
|
||||
'silver',
|
||||
'gold',
|
||||
'copper',
|
||||
'bronze',
|
||||
'steel',
|
||||
'flow',
|
||||
'light',
|
||||
'dark',
|
||||
'dream',
|
||||
'stream',
|
||||
'life',
|
||||
'sight',
|
||||
'mind',
|
||||
'craft',
|
||||
'blend',
|
||||
'wave',
|
||||
'swift',
|
||||
'sharp',
|
||||
'soft',
|
||||
'bold',
|
||||
'clear',
|
||||
'deep',
|
||||
'lift',
|
||||
'shift',
|
||||
'grace',
|
||||
];
|
||||
|
||||
const thirdWords = [
|
||||
'Sans',
|
||||
'Serif',
|
||||
'Mono',
|
||||
'Script',
|
||||
'Display',
|
||||
'Slab',
|
||||
'Round',
|
||||
'Thin',
|
||||
'Bold',
|
||||
'Italic',
|
||||
'Pro',
|
||||
'Neo',
|
||||
'Prime',
|
||||
'Plus',
|
||||
'One',
|
||||
'Two',
|
||||
'Nova',
|
||||
'Ultra',
|
||||
'Elite',
|
||||
'Max',
|
||||
'Type',
|
||||
'Text',
|
||||
'View',
|
||||
'Graph',
|
||||
'Print',
|
||||
'Read',
|
||||
'Write',
|
||||
'Book',
|
||||
'Note',
|
||||
'Letter',
|
||||
];
|
||||
|
||||
const randomElement = <T extends any>(array: T[]): T =>
|
||||
array[Math.floor(Math.random() * array.length)];
|
||||
|
||||
const uniqueRandomNames = Array.from({ length: count })
|
||||
.map(() => {
|
||||
const firstName = randomElement(firstWords);
|
||||
const secondName = randomElement(secondWords);
|
||||
const thirdName = randomElement(thirdWords);
|
||||
return `${firstName}${secondName} ${thirdName}`;
|
||||
})
|
||||
.reduce((accSet, label) => {
|
||||
accSet.add(label);
|
||||
return accSet;
|
||||
}, new Set<string>())
|
||||
.values();
|
||||
|
||||
return Array.from(uniqueRandomNames).map(label => ({
|
||||
value: label.toLocaleLowerCase('en-US').replaceAll(' ', '-'),
|
||||
label,
|
||||
}));
|
||||
};
|
||||
|
||||
export const WithManyOptions: Story = {
|
||||
args: {
|
||||
label: 'Font Family',
|
||||
options: generateOptions(),
|
||||
name: 'font',
|
||||
},
|
||||
};
|
||||
|
||||
async function validateFont(value: string) {
|
||||
// Mimic a server response
|
||||
await new Promise(resolve => {
|
||||
setTimeout(resolve, 500);
|
||||
});
|
||||
|
||||
const restrictedFonts = ['comic-sans'];
|
||||
if (restrictedFonts.includes(value)) {
|
||||
return { error: 'This font should not be allowed.' };
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export const ShowErrorOnSubmit: Story = {
|
||||
args: {
|
||||
...Default.args,
|
||||
label: 'Font Family (select Comic sans to see error)',
|
||||
options: [...fontOptions, { value: 'comic-sans', label: 'Comic sans' }],
|
||||
required: true,
|
||||
},
|
||||
decorators: [
|
||||
Story => {
|
||||
const [errors, setErrors] = useState({});
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
return (
|
||||
<Form
|
||||
errors={errors}
|
||||
onClearErrors={setErrors}
|
||||
onSubmit={async event => {
|
||||
event.preventDefault();
|
||||
const formData = new FormData(event.currentTarget);
|
||||
const fontValue = formData.get('font') as string;
|
||||
|
||||
setLoading(true);
|
||||
const response = await validateFont(fontValue);
|
||||
if (response.error) {
|
||||
setErrors({
|
||||
font: response.error,
|
||||
});
|
||||
} else {
|
||||
setErrors({});
|
||||
}
|
||||
|
||||
setLoading(false);
|
||||
}}
|
||||
>
|
||||
<Story />
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
size="small"
|
||||
style={{ marginTop: '0.75rem' }}
|
||||
>
|
||||
Submit
|
||||
</Button>
|
||||
</Form>
|
||||
);
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,184 @@
|
||||
/*
|
||||
* Copyright 2024 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
.canon-SelectFieldRoot {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
font-family: var(--canon-font-regular);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.canon-SelectFieldLabel {
|
||||
font-size: var(--canon-font-size-2);
|
||||
font-weight: var(--canon-font-weight-regular);
|
||||
color: var(--canon-fg-primary);
|
||||
margin-bottom: var(--canon-space-1_5);
|
||||
}
|
||||
|
||||
.canon-SelectFieldDescription {
|
||||
font-size: var(--canon-font-size-2);
|
||||
font-weight: var(--canon-font-weight-regular);
|
||||
color: var(--canon-fg-secondary);
|
||||
margin: 0;
|
||||
padding-top: var(--canon-space-1_5);
|
||||
}
|
||||
|
||||
.canon-SelectFieldError {
|
||||
font-size: var(--canon-font-size-2);
|
||||
font-weight: var(--canon-font-weight-regular);
|
||||
color: var(--canon-fg-danger);
|
||||
margin: 0;
|
||||
padding-top: var(--canon-space-1_5);
|
||||
}
|
||||
|
||||
.canon-SelectTrigger {
|
||||
box-sizing: border-box;
|
||||
border-radius: var(--canon-radius-3);
|
||||
border: 1px solid var(--canon-border);
|
||||
padding: 0 var(--canon-space-4);
|
||||
background-color: var(--canon-bg-surface-1);
|
||||
font-size: var(--canon-font-size-3);
|
||||
font-family: var(--canon-font-regular);
|
||||
font-weight: var(--canon-font-weight-regular);
|
||||
color: var(--canon-fg-primary);
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
transition: border-color 0.2s ease-in-out, outline-color 0.2s ease-in-out;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.canon-SelectTrigger::placeholder {
|
||||
color: var(--canon-fg-secondary);
|
||||
}
|
||||
|
||||
.canon-SelectTrigger:hover {
|
||||
border-color: var(--canon-border-hover);
|
||||
}
|
||||
|
||||
.canon-SelectTrigger:focus-visible {
|
||||
border-color: var(--canon-border-pressed);
|
||||
outline: 0;
|
||||
}
|
||||
|
||||
.canon-SelectTrigger[data-invalid] {
|
||||
border-color: var(--canon-fg-danger);
|
||||
}
|
||||
.canon-SelectTrigger[data-invalid]:hover {
|
||||
border-width: 2px;
|
||||
}
|
||||
|
||||
.canon-SelectTrigger[data-invalid]:focus-visible {
|
||||
border-width: 2px;
|
||||
}
|
||||
|
||||
.canon-SelectTrigger[data-disabled] {
|
||||
cursor: not-allowed;
|
||||
border-color: var(--canon-border-disabled);
|
||||
color: var(--canon-fg-disabled);
|
||||
}
|
||||
|
||||
.canon-SelectTrigger--size-small,
|
||||
.canon-SelectItem--size-small {
|
||||
height: 2rem;
|
||||
}
|
||||
|
||||
.canon-SelectTrigger--size-medium,
|
||||
.canon-SelectItem--size-medium {
|
||||
height: 3rem;
|
||||
}
|
||||
|
||||
.canon-SelectIcon {
|
||||
margin-left: var(--canon-space-5);
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.canon-SelectTrigger[data-popup-open] .canon-SelectIcon {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.canon-SelectPopup {
|
||||
box-sizing: border-box;
|
||||
max-height: var(--available-height);
|
||||
overflow-y: auto;
|
||||
background-color: var(--canon-bg-surface-1);
|
||||
border: 1px solid var(--canon-border);
|
||||
border-radius: var(--canon-radius-3);
|
||||
padding-block: var(--canon-space-1);
|
||||
z-index: 1;
|
||||
outline: 0;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
|
||||
transform-origin: var(--transform-origin);
|
||||
transition: transform 150ms, opacity 150ms;
|
||||
}
|
||||
|
||||
.canon-SelectPopup[data-starting-style],
|
||||
.canon-SelectPopup[data-ending-style] {
|
||||
opacity: 0;
|
||||
transform: scale(0.9);
|
||||
}
|
||||
|
||||
.canon-SelectItem {
|
||||
position: relative;
|
||||
width: var(--anchor-width);
|
||||
display: grid;
|
||||
grid-template-areas: 'icon text';
|
||||
grid-template-columns: 1rem 1fr;
|
||||
align-items: center;
|
||||
padding-block: var(--canon-space-2);
|
||||
padding-inline: var(--canon-space-4);
|
||||
color: var(--canon-fg-primary);
|
||||
border-radius: var(--canon-radius-3);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
font-size: var(--canon-font-size-3);
|
||||
gap: var(--canon-space-2);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.canon-SelectItem[data-highlighted] {
|
||||
z-index: 0;
|
||||
position: relative;
|
||||
color: var(--canon-fg-primary);
|
||||
}
|
||||
|
||||
.canon-SelectItem[data-highlighted]::before {
|
||||
content: '';
|
||||
z-index: -1;
|
||||
position: absolute;
|
||||
inset-block: 0;
|
||||
inset-inline: 0.25rem;
|
||||
border-radius: 0.25rem;
|
||||
background-color: var(--canon-bg-tint-hover);
|
||||
}
|
||||
|
||||
.canon-SelectItem[data-disabled] {
|
||||
cursor: not-allowed;
|
||||
color: var(--canon-fg-disabled);
|
||||
}
|
||||
|
||||
.canon-SelectItemIndicator {
|
||||
grid-area: icon;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.canon-SelectItemText {
|
||||
flex: 1;
|
||||
grid-area: text;
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
/*
|
||||
* Copyright 2024 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Select as SelectPrimitive } from '@base-ui-components/react/select';
|
||||
import { Field, Icon } from '@backstage/canon';
|
||||
import clsx from 'clsx';
|
||||
import './Select.styles.css';
|
||||
import { SelectProps } from './types';
|
||||
|
||||
/**
|
||||
* Select component
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export const Select = React.forwardRef<HTMLSelectElement, SelectProps>(
|
||||
(props, ref) => {
|
||||
const {
|
||||
className,
|
||||
name,
|
||||
label,
|
||||
description,
|
||||
value,
|
||||
defaultValue,
|
||||
placeholder,
|
||||
onValueChange,
|
||||
onOpenChange,
|
||||
options,
|
||||
size = 'medium',
|
||||
disabled = false,
|
||||
required = false,
|
||||
} = props;
|
||||
return (
|
||||
<Field.Root
|
||||
className={clsx('canon-SelectFieldRoot', className)}
|
||||
disabled={disabled}
|
||||
name={name}
|
||||
>
|
||||
{label && (
|
||||
<Field.Label className="canon-SelectFieldLabel">{label}</Field.Label>
|
||||
)}
|
||||
<SelectPrimitive.Root
|
||||
value={value}
|
||||
defaultValue={defaultValue}
|
||||
onValueChange={onValueChange}
|
||||
onOpenChange={onOpenChange}
|
||||
required={required}
|
||||
name={name}
|
||||
>
|
||||
<SelectPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={clsx(
|
||||
'canon-SelectTrigger',
|
||||
`canon-SelectTrigger--size-${size}`,
|
||||
)}
|
||||
>
|
||||
<SelectPrimitive.Value
|
||||
placeholder={placeholder || 'Select an option'}
|
||||
/>
|
||||
<SelectPrimitive.Icon className="canon-SelectIcon">
|
||||
<Icon name="chevron-down" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Backdrop />
|
||||
<SelectPrimitive.Positioner>
|
||||
<SelectPrimitive.Popup className="canon-SelectPopup">
|
||||
{options?.map(option => (
|
||||
<SelectPrimitive.Item
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
disabled={option.disabled}
|
||||
className="canon-SelectItem"
|
||||
>
|
||||
<SelectPrimitive.ItemIndicator className="canon-SelectItemIndicator">
|
||||
<Icon name="check" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
<SelectPrimitive.ItemText className="canon-SelectItemText">
|
||||
{option.label}
|
||||
</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
))}
|
||||
</SelectPrimitive.Popup>
|
||||
</SelectPrimitive.Positioner>
|
||||
</SelectPrimitive.Portal>
|
||||
</SelectPrimitive.Root>
|
||||
{description && (
|
||||
<Field.Description className="canon-SelectFieldDescription">
|
||||
{description}
|
||||
</Field.Description>
|
||||
)}
|
||||
<Field.Error className="canon-SelectFieldError" />
|
||||
</Field.Root>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
Select.displayName = 'Select';
|
||||
@@ -0,0 +1,17 @@
|
||||
/*
|
||||
* Copyright 2024 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export * from './Select';
|
||||
@@ -0,0 +1,88 @@
|
||||
/*
|
||||
* Copyright 2025 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { Breakpoint } from '@backstage/canon';
|
||||
|
||||
/** @public */
|
||||
export interface SelectProps {
|
||||
/**
|
||||
* The class name of the select field
|
||||
*/
|
||||
className?: string;
|
||||
|
||||
/**
|
||||
* The size of the select field
|
||||
* @defaultValue 'medium'
|
||||
*/
|
||||
size?: 'small' | 'medium' | Partial<Record<Breakpoint, 'small' | 'medium'>>;
|
||||
|
||||
/**
|
||||
* The label of the select field
|
||||
*/
|
||||
label?: string;
|
||||
|
||||
/**
|
||||
* The description of the select field
|
||||
*/
|
||||
description?: string;
|
||||
|
||||
/**
|
||||
* The name of the select field
|
||||
*/
|
||||
name: string;
|
||||
|
||||
/**
|
||||
* Whether the select field should ignore user input
|
||||
* @defaultValue false
|
||||
*/
|
||||
disabled?: boolean;
|
||||
|
||||
/**
|
||||
* Whether the select field is required
|
||||
* @defaultValue false
|
||||
*/
|
||||
required?: boolean;
|
||||
|
||||
/**
|
||||
* The options of the select field
|
||||
*/
|
||||
options?: Array<{ value: string; label: string; disabled?: boolean }>;
|
||||
|
||||
/**
|
||||
* The current value of the select field
|
||||
*/
|
||||
value?: string;
|
||||
|
||||
/**
|
||||
* The default value of the select field, if nothing has been selected yet
|
||||
*/
|
||||
defaultValue?: string;
|
||||
|
||||
/**
|
||||
* A placeholder text to show if nothing has been selected and there's no default value
|
||||
*/
|
||||
placeholder?: string;
|
||||
|
||||
/**
|
||||
* Callback that is called when the value of the select field changes
|
||||
*/
|
||||
onValueChange?: (value: string) => void;
|
||||
|
||||
/**
|
||||
* Callbak that is called when the select field is opened or closed
|
||||
*/
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
}
|
||||
@@ -31,3 +31,4 @@
|
||||
@import '../components/Link/styles.css';
|
||||
@import '../components/Tooltip/Tooltip.styles.css';
|
||||
@import '../components/ScrollArea/ScrollArea.styles.css';
|
||||
@import '../components/Select/Select.styles.css';
|
||||
|
||||
@@ -42,6 +42,7 @@ export * from './components/Field';
|
||||
export * from './components/Tooltip';
|
||||
export * from './components/Menu';
|
||||
export * from './components/ScrollArea';
|
||||
export * from './components/Select';
|
||||
|
||||
// Types
|
||||
export * from './types';
|
||||
|
||||
Reference in New Issue
Block a user