diff --git a/.github/workflows/chromatic.yml b/.github/workflows/chromatic.yml new file mode 100644 index 0000000..0261375 --- /dev/null +++ b/.github/workflows/chromatic.yml @@ -0,0 +1,27 @@ +name: "Chromatic Publish" + +on: + push: + branches: + - main + paths: + - "packages/shared-ui/**" + - ".storybook/**" + +jobs: + publish-chromatic: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: "pnpm" + - run: pnpm install --frozen-lockfile + - uses: chromaui/action@latest + with: + projectToken: ${{ secrets.CHROMATIC_PROJECT_TOKEN }} + token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.storybook/css.d.ts b/.storybook/css.d.ts new file mode 100644 index 0000000..cbe652d --- /dev/null +++ b/.storybook/css.d.ts @@ -0,0 +1 @@ +declare module "*.css"; diff --git a/.storybook/main.ts b/.storybook/main.ts index 1312f45..b663a71 100644 --- a/.storybook/main.ts +++ b/.storybook/main.ts @@ -5,12 +5,20 @@ import path from "path"; const projectRoot = path.resolve(process.cwd()); const config: StorybookConfig = { - stories: ["../packages/shared-ui/src/**/*.stories.@(js|jsx|mjs|ts|tsx)"], + stories: [ + { + directory: "../packages/shared-ui/src", + files: "**/*.stories.@(js|jsx|mjs|ts|tsx)", + }, + ], addons: ["@storybook/addon-essentials"], framework: { name: "@storybook/react-vite", options: {}, }, + docs: { + autodocs: true, + }, staticDirs: ["../apps/landing/public"], async viteFinal(config) { const webRoot = path.resolve(projectRoot, "apps/landing"); diff --git a/.storybook/preview.tsx b/.storybook/preview.tsx index e200451..705218e 100644 --- a/.storybook/preview.tsx +++ b/.storybook/preview.tsx @@ -1,12 +1,19 @@ -import React from "react"; - import type { Preview } from "@storybook/react"; +import { AppModalProvider } from "../packages/shared-ui/src/components/app-modal"; import "../packages/shared-ui/src/global.css"; import "./preview.css"; const preview: Preview = { + decorators: [ + (Story) => ( + + + + ), + ], parameters: { + layout: "centered", controls: { matchers: { color: /(background|color)$/i, @@ -18,7 +25,7 @@ const preview: Preview = { values: [ { name: "light", - value: "oklch(0.9843 0.0017 247.8393)", + value: "#ffffff", }, { name: "dark", @@ -27,22 +34,6 @@ const preview: Preview = { ], }, }, - decorators: [ - (Story) => ( -
- -
- ), - ], }; export default preview; diff --git a/.storybook/tsconfig.json b/.storybook/tsconfig.json new file mode 100644 index 0000000..ca4676d --- /dev/null +++ b/.storybook/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../tsconfig.base.json", + "compilerOptions": { + "noEmit": true, + "jsx": "react-jsx" + }, + "include": ["./**/*.ts", "./**/*.tsx", "./**/*.d.ts"] +} diff --git a/package.json b/package.json index 8c87b34..c4a375d 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,8 @@ "format": "prettier --write \"**/*.{js,jsx,ts,tsx,json,css,scss,md}\"", "build:packages": "pnpm --recursive --filter './packages/*' run build", "storybook": "storybook dev -p 6006", - "build-storybook": "storybook build" + "build:storybook": "storybook build", + "chromatic": "chromatic --build-script-name build:storybook" }, "devDependencies": { "@changesets/cli": "^2.31.0", @@ -21,9 +22,14 @@ "@tailwindcss/postcss": "^4", "@tailwindcss/vite": "^4", "@trivago/prettier-plugin-sort-imports": "^6.0.0", + "@types/react": "19.2.15", + "@types/react-dom": "19.2.3", + "chromatic": "^18.5.0", "eslint-plugin-storybook": "^10.1.10", "prettier": "^3.7.4", "prettier-plugin-tailwindcss": "^0.7.2", + "react": "19.2.4", + "react-dom": "19.2.4", "storybook": "^8.6.15", "tailwindcss": "^4", "tw-animate-css": "^1.4.0", diff --git a/packages/shared-ui/package.json b/packages/shared-ui/package.json index 3745a11..b7f922f 100644 --- a/packages/shared-ui/package.json +++ b/packages/shared-ui/package.json @@ -8,9 +8,7 @@ "build": "tsup && tsc --project tsconfig.build.json && tailwindcss -i ./src/global.css -o ./dist/output.css --minify && for f in dist/index.mjs dist/index.js; do { printf '\"use client\";\\n'; cat \"$f\"; } > \"${f}.tmp\" && mv \"${f}.tmp\" \"$f\"; done", "build:css": "tailwindcss -i ./src/global.css -o ./dist/output.css --minify", "dev": "tsup --watch", - "type-check": "tsc --noEmit", - "storybook": "storybook dev -p 6006", - "build-storybook": "storybook build" + "type-check": "tsc --noEmit" }, "exports": { ".": { @@ -33,8 +31,17 @@ }, "dependencies": { "@base-ui/react": "^1.5.0", + "canvg": "^4.0.3", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "countries-list": "^3.4.1", + "country-flag-icons": "^1.6.20", + "framer-motion": "^12.43.0", + "heic-to": "^1.5.2", + "libphonenumber-js": "^1.13.11", + "react-dropzone": "^20.1.1", + "react-syntax-highlighter": "^16.1.1", + "react-useanimations": "^2.10.0", "tailwind-merge": "^3.6.0" }, "devDependencies": { @@ -42,6 +49,7 @@ "@tailwindcss/postcss": "^4", "@types/react": "^19", "@types/react-dom": "^19", + "@types/react-syntax-highlighter": "^15.5.13", "tailwindcss": "^4", "tsup": "^8", "typescript": "^5" diff --git a/packages/shared-ui/src/button.tsx b/packages/shared-ui/src/button.tsx deleted file mode 100644 index ca58f87..0000000 --- a/packages/shared-ui/src/button.tsx +++ /dev/null @@ -1,50 +0,0 @@ -import { Button as ButtonPrimitive } from "@base-ui/react/button"; -import { type VariantProps, cva } from "class-variance-authority"; - -import { cn } from "./cn"; - -const buttonVariants = cva( - "group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", - { - variants: { - variant: { - default: "bg-primary text-primary-foreground hover:bg-primary/80", - outline: - "border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50", - secondary: - "bg-secondary text-secondary-foreground hover:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)] aria-expanded:bg-secondary aria-expanded:text-secondary-foreground", - ghost: - "hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50", - destructive: - "bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40", - link: "text-primary underline-offset-4 hover:underline", - }, - size: { - default: "h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2", - xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3", - sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5", - lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2", - icon: "size-8", - "icon-xs": - "size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3", - "icon-sm": "size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg", - "icon-lg": "size-9", - }, - }, - defaultVariants: { - variant: "default", - size: "default", - }, - } -); - -function Button({ - className, - variant = "default", - size = "default", - ...props -}: ButtonPrimitive.Props & VariantProps) { - return ; -} - -export { Button, buttonVariants }; diff --git a/packages/shared-ui/src/components/app-modal/app-modal.stories.tsx b/packages/shared-ui/src/components/app-modal/app-modal.stories.tsx new file mode 100644 index 0000000..12eac23 --- /dev/null +++ b/packages/shared-ui/src/components/app-modal/app-modal.stories.tsx @@ -0,0 +1,190 @@ +import type { Meta, StoryObj } from "@storybook/react" + +import { Button } from "../../ui/button" +import { Input } from "../../ui/input" +import { AppModal } from "./index" + +const meta: Meta = { + title: "Components/AppModal", + decorators: [ + (Story) => ( +
+ +
+ ), + ], +} + +export default meta + +type Story = StoryObj + +export const Small: Story = { + render: () => ( + + ), +} + +export const Medium: Story = { + render: () => ( + + ), +} + +export const Full: Story = { + render: () => ( + + ), +} + +export const CustomFooter: Story = { + render: () => ( + +
+ + +
+ + ), + }) + } + > + Open with custom footer + + ), +} + +export const Destructive: Story = { + render: () => ( + + ), +} + +export const NoCloseButton: Story = { + render: () => ( + + ), +} + +export const Steps: Story = { + render: () => ( + + ), +} diff --git a/packages/shared-ui/src/components/app-modal/index.tsx b/packages/shared-ui/src/components/app-modal/index.tsx new file mode 100644 index 0000000..3ce1555 --- /dev/null +++ b/packages/shared-ui/src/components/app-modal/index.tsx @@ -0,0 +1,235 @@ +import { + useCallback, + useEffect, + useState, + type ReactNode, +} from "react" +import { AnimatePresence, motion } from "framer-motion" + +import { CloseX } from "../../icons" +import { cn } from "../../cn" +import { Button, type ButtonProps } from "../../ui/button" +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "../../ui/dialog" + +type ModalButtonProps = ButtonProps & { + children: ReactNode +} + +export interface AppModalOptions { + title: string + step?: ReactNode + description?: string + content?: ReactNode + footer?: ReactNode + primaryButton?: ModalButtonProps + secondaryButton?: ModalButtonProps + size?: "small" | "medium" | "full" + showCloseButton?: boolean + onClose?: () => void +} + +type ModalState = { open: boolean; config: AppModalOptions | null } +type SetModalState = (value: ModalState | ((prev: ModalState) => ModalState)) => void + +let setGlobalState: SetModalState | null = null + +export const AppModal = { + open: (options: AppModalOptions) => + setGlobalState?.({ open: true, config: options }), + close: () => setGlobalState?.((prev) => ({ ...prev, open: false })), + updateConfig: (partial: Partial) => + setGlobalState?.((prev) => + prev.config ? { ...prev, config: { ...prev.config, ...partial } } : prev + ), +} + +export interface AppModalUIProps extends AppModalOptions { + open: boolean + onOpenChange: (open: boolean) => void + onExited?: () => void +} + +const sizeStyles = { + small: "max-h-[90vh] sm:max-w-md", + medium: "max-h-[90vh] sm:max-w-2xl", + full: "h-full max-h-none w-full max-w-none rounded-none sm:max-w-none", +} + +function AppModalUI({ + open, + onOpenChange, + title, + step, + description, + content, + footer, + primaryButton, + secondaryButton, + size = "small", + showCloseButton = true, + onExited, +}: AppModalUIProps) { + const [present, setPresent] = useState(open) + + useEffect(() => { + if (open) setPresent(true) + }, [open]) + + const generatedFooter = + footer ?? + ((primaryButton || secondaryButton) ? ( + <> + {primaryButton ? + + +
+
+
+
+ ) : isSuccess && file ? ( +
+ {file.preview ? ( + + ) : ( + + )} +
+

{file.name}

+

+ {formatBytes(file.size)} + {file.width && file.height ? ` - ${file.width}x${file.height}` : ""} +

+
+
+ + +
+
+ ) : displayError ? ( + } + title={displayError} + titleClassName="text-error" + hint={ + <> + Drag an image here or + + } + /> + ) : ( + + ) : ( + + ) + } + title={ + isDrag ? ( + "Drop to upload" + ) : ( + <> + Drag an image here or + + ) + } + hint={hint} + titleClassName={disabled ? "text-disabled-foreground" : undefined} + hintClassName={disabled ? "text-disabled-foreground" : undefined} + /> + )} + + + {displayError && !isUploading && !isSuccess ? ( +

+ {displayError} +

+ ) : null} + + ) +} + +export { FileUpload, ImageTransformer } +export type { FileRejection, FileUploadState, MimeType, TransformOptions } diff --git a/packages/shared-ui/src/components/phone-number-field/index.tsx b/packages/shared-ui/src/components/phone-number-field/index.tsx new file mode 100644 index 0000000..3c8e6ee --- /dev/null +++ b/packages/shared-ui/src/components/phone-number-field/index.tsx @@ -0,0 +1,369 @@ +import { Combobox } from "@base-ui/react/combobox" +import { + memo, + useCallback, + useId, + useLayoutEffect, + useMemo, + useRef, + useState, + type ChangeEvent, + type ComponentProps, +} from "react" +import { getCountryData, type TCountryCode } from "countries-list" +import { countries } from "country-flag-icons" +import * as CountryFlags from "country-flag-icons/react/3x2" +import { + AsYouType, + parsePhoneNumberWithError, + type CountryCode, +} from "libphonenumber-js" + +import { Check, ChevronDown, Search } from "../../icons" +import { type MixinProps, splitProps } from "../../lib/mixin" +import { cn } from "../../cn" +import { InputGroup, InputGroupInput } from "../../ui/input-group" +import { Label } from "../../ui/label" + +export type PhoneNumber = { + number: string + countryCode: string +} + +const DEFAULT_COUNTRY_CODE = "US" + +export const phoneNumberToString = (phoneNumber: PhoneNumber): string => { + try { + const parsed = parsePhoneNumberWithError( + phoneNumber.number, + phoneNumber.countryCode as CountryCode + ) + if (parsed?.isValid()) return parsed.formatInternational() + } catch {} + const { phone } = getCountryData(phoneNumber.countryCode as TCountryCode) + const prefix = phone?.[0] ? `+${phone[0]}` : "+1" + const digits = phoneNumber.number.replace(/[^\d]/g, "") + return `${prefix} ${digits}` +} + +export const phoneNumberFromString = (raw: string): PhoneNumber => { + if (!raw?.trim()) return { number: "", countryCode: DEFAULT_COUNTRY_CODE } + + try { + const parsed = parsePhoneNumberWithError(raw) + if (parsed?.country) { + return { number: parsed.formatNational(), countryCode: parsed.country } + } + } catch {} + + const countryCodeMatch = raw.match(/^(\+\d{1,4})/) + if (!countryCodeMatch) { + return { + number: raw.replace(/[^\d]/g, ""), + countryCode: DEFAULT_COUNTRY_CODE, + } + } + + const prefix = countryCodeMatch[1] + const number = raw.slice(prefix.length).replace(/[^\d]/g, "") + + if (prefix === "+1") return { number, countryCode: DEFAULT_COUNTRY_CODE } + + const countryCode = countries.find((iso) => { + const { phone } = getCountryData(iso as TCountryCode) + return phone && phone.length > 0 && `+${phone[0]}` === prefix + }) + + return { number, countryCode: countryCode ?? DEFAULT_COUNTRY_CODE } +} + +type CountryOption = { + name: string + prefix: string + countryCode: string + searchKey: string +} + +const COUNTRIES_DATA: CountryOption[] = countries.flatMap((countryCode) => { + const { name, phone } = getCountryData(countryCode as TCountryCode) + if (!name || !phone.length) return [] + + return phone.map((prefix) => ({ + name, + prefix: `+${prefix}`, + countryCode, + searchKey: `${name} ${countryCode} +${prefix}`.toLowerCase(), + })) +}) + +type LabelProps = ComponentProps +type ErrorProps = ComponentProps<"p"> +type FlagProps = ComponentProps<(typeof CountryFlags)["US"]> + +export interface PhoneNumberFieldProps + extends MixinProps<"flag", Omit>, + MixinProps<"label", Omit>, + MixinProps< + "input", + Omit, "onChange" | "value"> + >, + MixinProps<"error", Omit>, + MixinProps<"group", Omit, "children">> { + id?: string + value: PhoneNumber + onChange: (value: PhoneNumber) => void + disabled?: boolean + label?: LabelProps["children"] | null + error?: ErrorProps["children"] | null + defaultOpen?: boolean + className?: string + "data-state"?: "focus" +} + +const flagClassName = + "inline-flex h-3.5 w-[21px] shrink-0 overflow-hidden rounded-[2px] ring-1 ring-border [&_svg]:block [&_svg]:size-full" + +const CountryFlag = memo(function CountryFlag({ + countryCode, + className, + ...props +}: { countryCode: string } & FlagProps) { + const FlagComponent = + CountryFlags[countryCode as TCountryCode] ?? CountryFlags.US + + return ( + + + + ) +}) + +const formatAsYouType = (raw: string, countryCode: string): string => { + const formatted = new AsYouType(countryCode as CountryCode).input(raw) + const digits = raw.replace(/\D/g, "") + + if (formatted === digits && digits.length >= 6) { + if (digits.length <= 6) return `${digits.slice(0, 3)} ${digits.slice(3)}` + if (digits.length <= 9) + return `${digits.slice(0, 3)} ${digits.slice(3, 6)} ${digits.slice(6)}` + return `${digits.slice(0, 3)} ${digits.slice(3, 6)} ${digits.slice(6, 10)}` + } + + return formatted +} + +function PhoneNumberField({ + id: idProp, + value, + onChange, + disabled, + label, + error, + defaultOpen, + className, + "data-state": dataState, + ...mixProps +}: PhoneNumberFieldProps) { + const generatedId = useId() + const id = idProp ?? generatedId + const labelId = useId() + const [groupEl, setGroupEl] = useState(null) + const [countryOpen, setCountryOpen] = useState(false) + const inputRef = useRef(null) + const invalid = Boolean(error) + + useLayoutEffect(() => { + if (!defaultOpen) return + const frame = requestAnimationFrame(() => setCountryOpen(true)) + return () => cancelAnimationFrame(frame) + }, [defaultOpen]) + + const { + group, + label: labelProps, + flag, + input, + error: errorProps, + } = splitProps(mixProps, "label", "flag", "input", "error", "group") + + const selectedCountry = useMemo( + () => + COUNTRIES_DATA.find((country) => country.countryCode === value.countryCode) ?? + COUNTRIES_DATA.find((country) => country.countryCode === DEFAULT_COUNTRY_CODE), + [value.countryCode] + ) + + const displayNumber = useMemo( + () => (value.number ? formatAsYouType(value.number, value.countryCode) : ""), + [value.number, value.countryCode] + ) + + const handleCountrySelect = useCallback( + (country: CountryOption | null) => { + if (!country) return + const digits = value.number.replace(/\D/g, "") + const number = digits ? formatAsYouType(digits, country.countryCode) : "" + onChange({ countryCode: country.countryCode, number }) + requestAnimationFrame(() => inputRef.current?.focus()) + }, + [onChange, value.number] + ) + + const handleNumberChange = useCallback( + (event: ChangeEvent) => { + const raw = event.target.value + const newDigits = raw.replace(/\D/g, "") + const prevDigits = displayNumber.replace(/\D/g, "") + const digits = + newDigits.length === prevDigits.length && raw.length < displayNumber.length + ? newDigits.slice(0, -1) + : newDigits + + onChange({ + ...value, + number: digits ? formatAsYouType(digits, value.countryCode) : "", + }) + }, + [onChange, value, displayNumber] + ) + + return ( +
+ {label ? ( + + ) : null} + item.searchKey} + isItemEqualToValue={(a, b) => + a.countryCode === b.countryCode && a.prefix === b.prefix + } + > + + + + + {selectedCountry?.prefix || "+1"} + + + + + + + + +
+ + +
+ + No countries found + + + {(item: CountryOption) => ( + + + {item.prefix} + {item.name} + + + + + )} + +
+
+
+
+ {error ? ( +

+ {error} +

+ ) : null} +
+ ) +} + +export { PhoneNumberField, COUNTRIES_DATA } +export type { CountryOption } diff --git a/packages/shared-ui/src/components/phone-number-field/phone-number-field.stories.tsx b/packages/shared-ui/src/components/phone-number-field/phone-number-field.stories.tsx new file mode 100644 index 0000000..4b588cf --- /dev/null +++ b/packages/shared-ui/src/components/phone-number-field/phone-number-field.stories.tsx @@ -0,0 +1,73 @@ +import { useId, useState, type ComponentProps } from "react" +import type { Meta, StoryObj } from "@storybook/react" + +import { PhoneNumberField, type PhoneNumber } from "./index" + +const emptyValue: PhoneNumber = { number: "", countryCode: "US" } + +function PhoneFieldDemo( + props: Omit, "value" | "onChange"> +) { + const id = useId() + const [value, setValue] = useState(emptyValue) + return ( + + ) +} + +const meta: Meta = { + title: "Components/PhoneNumberField", + component: PhoneNumberField, + args: { + className: "w-72", + label: "Phone Number", + }, + render: (args) => , +} + +export default meta + +type Story = StoryObj + +export const Default: Story = {} + +export const Focused: Story = { + args: { "data-state": "focus" }, +} + +export const Invalid: Story = { + args: { + error: "Error message", + }, +} + +export const Disabled: Story = { + args: { disabled: true }, +} + +export const CountryOpen: Story = { + parameters: { + docs: { disable: true }, + }, + render: (args) => ( +
+ +
+ ), +} + +export const States: Story = { + render: () => ( +
+ + + + +
+ ), +} diff --git a/packages/shared-ui/src/components/resource-field/index.tsx b/packages/shared-ui/src/components/resource-field/index.tsx new file mode 100644 index 0000000..0ec6624 --- /dev/null +++ b/packages/shared-ui/src/components/resource-field/index.tsx @@ -0,0 +1,302 @@ +import { + useMemo, + useRef, + useState, + type ComponentProps, + type ReactNode, +} from "react" + +import { ChevronDown, ChevronsUpDown, CornerDownLeft, Search } from "../../icons" +import { type MixinProps, splitProps } from "../../lib/mixin" +import { cn } from "../../cn" +import { Input } from "../../ui/input" +import { InputGroup, InputGroupAddon, InputGroupInput } from "../../ui/input-group" +import { Label } from "../../ui/label" +import { Popover, PopoverContent, PopoverTrigger } from "../../ui/popover" +import { Spinner } from "../spinner" + +export interface ResourceFieldProps + extends MixinProps<"container", ComponentProps<"div">>, + MixinProps<"popover", ComponentProps>, + MixinProps<"label", Omit, "children">>, + MixinProps<"error", Omit, "children">> { + value: T | null + onChange: (val: T | null) => void + items: T[] + getItemTitle: (item: T) => string + getItemGroup?: (item: T) => string + renderSearchItem: (item: T, isSelected: boolean) => ReactNode + renderSummary?: (item: T) => ReactNode + searchFilter: (item: T, query: string) => boolean + renderActions?: ReactNode + recentLabel?: string + label?: ReactNode + error?: ReactNode + placeholder?: string + isLoading?: boolean + defaultOpen?: boolean +} + +function ResourceFieldItem({ + selected, + className, + children, + ...props +}: ComponentProps<"div"> & { selected?: boolean }) { + return ( +
+ {children} + +
+ ) +} + +function ResourceFieldKey({ + resource, + className, + ...inputProps +}: Omit, "className"> & { + resource: ReactNode + className?: string +}) { + return ( +
+ + {resource} + + + . + +
+ ) +} + +function ResourceField({ + value, + onChange, + items, + getItemTitle, + getItemGroup, + renderSearchItem, + renderSummary, + searchFilter, + renderActions, + recentLabel = "Recent", + label, + error, + placeholder = "Search resources", + isLoading = false, + defaultOpen = false, + ...mixProps +}: ResourceFieldProps) { + const { + container, + label: labelProps, + error: errorProps, + popover: popProps, + } = splitProps(mixProps, "container", "label", "error", "popover") + + const [open, setOpen] = useState(defaultOpen) + const [query, setQuery] = useState("") + const [anchor, setAnchor] = useState(null) + const inputRef = useRef(null) + + const filtered = useMemo( + () => items.filter((item) => searchFilter(item, query)), + [items, query, searchFilter] + ) + + const grouped = useMemo(() => { + if (!getItemGroup) return null + const groups = new Map() + for (const item of filtered) { + const group = getItemGroup(item) + const list = groups.get(group) ?? [] + list.push(item) + groups.set(group, list) + } + return [...groups.entries()] + }, [filtered, getItemGroup]) + + const isItemSelected = (item: T) => + value != null && getItemTitle(value) === getItemTitle(item) + + const selectItem = (item: T) => { + onChange(item) + setOpen(false) + setQuery("") + } + + const renderItemButton = (item: T) => ( + + ) + + const picker = ( + +
+ + + + + setQuery(event.target.value)} + /> + +
+ +
+ {renderActions && !query ? ( +
+
+ Create +
+ {renderActions} +
+ ) : null} + + {grouped + ? grouped.map(([group, groupItems]) => ( +
+
+ {group} +
+
+ {groupItems.map(renderItemButton)} +
+
+ )) + : ( + <> + {filtered.length > 0 && !query ? ( +
+ {recentLabel} +
+ ) : null} +
+ {filtered.map(renderItemButton)} +
+ + )} + + {filtered.length === 0 ? ( +
+ No results found +
+ ) : null} +
+
+ ) + + const trigger = (text: string) => ( + + {text} + {isLoading ? ( + + ) : ( + + )} + + ) + + const selectedTriggerClassName = cn( + "inline-flex h-9 min-w-0 items-center gap-1.5 rounded-lg border border-input bg-popover px-2.5 font-mono text-sm text-foreground outline-none select-none", + "focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50", + error && "border-error shadow-ring-error ring-0" + ) + + const summary = value ? renderSummary?.(value) : null + + const field = ( + + {value ? ( +
+ {summary ?? ( + + {getItemTitle(value)} + {isLoading ? ( + + ) : ( + + )} + + )} +
+ ) : ( + trigger(placeholder) + )} + {picker} +
+ ) + + return ( +
+ {label ? ( + + ) : null} + {field} + {error ? ( +

+ {error} +

+ ) : null} +
+ ) +} + +export { ResourceField, ResourceFieldItem, ResourceFieldKey } diff --git a/packages/shared-ui/src/components/resource-field/resource-field.stories.tsx b/packages/shared-ui/src/components/resource-field/resource-field.stories.tsx new file mode 100644 index 0000000..9fdaccd --- /dev/null +++ b/packages/shared-ui/src/components/resource-field/resource-field.stories.tsx @@ -0,0 +1,161 @@ +import { useState } from "react" +import type { Meta, StoryObj } from "@storybook/react" + +import { Popover } from "../../ui/popover" +import { + ResourceField, + ResourceFieldItem, + ResourceFieldKey, +} from "./index" + +type Resource = { + group: string + title: string + keyHint?: boolean +} + +const resources: Resource[] = [ + { group: "Payment", title: "amount" }, + { group: "Payment", title: "currency" }, + { group: "Payment", title: "status" }, + { group: "Customer", title: "customer.email" }, + { group: "Customer", title: "customer.name" }, + { group: "Customer", title: "customer.metadata.", keyHint: true }, + { group: "Product", title: "product.id" }, + { group: "Product", title: "product.metadata.", keyHint: true }, +] + +function itemLabel(item: Resource) { + if (!item.keyHint) return item.title + const [prefix] = item.title.split(".") + return ( + <> + {prefix}. + <key> + + ) +} + +function ResourceFieldDemo({ + defaultOpen, + defaultValue = null, + error, +}: { + defaultOpen?: boolean + defaultValue?: Resource | null + error?: string +}) { + const [value, setValue] = useState(defaultValue) + const [key, setKey] = useState("region") + + return ( + item.title} + getItemGroup={(item) => item.group} + searchFilter={(item, query) => + item.title.toLowerCase().includes(query.toLowerCase()) + } + renderSearchItem={(item, selected) => ( + {itemLabel(item)} + )} + renderSummary={(item) => + item.keyHint ? ( + ", "")} + value={key} + onChange={(event) => setKey(event.target.value)} + /> + ) : undefined + } + /> + ) +} + +const meta: Meta = { + title: "Components/ResourceField", + component: ResourceField, +} + +export default meta + +type Story = StoryObj + +export const Default: Story = { + render: () => , +} + +export const Open: Story = { + parameters: { + docs: { disable: true }, + }, + render: () => ( +
+ item.title === "product.id") ?? null} + /> +
+ ), +} + +export const Selected: Story = { + render: () => ( + item.title === "product.id") ?? null} + /> + ), +} + +export const WithKey: Story = { + render: () => ( + item.title === "product.metadata.") ?? null + } + /> + ), +} + +export const Item: Story = { + render: () => ( +
+
+ Payment +
+
+ amount + currency + status +
+
+ Product +
+
+ product.id + product.name +
+
+ ), +} + +export const Key: Story = { + render: function KeyComposer() { + const [key, setKey] = useState("region") + return ( + + setKey(event.target.value)} + /> + + ) + }, +} diff --git a/packages/shared-ui/src/components/rule-builder-condition-row/index.tsx b/packages/shared-ui/src/components/rule-builder-condition-row/index.tsx new file mode 100644 index 0000000..4709d47 --- /dev/null +++ b/packages/shared-ui/src/components/rule-builder-condition-row/index.tsx @@ -0,0 +1,184 @@ +import type { ComponentProps } from "react" + +import { CloseX } from "../../icons" +import { type MixinProps, splitProps } from "../../lib/mixin" +import { cn } from "../../cn" +import { Button } from "../../ui/button" +import { Input } from "../../ui/input" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "../../ui/select" + +const DEFAULT_OPERATORS = [ + { value: "is", label: "is" }, + { value: "is_not", label: "is not" }, + { value: "contains", label: "contains" }, +] as const + +export interface RuleBuilderConditionRowProps + extends MixinProps<"container", ComponentProps<"div">>, + MixinProps<"trigger", Omit, "children">>, + MixinProps<"content", Omit, "children">>, + MixinProps<"key", Omit, "value" | "onChange">>, + MixinProps< + "operatorTrigger", + Omit, "children"> + >, + MixinProps< + "operatorContent", + Omit, "children"> + >, + MixinProps<"input", Omit, "value" | "onChange">>, + MixinProps<"remove", Omit, "children">> { + resource: T | null + onResourceChange: (value: T | null) => void + resources: T[] + getItemValue: (item: T) => string + getItemTitle: (item: T) => string + fieldKey: string + onFieldKeyChange: (value: string) => void + operator: string | null + onOperatorChange: (value: string | null) => void + operators?: { value: string; label: string }[] + value: string + onValueChange: (value: string) => void + onRemove?: () => void + disabled?: boolean + placeholder?: string +} + +function RuleBuilderConditionRow({ + resource, + onResourceChange, + resources, + getItemValue, + getItemTitle, + fieldKey, + onFieldKeyChange, + operator, + onOperatorChange, + operators = [...DEFAULT_OPERATORS], + value, + onValueChange, + onRemove, + disabled = false, + placeholder = "Select field", + ...mixProps +}: RuleBuilderConditionRowProps) { + const { + container, + trigger, + content, + key: keyProps, + operatorTrigger, + operatorContent, + input, + remove, + } = splitProps( + mixProps, + "container", + "trigger", + "content", + "key", + "operatorTrigger", + "operatorContent", + "input", + "remove" + ) + + return ( +
+
+ + . + onFieldKeyChange(event.target.value)} + className={cn("h-9 w-28 font-mono md:text-sm", keyProps.className)} + /> +
+ + + + onValueChange(event.target.value)} + className={cn("h-9 w-36 md:text-sm", input.className)} + /> + + {onRemove ? ( + + ) : null} +
+ ) +} + +export { RuleBuilderConditionRow } diff --git a/packages/shared-ui/src/components/rule-builder-condition-row/rule-builder-condition-row.stories.tsx b/packages/shared-ui/src/components/rule-builder-condition-row/rule-builder-condition-row.stories.tsx new file mode 100644 index 0000000..bdfc529 --- /dev/null +++ b/packages/shared-ui/src/components/rule-builder-condition-row/rule-builder-condition-row.stories.tsx @@ -0,0 +1,97 @@ +import { useState } from "react" +import type { Meta, StoryObj } from "@storybook/react" + +import { RuleBuilderConditionRow } from "./index" + +type Resource = { + id: string + title: string +} + +const resources: Resource[] = [ + { id: "customer.metadata", title: "customer.metadata" }, + { id: "customer.email", title: "customer.email" }, + { id: "product.metadata", title: "product.metadata" }, + { id: "payment.status", title: "payment.status" }, +] + +type Row = { + resourceId: string + fieldKey: string + operator: string + value: string +} + +const figRow: Row = { + resourceId: "customer.metadata", + fieldKey: "plan_tier", + operator: "is", + value: "enterprise", +} + +function findResource(id: string) { + return resources.find((item) => item.id === id) ?? null +} + +function ConditionRowDemo(props: { + defaultRow?: Row + showRemove?: boolean + disabled?: boolean +}) { + const [row, setRow] = useState(props.defaultRow ?? figRow) + + return ( + + setRow((current) => ({ ...current, resourceId: item?.id ?? "" })) + } + resources={resources} + getItemValue={(item) => item.id} + getItemTitle={(item) => item.title} + fieldKey={row.fieldKey} + onFieldKeyChange={(fieldKey) => + setRow((current) => ({ ...current, fieldKey })) + } + operator={row.operator} + onOperatorChange={(operator) => + setRow((current) => ({ ...current, operator: operator ?? "is" })) + } + value={row.value} + onValueChange={(value) => setRow((current) => ({ ...current, value }))} + onRemove={props.showRemove ? () => undefined : undefined} + disabled={props.disabled} + /> + ) +} + +const meta: Meta = { + title: "Components/RuleBuilderConditionRow", + component: RuleBuilderConditionRow, +} + +export default meta + +type Story = StoryObj + +export const Default: Story = { + render: () => , +} + +export const Disabled: Story = { + render: () => , +} + +export const Empty: Story = { + render: () => ( + + ), +} diff --git a/packages/shared-ui/src/components/rule-builder-destination-row/index.tsx b/packages/shared-ui/src/components/rule-builder-destination-row/index.tsx new file mode 100644 index 0000000..65cfa98 --- /dev/null +++ b/packages/shared-ui/src/components/rule-builder-destination-row/index.tsx @@ -0,0 +1,156 @@ +import type { ComponentProps, ReactNode } from "react" + +import { CloseX } from "../../icons" +import { type MixinProps, splitProps } from "../../lib/mixin" +import { cn } from "../../cn" +import { Badge } from "../../ui/badge" +import { Button } from "../../ui/button" +import { Input } from "../../ui/input" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "../../ui/select" + +export interface RuleBuilderDestinationRowProps + extends MixinProps<"container", ComponentProps<"div">>, + MixinProps<"trigger", Omit, "children">>, + MixinProps<"content", Omit, "children">>, + MixinProps<"input", Omit, "value" | "onChange">>, + MixinProps<"suffix", Omit, "children">>, + MixinProps<"badge", Omit, "children">>, + MixinProps<"remove", Omit, "children">> { + destination: T | null + onDestinationChange: (value: T | null) => void + destinations: T[] + getItemValue: (item: T) => string + getItemTitle: (item: T) => string + share: string + onShareChange: (value: string) => void + suffix?: ReactNode + primary?: boolean + primaryLabel?: ReactNode + onRemove?: () => void + disabled?: boolean + placeholder?: string +} + +function RuleBuilderDestinationRow({ + destination, + onDestinationChange, + destinations, + getItemValue, + getItemTitle, + share, + onShareChange, + suffix = "%", + primary = false, + primaryLabel = "Primary", + onRemove, + disabled = false, + placeholder = "Select destination", + ...mixProps +}: RuleBuilderDestinationRowProps) { + const { + container, + trigger, + content, + input, + suffix: suffixProps, + badge, + remove, + } = splitProps( + mixProps, + "container", + "trigger", + "content", + "input", + "suffix", + "badge", + "remove" + ) + + return ( +
+
+ +
+ +
+ onShareChange(event.target.value)} + className={cn("h-9 w-14 text-center md:text-sm", input.className)} + /> + + {suffix} + +
+ +
+ {primary ? ( + {primaryLabel} + ) : onRemove ? ( + + ) : null} +
+
+ ) +} + +export { RuleBuilderDestinationRow } diff --git a/packages/shared-ui/src/components/rule-builder-destination-row/rule-builder-destination-row.stories.tsx b/packages/shared-ui/src/components/rule-builder-destination-row/rule-builder-destination-row.stories.tsx new file mode 100644 index 0000000..01b5337 --- /dev/null +++ b/packages/shared-ui/src/components/rule-builder-destination-row/rule-builder-destination-row.stories.tsx @@ -0,0 +1,164 @@ +import { useState } from "react" +import type { Meta, StoryObj } from "@storybook/react" + +import { RuleBuilderDestinationRow } from "./index" + +type Destination = { + id: string + name: string + description: string +} + +const destinations: Destination[] = [ + { id: "stripe-global", name: "Stripe", description: "global cards" }, + { id: "adyen-eu", name: "Adyen", description: "EU acquiring" }, + { id: "checkout-uk", name: "Checkout.com", description: "UK cards" }, +] + +const destinationTitle = (item: Destination) => + `${item.name} · ${item.description}` + +type Row = { + id: string + destinationId: string + share: string + primary?: boolean +} + +const figRows: Row[] = [ + { + id: "1", + destinationId: "stripe-global", + share: "60", + primary: true, + }, + { + id: "2", + destinationId: "adyen-eu", + share: "60", + }, +] + +function findDestination(id: string) { + return destinations.find((item) => item.id === id) ?? null +} + +function DestinationRowDemo(props: { + defaultRow?: Row + showRemove?: boolean + disabled?: boolean +}) { + const [row, setRow] = useState( + props.defaultRow ?? { + id: "1", + destinationId: "stripe-global", + share: "60", + primary: true, + } + ) + + return ( + + setRow((current) => ({ + ...current, + destinationId: item?.id ?? "", + })) + } + destinations={destinations} + getItemValue={(item) => item.id} + getItemTitle={destinationTitle} + share={row.share} + onShareChange={(share) => setRow((current) => ({ ...current, share }))} + primary={row.primary} + onRemove={props.showRemove ? () => undefined : undefined} + disabled={props.disabled} + containerClassName="max-w-xl" + /> + ) +} + +function DestinationRowsDemo({ defaultRows = figRows }: { defaultRows?: Row[] }) { + const [rows, setRows] = useState(defaultRows) + + const updateRow = (id: string, next: Partial) => { + setRows((current) => + current.map((row) => (row.id === id ? { ...row, ...next } : row)) + ) + } + + return ( +
+ {rows.map((row) => ( + + updateRow(row.id, { destinationId: item?.id ?? "" }) + } + destinations={destinations} + getItemValue={(item) => item.id} + getItemTitle={destinationTitle} + share={row.share} + onShareChange={(share) => updateRow(row.id, { share })} + primary={row.primary} + onRemove={ + row.primary + ? undefined + : () => + setRows((current) => + current.filter((item) => item.id !== row.id) + ) + } + /> + ))} +
+ ) +} + +const meta: Meta = { + title: "Components/RuleBuilderDestinationRow", + component: RuleBuilderDestinationRow, +} + +export default meta + +type Story = StoryObj + +export const Default: Story = { + render: () => , +} + +export const Primary: Story = { + render: () => , +} + +export const Removable: Story = { + render: () => ( + + ), +} + +export const Disabled: Story = { + render: () => , +} + +export const Empty: Story = { + render: () => ( + + ), +} diff --git a/packages/shared-ui/src/components/search-field/index.tsx b/packages/shared-ui/src/components/search-field/index.tsx new file mode 100644 index 0000000..64dd58c --- /dev/null +++ b/packages/shared-ui/src/components/search-field/index.tsx @@ -0,0 +1,84 @@ +import type { ComponentProps } from "react" + +import { CloseX, Search } from "../../icons" +import { cn } from "../../cn" +import { + InputGroup, + InputGroupAddon, + InputGroupButton, + InputGroupInput, +} from "../../ui/input-group" + +type SearchFieldProps = Omit< + ComponentProps, + "type" | "className" | "data-state" +> & { + className?: string + "data-state"?: "hover" | "focus" +} + +function SearchField({ + className, + disabled, + placeholder = "Search providers", + "data-state": dataState, + ...props +}: SearchFieldProps) { + return ( + + + + + + + + + { + const input = event.currentTarget + .closest("[data-slot=input-group]") + ?.querySelector("input") + if (!(input instanceof HTMLInputElement)) return + // ponytail: native setter so React's value tracker still fires onChange + Object.getOwnPropertyDescriptor( + HTMLInputElement.prototype, + "value" + )?.set?.call(input, "") + input.dispatchEvent(new Event("input", { bubbles: true })) + input.focus() + }} + > + + + + + ) +} + +export { SearchField } +export type { SearchFieldProps } diff --git a/packages/shared-ui/src/components/search-field/search-field.stories.tsx b/packages/shared-ui/src/components/search-field/search-field.stories.tsx new file mode 100644 index 0000000..2977734 --- /dev/null +++ b/packages/shared-ui/src/components/search-field/search-field.stories.tsx @@ -0,0 +1,45 @@ +import type { Meta, StoryObj } from "@storybook/react" + +import { SearchField } from "./index" + +const meta: Meta = { + title: "Components/SearchField", + component: SearchField, + args: { + className: "w-80", + }, +} + +export default meta + +type Story = StoryObj + +export const Default: Story = {} + +export const Hover: Story = { + args: { "data-state": "hover" }, +} + +export const Focused: Story = { + args: { "data-state": "focus" }, +} + +export const Filled: Story = { + args: { "data-state": "focus", defaultValue: "stripe2" }, +} + +export const Disabled: Story = { + args: { disabled: true }, +} + +export const States: Story = { + render: () => ( +
+ + + + + +
+ ), +} diff --git a/packages/shared-ui/src/components/spinner/index.tsx b/packages/shared-ui/src/components/spinner/index.tsx new file mode 100644 index 0000000..2a9005d --- /dev/null +++ b/packages/shared-ui/src/components/spinner/index.tsx @@ -0,0 +1,12 @@ +"use client"; + +import * as React from "react"; + +import UseAnimations from "react-useanimations"; +import loadingAnimation from "react-useanimations/lib/loading"; + +interface Props extends Omit, "animation"> {} + +export const Spinner = ({ size = 30, strokeColor = "currentColor", ...props }: Props) => { + return ; +}; diff --git a/packages/shared-ui/src/components/spinner/spinner.stories.tsx b/packages/shared-ui/src/components/spinner/spinner.stories.tsx new file mode 100644 index 0000000..474c0ad --- /dev/null +++ b/packages/shared-ui/src/components/spinner/spinner.stories.tsx @@ -0,0 +1,16 @@ +import { Meta, StoryObj } from "@storybook/react"; + +import { Spinner } from "./index"; + +const meta = { + title: "Components/Spinner", + component: Spinner, +} satisfies Meta; + +export default meta; + +export const Default: StoryObj = { + args: { + size: 30, + }, +}; diff --git a/packages/shared-ui/src/components/tag-input/index.tsx b/packages/shared-ui/src/components/tag-input/index.tsx new file mode 100644 index 0000000..a14566a --- /dev/null +++ b/packages/shared-ui/src/components/tag-input/index.tsx @@ -0,0 +1,206 @@ +import { useId, useState, type ComponentProps, type KeyboardEvent, type ReactNode } from "react" + +import { CloseX, Plus } from "../../icons" +import { type MixinProps, splitProps } from "../../lib/mixin" +import { cn } from "../../cn" +import { Badge } from "../../ui/badge" +import { Input } from "../../ui/input" +import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput } from "../../ui/input-group" +import { Label } from "../../ui/label" + +type InputMixin = Omit, "value" | "onChange"> & { + value?: string + onChange?: (value: string) => void +} + +export interface TagProps extends Omit, "children"> { + disabled?: boolean + onRemove?: () => void + children: ReactNode +} + +function Tag({ disabled, onRemove, className, children, ...props }: TagProps) { + return ( + + {children} + + + ) +} + +export interface TagInputProps + extends MixinProps<"input", InputMixin>, + MixinProps<"tag", Omit, "children">>, + MixinProps<"label", Omit, "children">>, + MixinProps<"error", Omit, "children">>, + MixinProps<"helpText", Omit, "children">> { + id?: string + value?: string[] + onChange?: (value: string[]) => void + placeholder?: string + label?: ReactNode + error?: ReactNode + helpText?: ReactNode + disabled?: boolean + className?: string + "data-state"?: "focus" +} + +function TagInput({ + id, + className, + value, + onChange, + placeholder = "Add values...", + label, + error, + helpText, + disabled = false, + "data-state": dataState, + ...restProps +}: TagInputProps) { + const generatedId = useId() + const inputId = id ?? generatedId + const { input, tag, label: labelProps, error: errorProps, helpText: helpTextProps } = splitProps( + restProps, + "input", + "tag", + "label", + "error", + "helpText" + ) + + const { value: pendingValue, onChange: onPendingChange, ...inputRest } = input + const isControlled = value !== undefined + const [internalTags, setInternalTags] = useState([]) + const [internalPending, setInternalPending] = useState("") + const tags = isControlled ? value : internalTags + const pending = pendingValue !== undefined ? pendingValue : internalPending + + const setTags = (next: string[]) => { + if (!isControlled) setInternalTags(next) + onChange?.(next) + } + + const setPending = (next: string) => { + if (onPendingChange) onPendingChange(next) + else setInternalPending(next) + } + + const addPending = () => { + const next = pending.trim() + if (!next) return + setTags(Array.from(new Set([...tags, next]))) + setPending("") + } + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "Enter" || event.key === ",") { + event.preventDefault() + addPending() + } else if (event.key === "Backspace" && !pending && tags.length > 0) { + setTags(tags.slice(0, -1)) + } + } + + return ( +
+ {label ? ( + + ) : null} + {helpText ? ( +

+ {helpText} +

+ ) : null} + + + {tags.map((item) => ( + setTags(tags.filter((tagItem) => tagItem !== item))} + {...tag} + className={cn(tag.className)} + > + {item} + + ))} + + setPending(event.target.value)} + onKeyDown={handleKeyDown} + onBlur={addPending} + /> + + {disabled ? null : ( + + { + if (pending.trim()) addPending() + else + event.currentTarget + .closest("[data-slot=input-group]") + ?.querySelector("input") + ?.focus() + }} + > + + + + )} + + + {error ? ( +

+ {error} +

+ ) : null} +
+ ) +} + +export { Tag, TagInput } diff --git a/packages/shared-ui/src/components/tag-input/tag-input.stories.tsx b/packages/shared-ui/src/components/tag-input/tag-input.stories.tsx new file mode 100644 index 0000000..0e3c36e --- /dev/null +++ b/packages/shared-ui/src/components/tag-input/tag-input.stories.tsx @@ -0,0 +1,64 @@ +import { useState } from "react" +import type { Meta, StoryObj } from "@storybook/react" + +import { Tag, TagInput } from "./index" + +const meta: Meta = { + title: "Components/TagInput", + component: TagInput, + args: { + className: "w-80", + placeholder: "Add values...", + }, +} + +export default meta + +type Story = StoryObj + +export const Default: Story = { + render: function DefaultStory(args) { + const [value, setValue] = useState([]) + return + }, +} + +export const Focused: Story = { + args: { + value: ["EU"], + "data-state": "focus", + }, +} + +export const Multiple: Story = { + args: { + value: ["EU", "UK"], + }, +} + +export const Disabled: Story = { + args: { + value: ["EU", "UK"], + disabled: true, + }, +} + +export const Tags: Story = { + render: () => ( +
+ EU + EU +
+ ), +} + +export const States: Story = { + render: () => ( +
+ + + + +
+ ), +} diff --git a/packages/shared-ui/src/components/timeline/index.tsx b/packages/shared-ui/src/components/timeline/index.tsx new file mode 100644 index 0000000..ed25b78 --- /dev/null +++ b/packages/shared-ui/src/components/timeline/index.tsx @@ -0,0 +1,364 @@ +import { useMemo, useState, type ComponentType, type ReactNode } from "react" + +import { Check, ChevronDown, ChevronUp, ClockPlay, CloseX } from "../../icons" +import { type MixinProps, splitProps } from "../../lib/mixin" +import { cn } from "../../cn" +import { Button } from "../../ui/button" +import { Skeleton } from "../../ui/skeleton" + +type LinkComponentType = ComponentType<{ + href: string + className?: string + target?: string + rel?: string + children?: ReactNode +}> + +const DefaultLink: LinkComponentType = ({ href, children, ...props }) => ( + + {children} + +) + +export type TimelineStatus = "success" | "pending" | "error" | "complete" + +export interface TimelineEntry { + title: ReactNode + date?: ReactNode + source?: ReactNode + description?: ReactNode + status?: TimelineStatus + data?: Record | null + contentOverride?: ReactNode + key?: string | number + titleClassName?: string +} + +export interface TimelineProps + extends MixinProps<"container", React.ComponentProps<"div">> { + items: T[] + renderItem: (item: T, index: number) => TimelineEntry + emptyMessage?: string + isLoading?: boolean + limit?: number + skeletonRowCount?: number + routeMap?: Record string> + linkComponent?: LinkComponentType +} + +const statusStyles: Record< + TimelineStatus, + { icon: typeof Check; className: string } +> = { + success: { + icon: Check, + className: "bg-muted text-muted-foreground", + }, + pending: { + icon: ClockPlay, + className: "bg-accent text-primary", + }, + error: { + icon: CloseX, + className: "bg-ring-error text-error", + }, + complete: { + icon: Check, + className: "bg-primary text-primary-foreground", + }, +} + +function formatLabel(key: string) { + return key.replace(/([A-Z])/g, " $1").replace(/^[a-z]/, (m) => m.toUpperCase()) +} + +function TimelineStatusIcon({ status }: { status: TimelineStatus }) { + const { icon: Icon, className } = statusStyles[status] + + return ( + + + + ) +} + +function TimelineSummary({ + data, + manualContent, + routeMap, + LinkComponent, +}: { + data?: Record | null + manualContent?: ReactNode + routeMap?: Record string> + LinkComponent: LinkComponentType +}) { + const summaryItems = useMemo(() => { + if (!data) return [] + + return Object.entries(data) + .filter(([key, val]) => key !== "$changes" && val !== null && typeof val !== "object") + .map(([key, val]) => { + const href = routeMap?.[key]?.(String(val)) + + return ( + + {formatLabel(key)}: + {href ? ( + + {String(val)} + + ) : ( + {String(val)} + )} + + ) + }) + }, [data, routeMap, LinkComponent]) + + if (manualContent) return
{manualContent}
+ if (!data || summaryItems.length === 0) return null + + return ( +
+ {summaryItems.reduce( + (prev, curr, i) => + i === 0 + ? [curr] + : [ + ...prev, + + • + , + curr, + ], + [] + )} +
+ ) +} + +function getDiffRows(changes: Record) { + const previousAttributes = changes.previous_attributes + + if (previousAttributes && typeof previousAttributes === "object") { + const previous = previousAttributes as Record + const current = (changes.data as Record | undefined) ?? {} + return Object.keys(previous).map((key) => ({ + key, + from: previous[key], + to: current[key], + })) + } + + return Object.entries(changes) + .filter(([, val]) => val && typeof val === "object" && ("from" in val || "to" in val)) + .map(([key, val]) => { + const entry = val as { from?: unknown; to?: unknown } + return { key, from: entry.from, to: entry.to } + }) +} + +function TimelineDiff({ changes }: { changes?: unknown }) { + if (!changes || typeof changes !== "object") return null + + const rows = getDiffRows(changes as Record) + if (rows.length === 0) return null + + return ( +
+ {rows.map((row) => ( +
+ {formatLabel(row.key)}: + + {row.from === null || row.from === "" || row.from === undefined ? "none" : String(row.from)} + + + + {row.to === null || row.to === "" || row.to === undefined ? "none" : String(row.to)} + +
+ ))} +
+ ) +} + +function TimelineItem({ + entry, + isLast, + routeMap, + LinkComponent, +}: { + entry: TimelineEntry + isLast: boolean + routeMap?: Record string> + LinkComponent: LinkComponentType +}) { + const status = entry.status ?? "success" + const isDetailed = Boolean(entry.description) + + return ( +
  • +
    + {!isLast ? ( +
    + +
    +
    +

    + {entry.title} +

    + {isDetailed ? ( + + {entry.date} + {entry.date && entry.source ? " - " : null} + {entry.source} + + ) : entry.source ? ( + {entry.source} + ) : null} +
    + + {!isDetailed && entry.date ? ( + + ) : null} + + {entry.description ? ( +

    {entry.description}

    + ) : null} + + + +
    +
  • + ) +} + +function Timeline({ + items, + renderItem, + isLoading, + limit = 0, + emptyMessage = "No story found", + skeletonRowCount = 3, + routeMap, + linkComponent: LinkComponent = DefaultLink, + ...mixProps +}: TimelineProps) { + const [isExpanded, setIsExpanded] = useState(false) + const { container } = splitProps(mixProps, "container") + + if (isLoading) { + return ( +
    +
      + {Array.from({ length: skeletonRowCount }).map((_, i) => ( +
    • +
      + {i < skeletonRowCount - 1 ? ( +
      +
      + + +
      +
    • + ))} +
    +
    + ) + } + + if (!items?.length) { + return ( +
    + {emptyMessage} +
    + ) + } + + const displayItems = limit > 0 && items.length > limit && !isExpanded ? items.slice(0, limit) : items + + return ( +
    +
      + {displayItems.map((item, index) => { + const entry = renderItem(item, index) + return ( + 0 && items.length > limit && !isExpanded)} + routeMap={routeMap} + LinkComponent={LinkComponent} + /> + ) + })} + + {limit > 0 && items.length > limit && !isExpanded ? ( +
      + +
      + ) : null} +
    + + {isExpanded ? ( + + ) : null} +
    + ) +} + +export { Timeline, TimelineStatusIcon } +export type { LinkComponentType } diff --git a/packages/shared-ui/src/components/timeline/timeline.stories.tsx b/packages/shared-ui/src/components/timeline/timeline.stories.tsx new file mode 100644 index 0000000..cbe3541 --- /dev/null +++ b/packages/shared-ui/src/components/timeline/timeline.stories.tsx @@ -0,0 +1,139 @@ +import type { Meta, StoryObj } from "@storybook/react" + +import { Timeline, type TimelineStatus } from "./index" + +type SampleItem = { + id: string + title: string + date: string + source?: string + description?: string + status?: TimelineStatus + data?: Record +} + +const figmaItems: SampleItem[] = [ + { + id: "1", + status: "success", + title: "Activity label", + source: "viaStripe", + date: "Jul 14 - 09:12", + }, + { + id: "2", + status: "pending", + title: "Activity label", + source: "viaStripe", + date: "Jul 14 - 09:12", + }, + { + id: "3", + status: "error", + title: "Activity label", + source: "viaStripe", + date: "Jul 14 - 09:12", + }, + { + id: "4", + status: "complete", + title: "Activity label", + source: "viaStripe", + date: "Jul 14", + description: "Description text goes here", + }, +] + +const dataItems: SampleItem[] = [ + { + id: "1", + status: "complete", + title: "Created", + date: "Jan 15, 2025 at 2:30 PM", + data: { customerId: "cust_abc123", productId: "prod_xyz" }, + }, + { + id: "2", + status: "pending", + title: "Updated", + date: "Jan 14, 2025 at 10:00 AM", + data: { + $changes: { + email: { from: "old@example.com", to: "new@example.com" }, + name: { from: "Old Name", to: "New Name" }, + }, + }, + }, + { + id: "3", + status: "success", + title: "Subscription renewed", + date: "Jan 10, 2025 at 9:15 AM", + data: { planId: "plan_pro", status: "active" }, + }, +] + +const meta: Meta = { + title: "Components/Timeline", + component: Timeline, + args: { + emptyMessage: "No history found", + isLoading: false, + limit: 0, + skeletonRowCount: 3, + }, +} + +export default meta + +type Story = StoryObj + +const renderItem = (item: SampleItem) => ({ + key: item.id, + title: item.title, + date: item.date, + source: item.source, + description: item.description, + status: item.status, + data: item.data, +}) + +export const Default: Story = { + render: (args) => ( +
    + +
    + ), +} + +export const WithData: Story = { + render: (args) => ( +
    + +
    + ), +} + +export const WithLimit: Story = { + render: (args) => ( +
    + +
    + ), +} + +export const Loading: Story = { + render: (args) => ( +
    + +
    + ), +} + +export const Empty: Story = { + render: (args) => ( +
    + +
    + ), +} diff --git a/packages/shared-ui/src/components/wizard-stepper/index.tsx b/packages/shared-ui/src/components/wizard-stepper/index.tsx new file mode 100644 index 0000000..564e1b6 --- /dev/null +++ b/packages/shared-ui/src/components/wizard-stepper/index.tsx @@ -0,0 +1,99 @@ +import { Fragment, type ComponentProps, type ReactNode } from "react" + +import { Check } from "../../icons" +import { cn } from "../../cn" + +export type WizardStepStatus = "complete" | "current" | "upcoming" + +export interface WizardStepProps extends ComponentProps<"div"> { + status?: WizardStepStatus + step: number + label?: ReactNode +} + +function WizardStep({ + status = "upcoming", + step, + label, + className, + ...props +}: WizardStepProps) { + return ( +
    + + {status === "complete" ? ( + + ) : status === "current" ? ( + step + ) : null} + + {label ? ( + + {label} + + ) : null} +
    + ) +} + +export interface WizardStepperProps extends ComponentProps<"div"> { + steps: ReactNode[] + current?: number +} + +function WizardStepper({ steps, current = 0, className, ...props }: WizardStepperProps) { + return ( +
    + {steps.map((label, index) => { + const status: WizardStepStatus = + index < current ? "complete" : index === current ? "current" : "upcoming" + + return ( + + + {index < steps.length - 1 ? ( + + ) + })} +
    + ) +} + +export { WizardStep, WizardStepper } diff --git a/packages/shared-ui/src/components/wizard-stepper/wizard-stepper.stories.tsx b/packages/shared-ui/src/components/wizard-stepper/wizard-stepper.stories.tsx new file mode 100644 index 0000000..491aed9 --- /dev/null +++ b/packages/shared-ui/src/components/wizard-stepper/wizard-stepper.stories.tsx @@ -0,0 +1,35 @@ +import type { Meta, StoryObj } from "@storybook/react" + +import { WizardStep, WizardStepper } from "./index" + +const steps = ["Welcome", "Organization", "Provider", "Routing", "Checkout", "Finish"] + +const meta: Meta = { + title: "Components/WizardStepper", + component: WizardStepper, + args: { + steps, + current: 2, + className: "w-full", + }, +} + +export default meta + +type Story = StoryObj + +export const Default: Story = {} + +export const First: Story = { args: { current: 0 } } + +export const Complete: Story = { args: { current: steps.length } } + +export const Steps: Story = { + render: () => ( +
    + + + +
    + ), +} diff --git a/packages/shared-ui/src/global.css b/packages/shared-ui/src/global.css index b0b2415..6ca288e 100644 --- a/packages/shared-ui/src/global.css +++ b/packages/shared-ui/src/global.css @@ -8,7 +8,7 @@ --signal-blue-60: #7dafd9; --signal-blue-40: #a8cae5; --signal-blue-20: #d4e4f2; - + --core-indigo-100: #100022; --core-indigo-80: #40334e; --core-indigo-60: #70667a; @@ -27,6 +27,27 @@ --summit-sky-40: #f3f9fd; --summit-sky-20: #f9fcfe; + --forest-green-100: #15803d; + --forest-green-80: #449964; + --forest-green-60: #73b38b; + --forest-green-40: #a1ccb1; + --forest-green-20: #d0e6d8; + + --amber-orange-100: #b45309; + --amber-orange-80: #c3753a; + --amber-orange-60: #d2986b; + --amber-orange-40: #e1ba9d; + --amber-orange-20: #f0ddce; + + --crimson-red-100: #bf3126; + --crimson-red-80: #cc5a51; + --crimson-red-60: #d9837d; + --crimson-red-40: #e5ada8; + --crimson-red-20: #f2d6d4; + + --gradient-start: var(--signal-blue-80); + --gradient-end: var(--signal-blue-100); + --background: var(--summit-sky-40); --foreground: var(--midnight-black-100); --card: var(--summit-sky-20); @@ -41,11 +62,34 @@ --muted-foreground: var(--midnight-black-60); --accent: var(--signal-blue-20); --accent-foreground: var(--signal-blue-100); - --destructive: var(--core-indigo-100); - --destructive-foreground: var(--summit-sky-20); - --border: var(--summit-sky-80); - --input: var(--summit-sky-80); + --success: var(--forest-green-100); + --success-foreground: var(--summit-sky-20); + --warning: var(--amber-orange-100); + --warning-foreground: var(--summit-sky-20); + --error: var(--crimson-red-100); + --error-foreground: var(--summit-sky-20); + --ring-error: var(--crimson-red-20); + --destructive: var(--error); + --destructive-foreground: var(--error-foreground); + --border: var(--core-indigo-20); + --input: var(--core-indigo-20); --ring: var(--signal-blue-100); + --disabled: var(--core-indigo-20); + --disabled-foreground: var(--midnight-black-40); + --code: var(--midnight-black-100); + --code-foreground: var(--summit-sky-20); + --toast-info: color-mix(in srgb, var(--signal-blue-20) 55%, var(--summit-sky-20)); + --toast-info-foreground: var(--signal-blue-100); + --toast-success: color-mix(in srgb, var(--forest-green-20) 55%, var(--summit-sky-20)); + --toast-success-foreground: var(--forest-green-100); + --toast-warning: color-mix(in srgb, var(--amber-orange-20) 55%, var(--summit-sky-20)); + --toast-warning-foreground: var(--amber-orange-100); + --toast-error: color-mix(in srgb, var(--crimson-red-20) 55%, var(--summit-sky-20)); + --toast-error-foreground: var(--crimson-red-100); + --shadow-button-hover: 0 8px 16px -2px color-mix(in srgb, var(--primary) 40%, transparent); + --shadow-thumb: 0 4px 10px -2px color-mix(in srgb, var(--primary) 60%, transparent); + --shadow-ring-error: 0 0 0 4px var(--ring-error); + --shadow-elevation-md: 0px 10px 20px -30px color-mix(in srgb, var(--signal-blue-100) 20%, transparent); --chart-1: var(--signal-blue-100); --chart-2: var(--core-indigo-100); --chart-3: var(--summit-sky-100); @@ -106,6 +150,27 @@ --summit-sky-40: #f3f9fd; --summit-sky-20: #f9fcfe; + --forest-green-100: #15803d; + --forest-green-80: #449964; + --forest-green-60: #73b38b; + --forest-green-40: #a1ccb1; + --forest-green-20: #d0e6d8; + + --amber-orange-100: #b45309; + --amber-orange-80: #c3753a; + --amber-orange-60: #d2986b; + --amber-orange-40: #e1ba9d; + --amber-orange-20: #f0ddce; + + --crimson-red-100: #bf3126; + --crimson-red-80: #cc5a51; + --crimson-red-60: #d9837d; + --crimson-red-40: #e5ada8; + --crimson-red-20: #f2d6d4; + + --gradient-start: var(--signal-blue-80); + --gradient-end: #19609a; + --background: var(--core-indigo-100); --foreground: var(--summit-sky-20); --card: var(--core-indigo-80); @@ -120,11 +185,34 @@ --muted-foreground: var(--core-indigo-20); --accent: var(--signal-blue-20); --accent-foreground: var(--signal-blue-100); - --destructive: var(--midnight-black-80); - --destructive-foreground: var(--summit-sky-20); + --success: var(--forest-green-100); + --success-foreground: var(--summit-sky-20); + --warning: var(--amber-orange-100); + --warning-foreground: var(--summit-sky-20); + --error: var(--crimson-red-100); + --error-foreground: var(--summit-sky-20); + --ring-error: var(--crimson-red-20); + --destructive: var(--error); + --destructive-foreground: var(--error-foreground); --border: var(--core-indigo-80); --input: var(--core-indigo-80); --ring: var(--signal-blue-100); + --disabled: var(--core-indigo-60); + --disabled-foreground: var(--core-indigo-40); + --code: var(--midnight-black-100); + --code-foreground: var(--summit-sky-20); + --toast-info: color-mix(in srgb, var(--signal-blue-20) 55%, var(--summit-sky-20)); + --toast-info-foreground: var(--signal-blue-100); + --toast-success: color-mix(in srgb, var(--forest-green-20) 55%, var(--summit-sky-20)); + --toast-success-foreground: var(--forest-green-100); + --toast-warning: color-mix(in srgb, var(--amber-orange-20) 55%, var(--summit-sky-20)); + --toast-warning-foreground: var(--amber-orange-100); + --toast-error: color-mix(in srgb, var(--crimson-red-20) 55%, var(--summit-sky-20)); + --toast-error-foreground: var(--crimson-red-100); + --shadow-button-hover: 0 8px 16px -2px color-mix(in srgb, var(--primary) 45%, transparent); + --shadow-thumb: 0 4px 10px -2px color-mix(in srgb, var(--primary) 60%, transparent); + --shadow-ring-error: 0 0 0 4px var(--ring-error); + --shadow-elevation-md: 0px 10px 20px -30px color-mix(in srgb, var(--signal-blue-100) 20%, transparent); --chart-1: var(--signal-blue-100); --chart-2: var(--summit-sky-100); --chart-3: var(--signal-blue-60); @@ -163,6 +251,8 @@ --color-popover-foreground: var(--popover-foreground); --color-primary: var(--primary); --color-primary-foreground: var(--primary-foreground); + --color-gradient-start: var(--gradient-start); + --color-gradient-end: var(--gradient-end); --color-secondary: var(--secondary); --color-secondary-foreground: var(--secondary-foreground); --color-muted: var(--muted); @@ -171,9 +261,28 @@ --color-accent-foreground: var(--accent-foreground); --color-destructive: var(--destructive); --color-destructive-foreground: var(--destructive-foreground); + --color-success: var(--success); + --color-success-foreground: var(--success-foreground); + --color-warning: var(--warning); + --color-warning-foreground: var(--warning-foreground); + --color-error: var(--error); + --color-error-foreground: var(--error-foreground); + --color-ring-error: var(--ring-error); --color-border: var(--border); --color-input: var(--input); --color-ring: var(--ring); + --color-disabled: var(--disabled); + --color-disabled-foreground: var(--disabled-foreground); + --color-code: var(--code); + --color-code-foreground: var(--code-foreground); + --color-toast-info: var(--toast-info); + --color-toast-info-foreground: var(--toast-info-foreground); + --color-toast-success: var(--toast-success); + --color-toast-success-foreground: var(--toast-success-foreground); + --color-toast-warning: var(--toast-warning); + --color-toast-warning-foreground: var(--toast-warning-foreground); + --color-toast-error: var(--toast-error); + --color-toast-error-foreground: var(--toast-error-foreground); --color-chart-1: var(--chart-1); --color-chart-2: var(--chart-2); --color-chart-3: var(--chart-3); @@ -205,6 +314,10 @@ --shadow-lg: var(--shadow-lg); --shadow-xl: var(--shadow-xl); --shadow-2xl: var(--shadow-2xl); + --shadow-button-hover: var(--shadow-button-hover); + --shadow-thumb: var(--shadow-thumb); + --shadow-ring-error: var(--shadow-ring-error); + --shadow-elevation-md: var(--shadow-elevation-md); --tracking-tighter: calc(var(--tracking-normal) - 0.05em); --tracking-tight: calc(var(--tracking-normal) - 0.025em); diff --git a/packages/shared-ui/src/hooks/use-copy.ts b/packages/shared-ui/src/hooks/use-copy.ts new file mode 100644 index 0000000..02e2a3b --- /dev/null +++ b/packages/shared-ui/src/hooks/use-copy.ts @@ -0,0 +1,13 @@ +import { useState } from "react" + +export function useCopy(resetDelay = 2000) { + const [copied, setCopied] = useState(false) + + const copy = async (text: string) => { + await navigator.clipboard.writeText(text) + setCopied(true) + window.setTimeout(() => setCopied(false), resetDelay) + } + + return { copied, copy } +} diff --git a/packages/shared-ui/src/icons.tsx b/packages/shared-ui/src/icons.tsx index 376b15e..12e72a2 100644 --- a/packages/shared-ui/src/icons.tsx +++ b/packages/shared-ui/src/icons.tsx @@ -1,3 +1,5 @@ +import type { SVGProps } from "react"; + export function PayroutesLogo({ className }: { className?: string }) { return ( ); } + + +export const ArchiveBox = (props: React.SVGProps) => ( + + + + + +); + +export const ArrowDown = (props: React.SVGProps) => ( + + + + +); + +export const ArrowLeft = (props: React.SVGProps) => ( + + + + +); + +export const ArrowRight = (props: React.SVGProps) => ( + + + + +); + +export const ArrowUpRight = (props: React.SVGProps) => ( + + + + +); + +export const ArrowUp = (props: React.SVGProps) => ( + + + + +); + +export const BoxArrowUp = (props: React.SVGProps) => ( + + + + + +); + +export const Calendar = (props: React.SVGProps) => ( + + + + + + +); + +export const Check = (props: React.SVGProps) => ( + + + +); + +export const ChevronDown = (props: React.SVGProps) => ( + + + +); + +export const ChevronLeft = (props: React.SVGProps) => ( + + + +); + +export const ChevronRight = (props: React.SVGProps) => ( + + + +); + +export const ChevronUp = (props: React.SVGProps) => ( + + + +); + +export const ChevronsLeftRight = (props: React.SVGProps) => ( + + + + +); + +export const ChevronsUpDown = (props: React.SVGProps) => ( + + + + +); + +export const CircleCheck = (props: React.SVGProps) => ( + + + + +); + +export const CircleSlash = (props: React.SVGProps) => ( + + + + +); + +export const CircleX = (props: React.SVGProps) => ( + + + + + +); + +export const ClockPlay = (props: React.SVGProps) => ( + + + + +); + +export const Copy = (props: React.SVGProps) => ( + + + + +); + +export const CornerDownLeft = (props: React.SVGProps) => ( + + + + +); + +export const CreditCard = (props: React.SVGProps) => ( + + + + +); + +export const DownloadCloud = (props: React.SVGProps) => ( + + + + + +); + +export const MoreVertical = (props: React.SVGProps) => ( + + + + + +); + +export const MoreHorizontal = (props: React.SVGProps) => ( + + + + + +); + +export const ExternalLink = (props: React.SVGProps) => ( + + + + + +); + +export const Eye = (props: React.SVGProps) => ( + + + + +); + +export const FileText = (props: React.SVGProps) => ( + + + + + + + +); + +export const GripHorizontal = (props: React.SVGProps) => ( + + + + + + + + +); + +export const GripVertical = (props: React.SVGProps) => ( + + + + + + + + +); + +export const HourglassVertical = (props: React.SVGProps) => ( + + + + + + +); + +export const House = (props: React.SVGProps) => ( + + + + +); + +export const Image = (props: React.SVGProps) => ( + + + + + +); + +export const ImageOff = (props: React.SVGProps) => ( + + + + + +); + +export const Inbox = (props: React.SVGProps) => ( + + + + +); + +export const Infinity = (props: React.SVGProps) => ( + + + +); + +export const InfoCircle = (props: React.SVGProps) => ( + + + + + +); + +export const LayoutDashboard = (props: React.SVGProps) => ( + + + + + + +); + +export const LayoutGrid = (props: React.SVGProps) => ( + + + + + + +); + +export const Link2 = (props: React.SVGProps) => ( + + + + + +); + +export const Link = (props: React.SVGProps) => ( + + + + +); + +export const Mail = (props: React.SVGProps) => ( + + + + +); + +export const Minus = (props: React.SVGProps) => ( + + + +); + +export const PencilErase = (props: React.SVGProps) => ( + + + + +); + +export const Pin = (props: React.SVGProps) => ( + + + + +); + +export const Plug = (props: React.SVGProps) => ( + + + + + + +); + +export const Plus = (props: React.SVGProps) => ( + + + + +); + +export const RefreshCw = (props: React.SVGProps) => ( + + + + + + +); + +export const Repeat = (props: React.SVGProps) => ( + + + + + + +); + +export const RotateCw = (props: React.SVGProps) => ( + + + + +); + +export const Route = (props: React.SVGProps) => ( + + + + + +); + +export const Scale = (props: React.SVGProps) => ( + + + + + + + +); + +export const Search = (props: React.SVGProps) => ( + + + + +); + +export const Settings = (props: React.SVGProps) => ( + + + + +); + +export const ShieldCheck = (props: React.SVGProps) => ( + + + + +); + +export const ShoppingCart = (props: React.SVGProps) => ( + + + + + +); + +export const Star = (props: React.SVGProps) => ( + + + +); + +export const TagCheck = (props: React.SVGProps) => ( + + + + + +); + +export const Ticket = (props: React.SVGProps) => ( + + + + + + +); + +export const Trash2 = (props: React.SVGProps) => ( + + + + + + + +); + +export const TriangleAlert = (props: React.SVGProps) => ( + + + + + +); + +export const User = (props: React.SVGProps) => ( + + + + +); + +export const Users = (props: React.SVGProps) => ( + + + + + + +); + +export const CloseX = (props: React.SVGProps) => ( + + + + +); + +export const Zap = (props: React.SVGProps) => ( + + + +); \ No newline at end of file diff --git a/packages/shared-ui/src/index.ts b/packages/shared-ui/src/index.ts index 6c476ca..0f27a8d 100644 --- a/packages/shared-ui/src/index.ts +++ b/packages/shared-ui/src/index.ts @@ -1,3 +1,44 @@ -export { Button, buttonVariants } from "./button"; +// Icons export { PayroutesLogo } from "./icons"; + export { cn } from "./cn"; +export { splitProps } from "./lib/mixin"; +export type { MixinProps } from "./lib/mixin"; + +// Custom blocks +export * from "./components/app-modal"; +export * from "./components/code-block"; +export * from "./components/conditional-field-row"; +export * from "./components/field-stack"; +export * from "./components/file-upload"; +export * from "./components/phone-number-field"; +export * from "./components/resource-field"; +export * from "./components/rule-builder-condition-row"; +export * from "./components/rule-builder-destination-row"; +export * from "./components/search-field"; +export * from "./components/spinner"; +export * from "./components/tag-input"; +export * from "./components/timeline"; +export * from "./components/wizard-stepper"; + +// UI primitives +export * from "./ui/accordion"; +export * from "./ui/avatar"; +export * from "./ui/badge"; +export * from "./ui/button"; +export * from "./ui/checkbox"; +export * from "./ui/dialog"; +export * from "./ui/input"; +export * from "./ui/input-group"; +export * from "./ui/label"; +export * from "./ui/popover"; +export * from "./ui/radio-group"; +export * from "./ui/scroll-area"; +export * from "./ui/select"; +export * from "./ui/skeleton"; +export * from "./ui/slider"; +export * from "./ui/switch"; +export * from "./ui/tabs"; +export * from "./ui/textarea"; +export * from "./ui/toast"; +export * from "./ui/tooltip"; diff --git a/packages/shared-ui/src/integrations/image-transformer.ts b/packages/shared-ui/src/integrations/image-transformer.ts new file mode 100644 index 0000000..a2d969f --- /dev/null +++ b/packages/shared-ui/src/integrations/image-transformer.ts @@ -0,0 +1,110 @@ +import { Canvg } from "canvg" +import { heicTo } from "heic-to" + +export type MimeType = `image/${string}` + +export type TransformOptions = { + to: MimeType + maxDimension?: number +} + +export class ImageTransformer { + async transform(file: File, { to, maxDimension }: TransformOptions): Promise { + if (file.type.toLowerCase() === to.toLowerCase() && !maxDimension) { + return file + } + + try { + const source = await this.resolveSource(file) + const { width: srcWidth, height: srcHeight } = this.sourceSize(source) + + let targetWidth = srcWidth + let targetHeight = srcHeight + + if (maxDimension) { + if (srcWidth > srcHeight) { + if (srcWidth > maxDimension) { + targetWidth = maxDimension + targetHeight = (srcHeight / srcWidth) * maxDimension + } + } else if (srcHeight > maxDimension) { + targetHeight = maxDimension + targetWidth = (srcWidth / srcHeight) * maxDimension + } + } + + return await this.draw(source, file.name, to, targetWidth, targetHeight) + } catch (err) { + throw new Error(`Transformation failed: ${err instanceof Error ? err.message : String(err)}`) + } + } + + private sourceSize(source: CanvasImageSource) { + if (source instanceof HTMLImageElement) { + return { width: source.naturalWidth, height: source.naturalHeight } + } + if ("width" in source && "height" in source) { + return { width: Number(source.width), height: Number(source.height) } + } + return { width: 0, height: 0 } + } + + private async resolveSource(file: File): Promise { + const type = file.type.toLowerCase() + const ext = file.name.split(".").pop()?.toLowerCase() + + if (type.includes("heic") || type.includes("heif") || ext === "heic" || ext === "heif") { + const result = await heicTo({ blob: file, type: "image/png" }) + return this.createImageSource(URL.createObjectURL(result), true) + } + + if (type.includes("svg") || ext === "svg") { + const canvas = document.createElement("canvas") + const ctx = canvas.getContext("2d") + if (!ctx) throw new Error("Canvas context unavailable") + const v = await Canvg.from(ctx, await file.text()) + await v.render() + return canvas + } + + return this.createImageSource(URL.createObjectURL(file), true) + } + + private async draw( + source: CanvasImageSource, + originalName: string, + to: MimeType, + w: number, + h: number + ): Promise { + const canvas = document.createElement("canvas") + canvas.width = w + canvas.height = h + + const ctx = canvas.getContext("2d") + if (!ctx) throw new Error("Canvas context unavailable") + ctx.imageSmoothingEnabled = true + ctx.imageSmoothingQuality = "high" + ctx.drawImage(source, 0, 0, w, h) + + const blob = await new Promise((res) => canvas.toBlob(res, to, 0.95)) + if (!blob) throw new Error("Canvas export failed") + + const ext = to.split("/")[1].replace("jpeg", "jpg") + const newName = originalName.replace(/\.[^/.]+$/, "") + `.${ext}` + + return new File([blob], newName, { type: to }) + } + + private async createImageSource(url: string, revoke: boolean): Promise { + return new Promise((res, rej) => { + const img = new window.Image() + img.onload = () => { + if (revoke) URL.revokeObjectURL(url) + res(img) + } + img.onerror = () => rej(new Error("Failed to load image source")) + img.src = url + }) + } +} diff --git a/packages/shared-ui/src/lib/mixin.ts b/packages/shared-ui/src/lib/mixin.ts new file mode 100644 index 0000000..160b6a4 --- /dev/null +++ b/packages/shared-ui/src/lib/mixin.ts @@ -0,0 +1,53 @@ +export type MixinProps = { + [Key in keyof Props as `${Mixin}${Capitalize}`]: Props[Key]; +}; + +type SplitProps = { + [Mixin in Mixins[number]]: { + [MixinKey in keyof Props as MixinKey extends `${Mixin}${infer Key}` ? Uncapitalize : never]: Props[MixinKey]; + }; +} & { + rest: Omit< + Props, + { + [Mixin in Mixins[number]]: keyof { + [MixinKey in keyof Props as MixinKey extends `${Mixin}${string}` ? MixinKey : never]: never; + }; + }[Mixins[number]] + >; +}; + +export const splitProps = ( + props: Props, + ...mixins: Mixins +): SplitProps => { + const result: Record = {}; + const rest: Record = {}; + + // Initialize result objects for each mixin + for (const mixinKey of mixins) { + result[mixinKey] = {}; + } + + // Process each prop + for (const key in props) { + let split = false; + + // Check if this prop belongs to any mixin + for (const mixinKey of mixins) { + if (!key.startsWith(mixinKey)) continue; + + split = true; + const remainingKey = key.substring(mixinKey.length); + const splitKey = remainingKey.charAt(0).toLowerCase() + remainingKey.slice(1); + + const splitProps = result[mixinKey] as Record; + splitProps[splitKey] = props[key as keyof typeof props]; + } + + if (!split) rest[key] = props[key as keyof typeof props]; + } + + result["rest"] = rest; + return result as SplitProps; +}; diff --git a/packages/shared-ui/src/ui/accordion.stories.tsx b/packages/shared-ui/src/ui/accordion.stories.tsx new file mode 100644 index 0000000..5bdc104 --- /dev/null +++ b/packages/shared-ui/src/ui/accordion.stories.tsx @@ -0,0 +1,119 @@ +import type { ComponentProps } from "react" +import type { Meta, StoryObj } from "@storybook/react" + +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, +} from "./accordion" + +const items = [ + { + value: "product-information", + title: "Product Information", + content: ( + <> +

    + This product features cutting-edge technology designed with precision. + Made from premium materials, it offers exceptional durability and + performance. +

    +

    + The intuitive user interface ensures a seamless experience from setup + to everyday use. +

    + + ), + }, + { + value: "shipping-details", + title: "Shipping Details", + content: ( + <> +

    + We partner with trusted courier partners to ensure your order arrives + safely and on time. +

    +

    + Standard delivery typically takes 3-5 business days depending on your + location. +

    + + ), + }, + { + value: "return-policy", + title: "Return Policy", + content: ( + <> +

    + We offer a 30-day return policy for unused items in their original + packaging. +

    +

    Return shipping is free for all eligible orders.

    + + ), + }, +] as const + +type AccordionRootProps = ComponentProps + +function ProductAccordion(props: AccordionRootProps) { + return ( + + {items.map((item) => ( + + {item.title} + {item.content} + + ))} + + ) +} + +const meta: Meta = { + title: "UI/Accordion", + component: Accordion, +} + +export default meta + +type Story = StoryObj + +export const Default: Story = { + render: () => , +} + +export const ProductInformation: Story = { + render: () => , +} + +export const ShippingDetails: Story = { + render: () => , +} + +export const ReturnPolicy: Story = { + render: () => , +} + +export const Multiple: Story = { + render: () => ( + + ), +} + +export const Disabled: Story = { + render: () => , +} + +export const States: Story = { + render: () => ( +
    + + + + +
    + ), +} diff --git a/packages/shared-ui/src/ui/accordion.tsx b/packages/shared-ui/src/ui/accordion.tsx new file mode 100644 index 0000000..860f2b6 --- /dev/null +++ b/packages/shared-ui/src/ui/accordion.tsx @@ -0,0 +1,81 @@ +import { Accordion as AccordionPrimitive } from "@base-ui/react/accordion" + +import { ChevronDown, ChevronUp } from "../icons" +import { cn } from "../cn" + +function Accordion({ className, ...props }: AccordionPrimitive.Root.Props) { + return ( + + ) +} + +function AccordionItem({ className, ...props }: AccordionPrimitive.Item.Props) { + return ( + + ) +} + +function AccordionTrigger({ + className, + children, + ...props +}: AccordionPrimitive.Trigger.Props) { + return ( + + + {children} + + + + + ) +} + +function AccordionContent({ + className, + children, + ...props +}: AccordionPrimitive.Panel.Props) { + return ( + +
    + {children} +
    +
    + ) +} + +export { Accordion, AccordionItem, AccordionTrigger, AccordionContent } diff --git a/packages/shared-ui/src/ui/avatar.stories.tsx b/packages/shared-ui/src/ui/avatar.stories.tsx new file mode 100644 index 0000000..d70f113 --- /dev/null +++ b/packages/shared-ui/src/ui/avatar.stories.tsx @@ -0,0 +1,74 @@ +import type { Meta, StoryObj } from "@storybook/react" + +import { Avatar, AvatarFallback, AvatarImage } from "./avatar" + +const sizes = ["sm", "default", "lg"] as const + +const meta: Meta = { + title: "UI/Avatar", + component: Avatar, + argTypes: { + size: { + control: "select", + options: sizes, + }, + }, +} + +export default meta + +type Story = StoryObj + +export const Default: Story = { + render: (args) => ( + + AB + + ), +} + +export const Fallback: Story = { + render: (args) => ( + + + + ), +} + +export const Image: Story = { + render: (args) => ( + + + AB + + ), +} + +export const Sizes: Story = { + render: () => ( +
    + {sizes.map((size) => ( + + AB + + ))} +
    + ), +} + +export const States: Story = { + render: () => ( +
    + {sizes.map((size) => ( +
    + + AB + + + + +
    + ))} +
    + ), +} diff --git a/packages/shared-ui/src/ui/avatar.tsx b/packages/shared-ui/src/ui/avatar.tsx new file mode 100644 index 0000000..216ea51 --- /dev/null +++ b/packages/shared-ui/src/ui/avatar.tsx @@ -0,0 +1,63 @@ +import { Avatar as AvatarPrimitive } from "@base-ui/react/avatar" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "../cn" + +const avatarVariants = cva( + "relative flex shrink-0 overflow-hidden rounded-full select-none", + { + variants: { + size: { + sm: "size-8 text-xs", + default: "size-10 text-sm", + lg: "size-16 text-lg", + }, + }, + defaultVariants: { + size: "default", + }, + } +) + +function Avatar({ + className, + size = "default", + ...props +}: AvatarPrimitive.Root.Props & VariantProps) { + return ( + + ) +} + +function AvatarImage({ className, ...props }: AvatarPrimitive.Image.Props) { + return ( + + ) +} + +function AvatarFallback({ + className, + ...props +}: AvatarPrimitive.Fallback.Props) { + return ( + + ) +} + +export { Avatar, AvatarImage, AvatarFallback, avatarVariants } diff --git a/packages/shared-ui/src/ui/badge.stories.tsx b/packages/shared-ui/src/ui/badge.stories.tsx new file mode 100644 index 0000000..70e1e88 --- /dev/null +++ b/packages/shared-ui/src/ui/badge.stories.tsx @@ -0,0 +1,77 @@ +import type { Meta, StoryObj } from "@storybook/react" + +import { CircleCheck } from "../icons" +import { Badge } from "./badge" + +const variants = ["default", "secondary", "outline", "success", "warning", "destructive", "ghost", "link"] as const + +const meta: Meta = { + title: "UI/Badge", + component: Badge, + args: { + children: "Text", + }, + argTypes: { + variant: { + control: "select", + options: variants, + }, + size: { + control: "select", + options: ["default", "icon"], + }, + }, +} + +export default meta + +type Story = StoryObj + +export const Default: Story = {} + +export const Secondary: Story = { args: { variant: "secondary" } } + +export const Outline: Story = { args: { variant: "outline" } } + +export const Success: Story = { args: { variant: "success" } } + +export const Warning: Story = { args: { variant: "warning" } } + +export const Destructive: Story = { args: { variant: "destructive" } } + +export const WithIcon: Story = { + render: () => ( + + + Text + + ), +} + +export const States: Story = { + render: () => ( +
    +
    + Text + Text + Text + + + Text + +
    +
    + Text + Text + Text +
    +
    + {(["default", "outline", "destructive"] as const).map((variant) => ( + + + + ))} +
    +
    + ), +} diff --git a/packages/shared-ui/src/ui/badge.tsx b/packages/shared-ui/src/ui/badge.tsx new file mode 100644 index 0000000..fef910a --- /dev/null +++ b/packages/shared-ui/src/ui/badge.tsx @@ -0,0 +1,59 @@ +import { mergeProps } from "@base-ui/react/merge-props" +import { useRender } from "@base-ui/react/use-render" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "../cn" + +const badgeVariants = cva( + "group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!", + { + variants: { + variant: { + default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80", + secondary: "bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80", + destructive: + "bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20", + success: + "bg-success/10 text-success focus-visible:ring-success/20 dark:bg-success/20 dark:focus-visible:ring-success/40 [a]:hover:bg-success/20", + warning: + "bg-warning/10 text-warning focus-visible:ring-warning/20 dark:bg-warning/20 dark:focus-visible:ring-warning/40 [a]:hover:bg-warning/20", + outline: "border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground", + ghost: "hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50", + link: "text-primary underline-offset-4 hover:underline", + }, + size: { + default: "px-2", + icon: "size-5 justify-center rounded-md px-0", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + } +) + +function Badge({ + className, + variant = "default", + size = "default", + render, + ...props +}: useRender.ComponentProps<"span"> & VariantProps) { + return useRender({ + defaultTagName: "span", + props: mergeProps<"span">( + { + className: cn(badgeVariants({ variant, size }), className), + }, + props + ), + render, + state: { + slot: "badge", + variant, + }, + }) +} + +export { Badge, badgeVariants } diff --git a/packages/shared-ui/src/button.stories.tsx b/packages/shared-ui/src/ui/button.stories.tsx similarity index 53% rename from packages/shared-ui/src/button.stories.tsx rename to packages/shared-ui/src/ui/button.stories.tsx index c3f6e15..8234f07 100644 --- a/packages/shared-ui/src/button.stories.tsx +++ b/packages/shared-ui/src/ui/button.stories.tsx @@ -2,8 +2,11 @@ import type { Meta, StoryObj } from "@storybook/react" import { Button } from "./button" +const variants = ["default", "outline", "ghost"] as const +const sizes = ["default", "sm"] as const + const meta: Meta = { - title: "Components/Button", + title: "UI/Button", component: Button, args: { children: "Button", @@ -11,11 +14,14 @@ const meta: Meta = { argTypes: { variant: { control: "select", - options: ["default", "outline", "secondary", "ghost", "destructive", "link"], + options: ["default", "outline", "ghost", "secondary", "destructive", "link"], }, size: { control: "select", - options: ["default", "xs", "sm", "lg", "icon", "icon-xs", "icon-sm", "icon-lg"], + options: ["default", "sm", "lg", "xs", "icon", "icon-xs", "icon-sm", "icon-lg"], + }, + disabled: { + control: "boolean", }, }, } @@ -28,10 +34,14 @@ export const Default: Story = {} export const Outline: Story = { args: { variant: "outline" } } -export const Secondary: Story = { args: { variant: "secondary" } } - export const Ghost: Story = { args: { variant: "ghost" } } +export const Hover: Story = { args: { "data-state": "hover" } } + +export const Disabled: Story = { args: { disabled: true } } + +export const Secondary: Story = { args: { variant: "secondary" } } + export const Destructive: Story = { args: { variant: "destructive" } } export const Link: Story = { args: { variant: "link" } } @@ -55,3 +65,27 @@ export const AllSizes: Story = { ), } + +export const States: Story = { + render: () => ( +
    + {sizes.map((size) => ( +
    + {variants.map((variant) => ( +
    + + + +
    + ))} +
    + ))} +
    + ), +} diff --git a/packages/shared-ui/src/ui/button.tsx b/packages/shared-ui/src/ui/button.tsx new file mode 100644 index 0000000..2227bca --- /dev/null +++ b/packages/shared-ui/src/ui/button.tsx @@ -0,0 +1,56 @@ +import { Button as ButtonPrimitive } from "@base-ui/react/button"; +import { type VariantProps, cva } from "class-variance-authority"; + +import { cn } from "../cn"; + +const buttonVariants = cva( + "inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent font-medium whitespace-nowrap transition-shadow outline-none select-none focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:shadow-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", + { + variants: { + variant: { + default: + "bg-linear-to-b from-gradient-start to-gradient-end text-primary-foreground hover:shadow-button-hover data-[state=hover]:shadow-button-hover disabled:from-disabled disabled:to-disabled disabled:text-disabled-foreground", + outline: + "border-primary bg-background text-primary hover:shadow-button-hover data-[state=hover]:shadow-button-hover disabled:border-disabled-foreground disabled:text-disabled-foreground", + ghost: + "text-primary hover:bg-accent hover:shadow-button-hover data-[state=hover]:bg-accent data-[state=hover]:shadow-button-hover disabled:text-disabled-foreground", + secondary: + "bg-secondary text-secondary-foreground hover:shadow-button-hover data-[state=hover]:shadow-button-hover disabled:bg-disabled disabled:text-disabled-foreground", + destructive: + "bg-destructive text-destructive-foreground hover:shadow-button-hover data-[state=hover]:shadow-button-hover disabled:bg-disabled disabled:text-disabled-foreground", + link: "text-primary underline-offset-4 hover:underline disabled:text-disabled-foreground", + }, + size: { + default: "h-9 gap-2 px-2.5 py-1 text-sm", + sm: "h-8 gap-1.5 px-3 text-sm", + lg: "h-11 gap-2 px-5 text-sm", + xs: "h-6 gap-1 px-2 text-xs [&_svg:not([class*='size-'])]:size-3", + icon: "size-10", + "icon-xs": "size-6 [&_svg:not([class*='size-'])]:size-3", + "icon-sm": "size-8", + "icon-lg": "size-11", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + } +); + +type ButtonProps = ButtonPrimitive.Props & + VariantProps & { + "data-state"?: "hover"; + }; + +function Button({ + className, + variant = "default", + size = "default", + ...props +}: ButtonProps) { + return ; +} + +export { Button, buttonVariants }; +export type { ButtonProps }; diff --git a/packages/shared-ui/src/ui/checkbox.stories.tsx b/packages/shared-ui/src/ui/checkbox.stories.tsx new file mode 100644 index 0000000..531b437 --- /dev/null +++ b/packages/shared-ui/src/ui/checkbox.stories.tsx @@ -0,0 +1,44 @@ +import type { Meta, StoryObj } from "@storybook/react" + +import { Checkbox } from "./checkbox" + +const meta: Meta = { + title: "UI/Checkbox", + component: Checkbox, + argTypes: { + defaultChecked: { + control: "boolean", + }, + disabled: { + control: "boolean", + }, + }, +} + +export default meta + +type Story = StoryObj + +export const Default: Story = {} + +export const Checked: Story = { args: { defaultChecked: true } } + +export const Disabled: Story = { args: { disabled: true } } + +export const States: Story = { + render: () => ( +
    + {[ + { defaultChecked: true }, + { defaultChecked: false }, + { defaultChecked: true, disabled: true }, + { defaultChecked: false, disabled: true }, + ].map(({ defaultChecked, disabled }, index) => ( + + ))} +
    + ), +} diff --git a/packages/shared-ui/src/ui/checkbox.tsx b/packages/shared-ui/src/ui/checkbox.tsx new file mode 100644 index 0000000..0c2e6c5 --- /dev/null +++ b/packages/shared-ui/src/ui/checkbox.tsx @@ -0,0 +1,26 @@ +import { Checkbox as CheckboxPrimitive } from "@base-ui/react/checkbox" + +import { Check } from "../icons" +import { cn } from "../cn" + +function Checkbox({ className, ...props }: CheckboxPrimitive.Root.Props) { + return ( + + + + + + ) +} + +export { Checkbox } diff --git a/packages/shared-ui/src/ui/dialog.stories.tsx b/packages/shared-ui/src/ui/dialog.stories.tsx new file mode 100644 index 0000000..ac87b24 --- /dev/null +++ b/packages/shared-ui/src/ui/dialog.stories.tsx @@ -0,0 +1,47 @@ +import { useState } from "react" +import type { Meta, StoryObj } from "@storybook/react" + +import { Button } from "./button" +import { Dialog, DialogContent, DialogDescription, DialogTitle, DialogTrigger } from "./dialog" + +const meta: Meta = { + title: "UI/Dialog", + component: Dialog, +} + +export default meta + +type Story = StoryObj + +export const Default: Story = { + render: () => ( + + }> + Open dialog + + + Dialog title + A short description of this dialog. + + + ), +} + +export const Open: Story = { + parameters: { + docs: { disable: true }, + }, + render: function OpenStory() { + const [open, setOpen] = useState(true) + return ( +
    + + + Dialog title + A short description of this dialog. + + +
    + ) + }, +} diff --git a/packages/shared-ui/src/ui/dialog.tsx b/packages/shared-ui/src/ui/dialog.tsx new file mode 100644 index 0000000..98c15c0 --- /dev/null +++ b/packages/shared-ui/src/ui/dialog.tsx @@ -0,0 +1,147 @@ +import type { ComponentProps } from "react" +import { Dialog as DialogPrimitive } from "@base-ui/react/dialog" + +import { CloseX } from "../icons" +import { cn } from "../cn" + +function Dialog({ ...props }: DialogPrimitive.Root.Props) { + return +} + +function DialogTrigger({ ...props }: DialogPrimitive.Trigger.Props) { + return +} + +function DialogPortal({ ...props }: DialogPrimitive.Portal.Props) { + return ( + + ) +} + +function DialogOverlay({ className, ...props }: DialogPrimitive.Backdrop.Props) { + return ( + + ) +} + +function DialogContent({ + className, + children, + showCloseButton = true, + viewportClassName, + ...props +}: DialogPrimitive.Popup.Props & { + showCloseButton?: boolean + viewportClassName?: string +}) { + return ( + + + + + {children} + {showCloseButton ? ( + + + + ) : null} + + + + ) +} + +function DialogHeader({ className, ...props }: ComponentProps<"div">) { + return ( +
    + ) +} + +function DialogFooter({ className, ...props }: ComponentProps<"div">) { + return ( +
    + ) +} + +function DialogTitle({ className, ...props }: DialogPrimitive.Title.Props) { + return ( + + ) +} + +function DialogDescription({ + className, + ...props +}: DialogPrimitive.Description.Props) { + return ( + + ) +} + +function DialogClose({ className, ...props }: DialogPrimitive.Close.Props) { + return ( + + ) +} + +export { + Dialog, + DialogTrigger, + DialogPortal, + DialogOverlay, + DialogContent, + DialogHeader, + DialogFooter, + DialogTitle, + DialogDescription, + DialogClose, +} diff --git a/packages/shared-ui/src/ui/input-group.tsx b/packages/shared-ui/src/ui/input-group.tsx new file mode 100644 index 0000000..abb94fa --- /dev/null +++ b/packages/shared-ui/src/ui/input-group.tsx @@ -0,0 +1,146 @@ +import { type VariantProps, cva } from "class-variance-authority" +import type { ComponentProps } from "react" + +import { cn } from "../cn" +import { Button, type ButtonProps } from "./button" +import { Input } from "./input" +import { Textarea } from "./textarea" + +function InputGroup({ className, ...props }: ComponentProps<"div">) { + return ( +
    textarea]:h-auto", + "has-[>[data-align=inline-start]]:[&>input]:pl-2", + "has-[>[data-align=inline-end]]:[&>input]:pr-2", + "has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>[data-align=block-start]]:[&>input]:pb-3", + "has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-end]]:[&>input]:pt-3", + "has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-3 has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50", + "has-[:focus-visible]:border-ring has-[:focus-visible]:ring-3 has-[:focus-visible]:ring-ring/50", + "data-[state=focus]:border-ring data-[state=focus]:ring-3 data-[state=focus]:ring-ring/50", + "has-[[data-slot=input-group-control][aria-invalid=true]]:border-error has-[[data-slot=input-group-control][aria-invalid=true]]:shadow-ring-error has-[[data-slot=input-group-control][aria-invalid=true]]:ring-0", + "aria-invalid:border-error aria-invalid:shadow-ring-error aria-invalid:ring-0", + "has-[:disabled]:border-transparent has-[:disabled]:bg-disabled has-[:disabled]:text-disabled-foreground", + "data-disabled:border-transparent data-disabled:bg-disabled data-disabled:text-disabled-foreground", + "dark:bg-input/30 dark:has-[:disabled]:bg-disabled dark:data-disabled:bg-disabled", + className + )} + {...props} + /> + ) +} + +const inputGroupAddonVariants = cva( + "flex h-auto cursor-text items-center justify-center gap-2 py-1.5 text-sm font-medium text-muted-foreground select-none [&>svg:not([class*='size-'])]:size-4 group-data-disabled/input-group:text-disabled-foreground", + { + variants: { + align: { + "inline-start": + "order-first pl-2.5 has-[>button]:ml-[-0.45rem]", + "inline-end": + "order-last pr-2.5 has-[>button]:mr-[-0.45rem]", + "block-start": + "order-first w-full justify-start px-2.5 pt-3 group-has-[>input]/input-group:pt-2.5", + "block-end": + "order-last w-full justify-start px-2.5 pb-3 group-has-[>input]/input-group:pb-2.5", + }, + }, + defaultVariants: { + align: "inline-start", + }, + } +) + +function InputGroupAddon({ + className, + align = "inline-start", + ...props +}: ComponentProps<"div"> & VariantProps) { + return ( +
    { + if ((event.target as HTMLElement).closest("button")) return + event.currentTarget.parentElement?.querySelector("input")?.focus() + }} + {...props} + /> + ) +} + +function InputGroupButton({ + className, + type = "button", + variant = "ghost", + size = "xs", + ...props +}: Omit & { + size?: ButtonProps["size"] +}) { + return ( +