commit 63a9425680387e25a012ec3a898907dd1066b677 Author: Andrewkydev Date: Sat Dec 27 18:23:41 2025 +0300 first commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..27592c8 Binary files /dev/null and b/.gitignore differ diff --git a/package.json b/package.json new file mode 100644 index 0000000..cbb8cc4 --- /dev/null +++ b/package.json @@ -0,0 +1,38 @@ +{ + "name": "@tria/ui", + "version": "0.1.0", + "private": true, + "type": "module", + "main": "./src/index.ts", + "dependencies": { + "@radix-ui/react-accordion": "^1.2.12", + "@radix-ui/react-dialog": "^1.1.15", + "@radix-ui/react-popover": "^1.1.15", + "@radix-ui/react-select": "^2.2.6", + "@radix-ui/react-slot": "^1.1.0", + "@radix-ui/react-tabs": "^1.1.13", + "@radix-ui/react-toast": "^1.2.15", + "@radix-ui/react-tooltip": "^1.2.8", + "@types/node": "^25.0.3", + "@vitejs/plugin-react": "^4.3.0", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "vite": "^5.4.0", + "vite-plugin-dts": "^4.5.4" + }, + "devDependencies": { + "@types/react": "^18.3.27", + "react": "^18.3.1", + "typescript": "^5.9.3" + }, + "scripts": { + "typecheck": "tsc -p tsconfig.json --noEmit" + }, + "sideEffects": ["**/*.css"], + "exports": { + ".": "./src/index.ts", + "./styles": "./src/styles/index.css", + "./auto": "./src/auto.ts" + } + +} diff --git a/package_pruduction.json b/package_pruduction.json new file mode 100644 index 0000000..a68347e --- /dev/null +++ b/package_pruduction.json @@ -0,0 +1,50 @@ +{ + "name": "@tria/ui", + "version": "0.1.0", + "private": true, + "type": "module", + "main": "./src/index.ts", + "dependencies": { + "@radix-ui/react-accordion": "^1.2.12", + "@radix-ui/react-dialog": "^1.1.15", + "@radix-ui/react-popover": "^1.1.15", + "@radix-ui/react-select": "^2.2.6", + "@radix-ui/react-slot": "^1.1.0", + "@radix-ui/react-tabs": "^1.1.13", + "@radix-ui/react-toast": "^1.2.15", + "@radix-ui/react-tooltip": "^1.2.8", + "@types/node": "^25.0.3", + "@vitejs/plugin-react": "^4.3.0", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "vite": "^5.4.0", + "vite-plugin-dts": "^4.5.4" + }, + "devDependencies": { + "@types/react": "^18.3.27", + "react": "^18.3.1", + "typescript": "^5.9.3" + }, + "scripts": { + "typecheck": "tsc -p tsconfig.json --noEmit" + }, + "sideEffects": [ + "**/*.css" + ], + "exports": { + ".": { + "types": "./dist/index.d.ts", + "development": "./src/index.ts", + "default": "./dist/index.js" + }, + "./auto": { + "types": "./dist/auto.d.ts", + "development": "./src/auto.ts", + "default": "./dist/auto.js" + }, + "./styles": { + "development": "./src/styles/index.css", + "default": "./dist/styles/style.css" + } + } +} diff --git a/src/auto.ts b/src/auto.ts new file mode 100644 index 0000000..564e116 --- /dev/null +++ b/src/auto.ts @@ -0,0 +1,2 @@ +import "./styles/index.css"; +export * from "./index"; diff --git a/src/components/_shared/control.css b/src/components/_shared/control.css new file mode 100644 index 0000000..8d0c68f --- /dev/null +++ b/src/components/_shared/control.css @@ -0,0 +1,38 @@ +.ui-control{ + height: 40px; + padding: 0 12px; + border-radius: var(--ui-radius); + border: 1px solid var(--ui-border); + background: var(--ui-panel); + color: var(--ui-fg); + font: inherit; + outline: none; + width: 100%; + transition: box-shadow .12s ease, border-color .12s ease, background .12s ease; +} +.ui-control:focus-visible{ + box-shadow: var(--ui-focus); + border-color: color-mix(in oklab, var(--ui-primary) 55%, var(--ui-border)); +} +.ui-control[disabled]{ + opacity: .6; + cursor: not-allowed; +} +.ui-control--sm{ height: 34px; } +.ui-control--lg{ height: 46px; } + +.ui-control__row{ + display: flex; + gap: 10px; + align-items: center; +} +.ui-control__hint{ + color: var(--ui-muted); + font-size: 12px; + line-height: 1.2; +} +.ui-control__error{ + color: color-mix(in oklab, var(--ui-danger) 85%, white); + font-size: 12px; + line-height: 1.2; +} diff --git a/src/components/button/Button.tsx b/src/components/button/Button.tsx new file mode 100644 index 0000000..0f5c39e --- /dev/null +++ b/src/components/button/Button.tsx @@ -0,0 +1,28 @@ +import React from "react"; +import { cva, type VariantProps } from "class-variance-authority"; +import clsx from "clsx"; + +const buttonCva = cva("ui-btn", { + variants: { + variant: { solid: "", outline: "", ghost: "" }, + tone: { primary: "", neutral: "", danger: "" }, + size: { sm: "", md: "", lg: "" }, + }, + defaultVariants: { variant: "solid", tone: "primary", size: "md" }, +}); + +export type ButtonProps = + React.ButtonHTMLAttributes & + VariantProps; + +export function Button({ className, variant, tone, size, ...rest }: ButtonProps) { + return ( + +
{title}
+ + + +
+ {DOW.map((d) =>
{d}
)} + {days.map(({ date, muted }) => ( + + ))} +
+ + ); +} diff --git a/src/components/dates/CalendarRange.tsx b/src/components/dates/CalendarRange.tsx new file mode 100644 index 0000000..3143e06 --- /dev/null +++ b/src/components/dates/CalendarRange.tsx @@ -0,0 +1,98 @@ +import React from "react"; +import { Button } from "../button/Button"; +// import "./calendar.css"; + + +const DOW = ["Пн","Вт","Ср","Чт","Пт","Сб","Вс"]; + +function startOfMonth(d: Date) { return new Date(d.getFullYear(), d.getMonth(), 1); } +function endOfMonth(d: Date) { return new Date(d.getFullYear(), d.getMonth() + 1, 0); } +function addMonths(d: Date, n: number) { return new Date(d.getFullYear(), d.getMonth() + n, 1); } +function toKey(d: Date) { return `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}`; } +function inRange(d: Date, a?: Date, b?: Date) { + if (!a || !b) return false; + const t = d.setHours(0,0,0,0); + const ta = a.getTime(); + const tb = b.getTime(); + return t >= Math.min(ta, tb) && t <= Math.max(ta, tb); +} +function sameDay(a?: Date, b?: Date) { + if (!a || !b) return false; + return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate(); +} + +export function CalendarRange(props: { + value?: { start?: Date; end?: Date }; + onChange: (v: { start?: Date; end?: Date }) => void; +}) { + const [month, setMonth] = React.useState(() => startOfMonth(props.value?.start ?? new Date())); + const start = props.value?.start; + const end = props.value?.end; + + const startM = startOfMonth(month); + const endM = endOfMonth(month); + + const jsDow = startM.getDay(); + const shift = (jsDow + 6) % 7; + + const days: { date: Date; muted: boolean }[] = []; + const prevMonthEnd = new Date(startM.getFullYear(), startM.getMonth(), 0); + for (let i = 0; i < shift; i++) { + const d = new Date(prevMonthEnd.getFullYear(), prevMonthEnd.getMonth(), prevMonthEnd.getDate() - (shift - 1 - i)); + days.push({ date: d, muted: true }); + } + for (let day = 1; day <= endM.getDate(); day++) { + days.push({ date: new Date(startM.getFullYear(), startM.getMonth(), day), muted: false }); + } + while (days.length % 7 !== 0) { + const last = days[days.length - 1].date; + const d = new Date(last.getFullYear(), last.getMonth(), last.getDate() + 1); + days.push({ date: d, muted: true }); + } + + const title = month.toLocaleString("ru-RU", { month: "long", year: "numeric" }); + + const clickDay = (d: Date) => { + if (!start || (start && end)) { + props.onChange({ start: d, end: undefined }); + return; + } + props.onChange({ start, end: d }); + }; + + return ( +
+
+ +
{title}
+ +
+ +
+
От: {start ? start.toLocaleDateString("ru-RU") : "—"}
+
До: {end ? end.toLocaleDateString("ru-RU") : "—"}
+
+ +
+ {DOW.map((d) =>
{d}
)} + {days.map(({ date, muted }) => { + const selected = sameDay(date, start) || sameDay(date, end); + const range = inRange(new Date(date.getFullYear(), date.getMonth(), date.getDate()), start, end); + return ( + + ); + })} +
+
+ ); +} diff --git a/src/components/dates/DateInput.tsx b/src/components/dates/DateInput.tsx new file mode 100644 index 0000000..ef90c28 --- /dev/null +++ b/src/components/dates/DateInput.tsx @@ -0,0 +1,24 @@ +import React from "react"; +import clsx from "clsx"; +// import "../_shared/control.css"; + +export function DateInput(props: { + value?: string; // yyyy-mm-dd + onChange: (value: string) => void; + label?: React.ReactNode; + className?: string; +}) { + const { value, onChange, label, className } = props; + + return ( +
+ {label ?
{label}
: null} + onChange(e.target.value)} + /> +
+ ); +} diff --git a/src/components/dates/DateRangeInput.tsx b/src/components/dates/DateRangeInput.tsx new file mode 100644 index 0000000..33609fa --- /dev/null +++ b/src/components/dates/DateRangeInput.tsx @@ -0,0 +1,32 @@ +import React from "react"; +import clsx from "clsx"; +// import "../_shared/control.css"; + +export function DateRangeInput(props: { + start?: string; + end?: string; + onChange: (v: { start?: string; end?: string }) => void; + label?: React.ReactNode; +}) { + const { start, end, onChange, label } = props; + + return ( +
+ {label ?
{label}
: null} +
+ onChange({ start: e.target.value, end })} + /> + onChange({ start, end: e.target.value })} + /> +
+
+ ); +} diff --git a/src/components/dates/calendar.css b/src/components/dates/calendar.css new file mode 100644 index 0000000..c78b006 --- /dev/null +++ b/src/components/dates/calendar.css @@ -0,0 +1,63 @@ +.ui-cal{ + border: 1px solid var(--ui-border); + background: var(--ui-panel); + width: 100%; + padding: var(--cal-p, 12px); + border-radius: var(--ui-radius); + font-family: var(--ui-font), serif; +} + +.ui-cal__head{ + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + margin-bottom: 10px; +} + +.ui-cal__title{ + font-weight: 800; +} + +.ui-cal__grid{ + display: grid; + grid-template-columns: repeat(7, 1fr); + gap: var(--cal-gap, 6px);; +} + +.ui-cal__dow{ + text-align: center; + color: var(--ui-muted); + font-size: 11px; + padding: 6px 0; +} + +.ui-cal__day{ + height: var(--cal-day, 36px); + border-radius: var(--cal-day-radius, 10px);; + border: 1px solid transparent; + background: transparent; + color: var(--ui-fg); + cursor: pointer; +} + +.ui-cal__day:hover{ + background: rgba(255,255,255,0.06); +} +:root[data-theme="light"] .ui-cal__day:hover{ + background: rgba(17,19,24,0.06); +} + +.ui-cal__day[data-muted="true"]{ + color: color-mix(in oklab, var(--ui-muted) 85%, transparent); +} + +.ui-cal__day[data-selected="true"]{ + background: var(--ui-primary); + color: var(--ui-primary-fg); +} + +.ui-cal__day[data-range="true"]{ + background: color-mix(in oklab, var(--ui-primary) 25%, transparent); + border-color: color-mix(in oklab, var(--ui-primary) 40%, transparent); +} diff --git a/src/components/dialog/Dialog.tsx b/src/components/dialog/Dialog.tsx new file mode 100644 index 0000000..abc2852 --- /dev/null +++ b/src/components/dialog/Dialog.tsx @@ -0,0 +1,28 @@ +import React from "react"; +import * as RadixDialog from "@radix-ui/react-dialog"; +// import "./dialog.css"; + + +export const Dialog = RadixDialog.Root; +export const DialogTrigger = RadixDialog.Trigger; +export const DialogClose = RadixDialog.Close; + +export function DialogContent( + props: React.PropsWithChildren<{ + title?: string; + description?: string; + }> & RadixDialog.DialogContentProps +) { + const { children, title, description, ...rest } = props; + + return ( + + + + {title ? {title} : null} + {description ? {description} : null} +
{children}
+
+
+ ); +} diff --git a/src/components/dialog/dialog.css b/src/components/dialog/dialog.css new file mode 100644 index 0000000..e265913 --- /dev/null +++ b/src/components/dialog/dialog.css @@ -0,0 +1,32 @@ +.ui-dialog__overlay { + position: fixed; + inset: 0; + background: var(--modal-overlay, var(--ui-overlay)); + backdrop-filter: blur(var(--modal-blur, 6px)); +} + +.ui-dialog__content { + width: min(var(--modal-w, 520px), calc(100vw - 24px)); + border-radius: var(--modal-radius, var(--ui-radius)); + padding: var(--modal-p, 16px); + font-family: var(--ui-font), serif; + position: fixed; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + background: var(--ui-panel); + color: var(--ui-fg); + border: 1px solid var(--ui-border); + box-shadow: var(--ui-shadow); +} + +.ui-dialog__title { + margin: 0; + font-size: 16px; + font-weight: 700; +} + +.ui-dialog__desc { + margin: 8px 0 0 0; + color: var(--ui-muted); +} diff --git a/src/components/display/accordion/Accordion.tsx b/src/components/display/accordion/Accordion.tsx new file mode 100644 index 0000000..9ab9384 --- /dev/null +++ b/src/components/display/accordion/Accordion.tsx @@ -0,0 +1,45 @@ +import React from "react"; +import * as RadixAccordion from "@radix-ui/react-accordion"; +import clsx from "clsx"; +// import "./accordion.css"; + + +export type AccordionItemData = { + value: string; + title: React.ReactNode; + content: React.ReactNode; +}; + +export function Accordion(props: { + items: AccordionItemData[]; + type?: "single" | "multiple"; + defaultValue?: string | string[]; + collapsible?: boolean; + className?: string; + chevron?: React.ReactNode; +}) { + const { items, type = "single", defaultValue, collapsible = true, className, chevron } = props; + + return ( + + {items.map((it) => ( + + + + {it.title} + + {chevron ?? "▾"} + + + + {it.content} + + ))} + + ); +} diff --git a/src/components/display/accordion/accordion.css b/src/components/display/accordion/accordion.css new file mode 100644 index 0000000..5ad8031 --- /dev/null +++ b/src/components/display/accordion/accordion.css @@ -0,0 +1,36 @@ +.ui-acc{ + border: 1px solid var(--ui-border); + background: var(--ui-panel); + border-radius: var(--ui-radius); + overflow: hidden; +} + +.ui-acc__item{ border-top: 1px solid var(--ui-border); } +.ui-acc__item:first-child{ border-top: 0; } + +.ui-acc__trigger{ + width: 100%; + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 12px; + background: transparent; + border: 0; + color: var(--ui-fg); + font: inherit; + cursor: pointer; +} +.ui-acc__trigger:hover{ background: rgba(255,255,255,0.04); } +:root[data-theme="light"] .ui-acc__trigger:hover{ background: rgba(17,19,24,0.04); } + +.ui-acc__title{ font-weight: 800; } +.ui-acc__chevron{ color: var(--ui-muted); transition: transform .12s ease; } + +.ui-acc__trigger[data-state="open"] .ui-acc__chevron{ transform: rotate(180deg); } + +.ui-acc__content{ + padding: 0 12px 12px 12px; + color: var(--ui-muted); + font-size: 13px; +} diff --git a/src/components/display/avatar/Avatar.tsx b/src/components/display/avatar/Avatar.tsx new file mode 100644 index 0000000..da24220 --- /dev/null +++ b/src/components/display/avatar/Avatar.tsx @@ -0,0 +1,29 @@ +import React from "react"; +import clsx from "clsx"; +// import "./avatar.css"; + + +export type AvatarSize = 20 | 24 | 32 | 40 | 48; + +export type AvatarProps = { + size?: AvatarSize; + src?: string; + alt?: string; + initials?: string; + className?: string; +} & React.HTMLAttributes; + +export function Avatar({ + size = 32, + src, + alt, + initials, + className, + ...rest +}: AvatarProps) { + return ( + + {src ? {alt : (initials ?? "?")} + + ); +} diff --git a/src/components/display/avatar/avatar.css b/src/components/display/avatar/avatar.css new file mode 100644 index 0000000..c544d4e --- /dev/null +++ b/src/components/display/avatar/avatar.css @@ -0,0 +1,26 @@ +.ui-avatar{ + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 999px; + overflow: hidden; + border: 1px solid var(--ui-border); + background: rgba(255,255,255,0.06); + color: var(--ui-fg); + font-weight: 800; + user-select: none; +} +:root[data-theme="light"] .ui-avatar{ background: rgba(17,19,24,0.06); } + +.ui-avatar img{ + width: 100%; + height: 100%; + object-fit: cover; + display: block; +} + +.ui-avatar[data-size="20"]{ width: 20px; height: 20px; font-size: 10px; } +.ui-avatar[data-size="24"]{ width: 24px; height: 24px; font-size: 11px; } +.ui-avatar[data-size="32"]{ width: 32px; height: 32px; font-size: 12px; } +.ui-avatar[data-size="40"]{ width: 40px; height: 40px; font-size: 14px; } +.ui-avatar[data-size="48"]{ width: 48px; height: 48px; font-size: 16px; } diff --git a/src/components/display/badge/Badge.tsx b/src/components/display/badge/Badge.tsx new file mode 100644 index 0000000..e2bdad4 --- /dev/null +++ b/src/components/display/badge/Badge.tsx @@ -0,0 +1,38 @@ +import React from "react"; +import clsx from "clsx"; +// import "./badge.css"; + + +export type BadgeTone = "neutral" | "primary" | "danger"; +export type BadgeVariant = "solid" | "soft" | "outline"; +export type BadgeSize = "sm" | "md"; + +export type BadgeProps = React.HTMLAttributes & { + tone?: BadgeTone; + variant?: BadgeVariant; + size?: BadgeSize; + dot?: boolean; +}; + +export function Badge({ + tone = "neutral", + variant = "soft", + size = "md", + dot, + className, + children, + ...rest +}: BadgeProps) { + return ( + + {dot ? : null} + {children} + + ); +} diff --git a/src/components/display/badge/badge.css b/src/components/display/badge/badge.css new file mode 100644 index 0000000..f9a48bc --- /dev/null +++ b/src/components/display/badge/badge.css @@ -0,0 +1,58 @@ +.ui-badge{ + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + border-radius: 999px; + padding: 0 10px; + height: 22px; + font-size: 12px; + line-height: 1; + border: 1px solid transparent; + user-select: none; + white-space: nowrap; +} + +.ui-badge[data-size="sm"]{ height: 18px; padding: 0 8px; font-size: 11px; } +.ui-badge[data-size="md"]{ height: 22px; } + +/* variants */ +.ui-badge[data-variant="solid"]{ + background: var(--ui-fg); + color: var(--ui-bg); +} +.ui-badge[data-variant="soft"]{ + background: rgba(255,255,255,0.08); + color: var(--ui-fg); + border-color: rgba(255,255,255,0.06); +} +:root[data-theme="light"] .ui-badge[data-variant="soft"]{ + background: rgba(17,19,24,0.06); + border-color: rgba(17,19,24,0.08); +} +.ui-badge[data-variant="outline"]{ + background: transparent; + color: var(--ui-fg); + border-color: var(--ui-border); +} + +/* tones */ +.ui-badge[data-tone="primary"][data-variant="solid"]{ background: var(--ui-primary); color: var(--ui-primary-fg); } +.ui-badge[data-tone="danger"][data-variant="solid"]{ background: var(--ui-danger); color: var(--ui-danger-fg); } + +.ui-badge[data-tone="primary"][data-variant="soft"]{ + background: color-mix(in oklab, var(--ui-primary) 20%, transparent); + border-color: color-mix(in oklab, var(--ui-primary) 30%, transparent); +} +.ui-badge[data-tone="danger"][data-variant="soft"]{ + background: color-mix(in oklab, var(--ui-danger) 18%, transparent); + border-color: color-mix(in oklab, var(--ui-danger) 28%, transparent); +} + +.ui-badge__dot{ + width: 6px; + height: 6px; + border-radius: 999px; + background: currentColor; + opacity: 0.9; +} diff --git a/src/components/display/banner/Banner.tsx b/src/components/display/banner/Banner.tsx new file mode 100644 index 0000000..2ebe4e2 --- /dev/null +++ b/src/components/display/banner/Banner.tsx @@ -0,0 +1,27 @@ +import React from "react"; +import clsx from "clsx"; +// import "./banner.css"; + + +export type BannerTone = "neutral" | "primary" | "danger"; + +export function Banner(props: { + title: React.ReactNode; + description?: React.ReactNode; + icon?: React.ReactNode; + actions?: React.ReactNode; + tone?: BannerTone; + className?: string; +}) { + const { title, description, icon, actions, tone = "primary", className } = props; + return ( +
+ {icon ?
{icon}
: null} +
+

