@evergis/uilib-gl 1.0.139 → 1.0.140

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -17,6 +17,11 @@ export interface IIconButtonProps extends ButtonHTMLAttributes<HTMLButtonElement
17
17
  onClick?: (e: MouseEvent<HTMLButtonElement>) => void;
18
18
  icon?: ReactNode;
19
19
  iconRight?: boolean;
20
+ /**
21
+ * Иконка над подписью, подпись по центру с переносом по словам, шеврон — под подписью.
22
+ * Перекрывает iconRight. Работает в обоих режимах, сам по себе новый режим не включает.
23
+ */
24
+ iconTop?: boolean;
20
25
  active?: boolean;
21
26
  /** Заливка и обводка. Включает новый режим. */
22
27
  variant?: IconButtonVariant;
@@ -42,6 +47,7 @@ export interface IconButtonProps {
42
47
  error?: boolean;
43
48
  notFixedWidth?: boolean;
44
49
  iconRight?: boolean;
50
+ iconTop?: boolean;
45
51
  active?: boolean;
46
52
  hasBg?: boolean;
47
53
  }
@@ -0,0 +1,8 @@
1
+ /** Минимальная высота поля с autoResize, если `minHeight` не задан. */
2
+ export declare const DEFAULT_AUTO_RESIZE_MIN_HEIGHT = 36;
3
+ /** Текст замера пустого поля без placeholder — даёт высоту одной строки. */
4
+ export declare const EMPTY_MEASURE_TEXT = "x";
5
+ /** Свойства, от которых зависит раскладка текста: замерщик повторяет их за полем. */
6
+ export declare const SIZING_STYLE_PROPERTIES: string[];
7
+ /** Замерщик невидим и нулевой высоты — его `scrollHeight` равен высоте текста с отступами. */
8
+ export declare const HIDDEN_TEXTAREA_STYLE: string;
@@ -0,0 +1,2 @@
1
+ export * from "./useTextareaAutoResize";
2
+ export * from "./useTextareaResizeTriggers";
@@ -0,0 +1,15 @@
1
+ import { CSSProperties } from "react";
2
+ import { ITextareaProps } from "../types";
3
+ type UseTextareaAutoResizeParams = Pick<ITextareaProps, "autoResize" | "value" | "placeholder" | "minHeight" | "maxHeight" | "innerRef">;
4
+ /**
5
+ * Высота поля с autoResize — по фактической раскладке текста (см. `calculateTextareaHeight`).
6
+ *
7
+ * Пересчёт идёт до отрисовки при смене значения, placeholder и пределов, а события вне React
8
+ * ловит `useTextareaResizeTriggers`. Высота уходит inline-стилем: через styled-проп каждое её
9
+ * значение порождало бы новый класс.
10
+ */
11
+ export declare const useTextareaAutoResize: ({ autoResize, value, placeholder, minHeight, maxHeight, innerRef, }: UseTextareaAutoResizeParams) => {
12
+ ref: (node: HTMLTextAreaElement | null) => void;
13
+ style: CSSProperties | undefined;
14
+ };
15
+ export {};
@@ -0,0 +1,6 @@
1
+ import { MutableRefObject } from "react";
2
+ /**
3
+ * Пересчёт высоты на события вне React: ввод в неконтролируемое поле, смена ширины поля (перенос
4
+ * строк меняется вместе с ней) и загрузка шрифтов (меняет ширину глифов и высоту строки).
5
+ */
6
+ export declare const useTextareaResizeTriggers: (element: MutableRefObject<HTMLTextAreaElement | null>, enabled: boolean | undefined, updateHeight: () => void) => void;
@@ -4,6 +4,8 @@ export interface ITextareaProps extends ITextFieldProps {
4
4
  help?: ReactNode;
5
5
  id?: string;
6
6
  minHeight?: number;
7
+ /** Предел высоты в пикселях: `autoResize` дальше не растит, лишнее прокручивается. */
8
+ maxHeight?: number;
7
9
  autoResize?: boolean;
8
10
  innerRef?: RefType<HTMLTextAreaElement>;
9
11
  }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Высота, при которой текст поля помещается без прокрутки, — в единицах `box-sizing` поля.
