feat(admin,admin-ui,medusa): Add Medusa Admin plugin (#3334)

This commit is contained in:
Kasper Fabricius Kristensen
2023-03-03 10:09:16 +01:00
committed by GitHub
parent d6b1ad1ccd
commit 40de54b010
928 changed files with 85441 additions and 384 deletions

View File

@@ -0,0 +1,235 @@
import clsx from "clsx"
import React, {
useContext,
useEffect,
useImperativeHandle,
useRef,
useState,
} from "react"
import Primitive from "react-select"
import AsyncPrimitive from "react-select/async"
import AsyncCreatablePrimitive from "react-select/async-creatable"
import CreatablePrimitive from "react-select/creatable"
import InputHeader, { InputHeaderProps } from "../../fundamentals/input-header"
import { ModalContext } from "../modal"
import { SelectComponents } from "./select-components"
export type SelectOption<T> = {
value: T
label: string
disabled?: boolean
}
type MultiSelectProps = InputHeaderProps & {
// component props
label: string
required?: boolean
name?: string
className?: string
fullWidth?: boolean
// Multiselect props
placeholder?: string
isMultiSelect?: boolean
labelledBy?: string
options: { label: string; value: string | null; disabled?: boolean }[]
value:
| { label: string; value: string }[]
| { label: string; value: string }
| null
filterOptions?: (q: string) => any[]
hasSelectAll?: boolean
isLoading?: boolean
shouldToggleOnHover?: boolean
onChange: (values: any[] | any) => void
disabled?: boolean
enableSearch?: boolean
isCreatable?: boolean
clearSelected?: boolean
onCreateOption?: (value: string) => { value: string; label: string }
}
const SSelect = React.forwardRef(
(
{
label,
name,
fullWidth = false,
required,
value,
onChange,
className,
isMultiSelect,
hasSelectAll,
tooltipContent,
tooltip,
enableSearch = true,
clearSelected = false,
isCreatable,
filterOptions,
placeholder = "Search...",
options,
onCreateOption,
}: MultiSelectProps,
ref
) => {
const { portalRef } = useContext(ModalContext)
const [isFocussed, setIsFocussed] = useState(false)
const [scrollBlocked, setScrollBlocked] = useState(true)
useEffect(() => {
window.addEventListener("resize", () => {
setIsFocussed(false)
selectRef?.current?.blur()
})
}, [])
const selectRef = useRef(null)
useImperativeHandle(ref, () => selectRef.current)
const containerRef = useRef(null)
const onClickOption = (val, ...args) => {
if (
val?.length &&
val?.find((option) => option.value === "all") &&
hasSelectAll &&
isMultiSelect
) {
onChange(options)
} else {
onChange(val)
if (!isMultiSelect) {
selectRef?.current?.blur()
setIsFocussed(false)
}
}
}
const handleOnCreateOption = (val) => {
if (onCreateOption) {
onCreateOption(val)
setIsFocussed(false)
selectRef?.current?.blur()
}
}
useEffect(() => {
const delayDebounceFn = setTimeout(() => {
if (isFocussed) {
setScrollBlocked(false)
}
}, 50)
return () => clearTimeout(delayDebounceFn)
}, [isFocussed])
return (
<div
ref={containerRef}
className={clsx({
"w-full": fullWidth,
})}
>
<div
key={name}
className={clsx(className, {
"bg-white rounded-t-rounded": isFocussed,
})}
>
<div className="w-full flex text-grey-50 pr-0.5 justify-between pointer-events-none cursor-pointer mb-2">
<InputHeader {...{ label, required, tooltip, tooltipContent }} />
</div>
{
<GetSelect
isCreatable={isCreatable}
searchBackend={filterOptions}
options={
hasSelectAll && isMultiSelect
? [{ value: "all", label: "Select All" }, ...options]
: options
}
ref={selectRef}
value={value}
isMulti={isMultiSelect}
openMenuOnFocus={isMultiSelect}
isSearchable={enableSearch}
isClearable={clearSelected}
onChange={onClickOption}
onMenuOpen={() => {
setIsFocussed(true)
}}
onMenuClose={() => {
setScrollBlocked(true)
setIsFocussed(false)
}}
closeMenuOnScroll={(e) => {
if (
!scrollBlocked &&
e.target?.contains(containerRef.current) &&
e.target !== document
) {
return true
}
}}
closeMenuOnSelect={!isMultiSelect}
blurInputOnSelect={!isMultiSelect}
styles={{ menuPortal: (base) => ({ ...base, zIndex: 60 }) }}
hideSelectedOptions={false}
menuPortalTarget={portalRef?.current?.lastChild || document.body}
menuPlacement="auto"
backspaceRemovesValue={false}
classNamePrefix="react-select"
placeholder={placeholder}
className="react-select-container"
onCreateOption={handleOnCreateOption}
components={SelectComponents}
/>
}
{isFocussed && enableSearch && <div className="w-full h-5" />}
</div>
</div>
)
}
)
const GetSelect = React.forwardRef(
(
{ isCreatable, searchBackend, onCreateOption, handleClose, ...props },
ref
) => {
if (isCreatable) {
return searchBackend ? (
<AsyncCreatablePrimitive
ref={ref}
defaultOptions={true}
onCreateOption={onCreateOption}
isSearchable
loadOptions={searchBackend}
{...props}
/>
) : (
<CreatablePrimitive
{...props}
isSearchable
ref={ref}
onCreateOption={onCreateOption}
/>
)
} else if (searchBackend) {
return (
<AsyncPrimitive
ref={ref}
defaultOptions={true}
loadOptions={searchBackend}
{...props}
/>
)
}
return <Primitive ref={ref} {...props} />
}
)
export default SSelect

View File

@@ -0,0 +1,190 @@
import clsx from "clsx"
import React, { ComponentPropsWithRef, forwardRef } from "react"
import {
ContainerProps,
GroupBase,
IndicatorsContainerProps,
ValueContainerProps,
} from "react-select"
import InputError from "../../../../atoms/input-error"
type AdjacentContainerProps = {
label?: string
htmlFor?: string
helperText?: string
required?: boolean
name?: string
errors?: Record<string, unknown>
children?: React.ReactNode
} & ComponentPropsWithRef<"div">
export const AdjacentContainer = forwardRef<
HTMLDivElement,
AdjacentContainerProps
>(
(
{
label,
helperText,
required,
errors,
name,
children,
}: AdjacentContainerProps,
ref
) => {
return (
<div className="flex flex-col gap-y-xsmall w-full" ref={ref}>
{label && (
<label
className="inter-small-semibold text-grey-50"
id={`${name}_label`}
>
{label}
{required && <span className="text-rose-50">*</span>}
</label>
)}
{children}
{name && errors ? (
<InputError errors={errors} name={name} className="-mt-0.5" />
) : helperText ? (
<p className="inter-small-regular text-grey-50">{helperText}</p>
) : null}
</div>
)
}
)
export const SelectContainer = <
Option,
IsMulti extends boolean,
Group extends GroupBase<Option>
>({
innerProps,
selectProps: { isDisabled, isRtl },
hasValue,
cx,
className,
children,
}: ContainerProps<Option, IsMulti, Group>) => {
return (
<div
{...innerProps}
className={cx(
{
"--is-disabled": isDisabled,
"--is-rtl": isRtl,
"--has-value": hasValue,
},
clsx(
"relative pointer-events-auto",
{ "text-grey-40": isDisabled },
className
)
)}
>
{children}
</div>
)
}
export const ValueContainer = <
Option,
IsMulti extends boolean,
Group extends GroupBase<Option>
>(
props: ValueContainerProps<Option, IsMulti, Group>
) => {
const {
className,
children,
cx,
innerProps,
isMulti,
hasValue,
selectProps: { value, inputValue, label, selectedPlaceholder },
} = props
if (isMulti && Array.isArray(value)) {
return (
<div
{...innerProps}
className={cx(
{
"value-container": true,
"value-container--is-multi": isMulti,
"value-container--has-value": hasValue,
},
clsx(
"group flex items-center flex-wrap relative scrolling-touch overflow-hidden flex-1",
{
"gap-2xsmall": isMulti,
},
className
)
)}
>
{value?.length > 0 && (
<div className="h-7 bg-grey-20 text-grey-50 px-small inter-small-semibold flex items-center rounded-rounded gap-x-2xsmall cursor-default">
<span>{value.length}</span>
</div>
)}
<div className="relative grow">
{children}
{value?.length > 0 && inputValue === "" && (
<span className="absolute top-1/2 -translate-y-1/2 inter-base-regular text-grey-50">
{selectedPlaceholder || label || "Selected"}
</span>
)}
</div>
</div>
)
}
return (
<div
{...innerProps}
className={cx(
{
"value-container": true,
"value-container--is-multi": isMulti,
"value-container--has-value": hasValue,
},
clsx(
"flex items-center flex-wrap relative scrolling-touch overflow-hidden flex-1",
{
"gap-2xsmall": isMulti,
},
className
)
)}
>
{children}
</div>
)
}
export const IndicatorsContainer = <
Option,
IsMulti extends boolean,
Group extends GroupBase<Option>
>({
className,
cx,
innerProps,
children,
}: IndicatorsContainerProps<Option, IsMulti, Group>) => {
return (
<div
{...innerProps}
className={cx(
{
"indicators-container": true,
},
clsx("text-grey-50 flex items-center gap-x-small px-small", className)
)}
>
{children}
</div>
)
}

View File

@@ -0,0 +1,149 @@
import clsx from "clsx"
import {
ClearIndicatorProps,
ControlProps,
DropdownIndicatorProps,
GroupBase,
LoadingIndicatorProps,
} from "react-select"
import Spinner from "../../../../atoms/spinner"
import ChevronDownIcon from "../../../../fundamentals/icons/chevron-down"
import XCircleIcon from "../../../../fundamentals/icons/x-circle-icon"
const Control = <
Option,
IsMulti extends boolean,
Group extends GroupBase<Option>
>({
className,
cx,
children,
innerRef,
innerProps,
isDisabled,
isFocused,
menuIsOpen,
selectProps: { size, customStyles, name },
}: ControlProps<Option, IsMulti, Group>) => {
return (
<div
ref={innerRef}
{...innerProps}
id={name}
className={cx(
{
control: true,
"control--is-disabled": isDisabled,
"control--is-focused": isFocused,
"control--menu-is-open": menuIsOpen,
},
clsx(
"flex p-0 overflow-hidden rounded-rounded border border-gray-20 bg-grey-5 focus-within:shadow-cta transition-colors focus-within:border-violet-60 box-border pl-small",
{
"h-xlarge": size === "sm",
"h-10": size === "md" || !size,
},
className,
customStyles?.control
)
)}
>
<div
className={clsx(
"flex items-center flex-1",
customStyles?.inner_control
)}
>
{children}
</div>
</div>
)
}
export default Control
export const DropdownIndicator = <
Option,
IsMulti extends boolean,
Group extends GroupBase<Option>
>({
innerProps,
cx,
children,
className,
selectProps: { menuIsOpen },
}: DropdownIndicatorProps<Option, IsMulti, Group>) => {
return (
<div
{...innerProps}
className={cx(
{
indicator: true,
"dropdown-indicator": true,
},
clsx(
"transition-all",
{
"rotate-180": menuIsOpen,
},
className
)
)}
>
{children || <ChevronDownIcon size={16} />}
</div>
)
}
export const LoadingIndicator = <
Option,
IsMulti extends boolean,
Group extends GroupBase<Option>
>({
innerProps,
className,
cx,
}: LoadingIndicatorProps<Option, IsMulti, Group>) => {
return (
<div
{...innerProps}
className={cx(
{
indicator: true,
"loading-indicator": true,
},
className
)}
>
<Spinner size="small" variant="secondary" />
</div>
)
}
export const ClearIndicator = <
Option,
IsMulti extends boolean,
Group extends GroupBase<Option>
>({
innerProps,
className,
cx,
children,
}: ClearIndicatorProps<Option, IsMulti, Group>) => {
return (
<div
{...innerProps}
role="button"
aria-label="Clear selected options"
className={cx(
{
indicator: true,
"clear-indicator": true,
},
className
)}
>
{children || <XCircleIcon size={16} />}
</div>
)
}

View File

@@ -0,0 +1,39 @@
import {
AdjacentContainer,
IndicatorsContainer,
SelectContainer,
ValueContainer,
} from "./containers"
import Control, {
ClearIndicator,
DropdownIndicator,
LoadingIndicator,
} from "./control"
import Input from "./input"
import Menu, { LoadingMessage, MenuList, Option } from "./menu"
import Placeholder from "./placeholder"
import SingleValue from "./single-value"
const Components = {
ClearIndicator,
DropdownIndicator,
LoadingIndicator,
SelectContainer,
Control,
Input,
Placeholder,
Menu,
MenuList,
Option,
SingleValue,
MultiValue: () => null,
MultiValueContainer: () => null,
MultiValueRemove: () => null,
ValueContainer,
IndicatorsContainer,
LoadingMessage,
IndicatorSeparator: null,
}
export { AdjacentContainer }
export default Components

View File

@@ -0,0 +1,36 @@
import clsx from "clsx"
import { GroupBase, InputProps } from "react-select"
import SelectPrimitives from "./select-primitives"
const Input = <
Option,
IsMulti extends boolean,
Group extends GroupBase<Option>
>(
props: InputProps<Option, IsMulti, Group>
) => {
const { className, cx, value, inputClassName } = props
return (
<div
data-value={value || ""}
className={cx({ "input-container": true }, className)}
>
<SelectPrimitives.Input
{...props}
className={cx(
{
input: true,
"input--is-disabled": props.isDisabled ? true : false,
},
clsx(
"inter-base-regular text-grey-90 caret-violet-60",
inputClassName
)
)}
/>
</div>
)
}
export default Input

View File

@@ -0,0 +1,343 @@
import clsx from "clsx"
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
import {
ActionMeta,
CX,
GroupBase,
MenuListProps,
MenuProps,
NoticeProps,
OnChangeValue,
OptionProps,
OptionsOrGroups,
PropsValue,
} from "react-select"
import Button from "../../../../fundamentals/button"
import CheckIcon from "../../../../fundamentals/icons/check-icon"
import ListArrowIcon from "../../../../fundamentals/icons/list-arrow-icon"
import { hasPrefix, hasSuffix, optionIsDisabled } from "../utils"
import SelectPrimitives from "./select-primitives"
const Menu = <
Option,
IsMulti extends boolean,
Group extends GroupBase<Option>
>({
className,
cx,
children,
innerProps,
innerRef,
placement,
selectProps: { onMenuClose, menuIsOpen, customStyles, styles },
}: MenuProps<Option, IsMulti, Group>) => {
useEffect(() => {
const closeOnResize = () => {
if (menuIsOpen) {
onMenuClose()
}
}
window.addEventListener("resize", closeOnResize)
return () => {
window.removeEventListener("resize", closeOnResize)
}
}, [menuIsOpen, onMenuClose])
return (
<div
{...innerProps}
ref={innerRef}
style={{ ...styles?.menu }}
className={cx(
{ menu: true },
clsx(
"absolute w-full overflow-hidden border-border z-[60] bg-grey-0 rounded-rounded border border-grey-20 shadow-dropdown mb-base",
{
"top-[calc(100%+8px)]": placement === "bottom",
"bottom-full": placement === "top",
},
className,
customStyles?.menu
)
)}
>
{children}
</div>
)
}
export default Menu
type SelectAllOptionProps<
Option,
IsMulti extends boolean,
Group extends GroupBase<Option>
> = {
cx: CX
onChange: (
newValue: OnChangeValue<Option, IsMulti>,
actionMeta: ActionMeta<Option>
) => void
options: OptionsOrGroups<Option, Group>
value: PropsValue<Option>
}
const SelectAllOption = <
Option,
IsMulti extends boolean,
Group extends GroupBase<Option>
>({
cx,
onChange,
options,
value,
}: SelectAllOptionProps<Option, IsMulti, Group>) => {
const [isFocused, setIsFocused] = useState(false)
const ref = useRef<HTMLButtonElement>(null)
const isAllSelected = useMemo(() => {
if (Array.isArray(value)) {
const selectableOptions = options.filter((o) => !optionIsDisabled(o))
return value.length === selectableOptions.length
}
return false
}, [options, value])
const onClick = useCallback(() => {
if (isAllSelected) {
onChange([] as unknown as OnChangeValue<Option, IsMulti>, {
action: "deselect-option",
option: [] as unknown as Option,
})
} else {
const selectableOptions = options.filter((o) => !optionIsDisabled(o))
onChange(selectableOptions as unknown as OnChangeValue<Option, IsMulti>, {
action: "select-option",
option: selectableOptions as unknown as Option,
})
}
}, [isAllSelected, onChange, options])
useEffect(() => {
setIsFocused(
document.activeElement !== null && document.activeElement === ref.current
)
return () => {
setIsFocused(false)
}
}, [])
return (
<Button
ref={ref}
variant="secondary"
size="small"
className={cx(
{
option: true,
"option--is-focused": isFocused,
},
clsx("mx-base mb-2xsmall h-xlarge")
)}
type="button"
onClick={onClick}
>
<ListArrowIcon size={16} />
<span className="inter-small-semibold">
{!isAllSelected ? "Select All" : "Deselect All"}
</span>
</Button>
)
}
export const MenuList = <
Option,
IsMulti extends boolean,
Group extends GroupBase<Option>
>(
props: MenuListProps<Option, IsMulti, Group>
) => {
const {
className,
children,
cx,
isMulti,
selectProps: { selectAll, value, onChange },
options,
} = props
return (
<SelectPrimitives.MenuList
{...props}
className={cx(
{
"menu-list": true,
"menu-list--is-multi": isMulti,
},
clsx("overflow-y-auto flex flex-col py-xsmall no-scrollbar", className)
)}
>
{isMulti && selectAll && (
<SelectAllOption
cx={cx}
onChange={onChange}
options={options}
value={value}
/>
)}
{children}
</SelectPrimitives.MenuList>
)
}
export const LoadingMessage = <
Option,
IsMulti extends boolean,
Group extends GroupBase<Option>
>({
innerProps,
cx,
className,
selectProps: { size },
}: NoticeProps<Option, IsMulti, Group>) => {
const Skeleton = () => {
return (
<div
className={clsx(
"w-full flex items-center px-base transition-colors hover:bg-grey-5",
{
"h-xlarge": size === "sm",
"h-10": size === "md" || !size,
}
)}
>
<div className="bg-grey-10 animate-pulse w-1/4 h-xsmall rounded-rounded" />
</div>
)
}
return (
<div
{...innerProps}
className={cx(
{
"menu-notice": true,
"menu-notice--loading": true,
},
clsx("flex flex-col", className)
)}
>
<Skeleton />
<Skeleton />
<Skeleton />
</div>
)
}
export const Option = <
Option,
IsMulti extends boolean,
Group extends GroupBase<Option>
>(
props: OptionProps<Option, IsMulti, Group>
) => {
const {
isSelected,
isDisabled,
isFocused,
children,
cx,
className,
innerProps,
innerRef,
selectProps: { hideSelectedOptions, isMulti, size, truncateOption },
} = props
const prefix = hasPrefix(props.data) ? props.data.prefix : null
const suffix = hasSuffix(props.data) ? props.data.suffix : null
return (
<div
role="button"
className={cx(
{
option: true,
"option--is-selected": isSelected,
"option--is-disabled": isDisabled,
"option--is-focused": isFocused,
},
clsx(
"flex items-center justify-between py-xsmall px-base transition-colors hover:bg-grey-5",
{
"text-grey-30 select-none cursor-not-allowed": isDisabled,
"bg-grey-10": isFocused && !isDisabled,
hidden: hideSelectedOptions && isSelected,
},
{
"h-xlarge": size === "sm",
"h-10": size === "md" || !size,
},
className
)
)}
ref={innerRef}
data-diabled={isDisabled ? true : undefined}
aria-disabled={isDisabled ? true : undefined}
tabIndex={isDisabled ? -1 : 0}
{...innerProps}
>
<div className="flex items-center gap-x-small flex-1">
{isMulti && (
<CheckboxAdornment isSelected={isSelected} isDisabled={isDisabled} />
)}
<div
className={clsx(
"flex items-center justify-between gap-x-xsmall inter-base-regular flex-1",
{
truncate: !!truncateOption,
}
)}
>
{prefix && <span className="inter-base-semibold">{prefix}</span>}
<span className="w-full">{children}</span>
{suffix && (
<span className="inter-base-regular justify-self-end text-grey-50">
{suffix}
</span>
)}
</div>
</div>
{!isMulti && (
<div className="w-5 ml-xsmall">
{isSelected && <CheckIcon size={16} />}
</div>
)}
</div>
)
}
const CheckboxAdornment = ({
isSelected,
isDisabled,
}: Pick<OptionProps, "isSelected" | "isDisabled">) => {
return (
<div
className={clsx(
`w-base h-base flex justify-center text-grey-0 border-grey-30 border rounded-base transition-colors`,
{
"bg-violet-60 border-violet-60": isSelected,
"bg-grey-5": isDisabled,
}
)}
>
<span className="self-center">
{isSelected && <CheckIcon size={10} />}
</span>
</div>
)
}

View File

@@ -0,0 +1,113 @@
import clsx from "clsx";
import React from "react";
import {
GroupBase,
MultiValueGenericProps,
MultiValueProps,
MultiValueRemoveProps
} from "react-select";
import CrossIcon from "../../../../fundamentals/icons/cross-icon";
import { optionIsFixed } from "../utils";
const MultiValue = <
Option,
IsMulti extends boolean,
Group extends GroupBase<Option>
>({
children,
className,
cx,
innerProps,
isDisabled,
isFocused,
removeProps,
components,
selectProps,
data,
}: MultiValueProps<Option, IsMulti, Group>) => {
const { Container, Label, Remove } = components
return (
<Container
data={data}
innerProps={{
className: cx({
"multi-value": true,
"multi-value--is-disabled": isDisabled,
}, clsx({
"bg-grey-70 text-grey-0": isFocused
})),
...innerProps,
}}
selectProps={selectProps}
>
<Label
data={data}
innerProps={{
className: cx(
{
"multi-value__label": true,
},
className
),
}}
selectProps={selectProps}
>
{children}
</Label>
<Remove
data={data}
selectProps={selectProps}
innerProps={{
className: cx(
{
"multi-value__remove": true,
},
className
),
"aria-label": `Remove ${children || "option"}`,
...removeProps,
}}
/>
</Container>
)
}
export default MultiValue
export const MultiValueContainer = <
Option,
IsMulti extends boolean,
Group extends GroupBase<Option>
>({
children,
innerProps: { className },
}: MultiValueGenericProps<Option, IsMulti, Group>) => {
return (
<span className={clsx("flex items-center gap-x-2xsmall py-2xsmall pl-small pr-2.5 rounded-rounded transition-colors bg-grey-20 text-grey-50 inter-small-semibold", className)}>{children}</span>
)
}
export const MultiValueRemove = <
Option = unknown,
IsMulti extends boolean = boolean,
Group extends GroupBase<Option> = GroupBase<Option>
>(
props: MultiValueRemoveProps<Option, IsMulti, Group>
) => {
const { children, innerProps, data } = props;
if (optionIsFixed(data) && data.isFixed) {
return null;
}
return (
<div
{...innerProps}
role="button"
className="text-grey-40"
>
{children || <CrossIcon size={16} />}
</div>
);
};

View File

@@ -0,0 +1,33 @@
import clsx from "clsx"
import React from "react"
import { GroupBase, PlaceholderProps } from "react-select"
const Placeholder = <
Option,
IsMulti extends boolean,
Group extends GroupBase<Option>
>({
innerProps,
children,
className,
cx,
}: PlaceholderProps<Option, IsMulti, Group>) => {
return (
<div
{...innerProps}
className={cx(
{
placeholder: true,
},
clsx(
"absolute top-1/2 -translate-y-1/2 select-none inter-base-regular text-grey-50",
className
)
)}
>
{children}
</div>
)
}
export default Placeholder

View File

@@ -0,0 +1,3 @@
import { components as SelectPrimitives } from "react-select"
export default SelectPrimitives

View File

@@ -0,0 +1,42 @@
import clsx from "clsx"
import React from "react"
import { GroupBase, SingleValueProps } from "react-select"
import { hasPrefix } from "../utils"
const SingleValue = <
Option,
IsMulti extends boolean,
Group extends GroupBase<Option>
>({
innerProps,
children,
cx,
className,
isDisabled,
data,
}: SingleValueProps<Option, IsMulti, Group>) => {
const prefix = hasPrefix(data) ? data.prefix : null
return (
<div
{...innerProps}
className={cx(
{
"single-value": true,
"single-value--is-disabled": isDisabled,
},
clsx(
"overflow-hidden absolute top-1/2 -translate-y-1/2 whitespace-nowrap overflow-ellipsis",
className
)
)}
>
<div className="flex items-center gap-x-xsmall inter-base-regular">
{prefix && <span className="inter-base-semibold">{prefix}</span>}
{children}
</div>
</div>
)
}
export default SingleValue

View File

@@ -0,0 +1,40 @@
import type { MutableRefObject, ReactElement, RefAttributes } from "react"
import React, { forwardRef } from "react"
import type { GroupBase, SelectInstance } from "react-select"
import type { CreatableProps } from "react-select/creatable"
import CreatableReactSelect from "react-select/creatable"
import { AdjacentContainer } from "../components"
import { useSelectProps } from "../use-select-props"
export type CreatableSelectComponent = <
Option = unknown,
IsMulti extends boolean = false,
Group extends GroupBase<Option> = GroupBase<Option>
>(
props: CreatableProps<Option, IsMulti, Group> &
RefAttributes<SelectInstance<Option, IsMulti, Group>>
) => ReactElement
const CreatableSelect = forwardRef(
<Option, IsMulti extends boolean, Group extends GroupBase<Option>>(
props: CreatableProps<Option, IsMulti, Group>,
ref:
| ((instance: SelectInstance<Option, IsMulti, Group> | null) => void)
| MutableRefObject<SelectInstance<Option, IsMulti, Group> | null>
| null
) => {
const { label, helperText, required, ...rest } = useSelectProps(props)
return (
<AdjacentContainer
label={label}
helperText={helperText}
required={required}
>
<CreatableReactSelect ref={ref} {...rest} />
</AdjacentContainer>
)
}
) as CreatableSelectComponent
export default CreatableSelect

View File

@@ -0,0 +1,4 @@
import NextCreateableSelect from "./createable-select"
import NextSelect from "./select"
export { NextSelect, NextCreateableSelect }

View File

@@ -0,0 +1,74 @@
import { ComponentProps } from "react"
import { GroupBase } from "react-select"
export type SelectSize = "sm" | "md"
type SelectComponent = "control" | "inner_control" | "menu"
type SelectComponentStyles = Partial<
Record<SelectComponent, ComponentProps<"div">["className"]>
>
declare module "react-select/dist/declarations/src/Select" {
export interface Props<
Option,
IsMulti extends boolean,
Group extends GroupBase<Option>
> {
/**
* An optional label to display above the select.
*
* @defaultValue `undefined`
*/
label?: string
/**
* An optional flag to indicate if the select is required.
* If set to `true`, an asterisk will be displayed next to the label.
*
* @defaultValue `false`
*/
required?: boolean
/**
* An optional string to display when multiple options are selected in a Select where isMulti is true.
*
* @defaultValue `undefined`
*/
selectedPlaceholder?: string
/**
* An optional flag to indicate if the size of the select.
*
* @defaultValue `"md"`
*/
size?: SelectSize
/**
* An optinal helper text to display below the select.
*
* @defaultValue `undefined`
*/
helperText?: string
/**
* Errors provided by a containing form.
*
* @defaultValue `undefined`
*/
errors?: Record<string, unknown>
/**
* An optional flag to indicate if the select should be able to select all options.
*
* @defaultValue `false`
*/
selectAll?: boolean
/**
* An optinal object that can be used to override the default styles of the select components.
*
* @defaultValue `undefined`
*/
customStyles?: SelectComponentStyles
/**
*
* @defaultValue false
*
*/
truncateOption?: boolean
}
}

View File

@@ -0,0 +1,62 @@
import {
forwardRef,
MutableRefObject,
ReactElement,
RefAttributes,
useContext,
useRef,
} from "react"
import type { GroupBase, Props, SelectInstance } from "react-select"
import ReactSelect from "react-select"
import { ModalContext } from "../../../modal"
import { AdjacentContainer } from "../components"
import { useSelectProps } from "../use-select-props"
export type SelectComponent = <
Option = unknown,
IsMulti extends boolean = true,
Group extends GroupBase<Option> = GroupBase<Option>
>(
props: Props<Option, IsMulti, Group> &
RefAttributes<SelectInstance<Option, IsMulti, Group>>
) => ReactElement
const Select = forwardRef(
<Option, IsMulti extends boolean, Group extends GroupBase<Option>>(
props: Props<Option, IsMulti, Group>,
ref:
| ((instance: SelectInstance<Option, IsMulti, Group> | null) => void)
| MutableRefObject<SelectInstance<Option, IsMulti, Group> | null>
| null
) => {
const selectProps = useSelectProps(props)
const { label, required, helperText, name, errors } = selectProps
const containerRef = useRef<HTMLDivElement>(null)
const { portalRef } = useContext(ModalContext)
return (
<AdjacentContainer
ref={containerRef}
label={label}
htmlFor={name}
helperText={helperText}
required={required}
name={name}
errors={errors}
>
<ReactSelect
aria-labelledby={`${name}_label`}
ref={ref}
name={name}
{...selectProps}
menuPortalTarget={portalRef?.current?.lastChild || null}
menuShouldBlockScroll={true}
/>
</AdjacentContainer>
)
}
) as SelectComponent
export default Select

View File

@@ -0,0 +1,85 @@
import isEqual from "lodash/isEqual"
import { useEffect, useState } from "react"
import { ActionMeta, GroupBase, OnChangeValue, Props } from "react-select"
import Components from "./components"
import { formatOptionLabel, hasLabel } from "./utils"
export const useSelectProps = <
Option,
IsMulti extends boolean = false,
Group extends GroupBase<Option> = GroupBase<Option>
>({
components = {},
isMulti,
closeMenuOnScroll = true,
hideSelectedOptions = false,
closeMenuOnSelect,
label,
size,
options,
onChange: changeFn,
styles,
...props
}: Props<Option, IsMulti, Group>): Props<Option, IsMulti, Group> => {
const [stateOptions, setStateOptions] = useState(options || [])
const sortOptions = (values: Option[]) => {
const tmp = values || []
const unselectedOptions = stateOptions.filter(
(option) => !tmp.find((op) => isEqual(op, option))
)
const orderedNewOptions = tmp.sort((a, b) => {
if (hasLabel(a) && hasLabel(b)) {
return a.label > b.label ? 1 : b.label > a.label ? -1 : 0
}
return 0
})
setStateOptions(orderedNewOptions.concat(unselectedOptions as Option[]))
}
useEffect(() => {
if (isMulti && options) {
sortOptions(props.value as Option[])
} else {
setStateOptions(options || [])
}
}, [options, props.value, isMulti])
const onChange = (
newValue: OnChangeValue<Option, IsMulti>,
actionMeta: ActionMeta<Option>
) => {
if (isMulti) {
sortOptions(newValue as Option[])
}
if (changeFn) {
changeFn(newValue, actionMeta)
}
}
return {
label,
components: Components,
styles: {
menuPortal: (base) => ({ ...base, zIndex: 60 }),
...styles,
},
isMulti,
closeMenuOnScroll: true,
closeMenuOnSelect:
closeMenuOnSelect !== undefined ? closeMenuOnSelect : isMulti !== true,
hideSelectedOptions,
menuPosition: "fixed",
maxMenuHeight: size === "sm" ? 154 : 188,
formatOptionLabel,
size,
options: stateOptions,
onChange,
...props,
}
}

View File

@@ -0,0 +1,83 @@
import { omit } from "lodash"
import Highlighter from "react-highlight-words"
import type {
CommonPropsAndClassName,
FormatOptionLabelMeta,
GroupBase,
} from "react-select"
export const cleanCommonProps = <
Option,
IsMulti extends boolean,
Group extends GroupBase<Option>,
AdditionalProps
>(
props: Partial<CommonPropsAndClassName<Option, IsMulti, Group>> &
AdditionalProps
) => {
const innerProps = omit(props, [
"className",
"clearValue",
"cx",
"getStyles",
"getValue",
"hasValue",
"isMulti",
"isRtl",
"options",
"selectOption",
"selectProps",
"setValue",
"theme",
])
return { ...innerProps }
}
export const optionIsFixed = (
option: unknown
): option is { isFixed: unknown } =>
typeof option === "object" && option !== null && "isFixed" in option
export const optionIsDisabled = (
option: unknown
): option is { isDisabled: boolean } =>
typeof option === "object" && option !== null && "isDisabled" in option
export const hasLabel = (option: unknown): option is { label: string } => {
return typeof option === "object" && option !== null && "label" in option
}
export const hasPrefix = (option: unknown): option is { prefix: string } => {
return typeof option === "object" && option !== null && "prefix" in option
}
export const hasSuffix = (option: unknown): option is { suffix: string } => {
return typeof option === "object" && option !== null && "suffix" in option
}
export const isCreateOption = (
option: unknown
): option is { __isNew__: true } => {
return typeof option === "object" && option !== null && "__isNew__" in option
}
export const formatOptionLabel = <Option,>(
option: Option,
{ inputValue }: FormatOptionLabelMeta<Option>
) => {
if (!hasLabel(option)) {
return
}
if (isCreateOption(option)) {
return option.label
}
return (
<Highlighter
searchWords={[inputValue]}
textToHighlight={option.label}
highlightClassName="bg-orange-10"
/>
)
}

View File

@@ -0,0 +1,314 @@
import clsx from "clsx"
import React from "react"
import {
ClearIndicatorProps,
components,
ContainerProps,
DropdownIndicatorProps,
GroupBase,
InputProps,
MenuListProps,
MenuProps,
MultiValueProps,
NoticeProps,
OptionProps,
PlaceholderProps,
SingleValueProps,
} from "react-select"
import CheckIcon from "../../fundamentals/icons/check-icon"
import ChevronDownIcon from "../../fundamentals/icons/chevron-down"
import SearchIcon from "../../fundamentals/icons/search-icon"
import XCircleIcon from "../../fundamentals/icons/x-circle-icon"
const MultiValueLabel = <
Option,
IsMulti extends boolean,
Group extends GroupBase<Option>
>({
innerProps,
data,
selectProps: { value, isSearchable, menuIsOpen },
children,
}: MultiValueProps<Option, IsMulti, Group>) => {
const isLast = Array.isArray(value) ? value[value.length - 1] === data : true
if (menuIsOpen && isSearchable) {
return null
}
return (
<div
{...innerProps}
className={clsx("bg-grey-5 mx-0 inter-base-regular p-0", {
"after:content-[',']": !isLast,
})}
>
{children}
</div>
)
}
const Menu = <
Option,
IsMulti extends boolean,
Group extends GroupBase<Option>
>({
className,
...props
}: MenuProps<Option, IsMulti, Group>) => {
return (
<components.Menu
className={clsx("!rounded-rounded", {
"-mt-1 z-60":
!props.selectProps.isSearchable && props.menuPlacement === "bottom",
"mb-3 z-60":
!props.selectProps.isSearchable && props.menuPlacement === "top",
})}
{...props}
>
{props.children}
</components.Menu>
)
}
const MenuList = <
Option,
IsMulti extends boolean,
Group extends GroupBase<Option>
>({
className,
...props
}: MenuListProps<Option, IsMulti, Group>) => {
return (
<components.MenuList
className={clsx(className, "!rounded-rounded !no-scrollbar")}
{...props}
/>
)
}
const Placeholder = <
Option,
IsMulti extends boolean,
Group extends GroupBase<Option>
>(
props: PlaceholderProps<Option, IsMulti, Group>
) => {
return props.selectProps.menuIsOpen ? null : (
<components.Placeholder {...props} className="!mx-0 !text-grey-40" />
)
}
const SingleValue = <
Option,
IsMulti extends boolean,
Group extends GroupBase<Option>
>({
children,
...props
}: SingleValueProps<Option, IsMulti, Group>) => {
if (props.selectProps.menuIsOpen && props.selectProps.isSearchable) {
return null
}
return <components.SingleValue {...props}>{children}</components.SingleValue>
}
const DropdownIndicator = <
Option,
IsMulti extends boolean,
Group extends GroupBase<Option>
>({
innerProps,
selectProps: { menuIsOpen },
}: DropdownIndicatorProps<Option, IsMulti, Group>) => {
return (
<div {...innerProps} className="flex items-center justify-center">
<ChevronDownIcon
size={16}
className={clsx("text-grey-50 transition-all", {
"rotate-180": menuIsOpen,
})}
/>
</div>
)
}
const SelectContainer = <
Option,
IsMulti extends boolean,
Group extends GroupBase<Option>
>(
props: ContainerProps<Option, IsMulti, Group>
) => {
return (
<div className="bg-grey-5 h-10 border border-grey-20 rounded-rounded focus-within:shadow-cta focus-within:border-violet-60 px-small">
<components.SelectContainer {...props} />
</div>
)
}
const Input = <
Option,
IsMulti extends boolean,
Group extends GroupBase<Option>
>(
props: InputProps<Option, IsMulti, Group>
) => {
if (
props.isHidden ||
!props.selectProps.menuIsOpen ||
!props.selectProps.isSearchable
) {
return <components.Input {...props} className="pointer-events-none" />
}
return (
<div className="w-full flex items-center h-full space-between">
<div className="w-full flex items-center">
<span className="text-grey-40 mr-2">
<SearchIcon size={16} />
</span>
<components.Input {...props} />
</div>
</div>
)
}
const ClearIndicator = <
Option,
IsMulti extends boolean,
Group extends GroupBase<Option>
>({
innerProps,
selectProps: { isMulti, menuIsOpen },
}: ClearIndicatorProps<Option, IsMulti, Group>) => {
if (menuIsOpen || isMulti) {
return <></>
}
return (
<div
{...innerProps}
className="hover:bg-grey-10 text-grey-50 rounded cursor-pointer"
>
<XCircleIcon size={16} />
</div>
)
}
const CheckboxAdornment = ({ isSelected }: { isSelected: boolean }) => {
return (
<div
className={clsx(
`w-5 h-5 flex justify-center text-grey-0 border-grey-30 border rounded-base`,
{
"bg-violet-60": isSelected,
}
)}
>
<span className="self-center">
{isSelected && <CheckIcon size={16} />}
</span>
</div>
)
}
const RadioAdornment = ({ isSelected }: { isSelected: boolean }) => {
return (
<div
className={clsx(
"radio-outer-ring outline-0",
"shrink-0 w-[20px] h-[20px] rounded-circle",
{
"shadow-[0_0_0_1px] shadow-[#D1D5DB]": !isSelected,
"shadow-[0_0_0_2px] shadow-violet-60": isSelected,
}
)}
>
{isSelected && (
<div
className={clsx(
"group flex items-center justify-center w-full h-full relative",
"after:absolute after:inset-0 after:m-auto after:block after:w-[12px] after:h-[12px] after:bg-violet-60 after:rounded-circle"
)}
/>
)}
</div>
)
}
const NoOptionsMessage = <
Option,
IsMulti extends boolean,
Group extends GroupBase<Option>
>({
innerProps,
selectProps: { isLoading },
}: NoticeProps<Option, IsMulti, Group>) => {
return (
<div
className="text-grey-50 inter-small-semibold text-center p-xsmall"
{...innerProps}
>
<p>{isLoading ? "Loading..." : "No options"}</p>
</div>
)
}
const Option = <
Option,
IsMulti extends boolean,
Group extends GroupBase<Option>
>({
className,
...props
}: OptionProps<Option, IsMulti, Group>) => {
return (
<components.Option
{...props}
className="my-1 py-0 px-2 bg-grey-0 active:bg-grey-0"
>
<div
className={`item-renderer h-full hover:bg-grey-10 py-2 px-2 cursor-pointer rounded`}
>
<div className="items-center h-full flex">
{props.data?.value !== "all" && props.data?.label !== "Select All" ? (
<>
{props.isMulti ? (
<CheckboxAdornment isSelected={props.isSelected} />
) : (
<RadioAdornment isSelected={props.isSelected} />
)}
<span className="ml-3 text-grey-90 inter-base-regular">
{props.data.label}
</span>
</>
) : (
<span className="text-grey-90 inter-base-regular">
{props.data.label}
</span>
)}
</div>
</div>
</components.Option>
)
}
export const SelectComponents = {
Menu,
MenuList,
Placeholder,
SingleValue,
DropdownIndicator,
SelectContainer,
Input,
ClearIndicator,
CheckboxAdornment,
RadioAdornment,
NoOptionsMessage,
Option,
IndicatorSeparator: () => null,
MultiValueRemove: () => null,
MultiValueLabel,
}