{title}

+ {description ?

{description}

: null} + {actions ?
{actions}
: null} +
+
+ ); +} diff --git a/src/components/display/banner/banner.css b/src/components/display/banner/banner.css new file mode 100644 index 0000000..a8d6a04 --- /dev/null +++ b/src/components/display/banner/banner.css @@ -0,0 +1,29 @@ +.ui-banner{ + background: var(--ui-panel); + border: 1px solid var(--ui-border); + border-radius: var(--ui-radius); + box-shadow: var(--ui-shadow); + padding: 12px; + display: flex; + gap: 12px; + align-items: flex-start; +} + +.ui-banner__icon{ + width: 40px; + height: 40px; + border-radius: 12px; + background: color-mix(in oklab, var(--ui-primary) 20%, transparent); + display: inline-flex; + align-items: center; + justify-content: center; +} + +.ui-banner__body{ flex: 1; display: grid; gap: 6px; } +.ui-banner__title{ margin: 0; font-weight: 800; font-size: 14px; } +.ui-banner__desc{ margin: 0; color: var(--ui-muted); font-size: 13px; } +.ui-banner__actions{ display: flex; gap: 8px; flex-wrap: wrap; margin-top: 2px; } + +.ui-banner[data-tone="danger"] .ui-banner__icon{ + background: color-mix(in oklab, var(--ui-danger) 20%, transparent); +} diff --git a/src/components/display/content-badge/ContentBadge.tsx b/src/components/display/content-badge/ContentBadge.tsx new file mode 100644 index 0000000..450225f --- /dev/null +++ b/src/components/display/content-badge/ContentBadge.tsx @@ -0,0 +1,16 @@ +import React from "react"; +import clsx from "clsx"; +import { Badge, type BadgeProps } from "../badge/Badge"; +// import "./content-badge.css"; + + +export type ContentBadgePosition = "tl" | "tr" | "bl" | "br"; + +export function ContentBadge(props: BadgeProps & { position?: ContentBadgePosition; className?: string }) { + const { position = "tl", className, ...rest } = props; + return ( +
+ +
+ ); +} diff --git a/src/components/display/content-badge/content-badge.css b/src/components/display/content-badge/content-badge.css new file mode 100644 index 0000000..3b184d9 --- /dev/null +++ b/src/components/display/content-badge/content-badge.css @@ -0,0 +1,9 @@ +.ui-contentbadge{ + position: absolute; + top: 10px; + left: 10px; + z-index: 2; +} +.ui-contentbadge[data-pos="tr"]{ left: auto; right: 10px; } +.ui-contentbadge[data-pos="bl"]{ top: auto; bottom: 10px; } +.ui-contentbadge[data-pos="br"]{ top: auto; bottom: 10px; left: auto; right: 10px; } diff --git a/src/components/display/content-card/ContentCard.tsx b/src/components/display/content-card/ContentCard.tsx new file mode 100644 index 0000000..3cac37c --- /dev/null +++ b/src/components/display/content-card/ContentCard.tsx @@ -0,0 +1,40 @@ +import React from "react"; +import clsx from "clsx"; +import { ImageBase } from "../image-base/ImageBase"; +// import "./content-card.css"; + + +export function ContentCard(props: { + title: React.ReactNode; + subtitle?: React.ReactNode; + meta?: React.ReactNode; + media?: { src: string; alt?: string; ratio?: number; overlay?: React.ReactNode }; + href?: string; + onClick?: () => void; + className?: string; +}) { + const { title, subtitle, meta, media, href, onClick, className } = props; + const clickable = Boolean(href || onClick); + const Comp: any = href ? "a" : "div"; + + return ( + + {media ? ( +
+ +
+ ) : null} + +
+
{title}
+ {subtitle ?
{subtitle}
: null} + {meta ?
{meta}
: null} +
+
+ ); +} diff --git a/src/components/display/content-card/content-card.css b/src/components/display/content-card/content-card.css new file mode 100644 index 0000000..296f94c --- /dev/null +++ b/src/components/display/content-card/content-card.css @@ -0,0 +1,18 @@ +.ui-contentcard{ + border-radius: var(--ui-radius); + border: 1px solid var(--ui-border); + background: var(--ui-panel); + box-shadow: var(--ui-shadow); + overflow: hidden; + color: inherit; + text-decoration: none; + display: grid; +} +.ui-contentcard[data-clickable="true"]{ cursor: pointer; } +.ui-contentcard[data-clickable="true"]:active{ transform: translateY(1px); } + +.ui-contentcard__media{ position: relative; } +.ui-contentcard__body{ padding: 12px; display: grid; gap: 6px; } +.ui-contentcard__title{ font-weight: 900; } +.ui-contentcard__subtitle{ color: var(--ui-muted); font-size: 13px; } +.ui-contentcard__meta{ color: var(--ui-muted); font-size: 12px; } diff --git a/src/components/display/counter/Counter.tsx b/src/components/display/counter/Counter.tsx new file mode 100644 index 0000000..378bde7 --- /dev/null +++ b/src/components/display/counter/Counter.tsx @@ -0,0 +1,39 @@ +import React from "react"; +import clsx from "clsx"; +// import "./counter.css"; + + +export type CounterTone = "danger" | "primary" | "neutral"; +export type CounterVariant = "solid" | "soft" | "outline"; +export type CounterSize = "sm" | "md"; + +export type CounterProps = React.HTMLAttributes & { + value: number; + max?: number; // e.g. 99 -> "99+" + tone?: CounterTone; + variant?: CounterVariant; + size?: CounterSize; +}; + +export function Counter({ + value, + max = 99, + tone = "danger", + variant = "solid", + size = "md", + className, + ...rest +}: CounterProps) { + const text = value > max ? `${max}+` : String(value); + return ( + + {text} + + ); +} diff --git a/src/components/display/counter/counter.css b/src/components/display/counter/counter.css new file mode 100644 index 0000000..53489f4 --- /dev/null +++ b/src/components/display/counter/counter.css @@ -0,0 +1,31 @@ +.ui-counter{ + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 20px; + height: 20px; + padding: 0 6px; + border-radius: 999px; + font-size: 12px; + line-height: 1; + border: 1px solid transparent; + user-select: none; + white-space: nowrap; +} + +.ui-counter[data-variant="solid"]{ background: var(--ui-danger); color: var(--ui-danger-fg); } +.ui-counter[data-variant="soft"]{ + background: color-mix(in oklab, var(--ui-danger) 18%, transparent); + border-color: color-mix(in oklab, var(--ui-danger) 28%, transparent); + color: var(--ui-fg); +} +.ui-counter[data-variant="outline"]{ background: transparent; border-color: var(--ui-border); color: var(--ui-fg); } + +.ui-counter[data-size="sm"]{ min-width: 18px; height: 18px; font-size: 11px; } +.ui-counter[data-size="md"]{ min-width: 20px; height: 20px; } + +.ui-counter[data-tone="primary"][data-variant="solid"]{ background: var(--ui-primary); color: var(--ui-primary-fg); } +.ui-counter[data-tone="primary"][data-variant="soft"]{ + background: color-mix(in oklab, var(--ui-primary) 18%, transparent); + border-color: color-mix(in oklab, var(--ui-primary) 28%, transparent); +} diff --git a/src/components/display/gallery/Gallery.tsx b/src/components/display/gallery/Gallery.tsx new file mode 100644 index 0000000..6df61ed --- /dev/null +++ b/src/components/display/gallery/Gallery.tsx @@ -0,0 +1,31 @@ +import React from "react"; +import clsx from "clsx"; +import { ImageBase } from "../image-base/ImageBase"; +// import "./gallery.css"; + + +export type GalleryItem = { + id: string; + src: string; + alt?: string; +}; + +export function Gallery(props: { + items: GalleryItem[]; + cols?: 2 | 3 | 4; + ratio?: number; + onItemClick?: (id: string) => void; + className?: string; +}) { + const { items, cols = 3, ratio = 1, onItemClick, className } = props; + + return ( +
+ {items.map((it) => ( +
onItemClick?.(it.id)} style={{ cursor: onItemClick ? "pointer" : "default" }}> + +
+ ))} +
+ ); +} diff --git a/src/components/display/gallery/gallery.css b/src/components/display/gallery/gallery.css new file mode 100644 index 0000000..aa4f3c9 --- /dev/null +++ b/src/components/display/gallery/gallery.css @@ -0,0 +1,11 @@ +.ui-gallery{ + display: grid; + gap: 10px; +} +.ui-gallery[data-cols="2"]{ grid-template-columns: repeat(2, minmax(0,1fr)); } +.ui-gallery[data-cols="3"]{ grid-template-columns: repeat(3, minmax(0,1fr)); } +.ui-gallery[data-cols="4"]{ grid-template-columns: repeat(4, minmax(0,1fr)); } + +@media (max-width: 560px){ + .ui-gallery{ grid-template-columns: repeat(2, minmax(0,1fr)); } +} diff --git a/src/components/display/grid-avatar/GridAvatar.tsx b/src/components/display/grid-avatar/GridAvatar.tsx new file mode 100644 index 0000000..05aae97 --- /dev/null +++ b/src/components/display/grid-avatar/GridAvatar.tsx @@ -0,0 +1,35 @@ +import React from "react"; +import clsx from "clsx"; +import { Avatar, type AvatarSize } from "../avatar/Avatar"; +// import "./grid-avatar.css"; + + +export type GridAvatarItem = { + id: string; + title: React.ReactNode; + subtitle?: React.ReactNode; + src?: string; + initials?: string; +}; + +export function GridAvatar(props: { + items: GridAvatarItem[]; + cols?: 2 | 3 | 4; + size?: AvatarSize; + className?: string; +}) { + const { items, cols = 2, size = 32, className } = props; + return ( +
+ {items.map((it) => ( +
+ +
+
{it.title}
+ {it.subtitle ?
{it.subtitle}
: null} +
+
+ ))} +
+ ); +} diff --git a/src/components/display/grid-avatar/grid-avatar.css b/src/components/display/grid-avatar/grid-avatar.css new file mode 100644 index 0000000..2ce7b19 --- /dev/null +++ b/src/components/display/grid-avatar/grid-avatar.css @@ -0,0 +1,12 @@ +.ui-gridavatar{ + display: grid; + gap: 10px; +} +.ui-gridavatar[data-cols="2"]{ grid-template-columns: repeat(2, minmax(0,1fr)); } +.ui-gridavatar[data-cols="3"]{ grid-template-columns: repeat(3, minmax(0,1fr)); } +.ui-gridavatar[data-cols="4"]{ grid-template-columns: repeat(4, minmax(0,1fr)); } + +.ui-gridavatar__cell{ display: flex; align-items: center; gap: 10px; } +.ui-gridavatar__label{ display: grid; gap: 2px; } +.ui-gridavatar__title{ font-weight: 700; } +.ui-gridavatar__subtitle{ font-size: 12px; color: var(--ui-muted); } diff --git a/src/components/display/horizontal-cell/HorizontalCell.tsx b/src/components/display/horizontal-cell/HorizontalCell.tsx new file mode 100644 index 0000000..38c8b8a --- /dev/null +++ b/src/components/display/horizontal-cell/HorizontalCell.tsx @@ -0,0 +1,40 @@ +import React from "react"; +import clsx from "clsx"; +import { ImageBase } from "../image-base/ImageBase"; +// import "./horizontal-cell.css"; + + +export function HorizontalCell(props: { + title: React.ReactNode; + subtitle?: React.ReactNode; + image?: { src: string; alt?: string; ratio?: number }; + width?: number; + clickable?: boolean; + href?: string; + onClick?: () => void; + className?: string; +}) { + const { title, subtitle, image, width = 160, clickable, href, onClick, className } = props; + const Comp: any = href ? "a" : "div"; + + return ( + + {image ? ( +
+ +
+ ) : null} + +
+
{title}
+ {subtitle ?
{subtitle}
: null} +
+
+ ); +} diff --git a/src/components/display/horizontal-cell/horizontal-cell.css b/src/components/display/horizontal-cell/horizontal-cell.css new file mode 100644 index 0000000..0febaab --- /dev/null +++ b/src/components/display/horizontal-cell/horizontal-cell.css @@ -0,0 +1,19 @@ +.ui-hcell{ + width: var(--ui-hcell-w, 160px); + border-radius: var(--ui-radius); + border: 1px solid var(--ui-border); + background: var(--ui-panel); + box-shadow: var(--ui-shadow); + overflow: hidden; + text-decoration: none; + color: inherit; + display: grid; +} + +.ui-hcell__media{ width: 100%; } +.ui-hcell__body{ padding: 10px 10px 12px 10px; display: grid; gap: 4px; } +.ui-hcell__title{ font-weight: 800; font-size: 13px; } +.ui-hcell__subtitle{ font-size: 12px; color: var(--ui-muted); } + +.ui-hcell[data-clickable="true"]{ cursor: pointer; } +.ui-hcell[data-clickable="true"]:active{ transform: translateY(1px); } diff --git a/src/components/display/horizontal-scroll/HorizontalScroll.tsx b/src/components/display/horizontal-scroll/HorizontalScroll.tsx new file mode 100644 index 0000000..f6b3d47 --- /dev/null +++ b/src/components/display/horizontal-scroll/HorizontalScroll.tsx @@ -0,0 +1,13 @@ +import React from "react"; +import clsx from "clsx"; +// import "./horizontal-scroll.css"; + + +export function HorizontalScroll(props: React.PropsWithChildren<{ gap?: number; className?: string }>) { + const { gap = 12, className, children } = props; + return ( +
+
{children}
+
+ ); +} diff --git a/src/components/display/horizontal-scroll/horizontal-scroll.css b/src/components/display/horizontal-scroll/horizontal-scroll.css new file mode 100644 index 0000000..288a335 --- /dev/null +++ b/src/components/display/horizontal-scroll/horizontal-scroll.css @@ -0,0 +1,16 @@ +.ui-hscroll{ + overflow-x: auto; + overflow-y: hidden; + -webkit-overflow-scrolling: touch; + padding-bottom: 4px; +} + +.ui-hscroll__inner{ + display: flex; + gap: var(--ui-hscroll-gap, 12px); + width: max-content; +} + +.ui-hscroll::-webkit-scrollbar{ height: 8px; } +.ui-hscroll::-webkit-scrollbar-thumb{ background: rgba(255,255,255,0.12); border-radius: 999px; } +:root[data-theme="light"] .ui-hscroll::-webkit-scrollbar-thumb{ background: rgba(17,19,24,0.12); } diff --git a/src/components/display/image-base/ImageBase.tsx b/src/components/display/image-base/ImageBase.tsx new file mode 100644 index 0000000..b6a79b1 --- /dev/null +++ b/src/components/display/image-base/ImageBase.tsx @@ -0,0 +1,36 @@ +import React from "react"; +import clsx from "clsx"; +// import "./image-base.css"; + + +export type ImageFit = "cover" | "contain"; + +export function ImageBase(props: { + src: string; + alt?: string; + ratio?: number; // width/height, e.g. 16/9 + fit?: ImageFit; + overlay?: React.ReactNode; + className?: string; + style?: React.CSSProperties; +}) { + const { src, alt, ratio = 16 / 9, fit = "cover", overlay, className, style } = props; + const paddingBottom = `${(1 / ratio) * 100}%`; + + return ( +
+
+ {alt + {overlay ?
{overlay}
: null} +
+ ); +} diff --git a/src/components/display/image-base/image-base.css b/src/components/display/image-base/image-base.css new file mode 100644 index 0000000..773701b --- /dev/null +++ b/src/components/display/image-base/image-base.css @@ -0,0 +1,31 @@ +.ui-imagebase{ + position: relative; + width: 100%; + border-radius: var(--ui-radius); + overflow: hidden; + border: 1px solid var(--ui-border); + background: rgba(255,255,255,0.04); +} +:root[data-theme="light"] .ui-imagebase{ background: rgba(17,19,24,0.04); } + +.ui-imagebase__ratio{ + width: 100%; + height: 0; + padding-bottom: var(--ui-image-ratio, 56.25%); /* 16:9 default */ +} + +.ui-imagebase img, +.ui-imagebase video{ + position: absolute; + inset: 0; + width: 100%; + height: 100%; + object-fit: var(--ui-image-fit, cover); + display: block; +} + +.ui-imagebase__overlay{ + position: absolute; + inset: 0; + pointer-events: none; +} diff --git a/src/components/display/image/Image.tsx b/src/components/display/image/Image.tsx new file mode 100644 index 0000000..29e0d9f --- /dev/null +++ b/src/components/display/image/Image.tsx @@ -0,0 +1,14 @@ +import React from "react"; +import { ImageBase, type ImageFit } from "../image-base/ImageBase"; + +export function Image(props: { + src: string; + alt?: string; + ratio?: number; + fit?: ImageFit; + overlay?: React.ReactNode; + className?: string; + style?: React.CSSProperties; +}) { + return ; +} diff --git a/src/components/display/index.ts b/src/components/display/index.ts new file mode 100644 index 0000000..cec9501 --- /dev/null +++ b/src/components/display/index.ts @@ -0,0 +1,17 @@ +export * from "./badge/Badge"; +export * from "./counter/Counter"; +export * from "./avatar/Avatar"; +export * from "./users-stack/UsersStack"; +export * from "./grid-avatar/GridAvatar"; +export * from "./banner/Banner"; +export * from "./placeholder/Placeholder"; +export * from "./image-base/ImageBase"; +export * from "./image/Image"; +export * from "./horizontal-scroll/HorizontalScroll"; +export * from "./horizontal-cell/HorizontalCell"; +export * from "./info-row/InfoRow"; +export * from "./mini-info-cell/MiniInfoCell"; +export * from "./gallery/Gallery"; +export * from "./content-badge/ContentBadge"; +export * from "./content-card/ContentCard"; +export * from "./accordion/Accordion"; diff --git a/src/components/display/info-row/InfoRow.tsx b/src/components/display/info-row/InfoRow.tsx new file mode 100644 index 0000000..c723f9b --- /dev/null +++ b/src/components/display/info-row/InfoRow.tsx @@ -0,0 +1,18 @@ +import React from "react"; +import clsx from "clsx"; +// import "./info-row.css"; + + +export function InfoRow(props: { + label: React.ReactNode; + value: React.ReactNode; + className?: string; +}) { + const { label, value, className } = props; + return ( +
+
{label}
+
{value}
+
+ ); +} diff --git a/src/components/display/info-row/info-row.css b/src/components/display/info-row/info-row.css new file mode 100644 index 0000000..b523062 --- /dev/null +++ b/src/components/display/info-row/info-row.css @@ -0,0 +1,8 @@ +.ui-inforow{ + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 12px; +} +.ui-inforow__label{ color: var(--ui-muted); font-size: 13px; } +.ui-inforow__value{ font-weight: 700; } diff --git a/src/components/display/mini-info-cell/MiniInfoCell.tsx b/src/components/display/mini-info-cell/MiniInfoCell.tsx new file mode 100644 index 0000000..e443d78 --- /dev/null +++ b/src/components/display/mini-info-cell/MiniInfoCell.tsx @@ -0,0 +1,34 @@ +import React from "react"; +import clsx from "clsx"; +// import "./mini-info-cell.css"; + + +export function MiniInfoCell(props: { + title: React.ReactNode; + subtitle?: React.ReactNode; + before?: React.ReactNode; + after?: React.ReactNode; + href?: string; + onClick?: () => void; + className?: string; +}) { + const { title, subtitle, before, after, href, onClick, className } = props; + const clickable = Boolean(href || onClick); + const Comp: any = href ? "a" : "div"; + + return ( + + {before ?
{before}
: null} +
+
{title}
+ {subtitle ?
{subtitle}
: null} +
+ {after ?
{after}
: null} +
+ ); +} diff --git a/src/components/display/mini-info-cell/mini-info-cell.css b/src/components/display/mini-info-cell/mini-info-cell.css new file mode 100644 index 0000000..52fe1ab --- /dev/null +++ b/src/components/display/mini-info-cell/mini-info-cell.css @@ -0,0 +1,17 @@ +.ui-minicell{ + display: flex; + align-items: center; + gap: 12px; + padding: 12px; + text-decoration: none; + color: inherit; +} +.ui-minicell[data-clickable="true"]{ cursor: pointer; } +.ui-minicell[data-clickable="true"]:hover{ background: rgba(255,255,255,0.04); } +:root[data-theme="light"] .ui-minicell[data-clickable="true"]:hover{ background: rgba(17,19,24,0.04); } + +.ui-minicell__before{ display: inline-flex; align-items: center; justify-content: center; } +.ui-minicell__body{ flex: 1; display: grid; gap: 2px; } +.ui-minicell__title{ font-weight: 700; } +.ui-minicell__subtitle{ font-size: 12px; color: var(--ui-muted); } +.ui-minicell__after{ display: inline-flex; align-items: center; justify-content: center; color: var(--ui-muted); } diff --git a/src/components/display/placeholder/Placeholder.tsx b/src/components/display/placeholder/Placeholder.tsx new file mode 100644 index 0000000..a553715 --- /dev/null +++ b/src/components/display/placeholder/Placeholder.tsx @@ -0,0 +1,22 @@ +import React from "react"; +import clsx from "clsx"; +// import "./placeholder.css"; + + +export function Placeholder(props: { + title: React.ReactNode; + description?: React.ReactNode; + icon?: React.ReactNode; + actions?: React.ReactNode; + className?: string; +}) { + const { title, description, icon, actions, className } = props; + return ( +
+ {icon ?
{icon}
: null} +

