first commit

This commit is contained in:
2025-12-27 18:23:41 +03:00
commit 63a9425680
197 changed files with 7078 additions and 0 deletions

BIN
.gitignore vendored Normal file

Binary file not shown.

38
package.json Normal file
View File

@@ -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"
}
}

50
package_pruduction.json Normal file
View File

@@ -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"
}
}
}

2
src/auto.ts Normal file
View File

@@ -0,0 +1,2 @@
import "./styles/index.css";
export * from "./index";

View File

@@ -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;
}

View File

@@ -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<HTMLButtonElement> &
VariantProps<typeof buttonCva>;
export function Button({ className, variant, tone, size, ...rest }: ButtonProps) {
return (
<button
className={clsx(buttonCva({ variant, tone, size }), className)}
data-variant={variant ?? "solid"}
data-tone={tone ?? "primary"}
data-size={size ?? "md"}
{...rest}
/>
);
}

View File

@@ -0,0 +1,66 @@
.ui-btn {
appearance: none;
border: 1px solid transparent;
border-radius: var(--btn-radius, var(--ui-radius));
font: inherit;
color: var(--ui-fg);
background: transparent;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--btn-gap, 10px);
line-height: 1;
user-select: none;
transition: transform var(--ui-duration-fast, 120ms) var(--ui-ease, ease), background var(--ui-duration, 180ms) var(--ui-ease, ease), border-color var(--ui-duration, 180ms) var(--ui-ease, ease), opacity var(--ui-duration, 180ms) var(--ui-ease, ease);
}
.ui-control{
height: var(--input-h-md, 40px);
padding: 0 var(--input-px, 12px);
border-radius: var(--input-radius, var(--ui-radius));
font-family: var(--ui-font);
}
.ui-control--sm{ height: var(--input-h-sm, 34px); }
.ui-control--lg{ height: var(--input-h-lg, 46px); }
.ui-btn:focus-visible {
outline: none;
box-shadow: var(--ui-focus);
}
.ui-btn:active { transform: translateY(1px); }
.ui-btn[disabled] { opacity: .55; cursor: not-allowed; }
.ui-btn[data-size="sm"] { height: var(--btn-h-sm, 32px); padding: 0 var(--btn-px-sm, 12px); }
.ui-btn[data-size="md"] { height: var(--btn-h-md, 40px); padding: 0 var(--btn-px-md, 14px); }
.ui-btn[data-size="lg"] { height: var(--btn-h-lg, 48px); padding: 0 var(--btn-px-lg, 16px); }
.ui-btn[data-variant="solid"][data-tone="primary"] {
background: var(--ui-primary);
color: var(--ui-primary-fg);
}
.ui-btn[data-variant="outline"] {
border-color: var(--ui-border);
background: rgba(255,255,255,0.02);
}
:root[data-theme="light"] .ui-btn[data-variant="outline"] {
background: rgba(17,19,24,0.02);
}
.ui-btn[data-variant="ghost"] {
border-color: transparent;
background: transparent;
}
.ui-btn[data-variant="ghost"]:hover {
background: rgba(255,255,255,0.06);
}
:root[data-theme="light"] .ui-btn[data-variant="ghost"]:hover {
background: rgba(17,19,24,0.06);
}
.ui-btn[data-tone="danger"][data-variant="solid"] {
background: var(--ui-danger);
color: var(--ui-danger-fg);
}

View File

@@ -0,0 +1,18 @@
import React from "react";
import clsx from "clsx";
// import "./card-grid.css";
export function CardGrid(
props: React.PropsWithChildren<{
cols?: 1 | 2 | 3;
className?: string;
}>
) {
const cols = props.cols ?? 2;
return (
<div className={clsx("ui-cardgrid", props.className)} data-cols={String(cols)}>
{props.children}
</div>
);
}

View File

@@ -0,0 +1,12 @@
.ui-cardgrid{
display: grid;
gap: 12px;
}
.ui-cardgrid[data-cols="1"]{ grid-template-columns: 1fr; }
.ui-cardgrid[data-cols="2"]{ grid-template-columns: repeat(2, 1fr); }
.ui-cardgrid[data-cols="3"]{ grid-template-columns: repeat(3, 1fr); }
@media (max-width: 560px){
.ui-cardgrid{ grid-template-columns: 1fr !important; }
}

View File

@@ -0,0 +1,55 @@
import React from "react";
import { Slot } from "@radix-ui/react-slot";
import clsx from "clsx";
// import "./card.css";
export type CardVariant = "default" | "flat" | "outline";
export type CardProps = React.PropsWithChildren<{
className?: string;
variant?: CardVariant;
clickable?: boolean;
asChild?: boolean;
}> & React.HTMLAttributes<HTMLElement>;
export function Card({
className,
variant = "default",
clickable,
asChild,
children,
...rest
}: CardProps) {
const Comp: any = asChild ? Slot : "div";
return (
<Comp
className={clsx("ui-card", className)}
data-variant={variant}
data-clickable={clickable ? "true" : "false"}
{...rest}
>
{children}
</Comp>
);
}
export function CardHeader(props: React.PropsWithChildren<{ className?: string }>) {
return <div className={clsx("ui-card__header", props.className)}>{props.children}</div>;
}
export function CardTitle(props: React.PropsWithChildren<{ className?: string }>) {
return <h4 className={clsx("ui-card__title", props.className)}>{props.children}</h4>;
}
export function CardSubtitle(props: React.PropsWithChildren<{ className?: string }>) {
return <p className={clsx("ui-card__subtitle", props.className)}>{props.children}</p>;
}
export function CardContent(props: React.PropsWithChildren<{ className?: string }>) {
return <div className={clsx("ui-card__content", props.className)}>{props.children}</div>;
}
export function CardFooter(props: React.PropsWithChildren<{ className?: string }>) {
return <div className={clsx("ui-card__footer", props.className)}>{props.children}</div>;
}

View File

