feat: DropdownButton + printRoot() экспорт из PrintButton (0.12.0)
Кнопка с выпадающим меню (клик по кнопке — открыть список, выбор варианта сразу вызывает onSelect). Логика печати PrintButton вынесена в printRoot(), чтобы её можно было вызвать программно после выбора варианта в DropdownButton.
This commit is contained in:
+1
-1
@@ -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>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -59,57 +59,63 @@ 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 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-модули инжектятся как <style>, шрифты — <link>).
|
||||||
|
const head = Array.from(document.querySelectorAll('style, link[rel="stylesheet"]'))
|
||||||
|
.map((n) => n.outerHTML)
|
||||||
|
.join('\n');
|
||||||
|
|
||||||
|
win.document.open();
|
||||||
|
win.document.write(
|
||||||
|
`<!doctype html><html><head><meta charset="utf-8">${head}` +
|
||||||
|
`<style>${PRINT_CSS}</style></head><body>${clone.outerHTML}</body></html>`
|
||||||
|
);
|
||||||
|
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 = 'Печать' }) => {
|
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-модули инжектятся как <style>, шрифты — <link>).
|
|
||||||
const head = Array.from(document.querySelectorAll('style, link[rel="stylesheet"]'))
|
|
||||||
.map((n) => n.outerHTML)
|
|
||||||
.join('\n');
|
|
||||||
|
|
||||||
win.document.open();
|
|
||||||
win.document.write(
|
|
||||||
`<!doctype html><html><head><meta charset="utf-8">${head}` +
|
|
||||||
`<style>${PRINT_CSS}</style></head><body>${clone.outerHTML}</body></html>`
|
|
||||||
);
|
|
||||||
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 (
|
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
@@ -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';
|
||||||
|
|||||||
Reference in New Issue
Block a user