@brandup/ui-kit 1.0.45 → 1.0.48

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.
package/README.md CHANGED
@@ -55,8 +55,45 @@ PopupManager.close();
55
55
 
56
56
  // Проверить, открыт ли какой-либо попап
57
57
  PopupManager.isOpened(); // boolean
58
+
59
+ // ...или именно этот — так после open() узнают, открылся попап или закрылся повторным нажатием
60
+ PopupManager.isOpened(popupElem); // boolean
58
61
  ```
59
62
 
63
+ ### Состояния
64
+
65
+ Пока попап открыт, классы расставлены так:
66
+
67
+ | Элемент | Класс | Константа |
68
+ | --------- | ------------------- | ------------------------- |
69
+ | Попап | `opened` | `POPUP_OPENED_CLASS` |
70
+ | Инициатор | `ui-popup-expanded` | `POPUP_EXPANDED_CLASS` |
71
+ | `body` | `ui-popup-opened` | `POPUP_OPENED_BODY_CLASS` |
72
+
73
+ Класс на `body` — точка расширения для страницы: например, чтобы придержать фон, пока попап открыт.
74
+
75
+ ### Закрытие
76
+
77
+ Попап закрывается кликом мимо себя, повторным кликом по инициатору, клавишей `Escape` и навигацией
78
+ приложения. Свои обработчики `Escape` компоненту не нужны — менеджер снимает попап сам; если нужно
79
+ вернуть фокус на инициатор, делайте это в `onClose`.
80
+
81
+ ### Узкий экран
82
+
83
+ До `@adaptive-tablet-small` (850px) попап позиционируется относительно инициатора. Ниже этой ширины
84
+ рядом с кнопкой места уже нет, поэтому попап отрывается от неё и показывается окном по центру экрана
85
+ с затемнением — `position: fixed`, ширина по экрану до `--popup-window-max-width`, прокрутка страницы
86
+ под ним придержана через `body.ui-popup-opened`. Собственные `top` / `left` / `width` попапа в этом
87
+ режиме перекрываются: правила кита идут с `body` в селекторе и весят больше, чем два класса.
88
+
89
+ Настраивается переменными `--popup-backdrop`, `--popup-window-inset`, `--popup-window-max-width`.
90
+
91
+ Ловушки фокуса в этом режиме нет: за экраном попапа остаются достижимые с клавиатуры элементы
92
+ страницы. Если попап — полноценный диалог, берите `Modal`.
93
+
94
+ Прокрутку держит `overflow: hidden` на `body` — в Safari на iOS этого не всегда достаточно, страница
95
+ под попапом может тянуться. Оговорка общая для попапа, модального окна и списка дропдауна.
96
+
60
97
  ## Modal
61
98
 
62
99
  Базовое модальное окно: затемнение, шапка с заголовком и крестиком, тело. Наследник наполняет `body` в своём конструкторе и живёт до `close()`; само окно знает только про рамку, слои и закрытие.
@@ -99,6 +136,20 @@ const list = DOM.tag("div", { class: SCROLLABLE_CLASS });
99
136
 
100
137
  Оформление держится на `::-webkit-scrollbar`: стандартные `scrollbar-width`/`scrollbar-color` не задаются намеренно — Blink при них отдаёт системную полосу со стрелками. Браузерам без `::-webkit-scrollbar` (Firefox) стандартные свойства выдаются отдельным правилом.
101
138
 
139
+ ### Полоса прокрутки страницы
140
+
141
+ Место под полосой прокрутки страницы зарезервировано всегда — `scrollbar-gutter: stable` на `html` и `body`. Без этого страница дёргается по ширине каждый раз, когда прокрутку придерживают (`ui-popup-opened`, `ui-modal-opened`) или контент перестаёт её требовать: полоса пропадает, вьюпорт становится шире на её толщину.
142
+
143
+ Ценой этого на странице, которой прокрутка не нужна, остаётся пустая полоса. Если такое поведение не нужно, выключите резерв переменной:
144
+
145
+ ```css
146
+ :root {
147
+ --scrollbar-gutter: auto;
148
+ }
149
+ ```
150
+
151
+ Браузеры без поддержки `scrollbar-gutter` (Safari до 18.2) объявление игнорируют, но там, где полосы накладные (iOS, Android), ширина и так не прыгает.
152
+
102
153
  ## Утилиты
103
154
 
104
155
  ```typescript