@@ -0,0 +1,61 @@
.ui-card{
background: var(--ui-panel);
border: 1px solid var(--ui-border);
border-radius: var(--ui-radius);
box-shadow: var(--ui-shadow);
overflow: hidden;
}
/* варианты */
.ui-card[data-variant="flat"]{
box-shadow: none;
}
.ui-card[data-variant="outline"]{
box-shadow: none;
background: transparent;
}
/* кликабельная карточка */
.ui-card[data-clickable="true"]{
cursor: pointer;
transition: transform .08s ease, background .12s ease, border-color .12s ease;
}
.ui-card[data-clickable="true"]:hover{
background: color-mix(in oklab, var(--ui-panel) 92%, white);
}
:root[data-theme="light"] .ui-card[data-clickable="true"]:hover{
background: color-mix(in oklab, var(--ui-panel) 92%, black);
}
.ui-card[data-clickable="true"]:active{
transform: translateY(1px);
}
/* части карточки */
.ui-card__header{
padding: var(--card-header-py, 12px) var(--card-p, 12px) var(--card-header-pb, 10px) var(--card-p, 12px);
display: grid;
gap: 4px;
}
.ui-card__title{
margin: 0;
font-weight: 800;
font-size: 14px;
}
.ui-card__subtitle{
margin: 0;
color: var(--ui-muted);
font-size: 13px;
}
.ui-card__content{
padding: var(--card-p, 12px);
}
.ui-card__footer{
padding: var(--card-footer-pt, 10px) var(--card-p, 12px) var(--card-footer-pb, 12px) var(--card-p, 12px);
display: flex;
gap: 10px;
align-items: center;
justify-content: flex-end;
border-top: 1px solid var(--ui-border);
}

View File

@@ -0,0 +1,87 @@
import React from "react";
import clsx from "clsx";
import { Slot } from "@radix-ui/react-slot";
import { ChevronRight } from "../icons";
// import "./cell.css";
export type CellBadge = number | "dot" | null;
export type CellProps = React.PropsWithChildren<{
className?: string;
/** left side icon/avatar */
before?: React.ReactNode;
/** right side content (e.g., value, switch, button) */
after?: React.ReactNode;
/** subtitle / description */
subtitle?: React.ReactNode;
/** badge near title */
badge?: CellBadge;
/** show chevron on right */
chevron?: boolean;
/** allow title/subtitle to wrap */
multiline?: boolean;
/** render as child (Link/NavLink) */
asChild?: boolean;
/** click handler (when not using asChild) */
onClick?: React.MouseEventHandler<HTMLElement>;
/** disabled state */
disabled?: boolean;
/** make it look clickable (hover/active) */
clickable?: boolean;
}> & Omit<React.HTMLAttributes<HTMLElement>, "onClick">;
function renderBadge(badge: CellBadge) {
if (badge === null || badge === undefined) return null;
if (badge === "dot") return <span className="ui-cell__badge" aria-label="badge"></span>;
if (typeof badge === "number") return <span className="ui-cell__badge">{badge > 99 ? "99+" : badge}</span>;
return null;
}
export function Cell({
className,
before,
after,
subtitle,
badge,
chevron = false,
multiline = false,
asChild = false,
onClick,
disabled,
clickable,
children,
...rest
}: CellProps) {
const Comp: any = asChild ? Slot : "div";
const isClickable = Boolean(clickable ?? onClick ?? asChild);
return (
<Comp
className={clsx("ui-cell", className)}
data-clickable={isClickable ? "true" : "false"}
data-multiline={multiline ? "true" : "false"}
aria-disabled={disabled ? "true" : "false"}
onClick={disabled ? undefined : onClick}
{...rest}
>
{before ? <div className="ui-cell__before">{before}</div> : <div className="ui-cell__before" />}
<div className="ui-cell__main">
<div className="ui-cell__titleRow">
<div className="ui-cell__title">{children}</div>
{/*@ts-ignore*/}
{renderBadge(badge)}
</div>
{subtitle ? <div className="ui-cell__subtitle">{subtitle}</div> : null}
</div>
<div className="ui-cell__after">
{after}
{chevron ? <ChevronRight className="ui-cell__chevron" /> : null}
</div>
</Comp>
);
}
/** Alias, closer to VKUI naming */
export const SimpleCell = Cell;

View File

@@ -0,0 +1,72 @@
import React from "react";
import clsx from "clsx";
import { Slot } from "@radix-ui/react-slot";
import { ChevronRight } from "../icons";
// import "./cell.css";
// import "./richcell.css";
export type RichCellProps = React.PropsWithChildren<{
className?: string;
before?: React.ReactNode;
after?: React.ReactNode;
/** main line under title */
text?: React.ReactNode;
/** secondary text below */
caption?: React.ReactNode;
/** bottom actions row (chips/buttons) */
bottom?: React.ReactNode;
chevron?: boolean;
multiline?: boolean;
asChild?: boolean;
onClick?: React.MouseEventHandler<HTMLElement>;
disabled?: boolean;
clickable?: boolean;
}> & Omit<React.HTMLAttributes<HTMLElement>, "onClick">;
export function RichCell({
className,
before,
after,
text,
caption,
bottom,
chevron,
multiline = true,
asChild,
onClick,
disabled,
clickable,
children,
...rest
}: RichCellProps) {
const Comp: any = asChild ? Slot : "div";
const isClickable = Boolean(clickable ?? onClick ?? asChild);
return (
<Comp
className={clsx("ui-cell", className)}
data-clickable={isClickable ? "true" : "false"}
data-multiline={multiline ? "true" : "false"}
aria-disabled={disabled ? "true" : "false"}
onClick={disabled ? undefined : onClick}
{...rest}
>
{before ? <div className="ui-cell__before">{before}</div> : <div className="ui-cell__before" />}
<div className="ui-cell__main">
<div className="ui-cell__titleRow">
<div className="ui-cell__title">{children}</div>
</div>
{text ? <div className="ui-cell__subtitle">{text}</div> : null}
{caption ? <div className="ui-richcell__caption">{caption}</div> : null}
{bottom ? <div className="ui-richcell__bottom">{bottom}</div> : null}
</div>
<div className="ui-cell__after">
{after}
{chevron ? <ChevronRight className="ui-cell__chevron" /> : null}
</div>
</Comp>
);
}

View File

