From 0082b90c2b1b814a4b1f4dfaf6563d01a70c8d10 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: Thu, 27 Aug 2026 10:02:29 +0500 Subject: [PATCH] =?UTF-8?q?feat:=20DropdownButton=20+=20printRoot()=20?= =?UTF-8?q?=D1=8D=D0=BA=D1=81=D0=BF=D0=BE=D1=80=D1=82=20=D0=B8=D0=B7=20Pri?= =?UTF-8?q?ntButton=20(0.12.0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Кнопка с выпадающим меню (клик по кнопке — открыть список, выбор варианта сразу вызывает onSelect). Логика печати PrintButton вынесена в printRoot(), чтобы её можно было вызвать программно после выбора варианта в DropdownButton. --- package.json | 2 +- src/DropdownButton/DropdownButton.module.scss | 83 ++++++++++++++ src/DropdownButton/DropdownButton.stories.tsx | 29 +++++ src/DropdownButton/DropdownButton.tsx | 107 ++++++++++++++++++ src/PrintButton/PrintButton.tsx | 100 ++++++++-------- src/index.ts | 4 +- 6 files changed, 276 insertions(+), 49 deletions(-) create mode 100644 src/DropdownButton/DropdownButton.module.scss create mode 100644 src/DropdownButton/DropdownButton.stories.tsx create mode 100644 src/DropdownButton/DropdownButton.tsx diff --git a/package.json b/package.json index d1d7bbc..024ed66 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@tnsu/ui-kit", - "version": "0.11.0", + "version": "0.12.0", "description": "TNSU shared UI components for SPFx web parts", "author": "Timur Gorodetskiy", "license": "ISC", diff --git a/src/DropdownButton/DropdownButton.module.scss b/src/DropdownButton/DropdownButton.module.scss new file mode 100644 index 0000000..a37b2e6 --- /dev/null +++ b/src/DropdownButton/DropdownButton.module.scss @@ -0,0 +1,83 @@ +@import "../tokens/tokens"; + +.root { + position: relative; + display: inline-block; +} + +.chevron, +.chevronOpen { + transition: transform 0.18s; +} + +.chevronOpen { + transform: rotate(180deg); +} + +.menu { + position: absolute; + top: calc(100% + 6px); + z-index: 20; + min-width: 220px; + max-width: min(360px, calc(100vw - 24px)); + background: $white; + border: 1px solid $gray-200; + border-radius: $r-md; + box-shadow: $sh-3; + padding: 6px; + display: flex; + flex-direction: column; + gap: 2px; +} + +.right { + right: 0; +} + +.left { + left: 0; +} + +.item { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 9px 12px; + border: 0; + background: transparent; + border-radius: $r-sm; + font-family: $font-sans; + font-size: 13px; + color: $navy-800; + text-align: left; + cursor: pointer; + white-space: nowrap; + transition: background 0.15s; + + &:hover { + background: $navy-50; + } +} + +.itemLabel { + overflow: hidden; + text-overflow: ellipsis; +} + +.check { + flex: none; + color: $gold-600; +} + +@include below($bp-xs) { + .menu { + min-width: 180px; + padding: 4px; + } + + .item { + padding: 8px 10px; + white-space: normal; + } +} diff --git a/src/DropdownButton/DropdownButton.stories.tsx b/src/DropdownButton/DropdownButton.stories.tsx new file mode 100644 index 0000000..ab03fb9 --- /dev/null +++ b/src/DropdownButton/DropdownButton.stories.tsx @@ -0,0 +1,29 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { useState } from 'react'; +import { DropdownButton } from './DropdownButton'; +import { Icon } from '../Icon/Icon'; + +const OPTIONS = [ + { value: '0', label: 'ТОО Монтажник-Т — Директор' }, + { value: '1', label: 'ТОО Стройсервис — Главный инженер' }, +]; + +const meta: Meta = { + title: 'Кнопки/DropdownButton', + component: DropdownButton, + tags: ['autodocs'], +}; + +export default meta; + +export const Default: StoryObj = { + render: () => { + const [v, setV] = useState('0'); + return ( + + + Печать + + ); + }, +}; diff --git a/src/DropdownButton/DropdownButton.tsx b/src/DropdownButton/DropdownButton.tsx new file mode 100644 index 0000000..ce90be7 --- /dev/null +++ b/src/DropdownButton/DropdownButton.tsx @@ -0,0 +1,107 @@ +import * as React from 'react'; +import styles from './DropdownButton.module.scss'; +import { Button } from '../Button/Button'; +import { Icon } from '../Icon/Icon'; + +export interface IDropdownButtonOption { + value: string; + label: string; +} + +type DropdownButtonVariant = 'primary' | 'accent' | 'secondary' | 'ghost' | 'danger'; +type DropdownButtonSize = 'md' | 'sm'; + +export interface IDropdownButtonProps { + options: IDropdownButtonOption[]; + onSelect: (value: string) => void; + /** Текущее значение — помечается галочкой в списке. */ + value?: string; + variant?: DropdownButtonVariant; + size?: DropdownButtonSize; + disabled?: boolean; + /** К какому краю кнопки прижимать меню (по умолчанию — правый). */ + align?: 'left' | 'right'; + /** Содержимое кнопки-триггера (текст/иконка) — стрелка добавляется сама. */ + children: React.ReactNode; +} + +/** + * Кнопка с выпадающим списком вариантов: клик по кнопке открывает меню, + * выбор варианта сразу вызывает onSelect и закрывает его. В отличие от + * классической split-button здесь нет отдельной зоны действия по умолчанию — + * вся кнопка целиком является триггером меню, так как без выбора варианта + * действие не определено (см. PrintSplitButton в request-portal). + */ +export const DropdownButton: React.FC = ({ + options, + onSelect, + value, + variant = 'secondary', + size = 'md', + disabled, + align = 'right', + children +}) => { + const [open, setOpen] = React.useState(false); + const rootRef = React.useRef(null); + + React.useEffect(() => { + if (!open) { + return undefined; + } + const onDocMouseDown = (e: MouseEvent): void => { + if (rootRef.current && !rootRef.current.contains(e.target as Node)) { + setOpen(false); + } + }; + const onKey = (e: KeyboardEvent): void => { + if (e.key === 'Escape') { + setOpen(false); + } + }; + document.addEventListener('mousedown', onDocMouseDown); + document.addEventListener('keydown', onKey); + return () => { + document.removeEventListener('mousedown', onDocMouseDown); + document.removeEventListener('keydown', onKey); + }; + }, [open]); + + const pick = (v: string): void => { + setOpen(false); + onSelect(v); + }; + + return ( +
+ + {open && ( +
+ {options.map((opt) => ( + + ))} +
+ )} +
+ ); +}; diff --git a/src/PrintButton/PrintButton.tsx b/src/PrintButton/PrintButton.tsx index 8910f02..78641a0 100644 --- a/src/PrintButton/PrintButton.tsx +++ b/src/PrintButton/PrintButton.tsx @@ -59,57 +59,63 @@ const PRINT_CSS = '.rp-print-value{white-space:pre-wrap;color:#000;padding:1px 0 2px}'; /** - * Кнопка «Печать»: переносит документ (`.rp-print-root`) в отдельное чистое окно, + * Переносит документ (по умолчанию `.rp-print-root`) в отдельное чистое окно, * заменяя поля ввода на текст значений, и печатает его. Не зависит от скролл- - * контейнеров/высоты SharePoint, корректно разбивается на страницы. Общая для форм. + * контейнеров/высоты SharePoint, корректно разбивается на страницы. Вынесена + * из PrintButton отдельной функцией, чтобы печать можно было вызвать + * программно — например, сразу после выбора варианта в DropdownButton, а не + * только по клику на саму кнопку (см. PrintSplitButton в request-portal). + */ +export function printRoot(selector = '.rp-print-root'): void { + const source = document.querySelector(selector); + if (!source) { + window.print(); + return; + } + const win = window.open('', '_blank', 'width=980,height=1200'); + if (!win) { + window.print(); // всплывающее окно заблокировано — печатаем как есть + return; + } + + const clone = source.cloneNode(true) as Element; + // Убираем из печати всё помеченное rp-no-print (напр. блок «Вложения»). + clone.querySelectorAll('.rp-no-print').forEach((el) => el.remove()); + renderValuesAsText(source, clone); + + // Переносим стили страницы (CSS-модули инжектятся как ${clone.outerHTML}` + ); + win.document.close(); + win.focus(); + + // Даём стилям/шрифтам примениться, затем печатаем и закрываем окно. + const done = (): void => { + win.print(); + win.close(); + }; + if (win.document.readyState === 'complete') { + window.setTimeout(done, 350); + } else { + win.onload = (): void => { + window.setTimeout(done, 350); + }; + } +} + +/** + * Кнопка «Печать» — тонкая обёртка над printRoot(). Общая для форм. */ export const PrintButton: React.FC<{ label?: string }> = ({ label = 'Печать' }) => { - const onPrint = (): void => { - const source = document.querySelector('.rp-print-root'); - if (!source) { - window.print(); - return; - } - const win = window.open('', '_blank', 'width=980,height=1200'); - if (!win) { - window.print(); // всплывающее окно заблокировано — печатаем как есть - return; - } - - const clone = source.cloneNode(true) as Element; - // Убираем из печати всё помеченное rp-no-print (напр. блок «Вложения»). - clone.querySelectorAll('.rp-no-print').forEach((el) => el.remove()); - renderValuesAsText(source, clone); - - // Переносим стили страницы (CSS-модули инжектятся как ${clone.outerHTML}` - ); - win.document.close(); - win.focus(); - - // Даём стилям/шрифтам примениться, затем печатаем и закрываем окно. - const done = (): void => { - win.print(); - win.close(); - }; - if (win.document.readyState === 'complete') { - window.setTimeout(done, 350); - } else { - win.onload = (): void => { - window.setTimeout(done, 350); - }; - } - }; - return ( - diff --git a/src/index.ts b/src/index.ts index 9cc745e..b5591bb 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,9 +5,11 @@ // --- Кнопки --- export { Button } from './Button/Button'; -export { PrintButton } from './PrintButton/PrintButton'; +export { PrintButton, printRoot } from './PrintButton/PrintButton'; export { IconButton } from './IconButton/IconButton'; export type { IIconButtonProps } from './IconButton/IconButton'; +export { DropdownButton } from './DropdownButton/DropdownButton'; +export type { IDropdownButtonProps, IDropdownButtonOption } from './DropdownButton/DropdownButton'; // --- Сообщения / плашки --- export { Alert } from './Alert/Alert';