first commit
This commit is contained in:
BIN
.gitignore
vendored
Normal file
BIN
.gitignore
vendored
Normal file
Binary file not shown.
38
package.json
Normal file
38
package.json
Normal 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
50
package_pruduction.json
Normal 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
2
src/auto.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
import "./styles/index.css";
|
||||||
|
export * from "./index";
|
||||||
38
src/components/_shared/control.css
Normal file
38
src/components/_shared/control.css
Normal 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;
|
||||||
|
}
|
||||||
28
src/components/button/Button.tsx
Normal file
28
src/components/button/Button.tsx
Normal 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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
66
src/components/button/button.css
Normal file
66
src/components/button/button.css
Normal 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);
|
||||||
|
}
|
||||||
18
src/components/card-grid/CardGrid.tsx
Normal file
18
src/components/card-grid/CardGrid.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
12
src/components/card-grid/card-grid.css
Normal file
12
src/components/card-grid/card-grid.css
Normal 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; }
|
||||||
|
}
|
||||||
55
src/components/card/Card.tsx
Normal file
55
src/components/card/Card.tsx
Normal 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>;
|
||||||
|
}
|
||||||
61
src/components/card/card.css
Normal file
61
src/components/card/card.css
Normal 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);
|
||||||
|
}
|
||||||
87
src/components/cell/Cell.tsx
Normal file
87
src/components/cell/Cell.tsx
Normal 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;
|
||||||
72
src/components/cell/RichCell.tsx
Normal file
72
src/components/cell/RichCell.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
127
src/components/cell/cell.css
Normal file
127
src/components/cell/cell.css
Normal 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);
|
||||||
|
}
|
||||||
2
src/components/cell/index.ts
Normal file
2
src/components/cell/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export * from "./Cell";
|
||||||
|
export * from "./RichCell";
|
||||||
14
src/components/cell/richcell.css
Normal file
14
src/components/cell/richcell.css
Normal 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;
|
||||||
|
}
|
||||||
83
src/components/dates/Calendar.tsx
Normal file
83
src/components/dates/Calendar.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
98
src/components/dates/CalendarRange.tsx
Normal file
98
src/components/dates/CalendarRange.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
24
src/components/dates/DateInput.tsx
Normal file
24
src/components/dates/DateInput.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
32
src/components/dates/DateRangeInput.tsx
Normal file
32
src/components/dates/DateRangeInput.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
63
src/components/dates/calendar.css
Normal file
63
src/components/dates/calendar.css
Normal 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);
|
||||||
|
}
|
||||||
28
src/components/dialog/Dialog.tsx
Normal file
28
src/components/dialog/Dialog.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
32
src/components/dialog/dialog.css
Normal file
32
src/components/dialog/dialog.css
Normal 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);
|
||||||
|
}
|
||||||
45
src/components/display/accordion/Accordion.tsx
Normal file
45
src/components/display/accordion/Accordion.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
36
src/components/display/accordion/accordion.css
Normal file
36
src/components/display/accordion/accordion.css
Normal 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;
|
||||||
|
}
|
||||||
29
src/components/display/avatar/Avatar.tsx
Normal file
29
src/components/display/avatar/Avatar.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
26
src/components/display/avatar/avatar.css
Normal file
26
src/components/display/avatar/avatar.css
Normal 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; }
|
||||||
38
src/components/display/badge/Badge.tsx
Normal file
38
src/components/display/badge/Badge.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
58
src/components/display/badge/badge.css
Normal file
58
src/components/display/badge/badge.css
Normal 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;
|
||||||
|
}
|
||||||
27
src/components/display/banner/Banner.tsx
Normal file
27
src/components/display/banner/Banner.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
29
src/components/display/banner/banner.css
Normal file
29
src/components/display/banner/banner.css
Normal 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);
|
||||||
|
}
|
||||||
16
src/components/display/content-badge/ContentBadge.tsx
Normal file
16
src/components/display/content-badge/ContentBadge.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
9
src/components/display/content-badge/content-badge.css
Normal file
9
src/components/display/content-badge/content-badge.css
Normal 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; }
|
||||||
40
src/components/display/content-card/ContentCard.tsx
Normal file
40
src/components/display/content-card/ContentCard.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
18
src/components/display/content-card/content-card.css
Normal file
18
src/components/display/content-card/content-card.css
Normal 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; }
|
||||||
39
src/components/display/counter/Counter.tsx
Normal file
39
src/components/display/counter/Counter.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
31
src/components/display/counter/counter.css
Normal file
31
src/components/display/counter/counter.css
Normal 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);
|
||||||
|
}
|
||||||
31
src/components/display/gallery/Gallery.tsx
Normal file
31
src/components/display/gallery/Gallery.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
11
src/components/display/gallery/gallery.css
Normal file
11
src/components/display/gallery/gallery.css
Normal 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)); }
|
||||||
|
}
|
||||||
35
src/components/display/grid-avatar/GridAvatar.tsx
Normal file
35
src/components/display/grid-avatar/GridAvatar.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
12
src/components/display/grid-avatar/grid-avatar.css
Normal file
12
src/components/display/grid-avatar/grid-avatar.css
Normal 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); }
|
||||||
40
src/components/display/horizontal-cell/HorizontalCell.tsx
Normal file
40
src/components/display/horizontal-cell/HorizontalCell.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
19
src/components/display/horizontal-cell/horizontal-cell.css
Normal file
19
src/components/display/horizontal-cell/horizontal-cell.css
Normal 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); }
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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); }
|
||||||
36
src/components/display/image-base/ImageBase.tsx
Normal file
36
src/components/display/image-base/ImageBase.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
31
src/components/display/image-base/image-base.css
Normal file
31
src/components/display/image-base/image-base.css
Normal 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;
|
||||||
|
}
|
||||||
14
src/components/display/image/Image.tsx
Normal file
14
src/components/display/image/Image.tsx
Normal 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} />;
|
||||||
|
}
|
||||||
17
src/components/display/index.ts
Normal file
17
src/components/display/index.ts
Normal 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";
|
||||||
18
src/components/display/info-row/InfoRow.tsx
Normal file
18
src/components/display/info-row/InfoRow.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
8
src/components/display/info-row/info-row.css
Normal file
8
src/components/display/info-row/info-row.css
Normal 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; }
|
||||||
34
src/components/display/mini-info-cell/MiniInfoCell.tsx
Normal file
34
src/components/display/mini-info-cell/MiniInfoCell.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
17
src/components/display/mini-info-cell/mini-info-cell.css
Normal file
17
src/components/display/mini-info-cell/mini-info-cell.css
Normal 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); }
|
||||||
22
src/components/display/placeholder/Placeholder.tsx
Normal file
22
src/components/display/placeholder/Placeholder.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
25
src/components/display/placeholder/placeholder.css
Normal file
25
src/components/display/placeholder/placeholder.css
Normal 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; }
|
||||||
38
src/components/display/rich-cell/RichCell.tsx
Normal file
38
src/components/display/rich-cell/RichCell.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
18
src/components/display/rich-cell/rich-cell.css
Normal file
18
src/components/display/rich-cell/rich-cell.css
Normal 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); }
|
||||||
35
src/components/display/users-stack/UsersStack.tsx
Normal file
35
src/components/display/users-stack/UsersStack.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
24
src/components/display/users-stack/users-stack.css
Normal file
24
src/components/display/users-stack/users-stack.css
Normal 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); }
|
||||||
20
src/components/feedback/Alert.tsx
Normal file
20
src/components/feedback/Alert.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
13
src/components/feedback/Progress.tsx
Normal file
13
src/components/feedback/Progress.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
95
src/components/feedback/PullToRefresh.tsx
Normal file
95
src/components/feedback/PullToRefresh.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
13
src/components/feedback/Skeleton.tsx
Normal file
13
src/components/feedback/Skeleton.tsx
Normal 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 />;
|
||||||
|
}
|
||||||
1
src/components/feedback/Snackbar.tsx
Normal file
1
src/components/feedback/Snackbar.tsx
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { ToastProvider as SnackbarProvider, useToast as useSnackbar } from "../toast/ToastProvider";
|
||||||
19
src/components/feedback/Spinner.tsx
Normal file
19
src/components/feedback/Spinner.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
16
src/components/feedback/alert.css
Normal file
16
src/components/feedback/alert.css
Normal 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); }
|
||||||
15
src/components/feedback/progress.css
Normal file
15
src/components/feedback/progress.css
Normal 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;
|
||||||
|
}
|
||||||
28
src/components/feedback/pull.css
Normal file
28
src/components/feedback/pull.css
Normal 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
25
src/components/feedback/skeleton.css
vendored
Normal 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%; }
|
||||||
|
}
|
||||||
27
src/components/feedback/spinner.css
Normal file
27
src/components/feedback/spinner.css
Normal 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;
|
||||||
|
}
|
||||||
44
src/components/forms/Checkbox.tsx
Normal file
44
src/components/forms/Checkbox.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
16
src/components/forms/Chip.tsx
Normal file
16
src/components/forms/Chip.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
56
src/components/forms/ChipsInput.tsx
Normal file
56
src/components/forms/ChipsInput.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
41
src/components/forms/ChipsSelect.tsx
Normal file
41
src/components/forms/ChipsSelect.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
44
src/components/forms/CustomSelect.tsx
Normal file
44
src/components/forms/CustomSelect.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
56
src/components/forms/DropZone.tsx
Normal file
56
src/components/forms/DropZone.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
41
src/components/forms/File.tsx
Normal file
41
src/components/forms/File.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
16
src/components/forms/FormField.tsx
Normal file
16
src/components/forms/FormField.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
5
src/components/forms/FormItem.tsx
Normal file
5
src/components/forms/FormItem.tsx
Normal 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>;
|
||||||
|
}
|
||||||
12
src/components/forms/FormStatus.tsx
Normal file
12
src/components/forms/FormStatus.tsx
Normal 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} />;
|
||||||
|
}
|
||||||
43
src/components/forms/Input.tsx
Normal file
43
src/components/forms/Input.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
23
src/components/forms/NativeSelect.tsx
Normal file
23
src/components/forms/NativeSelect.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
44
src/components/forms/Radio.tsx
Normal file
44
src/components/forms/Radio.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
32
src/components/forms/SegmentedControl.tsx
Normal file
32
src/components/forms/SegmentedControl.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
55
src/components/forms/SelectMimicry.tsx
Normal file
55
src/components/forms/SelectMimicry.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
33
src/components/forms/Slider.tsx
Normal file
33
src/components/forms/Slider.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
39
src/components/forms/Switch.tsx
Normal file
39
src/components/forms/Switch.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
26
src/components/forms/Textarea.tsx
Normal file
26
src/components/forms/Textarea.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
38
src/components/forms/WriteBar.tsx
Normal file
38
src/components/forms/WriteBar.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
52
src/components/forms/checkable.css
Normal file
52
src/components/forms/checkable.css
Normal 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;
|
||||||
|
}
|
||||||
49
src/components/forms/chips.css
Normal file
49
src/components/forms/chips.css
Normal 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;
|
||||||
|
}
|
||||||
36
src/components/forms/custom-select.css
Normal file
36
src/components/forms/custom-select.css
Normal 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);
|
||||||
|
}
|
||||||
23
src/components/forms/dropzone.css
Normal file
23
src/components/forms/dropzone.css
Normal 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;
|
||||||
|
}
|
||||||
24
src/components/forms/file.css
Normal file
24
src/components/forms/file.css
Normal 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;
|
||||||
|
}
|
||||||
38
src/components/forms/input.css
Normal file
38
src/components/forms/input.css
Normal 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; }
|
||||||
24
src/components/forms/segmented.css
Normal file
24
src/components/forms/segmented.css
Normal 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);
|
||||||
|
}
|
||||||
40
src/components/forms/select-mimicry.css
Normal file
40
src/components/forms/select-mimicry.css
Normal 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;
|
||||||
|
}
|
||||||
22
src/components/forms/slider.css
Normal file
22
src/components/forms/slider.css
Normal 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);
|
||||||
|
}
|
||||||
46
src/components/forms/switch.css
Normal file
46
src/components/forms/switch.css
Normal 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;
|
||||||
|
}
|
||||||
30
src/components/forms/writebar.css
Normal file
30
src/components/forms/writebar.css
Normal 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
Reference in New Issue
Block a user