1 звезда на GitHub = 1 новый компонент.

Поставить звезду
E-commerceecommerceshopvariantsswatchsizeradio

Variant Picker

Выбор цвета или размера как нативная группа радиокнопок, синхронизированная с кнопкой «в корзину».

Живое превью

Colour: Graphite

Использование

Скопируйте и вставьте этот код в ваш React-компонент:

tsx
import { VariantPicker } from 'react-component-kit-ui/ecommerce';

<VariantPicker label="Size" type="pill" target="add-btn" options={[{ label: 'S' }, { label: 'M' }, { label: 'L', disabled: true }]} />

Ручная установка

Если вы не используете пакет npm, создайте файл и вставьте следующий код: src/components/VariantPicker.tsx + VariantPicker.css:

VariantPicker.tsx
'use client';

import { useEffect, useRef } from 'react';
import type { CSSProperties } from 'react';
import './VariantPicker.css';

/** class:list → className */
function cx(...args: unknown[]): string {
  const out: string[] = [];
  const walk = (v: unknown) => {
    if (!v) return;
    if (typeof v === 'string' || typeof v === 'number') out.push(String(v));
    else if (Array.isArray(v)) v.forEach(walk);
    else if (typeof v === 'object') for (const [k, on] of Object.entries(v as Record<string, unknown>)) if (on) out.push(k);
  };
  args.forEach(walk);
  return out.join(' ');
}

/** "a: b; --c: d" → style object */
function st(css: string | undefined): CSSProperties {
  const o: Record<string, string> = {};
  for (const decl of String(css ?? '').split(';')) {
    const i = decl.indexOf(':');
    if (i < 0) continue;
    const k = decl.slice(0, i).trim();
    if (k) o[k.startsWith('--') ? k : k.replace(/-([a-z])/g, (_, c) => c.toUpperCase())] = decl.slice(i + 1).trim();
  }
  return o as CSSProperties;
}

interface Option { label: string; value?: string; hex?: string; disabled?: boolean }

/**
 * VariantPicker — Colour swatches or size pills as an accessible radio group.
 * Can keep an add-to-cart button's `data-variant` in sync with the selection.
 *
 * @param {string} label - Group legend. Default 'Colour'.
 * @param {string} name - Radio name. Default 'variant'.
 * @param {string} type - Picker style (swatch|pill). Default 'swatch'.
 * @param {Array<{label: string, value?: string, hex?: string, disabled?: boolean}>} options - Choices.
 * @param {number} selected - Index of the pre-selected option. Default 0.
 * @param {string} target - id of an add-to-cart button whose `data-variant` follows the choice.
 * @param {string} size - Control size (sm|md|lg). Default 'md'.
 * @param {string} className - Extra class.
 *
 * Theming: --vp-active-bg, --vp-active-color, --vp-active-border, --vp-ring, --vp-radius.
 *
 * Accessibility: native radios (arrow keys work), visible focus ring, the chosen value is
 * printed next to the legend; disabled options are announced as unavailable.
 */
export interface VariantPickerProps {
  label?: string;
  name?: string;
  type?: 'swatch' | 'pill';
  options?: Option[];
  selected?: number;
  target?: string;
  size?: 'sm' | 'md' | 'lg';
  className?: string;
}