package/package.json CHANGED
@@ -25,7 +25,7 @@
25
25
  },
26
26
  "type": "module",
27
27
  "license": "Apache-2.0",
28
- "version": "1.0.45",
28
+ "version": "1.0.48",
29
29
  "main": "source/index.ts",
30
30
  "types": "source/index.ts",
31
31
  "dependencies": {
@@ -38,23 +38,25 @@
38
38
  --svg-fill: @svg-fill;
39
39
  --svg-stroke: @svg-stroke;
40
40
 
41
- // popup
42
- --popup-fill: @popup-fill;
43
- --popup-color: @popup-color;
44
- --popup-border-style: @popup-border-style;
45
- --popup-border-width: @popup-border-width;
46
- --popup-border-color: @popup-border-color;
47
- --popup-border-radius: @popup-border-radius;
48
- --popup-box-shadow: @popup-box-shadow;
49
-
50
41
  // scrollable
51
42
  --scrollbar-size: @scrollbar-size;
52
43
  --scrollbar-thumb: @scrollbar-thumb;
53
44
  --scrollbar-thumb-radius: @scrollbar-thumb-radius;
45
+ --scrollbar-gutter: @scrollbar-gutter;
54
46
  }
55
47
 
56
48
  // Body styles
57
49
 
50
+ // Место под полосой прокрутки держим занятым всегда. Иначе страница дёргается по ширине каждый
51
+ // раз, когда прокрутку придерживают (попап-окно, модальное окно): полоса пропадает, вьюпорт
52
+ // становится шире на её толщину, и весь контент уезжает вбок. Свойство пишем на обоих элементах:
53
+ // полосу вьюпорта задаёт то из них, чей overflow до вьюпорта доехал (у нас это body), но движки
54
+ // читают gutter с корня. Отключается переменной: --scrollbar-gutter: auto.
55
+ html,
56
+ body {
57
+ scrollbar-gutter: var(--scrollbar-gutter);
58
+ }
59
+
58
60
  body {
59
61
  -webkit-font-smoothing: antialiased;
60
62
  -moz-osx-font-smoothing: grayscale;
@@ -127,31 +129,6 @@ svg {
127
129
  box-sizing: border-box;
128
130
  }
129
131
 
130
- // Popup styles
131
-
132
- .ui-popup {
133
- position: absolute;
134
- box-sizing: border-box;
135
- z-index: 1000;
136
- background: var(--popup-fill);
137
- color: var(--popup-color);
138
- border-style: var(--popup-border-style);
139
- border-color: var(--popup-border-color);
140
- border-width: var(--popup-border-width);
141
- border-radius: var(--popup-border-radius);
142
- box-shadow: var(--popup-box-shadow);
143
- visibility: collapse;
144
-
145
- &.opened {
146
- visibility: visible;
147
- }
148
- }
149
-
150
- .ui-popup-expanded {
151
- & ~ .ui-popup {
152
- visibility: visible;
153
- }
154
- }
155
132
  // Scrollable styles
156
133
 
157
134
  // Прокручиваемая область с оформленной полосой: везде в ките она выглядит одинаково,
package/source/modal.less CHANGED
@@ -8,7 +8,7 @@
8
8
  --modal-color: var(--popup-color);
9
9
  --modal-border-radius: var(--popup-border-radius);
10
10
  --modal-box-shadow: var(--popup-box-shadow);
11
- --modal-backdrop: rgba(0, 0, 0, 0.45);
11
+ --modal-backdrop: var(--popup-backdrop);
12
12
  --modal-width: 480px;
13
13
  --modal-padding: 20px;
14
14
  --modal-z-index: 2000; // выше popup (1000): окно перекрывает и его
@@ -0,0 +1,81 @@
1
+ @import (reference) "../vars.less";
2
+ @import (reference) "adaptive.less";
3
+
4
+ // Всплывающая поверхность: позиционируется рядом с инициатором и показывается по классу.
5
+
6
+ :root {
7
+ --popup-fill: @popup-fill;
8
+ --popup-color: @popup-color;
9
+ --popup-border-style: @popup-border-style;
10
+ --popup-border-width: @popup-border-width;
11
+ --popup-border-color: @popup-border-color;
12
+ --popup-border-radius: @popup-border-radius;
13
+ --popup-box-shadow: @popup-box-shadow;
14
+
15
+ // попап, оторванный от инициатора на узком экране: отступ от краёв экрана и предел ширины
16
+ --popup-backdrop: rgba(0, 0, 0, 0.45);
17
+ --popup-window-inset: 20px;
18
+ --popup-window-max-width: 540px;
19
+ }
20
+
21
+ .ui-popup {
22
+ position: absolute;
23
+ box-sizing: border-box;
24
+ z-index: 1000;
25
+ background: var(--popup-fill);
26
+ color: var(--popup-color);
27
+ border-style: var(--popup-border-style);
28
+ border-color: var(--popup-border-color);
29
+ border-width: var(--popup-border-width);
30
+ border-radius: var(--popup-border-radius);
31
+ box-shadow: var(--popup-box-shadow);
32
+ visibility: collapse;
33
+
34
+ &.opened {
35
+ visibility: visible;
36
+ }
37
+ }
38
+
39
+ .ui-popup-expanded {
40
+ & ~ .ui-popup {
41
+ visibility: visible;
42
+ }
43
+ }
44
+
45
+ // На узком экране попап отрывается от инициатора и показывается окном по центру с затемнением:
46
+ // места рядом с кнопкой уже нет, а попап у края экрана уезжает за него. Так же ведёт себя popup
47
+ // dropdown, только у него подложка — отдельный элемент разметки.
48
+ .adaptive-tablet-small({
49
+ // Селектор с body намеренно: потребитель позиционирует попап вложенным правилом вида
50
+ // `.ui-messageeditor .ui-richeditor-emoji` — это два класса, столько же, сколько у пары
51
+ // `.ui-popup.opened`, и при равной специфичности исход решал бы порядок стилей в сборке.
52
+ // Элемент в селекторе даёт тот вес, которого паре классов не хватает.
53
+ body .ui-popup.opened,
54
+ body .ui-popup-expanded ~ .ui-popup {
55
+ position: fixed;
56
+ inset: 0;
57
+ margin: auto;
58
+ width: calc(100% - var(--popup-window-inset) * 2);
59
+ max-width: var(--popup-window-max-width);
60
+ // Браузер без fit-content отбросит свойство, и при inset: 0 окно растянется на всю
61
+ // высоту экрана — режим сохраняется, теряется только подгонка под содержимое.
62
+ height: fit-content;
63
+ max-height: calc(100% - var(--popup-window-inset) * 2);
64
+ overflow: auto;
65
+
66
+ // Затемнение — тенью с растяжкой на весь экран: отдельной подложки в разметке нет,
67
+ // а псевдоэлемент пришлось бы уводить под фон самого попапа (z-index: -1), где он
68
+ // перекрыл бы фон, но не текст. Собственную тень попапа не подмешиваем: поверх
69
+ // затемнения её всё равно не видно, а список с `none` (--popup-box-shadow может быть
70
+ // и таким) невалиден — декларация отвалилась бы целиком вместе с затемнением.
71
+ // Клик по тени не ловится и уходит на страницу — то есть закрывает попап, как и любой
72
+ // клик снаружи.
73
+ box-shadow: 0 0 0 100vmax var(--popup-backdrop);
74
+ }
75
+
76
+ // Класс ставит PopupManager: пока открыт попап-окно, страница под ним не прокручивается.
77
+ // По ширине она при этом не дёргается — место под полосой держит --scrollbar-gutter (common.less).
78
+ body.ui-popup-opened {
79
+ overflow: hidden;
80
+ }
81
+ });
package/source/popup.ts CHANGED
@@ -1,5 +1,11 @@
1
+ import "./popup.less"; // стили всплывающей поверхности
2
+
1
3
  export const POPUP_CLASS = "ui-popup";
4
+ /** Ставится на сам попап, пока он открыт. */
5
+ export const POPUP_OPENED_CLASS = "opened";
2
6
  export const POPUP_EXPANDED_CLASS = "ui-popup-expanded";
7
+ /** Ставится на body, пока открыт попап: страница может подстроиться под него (см. popup.less). */
8
+ export const POPUP_OPENED_BODY_CLASS = "ui-popup-opened";
3
9
  export const POPUP_COMMAND = "ui-popup-toggle";
4
10
 
5
11
  type CurrentPopup = {
@@ -27,17 +33,28 @@ const closePopupEventHandler = (e: MouseEvent) => {
27
33
  }
28
34
  };
29
35
 
36
+ // Escape закрывает попап — это ожидание от любого всплывающего слоя, и держать свой обработчик
37
+ // каждому компоненту незачем. Слушатель не перехватывающий: обработчики на самом попапе получают
38
+ // клавишу первыми и успевают сделать своё (вернуть фокус, отменить ввод).
39
+ const closePopupKeyHandler = (e: KeyboardEvent) => {
40
+ if (e.key !== "Escape") return;
41
+
42
+ close();
43
+ };
44
+
30
45
  const close = () => {
31
46
  if (current) {
32
47
  if (current.closeCallback) current.closeCallback();
33
48
 
34
49
  current.initiator?.classList.remove(POPUP_EXPANDED_CLASS); // закрываем последнее открытое контекстное меню
35
- current.popup.classList.remove("opened");
50
+ current.popup.classList.remove(POPUP_OPENED_CLASS);
36
51
 
37
52
  current = null;
38
53
  }
39
54
 
55
+ document.body.classList.remove(POPUP_OPENED_BODY_CLASS);
40
56
  document.body.removeEventListener("click", closePopupEventHandler);
57
+ document.removeEventListener("keydown", closePopupKeyHandler);
41
58
  };
42
59
 
43
60
  const open = (popupElem: HTMLElement, options?: PopupOptions) => {
@@ -50,12 +67,14 @@ const open = (popupElem: HTMLElement, options?: PopupOptions) => {
50
67
 
51
68
  newPopup.closeCallback = options?.onClose;
52
69
 
53
- if (newPopup.popup.classList.toggle("opened")) {
70
+ if (newPopup.popup.classList.toggle(POPUP_OPENED_CLASS)) {
54
71
  // это новый popup, открываем его
55
72
 
56
73
  newPopup.initiator?.classList.add(POPUP_EXPANDED_CLASS);
74
+ document.body.classList.add(POPUP_OPENED_BODY_CLASS);
57
75
 
58
76
  document.body.addEventListener("click", closePopupEventHandler);
77
+ document.addEventListener("keydown", closePopupKeyHandler);
59
78
 
60
79
  current = newPopup;
61
80
  } else {
@@ -67,13 +86,14 @@ const open = (popupElem: HTMLElement, options?: PopupOptions) => {
67
86
  export const PopupManager: IPopupManager = {
68
87
  open,
69
88
  close,
70
- isOpened: () => !!current,
89
+ isOpened: (popupElem?: HTMLElement) => (popupElem ? current?.popup === popupElem : !!current),
71
90
  };
72
91
 
73
92
  interface IPopupManager {
74
93
  open: (popupElem: HTMLElement, options?: PopupOptions) => void;
75
94
  close: () => void;
76
- isOpened: () => boolean;
95
+ /** Без аргумента — открыт ли хоть один попап; с аргументом — открыт ли именно этот. */
96
+ isOpened: (popupElem?: HTMLElement) => boolean;
77
97
  }
78
98
 
79
99
  interface PopupOptions {
package/vars.less CHANGED
@@ -51,6 +51,7 @@
51
51
  @scrollbar-size: 6px;
52
52
  @scrollbar-thumb: #8696a0;
53
53
  @scrollbar-thumb-radius: 3px;
54
+ @scrollbar-gutter: stable;
54
55
 
55
56
  // inputs
56
57