feat(dashboard): Product create from - details (#7121)
**What** - First part of the product creation form. - New components: - ChipInput - Allows users to input chips into a input field. Chips are created by hitting the `,` or `Enter / Return` keys. Deleting a chip is done by hitting `Backspace` when the cursor is next to chip, or clicking the `X` button in the chip. Used for inputting option values. - SortableList - A sortable drag-n-drop list that allows the user to re-arrange the order of items. Used for re-arranging the ranking of variants. - ChipGroup - New re-usable component that is used to render a group of values as Chips. This should be used for SplitView form items. - CategoryCombobox - (WIP) Nested Combobox component for selecting multiple categories a product should be associated with. - New hooks: - useComboboxData - Hook for easily managing the state of comboboxes. - useDebouncedSearch - Hook for managing debounced search queries.
This commit is contained in:
@@ -0,0 +1,111 @@
|
||||
import { XMarkMini } from "@medusajs/icons"
|
||||
import { Button, clx } from "@medusajs/ui"
|
||||
import { Children, PropsWithChildren, createContext, useContext } from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
|
||||
type ChipGroupVariant = "base" | "component"
|
||||
|
||||
type ChipGroupProps = PropsWithChildren<{
|
||||
onClearAll?: () => void
|
||||
onRemove?: (index: number) => void
|
||||
variant?: ChipGroupVariant
|
||||
className?: string
|
||||
}>
|
||||
|
||||
type GroupContextValue = {
|
||||
onRemove?: (index: number) => void
|
||||
variant: ChipGroupVariant
|
||||
}
|
||||
|
||||
const GroupContext = createContext<GroupContextValue | null>(null)
|
||||
|
||||
const useGroupContext = () => {
|
||||
const context = useContext(GroupContext)
|
||||
|
||||
if (!context) {
|
||||
throw new Error("useGroupContext must be used within a ChipGroup component")
|
||||
}
|
||||
|
||||
return context
|
||||
}
|
||||
|
||||
const Group = ({
|
||||
onClearAll,
|
||||
onRemove,
|
||||
variant = "component",
|
||||
className,
|
||||
children,
|
||||
}: ChipGroupProps) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const showClearAll = !!onClearAll && Children.count(children) > 0
|
||||
|
||||
return (
|
||||
<GroupContext.Provider value={{ onRemove, variant }}>
|
||||
<ul
|
||||
role="application"
|
||||
className={clx("flex flex-wrap items-center gap-2", className)}
|
||||
>
|
||||
{children}
|
||||
{showClearAll && (
|
||||
<li>
|
||||
<Button
|
||||
size="small"
|
||||
variant="transparent"
|
||||
type="button"
|
||||
onClick={onClearAll}
|
||||
className="text-ui-fg-muted active:text-ui-fg-subtle"
|
||||
>
|
||||
{t("actions.clearAll")}
|
||||
</Button>
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
</GroupContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
type ChipProps = PropsWithChildren<{
|
||||
index: number
|
||||
className?: string
|
||||
}>
|
||||
|
||||
const Chip = ({ index, className, children }: ChipProps) => {
|
||||
const { onRemove, variant } = useGroupContext()
|
||||
|
||||
return (
|
||||
<li
|
||||
className={clx(
|
||||
"bg-ui-bg-component shadow-borders-base flex items-center divide-x overflow-hidden rounded-md",
|
||||
{
|
||||
"bg-ui-bg-component": variant === "component",
|
||||
"bg-ui-bg-base-": variant === "base",
|
||||
},
|
||||
className
|
||||
)}
|
||||
>
|
||||
<span className="txt-compact-small-plus flex items-center justify-center px-2 py-1">
|
||||
{children}
|
||||
</span>
|
||||
{!!onRemove && (
|
||||
<button
|
||||
onClick={() => onRemove(index)}
|
||||
type="button"
|
||||
className={clx(
|
||||
"text-ui-fg-muted active:text-ui-fg-subtle transition-fg flex items-center justify-center p-1",
|
||||
{
|
||||
"hover:bg-ui-bg-component-hover active:bg-ui-bg-component-pressed":
|
||||
variant === "component",
|
||||
"hover:bg-ui-bg-base-hover active:bg-ui-bg-base-pressed":
|
||||
variant === "base",
|
||||
}
|
||||
)}
|
||||
>
|
||||
<XMarkMini />
|
||||
</button>
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
export const ChipGroup = Object.assign(Group, { Chip })
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./chip-group"
|
||||
@@ -1 +0,0 @@
|
||||
export * from "./keypair"
|
||||
@@ -1,149 +0,0 @@
|
||||
import { Plus, Trash } from "@medusajs/icons"
|
||||
import { Button, Input, Table } from "@medusajs/ui"
|
||||
import { useState } from "react"
|
||||
|
||||
interface KeyPair {
|
||||
key: string
|
||||
value: string
|
||||
}
|
||||
|
||||
export interface KeypairProps {
|
||||
labels: {
|
||||
add: string
|
||||
key?: string
|
||||
value?: string
|
||||
}
|
||||
value: KeyPair[]
|
||||
onChange: (value: KeyPair[]) => void
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export const Keypair = ({ labels, onChange, value }: KeypairProps) => {
|
||||
const addKeyPair = () => {
|
||||
onChange([...value, { key: ``, value: `` }])
|
||||
}
|
||||
|
||||
const deleteKeyPair = (index: number) => {
|
||||
return () => {
|
||||
onChange(value.filter((_, i) => i !== index))
|
||||
}
|
||||
}
|
||||
|
||||
const onKeyChange = (index: number) => {
|
||||
return (key: string) => {
|
||||
const newArr = value.map((pair, i) => {
|
||||
if (i === index) {
|
||||
return { key, value: pair.value }
|
||||
}
|
||||
return pair
|
||||
})
|
||||
|
||||
onChange(newArr)
|
||||
}
|
||||
}
|
||||
|
||||
const onValueChange = (index: number) => {
|
||||
return (val: string) => {
|
||||
const newArr = value.map((pair, i) => {
|
||||
if (i === index) {
|
||||
return { key: pair.key, value: val }
|
||||
}
|
||||
return pair
|
||||
})
|
||||
|
||||
onChange(newArr)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Table className="w-full">
|
||||
<Table.Header className="border-t-0">
|
||||
<Table.Row>
|
||||
<Table.HeaderCell>{labels.key}</Table.HeaderCell>
|
||||
<Table.HeaderCell>{labels.value}</Table.HeaderCell>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{value.map((pair, index) => {
|
||||
return (
|
||||
<Field
|
||||
labels={labels}
|
||||
field={pair}
|
||||
updateKey={onKeyChange(index)}
|
||||
updateValue={onValueChange(index)}
|
||||
onDelete={deleteKeyPair(index)}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</Table.Body>
|
||||
</Table>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="small"
|
||||
type="button"
|
||||
className="w-full mt-4"
|
||||
onClick={addKeyPair}
|
||||
>
|
||||
<Plus />
|
||||
{labels.add}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type FieldProps = {
|
||||
field: KeyPair
|
||||
labels: {
|
||||
key?: string
|
||||
value?: string
|
||||
}
|
||||
updateKey: (key: string) => void
|
||||
updateValue: (value: string) => void
|
||||
onDelete: () => void
|
||||
}
|
||||
|
||||
const Field: React.FC<FieldProps> = ({
|
||||
field,
|
||||
updateKey,
|
||||
updateValue,
|
||||
onDelete,
|
||||
}) => {
|
||||
const [key, setKey] = useState(field.key)
|
||||
const [value, setValue] = useState(field.value)
|
||||
|
||||
return (
|
||||
<Table.Row>
|
||||
<Table.Cell className="!p-0 h-0">
|
||||
<Input
|
||||
className="rounded-none bg-transparent"
|
||||
onBlur={() => updateKey(key)}
|
||||
value={key}
|
||||
onChange={(e) => {
|
||||
setKey(e.currentTarget.value)
|
||||
}}
|
||||
/>
|
||||
</Table.Cell>
|
||||
<Table.Cell className="!p-0 h-0">
|
||||
<Input
|
||||
className="rounded-none bg-transparent"
|
||||
onBlur={() => updateValue(value)}
|
||||
value={value}
|
||||
onChange={(e) => {
|
||||
setValue(e.currentTarget.value)
|
||||
}}
|
||||
/>
|
||||
</Table.Cell>
|
||||
<Table.Cell className="!p-0 h-0 border-r">
|
||||
<Button
|
||||
variant="transparent"
|
||||
size="small"
|
||||
type="button"
|
||||
onClick={onDelete}
|
||||
>
|
||||
<Trash />
|
||||
</Button>
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
)
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export * from "./list"
|
||||
@@ -1,54 +0,0 @@
|
||||
import { Checkbox, Text } from "@medusajs/ui"
|
||||
|
||||
export interface ListProps<T> {
|
||||
options: { title: string; value: T }[]
|
||||
value?: T[]
|
||||
onChange?: (value: T[]) => void
|
||||
compare?: (a: T, b: T) => boolean
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export const List = <T extends any>({
|
||||
options,
|
||||
onChange,
|
||||
value,
|
||||
compare,
|
||||
disabled,
|
||||
}: ListProps<T>) => {
|
||||
if (options.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-row justify-center border divide-y rounded-lg">
|
||||
{options.map((option) => {
|
||||
return (
|
||||
<div className="flex p-4 gap-x-4">
|
||||
{onChange && value !== undefined && (
|
||||
<Checkbox
|
||||
disabled={disabled}
|
||||
checked={value.some(
|
||||
(v) => compare?.(v, option.value) ?? v === option.value
|
||||
)}
|
||||
onCheckedChange={(checked) => {
|
||||
if (checked) {
|
||||
onChange([...value, option.value])
|
||||
} else {
|
||||
onChange(
|
||||
value.filter(
|
||||
(v) =>
|
||||
!(compare?.(v, option.value) ?? v === option.value)
|
||||
)
|
||||
)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Text key={option.title}>{option.title}</Text>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export * from "./product-table-cells"
|
||||
-130
@@ -1,130 +0,0 @@
|
||||
import { SalesChannel } from "@medusajs/medusa"
|
||||
import {
|
||||
ProductCollectionDTO,
|
||||
ProductDTO,
|
||||
ProductVariantDTO,
|
||||
} from "@medusajs/types"
|
||||
import { StatusBadge, Text } from "@medusajs/ui"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { Thumbnail } from "../thumbnail"
|
||||
|
||||
export const ProductVariantCell = ({
|
||||
variants,
|
||||
}: {
|
||||
variants: ProductVariantDTO[] | null
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
if (!variants || !variants.length) {
|
||||
return (
|
||||
<Text size="small" className="text-ui-fg-subtle">
|
||||
-
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Text size="small" className="text-ui-fg-base">
|
||||
{t("products.variantCount", {
|
||||
count: variants.length,
|
||||
})}
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
|
||||
export const ProductStatusCell = ({
|
||||
status,
|
||||
}: {
|
||||
status: ProductDTO["status"]
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const color = {
|
||||
draft: "grey",
|
||||
published: "green",
|
||||
rejected: "red",
|
||||
proposed: "blue",
|
||||
}[status] as "grey" | "green" | "red" | "blue"
|
||||
|
||||
return (
|
||||
<StatusBadge color={color}>
|
||||
{t(`products.productStatus.${status}`)}
|
||||
</StatusBadge>
|
||||
)
|
||||
}
|
||||
|
||||
export const ProductAvailabilityCell = ({
|
||||
salesChannels,
|
||||
}: {
|
||||
salesChannels: SalesChannel[] | null
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
if (!salesChannels || salesChannels.length === 0) {
|
||||
return (
|
||||
<Text size="small" className="text-ui-fg-subtle">
|
||||
-
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
|
||||
if (salesChannels.length < 3) {
|
||||
return (
|
||||
<Text size="small" className="text-ui-fg-base">
|
||||
{salesChannels.map((sc) => sc.name).join(", ")}
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-x-2">
|
||||
<Text size="small" className="text-ui-fg-base">
|
||||
<span>
|
||||
{salesChannels
|
||||
.slice(0, 2)
|
||||
.map((sc) => sc.name)
|
||||
.join(", ")}
|
||||
</span>{" "}
|
||||
<span>
|
||||
{t("general.plusCountMore", {
|
||||
count: salesChannels.length - 2,
|
||||
})}
|
||||
</span>
|
||||
</Text>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const ProductTitleCell = ({ product }: { product: ProductDTO }) => {
|
||||
const thumbnail = product.thumbnail
|
||||
const title = product.title
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-x-3">
|
||||
<Thumbnail src={thumbnail} alt={`Thumbnail image of ${title}`} />
|
||||
<Text size="small" className="text-ui-fg-base">
|
||||
{title}
|
||||
</Text>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const ProductCollectionCell = ({
|
||||
collection,
|
||||
}: {
|
||||
collection: ProductCollectionDTO | null
|
||||
}) => {
|
||||
if (!collection) {
|
||||
return (
|
||||
<Text size="small" className="text-ui-fg-subtle">
|
||||
-
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Text size="small" className="text-ui-fg-base">
|
||||
{collection.title}
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./sortable-list"
|
||||
@@ -0,0 +1,228 @@
|
||||
import {
|
||||
Active,
|
||||
DndContext,
|
||||
DragEndEvent,
|
||||
DragOverlay,
|
||||
DragStartEvent,
|
||||
DraggableSyntheticListeners,
|
||||
KeyboardSensor,
|
||||
PointerSensor,
|
||||
defaultDropAnimationSideEffects,
|
||||
useSensor,
|
||||
useSensors,
|
||||
type DropAnimation,
|
||||
type UniqueIdentifier,
|
||||
} from "@dnd-kit/core"
|
||||
import {
|
||||
SortableContext,
|
||||
arrayMove,
|
||||
sortableKeyboardCoordinates,
|
||||
useSortable,
|
||||
} from "@dnd-kit/sortable"
|
||||
import { CSS } from "@dnd-kit/utilities"
|
||||
import { DotsSix } from "@medusajs/icons"
|
||||
import { IconButton, clx } from "@medusajs/ui"
|
||||
import {
|
||||
CSSProperties,
|
||||
Fragment,
|
||||
PropsWithChildren,
|
||||
ReactNode,
|
||||
createContext,
|
||||
useContext,
|
||||
useMemo,
|
||||
useState,
|
||||
} from "react"
|
||||
|
||||
type SortableBaseItem = {
|
||||
id: UniqueIdentifier
|
||||
}
|
||||
|
||||
interface SortableListProps<TItem extends SortableBaseItem> {
|
||||
items: TItem[]
|
||||
onChange: (items: TItem[]) => void
|
||||
renderItem: (item: TItem, index: number) => ReactNode
|
||||
}
|
||||
|
||||
const List = <TItem extends SortableBaseItem>({
|
||||
items,
|
||||
onChange,
|
||||
renderItem,
|
||||
}: SortableListProps<TItem>) => {
|
||||
const [active, setActive] = useState<Active | null>(null)
|
||||
|
||||
const [activeItem, activeIndex] = useMemo(() => {
|
||||
if (active === null) {
|
||||
return [null, null]
|
||||
}
|
||||
|
||||
const index = items.findIndex(({ id }) => id === active.id)
|
||||
|
||||
return [items[index], index]
|
||||
}, [active, items])
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor),
|
||||
useSensor(KeyboardSensor, {
|
||||
coordinateGetter: sortableKeyboardCoordinates,
|
||||
})
|
||||
)
|
||||
|
||||
const handleDragStart = ({ active }: DragStartEvent) => {
|
||||
setActive(active)
|
||||
}
|
||||
|
||||
const handleDragEnd = ({ active, over }: DragEndEvent) => {
|
||||
if (over && active.id !== over.id) {
|
||||
const activeIndex = items.findIndex(({ id }) => id === active.id)
|
||||
const overIndex = items.findIndex(({ id }) => id === over.id)
|
||||
|
||||
onChange(arrayMove(items, activeIndex, overIndex))
|
||||
}
|
||||
|
||||
setActive(null)
|
||||
}
|
||||
|
||||
const handleDragCancel = () => {
|
||||
setActive(null)
|
||||
}
|
||||
|
||||
return (
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
onDragCancel={handleDragCancel}
|
||||
>
|
||||
<Overlay>
|
||||
{activeItem && activeIndex !== null
|
||||
? renderItem(activeItem, activeIndex)
|
||||
: null}
|
||||
</Overlay>
|
||||
<SortableContext items={items}>
|
||||
<ul
|
||||
role="application"
|
||||
className="flex list-inside list-none list-image-none flex-col p-0"
|
||||
>
|
||||
{items.map((item, index) => (
|
||||
<Fragment key={item.id}>{renderItem(item, index)}</Fragment>
|
||||
))}
|
||||
</ul>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
)
|
||||
}
|
||||
|
||||
const dropAnimationConfig: DropAnimation = {
|
||||
sideEffects: defaultDropAnimationSideEffects({
|
||||
styles: {
|
||||
active: {
|
||||
opacity: "0.4",
|
||||
},
|
||||
},
|
||||
}),
|
||||
}
|
||||
|
||||
type SortableOverlayProps = PropsWithChildren
|
||||
|
||||
const Overlay = ({ children }: SortableOverlayProps) => {
|
||||
return (
|
||||
<DragOverlay
|
||||
className="shadow-elevation-card-hover overflow-hidden rounded-md [&>li]:border-b-0"
|
||||
dropAnimation={dropAnimationConfig}
|
||||
>
|
||||
{children}
|
||||
</DragOverlay>
|
||||
)
|
||||
}
|
||||
|
||||
type SortableItemProps<TItem extends SortableBaseItem> = PropsWithChildren<{
|
||||
id: TItem["id"]
|
||||
className?: string
|
||||
}>
|
||||
|
||||
type SortableItemContextValue = {
|
||||
attributes: Record<string, any>
|
||||
listeners: DraggableSyntheticListeners
|
||||
ref: (node: HTMLElement | null) => void
|
||||
isDragging: boolean
|
||||
}
|
||||
|
||||
const SortableItemContext = createContext<SortableItemContextValue | null>(null)
|
||||
|
||||
const useSortableItemContext = () => {
|
||||
const context = useContext(SortableItemContext)
|
||||
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
"useSortableItemContext must be used within a SortableItemContext"
|
||||
)
|
||||
}
|
||||
|
||||
return context
|
||||
}
|
||||
|
||||
const Item = <TItem extends SortableBaseItem>({
|
||||
id,
|
||||
className,
|
||||
children,
|
||||
}: SortableItemProps<TItem>) => {
|
||||
const {
|
||||
attributes,
|
||||
isDragging,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
setActivatorNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
} = useSortable({ id })
|
||||
|
||||
const context = useMemo(
|
||||
() => ({
|
||||
attributes,
|
||||
listeners,
|
||||
ref: setActivatorNodeRef,
|
||||
isDragging,
|
||||
}),
|
||||
[attributes, listeners, setActivatorNodeRef, isDragging]
|
||||
)
|
||||
|
||||
const style: CSSProperties = {
|
||||
opacity: isDragging ? 0.4 : undefined,
|
||||
transform: CSS.Translate.toString(transform),
|
||||
transition,
|
||||
}
|
||||
|
||||
return (
|
||||
<SortableItemContext.Provider value={context}>
|
||||
<li
|
||||
className={clx("transition-fg flex flex-1 list-none", className)}
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
>
|
||||
{children}
|
||||
</li>
|
||||
</SortableItemContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
const DragHandle = () => {
|
||||
const { attributes, listeners, ref } = useSortableItemContext()
|
||||
|
||||
return (
|
||||
<IconButton
|
||||
variant="transparent"
|
||||
size="small"
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
ref={ref}
|
||||
className="cursor-grab touch-none active:cursor-grabbing"
|
||||
>
|
||||
<DotsSix className="text-ui-fg-muted" />
|
||||
</IconButton>
|
||||
)
|
||||
}
|
||||
|
||||
export const SortableList = Object.assign(List, {
|
||||
Item,
|
||||
DragHandle,
|
||||
})
|
||||
@@ -5,8 +5,8 @@ import { z } from "zod"
|
||||
|
||||
import { Control } from "react-hook-form"
|
||||
import { AddressSchema } from "../../../lib/schemas"
|
||||
import { CountrySelect } from "../../common/country-select"
|
||||
import { Form } from "../../common/form"
|
||||
import { CountrySelect } from "../../inputs/country-select"
|
||||
|
||||
type AddressFieldValues = z.infer<typeof AddressSchema>
|
||||
|
||||
|
||||
+1
-1
@@ -16,9 +16,9 @@ import {
|
||||
getOrderPaymentStatus,
|
||||
} from "../../../lib/order-helpers"
|
||||
import { TransferOwnershipSchema } from "../../../lib/schemas"
|
||||
import { Combobox } from "../../common/combobox"
|
||||
import { Form } from "../../common/form"
|
||||
import { Skeleton } from "../../common/skeleton"
|
||||
import { Combobox } from "../../inputs/combobox"
|
||||
|
||||
type TransferOwnerShipFieldValues = z.infer<typeof TransferOwnershipSchema>
|
||||
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
import { XMarkMini } from "@medusajs/icons"
|
||||
import { Badge, clx } from "@medusajs/ui"
|
||||
import { AnimatePresence, motion } from "framer-motion"
|
||||
import {
|
||||
FocusEvent,
|
||||
KeyboardEvent,
|
||||
forwardRef,
|
||||
useImperativeHandle,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react"
|
||||
|
||||
type ChipInputProps = {
|
||||
value?: string[]
|
||||
onChange?: (value: string[]) => void
|
||||
onBlur?: () => void
|
||||
name?: string
|
||||
disabled?: boolean
|
||||
allowDuplicates?: boolean
|
||||
showRemove?: boolean
|
||||
variant?: "base" | "contrast"
|
||||
className?: string
|
||||
}
|
||||
|
||||
export const ChipInput = forwardRef<HTMLInputElement, ChipInputProps>(
|
||||
(
|
||||
{
|
||||
value,
|
||||
onChange,
|
||||
onBlur,
|
||||
disabled,
|
||||
name,
|
||||
showRemove = true,
|
||||
variant = "base",
|
||||
allowDuplicates = false,
|
||||
className,
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const innerRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const isControlledRef = useRef(typeof value !== "undefined")
|
||||
const isControlled = isControlledRef.current
|
||||
|
||||
const [uncontrolledValue, setUncontrolledValue] = useState<string[]>([])
|
||||
|
||||
useImperativeHandle<HTMLInputElement | null, HTMLInputElement | null>(
|
||||
ref,
|
||||
() => innerRef.current
|
||||
)
|
||||
|
||||
const [duplicateIndex, setDuplicateIndex] = useState<number | null>(null)
|
||||
|
||||
const chips = isControlled ? (value as string[]) : uncontrolledValue
|
||||
|
||||
const handleAddChip = (chip: string) => {
|
||||
const cleanValue = chip.trim()
|
||||
|
||||
if (!cleanValue) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!allowDuplicates && chips.includes(cleanValue)) {
|
||||
setDuplicateIndex(chips.indexOf(cleanValue))
|
||||
|
||||
setTimeout(() => {
|
||||
setDuplicateIndex(null)
|
||||
}, 300)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
onChange?.([...chips, cleanValue])
|
||||
|
||||
if (!isControlled) {
|
||||
setUncontrolledValue([...chips, cleanValue])
|
||||
}
|
||||
}
|
||||
|
||||
const handleRemoveChip = (chip: string) => {
|
||||
onChange?.(chips.filter((v) => v !== chip))
|
||||
|
||||
if (!isControlled) {
|
||||
setUncontrolledValue(chips.filter((v) => v !== chip))
|
||||
}
|
||||
}
|
||||
|
||||
const handleBlur = (e: FocusEvent<HTMLInputElement>) => {
|
||||
onBlur?.()
|
||||
|
||||
if (e.target.value) {
|
||||
handleAddChip(e.target.value)
|
||||
e.target.value = ""
|
||||
}
|
||||
}
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === "Enter" || e.key === ",") {
|
||||
e.preventDefault()
|
||||
|
||||
if (!innerRef.current?.value) {
|
||||
return
|
||||
}
|
||||
|
||||
handleAddChip(innerRef.current?.value ?? "")
|
||||
innerRef.current.value = ""
|
||||
innerRef.current?.focus()
|
||||
}
|
||||
|
||||
if (e.key === "Backspace" && innerRef.current?.value === "") {
|
||||
handleRemoveChip(chips[chips.length - 1])
|
||||
}
|
||||
}
|
||||
|
||||
// create a shake animation using framer motion
|
||||
const shake = {
|
||||
x: [0, -2, 2, -2, 2, 0],
|
||||
transition: { duration: 0.3 },
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clx(
|
||||
"shadow-borders-base flex min-h-8 flex-wrap items-center gap-1 rounded-md px-2 py-1.5",
|
||||
"transition-fg focus-within:shadow-borders-interactive-with-active",
|
||||
"has-[input:disabled]:bg-ui-bg-disabled has-[input:disabled]:text-ui-fg-disabled has-[input:disabled]:cursor-not-allowed",
|
||||
{
|
||||
"bg-ui-bg-field-component hover:bg-ui-bg-field-component-hover":
|
||||
variant === "contrast",
|
||||
"bg-ui-bg-field hover:bg-ui-bg-field-hover": variant === "base",
|
||||
},
|
||||
className
|
||||
)}
|
||||
tabIndex={-1}
|
||||
onClick={() => innerRef.current?.focus()}
|
||||
>
|
||||
{chips.map((v, index) => {
|
||||
return (
|
||||
<AnimatePresence key={`${v}-${index}`}>
|
||||
<Badge
|
||||
size="2xsmall"
|
||||
className={clx("gap-x-0.5 pl-1.5 pr-1.5", {
|
||||
"transition-fg pr-1": showRemove,
|
||||
"shadow-borders-focus": index === duplicateIndex,
|
||||
})}
|
||||
asChild
|
||||
>
|
||||
<motion.div
|
||||
animate={index === duplicateIndex ? shake : undefined}
|
||||
>
|
||||
{v}
|
||||
{showRemove && (
|
||||
<button
|
||||
tabIndex={-1}
|
||||
type="button"
|
||||
onClick={() => handleRemoveChip(v)}
|
||||
className={clx(
|
||||
"text-ui-fg-subtle transition-fg outline-none"
|
||||
)}
|
||||
>
|
||||
<XMarkMini />
|
||||
</button>
|
||||
)}
|
||||
</motion.div>
|
||||
</Badge>
|
||||
</AnimatePresence>
|
||||
)
|
||||
})}
|
||||
<input
|
||||
className={clx(
|
||||
"caret-ui-fg-base text-ui-fg-base txt-compact-small flex-1 appearance-none bg-transparent",
|
||||
"disabled:text-ui-fg-disabled disabled:cursor-not-allowed",
|
||||
"focus:outline-none",
|
||||
"placeholder:text-ui-fg-muted"
|
||||
)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onBlur={handleBlur}
|
||||
disabled={disabled}
|
||||
name={name}
|
||||
ref={innerRef}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
ChipInput.displayName = "ChipInput"
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./chip-input"
|
||||
+1
-1
@@ -30,7 +30,7 @@ import {
|
||||
} from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
|
||||
import { genericForwardRef } from "../generic-forward-ref"
|
||||
import { genericForwardRef } from "../../common/generic-forward-ref"
|
||||
|
||||
type ComboboxOption = {
|
||||
value: string
|
||||
+5
@@ -183,6 +183,8 @@ export const DataTableRoot = <TData,>({
|
||||
const to = navigateTo ? navigateTo(row) : undefined
|
||||
const isRowDisabled = hasSelect && !row.getCanSelect()
|
||||
|
||||
const isOdd = row.depth % 2 !== 0
|
||||
|
||||
return (
|
||||
<Table.Row
|
||||
key={row.id}
|
||||
@@ -190,6 +192,7 @@ export const DataTableRoot = <TData,>({
|
||||
className={clx(
|
||||
"transition-fg group/row [&_td:last-of-type]:w-[1%] [&_td:last-of-type]:whitespace-nowrap",
|
||||
{
|
||||
"bg-ui-bg-subtle hover:bg-ui-bg-subtle-hover": isOdd,
|
||||
"cursor-pointer": !!to,
|
||||
"bg-ui-bg-highlight hover:bg-ui-bg-highlight-hover":
|
||||
row.getIsSelected(),
|
||||
@@ -228,6 +231,8 @@ export const DataTableRoot = <TData,>({
|
||||
className={clx({
|
||||
"bg-ui-bg-base group-data-[selected=true]/row:bg-ui-bg-highlight group-data-[selected=true]/row:group-hover/row:bg-ui-bg-highlight-hover group-hover/row:bg-ui-bg-base-hover transition-fg sticky left-0 after:absolute after:inset-y-0 after:right-0 after:h-full after:w-px after:bg-transparent after:content-['']":
|
||||
isStickyCell,
|
||||
"bg-ui-bg-subtle group-hover/row:bg-ui-bg-subtle-hover":
|
||||
isOdd && isStickyCell,
|
||||
"left-[68px]":
|
||||
isStickyCell && hasSelect && !isSelectCell,
|
||||
"after:bg-ui-border-base":
|
||||
|
||||
Reference in New Issue
Block a user