From fa6fe8dbe1e1bad344bc92db9575ae57aa936f1c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A2=D0=B8=D0=BC=D1=83=D1=80=20=D0=93=D0=BE=D1=80=D0=BE?= =?UTF-8?q?=D0=B4=D0=B5=D1=86=D0=BA=D0=B8=D0=B9?= Date: Tue, 25 Aug 2026 18:28:50 +0500 Subject: [PATCH] =?UTF-8?q?feat:=20=D0=BF=D1=80=D0=B8=D0=BC=D0=B8=D1=82?= =?UTF-8?q?=D0=B8=D0=B2=D1=8B=20Checkbox,=20Spinner,=20IconButton,=20Dialo?= =?UTF-8?q?g,=20Link=20(0.10.0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Набор B перекраски форм: брендовые адаптивные примитивы + иконки arrow-up/arrow-down/swap/refresh + токен $amber-50. Co-Authored-By: Claude Opus 4.8 --- package.json | 2 +- src/Checkbox/Checkbox.module.scss | 103 ++++++++++++++++++++++++++ src/Checkbox/Checkbox.stories.tsx | 38 ++++++++++ src/Checkbox/Checkbox.tsx | 58 +++++++++++++++ src/Dialog/Dialog.module.scss | 102 +++++++++++++++++++++++++ src/Dialog/Dialog.stories.tsx | 48 ++++++++++++ src/Dialog/Dialog.tsx | 70 +++++++++++++++++ src/Icon/IconSprite.tsx | 14 ++++ src/Icon/iconNames.ts | 4 + src/IconButton/IconButton.module.scss | 49 ++++++++++++ src/IconButton/IconButton.stories.tsx | 30 ++++++++ src/IconButton/IconButton.tsx | 36 +++++++++ src/Link/Link.module.scss | 24 ++++++ src/Link/Link.stories.tsx | 18 +++++ src/Link/Link.tsx | 29 ++++++++ src/Spinner/Spinner.module.scss | 41 ++++++++++ src/Spinner/Spinner.stories.tsx | 29 ++++++++ src/Spinner/Spinner.tsx | 31 ++++++++ src/index.ts | 14 ++++ src/tokens/_tokens.scss | 1 + 20 files changed, 740 insertions(+), 1 deletion(-) create mode 100644 src/Checkbox/Checkbox.module.scss create mode 100644 src/Checkbox/Checkbox.stories.tsx create mode 100644 src/Checkbox/Checkbox.tsx create mode 100644 src/Dialog/Dialog.module.scss create mode 100644 src/Dialog/Dialog.stories.tsx create mode 100644 src/Dialog/Dialog.tsx create mode 100644 src/IconButton/IconButton.module.scss create mode 100644 src/IconButton/IconButton.stories.tsx create mode 100644 src/IconButton/IconButton.tsx create mode 100644 src/Link/Link.module.scss create mode 100644 src/Link/Link.stories.tsx create mode 100644 src/Link/Link.tsx create mode 100644 src/Spinner/Spinner.module.scss create mode 100644 src/Spinner/Spinner.stories.tsx create mode 100644 src/Spinner/Spinner.tsx diff --git a/package.json b/package.json index 778141a..b5ee63e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@tnsu/ui-kit", - "version": "0.9.0", + "version": "0.10.0", "description": "TNSU shared UI components for SPFx web parts", "author": "Timur Gorodetskiy", "license": "ISC", diff --git a/src/Checkbox/Checkbox.module.scss b/src/Checkbox/Checkbox.module.scss new file mode 100644 index 0000000..e866fcd --- /dev/null +++ b/src/Checkbox/Checkbox.module.scss @@ -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; } +} diff --git a/src/Checkbox/Checkbox.stories.tsx b/src/Checkbox/Checkbox.stories.tsx new file mode 100644 index 0000000..1ef4109 --- /dev/null +++ b/src/Checkbox/Checkbox.stories.tsx @@ -0,0 +1,38 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { useState } from 'react'; +import { Checkbox } from './Checkbox'; + +const meta: Meta = { + title: 'Поля/Checkbox', + component: Checkbox, + tags: ['autodocs'], +}; + +export default meta; + +export const Default: StoryObj = { + render: () => { + const [c, setC] = useState(false); + return ; + }, +}; + +export const States: StoryObj = { + render: () => ( +
+ undefined} label="Не выбран" /> + undefined} label="Выбран" /> + undefined} label="Промежуточный (выбрать все)" /> + undefined} label="Выбран, недоступен" /> + undefined} label="Недоступен" /> +
+ ), +}; + +/** В ячейках таблиц — без видимой подписи, с ariaLabel для доступности. */ +export const InTableCell: StoryObj = { + render: () => { + const [c, setC] = useState(true); + return ; + }, +}; diff --git a/src/Checkbox/Checkbox.tsx b/src/Checkbox/Checkbox.tsx new file mode 100644 index 0000000..f5b1fe2 --- /dev/null +++ b/src/Checkbox/Checkbox.tsx @@ -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 = ({ + id, + checked, + onChange, + indeterminate, + disabled, + readOnly, + label, + ariaLabel +}) => { + const contextReadOnly = useFormReadOnly(); + const effectiveDisabled = disabled || (readOnly ?? contextReadOnly); + const ref = React.useRef(null); + + React.useEffect(() => { + if (ref.current) { + ref.current.indeterminate = !!indeterminate; + } + }, [indeterminate]); + + return ( + + ); +}; diff --git a/src/Dialog/Dialog.module.scss b/src/Dialog/Dialog.module.scss new file mode 100644 index 0000000..eaa153f --- /dev/null +++ b/src/Dialog/Dialog.module.scss @@ -0,0 +1,102 @@ +@import '../tokens/tokens'; + +.overlay { + position: fixed; + inset: 0; + z-index: 1000; + display: flex; + align-items: center; + justify-content: center; + padding: 24px; + background: rgba(10, 27, 48, .45); + animation: tnsu-dlg-fade .15s ease-out; +} + +.panel { + width: 100%; + max-width: 480px; + max-height: calc(100vh - 48px); + display: flex; + flex-direction: column; + background: $white; + border-radius: $r-lg; + box-shadow: $sh-modal; + overflow: hidden; + animation: tnsu-dlg-pop .16s ease-out; +} + +.sm { max-width: 380px; } +.lg { max-width: 680px; } + +.header { + display: flex; + align-items: flex-start; + gap: 12px; + padding: 20px 22px 14px; + border-bottom: 1px solid $gray-200; +} + +.titles { flex: 1; min-width: 0; } + +.title { + margin: 0; + font-family: $font-serif; + font-size: $fs-form-title; + font-weight: 600; + color: $navy-900; +} + +.sub { + margin: 6px 0 0; + font-size: $fs-sm; + color: $gray-500; + line-height: 1.4; +} + +.close { + flex-shrink: 0; + display: inline-flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + border: 0; + border-radius: $r-sm; + background: transparent; + color: $gray-500; + cursor: pointer; + transition: background .15s, color .15s; + + &:hover { background: $gray-100; color: $navy-800; } + &:focus-visible { outline: 0; box-shadow: 0 0 0 3px rgba(212, 160, 30, .25); } +} + +.body { + padding: 18px 22px; + overflow-y: auto; + font-size: $fs-body; + color: $gray-900; +} + +.footer { + display: flex; + justify-content: flex-end; + gap: 10px; + padding: 14px 22px 18px; + border-top: 1px solid $gray-200; + flex-wrap: wrap; +} + +@keyframes tnsu-dlg-fade { from { opacity: 0; } to { opacity: 1; } } +@keyframes tnsu-dlg-pop { from { opacity: 0; transform: translateY(8px) scale(.98); } to { opacity: 1; transform: none; } } + +// На телефоне — почти на весь экран, футер-кнопки на всю ширину. +@include below($bp-sm) { + .overlay { padding: 0; align-items: flex-end; } + .panel { max-width: 100%; max-height: 92vh; border-radius: $r-lg $r-lg 0 0; } + .footer > * { flex: 1; } +} + +@media (prefers-reduced-motion: reduce) { + .overlay, .panel { animation: none; } +} diff --git a/src/Dialog/Dialog.stories.tsx b/src/Dialog/Dialog.stories.tsx new file mode 100644 index 0000000..13b9b39 --- /dev/null +++ b/src/Dialog/Dialog.stories.tsx @@ -0,0 +1,48 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { useState } from 'react'; +import { Dialog } from './Dialog'; +import { Button } from '../Button/Button'; +import { TextArea } from '../TextArea/TextArea'; +import { IconSprite } from '../Icon/IconSprite'; + +const meta: Meta = { + title: 'Оверлеи/Dialog', + component: Dialog, + tags: ['autodocs'], + decorators: [ + (Story) => ( + <> + + + + ), + ], +}; + +export default meta; + +export const DecisionForm: StoryObj = { + render: () => { + const [open, setOpen] = useState(true); + const [comment, setComment] = useState(''); + return ( + <> + + setOpen(false)} + footer={ + <> + + + + } + > +