export function VariantPicker({
  label = 'Colour',
  name = 'variant',
  type = 'swatch',
  options = [
    { label: 'Graphite', hex: '#2b2b2f' },
    { label: 'Sand', hex: '#d8c3a5' },
    { label: 'Ocean', hex: '#3d6f9e' },
    { label: 'Clay', hex: '#b5654a', disabled: true },
  ],
  selected = 0,
  target,
  size = 'md',
  className = '',
}: VariantPickerProps) {
  const uid = `vp-${name}`;

  // Ported from the Astro <script>: runs once after mount (DOM logic kept as in the Astro kit).
  const mounted = useRef(false);
  useEffect(() => {
    if (mounted.current) return;
    mounted.current = true;
    document.querySelectorAll<HTMLFieldSetElement>('[data-variant-picker]:not([data-bound])').forEach((fs) => {
        fs.dataset.bound = '';
        const out = fs.querySelector<HTMLElement>('[data-variant-value]');
        const sync = () => {
          const checked = fs.querySelector<HTMLInputElement>('input:checked');
          if (!checked) return;
          if (out) out.textContent = checked.dataset.label || checked.value;
          const btn = fs.dataset.target ? document.getElementById(fs.dataset.target) : null;
          if (btn) {
            // Combine every picker that targets the same button: "Sand / M"
            const parts = Array.from(document.querySelectorAll<HTMLElement>(`[data-variant-picker][data-target="${fs.dataset.target}"]`))
              .map((p) => p.querySelector<HTMLInputElement>('input:checked')?.dataset.label)
              .filter(Boolean);
            btn.dataset.variant = parts.join(' / ');
          }
        };
        fs.addEventListener('change', sync);
        sync();
      });
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);

  return (
    <>
      <fieldset className={cx(['v-picker', `v-picker--${type}`, `v-picker--${size}`, className])} data-variant-picker data-target={target} data-rk-799908="">
        <legend className="v-picker__legend" data-rk-799908="">
          {label}: <span className="v-picker__value" data-variant-value data-rk-799908="">{options[selected]?.label}</span>
        </legend>
        <div className="v-picker__options" data-rk-799908="">
          {options.map((o, i) => (
            <label key={i} className={cx(['v-picker__opt', { 'is-disabled': o.disabled }])} style={st(o.hex ? `--sw: ${o.hex}` : '')} title={o.label} data-rk-799908="">
              <input type="radio" name={`${uid}`} defaultValue={o.value ?? o.label} data-label={o.label} defaultChecked={i === selected} disabled={o.disabled} data-rk-799908="" />
              {type === 'swatch'
                ? <span className="v-picker__dot" aria-hidden="true" data-rk-799908=""></span>
                : <span className="v-picker__pill" data-rk-799908="">{o.label}</span>}
              {type === 'swatch' && <span className="sr-only" data-rk-799908="">{o.label}{o.disabled ? ' (sold out)' : ''}</span>}
              {type === 'pill' && o.disabled && <span className="sr-only" data-rk-799908=""> (sold out)</span>}
            </label>
          ))}
        </div>
      </fieldset>
    </>
  );
}

export default VariantPicker;
VariantPicker.css
/* VariantPicker — ported from astro-component-kit (ecommerce/VariantPicker.astro) */

.v-picker:where([data-rk-799908]) { margin: 0; padding: 0; border: 0; min-width: 0; }
.v-picker__legend:where([data-rk-799908]) { padding: 0; margin-bottom: var(--sp-3, 0.75rem); font-size: 0.85rem; font-weight: 600; color: var(--c-text-2, #a3a3a3); }
.v-picker__value:where([data-rk-799908]) { color: var(--c-text-1, #fff); }
.v-picker__options:where([data-rk-799908]) { display: flex; flex-wrap: wrap; gap: var(--sp-2, 0.5rem); }
.v-picker__opt:where([data-rk-799908]) { position: relative; cursor: pointer; }
.v-picker__opt:where([data-rk-799908]) input:where([data-rk-799908]) { position: absolute; opacity: 0; width: 1px; height: 1px; margin: 0; }
.v-picker__opt.is-disabled:where([data-rk-799908]) { cursor: not-allowed; }

.v-picker__dot:where([data-rk-799908]) {
  display: block;
  width: 36px;
  height: 36px;
  margin: 4px;
  border-radius: 50%;
  background: var(--sw);
  box-shadow: 0 0 0 1px rgb(var(--ink-rgb, 255 255 255) / 0.2);
  transition: box-shadow 0.15s ease, transform 0.15s ease;
}
.v-picker--sm:where([data-rk-799908]) .v-picker__dot:where([data-rk-799908]) { width: 28px; height: 28px; margin: 8px; }
.v-picker--lg:where([data-rk-799908]) .v-picker__dot:where([data-rk-799908]) { width: 44px; height: 44px; margin: 2px; }
.v-picker__opt:hover:where([data-rk-799908]) .v-picker__dot:where([data-rk-799908]) { transform: scale(1.06); }
.v-picker__opt:where([data-rk-799908]) input:checked:where([data-rk-799908]) + .v-picker__dot:where([data-rk-799908]) { box-shadow: 0 0 0 3px var(--c-bg, #050505), 0 0 0 5px var(--vp-ring, var(--c-primary-light, #d4a017)); }
.v-picker__opt.is-disabled:where([data-rk-799908]) .v-picker__dot:where([data-rk-799908]) { opacity: 0.35; background-image: linear-gradient(135deg, transparent 46%, rgb(var(--ink-rgb, 255 255 255) / 0.8) 47% 53%, transparent 54%); }

.v-picker__pill:where([data-rk-799908]) {
  display: grid;
  place-items: center;
  min-width: 52px;
  min-height: 44px;
  padding: 0 var(--sp-4, 1rem);
  border-radius: var(--vp-radius, var(--r-md, 12px));
  border: 1px solid rgb(var(--ink-rgb, 255 255 255) / 0.12);
  color: var(--c-text-1, #fff);
  font-size: 0.88rem;
  font-weight: 600;
  transition: border-color 0.15s ease, background 0.15s ease;
}
.v-picker--sm:where([data-rk-799908]) .v-picker__pill:where([data-rk-799908]) { min-height: 36px; min-width: 44px; font-size: 0.8rem; }
.v-picker__opt:hover:where([data-rk-799908]) .v-picker__pill:where([data-rk-799908]) { border-color: rgb(var(--ink-rgb, 255 255 255) / 0.3); }
.v-picker__opt:where([data-rk-799908]) input:checked:where([data-rk-799908]) + .v-picker__pill:where([data-rk-799908]) { border-color: var(--vp-active-border, var(--c-primary-light, #d4a017)); background: var(--vp-active-bg, rgba(212, 160, 23, 0.12)); color: var(--vp-active-color, var(--c-text-1, #fff)); }
.v-picker__opt.is-disabled:where([data-rk-799908]) .v-picker__pill:where([data-rk-799908]) { color: var(--c-text-3, #737373); text-decoration: line-through; }

.v-picker__opt:where([data-rk-799908]) input:focus-visible:where([data-rk-799908]) + .v-picker__dot:where([data-rk-799908]),
.v-picker__opt:where([data-rk-799908]) input:focus-visible:where([data-rk-799908]) + .v-picker__pill:where([data-rk-799908]) { outline: 2px solid var(--c-primary-light, #d4a017); outline-offset: 3px; }

.sr-only:where([data-rk-799908]) { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }

Краткая информация

Категория
E-commerce
Имя файла
VariantPicker.tsx
Зависимости
Нет — чистый React + CSS
Теги
ecommerceshopvariantsswatchsizeradio