feat: примитивы Checkbox, Spinner, IconButton, Dialog, Link (0.10.0)

Набор B перекраски форм: брендовые адаптивные примитивы + иконки
arrow-up/arrow-down/swap/refresh + токен $amber-50.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Тимур Городецкий
2026-08-25 18:28:50 +05:00
parent 2958ab7320
commit fa6fe8dbe1
20 changed files with 740 additions and 1 deletions
+103
View File
@@ -0,0 +1,103 @@
@import '../tokens/tokens';
.wrap {
display: inline-flex;
align-items: center;
gap: 8px;
cursor: pointer;
min-height: 24px;
vertical-align: middle;
}
// Нативный input скрыт, но остаётся фокусируемым/доступным.
.input {
position: absolute;
opacity: 0;
width: 0;
height: 0;
}
.box {
position: relative;
flex-shrink: 0;
width: 18px;
height: 18px;
border: 1.5px solid $gray-400;
border-radius: 4px;
background: $white;
transition: background .15s, border-color .15s;
}
// Выбран: navy-заливка + белая галка (контраст ~9:1).
.input:checked + .box {
background: $navy-800;
border-color: $navy-800;
}
.input:checked + .box::after {
content: '';
position: absolute;
left: 5px;
top: 1px;
width: 5px;
height: 9px;
border: solid $white;
border-width: 0 2px 2px 0;
transform: rotate(45deg);
}
// Промежуточное (после :checked, чтобы перебить галку на чёрточку).
.input:indeterminate + .box {
background: $navy-800;
border-color: $navy-800;
}
.input:indeterminate + .box::after {
content: '';
position: absolute;
left: 3px;
top: 7px;
width: 10px;
height: 2px;
border: 0;
border-radius: 1px;
background: $white;
transform: none;
}
// Фокус с клавиатуры — золотое кольцо (бренд).
.input:focus-visible + .box {
box-shadow: 0 0 0 3px rgba(212, 160, 30, .25);
border-color: $gold-500;
}
// Ховер (когда доступен).
.wrap:hover .input:not(:disabled) + .box {
border-color: $navy-600;
}
// Недоступен / только чтение.
.input:disabled + .box {
opacity: .5;
}
.input:disabled ~ .text {
color: $gray-400;
}
.wrap:has(.input:disabled) {
cursor: default;
}
.text {
font-size: $fs-sm;
color: $gray-700;
user-select: none;
}
// На узких экранах увеличиваем тач-таргет.
@include below($bp-md) {
.wrap { min-height: 32px; gap: 10px; }
.box { width: 20px; height: 20px; }
.input:checked + .box::after { left: 6px; top: 2px; }
}
+38
View File
@@ -0,0 +1,38 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { useState } from 'react';
import { Checkbox } from './Checkbox';
const meta: Meta<typeof Checkbox> = {
title: 'Поля/Checkbox',
component: Checkbox,
tags: ['autodocs'],
};
export default meta;
export const Default: StoryObj<typeof Checkbox> = {
render: () => {
const [c, setC] = useState(false);
return <Checkbox checked={c} onChange={setC} label="Согласен" />;
},
};
export const States: StoryObj<typeof Checkbox> = {
render: () => (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<Checkbox checked={false} onChange={() => undefined} label="Не выбран" />
<Checkbox checked onChange={() => undefined} label="Выбран" />
<Checkbox checked={false} indeterminate onChange={() => undefined} label="Промежуточный (выбрать все)" />
<Checkbox checked disabled onChange={() => undefined} label="Выбран, недоступен" />
<Checkbox checked={false} disabled onChange={() => undefined} label="Недоступен" />
</div>
),
};
/** В ячейках таблиц — без видимой подписи, с ariaLabel для доступности. */
export const InTableCell: StoryObj<typeof Checkbox> = {
render: () => {
const [c, setC] = useState(true);
return <Checkbox checked={c} onChange={setC} ariaLabel="Выбрать строку" />;
},
};
+58
View File
@@ -0,0 +1,58 @@
import * as React from 'react';
import styles from './Checkbox.module.scss';
import { useFormReadOnly } from '../FormMode';
export interface ICheckboxProps {
id?: string;
checked: boolean;
onChange: (checked: boolean) => void;
/** Промежуточное состояние (шапка «выбрать все» при частичном выборе). */
indeterminate?: boolean;
/** Недоступен (заблокирован). */
disabled?: boolean;
/** Только чтение (режим согласующего). По умолчанию берётся из FormMode-контекста. */
readOnly?: boolean;
/** Подпись справа от флажка. */
label?: string;
/** a11y-подпись для флажков без видимого label (в ячейках таблиц). */
ariaLabel?: string;
}
/** Флажок (SharePoint Boolean / выбор строк). Управляемый: checked внутрь, onChange наружу. */
export const Checkbox: React.FC<ICheckboxProps> = ({
id,
checked,
onChange,
indeterminate,
disabled,
readOnly,
label,
ariaLabel
}) => {
const contextReadOnly = useFormReadOnly();
const effectiveDisabled = disabled || (readOnly ?? contextReadOnly);
const ref = React.useRef<HTMLInputElement>(null);
React.useEffect(() => {
if (ref.current) {
ref.current.indeterminate = !!indeterminate;
}
}, [indeterminate]);
return (
<label className={styles.wrap}>
<input
ref={ref}
id={id}
type="checkbox"
className={styles.input}
checked={checked}
disabled={effectiveDisabled}
aria-label={ariaLabel}
onChange={(e) => onChange(e.target.checked)}
/>
<span className={styles.box} aria-hidden="true" />
{label && <span className={styles.text}>{label}</span>}
</label>
);
};