@@ -0,0 +1,127 @@
.ui-cell{
width: 100%;
display: flex;
gap: var(--cell-gap, 12px);
align-items: center;
padding: var(--cell-px, 12px);
min-height: var(--cell-h, 52px);
background: transparent;
border: 0;
color: inherit;
text-align: left;
cursor: default;
font-family: var(--ui-font);
}
.ui-cell[data-clickable="true"]{
cursor: pointer;
transition: background .12s ease, transform .06s ease;
}
.ui-cell[data-clickable="true"]:hover{
background: rgba(255,255,255,0.04);
}
:root[data-theme="light"] .ui-cell[data-clickable="true"]:hover{
background: rgba(17,19,24,0.04);
}
.ui-cell[data-clickable="true"]:active{
transform: translateY(1px);
}
.ui-cell:focus-visible{
outline: none;
box-shadow: var(--ui-focus);
}
.ui-cell[disabled]{
opacity: .55;
cursor: not-allowed;
}
.ui-cell__before{
width: var(--cell-before, 28px);
height: var(--cell-before, 28px);
display: inline-flex;
align-items: center;
justify-content: center;
flex: 0 0 auto;
}
.ui-cell__main{
min-width: 0;
flex: 1 1 auto;
display: grid;
gap: 4px;
}
.ui-cell__titleRow{
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.ui-cell__title{
font-weight: 650;
font-size: 14px;
line-height: 1.15;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ui-cell[data-multiline="true"] .ui-cell__title{
white-space: normal;
overflow: visible;
text-overflow: clip;
}
.ui-cell__subtitle{
font-size: 13px;
color: var(--ui-muted);
line-height: 1.2;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ui-cell[data-multiline="true"] .ui-cell__subtitle{
white-space: normal;
overflow: visible;
text-overflow: clip;
}
.ui-cell__after{
flex: 0 0 auto;
display: inline-flex;
align-items: center;
gap: 8px;
color: var(--ui-muted);
}
.ui-cell__chevron{
width: var(--cell-chev, 18px);
height: var(--cell-chev, 18px);
opacity: .8;
}
.ui-cell__badge{
display: inline-flex;
align-items: center;
justify-content: center;
min-width: var(--cell-badge-minw, 18px);
height: var(--cell-badge-h, 18px);
padding: 0 var(--cell-badge-px, 6px);
border-radius: 999px;
background: rgba(255,255,255,0.08);
color: var(--ui-fg);
font-size: var(--cell-badge-fs, 11px);
line-height: 1;
}
:root[data-theme="light"] .ui-cell__badge{
background: rgba(17,19,24,0.08);
color: var(--ui-fg);
}

View File

@@ -0,0 +1,2 @@
export * from "./Cell";
export * from "./RichCell";

View File

@@ -0,0 +1,14 @@
.ui-richcell__bottom{
margin-top: 8px;
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
}
.ui-richcell__caption{
margin-top: 6px;
font-size: 12px;
color: var(--ui-muted);
line-height: 1.2;
}

View File

@@ -0,0 +1,83 @@
import React from "react";
import { Button } from "../button/Button";
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 sameDay(a?: Date, b?: Date) {
if (!a || !b) return false;
return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate();
}
function toKey(d: Date) { return `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}`; }
export function Calendar(props: {
value?: Date;
onChange: (d: Date) => void;
month?: Date;
onMonthChange?: (d: Date) => void;
}) {
const controlledMonth = props.month;
const [innerMonth, setInnerMonth] = React.useState<Date>(() => startOfMonth(props.value ?? new Date()));
const month = controlledMonth ?? innerMonth;
const setMonth = (d: Date) => {
props.onMonthChange?.(d);
if (!controlledMonth) setInnerMonth(d);
};
const start = startOfMonth(month);
const end = endOfMonth(month);
const jsDow = start.getDay();
const shift = (jsDow + 6) % 7;
const days: { date: Date; muted: boolean }[] = [];
const prevMonthEnd = new Date(start.getFullYear(), start.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 <= end.getDate(); day++) {
days.push({ date: new Date(start.getFullYear(), start.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" });
return (
<div className="ui-cal">
<div className="ui-cal__head">
<Button variant="ghost" tone="neutral" size="sm" onClick={() => setMonth(addMonths(month, -1))}>
</Button>
<div className="ui-cal__title">{title}</div>
<Button variant="ghost" tone="neutral" size="sm" onClick={() => setMonth(addMonths(month, 1))}>
</Button>
</div>
<div className="ui-cal__grid">
{DOW.map((d) => <div key={d} className="ui-cal__dow">{d}</div>)}
{days.map(({ date, muted }) => (
<button
key={toKey(date)}
type="button"
className="ui-cal__day"
data-muted={muted ? "true" : "false"}
data-selected={sameDay(date, props.value) ? "true" : "false"}
onClick={() => props.onChange(date)}
>
{date.getDate()}
</button>
))}
</div>
</div>
);
}

View File

@@ -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<Date>(() => 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 (
<div className="ui-cal">
<div className="ui-cal__head">
<Button variant="ghost" tone="neutral" size="sm" onClick={() => setMonth(addMonths(month, -1))}></Button>
<div className="ui-cal__title">{title}</div>
<Button variant="ghost" tone="neutral" size="sm" onClick={() => setMonth(addMonths(month, 1))}></Button>
</div>
<div style={{ display: "flex", gap: 10, color: "var(--ui-muted)", fontSize: 12, marginBottom: 8 }}>
<div>От: {start ? start.toLocaleDateString("ru-RU") : "—"}</div>
<div>До: {end ? end.toLocaleDateString("ru-RU") : "—"}</div>
</div>
<div className="ui-cal__grid">
{DOW.map((d) => <div key={d} className="ui-cal__dow">{d}</div>)}
{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 (
<button
key={toKey(date)}
type="button"
className="ui-cal__day"
data-muted={muted ? "true" : "false"}
data-selected={selected ? "true" : "false"}
data-range={range && !selected ? "true" : "false"}
onClick={() => clickDay(date)}
>
{date.getDate()}
</button>
);
})}
</div>
</div>
);
}

View File

@@ -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 (
<div style={{ display: "grid", gap: 8 }}>
{label ? <div style={{ fontWeight: 700, fontSize: 13 }}>{label}</div> : null}
<input
type="date"
className={clsx("ui-control", className)}
value={value ?? ""}
onChange={(e) => onChange(e.target.value)}
/>
</div>
);
}

View File

@@ -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 (
<div style={{ display: "grid", gap: 8 }}>
{label ? <div style={{ fontWeight: 700, fontSize: 13 }}>{label}</div> : null}
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 10 }}>
<input
type="date"
className={clsx("ui-control")}
value={start ?? ""}
onChange={(e) => onChange({ start: e.target.value, end })}
/>
<input
type="date"
className={clsx("ui-control")}
value={end ?? ""}
onChange={(e) => onChange({ start, end: e.target.value })}
/>
</div>
</div>
);
}

View File

@@ -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);
}

View File

