Загрузка данных
import { InputText } from 'exchange-elements/v2';
import { ChangeEvent, useEffect, useRef, useState } from 'react';
import { normalizeColor } from '@src/utils';
import styles from './index.module.scss';
import { RangeField } from './RangeField';
interface ColorFieldProps {
label: string;
value: string;
onChange: (value: string) => void;
}
function normalizeHex(value: string): string {
const next = value.trim().startsWith('#') ? normalizeColor(value) : `#${normalizeColor(value)}`;
if (/^#[0-9a-f]{6}$/.test(next)) {
return `${next}ff`;
}
if (/^#[0-9a-f]{8}$/.test(next)) {
return next;
}
return '#000000ff';
}
function isValidHex(value: string): boolean {
return /^#([0-9a-f]{6}|[0-9a-f]{8})$/i.test(value) || /^([0-9a-f]{6}|[0-9a-f]{8})$/i.test(value);
}
export const ColorField = ({ label, value, onChange }: ColorFieldProps) => {
const colorInputRef = useRef<HTMLInputElement | null>(null);
const [draft, setDraft] = useState(normalizeHex(value));
useEffect(() => {
setDraft(normalizeHex(value));
}, [value]);
const current = isValidHex(draft) ? normalizeHex(draft) : normalizeHex(value);
const opacity = Math.round((parseInt(current.slice(7, 9), 16) / 255) * 100);
const handleTextChange = (event: ChangeEvent<HTMLInputElement>) => {
setDraft(event.target.value);
};
const handleTextBlur = () => {
if (!isValidHex(draft)) {
setDraft(normalizeHex(value));
return;
}
const next = normalizeHex(draft);
setDraft(next);
onChange(next);
};
const handleColorChange = (event: ChangeEvent<HTMLInputElement>) => {
const next = `${event.target.value}${current.slice(7, 9)}`;
setDraft(next);
onChange(next);
};
const handleOpacityChange = (event: ChangeEvent<HTMLInputElement>) => {
const alpha = Math.round((Number(event.target.value) / 100) * 255)
.toString(16)
.padStart(2, '0');
const next = `${current.slice(0, 7)}${alpha}`;
setDraft(next);
onChange(next);
};
return (
<div className={styles.input_color}>
<div className={styles.input_wrapper}>
<InputText
value={draft}
label={label}
labelPos="top"
size="sm"
onChange={handleTextChange}
onBlur={handleTextBlur}
/>
<button
type="button"
className={styles.button_color}
style={{ backgroundColor: current }}
onClick={() => colorInputRef.current?.click()}
/>
<input
ref={colorInputRef}
type="color"
className={styles.input_hidden}
value={current.slice(0, 7)}
onChange={handleColorChange}
tabIndex={-1}
/>
</div>
<RangeField
value={opacity}
color={current.slice(0, 7)}
suffix="%"
onChange={handleOpacityChange}
/>
</div>
);
};
import { ChangeEvent, useId } from 'react';
import styles from './index.module.scss';
interface RangeFieldProps {
label?: string;
value: number;
min?: number;
max?: number;
step?: number;
color?: string;
suffix?: string;
onChange: (event: ChangeEvent<HTMLInputElement>) => void;
}
export const RangeField = ({
label,
value,
min = 0,
max = 100,
step = 1,
color = 'var(--blue-1)',
suffix = '%',
onChange,
}: RangeFieldProps) => {
const inputId = useId();
return (
<div className={styles.range}>
{label && (
<label
htmlFor={inputId}
className={styles.range_label}
>
{label}
</label>
)}
<div className={styles.range_wrapper}>
<input
id={inputId}
type="range"
min={min}
max={max}
step={step}
value={value}
className={styles.range_input}
style={{ ['--range-color' as string]: color }}
onChange={onChange}
/>
<span className={styles.range_value}>
{value}
{suffix}
</span>
</div>
</div>
);
};
[data-theme='mxt'],
[data-theme='mb'][data-mode='light'] {
--dd-item-text-selected: var(--neutral-2);
--dd-item-bg-selected: var(--neutral-12);
--color-field-border: var(--neutral-11);
--color-field-square: #00000024;
}
[data-theme='tr'],
[data-theme='mb'][data-mode='dark'] {
--dd-item-text-selected: var(--neutral-13);
--dd-item-bg-selected: var(--neutral-5);
--color-field-border: var(--neutral-9);
--color-field-square: #ffffff14;
}
$button-color-bottom: 5px;
$slider-thumb-top: -3px;
.input {
color: var(--neutral-12);
input {
color: var(--neutral-12);
}
&_select {
&:focus-within {
background-color: transparent !important;
}
div {
background-color: transparent;
&:focus-within {
background-color: transparent !important;
}
.input {
background-color: transparent;
}
}
}
&_color {
display: flex;
flex-direction: column;
gap: var(--space-0250);
.input_wrapper {
position: relative;
width: 100%;
}
}
}
.dropdown {
li {
&[aria-selected='true'] {
background-color: var(--dd-item-bg-selected);
div[aria-label='Label'] {
color: var(--dd-item-text-selected);
}
}
svg {
color: transparent;
}
}
}
.button_color {
position: absolute;
bottom: $button-color-bottom;
right: var(--space-0500);
width: var(--space-1375);
height: var(--space-1375);
border: 1px solid var(--color-field-border);
border-radius: var(--space-0125);
}
.input_hidden {
position: absolute;
bottom: $button-color-bottom;
right: var(--space-0500);
width: var(--space-1375);
height: var(--space-1375);
opacity: 0;
}
.input_checkbox {
padding: var(--space-0375) 0 !important;
span {
font-size: var(--space-0875);
line-height: normal;
}
}
.input_textarea {
textarea {
font-size: var(--space-0875);
line-height: normal;
}
}
.range {
display: flex;
flex-direction: column;
gap: var(--space-0250);
&_label {
font-size: var(--space-0875);
color: var(--neutral-11);
}
&_wrapper {
display: grid;
align-items: center;
grid-template: 1fr / 1fr auto;
gap: var(--space-0500);
}
&_value {
min-width: var(--space-2000);
color: var(--neutral-11);
font-size: var(--space-0750);
text-align: right;
}
&_input {
--range-color: var(--blue-1);
-webkit-appearance: none;
appearance: none;
width: 100%;
height: var(--space-1000);
cursor: pointer;
background: transparent;
&::-webkit-slider-runnable-track {
height: var(--space-0750);
border: 1px solid var(--color-field-border);
border-radius: 999px;
background: linear-gradient(to right, transparent 0%, var(--range-color) 100%),
linear-gradient(45deg, var(--color-field-square) 25%, transparent 25%),
linear-gradient(-45deg, var(--color-field-square) 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, var(--color-field-square) 75%),
linear-gradient(-45deg, transparent 75%, var(--color-field-square) 75%);
background-size:
100% 100%,
var(--space-0500) var(--space-0500),
var(--space-0500) var(--space-0500),
var(--space-0500) var(--space-0500),
var(--space-0500) var(--space-0500);
background-position:
0 0,
0 0,
0 calc(var(--space-0500) / 2),
calc(var(--space-0500) / 2) calc(var(--space-0500) / -2),
calc(var(--space-0500) / -2) 0;
}
&::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: var(--space-1000);
height: var(--space-1000);
margin-top: $slider-thumb-top;
border: 1px solid var(--color-field-border);
border-radius: 50%;
background-color: var(--neutral-panel);
}
&::-moz-range-track {
height: var(--space-0750);
border: 1px solid var(--color-field-border);
border-radius: 999px;
background: linear-gradient(to right, transparent 0%, var(--range-color) 100%),
linear-gradient(45deg, var(--color-field-square) 25%, transparent 25%),
linear-gradient(-45deg, var(--color-field-square) 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, var(--color-field-square) 75%),
linear-gradient(-45deg, transparent 75%, var(--color-field-square) 75%);
background-size:
100% 100%,
var(--space-0500) var(--space-0500),
var(--space-0500) var(--space-0500),
var(--space-0500) var(--space-0500),
var(--space-0500) var(--space-0500);
background-position:
0 0,
0 0,
0 calc(var(--space-0500) / 2),
calc(var(--space-0500) / 2) calc(var(--space-0500) / -2),
calc(var(--space-0500) / -2) 0;
}
&::-moz-range-progress {
background: transparent;
}
&::-moz-range-thumb {
width: var(--space-1000);
height: var(--space-1000);
border: 1px solid var(--color-field-border);
border-radius: 50%;
background-color: var(--neutral-panel);
}
}
}
import classNames from 'classnames';
import { Button } from 'exchange-elements/v2';
import { useEffect, useMemo, useState } from 'react';
import {
CheckboxField,
ColorField,
NumberField,
RangeField,
SelectField,
TextAreaField,
TextField,
} from '@src/components/FormFields';
import { SettingField, SettingsTab, SettingsValues, SettingValue } from '@src/types';
import styles from './index.module.scss';
interface EntitySettingsModalProps<TValues extends SettingsValues, TField extends SettingField = SettingField> {
tabs: SettingsTab<TField>[];
values: TValues;
onChange: (settings: TValues) => void;
initialTabKey?: string;
}
export const EntitySettingsModal = <TValues extends SettingsValues, TField extends SettingField = SettingField>({
tabs,
values,
onChange,
initialTabKey,
}: EntitySettingsModalProps<TValues, TField>) => {
const availableTabs = useMemo(() => tabs.filter((tab) => tab.fields.length > 0), [tabs]);
const [activeTabKey, setActiveTabKey] = useState(initialTabKey ?? availableTabs[0]?.key ?? '');
const [form, setForm] = useState<TValues>(values);
useEffect(() => {
setForm(values);
}, [values]);
useEffect(() => {
if (!availableTabs.find((tab) => tab.key === activeTabKey)) {
setActiveTabKey(availableTabs[0]?.key ?? '');
}
}, [availableTabs, activeTabKey]);
const activeTab = availableTabs.find((tab) => tab.key === activeTabKey) ?? availableTabs[0];
const changeValue = (key: string, value: SettingValue) => {
setForm((current) => {
const next = {
...current,
[key]: value,
} as TValues;
onChange(next);
return next;
});
};
const renderField = (field: TField) => {
const value = form[field.key] ?? field.defaultValue;
if (field.type === 'select') {
return (
<SelectField
key={field.key}
label={field.label}
value={String(value)}
options={field.options}
onValueChange={(nextValue) => {
changeValue(field.key, nextValue);
}}
/>
);
}
if (field.type === 'color') {
return (
<ColorField
key={field.key}
label={field.label}
value={String(value)}
onChange={(nextValue) => {
changeValue(field.key, nextValue);
}}
/>
);
}
if (field.type === 'text') {
return (
<TextField
key={field.key}
label={field.label}
value={String(value)}
placeholder={field.placeholder}
onValueChange={(nextValue) => {
changeValue(field.key, nextValue);
}}
/>
);
}
if (field.type === 'textarea') {
return (
<TextAreaField
key={field.key}
label={field.label}
value={String(value)}
placeholder={field.placeholder}
onValueChange={(nextValue) => {
changeValue(field.key, nextValue);
}}
/>
);
}
if (field.type === 'boolean') {
return (
<CheckboxField
key={field.key}
label={field.label}
checked={Boolean(value)}
onValueChange={(nextValue) => {
changeValue(field.key, nextValue);
}}
/>
);
}
if (field.type === 'range') {
return (
<RangeField
key={field.key}
label={field.label}
value={typeof value === 'number' ? value : field.defaultValue}
min={field.min}
max={field.max}
step={field.step}
color={field.color}
suffix={field.suffix}
onChange={(event) => {
changeValue(field.key, Number(event.target.value));
}}
/>
);
}
return (
<NumberField
key={field.key}
label={field.label}
value={typeof value === 'number' ? value : field.defaultValue}
min={field.min}
max={field.max}
onValueChange={(nextValue) => {
changeValue(field.key, nextValue);
}}
/>
);
};
return (
<div className={styles.wrapper}>
{availableTabs.length > 1 && (
<div className={styles.tabs}>
{availableTabs.map(({ key, label }) => (
<Button
key={key}
className={classNames(styles.tab, { [styles.tab_active]: key === activeTabKey })}
onClick={() => setActiveTabKey(key)}
>
{label}
</Button>
))}
</div>
)}
{activeTab?.fields.map(renderField)}
</div>
);
};