diff --git a/lib/components/input/input.tsx b/lib/components/input/input.tsx index af64b17..d8ca4e5 100644 --- a/lib/components/input/input.tsx +++ b/lib/components/input/input.tsx @@ -1,4 +1,4 @@ -import { useMemo } from "react"; +import { forwardRef, useMemo } from "react"; import { twMerge } from "tailwind-merge"; import { useComponentStyle } from "../../customization/styles/theme.context"; import { Box } from "../box/box"; @@ -9,62 +9,66 @@ const defaultProps: Partial = { variant: "filled", }; -export const Input = (props: IInput) => { - const theme = useComponentStyle("Input"); - const { - className = "", - size, - variant, - leftElement, - rightElement, - ...restProps - } = { ...defaultProps, ...props }; +export const Input = forwardRef( + (props: IInput, ref) => { + const theme = useComponentStyle("Input"); + const { + className = "", + size, + variant, + leftElement, + rightElement, + ...restProps + } = { ...defaultProps, ...props }; - const containerClasses = useMemo(() => { - return twMerge( - theme.container({ - size, - variant, - addonLeft: !!leftElement, - addonRight: !!rightElement, - }), - theme.group(), - className - ); - }, [theme, size, variant, leftElement, rightElement, className]); + const containerClasses = useMemo(() => { + return twMerge( + theme.container({ + size, + variant, + addonLeft: !!leftElement, + addonRight: !!rightElement, + }), + theme.group(), + className + ); + }, [theme, size, variant, leftElement, rightElement, className]); - const inputClasses = useMemo(() => { - return twMerge( - theme.input({ - size, - variant, - isInGroup: !!leftElement || !!rightElement, - }), - className - ); - }, [theme, size, variant, leftElement, rightElement, className]); + const inputClasses = useMemo(() => { + return twMerge( + theme.input({ + size, + variant, + isInGroup: !!leftElement || !!rightElement, + }), + className + ); + }, [theme, size, variant, leftElement, rightElement, className]); - const leftElementClasses = useMemo(() => { - return twMerge(theme.leftElement()); - }, [theme]); + const leftElementClasses = useMemo(() => { + return twMerge(theme.leftElement()); + }, [theme]); - const rightElementClasses = useMemo(() => { - return twMerge(theme.rightElement()); - }, [theme]); + const rightElementClasses = useMemo(() => { + return twMerge(theme.rightElement()); + }, [theme]); - return ( -
- {leftElement ? ( - {leftElement} - ) : null} - - {rightElement ? ( - {rightElement} - ) : null} -
- ); -}; + return ( +
+ {leftElement ? ( + {leftElement} + ) : null} + + {rightElement ? ( + {rightElement} + ) : null} +
+ ); + } +); +Input.displayName = "Input"; diff --git a/lib/components/select/select.stories.tsx b/lib/components/select/select.stories.tsx index 956eac5..d5a3c76 100644 --- a/lib/components/select/select.stories.tsx +++ b/lib/components/select/select.stories.tsx @@ -1,9 +1,10 @@ import type { Meta, StoryObj } from "@storybook/react"; import { Select } from "./select"; +import { MapPinLine } from "@phosphor-icons/react"; const meta = { - title: "LAYOUT/Select", + title: "FORMS/Select", component: Select, tags: ["autodocs"], argTypes: {}, @@ -13,5 +14,28 @@ export default meta; type Story = StoryObj; export const Primary: Story = { - args: {}, + args: { + variant: "outline", + size: "md", + isClearable: true, + // leftElement: , + options: [ + { + label: "This is a really long option label, you see?", + value: 1, + }, + { + label: "Option 2", + value: 2, + }, + { + label: "Option 3", + value: 3, + }, + { + label: "Option 4", + value: 4, + }, + ], + }, }; diff --git a/lib/components/select/select.tsx b/lib/components/select/select.tsx index 109c0dd..3cb19d0 100644 --- a/lib/components/select/select.tsx +++ b/lib/components/select/select.tsx @@ -1,27 +1,177 @@ +import { AnimatePresence, motion } from "framer-motion"; +import React, { useEffect, useMemo, useRef, useState } from "react"; +import { useLayer } from "react-laag"; import { twMerge } from "tailwind-merge"; import { useComponentStyle } from "../../customization/styles/theme.context"; -import { ISelect } from "./select.types"; -import { useMemo } from "react"; +import { ISelect, ISelectOption } from "./select.types"; +import { Box } from "../box/box"; +import { CaretDown, CaretUp, X } from "@phosphor-icons/react"; -const defaultProps: Partial = { - children: undefined, -}; +export const Select = ({ + options = [], + variant = "outline", + size = "md", + value, + defaultValue, + onChange, + leftElement, + rightElement: rightElementProp, + isClearable = false, +}: ISelect) => { + const inputRef = useRef(null); + const dropdownRef = useRef(null); + + // STATE + const [isOpen, setOpen] = useState(false); + const [valueState, setValueState] = useState( + value || defaultValue + ); -export const Select = (props: ISelect) => { + // VARS const theme = useComponentStyle("Select"); - const { - children, - className = "", - ...restProps - } = { ...defaultProps, ...props }; + const rightElement = useMemo(() => { + return rightElementProp || isOpen ? : ; + }, [isOpen, rightElementProp]); + + const { triggerProps, layerProps, renderLayer } = useLayer({ + isOpen: isOpen, + auto: true, + triggerOffset: 4, + placement: "bottom-start", + onOutsideClick: () => { + setOpen(false); + }, + }); + + // CLASSES + const containerClasses = useMemo(() => { + return twMerge( + theme.container({ + variant, + size, + }), + theme.group() + ); + }, [size, theme, variant]); + + const secContainerClasses = useMemo(() => { + return twMerge( + theme.secContainer({ + addonLeft: !!leftElement, + addonRight: !!rightElement, + }) + ); + }, [leftElement, rightElement, theme]); + + const inputClasses = useMemo(() => { + return twMerge( + theme.input({ + variant, + size, + isInGroup: !!leftElement || !!rightElement, + }) + ); + }, [leftElement, rightElement, size, theme, variant]); + + const dropdownClasses = useMemo(() => { + return twMerge(theme.dropdown()); + }, [theme]); + + const leftElementClasses = useMemo(() => { + return twMerge(theme.leftElement()); + }, [theme]); + + const rightElementClasses = useMemo(() => { + return twMerge(theme.rightElement()); + }, [theme]); + + const clearElementClasses = useMemo(() => { + return twMerge(theme.clearElement()); + }, [theme]); + + // FUNCTIONS + const onClear = (e: React.MouseEvent) => { + e.stopPropagation() + setOpen(false); + setValueState(undefined); + }; + useEffect(() => { + if (inputRef.current && dropdownRef.current) { + const inputWidth = inputRef.current.offsetWidth; + dropdownRef.current.style.width = `${inputWidth}px`; + } + }, [isOpen, inputRef.current?.offsetWidth]); + + useEffect(() => { + setValueState(value); + }, [value]); - const classes = useMemo(() => { - return twMerge(theme.base(), className) - }, [className, theme]); + // UI + const renderOptions = () => { + return options.map((x: ISelectOption) => ( +
  • { + onChange?.(x); + setValueState(x); + setOpen(false); + }} + > + {x.label} +
  • + )); + }; return ( -
    - {children} -
    + <> +
    { + const _isOpen = !isOpen; + setOpen(_isOpen); + }} + className={containerClasses} + > +
    + {!!leftElement && ( + {leftElement} + )} +
    + {valueState?.label || ( + Placeholder + )} +
    + {isClearable && !!valueState?.value && ( + + + + )} + {!!rightElement && ( + {rightElement} + )} +
    +
    + {isOpen && + renderLayer( + + +
    +
      {renderOptions()}
    +
    +
    +
    + )} + ); }; diff --git a/lib/components/select/select.types.ts b/lib/components/select/select.types.ts index a3d6337..37387a7 100644 --- a/lib/components/select/select.types.ts +++ b/lib/components/select/select.types.ts @@ -1,3 +1,24 @@ -export interface ISelect { - children?: React.ReactNode; +import { ReactNode, SelectHTMLAttributes } from "react"; + +export interface ISelect + extends Omit< + SelectHTMLAttributes, + "size" | "value" | "defaultValue" | "onChange" + > { + options?: ISelectOption[]; + size?: "xs" | "sm" | "md" | "lg"; + variant?: "outline" | "filled" | "flushed" | "unstyled"; + value?: ISelectOption; + defaultValue?: ISelectOption; + onChange?: (value: ISelectOption) => void; + leftElement?: React.ReactNode; + rightElement?: React.ReactNode; + isClearable?: boolean; +} + +export type ISelectValue = string | number; + +export interface ISelectOption { + label?: ReactNode; + value?: ISelectValue; } diff --git a/lib/customization/styles/components/select.styles.ts b/lib/customization/styles/components/select.styles.ts index 80cf7be..fe67848 100644 --- a/lib/customization/styles/components/select.styles.ts +++ b/lib/customization/styles/components/select.styles.ts @@ -1,9 +1,189 @@ import { cva } from "class-variance-authority"; -const base = cva(["block"]); +const container = cva( + [ + "rounded-md", + "text-primary-text", + "w-fit", + "outline", + "outline-1", + "outline-transparent", + "w-full", + ], + { + variants: { + size: { + xs: ["h-6"], + sm: ["h-8"], + md: ["h-10"], + lg: ["h-12"], + }, + variant: { + outline: [ + "border", + "border-line-primary", + "hover:!border-primary", + + "focus-within:!border-primary", + "focus-within:!outline-primary", + ], + filled: [ + "bg-line-primary", + + "border", + "border-line-primary", + + "hover:!border-primary", + + "focus-within:!border-primary", + "focus-within:!outline-primary", + "focus-within:!bg-sec-background", + ], + flushed: [ + "!rounded-none", + + "border-b", + "border-b-line-primary", + + "hover:shadow-[0_1px_0_0_var(--color-primary)]", + "focus-within:!shadow-[0_1px_0_0_var(--color-primary)]", + "focus-within:!border-b-primary", + + "!px-0", + ], + unstyled: ["!border-none", "!bg-none", "!outline-none", "!px-0"], + }, + }, + } +); +const secContainer = cva(["w-full", "flex", "items-center"], { + variants: { + addonRight: { + true: ["!pl-3", "!pr-1"], + false: [], + }, + addonLeft: { + true: ["!pr-3"], + false: [], + }, + }, + compoundVariants: [ + { + addonLeft: true, + addonRight: true, + class: "!px-0", + }, + ], +}); +const input = cva( + [ + "rounded-md", + "w-full", + "h-full", + "!bg-transparent", + "outline-none", + "flex", + "items-center", + "cursor-pointer", + "truncate", + "inline-block", + ], + { + variants: { + size: { + xs: ["px-3", "text-xs"], + sm: ["px-3", "text-sm"], + md: ["px-3", "text-base"], + lg: ["px-3", "text-lg"], + }, + variant: { + outline: [], + filled: [], + flushed: ["!px-0"], + unstyled: ["!px-0"], + }, + isInGroup: { + true: ["!px-0"], + false: [], + }, + }, + + defaultVariants: { + size: "md", + variant: "outline", + }, + } +); +const dropdown = cva([ + "max-h-[300px]", + "overflow-y-auto", + "rounded-lg", + "px-1", + "py-1", + "bg-sec-background", + "text-primary-text", + "text-base", + "border", + "border-line-primary", + "shadow-sm", + "overflow-x-hidden", +]); +const option = cva( + [ + "w-full", + "px-2", + "py-1.5", + "my-0.5", + "rounded-md", + "hover:bg-gray-100", + "cursor-pointer", + ], + { + variants: { + isSelected: { + true: ["font-medium", "bg-line-primary"], + false: [], + }, + }, + } +); +const group = cva(["flex", "items-center"]); +const placeholder = cva(["text-opacity-40", "text-black"]); +const leftElement = cva([ + "flex", + "items-center", + "justify-center", + "cursor-pointer", + "p-1.5", +]); +const rightElement = cva([ + "flex", + "items-center", + "justify-center", + "cursor-pointer", + "p-1.5", +]); +const clearElement = cva([ + "flex", + "items-center", + "justify-center", + "p-1.5", + "hover:cursor-pointer", + "hover:bg-gray-100", + "hover:rounded-lg", +]); const selectStyles = { - base, + container, + secContainer, + input, + dropdown, + option, + group, + placeholder, + leftElement, + rightElement, + clearElement, }; export { selectStyles };