3
+ *
4
+ * Текст раскладывает сам браузер: замерщик повторяет ширину, шрифт, отступы и рамки поля, а
5
+ * нулевая высота делает его `scrollHeight` равным высоте текста. Поэтому замер верен для любых
6
+ * глифов, переносов по словам и пустых строк.
7
+ */
8
+ export declare const calculateTextareaHeight: (textarea: HTMLTextAreaElement) => number;
@@ -14657,12 +14657,24 @@ const getRadius = props => {
14657
14657
 
14658
14658
  return props.$shape === "round" ? "9999px" : getSize(props).radius;
14659
14659
  };
14660
+
14661
+ const getPaddingX = props => props.$iconOnly ? "0" : getSize(props).paddingX;
14662
+ /** Колонка: иконка, подпись по центру с переносом, шеврон. Вертикальный отступ равен gap размера. */
14663
+
14664
+
14665
+ const iconButtonIconTopMixin = css`
14666
+ flex-direction: column;
14667
+ height: auto;
14668
+ min-height: ${props => getSize(props).height};
14669
+ padding: ${props => getSize(props).gap} ${getPaddingX};
14670
+ white-space: normal;
14671
+ text-align: center;
14672
+ `;
14660
14673
  /**
14661
14674
  * Состояния: проп active держит «нажатый» вид постоянно (и на hover), data-state="hover|active"
14662
14675
  * включает состояние статично (для Storybook). Disabled объявлен последним — побеждает остальные.
14663
14676
  */
14664
14677
 
