Skip to content
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

[ShadCN] Migrate Select #13725

Merged
merged 15 commits into from
Sep 11, 2024
Merged
Show file tree
Hide file tree
Changes from 5 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
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@
"reading-time": "^1.5.0",
"remark-gfm": "^3.0.1",
"tailwind-merge": "^2.3.0",
"tailwind-variants": "^0.2.1",
"tailwindcss-animate": "^1.0.7",
"usehooks-ts": "^3.1.0",
"yaml-loader": "^0.8.0"
Expand Down
70 changes: 70 additions & 0 deletions src/components/ui/Select/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import ReactSelect, { ActionMeta, GroupBase, Props } from "react-select"

import {
components,
SelectStylesContext,
type SelectVariants,
selectVariants,
} from "./innerComponents"

/**
* Type for onChange handler in the `Select` component
*
* Specifically declared for single selects.
*
* @see https://react-select.com/typescript#onchange
*
* @typeParam Option - The object type in the array
* @param newValue - The object returned from the payload
* @param actionMeta - The set of actions that can be run on change
*/
export type SelectOnChange<Option> = (
newValue: Option | null,
actionMeta: ActionMeta<Option>
) => void

/**
* Custom Built Version of the `react-select` single-select component.
*
* A styles provider wraps the original `Select` to send Tailwind styles straight to the
* custom internal components which are code-split into their own file.
* See `./innerComponents.tsx`
*
* You can use the `variant` prop from tailwind-variants to declare a variant from the extended theme,
* and use any valid props sent to the `Select` component.
*
* @see {@link https://react-select.com/props#select-props} for the list of valid props
*
* `WARNING`: the `unstyled`, and `menuPlacement` props are locked and should not be altered, per Design System.
*/
const Select = <
Option,
Group extends GroupBase<Option> = GroupBase<Option>,
IsMulti extends boolean = false,
>({
variant,
...rest
}: Omit<Props<Option, IsMulti, Group>, "unstyled" | "menuPlacement"> &
SelectVariants) => {
const styles = selectVariants({ variant })
return (
<SelectStylesContext.Provider value={styles}>
<ReactSelect
components={components}
styles={{
singleValue: (base) => ({
...base,
// Force text overflow at smaller widths when inline with other select components
position: "absolute",
}),
}}
isSearchable={false}
{...rest}
unstyled
menuPlacement="bottom"
/>
</SelectStylesContext.Provider>
)
}

export default Select
222 changes: 222 additions & 0 deletions src/components/ui/Select/innerComponents.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,222 @@
import { createContext, useContext } from "react"
import { FaChevronDown } from "react-icons/fa"
import type {
ContainerProps,
ControlProps,
DropdownIndicatorProps,
GroupBase,
GroupProps,
MenuListProps,
OptionProps,
} from "react-select"
import { tv, type VariantProps } from "tailwind-variants"

import { cn } from "@/lib/utils/cn"

export const selectVariants = tv({
slots: {
container:
"w-full min-h-10.5 [--border-base-width:1px] relative z-[1] [&_>_.react-select\\_\\_menu]:-z-[1] cursor-pointer",
control:
"p-2 flex items-center gap-4 border-[length:var(--border-base-width)] border-current text-[color:var(--my-var)] not-[[data-expanded=true]]:focus-within:outline-3 not-[[data-expanded=true]]:focus-within:outline-primary-hover not-[[data-expanded=true]]:focus-within:outline -outline-offset-2 [&[data-expanded=true]]:bg-background-highlight [&[data-expanded=true]]:text-primary [&[data-expanded=true]]:border-primary-low-contrast hover:text-primary hover:border-primary-low-contrast",
indicatorIcon:
"text-sm leading-none transition-transform [*[data-expanded=true]_&]:rotate-180",
menuList:
"overflow-y-auto bg-background-highlight w-full max-h-xs border-x-[length:--border-base-width] border-b-[length:--border-base-width] rounded-b",
option:
"text-body p-2 [&[data-focused=true]]:bg-primary-low-contrast [&[data-focused=true]]:text-primary [&[data-active=true]]:bg-body-light [&[data-active=true]]:text-primary-visited",
groupHeading: "text-body-medium text-xs",
},
variants: {
variant: {
flushed: {
container: "[--border-top-radius:4px] rounded-t-[--border-top-radius]",
control:
"border-t-transparent border-x-transparent rounded-t-[--border-top-radius] hover:border-t-transparent hover:border-x-transparent [&[data-expanded=true]]:border-body-light [&[data-expanded=true]]:border-b-primary",
menuList: "border-body-light",
},
outline: {
container:
"[--border-outline-radius:4px] rounded-[--border-outline-radius]",
control:
"rounded-[--border-outline-radius] [&[data-expanded=true]]:border-b-transparent [&[data-expanded=true]]:rounded-b-none",
menuList: "border-primary-low-contrast",
},
},
},
defaultVariants: {
variant: "flushed",
},
})

