feat: DropdownButton + printRoot() экспорт из PrintButton (0.12.0)

Кнопка с выпадающим меню (клик по кнопке — открыть список, выбор варианта
сразу вызывает onSelect). Логика печати PrintButton вынесена в printRoot(),
чтобы её можно было вызвать программно после выбора варианта в DropdownButton.
This commit is contained in:
Тимур Городецкий
2026-08-27 10:02:29 +05:00
parent f2e8108d1b
commit 0082b90c2b
6 changed files with 276 additions and 49 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@tnsu/ui-kit", "name": "@tnsu/ui-kit",
"version": "0.11.0", "version": "0.12.0",
"description": "TNSU shared UI components for SPFx web parts", "description": "TNSU shared UI components for SPFx web parts",
"author": "Timur Gorodetskiy", "author": "Timur Gorodetskiy",
"license": "ISC", "license": "ISC",
@@ -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;
}
}
@@ -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<typeof DropdownButton> = {
title: 'Кнопки/DropdownButton',
component: DropdownButton,
tags: ['autodocs'],
};
export default meta;
export const Default: StoryObj<typeof DropdownButton> = {
render: () => {
const [v, setV] = useState('0');
return (
<DropdownButton options={OPTIONS} value={v} onSelect={setV}>
<Icon name="document" size="sm" />
Печать
</DropdownButton>
);
},
};
+107
View File
@@ -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<IDropdownButtonProps> = ({
options,
onSelect,
value,
variant = 'secondary',
size = 'md',
disabled,
align = 'right',
children
}) => {
const [open, setOpen] = React.useState(false);
const rootRef = React.useRef<HTMLDivElement>(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 (
<div className={styles.root} ref={rootRef}>
<Button
type="button"
variant={variant}
size={size}
disabled={disabled}
aria-haspopup="menu"
aria-expanded={open}
onClick={() => setOpen((o) => !o)}
>
{children}
<Icon name="chevron-down" size="sm" className={open ? styles.chevronOpen : styles.chevron} />
</Button>
{open && (
<div className={`${styles.menu} ${align === 'left' ? styles.left : styles.right}`} role="menu">
{options.map((opt) => (
<button
key={opt.value}
type="button"
role="menuitem"
className={styles.item}
onClick={() => pick(opt.value)}
>
<span className={styles.itemLabel}>{opt.label}</span>
{value === opt.value && <Icon name="check" size="sm" className={styles.check} />}
</button>
))}
</div>
)}
</div>
);
};
+13 -7
View File
@@ -59,13 +59,15 @@ const PRINT_CSS =
'.rp-print-value{white-space:pre-wrap;color:#000;padding:1px 0 2px}'; '.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 const PrintButton: React.FC<{ label?: string }> = ({ label = 'Печать' }) => { export function printRoot(selector = '.rp-print-root'): void {
const onPrint = (): void => { const source = document.querySelector(selector);
const source = document.querySelector('.rp-print-root');
if (!source) { if (!source) {
window.print(); window.print();
return; return;
@@ -106,10 +108,14 @@ export const PrintButton: React.FC<{ label?: string }> = ({ label = 'Печат
window.setTimeout(done, 350); window.setTimeout(done, 350);
}; };
} }
}; }
/**
* Кнопка «Печать» — тонкая обёртка над printRoot(). Общая для форм.
*/
export const PrintButton: React.FC<{ label?: string }> = ({ label = 'Печать' }) => {
return ( return (
<Button variant="secondary" onClick={onPrint}> <Button variant="secondary" onClick={() => printRoot()}>
<Icon name="document" size="sm" /> <Icon name="document" size="sm" />
{label} {label}
</Button> </Button>
+3 -1
View File
@@ -5,9 +5,11 @@
// --- Кнопки --- // --- Кнопки ---
export { Button } from './Button/Button'; export { Button } from './Button/Button';
export { PrintButton } from './PrintButton/PrintButton'; export { PrintButton, printRoot } from './PrintButton/PrintButton';
export { IconButton } from './IconButton/IconButton'; export { IconButton } from './IconButton/IconButton';
export type { IIconButtonProps } 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'; export { Alert } from './Alert/Alert';