diff --git a/packages/design-system/lib/components/Select/Select.stories.tsx b/packages/design-system/lib/components/Select/Select.stories.tsx new file mode 100644 index 000000000..6ac4240e9 --- /dev/null +++ b/packages/design-system/lib/components/Select/Select.stories.tsx @@ -0,0 +1,67 @@ +import 'react-material-symbols/rounded' + +import type { Meta, StoryObj } from '@storybook/react' + +import { Select } from './Select' + +const meta: Meta = { + title: 'Components/Select', + component: Select, + argTypes: {}, +} + +export default meta + +type Story = StoryObj + +const items = [ + { label: 'Foo', value: 'foo' }, + { label: 'Bar', value: 'bar' }, + { label: 'Baz', value: 'baz' }, +] + +export const Default: Story = { + args: { + items, + label: 'Select an item', + name: 'foo', + }, +} + +export const DefaultSelected: Story = { + args: { + items, + label: 'Select an item', + name: 'foo', + defaultSelectedKey: 'foo', + }, +} + +export const Icons: Story = { + args: { + items, + label: 'Select an item', + name: 'foo', + icon: 'star', + itemIcon: 'check', + }, +} + +export const Filtering: Story = { + args: { + items, + label: 'Select an item', + name: 'foo', + enableFiltering: true, + }, +} + +export const FilteringSelected: Story = { + args: { + items, + label: 'Select an item', + name: 'foo', + enableFiltering: true, + defaultSelectedKey: 'foo', + }, +} diff --git a/packages/design-system/lib/components/Select/Select.tsx b/packages/design-system/lib/components/Select/Select.tsx new file mode 100644 index 000000000..0682939b2 --- /dev/null +++ b/packages/design-system/lib/components/Select/Select.tsx @@ -0,0 +1,107 @@ +import { + Select as AriaSelect, + SelectValue, + Popover, + ListBox, + Button, +} from 'react-aria-components' +import { cx } from 'class-variance-authority' + +import { MaterialIcon } from '../Icons/MaterialIcon' +import { Typography } from '../Typography' +import { SelectItem } from './SelectItem' +import { SelectFilter } from './SelectFilter' + +import type { SelectProps, SelectFilterProps } from './types' + +import styles from './select.module.css' + +export function Select({ + name, + label, + items, + isDisabled, + icon, + itemIcon, + ...props +}: SelectProps | SelectFilterProps) { + if ('enableFiltering' in props) { + return ( + + ) + } + const iconColor = isDisabled ? 'Icon/Interactive/Disabled' : 'Icon/Default' + + return ( + + + + + + {items.map((item) => ( + + {item.label} + + ))} + + + + ) +} diff --git a/packages/design-system/lib/components/Select/SelectFilter.tsx b/packages/design-system/lib/components/Select/SelectFilter.tsx new file mode 100644 index 000000000..42a6e5514 --- /dev/null +++ b/packages/design-system/lib/components/Select/SelectFilter.tsx @@ -0,0 +1,105 @@ +import { + Button, + ComboBox, + Input, + Key, + ListBox, + Popover, +} from 'react-aria-components' +import { useState } from 'react' + +import { MaterialIcon } from '../Icons/MaterialIcon' +import { Typography } from '../Typography' +import { SelectItem } from './SelectItem' + +import type { SelectFilterProps } from './types' + +import styles from './select.module.css' + +export function SelectFilter({ + name, + label, + items, + isDisabled, + icon, + itemIcon, + defaultSelectedKey, + onSelectionChange, + ...props +}: SelectFilterProps) { + const [focus, setFocus] = useState(false) + const [value, setValue] = useState(defaultSelectedKey ?? null) + const iconColor = isDisabled ? 'Icon/Interactive/Disabled' : 'Icon/Default' + + return ( + { + setValue(val) + if (onSelectionChange) { + onSelectionChange(val) + } + }} + onFocus={() => setFocus(true)} + onBlur={() => setFocus(false)} + defaultSelectedKey={defaultSelectedKey} + {...props} + > + + + + + {items.map((item) => ( + + {item.label} + + ))} + + + + ) +} diff --git a/packages/design-system/lib/components/Select/SelectItem.tsx b/packages/design-system/lib/components/Select/SelectItem.tsx new file mode 100644 index 000000000..0a2d8a631 --- /dev/null +++ b/packages/design-system/lib/components/Select/SelectItem.tsx @@ -0,0 +1,43 @@ +import { ListBoxItem } from 'react-aria-components' +import { Typography } from '../Typography' +import styles from './select.module.css' +import { MaterialIcon } from '../Icons/MaterialIcon' +import { SelectItemProps } from './types' + +export function SelectItem({ + children, + icon, + isDisabled, + ...props +}: SelectItemProps) { + const iconColor = isDisabled ? 'Icon/Interactive/Disabled' : 'Icon/Default' + + return ( + + {({ isSelected }) => ( + <> + {icon ? ( + + ) +} diff --git a/packages/design-system/lib/components/Select/index.ts b/packages/design-system/lib/components/Select/index.ts new file mode 100644 index 000000000..20fbbe724 --- /dev/null +++ b/packages/design-system/lib/components/Select/index.ts @@ -0,0 +1,3 @@ +export { Select } from './Select' +export { SelectItem } from './SelectItem' +export { SelectFilter } from './SelectFilter' diff --git a/packages/design-system/lib/components/Select/select.module.css b/packages/design-system/lib/components/Select/select.module.css new file mode 100644 index 000000000..c56960e97 --- /dev/null +++ b/packages/design-system/lib/components/Select/select.module.css @@ -0,0 +1,134 @@ +.select { + position: relative; + background-color: var(--Surface-UI-Fill-Default); + border: 1px solid var(--Border-Interactive-Default); + border-radius: var(--Corner-radius-md); + + &[data-required] .label::after { + content: ' *'; + } + &[data-open] .chevron { + rotate: -90deg; + } + &[data-focused] { + border-color: var(--Border-Interactive-Focus); + + .button, + .input { + outline: none; + } + .input { + position: unset; + } + .label { + color: var(--Text-Interactive-Focus); + } + } + &[data-disabled] { + .inner { + background-color: var(--Surface-Primary-Disabled); + color: var(--Text-Interactive-Disabled); + } + .button, + .input, + .label, + .selectValue { + color: var(--Text-Interactive-Disabled); + } + } + &[data-invalid] { + border-color: var(--Border-Interactive-Error); + } +} + +.chevron { + rotate: 90deg; +} + +.inner { + display: flex; + align-items: center; + gap: var(--Space-x1); + width: 100%; + height: 56px; + padding: var(--Space-x15); + box-sizing: border-box; + + .button { + padding: 0; + } +} + +.button, +.input { + background: none; + border: 0; +} + +.input { + position: absolute; + padding: 0; + + &[value]:not([value='']) { + position: unset; + } +} + +.displayText { + display: flex; + flex-direction: column; + gap: calc(var(--Space-x05) / 2); + flex: 1; + justify-content: center; + height: 100%; + + &:has(.input) { + cursor: text; + } +} + +.selectValue { + align-items: flex-start; + color: var(--Text-Default); +} + +.label { + color: var(--Text-Interactive-Placeholder); +} + +.popover { + background-color: var(--Surface-Primary-Default); + border-radius: var(--Corner-radius-md); + box-shadow: 0 0 14px 6px rgb(0 0 0 / 10%); + display: inline-flex; + flex-direction: column; + gap: var(--Space-x1); + overflow: auto; + padding: var(--Space-x2); + outline: none; + min-width: 280px; +} + +.listBox { + display: flex; + flex-direction: column; + gap: var(--Space-x1); + outline: none; +} + +.listBoxItem { + padding: var(--Space-x1) var(--Space-x1) var(--Space-x1) var(--Space-x15); + color: var(--Text-Default); + border-radius: var(--Corner-radius-md); + display: flex; + align-items: center; + gap: var(--Space-x1); + + &[data-focused] { + outline: none; + } + &[data-focused], + &[data-hovered] { + background-color: var(--Surface-Primary-Hover); + } +} diff --git a/packages/design-system/lib/components/Select/types.ts b/packages/design-system/lib/components/Select/types.ts new file mode 100644 index 000000000..49a40ff8e --- /dev/null +++ b/packages/design-system/lib/components/Select/types.ts @@ -0,0 +1,34 @@ +import { ComponentProps } from 'react' +import { ComboBox, Key, ListBoxItem, Select } from 'react-aria-components' +import { MaterialIconProps } from '../Icons/MaterialIcon' + +interface Item extends Record { + label: string + value: Key + isDisabled?: boolean + icon?: MaterialIconProps['icon'] +} + +export interface SelectProps extends ComponentProps { + icon?: MaterialIconProps['icon'] + itemIcon?: MaterialIconProps['icon'] + items: Item[] + name: string + label: string + onSelectionChange?: (key: Key | null) => void +} + +export interface SelectItemProps extends ComponentProps { + icon?: MaterialIconProps['icon'] + children: string +} + +export interface SelectFilterProps extends ComponentProps { + icon?: MaterialIconProps['icon'] + itemIcon?: MaterialIconProps['icon'] + items: Item[] + name: string + label: string + onSelectionChange?: (key: Key | null) => void + enableFiltering: boolean +} diff --git a/packages/design-system/package.json b/packages/design-system/package.json index 051a1bcb5..1aad0512e 100644 --- a/packages/design-system/package.json +++ b/packages/design-system/package.json @@ -9,6 +9,7 @@ "./ChipButton": "./dist/components/ChipButton/index.js", "./ChipLink": "./dist/components/ChipLink/index.js", "./Chips": "./dist/components/Chips/index.js", + "./Select": "./dist/components/Select/index.js", "./Typography": "./dist/components/Typography/index.js", "./RegularRateCard": "./dist/components/RateCard/Regular/index.js", "./CampaignRateCard": "./dist/components/RateCard/Campaign/index.js",