@@ -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 (
<RadixDialog.Portal>
<RadixDialog.Overlay className="ui-dialog__overlay" />
<RadixDialog.Content className="ui-dialog__content" {...rest}>
{title ? <RadixDialog.Title className="ui-dialog__title">{title}</RadixDialog.Title> : null}
{description ? <RadixDialog.Description className="ui-dialog__desc">{description}</RadixDialog.Description> : null}
<div style={{ marginTop: 14 }}>{children}</div>
</RadixDialog.Content>
</RadixDialog.Portal>
);
}

View File

@@ -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);
}

View File

@@ -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 (
<RadixAccordion.Root
className={clsx("ui-acc", className)}
type={type as any}
defaultValue={defaultValue as any}
collapsible={collapsible}
>
{items.map((it) => (
<RadixAccordion.Item key={it.value} className="ui-acc__item" value={it.value}>
<RadixAccordion.Header>
<RadixAccordion.Trigger className="ui-acc__trigger">
<span className="ui-acc__title">{it.title}</span>
<span className="ui-acc__chevron" aria-hidden>
{chevron ?? "▾"}
</span>
</RadixAccordion.Trigger>
</RadixAccordion.Header>
<RadixAccordion.Content className="ui-acc__content">{it.content}</RadixAccordion.Content>
</RadixAccordion.Item>
))}
</RadixAccordion.Root>
);
}

View File

@@ -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;
}

View File

@@ -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<HTMLSpanElement>;
export function Avatar({
size = 32,
src,
alt,
initials,
className,
...rest
}: AvatarProps) {
return (
<span className={clsx("ui-avatar", className)} data-size={String(size)} {...rest}>
{src ? <img src={src} alt={alt ?? ""} loading="lazy" /> : (initials ?? "?")}
</span>
);
}

View File

@@ -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; }

View File

@@ -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<HTMLSpanElement> & {
tone?: BadgeTone;
variant?: BadgeVariant;
size?: BadgeSize;
dot?: boolean;
};
export function Badge({
tone = "neutral",
variant = "soft",
size = "md",
dot,
className,
children,
...rest
}: BadgeProps) {
return (
<span
className={clsx("ui-badge", className)}
data-tone={tone}
data-variant={variant}
data-size={size}
{...rest}
>
{dot ? <span className="ui-badge__dot" aria-hidden /> : null}
{children}
</span>
);
}

View File

@@ -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;
}

View File

@@ -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 (
<div className={clsx("ui-banner", className)} data-tone={tone}>
{icon ? <div className="ui-banner__icon" aria-hidden>{icon}</div> : null}
<div className="ui-banner__body">
<h4 className="ui-banner__title">{title}</h4>
{description ? <p className="ui-banner__desc">{description}</p> : null}
{actions ? <div className="ui-banner__actions">{actions}</div> : null}
</div>
</div>
);
}

View File

@@ -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);
}

View File

@@ -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 (
<div className={clsx("ui-contentbadge", className)} data-pos={position}>
<Badge {...rest} />
</div>
);
}

View File

@@ -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; }

View File

@@ -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 (
<Comp
className={clsx("ui-contentcard", className)}
data-clickable={clickable ? "true" : "false"}
href={href}
onClick={onClick}
>
{media ? (
<div className="ui-contentcard__media">
<ImageBase src={media.src} alt={media.alt} ratio={media.ratio ?? (16 / 9)} overlay={media.overlay} />
</div>
) : null}
<div className="ui-contentcard__body">
<div className="ui-contentcard__title">{title}</div>
{subtitle ? <div className="ui-contentcard__subtitle">{subtitle}</div> : null}
{meta ? <div className="ui-contentcard__meta">{meta}</div> : null}
</div>
</Comp>
);
}

View File

@@ -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; }

View File

@@ -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<HTMLSpanElement> & {
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 (
<span
className={clsx("ui-counter", className)}
data-tone={tone}
data-variant={variant}
data-size={size}
{...rest}
>
{text}
</span>
);
}

View File

@@ -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);
}

View File

@@ -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 (
<div className={clsx("ui-gallery", className)} data-cols={String(cols)}>
{items.map((it) => (
<div key={it.id} onClick={() => onItemClick?.(it.id)} style={{ cursor: onItemClick ? "pointer" : "default" }}>
<ImageBase src={it.src} alt={it.alt} ratio={ratio} />
</div>
))}
</div>
);
}

View File

@@ -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)); }
}

View File

@@ -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 (
<div className={clsx("ui-gridavatar", className)} data-cols={String(cols)}>
{items.map((it) => (
<div key={it.id} className="ui-gridavatar__cell">
<Avatar size={size} src={it.src} initials={it.initials} />
<div className="ui-gridavatar__label">
<div className="ui-gridavatar__title">{it.title}</div>
{it.subtitle ? <div className="ui-gridavatar__subtitle">{it.subtitle}</div> : null}
</div>
</div>
))}
</div>
);
}

View File

@@ -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); }

View File

@@ -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 (
<Comp
className={clsx("ui-hcell", className)}
data-clickable={clickable ? "true" : "false"}
href={href}
onClick={onClick}
style={{ ["--ui-hcell-w" as any]: `${width}px` } as React.CSSProperties}
>
{image ? (
<div className="ui-hcell__media">
<ImageBase src={image.src} alt={image.alt} ratio={image.ratio ?? 1} />
</div>
) : null}
<div className="ui-hcell__body">
<div className="ui-hcell__title">{title}</div>
{subtitle ? <div className="ui-hcell__subtitle">{subtitle}</div> : null}
</div>
</Comp>
);
}

View File

@@ -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); }

View File

@@ -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 (
<div className={clsx("ui-hscroll", className)} style={{ ["--ui-hscroll-gap" as any]: `${gap}px` } as React.CSSProperties}>
<div className="ui-hscroll__inner">{children}</div>
</div>
);
}

View File

@@ -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); }

View File

@@ -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 (
<div
className={clsx("ui-imagebase", className)}
style={
{
...style,
["--ui-image-ratio" as any]: paddingBottom,
["--ui-image-fit" as any]: fit,
} as React.CSSProperties
}
>
<div className="ui-imagebase__ratio" />
<img src={src} alt={alt ?? ""} loading="lazy" />
{overlay ? <div className="ui-imagebase__overlay">{overlay}</div> : null}
</div>
);
}

View File

@@ -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;
}

View File

@@ -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 <ImageBase {...props} />;
}

View File

@@ -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";

View File

