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


import classNames from 'classnames';
import { Button } from 'exchange-elements/v2';
import { ChangeEvent } from 'react';

import { t } from '@src/translations';
import { normalizeColor } from '@src/utils';

import styles from './index.module.scss';
import { RangeField } from './RangeField';

interface ColorPickerProps {
  value: string;
  onChange: (value: string) => void;
}

const COLOR_PALETTE = [
  ['#ffffff', '#d9d9d9', '#bfbfbf', '#a6a6a6', '#8c8c8c', '#737373', '#595959', '#404040', '#262626', '#000000'],
  ['#f23645', '#ff9800', '#ffeb3b', '#4caf50', '#009688', '#16b8c4', '#2962ff', '#673ab7', '#9c27b0', '#e91e63'],
  ['#f8c1c5', '#ffe0b2', '#fff9c4', '#c8e6c9', '#b2dfdb', '#b2ebf2', '#bbdefb', '#d1c4e9', '#e1bee7', '#f8bbd0'],
  ['#f58f97', '#ffcc80', '#fff59d', '#a5d6a7', '#80cbc4', '#80deea', '#90caf9', '#b39ddb', '#ce93d8', '#f48fb1'],
  ['#f16b75', '#ffb74d', '#ffee58', '#81c784', '#4db6ac', '#4dd0e1', '#64b5f6', '#9575cd', '#ba68c8', '#f06292'],
  ['#ed4c5a', '#ffa726', '#fdd835', '#66bb6a', '#26a69a', '#26c6da', '#4285e8', '#7e57c2', '#ab47bc', '#ec407a'],
  ['#c62835', '#f57c00', '#fbc02d', '#388e3c', '#00796b', '#0097a7', '#1f4dcc', '#5e35b1', '#8e24aa', '#c2185b'],
  ['#991b24', '#e65100', '#ef6c00', '#1b5e20', '#004d40', '#006064', '#173b99', '#311b92', '#4a148c', '#880e4f'],
] as const;

export function normalizeHexColor(value: string): string {
  const normalized = value.trim().startsWith('#') ? normalizeColor(value) : `#${normalizeColor(value)}`;

  if (/^#[0-9a-f]{6}$/i.test(normalized)) {
    return `${normalized}ff`;
  }

  if (/^#[0-9a-f]{8}$/i.test(normalized)) {
    return normalized;
  }

  return '#000000ff';
}

export function ColorPicker({ value, onChange }: ColorPickerProps) {
  const currentColor = normalizeHexColor(value);
  const rgb = currentColor.slice(0, 7).toLowerCase();
  const alpha = currentColor.slice(7, 9);

  const opacity = Math.round((parseInt(alpha, 16) / 255) * 100);

  const handleColorChange = (color: string): void => {
    onChange(`${color}${alpha}`);
  };

  const handleOpacityChange = (event: ChangeEvent<HTMLInputElement>): void => {
    const nextOpacity = Number(event.target.value);
    const nextAlpha = Math.round((nextOpacity / 100) * 255)
      .toString(16)
      .padStart(2, '0');

    onChange(`${rgb}${nextAlpha}`);
  };

  return (
    <div className={styles.colorPicker}>
      <div className={styles.colorPalette}>
        {COLOR_PALETTE.flatMap((row) =>
          row.map((color) => {
            const isSelected = color.toLowerCase() === rgb;

            return (
              <Button
                key={color}
                size="sm"
                className={classNames(styles.colorOption, {
                  [styles.selected]: isSelected,
                })}
                style={{
                  ['--color-option' as string]: color,
                }}
                aria-label={color}
                aria-pressed={isSelected}
                onClick={() => handleColorChange(color)}
              />
            );
          }),
        )}
      </div>

      <div className={styles.colorDivider} />

      <RangeField
        label={t('Opacity')}
        value={opacity}
        color={rgb}
        suffix="%"
        onChange={handleOpacityChange}
      />
    </div>
  );
}






import classNames from 'classnames';
import { Button } from 'exchange-elements/v2';

import { Dropdown } from '@src/components/Dropdown';

import styles from './index.module.scss';
import { ColorPicker, normalizeHexColor } from './ColorPicker';

interface ColorFieldProps {
  label: string;
  value: string;
  onChange: (value: string) => void;
}

export function ColorField({ label, value, onChange }: ColorFieldProps) {
  const currentColor = normalizeHexColor(value);

  return (
    <div className={styles.colorField}>
      <span className={styles.colorFieldLabel}>{label}</span>

      <Dropdown
        selectedValue={currentColor}
        position="bottom"
        menuClassName={styles.colorMenu}
        renderTrigger={({ isOpen, onToggle }) => (
          <Button
            size="sm"
            className={classNames(styles.colorTrigger, {
              [styles.pressed]: isOpen,
            })}
            aria-label={label}
            aria-haspopup="dialog"
            aria-expanded={isOpen}
            onClick={onToggle}
          >
            <span className={styles.colorPreview}>
              <span
                className={styles.colorPreviewFill}
                style={{ backgroundColor: currentColor }}
              />
            </span>
          </Button>
        )}
      >
        <ColorPicker
          value={currentColor}
          onChange={onChange}
        />
      </Dropdown>
    </div>
  );
}



@use '../../theme/mixins' as m;

$color-option-size: 32px;
$color-menu-width: 384px;

.colorField {
  width: 100%;
  display: flex;
  justify-content: space-between;
  align-items: center;
  gap: var(--space-1000);
}

.colorFieldLabel {
  color: var(--neutral-12);
  font-size: var(--space-0875);
}

.colorTrigger {
  @include m.buttonBase;

  min-width: auto;
  padding: var(--space-0375);
  border: 1px solid var(--color-field-border);
  background-color: transparent;

  &:hover:not(:disabled):not(.pressed) {
    border-color: var(--blue-1);
  }

  &.pressed {
    border-color: var(--blue-1);
  }

  p {
    padding: 0;
  }
}

.colorPreview {
  position: relative;
  width: var(--space-2000);
  height: var(--space-2000);
  flex-shrink: 0;
  overflow: hidden;
  border: 1px solid var(--color-field-border);
  border-radius: var(--space-0250);
  background:
    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: var(--space-0500) var(--space-0500);
  background-position:
    0 0,
    0 calc(var(--space-0500) / 2),
    calc(var(--space-0500) / 2) calc(var(--space-0500) / -2),
    calc(var(--space-0500) / -2) 0;
}

.colorPreviewFill {
  position: absolute;
  inset: 0;
}

.colorMenu {
  --dropdown-width: #{$color-menu-width};

  padding: var(--space-1000);
}

.colorPicker {
  display: flex;
  flex-direction: column;
  gap: var(--space-1000);
}

.colorPalette {
  display: grid;
  grid-template-columns: repeat(10, $color-option-size);
  gap: var(--space-0375);
}

.colorOption {
  @include m.buttonBase;

  --color-option: transparent;

  position: relative;
  min-width: 0;
  width: $color-option-size;
  height: $color-option-size;
  padding: 0;
  border: 0;
  border-radius: var(--space-0250);
  background-color: var(--color-option);

  &:hover:not(:disabled):not(.selected),
  &:active:not(:disabled):not(.selected) {
    background-color: var(--color-option);
    box-shadow: 0 0 0 2px var(--neutral-8);
  }

  &.selected {
    background-color: var(--color-option);
    box-shadow:
      0 0 0 2px var(--neutral-2),
      0 0 0 4px var(--blue-1);
  }

  p {
    display: none;
  }
}

.colorDivider {
  width: 100%;
  height: 1px;
  background-color: var(--color-field-border);
}