feat(dashboard): Regions domain (#6534)

**What**
- Implements new Region domain design
- Adds new SplitView component for managing adding nested relations in FocusModals, eg. adding countries to a region.
- Adds new Combobox component for multi select fields in forms

**medusajs/ui**
- Fix styling of RadioGroup.Choicebox component

CLOSES CORE-1650, CORE-1671
This commit is contained in:
Kasper Fabricius Kristensen
2024-02-29 14:16:14 +01:00
committed by GitHub
parent 0b9fcb6324
commit 44a5567d0d
46 changed files with 3163 additions and 885 deletions
@@ -1,17 +1,18 @@
import { Combobox as Primitive } from "@headlessui/react"
import { EllipseMiniSolid, TrianglesMini } from "@medusajs/icons"
import { Product } from "@medusajs/medusa"
import { clx } from "@medusajs/ui"
import {
Combobox as PrimitiveCombobox,
ComboboxItem as PrimitiveComboboxItem,
ComboboxItemCheck as PrimitiveComboboxItemCheck,
ComboboxItemValue as PrimitiveComboboxItemValue,
ComboboxList as PrimitiveComboboxList,
ComboboxProvider as PrimitiveComboboxProvider,
} from "@ariakit/react"
import { EllipseMiniSolid, TrianglesMini, XMarkMini } from "@medusajs/icons"
import { Text, clx } from "@medusajs/ui"
import * as Popover from "@radix-ui/react-popover"
import { useAdminProducts } from "medusa-react"
import { matchSorter } from "match-sorter"
import {
ComponentPropsWithoutRef,
ElementRef,
ReactNode,
createContext,
forwardRef,
useContext,
useEffect,
useImperativeHandle,
useMemo,
useRef,
@@ -24,370 +25,204 @@ type ComboboxOption = {
label: string
}
type ComboboxProps = {
size?: "base" | "small"
interface ComboboxProps
extends Omit<ComponentPropsWithoutRef<"input">, "onChange" | "value"> {
value?: string[]
onChange?: (value?: string[]) => void
options: ComboboxOption[]
value: string
}
export const Combobox = ({ size = "base" }: ComboboxProps) => {
const [product, setProduct] = useState<Product | null>(null)
const [query, setQuery] = useState("")
const { products, count, isLoading } = useAdminProducts(
{
q: query,
},
{
keepPreviousData: true,
}
)
return (
<Primitive by="id" value={product} onChange={setProduct}>
<div className="relative">
<div className="relative w-full">
<Primitive.Input
onChange={(e) => setQuery(e.target.value)}
displayValue={(value: Product) => value?.title}
className={clx(
"bg-ui-bg-field shadow-buttons-neutral transition-fg flex w-full select-none items-center justify-between rounded-md outline-none",
"placeholder:text-ui-fg-muted text-ui-fg-base",
"hover:bg-ui-bg-field-hover",
"focus-visible:shadow-borders-interactive-with-active data-[state=open]:!shadow-borders-interactive-with-active",
"aria-[invalid=true]:border-ui-border-error aria-[invalid=true]:shadow-borders-error",
"invalid:border-ui-border-error invalid:shadow-borders-error",
"disabled:!bg-ui-bg-disabled disabled:!text-ui-fg-disabled",
{
"h-8 px-2 py-1.5 txt-compact-small": size === "base",
"h-7 px-2 py-1 txt-compact-small": size === "small",
}
)}
/>
<Primitive.Button className="absolute inset-y-0 right-0 flex items-center pr-2">
<TrianglesMini className="text-ui-fg-muted" aria-hidden="true" />
</Primitive.Button>
</div>
<Primitive.Options className="absolute mt-2 max-h-[200px] w-full overflow-auto z-10 bg-ui-bg-base text-ui-fg-base shadow-elevation-flyout rounded-lg p-1">
{products?.map((p) => (
<Primitive.Option
key={p.id}
value={p}
className={clx(
"bg-ui-bg-base grid cursor-pointer grid-cols-[20px_1fr] gap-x-2 items-center rounded-md px-3 py-2 outline-none transition-colors",
"ui-active:bg-ui-bg-base-hover",
{
"txt-compact-medium data-[state=checked]:txt-compact-medium-plus":
size === "base",
"txt-compact-small data-[state=checked]:txt-compact-medium-plus":
size === "small",
}
)}
>
<div
className="w-5 h-5 flex items-center justify-center"
aria-hidden="true"
>
<EllipseMiniSolid className="ui-selected:block hidden" />
</div>
<span className="block truncate ui-selected:font-medium">
{p.title}
</span>
</Primitive.Option>
))}
</Primitive.Options>
</div>
</Primitive>
)
}
type ComboboxContextValue = {
size: "base" | "small"
open: boolean
setOpen: (open: boolean) => void
}
const ComboboxContext = createContext<ComboboxContextValue | null>(null)
const useComboboxContext = () => {
const context = useContext(ComboboxContext)
if (!context) {
throw new Error(
"Combobox compound components cannot be rendered outside the Combobox component"
)
}
return context
}
const Root = forwardRef<
ElementRef<typeof Primitive>,
Omit<ComponentPropsWithoutRef<typeof Primitive>, "children"> & {
className?: string
size?: "base" | "small"
children: ReactNode
}
>(({ children, className, size = "base", ...props }, ref) => {
const [open, setOpen] = useState(false)
const value = useMemo(() => ({ size, open, setOpen }), [size, open])
return (
<ComboboxContext.Provider value={value}>
<Primitive {...props} ref={ref}>
<Popover.Root open={open} onOpenChange={setOpen}>
{children}
</Popover.Root>
</Primitive>
</ComboboxContext.Provider>
)
})
Root.displayName = "Combobox"
const Trigger = forwardRef<HTMLDivElement, ComponentPropsWithoutRef<"div">>(
({ className, children, ...props }, ref) => {
const { size } = useComboboxContext()
return (
<Popover.Trigger asChild>
<div
className={clx(
"relative bg-ui-bg-field shadow-buttons-neutral transition-fg w-full select-none items-center justify-between rounded-md outline-none",
"hover:bg-ui-bg-field-hover",
"focus-visible:shadow-borders-interactive-with-active data-[state=open]:!shadow-borders-interactive-with-active",
"aria-[invalid=true]:border-ui-border-error aria-[invalid=true]:shadow-borders-error",
"invalid:border-ui-border-error invalid:shadow-borders-error",
"disabled:!bg-ui-bg-disabled disabled:!text-ui-fg-disabled",
{
"h-8 px-2 py-1.5 txt-compact-small": size === "base",
"h-7 px-2 py-1 txt-compact-small": size === "small",
},
className
)}
{...props}
ref={ref}
>
{children}
<Primitive.Button className="absolute inset-y-0 right-0 flex items-center pr-2">
<TrianglesMini className="text-ui-fg-muted" aria-hidden="true" />
</Primitive.Button>
</div>
</Popover.Trigger>
)
}
)
Trigger.displayName = "Combobox.Trigger"
const Value = forwardRef<
ElementRef<typeof Primitive.Input>,
ComponentPropsWithoutRef<typeof Primitive.Input>
>(({ className, ...props }, ref) => {
const { size } = useComboboxContext()
return (
<Primitive.Input
{...props}
ref={ref}
className={clx(
"placeholder:text-ui-fg-muted text-ui-fg-base outline-none bg-transparent w-full",
"disabled:!text-ui-fg-disabled",
{
" txt-compact-small": size === "base",
"txt-compact-small": size === "small",
},
className
)}
/>
)
})
Value.displayName = "Combobox.Value"
const Item = forwardRef<
ElementRef<typeof Primitive.Option>,
Omit<ComponentPropsWithoutRef<typeof Primitive.Option>, "children"> & {
children?: ReactNode
}
>(({ children, className, ...props }, ref) => {
const { size } = useComboboxContext()
return (
<Primitive.Option
{...props}
ref={ref}
className={clx(
"bg-ui-bg-base grid cursor-pointer grid-cols-[20px_1fr] gap-x-2 items-center rounded-md px-2 py-1.5 outline-none transition-colors",
"ui-active:bg-ui-bg-base-hover",
{
"txt-compact-medium data-[state=checked]:txt-compact-medium-plus":
size === "base",
"txt-compact-small data-[state=checked]:txt-compact-medium-plus":
size === "small",
},
className
)}
>
<div
className="w-5 h-5 flex items-center justify-center"
aria-hidden="true"
>
<EllipseMiniSolid className="ui-selected:block hidden" />
</div>
{children}
</Primitive.Option>
)
})
Item.displayName = "Combobox.Item"
const NoResults = forwardRef<
ElementRef<"span">,
ComponentPropsWithoutRef<"span">
>(({ children, className, ...props }, ref) => {
const { size } = useComboboxContext()
const { t } = useTranslation()
return (
<span
{...props}
ref={ref}
className={clx(
"bg-ui-bg-base items-center flex w-full justify-center rounded-md px-2 py-1.5 outline-none transition-colors",
"ui-active:bg-ui-bg-base-hover",
{
"txt-compact-medium data-[state=checked]:txt-compact-medium-plus":
size === "base",
"txt-compact-small data-[state=checked]:txt-compact-medium-plus":
size === "small",
},
className
)}
>
{children ?? t("general.noResultsTitle")}
</span>
)
})
Item.displayName = "Combobox.NoResults"
const Content = forwardRef<
ElementRef<typeof Popover.Content>,
ComponentPropsWithoutRef<typeof Popover.Content>
>(
export const Combobox = forwardRef<HTMLInputElement, ComboboxProps>(
(
{
children,
value: controlledValue,
onChange,
options,
className,
side = "bottom",
sideOffset = 8,
collisionPadding = 24,
...props
placeholder,
...inputProps
},
ref
) => {
const [open, setOpen] = useState(false)
const { t } = useTranslation()
const comboboxRef = useRef<HTMLInputElement>(null)
const listboxRef = useRef<HTMLDivElement>(null)
useImperativeHandle(ref, () => comboboxRef.current!)
const isControlled = controlledValue !== undefined
const [searchValue, setSearchValue] = useState("")
const [uncontrolledValue, setUncontrolledValue] = useState<string[]>([])
const selectedValues = isControlled ? controlledValue : uncontrolledValue
const handleValueChange = (newValues?: string[]) => {
if (!isControlled) {
setUncontrolledValue(newValues || [])
}
if (onChange) {
onChange(newValues)
}
}
/**
* Filter and sort the options based on the search value,
* and whether the value is already selected.
*/
const matches = useMemo(() => {
return matchSorter(options, searchValue, {
keys: ["label"],
baseSort: (a, b) => {
const aIndex = selectedValues.indexOf(a.item.value)
const bIndex = selectedValues.indexOf(b.item.value)
if (aIndex === -1 && bIndex === -1) {
return 0
}
if (aIndex === -1) {
return 1
}
if (bIndex === -1) {
return -1
}
return aIndex - bIndex
},
})
}, [options, searchValue, selectedValues])
const hasValues = selectedValues.length > 0
const showSelected = hasValues && !searchValue && !open
const hidePlaceholder = showSelected || open
return (
<Popover.Portal>
<Popover.Content
ref={ref}
className={clx(
"bg-ui-bg-base text-ui-fg-base shadow-elevation-flyout relative max-h-[120px] h-full min-w-[var(--radix-popper-anchor-width)] overflow-hidden rounded-lg flex flex-col divide-y",
"data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
"data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95",
"data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
side={side}
sideOffset={sideOffset}
collisionPadding={collisionPadding}
{...props}
<Popover.Root open={open} onOpenChange={setOpen}>
<PrimitiveComboboxProvider
open={open}
setOpen={setOpen}
selectedValue={selectedValues}
setSelectedValue={handleValueChange}
setValue={(value) => {
setSearchValue(value)
}}
>
<Primitive.Options
static={true}
className={clx("p-1 flex-1 overflow-auto")}
>
{children}
</Primitive.Options>
</Popover.Content>
</Popover.Portal>
<Popover.Anchor asChild>
<div
className={clx(
"relative flex cursor-pointer items-center gap-x-2 overflow-hidden",
"h-8 w-full rounded-md px-2 py-0.5",
"bg-ui-bg-field transition-fg shadow-borders-base",
"hover:bg-ui-bg-field-hover",
"has-[input:focus]:shadow-borders-interactive-with-active",
"has-[:invalid]:shadow-borders-error",
"has-[:disabled]:bg-ui-bg-disabled has-[:disabled]:text-ui-fg-disabled has-[:disabled]:cursor-not-allowed",
{
"pl-0.5": hasValues,
},
className
)}
>
{hasValues && (
<div className="bg-ui-bg-base txt-compact-small-plus text-ui-fg-subtle focus-within:border-ui-fg-interactive relative flex h-[28px] items-center rounded-[4px] border py-[3px] pl-1.5 pr-1">
<span>{selectedValues.length}</span>
<button
type="button"
className="size-fit outline-none"
onClick={(e) => {
e.preventDefault()
handleValueChange(undefined)
}}
>
<XMarkMini className="text-ui-fg-muted" />
</button>
</div>
)}
<div className="relative flex size-full items-center">
{showSelected && (
<Text size="small" leading="compact">
{t("general.selected")}
</Text>
)}
<PrimitiveCombobox
ref={comboboxRef}
className="txt-compact-small text-ui-fg-base placeholder:text-ui-fg-subtle size-full cursor-pointer bg-transparent pr-7 outline-none focus:cursor-text"
placeholder={hidePlaceholder ? undefined : placeholder}
{...inputProps}
/>
</div>
<button
type="button"
tabIndex={-1}
className="text-ui-fg-muted pointer-events-none absolute right-2 size-fit outline-none"
>
<TrianglesMini />
</button>
</div>
</Popover.Anchor>
<Popover.Portal>
<Popover.Content
asChild
align="center"
side="bottom"
sideOffset={8}
onOpenAutoFocus={(event) => event.preventDefault()}
onInteractOutside={(event) => {
const target = event.target as Element | null
const isCombobox = target === comboboxRef.current
const inListbox = target && listboxRef.current?.contains(target)
if (isCombobox || inListbox) {
event.preventDefault()
}
}}
>
<PrimitiveComboboxList
ref={listboxRef}
role="listbox"
className={clx(
"shadow-elevation-flyout bg-ui-bg-base w-[var(--radix-popper-anchor-width)] rounded-[8px] p-1",
"max-h-[200px] overflow-y-auto",
"data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
"data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95",
"data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2"
)}
>
{matches.map(({ value, label }) => (
<PrimitiveComboboxItem
key={value}
value={value}
focusOnHover
className="transition-fg bg-ui-bg-base data-[active-item=true]:bg-ui-bg-base-hover group flex items-center gap-x-2 rounded-[4px] px-2 py-1.5"
>
<PrimitiveComboboxItemCheck className="flex !size-5 items-center justify-center">
<EllipseMiniSolid />
</PrimitiveComboboxItemCheck>
<PrimitiveComboboxItemValue className="txt-compact-small group-aria-selected:txt-compact-small-plus">
{label}
</PrimitiveComboboxItemValue>
</PrimitiveComboboxItem>
))}
{!matches.length && (
<div className="flex items-center gap-x-2 rounded-[4px] px-2 py-1.5">
<Text
size="small"
leading="compact"
className="text-ui-fg-subtle"
>
{t("general.noResultsTitle")}
</Text>
</div>
)}
</PrimitiveComboboxList>
</Popover.Content>
</Popover.Portal>
</PrimitiveComboboxProvider>
</Popover.Root>
)
}
)
Content.displayName = "Combobox.Content"
const Pagination = forwardRef<
ElementRef<"div">,
{
isLoading?: boolean
hasNext?: boolean
onPaginate: () => void
className?: string
}
>(({ isLoading, hasNext, onPaginate, className, ...props }, ref) => {
const observerRef = useRef<IntersectionObserver | null>(null)
const innerRef = useRef<HTMLDivElement>(null)
// Merge innerRef and ref
useImperativeHandle<HTMLDivElement | null, HTMLDivElement | null>(ref, () => {
return innerRef.current
})
useEffect(() => {
if (innerRef.current) {
observerRef.current = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting) {
onPaginate()
}
})
observerRef.current.observe(innerRef.current)
}
return () => {
if (observerRef.current && innerRef.current) {
observerRef.current.unobserve(innerRef.current)
}
}
}, [isLoading, hasNext, onPaginate, ref])
return <div ref={innerRef} className="bg-transparent w-px h-px" {...props} />
})
Pagination.displayName = "Combobox.Pagination"
const Combo = Object.assign(Root, {
Trigger,
Value,
Item,
NoResults,
Pagination,
Content,
})
export const TestCombobox = () => {
const [product, setProduct] = useState<Product[]>([])
const [query, setQuery] = useState("")
const { products, count, isLoading } = useAdminProducts(
{
q: query,
},
{
keepPreviousData: true,
}
)
return (
<Combo value={product} onChange={setProduct}>
<Combo.Trigger>
<Combo.Value
onChange={(e) => setQuery(e.target.value)}
displayValue={(value: Product[]) => `${value?.length}`}
/>
</Combo.Trigger>
<Combo.Content>
{!products?.length && <Combo.NoResults />}
<Combo.Pagination isLoading onPaginate={() => console.log("Heyo!")} />
{products?.map((p) => (
<Combo.Item key={p.id} value={p}>
{p.title}
</Combo.Item>
))}
</Combo.Content>
</Combo>
)
}
Combobox.displayName = "Combobox"
@@ -0,0 +1 @@
export * from "./combobox"
@@ -100,8 +100,9 @@ const Label = forwardRef<
React.ComponentPropsWithoutRef<typeof LabelPrimitives.Root> & {
optional?: boolean
tooltip?: ReactNode
icon?: ReactNode
}
>(({ className, optional = false, tooltip, ...props }, ref) => {
>(({ className, optional = false, tooltip, icon, ...props }, ref) => {
const { formItemId } = useFormField()
const { t } = useTranslation()
@@ -120,6 +121,7 @@ const Label = forwardRef<
<InformationCircleSolid className="text-ui-fg-muted" />
</Tooltip>
)}
{icon}
{optional && (
<Text size="small" leading="compact" className="text-ui-fg-muted">
({t("fields.optional")})
@@ -0,0 +1,23 @@
import { BuildingTax } from "@medusajs/icons"
import { Tooltip } from "@medusajs/ui"
import { useTranslation } from "react-i18next"
type IncludesTaxTooltipProps = {
includesTax?: boolean
}
export const IncludesTaxTooltip = ({
includesTax,
}: IncludesTaxTooltipProps) => {
const { t } = useTranslation()
if (!includesTax) {
return null
}
return (
<Tooltip content={t("general.includesTaxTooltip")}>
<BuildingTax className="text-ui-fg-muted" />
</Tooltip>
)
}