@@ -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 (
<div className={clsx("ui-inforow", className)}>
<div className="ui-inforow__label">{label}</div>
<div className="ui-inforow__value">{value}</div>
</div>
);
}

View File

@@ -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; }

View File

@@ -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 (
<Comp
className={clsx("ui-minicell", className)}
data-clickable={clickable ? "true" : "false"}
href={href}
onClick={onClick}
>
{before ? <div className="ui-minicell__before" aria-hidden>{before}</div> : null}
<div className="ui-minicell__body">
<div className="ui-minicell__title">{title}</div>
{subtitle ? <div className="ui-minicell__subtitle">{subtitle}</div> : null}
</div>
{after ? <div className="ui-minicell__after">{after}</div> : null}
</Comp>
);
}

View File

@@ -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); }

View File

@@ -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 (
<div className={clsx("ui-placeholder", className)}>
{icon ? <div className="ui-placeholder__icon" aria-hidden>{icon}</div> : null}
<h3 className="ui-placeholder__title">{title}</h3>
{description ? <p className="ui-placeholder__desc">{description}</p> : null}
{actions ? <div className="ui-placeholder__actions">{actions}</div> : null}
</div>
);
}

View File

@@ -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; }

View File

@@ -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 (
<Comp
className={clsx("ui-richcell", className)}
data-clickable={clickable ? "true" : "false"}
href={href}
onClick={onClick}
>
{before ? <div className="ui-richcell__before" aria-hidden>{before}</div> : null}
<div className="ui-richcell__body">
<div>
<div className="ui-richcell__title">{title}</div>
{subtitle ? <div className="ui-richcell__subtitle">{subtitle}</div> : null}
</div>
{bottom ? <div className="ui-richcell__bottom">{bottom}</div> : null}
</div>
{after ? <div className="ui-richcell__after">{after}</div> : null}
</Comp>
);
}

View File

@@ -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); }

View File

@@ -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 (
<div className={clsx("ui-usersstack", className)}>
{visible.map((u) => (
<span key={u.id} className="ui-usersstack__item">
<Avatar size={size} src={u.src} alt={u.alt} initials={u.initials} />
</span>
))}
{rest > 0 ? <span className="ui-usersstack__more">+{rest}</span> : null}
</div>
);
}

View File

@@ -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); }

View File

@@ -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 (
<div className="ui-alert" data-variant={variant}>
<div style={{ display: "flex", gap: 12, justifyContent: "space-between", alignItems: "flex-start" }}>
<div>
<div className="ui-alert__title">{title}</div>
{description ? <div className="ui-alert__desc">{description}</div> : null}
</div>
{right ? <div>{right}</div> : null}
</div>
</div>
);
}

View File

@@ -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 (
<div className="ui-progress" role="progressbar" aria-valuenow={value} aria-valuemin={0} aria-valuemax={max}>
<div className="ui-progress__bar" style={{ width: pct + "%" }} />
</div>
);
}

View File

@@ -0,0 +1,95 @@
import React from "react";
// import "./pull.css";
export function PullToRefresh(props: React.PropsWithChildren<{
onRefresh: () => Promise<void> | 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<HTMLDivElement | null>(null);
const [pull, setPull] = React.useState(0);
const [loading, setLoading] = React.useState(false);
const startY = React.useRef<number | null>(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 (
<div
ref={ref}
className={className ? `ui-pull ${className}` : "ui-pull"}
style={style}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={end}
onPointerCancel={end}
>
<div className="ui-pull__indicator" style={{ height: pull }}>
<div className="ui-pull__pill">
{loading ? labelLoading : pull >= thresholdPx ? labelRelease : labelPull}
</div>
</div>
<div style={{ paddingTop: pull }}>{children}</div>
</div>
);
}

View File

@@ -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 <div className={clsx("ui-skeleton", props.className)} style={style} aria-hidden />;
}

View File

@@ -0,0 +1 @@
export { ToastProvider as SnackbarProvider, useToast as useSnackbar } from "../toast/ToastProvider";

View File

@@ -0,0 +1,19 @@
import React from "react";
// import "./spinner.css";
export function Spinner() {
return <div className="ui-spinner" aria-label="Loading" />;
}
export function ScreenSpinner(props: { visible: boolean; label?: string }) {
if (!props.visible) return null;
return (
<div className="ui-screenspinner" role="status" aria-live="polite">
<div style={{ display: "grid", gap: 10, placeItems: "center" }}>
<Spinner />
{props.label ? <div style={{ color: "white", fontWeight: 700 }}>{props.label}</div> : null}
</div>
</div>
);
}

View File

@@ -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); }

View File

@@ -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;
}

View File

@@ -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);
}

25
src/components/feedback/skeleton.css vendored Normal file
View File

@@ -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%; }
}

View File

@@ -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;
}

View File

@@ -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 (
<div
className="ui-check"
data-checked={checked ? "true" : "false"}
aria-disabled={disabled ? "true" : "false"}
onClick={() => {
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);
}
}}
>
<span className="ui-check__box" aria-hidden>
<span className="ui-check__mark" />
</span>
<span>
{label ? <div className="ui-check__label">{label}</div> : null}
{description ? <div className="ui-check__desc">{description}</div> : null}
</span>
</div>
);
}

View File

@@ -0,0 +1,16 @@
import React from "react";
// import "./chips.css";
export function Chip(props: { label: string; onRemove?: () => void }) {
return (
<span className="ui-chip">
<span>{props.label}</span>
{props.onRemove ? (
<button type="button" className="ui-chip__x" onClick={props.onRemove} aria-label="Remove">
×
</button>
) : null}
</span>
);
}

View File

@@ -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 (
<div style={{ display: "grid", gap: 8 }}>
{label ? <div style={{ fontWeight: 700, fontSize: 13 }}>{label}</div> : null}
<div className="ui-chipsbox">
<div className="ui-chiprow">
{value.map((v) => (
<Chip key={v} label={v} onRemove={() => onChange(value.filter((x) => x !== v))} />
))}
<input
className="ui-chipsinput__input"
value={text}
placeholder={placeholder}
onChange={(e) => 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));
}
}}
/>
</div>
<div style={{ color: "var(--ui-muted)", fontSize: 12 }}>
Подсказка: Enter добавить, Backspace удалить последний.
</div>
</div>
</div>
);
}

View File