{title}

+ {description ?

{description}

: null} + {actions ?
{actions}
: null} +
+ ); +} diff --git a/src/components/display/placeholder/placeholder.css b/src/components/display/placeholder/placeholder.css new file mode 100644 index 0000000..212f9e6 --- /dev/null +++ b/src/components/display/placeholder/placeholder.css @@ -0,0 +1,25 @@ +.ui-placeholder{ + background: var(--ui-panel); + border: 1px dashed color-mix(in oklab, var(--ui-border) 70%, transparent); + border-radius: var(--ui-radius); + padding: 18px; + text-align: center; + display: grid; + gap: 10px; + justify-items: center; +} + +.ui-placeholder__icon{ + width: 56px; + height: 56px; + border-radius: 18px; + display: inline-flex; + align-items: center; + justify-content: center; + background: rgba(255,255,255,0.06); +} +:root[data-theme="light"] .ui-placeholder__icon{ background: rgba(17,19,24,0.06); } + +.ui-placeholder__title{ margin: 0; font-weight: 900; } +.ui-placeholder__desc{ margin: 0; color: var(--ui-muted); } +.ui-placeholder__actions{ display: flex; gap: 8px; flex-wrap: wrap; justify-content: center; } diff --git a/src/components/display/rich-cell/RichCell.tsx b/src/components/display/rich-cell/RichCell.tsx new file mode 100644 index 0000000..7bd0dc0 --- /dev/null +++ b/src/components/display/rich-cell/RichCell.tsx @@ -0,0 +1,38 @@ +import React from "react"; +import clsx from "clsx"; +// import "./rich-cell.css"; + + +export function RichCell(props: { + title: React.ReactNode; + subtitle?: React.ReactNode; + bottom?: React.ReactNode; + before?: React.ReactNode; + after?: React.ReactNode; + href?: string; + onClick?: () => void; + className?: string; +}) { + const { title, subtitle, bottom, before, after, href, onClick, className } = props; + const clickable = Boolean(href || onClick); + const Comp: any = href ? "a" : "div"; + + return ( + + {before ?
{before}
: null} +
+
+
{title}
+ {subtitle ?
{subtitle}
: null} +
+ {bottom ?
{bottom}
: null} +
+ {after ?
{after}
: null} +
+ ); +} diff --git a/src/components/display/rich-cell/rich-cell.css b/src/components/display/rich-cell/rich-cell.css new file mode 100644 index 0000000..b255985 --- /dev/null +++ b/src/components/display/rich-cell/rich-cell.css @@ -0,0 +1,18 @@ +.ui-richcell{ + display: flex; + align-items: flex-start; + gap: 12px; + padding: 12px; + text-decoration: none; + color: inherit; +} +.ui-richcell[data-clickable="true"]{ cursor: pointer; } +.ui-richcell[data-clickable="true"]:hover{ background: rgba(255,255,255,0.04); } +:root[data-theme="light"] .ui-richcell[data-clickable="true"]:hover{ background: rgba(17,19,24,0.04); } + +.ui-richcell__before{ display: inline-flex; } +.ui-richcell__body{ flex: 1; display: grid; gap: 6px; min-width: 0; } +.ui-richcell__title{ font-weight: 800; } +.ui-richcell__subtitle{ color: var(--ui-muted); font-size: 13px; } +.ui-richcell__bottom{ display: flex; gap: 8px; flex-wrap: wrap; } +.ui-richcell__after{ display: inline-flex; align-items: center; justify-content: center; color: var(--ui-muted); } diff --git a/src/components/display/users-stack/UsersStack.tsx b/src/components/display/users-stack/UsersStack.tsx new file mode 100644 index 0000000..48ea30b --- /dev/null +++ b/src/components/display/users-stack/UsersStack.tsx @@ -0,0 +1,35 @@ +import React from "react"; +import clsx from "clsx"; +import { Avatar, type AvatarSize } from "../avatar/Avatar"; +// import "./users-stack.css"; + + +export type UsersStackUser = { + id: string; + src?: string; + initials?: string; + alt?: string; +}; + +export type UsersStackProps = { + users: UsersStackUser[]; + size?: AvatarSize; + maxVisible?: number; + className?: string; +}; + +export function UsersStack({ users, size = 32, maxVisible = 3, className }: UsersStackProps) { + const visible = users.slice(0, maxVisible); + const rest = Math.max(0, users.length - visible.length); + + return ( +
+ {visible.map((u) => ( + + + + ))} + {rest > 0 ? +{rest} : null} +
+ ); +} diff --git a/src/components/display/users-stack/users-stack.css b/src/components/display/users-stack/users-stack.css new file mode 100644 index 0000000..4e41bb5 --- /dev/null +++ b/src/components/display/users-stack/users-stack.css @@ -0,0 +1,24 @@ +.ui-usersstack{ + display: inline-flex; + align-items: center; +} +.ui-usersstack__item{ + margin-left: -8px; + border-radius: 999px; +} +.ui-usersstack__item:first-child{ margin-left: 0; } +.ui-usersstack__more{ + margin-left: -8px; + height: 32px; + min-width: 32px; + border-radius: 999px; + border: 1px solid var(--ui-border); + background: rgba(255,255,255,0.08); + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 12px; + color: var(--ui-fg); + user-select: none; +} +:root[data-theme="light"] .ui-usersstack__more{ background: rgba(17,19,24,0.06); } diff --git a/src/components/feedback/Alert.tsx b/src/components/feedback/Alert.tsx new file mode 100644 index 0000000..69cf16e --- /dev/null +++ b/src/components/feedback/Alert.tsx @@ -0,0 +1,20 @@ +import React from "react"; +// import "./alert.css"; + + +export type AlertVariant = "neutral" | "success" | "warning" | "danger"; + +export function Alert(props: { variant?: AlertVariant; title: string; description?: string; right?: React.ReactNode }) { + const { variant = "neutral", title, description, right } = props; + return ( +
+
+
+
{title}
+ {description ?
{description}
: null} +
+ {right ?
{right}
: null} +
+
+ ); +} diff --git a/src/components/feedback/Progress.tsx b/src/components/feedback/Progress.tsx new file mode 100644 index 0000000..45d3e1f --- /dev/null +++ b/src/components/feedback/Progress.tsx @@ -0,0 +1,13 @@ +import React from "react"; +// import "./progress.css"; + + +export function Progress(props: { value: number; max?: number }) { + const { value, max = 100 } = props; + const pct = Math.max(0, Math.min(100, (value / max) * 100)); + return ( +
+
+
+ ); +} diff --git a/src/components/feedback/PullToRefresh.tsx b/src/components/feedback/PullToRefresh.tsx new file mode 100644 index 0000000..6a4ece7 --- /dev/null +++ b/src/components/feedback/PullToRefresh.tsx @@ -0,0 +1,95 @@ +import React from "react"; +// import "./pull.css"; + + +export function PullToRefresh(props: React.PropsWithChildren<{ + onRefresh: () => Promise | void; + thresholdPx?: number; + labelPull?: string; + labelRelease?: string; + labelLoading?: string; + className?: string; + style?: React.CSSProperties; +}>) { + const { + children, + onRefresh, + thresholdPx = 70, + labelPull = "Потяните вниз", + labelRelease = "Отпустите для обновления", + labelLoading = "Обновление...", + className, + style + } = props; + + const ref = React.useRef(null); + const [pull, setPull] = React.useState(0); + const [loading, setLoading] = React.useState(false); + + const startY = React.useRef(null); + const active = React.useRef(false); + + const onPointerDown = (e: React.PointerEvent) => { + if (loading) return; + const el = ref.current; + if (!el) return; + if (el.scrollTop > 0) return; + active.current = true; + startY.current = e.clientY; + }; + + const onPointerMove = (e: React.PointerEvent) => { + if (!active.current || loading) return; + const el = ref.current; + if (!el) return; + if (el.scrollTop > 0) return; + + const dy = e.clientY - (startY.current ?? e.clientY); + if (dy <= 0) { + setPull(0); + return; + } + + // демпфирование + const damp = Math.min(140, dy * 0.6); + setPull(damp); + }; + + const end = async () => { + if (!active.current) return; + active.current = false; + + if (pull >= thresholdPx && !loading) { + setLoading(true); + setPull(thresholdPx); + try { + await onRefresh(); + } finally { + setLoading(false); + setPull(0); + } + } else { + setPull(0); + } + }; + + return ( +
+
+
+ {loading ? labelLoading : pull >= thresholdPx ? labelRelease : labelPull} +
+
+ +
{children}
+
+ ); +} diff --git a/src/components/feedback/Skeleton.tsx b/src/components/feedback/Skeleton.tsx new file mode 100644 index 0000000..27d9974 --- /dev/null +++ b/src/components/feedback/Skeleton.tsx @@ -0,0 +1,13 @@ +import React from "react"; +import clsx from "clsx"; +// import "./skeleton.css"; + + +export function Skeleton(props: { width?: number | string; height?: number | string; className?: string; style?: React.CSSProperties }) { + const style: React.CSSProperties = { + width: props.width ?? "100%", + height: props.height ?? 14, + ...props.style + }; + return
; +} diff --git a/src/components/feedback/Snackbar.tsx b/src/components/feedback/Snackbar.tsx new file mode 100644 index 0000000..84d0784 --- /dev/null +++ b/src/components/feedback/Snackbar.tsx @@ -0,0 +1 @@ +export { ToastProvider as SnackbarProvider, useToast as useSnackbar } from "../toast/ToastProvider"; diff --git a/src/components/feedback/Spinner.tsx b/src/components/feedback/Spinner.tsx new file mode 100644 index 0000000..3f022c3 --- /dev/null +++ b/src/components/feedback/Spinner.tsx @@ -0,0 +1,19 @@ +import React from "react"; +// import "./spinner.css"; + + +export function Spinner() { + return
; +} + +export function ScreenSpinner(props: { visible: boolean; label?: string }) { + if (!props.visible) return null; + return ( +
+
+ + {props.label ?
{props.label}
: null} +
+
+ ); +} diff --git a/src/components/feedback/alert.css b/src/components/feedback/alert.css new file mode 100644 index 0000000..9933a9f --- /dev/null +++ b/src/components/feedback/alert.css @@ -0,0 +1,16 @@ +.ui-alert{ + border: 1px solid var(--ui-border); + border-radius: var(--ui-radius); + background: rgba(255,255,255,0.03); + padding: 12px; + display: grid; + gap: 4px; +} +:root[data-theme="light"] .ui-alert{ background: rgba(17,19,24,0.03); } + +.ui-alert__title{ font-weight: 800; } +.ui-alert__desc{ color: var(--ui-muted); font-size: 13px; } + +.ui-alert[data-variant="success"]{ border-color: rgba(46,204,113,0.35); } +.ui-alert[data-variant="warning"]{ border-color: rgba(255,183,77,0.40); } +.ui-alert[data-variant="danger"]{ border-color: rgba(255,77,79,0.45); } diff --git a/src/components/feedback/progress.css b/src/components/feedback/progress.css new file mode 100644 index 0000000..6ba9636 --- /dev/null +++ b/src/components/feedback/progress.css @@ -0,0 +1,15 @@ +.ui-progress{ + height: 10px; + border-radius: 999px; + background: rgba(255,255,255,0.10); + overflow: hidden; + border: 1px solid var(--ui-border); +} +:root[data-theme="light"] .ui-progress{ background: rgba(17,19,24,0.08); } + +.ui-progress__bar{ + height: 100%; + width: 0%; + background: var(--ui-primary); + transition: width .16s ease; +} diff --git a/src/components/feedback/pull.css b/src/components/feedback/pull.css new file mode 100644 index 0000000..7e52039 --- /dev/null +++ b/src/components/feedback/pull.css @@ -0,0 +1,28 @@ +.ui-pull{ + position: relative; + overflow: auto; + -webkit-overflow-scrolling: touch; + height: 100%; +} +.ui-pull__indicator{ + position: absolute; + left: 0; + right: 0; + top: 0; + height: 0px; + display: flex; + align-items: flex-end; + justify-content: center; + pointer-events: none; + transition: height .12s ease; +} +.ui-pull__pill{ + margin-bottom: 8px; + padding: 6px 10px; + border-radius: 999px; + border: 1px solid var(--ui-border); + background: var(--ui-panel); + color: var(--ui-muted); + font-size: 12px; + box-shadow: var(--ui-shadow); +} diff --git a/src/components/feedback/skeleton.css b/src/components/feedback/skeleton.css new file mode 100644 index 0000000..920c5dd --- /dev/null +++ b/src/components/feedback/skeleton.css @@ -0,0 +1,25 @@ +.ui-skeleton{ + border-radius: var(--ui-radius); + background: linear-gradient( + 90deg, + rgba(255,255,255,0.06), + rgba(255,255,255,0.12), + rgba(255,255,255,0.06) + ); + background-size: 220% 100%; + animation: ui-skel 1.1s ease-in-out infinite; +} +:root[data-theme="light"] .ui-skeleton{ + background: linear-gradient( + 90deg, + rgba(17,19,24,0.06), + rgba(17,19,24,0.12), + rgba(17,19,24,0.06) + ); + background-size: 220% 100%; +} + +@keyframes ui-skel{ + 0%{ background-position: 0% 0%; } + 100%{ background-position: -200% 0%; } +} diff --git a/src/components/feedback/spinner.css b/src/components/feedback/spinner.css new file mode 100644 index 0000000..7928daf --- /dev/null +++ b/src/components/feedback/spinner.css @@ -0,0 +1,27 @@ +.ui-spinner{ + width: 22px; + height: 22px; + border-radius: 999px; + border: 2px solid rgba(255,255,255,0.25); + border-top-color: var(--ui-fg); + animation: ui-spin .7s linear infinite; +} +:root[data-theme="light"] .ui-spinner{ + border: 2px solid rgba(17,19,24,0.25); + border-top-color: var(--ui-fg); +} + +@keyframes ui-spin{ + to { transform: rotate(360deg); } +} + +.ui-screenspinner{ + position: fixed; + inset: 0; + background: rgba(0,0,0,0.35); + backdrop-filter: blur(6px); + display: flex; + align-items: center; + justify-content: center; + z-index: 90; +} diff --git a/src/components/forms/Checkbox.tsx b/src/components/forms/Checkbox.tsx new file mode 100644 index 0000000..6753d2f --- /dev/null +++ b/src/components/forms/Checkbox.tsx @@ -0,0 +1,44 @@ +import React from "react"; +// import "./checkable.css"; + + +export function Checkbox(props: { + checked: boolean; + onChange: (checked: boolean) => void; + label?: React.ReactNode; + description?: React.ReactNode; + disabled?: boolean; +}) { + const { checked, onChange, label, description, disabled } = props; + + return ( +
{ + if (disabled) return; + onChange(!checked); + }} + role="checkbox" + aria-checked={checked} + tabIndex={0} + onKeyDown={(e) => { + if (disabled) return; + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + onChange(!checked); + } + }} + > + + + + + + {label ?
{label}
: null} + {description ?
{description}
: null} +
+
+ ); +} diff --git a/src/components/forms/Chip.tsx b/src/components/forms/Chip.tsx new file mode 100644 index 0000000..a9894e7 --- /dev/null +++ b/src/components/forms/Chip.tsx @@ -0,0 +1,16 @@ +import React from "react"; +// import "./chips.css"; + + +export function Chip(props: { label: string; onRemove?: () => void }) { + return ( + + {props.label} + {props.onRemove ? ( + + ) : null} + + ); +} diff --git a/src/components/forms/ChipsInput.tsx b/src/components/forms/ChipsInput.tsx new file mode 100644 index 0000000..fd92af5 --- /dev/null +++ b/src/components/forms/ChipsInput.tsx @@ -0,0 +1,56 @@ +import React from "react"; +import { Chip } from "./Chip"; +// import "./chips.css"; + + +export function ChipsInput(props: { + value: string[]; + onChange: (value: string[]) => void; + placeholder?: string; + label?: React.ReactNode; +}) { + const { value, onChange, placeholder = "Введите и нажмите Enter", label } = props; + const [text, setText] = React.useState(""); + + const add = () => { + const v = text.trim(); + if (!v) return; + if (value.includes(v)) { + setText(""); + return; + } + onChange([...value, v]); + setText(""); + }; + + return ( +
+ {label ?
{label}
: null} +
+
+ {value.map((v) => ( + onChange(value.filter((x) => x !== v))} /> + ))} + setText(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + add(); + } + if (e.key === "Backspace" && text.length === 0 && value.length > 0) { + onChange(value.slice(0, -1)); + } + }} + /> +
+
+ Подсказка: Enter — добавить, Backspace — удалить последний. +
+
+
+ ); +} diff --git a/src/components/forms/ChipsSelect.tsx b/src/components/forms/ChipsSelect.tsx new file mode 100644 index 0000000..8bd701a --- /dev/null +++ b/src/components/forms/ChipsSelect.tsx @@ -0,0 +1,41 @@ +import React from "react"; +import { Chip } from "./Chip"; +// import "./chips.css"; + +import { SelectMimicry, type MimicOption } from "./SelectMimicry"; + +export function ChipsSelect(props: { + value: string[]; + onChange: (value: string[]) => void; + options: MimicOption[]; + label?: React.ReactNode; +}) { + const { value, onChange, options, label } = props; + + const available = options.filter((o) => !value.includes(o.key)); + + return ( +
+ {label ?
{label}
: null} + +
+
+ {value.map((k) => ( + o.key === k)?.label ?? k} + onRemove={() => onChange(value.filter((x) => x !== k))} + /> + ))} +
+ + onChange([...value, k])} + options={available} + placeholder={available.length ? "Добавить..." : "Все добавлено"} + /> +
+
+ ); +} diff --git a/src/components/forms/CustomSelect.tsx b/src/components/forms/CustomSelect.tsx new file mode 100644 index 0000000..ea56e2b --- /dev/null +++ b/src/components/forms/CustomSelect.tsx @@ -0,0 +1,44 @@ +import React from "react"; +import * as RadixSelect from "@radix-ui/react-select"; +// import "./custom-select.css"; + + +export type SelectOption = { value: string; label: string; disabled?: boolean }; + +export function CustomSelect(props: { + value: string; + onChange: (value: string) => void; + options: SelectOption[]; + placeholder?: string; + label?: React.ReactNode; + hint?: React.ReactNode; + error?: React.ReactNode; +}) { + const { value, onChange, options, placeholder, label, hint, error } = props; + + return ( +
+ {label ?
{label}
: null} + + + + + + + + + + {options.map((o) => ( + + {o.label} + + ))} + + + + + + {error ?
{error}
: hint ?
{hint}
: null} +
+ ); +} diff --git a/src/components/forms/DropZone.tsx b/src/components/forms/DropZone.tsx new file mode 100644 index 0000000..bb27fd4 --- /dev/null +++ b/src/components/forms/DropZone.tsx @@ -0,0 +1,56 @@ +import React from "react"; +// import "./dropzone.css"; + + +export function DropZone(props: { + onFiles: (files: FileList) => void; + title?: React.ReactNode; + hint?: React.ReactNode; +}) { + const { onFiles, title = "Перетащите файлы сюда", hint = "или нажмите, чтобы выбрать" } = props; + const [over, setOver] = React.useState(false); + const inputRef = React.useRef(null); + + return ( +
{ + e.preventDefault(); + setOver(true); + }} + onDragOver={(e) => { + e.preventDefault(); + setOver(true); + }} + onDragLeave={(e) => { + e.preventDefault(); + setOver(false); + }} + onDrop={(e) => { + e.preventDefault(); + setOver(false); + if (e.dataTransfer.files && e.dataTransfer.files.length > 0) { + onFiles(e.dataTransfer.files); + } + }} + onClick={() => inputRef.current?.click()} + role="button" + tabIndex={0} + > + { + const files = e.target.files; + if (!files || files.length === 0) return; + onFiles(files); + }} + /> + +
{title}
+
{hint}
+
+ ); +} diff --git a/src/components/forms/File.tsx b/src/components/forms/File.tsx new file mode 100644 index 0000000..e85b5cf --- /dev/null +++ b/src/components/forms/File.tsx @@ -0,0 +1,41 @@ +import React from "react"; +// import "./file.css"; + + +export function File(props: { + label?: React.ReactNode; + accept?: string; + multiple?: boolean; + onFiles: (files: FileList) => void; +}) { + const { label, accept, multiple, onFiles } = props; + const inputRef = React.useRef(null); + const [name, setName] = React.useState(""); + + return ( +
+ {label ?
{label}
: null} + { + const files = e.target.files; + if (!files || files.length === 0) return; + setName(multiple ? `${files.length} файлов` : files[0].name); + onFiles(files); + }} + /> + +
+ ); +} diff --git a/src/components/forms/FormField.tsx b/src/components/forms/FormField.tsx new file mode 100644 index 0000000..596bac3 --- /dev/null +++ b/src/components/forms/FormField.tsx @@ -0,0 +1,16 @@ +import React from "react"; + +export function FormField(props: React.PropsWithChildren<{ label?: React.ReactNode; hint?: React.ReactNode; error?: React.ReactNode }>) { + const { label, hint, error, children } = props; + return ( +
+ {label ?
{label}
: null} + {children} + {error ? ( +
{error}
+ ) : hint ? ( +
{hint}
+ ) : null} +
+ ); +} diff --git a/src/components/forms/FormItem.tsx b/src/components/forms/FormItem.tsx new file mode 100644 index 0000000..3b28948 --- /dev/null +++ b/src/components/forms/FormItem.tsx @@ -0,0 +1,5 @@ +import React from "react"; + +export function FormItem(props: React.PropsWithChildren) { + return
{props.children}
; +} diff --git a/src/components/forms/FormStatus.tsx b/src/components/forms/FormStatus.tsx new file mode 100644 index 0000000..1634023 --- /dev/null +++ b/src/components/forms/FormStatus.tsx @@ -0,0 +1,12 @@ +import React from "react"; +import { Alert } from "../feedback/Alert"; + +export function FormStatus(props: { type: "info" | "success" | "warning" | "error"; title: string; description?: string }) { + const { type, title, description } = props; + const variant = + type === "success" ? "success" : + type === "error" ? "danger" : + type === "warning" ? "warning" : "neutral"; + + return ; +} diff --git a/src/components/forms/Input.tsx b/src/components/forms/Input.tsx new file mode 100644 index 0000000..f0da0d6 --- /dev/null +++ b/src/components/forms/Input.tsx @@ -0,0 +1,43 @@ +import React from "react"; +import clsx from "clsx"; +// import "../_shared/control.css"; +// import "./input.css"; + + +export type InputProps = React.InputHTMLAttributes & { + label?: React.ReactNode; + hint?: React.ReactNode; + error?: React.ReactNode; + leftIcon?: React.ReactNode; + rightIcon?: React.ReactNode; +}; + +export function Input(props: InputProps) { + const { label, hint, error, leftIcon, rightIcon, className, ...rest } = props; + + const hasLeft = Boolean(leftIcon); + const hasRight = Boolean(rightIcon); + + return ( +
+ {label ?
{label}
: null} + +
+ {hasLeft ? {leftIcon} : null} + {hasRight ? {rightIcon} : null} + + +
+ + {error ?
{error}
: hint ?
{hint}
: null} +
+ ); +} diff --git a/src/components/forms/NativeSelect.tsx b/src/components/forms/NativeSelect.tsx new file mode 100644 index 0000000..c9cd76d --- /dev/null +++ b/src/components/forms/NativeSelect.tsx @@ -0,0 +1,23 @@ +import React from "react"; +import clsx from "clsx"; +// import "../_shared/control.css"; + +export function NativeSelect( + props: React.SelectHTMLAttributes & { + label?: React.ReactNode; + hint?: React.ReactNode; + error?: React.ReactNode; + } +) { + const { label, hint, error, className, children, ...rest } = props; + + return ( +
+ {label ?
{label}
: null} + + {error ?
{error}
: hint ?
{hint}
: null} +
+ ); +} diff --git a/src/components/forms/Radio.tsx b/src/components/forms/Radio.tsx new file mode 100644 index 0000000..127945f --- /dev/null +++ b/src/components/forms/Radio.tsx @@ -0,0 +1,44 @@ +import React from "react"; +// import "./checkable.css"; + + +export function Radio(props: { + checked: boolean; + onChange: () => void; + label?: React.ReactNode; + description?: React.ReactNode; + disabled?: boolean; +}) { + const { checked, onChange, label, description, disabled } = props; + + return ( +
{ + if (disabled) return; + onChange(); + }} + role="radio" + aria-checked={checked} + tabIndex={0} + onKeyDown={(e) => { + if (disabled) return; + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + onChange(); + } + }} + > + + + + + + {label ?
{label}
: null} + {description ?
{description}
: null} +
+
+ ); +} diff --git a/src/components/forms/SegmentedControl.tsx b/src/components/forms/SegmentedControl.tsx new file mode 100644 index 0000000..e576653 --- /dev/null +++ b/src/components/forms/SegmentedControl.tsx @@ -0,0 +1,32 @@ +import React from "react"; +// import "./segmented.css"; + + +export type SegmentedItem = { key: string; label: React.ReactNode; disabled?: boolean }; + +export function SegmentedControl(props: { + items: SegmentedItem[]; + value: string; + onChange: (key: string) => void; +}) { + const { items, value, onChange } = props; + return ( +
+ {items.map((it) => { + const active = it.key === value; + return ( + + ); + })} +
+ ); +} diff --git a/src/components/forms/SelectMimicry.tsx b/src/components/forms/SelectMimicry.tsx new file mode 100644 index 0000000..1b6b1fb --- /dev/null +++ b/src/components/forms/SelectMimicry.tsx @@ -0,0 +1,55 @@ +import React from "react"; +import * as Popover from "@radix-ui/react-popover"; +import clsx from "clsx"; +// import "../_shared/control.css"; +// import "./select-mimicry.css"; + +export type MimicOption = { key: string; label: string; description?: string; disabled?: boolean }; + +export function SelectMimicry(props: { + valueKey?: string; + onChange: (key: string) => void; + options: MimicOption[]; + placeholder?: string; + label?: React.ReactNode; + className?: string; +}) { + const { valueKey, onChange, options, placeholder = "Выберите...", label, className } = props; + + const selected = options.find((o) => o.key === valueKey); + + return ( +
+ {label ?
{label}
: null} + + + + + + + + + {options.map((o) => ( + + ))} + + + +
+ ); +} diff --git a/src/components/forms/Slider.tsx b/src/components/forms/Slider.tsx new file mode 100644 index 0000000..e183477 --- /dev/null +++ b/src/components/forms/Slider.tsx @@ -0,0 +1,33 @@ +import React from "react"; +// import "./slider.css"; + + +export function Slider(props: { + value: number; + onChange: (v: number) => void; + min?: number; + max?: number; + step?: number; + label?: React.ReactNode; + formatValue?: (v: number) => string; +}) { + const { value, onChange, min = 0, max = 100, step = 1, label, formatValue } = props; + + return ( +
+
+ {label ?
{label}
:
} +
{(formatValue ?? String)(value)}
+
+ + onChange(Number(e.target.value))} + /> +
+ ); +} diff --git a/src/components/forms/Switch.tsx b/src/components/forms/Switch.tsx new file mode 100644 index 0000000..7b7a2d0 --- /dev/null +++ b/src/components/forms/Switch.tsx @@ -0,0 +1,39 @@ +import React from "react"; +// import "./switch.css"; + + +export function Switch(props: { + checked: boolean; + onChange: (checked: boolean) => void; + label?: React.ReactNode; + disabled?: boolean; +}) { + const { checked, onChange, label, disabled } = props; + + return ( +
{ + if (disabled) return; + onChange(!checked); + }} + role="switch" + aria-checked={checked} + tabIndex={0} + onKeyDown={(e) => { + if (disabled) return; + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + onChange(!checked); + } + }} + > + + + + {label ? {label} : null} +
+ ); +} diff --git a/src/components/forms/Textarea.tsx b/src/components/forms/Textarea.tsx new file mode 100644 index 0000000..da2593d --- /dev/null +++ b/src/components/forms/Textarea.tsx @@ -0,0 +1,26 @@ +import React from "react"; +import clsx from "clsx"; +// import "../_shared/control.css"; + +export type TextareaProps = React.TextareaHTMLAttributes & { + label?: React.ReactNode; + hint?: React.ReactNode; + error?: React.ReactNode; +}; + +export function Textarea(props: TextareaProps) { + const { label, hint, error, className, rows = 4, ...rest } = props; + + return ( +
+ {label ?
{label}
: null} +