14665
-
14666
14678
  const iconButtonStatesMixin = _ref2 => {
14667
14679
  let {
14668
14680
  theme,
@@ -14708,7 +14720,7 @@ const iconButtonVariantMixin = css`
14708
14720
  height: ${props => getSize(props).height};
14709
14721
  min-width: ${props => getSize(props).height};
14710
14722
  width: ${props => props.$iconOnly ? getSize(props).height : "auto"};
14711
- padding: 0 ${props => props.$iconOnly ? "0" : getSize(props).paddingX};
14723
+ padding: 0 ${getPaddingX};
14712
14724
  border: none;
14713
14725
  border-radius: ${getRadius};
14714
14726
  outline: none;
@@ -14717,13 +14729,20 @@ const iconButtonVariantMixin = css`
14717
14729
  text-transform: ${props => getSize(props).textTransform};
14718
14730
  white-space: nowrap;
14719
14731
  transition: background-color ${transition.hover}, color ${transition.hover}, box-shadow ${transition.hover};
14732
+ ${_ref3 => {
14733
+ let {
14734
+ iconTop
14735
+ } = _ref3;
14736
+ return iconTop && iconButtonIconTopMixin;
14737
+ }};
14720
14738
 
14721
14739
  ${Icon} {
14722
- order: ${_ref3 => {
14740
+ order: ${_ref4 => {
14723
14741
  let {
14724
- iconRight
14725
- } = _ref3;
14726
- return iconRight ? 2 : 0;
14742
+ iconRight,
14743
+ iconTop
14744
+ } = _ref4;
14745
+ return iconRight && !iconTop ? 2 : 0;
14727
14746
  }};
14728
14747
  display: flex;
14729
14748
  align-items: center;
@@ -14856,7 +14875,20 @@ const buttonMixin = css`
14856
14875
  pointer-events: none;
14857
14876
  }
14858
14877
  `;
14859
- const legacyIconButtonMixin = css`
14878
+ /** Колонка: иконка, под ней подпись по центру; перекрывает iconRight. */
14879
+
14880
+ const defaultIconTopMixin = css`
14881
+ flex-direction: column;
14882
+ height: auto;
14883
+ min-height: 2.25rem;
14884
+ padding: 0.5rem 1rem;
14885
+ text-align: center;
14886
+
14887
+ ${IconButtonInnerChild} {
14888
+ margin: 0.25rem 0 0;
14889
+ }
14890
+ `;
14891
+ const defaultIconButtonMixin = css`
14860
14892
  &:hover > ${Icon}, &:hover > ${IconButtonInnerChild} {
14861
14893
  color: ${_ref7 => {
14862
14894
  let {
@@ -14903,6 +14935,12 @@ const legacyIconButtonMixin = css`
14903
14935
  }
14904
14936
 
14905
14937
  ${buttonMixin};
14938
+ ${_ref11 => {
14939
+ let {
14940
+ iconTop
14941
+ } = _ref11;
14942
+ return iconTop && defaultIconTopMixin;
14943
+ }};
14906
14944
  `;
14907
14945
  const IconButtonButton = /*#__PURE__*/styled.button.attrs(props => ({
14908
14946
  tabIndex: -1,
@@ -14910,11 +14948,11 @@ const IconButtonButton = /*#__PURE__*/styled.button.attrs(props => ({
14910
14948
  }))`
14911
14949
  flex-shrink: 0;
14912
14950
 
14913
- ${_ref11 => {
14951
+ ${_ref12 => {
14914
14952
  let {
14915
14953
  $variant
14916
- } = _ref11;
14917
- return $variant ? iconButtonVariantMixin : legacyIconButtonMixin;
14954
+ } = _ref12;
14955
+ return $variant ? iconButtonVariantMixin : defaultIconButtonMixin;
14918
14956
  }};
14919
14957
  `;
14920
14958
 
@@ -22293,64 +22331,215 @@ const TextField = /*#__PURE__*/styled.textarea.attrs(props => ({
22293
22331
  }};
22294
22332
  `;
22295
22333
 
22296
- const LabeledTextarea = /*#__PURE__*/withLabel(TextField);
22334
+ /** Минимальная высота поля с autoResize, если `minHeight` не задан. */
22335
+ const DEFAULT_AUTO_RESIZE_MIN_HEIGHT = 36;
22336
+ /** Текст замера пустого поля без placeholder — даёт высоту одной строки. */
22297
22337
 
22298
- const TextareaComponent = _ref => {
22338
+ const EMPTY_MEASURE_TEXT = "x";
22339
+ /** Свойства, от которых зависит раскладка текста: замерщик повторяет их за полем. */
22340
+
22341
+ const SIZING_STYLE_PROPERTIES = ["box-sizing", "width", "font-family", "font-size", "font-style", "font-weight", "font-variant", "font-stretch", "font-feature-settings", "letter-spacing", "word-spacing", "line-height", "text-indent", "text-transform", "text-rendering", "tab-size", "white-space", "word-break", "overflow-wrap", "padding-top", "padding-right", "padding-bottom", "padding-left", "border-top-width", "border-right-width", "border-bottom-width", "border-left-width", "border-top-style", "border-right-style", "border-bottom-style", "border-left-style"];
22342
+ /** Замерщик невидим и нулевой высоты — его `scrollHeight` равен высоте текста с отступами. */
22343
+
22344
+ const HIDDEN_TEXTAREA_STYLE = /*#__PURE__*/["min-height:0", "max-height:none", "height:0", "visibility:hidden", "overflow:hidden", "position:absolute", "z-index:-1000", "top:0", "right:0", "pointer-events:none"].map(declaration => `${declaration} !important`).join(";");
22345
+
22346
+ let hiddenTextarea = null;
22347
+ /** Один замерщик на все поля: живёт в `body`, вне раскладки страницы. */
22348
+
22349
+ const getHiddenTextarea = () => {
22350
+ if (!hiddenTextarea || !hiddenTextarea.parentNode) {
22351
+ hiddenTextarea = document.createElement("textarea");
22352
+ hiddenTextarea.setAttribute("tabindex", "-1");
22353
+ hiddenTextarea.setAttribute("aria-hidden", "true");
22354
+ document.body.appendChild(hiddenTextarea);
22355
+ }
22356
+
22357
+ return hiddenTextarea;
22358
+ };
22359
+ /** Пустое поле меряется по placeholder, чтобы длинная подсказка не обрезалась. */
22360
+
22361
+
22362
+ const getMeasureText = _ref => {
22299
22363
  let {
22300
22364
  value,
22301
- minHeight = 36,
22365
+ placeholder
22366
+ } = _ref;
22367
+ return value || placeholder || EMPTY_MEASURE_TEXT;
22368
+ };
22369
+ /**
22370
+ * Высота, при которой текст поля помещается без прокрутки, — в единицах `box-sizing` поля.
22371
+ *
22372
+ * Текст раскладывает сам браузер: замерщик повторяет ширину, шрифт, отступы и рамки поля, а
22373
+ * нулевая высота делает его `scrollHeight` равным высоте текста. Поэтому замер верен для любых
22374
+ * глифов, переносов по словам и пустых строк.
22375
+ */
22376
+
22377
+
22378
+ const calculateTextareaHeight = textarea => {
22379
+ const computedStyle = window.getComputedStyle(textarea);
22380
+ const measurer = getHiddenTextarea();
22381
+ const sizingStyle = SIZING_STYLE_PROPERTIES.map(property => `${property}:${computedStyle.getPropertyValue(property)}`).join(";");
22382
+ measurer.style.cssText = `${sizingStyle};${HIDDEN_TEXTAREA_STYLE}`;
22383
+ measurer.value = getMeasureText(textarea);
22384
+ const {
22385
+ scrollHeight
22386
+ } = measurer; // scrollHeight включает внутренние отступы, но не рамку.
22387
+
22388
+ if (computedStyle.boxSizing === "border-box") {
22389
+ return scrollHeight + getNumberFromPixels(computedStyle.borderTopWidth) + getNumberFromPixels(computedStyle.borderBottomWidth);
22390
+ }
22391
+
22392
+ return scrollHeight - getNumberFromPixels(computedStyle.paddingTop) - getNumberFromPixels(computedStyle.paddingBottom);
22393
+ };
22394
+
22395
+ /**
22396
+ * Пересчёт высоты на события вне React: ввод в неконтролируемое поле, смена ширины поля (перенос
22397
+ * строк меняется вместе с ней) и загрузка шрифтов (меняет ширину глифов и высоту строки).
22398
+ */
22399
+
22400
+ const useTextareaResizeTriggers = (element, enabled, updateHeight) => {
22401
+ const latestUpdateHeight = useRef(updateHeight);
22402
+ latestUpdateHeight.current = updateHeight;
22403
+ useEffect(() => {
22404
+ const textarea = element.current;
22405
+ if (!enabled || !textarea) return;
22406
+ let frameId = 0;
22407
+ let isActive = true;
22408
+ let width = textarea.offsetWidth; // Через кадр: смена высоты внутри колбэка ResizeObserver зациклила бы его.
22409
+
22410
+ const scheduleUpdate = () => {
22411
+ window.cancelAnimationFrame(frameId);
22412
+ frameId = window.requestAnimationFrame(() => latestUpdateHeight.current());
22413
+ }; // Своя смена высоты тоже будит наблюдателя — пересчёт нужен только при новой ширине.
22414
+
22415
+
22416
+ const onResize = () => {
22417
+ if (textarea.offsetWidth === width) return;
22418
+ width = textarea.offsetWidth;
22419
+ scheduleUpdate();
22420
+ };
22421
+
22422
+ const {
22423
+ ResizeObserver: ResizeObserverClass
22424
+ } = window;
22425
+ const resizeObserver = ResizeObserverClass ? new ResizeObserverClass(onResize) : null;
22426
+ const {
22427
+ fonts
22428
+ } = document;
22429
+ resizeObserver == null ? void 0 : resizeObserver.observe(textarea);
22430
+ textarea.addEventListener("input", scheduleUpdate);
22431
+ fonts == null ? void 0 : fonts.ready.then(() => isActive && scheduleUpdate());
22432
+ return () => {
22433
+ isActive = false;
22434
+ window.cancelAnimationFrame(frameId);
22435
+ resizeObserver == null ? void 0 : resizeObserver.disconnect();
22436
+ textarea.removeEventListener("input", scheduleUpdate);
22437
+ };
22438
+ }, [element, enabled]);
22439
+ };
22440
+
22441
+ /**
22442
+ * Высота поля с autoResize — по фактической раскладке текста (см. `calculateTextareaHeight`).
22443
+ *
22444
+ * Пересчёт идёт до отрисовки при смене значения, placeholder и пределов, а события вне React
22445
+ * ловит `useTextareaResizeTriggers`. Высота уходит inline-стилем: через styled-проп каждое её
22446
+ * значение порождало бы новый класс.
22447
+ */
22448
+
22449
+ const useTextareaAutoResize = _ref => {
22450
+ let {
22302
22451
  autoResize,
22303
- ...textareaProps
22452
+ value,
22453
+ placeholder,
22454
+ minHeight = DEFAULT_AUTO_RESIZE_MIN_HEIGHT,
22455
+ maxHeight,
22456
+ innerRef
22304
22457
  } = _ref;
22305
- const [height, setHeight] = useState(0);
22306
- const [containerSize, setContainerSize] = useState(0);
22307
- const [lettersSize, setLettersSize] = useState(0);
22308
22458
  const element = useRef(null);
22309
- const prevValue = useRef(value);
22310
- const updateTextareaHeight = useCallback(() => {
22311
- const valueLines = (value === void 0 || value === null ? "" : `${value}`).split("\n");
22312
- let countOfLines = valueLines.length;
22459
+ const [size, setSize] = useState(null);
22460
+ const updateHeight = useCallback(() => {
22461
+ const textarea = element.current; // Скрытое поле (display: none) не имеет ширины — замер был бы ложным, его догонит ResizeObserver.
22462
+
22463
+ if (!autoResize || !textarea || !textarea.offsetWidth) return;
22464
+ const contentHeight = calculateTextareaHeight(textarea);
22465
+ const limitedHeight = maxHeight ? Math.min(contentHeight, maxHeight) : contentHeight;
22466
+ const height = Math.max(minHeight, limitedHeight);
22467
+ const overflows = contentHeight > height;
22468
+ setSize(prevSize => (prevSize == null ? void 0 : prevSize.height) === height && prevSize.overflows === overflows ? prevSize : {
22469
+ height,
22470
+ overflows
22471
+ });
22472
+ }, [autoResize, minHeight, maxHeight]); // Значение и placeholder замер читает из DOM — в зависимостях они только будят пересчёт.
22313
22473
 
22314
- for (const line of valueLines) {
22315
- countOfLines += Math.round(line.length * lettersSize * 0.85 / containerSize);
22316
- }
22474
+ useLayoutEffect(() => {
22475
+ updateHeight();
22476
+ }, [updateHeight, value, placeholder]);
22477
+ useTextareaResizeTriggers(element, autoResize, updateHeight);
22478
+ const ref = useCallback(node => {
22479
+ element.current = node;
22480
+
22481
+ if (typeof innerRef === "function") {
22482
+ innerRef(node);
22483
+ } else if (innerRef) {
22484
+ innerRef.current = node;
22485
+ }
22486
+ }, [innerRef]);
22487
+ const style = useMemo(() => size ? {
22488
+ height: size.height,
22489
+ overflowY: size.overflows ? "auto" : "hidden"
22490
+ } : undefined, [size]);
22491
+ return {
22492
+ ref,
22493
+ style
22494
+ };
22495
+ };
22317
22496
 
22318
- const height = countOfLines === 1 ? lettersSize * 2 : lettersSize * countOfLines;
22319
- setHeight(!minHeight || height < minHeight ? minHeight : height);
22320
- }, [containerSize, lettersSize, minHeight, value]);
22321
- useEffect(() => {
22322
- if (autoResize && element.current) {
22323
- const {
22324
- fontSize,
22325
- width
22326
- } = getComputedStyle(element.current);
22327
- setContainerSize(getNumberFromPixels(width));
22328
- setLettersSize(getNumberFromPixels(fontSize));
22329
- updateTextareaHeight();
22330
- }
22331
- }, [autoResize, updateTextareaHeight]);
22332
- useEffect(() => {
22333
- if (autoResize && element.current) {
22334
- updateTextareaHeight();
22335
- }
22336
- }, [autoResize, updateTextareaHeight, value]);
22337
- useEffect(() => {
22338
- prevValue.current = value;
22339
- }, [value]);
22497
+ const LabeledTextarea = /*#__PURE__*/withLabel(TextField);
22498
+
22499
+ const TextareaComponent = _ref => {
22500
+ let {
22501
+ value,
22502
+ minHeight,
22503
+ maxHeight,
22504
+ autoResize,
22505
+ style,
22506
+ innerRef,
22507
+ ...textareaProps
22508
+ } = _ref;
22509
+ const {
22510
+ ref,
22511
+ style: autoResizeStyle
22512
+ } = useTextareaAutoResize({
22513
+ autoResize,
22514
+ value,
22515
+ placeholder: textareaProps.placeholder,
22516
+ minHeight,
22517
+ maxHeight,
22518
+ innerRef
22519
+ });
22340
22520
 
22341
22521
  if (autoResize) {
22342
22522
  return React.createElement(LabeledTextarea, {
22343
- height: `${height}px`,
22344
22523
  value: value,
22345
22524
  ...textareaProps,
22525
+ style: { ...style,
22526
+ ...autoResizeStyle
22527
+ },
22346
22528
  resize: "none",
22347
- innerRef: ref => element.current = ref
22529
+ innerRef: ref
22348
22530
  });
22349
- }
22531
+ } // Без autoResize высоту держит сам textarea: пределы ограничивают её и ручной resize.
22532
+
22350
22533
 
22351
22534
  return React.createElement(LabeledTextarea, {
22352
22535
  value: value,
22353
- ...textareaProps
22536
+ ...textareaProps,
22537
+ innerRef: innerRef,
22538
+ style: {
22539
+ minHeight,
22540
+ maxHeight,
22541
+ ...style
22542
+ }
22354
22543
  });
22355
22544
  };
22356
22545