@@ -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 (
<div style={{ display: "grid", gap: 8 }}>
{label ? <div style={{ fontWeight: 700, fontSize: 13 }}>{label}</div> : null}
<div className="ui-chipsbox">
<div className="ui-chiprow">
{value.map((k) => (
<Chip
key={k}
label={options.find((o) => o.key === k)?.label ?? k}
onRemove={() => onChange(value.filter((x) => x !== k))}
/>
))}
</div>
<SelectMimicry
valueKey={undefined}
onChange={(k) => onChange([...value, k])}
options={available}
placeholder={available.length ? "Добавить..." : "Все добавлено"}
/>
</div>
</div>
);
}

View File

@@ -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 (
<div style={{ display: "grid", gap: 8 }}>
{label ? <div style={{ fontWeight: 700, fontSize: 13 }}>{label}</div> : null}
<RadixSelect.Root value={value} onValueChange={onChange}>
<RadixSelect.Trigger className="ui-customselect__trigger">
<RadixSelect.Value placeholder={placeholder ?? "Выберите..."} />
</RadixSelect.Trigger>
<RadixSelect.Portal>
<RadixSelect.Content className="ui-customselect__content" position="popper" sideOffset={8}>
<RadixSelect.Viewport>
{options.map((o) => (
<RadixSelect.Item key={o.value} value={o.value} disabled={o.disabled} className="ui-customselect__item">
<RadixSelect.ItemText>{o.label}</RadixSelect.ItemText>
</RadixSelect.Item>
))}
</RadixSelect.Viewport>
</RadixSelect.Content>
</RadixSelect.Portal>
</RadixSelect.Root>
{error ? <div className="ui-control__error">{error}</div> : hint ? <div className="ui-control__hint">{hint}</div> : null}
</div>
);
}

View File

@@ -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<HTMLInputElement | null>(null);
return (
<div
className="ui-drop"
data-over={over ? "true" : "false"}
onDragEnter={(e) => {
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}
>
<input
ref={inputRef}
type="file"
style={{ display: "none" }}
onChange={(e) => {
const files = e.target.files;
if (!files || files.length === 0) return;
onFiles(files);
}}
/>
<div className="ui-drop__title">{title}</div>
<div className="ui-drop__hint">{hint}</div>
</div>
);
}

View File

@@ -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<HTMLInputElement | null>(null);
const [name, setName] = React.useState<string>("");
return (
<div className="ui-file">
{label ? <div className="ui-file__label">{label}</div> : null}
<input
ref={inputRef}
type="file"
accept={accept}
multiple={multiple}
style={{ display: "none" }}
onChange={(e) => {
const files = e.target.files;
if (!files || files.length === 0) return;
setName(multiple ? `${files.length} файлов` : files[0].name);
onFiles(files);
}}
/>
<button
type="button"
className="ui-file__button"
onClick={() => inputRef.current?.click()}
>
<span>Выбрать файл</span>
<span className="ui-file__name">{name || "Не выбрано"}</span>
</button>
</div>
);
}

View File

@@ -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 (
<div style={{ display: "grid", gap: 8 }}>
{label ? <div style={{ fontWeight: 700, fontSize: 13 }}>{label}</div> : null}
{children}
{error ? (
<div style={{ color: "color-mix(in oklab, var(--ui-danger) 85%, white)", fontSize: 12 }}>{error}</div>
) : hint ? (
<div style={{ color: "var(--ui-muted)", fontSize: 12 }}>{hint}</div>
) : null}
</div>
);
}

View File

@@ -0,0 +1,5 @@
import React from "react";
export function FormItem(props: React.PropsWithChildren) {
return <div style={{ display: "grid", gap: 12 }}>{props.children}</div>;
}

View File

@@ -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 <Alert variant={variant} title={title} description={description} />;
}

View File

@@ -0,0 +1,43 @@
import React from "react";
import clsx from "clsx";
// import "../_shared/control.css";
// import "./input.css";
export type InputProps = React.InputHTMLAttributes<HTMLInputElement> & {
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 (
<div className="ui-input__wrap">
{label ? <div className="ui-input__label">{label}</div> : null}
<div className="ui-input__fieldWrap">
{hasLeft ? <span className="ui-input__iconLeft">{leftIcon}</span> : null}
{hasRight ? <span className="ui-input__iconRight">{rightIcon}</span> : null}
<input
className={clsx(
"ui-control",
hasLeft && "ui-input__withLeft",
hasRight && "ui-input__withRight",
className
)}
{...rest}
/>
</div>
{error ? <div className="ui-control__error">{error}</div> : hint ? <div className="ui-control__hint">{hint}</div> : null}
</div>
);
}

View File

@@ -0,0 +1,23 @@
import React from "react";
import clsx from "clsx";
// import "../_shared/control.css";
export function NativeSelect(
props: React.SelectHTMLAttributes<HTMLSelectElement> & {
label?: React.ReactNode;
hint?: React.ReactNode;
error?: React.ReactNode;
}
) {
const { label, hint, error, className, children, ...rest } = props;
return (
<div style={{ display: "grid", gap: 8 }}>
{label ? <div style={{ fontWeight: 700, fontSize: 13 }}>{label}</div> : null}
<select className={clsx("ui-control", className)} {...rest}>
{children}
</select>
{error ? <div className="ui-control__error">{error}</div> : hint ? <div className="ui-control__hint">{hint}</div> : null}
</div>
);
}

View File

@@ -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 (
<div
className="ui-check"
data-checked={checked ? "true" : "false"}
aria-disabled={disabled ? "true" : "false"}
onClick={() => {
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();
}
}}
>
<span className="ui-check__box" aria-hidden style={{ borderRadius: 999 }}>
<span className="ui-check__mark" style={{ borderRadius: 999 }} />
</span>
<span>
{label ? <div className="ui-check__label">{label}</div> : null}
{description ? <div className="ui-check__desc">{description}</div> : null}
</span>
</div>
);
}

View File

@@ -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 (
<div className="ui-segmented" role="tablist" aria-label="Segmented control">
{items.map((it) => {
const active = it.key === value;
return (
<button
key={it.key}
type="button"
className="ui-segmented__btn"
data-active={active ? "true" : "false"}
disabled={it.disabled}
onClick={() => !it.disabled && onChange(it.key)}
>
{it.label}
</button>
);
})}
</div>
);
}

View File

