first commit
This commit is contained in:
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;
|
||||
}
|
||||
Reference in New Issue
Block a user