Загрузка данных


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