@@ -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 (
<div style={{ display: "grid", gap: 8 }}>
{label ? <div style={{ fontWeight: 700, fontSize: 13 }}>{label}</div> : null}
<Popover.Root>
<Popover.Trigger asChild>
<button type="button" className={clsx("ui-control", "ui-mimic__trigger", className)}>
<span style={{ opacity: selected ? 1 : 0.7 }}>{selected?.label ?? placeholder}</span>
<span className="ui-mimic__chev"></span>
</button>
</Popover.Trigger>
<Popover.Portal>
<Popover.Content className="ui-mimic__content" sideOffset={8} align="start">
{options.map((o) => (
<button
key={o.key}
type="button"
className="ui-mimic__item"
disabled={o.disabled}
onClick={() => {
if (o.disabled) return;
onChange(o.key);
}}
>
<div style={{ fontWeight: 700, fontSize: 13 }}>{o.label}</div>
{o.description ? <div style={{ color: "var(--ui-muted)", fontSize: 12, marginTop: 2 }}>{o.description}</div> : null}
</button>
))}
</Popover.Content>
</Popover.Portal>
</Popover.Root>
</div>
);
}

View File

@@ -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 (
<div className="ui-slider">
<div className="ui-slider__row">
{label ? <div className="ui-slider__label">{label}</div> : <div />}
<div className="ui-slider__value">{(formatValue ?? String)(value)}</div>
</div>
<input
type="range"
value={value}
min={min}
max={max}
step={step}
onChange={(e) => onChange(Number(e.target.value))}
/>
</div>
);
}

View File

@@ -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 (
<div
className="ui-switch"
data-checked={checked ? "true" : "false"}
aria-disabled={disabled ? "true" : "false"}
onClick={() => {
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);
}
}}
>
<span className="ui-switch__track" aria-hidden>
<span className="ui-switch__thumb" />
</span>
{label ? <span className="ui-switch__label">{label}</span> : null}
</div>
);
}

View File

@@ -0,0 +1,26 @@
import React from "react";
import clsx from "clsx";
// import "../_shared/control.css";
export type TextareaProps = React.TextareaHTMLAttributes<HTMLTextAreaElement> & {
label?: React.ReactNode;
hint?: React.ReactNode;
error?: React.ReactNode;
};
export function Textarea(props: TextareaProps) {
const { label, hint, error, className, rows = 4, ...rest } = props;
return (
<div style={{ display: "grid", gap: 8 }}>
{label ? <div style={{ fontWeight: 700, fontSize: 13 }}>{label}</div> : null}
<textarea
className={clsx("ui-control", className)}
rows={rows}
style={{ height: "auto", paddingTop: 10, paddingBottom: 10, resize: "vertical" }}
{...rest}
/>
{error ? <div className="ui-control__error">{error}</div> : hint ? <div className="ui-control__hint">{hint}</div> : null}
</div>
);
}

View File

@@ -0,0 +1,38 @@
import React from "react";
import { Button } from "../button/Button";
// import "./writebar.css";
export function WriteBar(props: {
value: string;
onChange: (v: string) => void;
onSend: () => void;
placeholder?: string;
disabled?: boolean;
}) {
const { value, onChange, onSend, placeholder = "Сообщение...", disabled } = props;
return (
<div className="ui-writebar">
<div className="ui-writebar__inner">
<textarea
className="ui-writebar__input"
value={value}
placeholder={placeholder}
disabled={disabled}
onChange={(e) => onChange(e.target.value)}
rows={1}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
if (!disabled) onSend();
}
}}
/>
<Button variant="solid" tone="primary" disabled={disabled || value.trim().length === 0} onClick={onSend}>
Отправить
</Button>
</div>
</div>
);
}

View File

@@ -0,0 +1,52 @@
.ui-check{
display: inline-flex;
align-items: center;
gap: 10px;
cursor: pointer;
user-select: none;
}
.ui-check input{ display: none; }
.ui-check__box{
width: 20px;
height: 20px;
border-radius: 6px;
border: 1px solid var(--ui-border);
background: var(--ui-panel);
display: inline-flex;
align-items: center;
justify-content: center;
transition: background .12s ease, border-color .12s ease;
}
.ui-check[data-checked="true"] .ui-check__box{
background: var(--ui-primary);
border-color: color-mix(in oklab, var(--ui-primary) 70%, var(--ui-border));
}
.ui-check__mark{
width: 10px;
height: 10px;
border-radius: 3px;
background: white;
opacity: 0;
transform: scale(.85);
transition: opacity .12s ease, transform .12s ease;
}
.ui-check[data-checked="true"] .ui-check__mark{
opacity: 1;
transform: scale(1);
}
.ui-check__label{
color: var(--ui-fg);
font-size: 13px;
}
.ui-check__desc{
color: var(--ui-muted);
font-size: 12px;
margin-top: 2px;
}
.ui-check[aria-disabled="true"]{
opacity: .6;
cursor: not-allowed;
}

View File

@@ -0,0 +1,49 @@
.ui-chiprow{
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
}
.ui-chip{
display: inline-flex;
align-items: center;
gap: 8px;
padding: 7px 10px;
border-radius: 999px;
border: 1px solid var(--ui-border);
background: rgba(255,255,255,0.04);
color: var(--ui-fg);
font-size: 13px;
}
:root[data-theme="light"] .ui-chip{
background: rgba(17,19,24,0.04);
}
.ui-chip__x{
border: 0;
background: transparent;
color: var(--ui-muted);
cursor: pointer;
padding: 0;
line-height: 1;
}
.ui-chipsinput__input{
height: 32px;
min-width: 120px;
border: 0;
outline: none;
background: transparent;
color: var(--ui-fg);
font: inherit;
}
.ui-chipsbox{
border: 1px solid var(--ui-border);
border-radius: var(--ui-radius);
background: var(--ui-panel);
padding: 10px;
display: grid;
gap: 8px;
}

View File

@@ -0,0 +1,36 @@
@import "../_shared/control.css";
.ui-customselect__trigger{
height: 40px;
padding: 0 12px;
border-radius: var(--ui-radius);
border: 1px solid var(--ui-border);
background: var(--ui-panel);
color: var(--ui-fg);
display: inline-flex;
align-items: center;
justify-content: space-between;
gap: 10px;
width: 100%;
}
.ui-customselect__content{
background: var(--ui-panel);
border: 1px solid var(--ui-border);
border-radius: var(--ui-radius);
box-shadow: var(--ui-shadow);
overflow: hidden;
min-width: 220px;
}
.ui-customselect__item{
padding: 10px 12px;
cursor: pointer;
}
.ui-customselect__item[data-highlighted]{
outline: none;
background: rgba(255,255,255,0.06);
}
:root[data-theme="light"] .ui-customselect__item[data-highlighted]{
background: rgba(17,19,24,0.06);
}