export type SelectVariants = VariantProps<typeof selectVariants>

export const SelectStylesContext = createContext(selectVariants())

const useSelectStyles = () => useContext(SelectStylesContext)

export const nullop = () => null

/*
* Note on the Generic declarations:
* Because the custom components are being created outside of the `components`
* prop, generics sent to the respective props have to be redeclared, else type
* errors throw for incompatibility.
*/

const SelectContainer = <
Option,
IsMulti extends boolean,
Group extends GroupBase<Option>,
>(
props: ContainerProps<Option, IsMulti, Group>
) => {
const { innerProps, children, className, selectProps } = props
const { menuIsOpen } = selectProps

const { container } = useSelectStyles()
return (
<div
className={cn(container(), className)}
data-expanded={menuIsOpen}
{...innerProps}
id="react-select-container"
>
{children}
</div>
)
}

const Control = <
Option,
IsMulti extends boolean,
Group extends GroupBase<Option>,
>(
props: ControlProps<Option, IsMulti, Group>
) => {
const { innerProps, innerRef, children, menuIsOpen } = props

const { control } = useSelectStyles()
return (
<div
ref={innerRef}
className={control()}
data-expanded={menuIsOpen}
{...innerProps}
id="react-select-control"
>
{children}
</div>
)
}

const DropdownIndicator = <
Option,
IsMulti extends boolean,
Group extends GroupBase<Option>,
>(
props: DropdownIndicatorProps<Option, IsMulti, Group>
) => {
const { innerProps } = props
const { indicatorIcon } = useSelectStyles()
return (
<div
{...innerProps}
className={indicatorIcon()}
id="react-select-dropdown-indicator"
>
<FaChevronDown />
</div>
)
}
const MenuList = <
Option,
IsMulti extends boolean,
Group extends GroupBase<Option>,
>(
props: MenuListProps<Option, IsMulti, Group>
) => {
const { innerProps, innerRef, children } = props
const { menuList } = useSelectStyles()
return (
<div
ref={innerRef}
{...innerProps}
className={menuList()}
id="react-select-menu-list"
>
{children}
</div>
)
}

const Option = <
Option,
IsMulti extends boolean,
Group extends GroupBase<Option>,
>(
props: OptionProps<Option, IsMulti, Group>
) => {
const { innerProps, innerRef, children, isSelected, isFocused } = props

const { option } = useSelectStyles()
return (
<div
ref={innerRef}
data-focused={isFocused}
data-active={isSelected}
{...innerProps}
className={option()}
id="react-select-option"
>
{children}
</div>
)
}

const Group = <
Option,
IsMulti extends boolean,
Group extends GroupBase<Option>,
>(
props: GroupProps<Option, IsMulti, Group>
) => {
const { children, headingProps, label } = props

const { groupHeading } = useSelectStyles()

const notFirstGroupClasses =
"not-[:first-of-type]:border-t-[1px] not-[:first-of-type]:border-primary-low-contrast"

const PARENT_ID = "react-select-group"

if (!label) {
return (
<div id={PARENT_ID} className={notFirstGroupClasses}>
{children}
</div>
)
}

return (
<div id={PARENT_ID} className={cn("p-2", notFirstGroupClasses)}>
<div className="text-sm">
<div id={headingProps.id} className={groupHeading()}>
{label}
</div>
</div>
{children}
</div>
)
}

export const components = {
SelectContainer,
Control,
// Essentially removes this component from default render
IndicatorSeparator: nullop,
DropdownIndicator,
MenuList,
Option,
Group,
}
52 changes: 52 additions & 0 deletions src/components/ui/__stories__/Select.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { Meta, StoryObj } from "@storybook/react"

import { HStack } from "../flex"
import Select from "../Select"

const meta = {
title: "Atoms / Form / ShadCN Dropdown",
component: Select,
parameters: {
// TODO: Remove this when this story file becomes the primary one
chromatic: { disableSnapshot: true },
},
decorators: [
(Story) => (
<div className="w-[32rem]">
<Story />
</div>
),
],
} satisfies Meta<typeof Select>