View File

@@ -0,0 +1,23 @@
.ui-drop{
border: 1px dashed var(--ui-border);
border-radius: var(--ui-radius);
background: rgba(255,255,255,0.02);
padding: 14px;
display: grid;
gap: 6px;
text-align: center;
}
:root[data-theme="light"] .ui-drop{
background: rgba(17,19,24,0.02);
}
.ui-drop[data-over="true"]{
border-color: color-mix(in oklab, var(--ui-primary) 55%, var(--ui-border));
box-shadow: var(--ui-focus);
}
.ui-drop__title{
font-weight: 800;
}
.ui-drop__hint{
color: var(--ui-muted);
font-size: 12px;
}

View File

@@ -0,0 +1,24 @@
.ui-file{
display: grid;
gap: 8px;
}
.ui-file__label{
font-weight: 700;
font-size: 13px;
}
.ui-file__button{
height: 40px;
padding: 0 12px;
border-radius: var(--ui-radius);
border: 1px dashed var(--ui-border);
background: transparent;
color: var(--ui-fg);
cursor: pointer;
display: flex;
align-items: center;
justify-content: space-between;
}
.ui-file__name{
color: var(--ui-muted);
font-size: 12px;
}

View File

@@ -0,0 +1,38 @@
@import "../_shared/control.css";
.ui-input{
}
.ui-input__wrap{
display: grid;
gap: 8px;
}
.ui-input__label{
font-weight: 700;
font-size: 13px;
}
.ui-input__fieldWrap{
position: relative;
}
.ui-input__iconLeft, .ui-input__iconRight{
position: absolute;
top: 50%;
transform: translateY(-50%);
color: var(--ui-muted);
display: inline-flex;
align-items: center;
justify-content: center;
width: 34px;
height: 34px;
pointer-events: none;
}
.ui-input__iconLeft{ left: 8px; }
.ui-input__iconRight{ right: 8px; }
.ui-input__withLeft{ padding-left: 42px; }
.ui-input__withRight{ padding-right: 42px; }

View File

@@ -0,0 +1,24 @@
.ui-segmented{
display: inline-flex;
border: 1px solid var(--ui-border);
border-radius: calc(var(--ui-radius) + 6px);
background: var(--ui-panel);
padding: 4px;
gap: 4px;
}
.ui-segmented__btn{
height: 34px;
padding: 0 12px;
border-radius: var(--ui-radius);
border: 0;
background: transparent;
color: var(--ui-muted);
cursor: pointer;
}
.ui-segmented__btn[data-active="true"]{
background: rgba(255,255,255,0.08);
color: var(--ui-fg);
}
:root[data-theme="light"] .ui-segmented__btn[data-active="true"]{
background: rgba(17,19,24,0.08);
}

View File

@@ -0,0 +1,40 @@
.ui-mimic__trigger{
display: flex;
align-items: center;
justify-content: space-between;
cursor: pointer;
}
.ui-mimic__chev{
color: var(--ui-muted);
}
.ui-mimic__content{
width: min(520px, calc(100vw - 24px));
background: var(--ui-panel);
border: 1px solid var(--ui-border);
border-radius: var(--ui-radius);
box-shadow: var(--ui-shadow);
padding: 6px;
}
.ui-mimic__item{
width: 100%;
border: 0;
background: transparent;
color: var(--ui-fg);
text-align: left;
padding: 10px 10px;
border-radius: calc(var(--ui-radius) - 2px);
cursor: pointer;
}
.ui-mimic__item:hover{
background: rgba(255,255,255,0.06);
}
:root[data-theme="light"] .ui-mimic__item:hover{
background: rgba(17,19,24,0.06);
}
.ui-mimic__item:disabled{
opacity: .55;
cursor: not-allowed;
}

View File

@@ -0,0 +1,22 @@
.ui-slider{
display: grid;
gap: 8px;
}
.ui-slider__row{
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.ui-slider__label{
font-weight: 700;
font-size: 13px;
}
.ui-slider__value{
color: var(--ui-muted);
font-size: 12px;
}
.ui-slider input[type="range"]{
width: 100%;
accent-color: var(--ui-primary);
}

View File

@@ -0,0 +1,46 @@
.ui-switch{
display: inline-flex;
align-items: center;
gap: 12px;
cursor: pointer;
user-select: none;
}
.ui-switch__track{
width: 46px;
height: 26px;
border-radius: 999px;
background: rgba(255,255,255,0.10);
border: 1px solid var(--ui-border);
position: relative;
transition: background .12s ease, border-color .12s ease;
}
:root[data-theme="light"] .ui-switch__track{
background: rgba(17,19,24,0.08);
}
.ui-switch[data-checked="true"] .ui-switch__track{
background: color-mix(in oklab, var(--ui-primary) 75%, black);
border-color: color-mix(in oklab, var(--ui-primary) 60%, var(--ui-border));
}
.ui-switch__thumb{
width: 22px;
height: 22px;
border-radius: 999px;
background: var(--ui-panel);
position: absolute;
top: 50%;
transform: translateY(-50%);
left: 2px;
transition: left .12s ease;
box-shadow: 0 6px 20px rgba(0,0,0,.18);
}
.ui-switch[data-checked="true"] .ui-switch__thumb{
left: 22px;
}
.ui-switch__label{
font-size: 13px;
color: var(--ui-fg);
}
.ui-switch[aria-disabled="true"]{
opacity: .6;
cursor: not-allowed;
}

View File

@@ -0,0 +1,30 @@
.ui-writebar{
position: fixed;
left: 0;
right: 0;
bottom: 0;
padding-bottom: var(--safe-bottom);
background: color-mix(in oklab, var(--ui-bg) 92%, transparent);
backdrop-filter: blur(10px);
border-top: 1px solid var(--ui-border);
z-index: 70;
}
.ui-writebar__inner{
display: flex;
gap: 10px;
align-items: flex-end;
padding: 10px 12px;
}
.ui-writebar__input{
flex: 1;
min-height: 40px;
max-height: 120px;
height: auto;
padding: 10px 12px;
border-radius: var(--ui-radius);
border: 1px solid var(--ui-border);
background: var(--ui-panel);
color: var(--ui-fg);
outline: none;
resize: none;
}

Some files were not shown because too many files have changed in this diff Show More