export default meta

type Story = StoryObj<typeof meta>

export const Dropdown: Story = {
args: {
options: [
{
options: [
{ label: "Ethereum", value: "eth" },
{ label: "Bitcoin", value: "bit" },
{ label: "Dogecoin", value: "doge" },
],
},
{
label: "Layer2 Options",
options: [
{ label: "Mainnet", value: "mainnet" },
{ label: "Arbitrum", value: "arbitrum" },
{ label: "Optimism", value: "optimism" },
],
},
],
},
render: (args) => (
<HStack className="gap-4">
<Select {...args} />
<Select {...args} variant="outline" />
</HStack>
),
}
19 changes: 18 additions & 1 deletion yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -13097,7 +13097,7 @@ prelude-ls@^1.2.1:
resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.2.1.tgz#debc6489d7a6e6b0e7611888cec880337d316396"
integrity sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==

"prettier-fallback@npm:prettier@^3", prettier@^3.1.1:
"prettier-fallback@npm:prettier@^3":
version "3.3.2"
resolved "https://registry.yarnpkg.com/prettier/-/prettier-3.3.2.tgz#03ff86dc7c835f2d2559ee76876a3914cec4a90a"
integrity sha512-rAVeHYMcv8ATV5d508CFdn+8/pHPpXeIid1DdrPwXnaAdH7cqjVbpJaT5eq4yRAFU/lsbwYwSF/n5iNrdJHPQA==
Expand All @@ -13112,6 +13112,11 @@ prettier@^2.0.5, prettier@^2.8.8:
resolved "https://registry.yarnpkg.com/prettier/-/prettier-2.8.8.tgz#e8c5d7e98a4305ffe3de2e1fc4aca1a71c28b1da"
integrity sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q==

prettier@^3.1.1:
version "3.3.2"
resolved "https://registry.yarnpkg.com/prettier/-/prettier-3.3.2.tgz#03ff86dc7c835f2d2559ee76876a3914cec4a90a"
integrity sha512-rAVeHYMcv8ATV5d508CFdn+8/pHPpXeIid1DdrPwXnaAdH7cqjVbpJaT5eq4yRAFU/lsbwYwSF/n5iNrdJHPQA==

prettier@^3.3.3:
version "3.3.3"
resolved "https://registry.yarnpkg.com/prettier/-/prettier-3.3.3.tgz#30c54fe0be0d8d12e6ae61dbb10109ea00d53105"
Expand Down Expand Up @@ -14721,11 +14726,23 @@ svgo@^3.0.2:
csso "^5.0.5"
picocolors "^1.0.0"

tailwind-merge@^2.2.0:
version "2.5.2"
resolved "https://registry.yarnpkg.com/tailwind-merge/-/tailwind-merge-2.5.2.tgz#000f05a703058f9f9f3829c644235f81d4c08a1f"
integrity sha512-kjEBm+pvD+6eAwzJL2Bi+02/9LFLal1Gs61+QB7HvTfQQ0aXwC5LGT8PEt1gS0CWKktKe6ysPTAy3cBC5MeiIg==

tailwind-merge@^2.3.0:
version "2.4.0"
resolved "https://registry.yarnpkg.com/tailwind-merge/-/tailwind-merge-2.4.0.tgz#1345209dc1f484f15159c9180610130587703042"
integrity sha512-49AwoOQNKdqKPd9CViyH5wJoSKsCDjUlzL8DxuGp3P1FsGY36NJDAa18jLZcaHAUUuTj+JB8IAo8zWgBNvBF7A==

tailwind-variants@^0.2.1:
version "0.2.1"
resolved "https://registry.yarnpkg.com/tailwind-variants/-/tailwind-variants-0.2.1.tgz#132f2537b0150819036f6c4f47d5c50b929b758d"
integrity sha512-2xmhAf4UIc3PijOUcJPA1LP4AbxhpcHuHM2C26xM0k81r0maAO6uoUSHl3APmvHZcY5cZCY/bYuJdfFa4eGoaw==
dependencies:
tailwind-merge "^2.2.0"

tailwindcss-animate@^1.0.7:
version "1.0.7"
resolved "https://registry.yarnpkg.com/tailwindcss-animate/-/tailwindcss-animate-1.0.7.tgz#318b692c4c42676cc9e67b19b78775742388bef4"
Expand Down
Loading