@brandup/ui-kit 1.0.53 → 1.0.54
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 +15 -1
- package/package.json +1 -1
- package/source/index.ts +2 -1
- package/source/middleware.ts +16 -0
- package/source/utils/compatibility.ts +17 -3
- package/source/utils/user-scroll.ts +82 -0
package/README.md
CHANGED
|
@@ -173,10 +173,24 @@ const list = DOM.tag("div", { class: SCROLLABLE_CLASS });
|
|
|
173
173
|
## Утилиты
|
|
174
174
|
|
|
175
175
|
```typescript
|
|
176
|
-
import { IS_TOUCH_DEVICE } from "@brandup/ui-kit";
|
|
176
|
+
import { IS_TOUCH_DEVICE, isCoarsePointer, hasUserScrolled, resetUserScroll } from "@brandup/ui-kit";
|
|
177
177
|
|
|
178
178
|
// true на touch-устройствах (мобильные, планшеты)
|
|
179
179
|
if (IS_TOUCH_DEVICE) { ... }
|
|
180
|
+
|
|
181
|
+
// true, когда основной указатель грубый — палец или стилус. В отличие от IS_TOUCH_DEVICE
|
|
182
|
+
// ноутбук с сенсорным экраном сюда не попадает: работают на нём мышью
|
|
183
|
+
if (isCoarsePointer()) { ... }
|
|
184
|
+
|
|
185
|
+
// true, если пользователь сам прокручивал страницу с момента её показа. Нужно отложенным
|
|
186
|
+
// действиям, которые двигают вид (автофокус контрола ввода). Считаются жесты — колесо, свайп,
|
|
187
|
+
// полоса прокрутки, автопрокрутка средней кнопкой, клавиши прокрутки, действие которых никто
|
|
188
|
+
// не отменил, — а не событие scroll: его поднимает и программная прокрутка
|
|
189
|
+
if (!hasUserScrolled()) { ... }
|
|
190
|
+
|
|
191
|
+
// забыть прокрутку — показана другая страница. При навигации это делает UiKitMiddleware,
|
|
192
|
+
// перед тем как новую страницу нарисуют: её контролы должны видеть уже чистый признак
|
|
193
|
+
resetUserScroll();
|
|
180
194
|
```
|
|
181
195
|
|
|
182
196
|
## Стили
|
package/package.json
CHANGED
package/source/index.ts
CHANGED
|
@@ -8,7 +8,8 @@ export * from "./popup";
|
|
|
8
8
|
export const SCROLLABLE_CLASS = "ui-scrollable";
|
|
9
9
|
|
|
10
10
|
export { default as Modal, MODAL_CLASS, MODAL_OPENED_CLASS, MODAL_CLOSE_COMMAND, type ModalOptions } from "./modal";
|
|
11
|
-
export { IS_TOUCH_DEVICE } from "./utils/compatibility";
|
|
11
|
+
export { IS_TOUCH_DEVICE, isCoarsePointer } from "./utils/compatibility";
|
|
12
|
+
export { hasUserScrolled, resetUserScroll } from "./utils/user-scroll";
|
|
12
13
|
export { textTag } from "./utils/text";
|
|
13
14
|
import "./styles.less";
|
|
14
15
|
|
package/source/middleware.ts
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
import { Middleware, MiddlewareNext, NavigateContext, StartContext } from "@brandup/ui-app";
|
|
2
2
|
import { PopupManager, POPUP_COMMAND, POPUP_CLASS } from "./popup";
|
|
3
|
+
import { resetUserScroll } from "./utils/user-scroll";
|
|
3
4
|
|
|
4
5
|
export class UiKitMiddleware implements Middleware {
|
|
5
6
|
name = "uikit";
|
|
6
7
|
|
|
8
|
+
private __navigated = false;
|
|
9
|
+
|
|
7
10
|
start(context: StartContext, next: MiddlewareNext) {
|
|
8
11
|
context.app.registerCommand(POPUP_COMMAND, (context) => {
|
|
9
12
|
if (!context.target.nextElementSibling?.classList.contains(POPUP_CLASS))
|
|
@@ -18,6 +21,19 @@ export class UiKitMiddleware implements Middleware {
|
|
|
18
21
|
navigate(_context: NavigateContext, next: MiddlewareNext) {
|
|
19
22
|
PopupManager.close(); // закрываем открытое контекстное меню при навигации
|
|
20
23
|
|
|
24
|
+
// Показана другая страница — прокрутка пользователя по прежней больше ни при чём
|
|
25
|
+
// (см. resetUserScroll). Первую навигацию пропускаем: она показывает ту же страницу,
|
|
26
|
+
// с которой приложение стартовало, и прокрутка во время его загрузки остаётся в силе —
|
|
27
|
+
// именно она и должна отменить автофокус.
|
|
28
|
+
//
|
|
29
|
+
// Забываем до next(), а не после: страницу рисуют внутри него, и её контролы обязаны
|
|
30
|
+
// видеть уже чистый признак. Плата за это — окно, пока новая страница ещё не нарисована:
|
|
31
|
+
// контрол, доинициализированный на прежней в этот момент, посчитает её непрокрученной.
|
|
32
|
+
// Возвращать флаг на неудачной навигации нельзя: редирект успевает нарисовать свою
|
|
33
|
+
// страницу до того, как отмена дойдёт сюда, и она получила бы чужую прокрутку.
|
|
34
|
+
if (this.__navigated) resetUserScroll();
|
|
35
|
+
this.__navigated = true;
|
|
36
|
+
|
|
21
37
|
return next();
|
|
22
38
|
}
|
|
23
39
|
}
|
|
@@ -1,3 +1,17 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
1
|
+
// Признаки окружения читаются при загрузке модуля, поэтому проверяем и само окружение:
|
|
2
|
+
// пакет собирают и там, где DOM появится позже или не появится вовсе (серверный рендер).
|
|
3
|
+
const HAS_DOM = typeof window !== "undefined" && !!window.document?.documentElement;
|
|
4
|
+
|
|
5
|
+
export const IS_TOUCH_DEVICE = HAS_DOM && "ontouchstart" in window.document.documentElement;
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Грубый ли основной указатель — палец или стилус, то есть точного наведения нет, а текст
|
|
9
|
+
* вводят экранной клавиатурой.
|
|
10
|
+
*
|
|
11
|
+
* Не то же, что {@link IS_TOUCH_DEVICE}: тот отвечает «есть ли у устройства сенсор», и ноутбук
|
|
12
|
+
* с сенсорным экраном попадает в него наравне с телефоном, хотя работают на нём мышью.
|
|
13
|
+
*
|
|
14
|
+
* Спрашиваем при каждом вызове, а не один раз при загрузке: на гибридных устройствах ответ
|
|
15
|
+
* меняется по ходу работы — планшет ставят в док-станцию с мышью и обратно.
|
|
16
|
+
*/
|
|
17
|
+
export const isCoarsePointer = (): boolean => (HAS_DOM && window.matchMedia?.("(pointer: coarse)").matches) ?? false;
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Прокручивал ли пользователь страницу сам с момента её показа.
|
|
3
|
+
*
|
|
4
|
+
* Нужно отложенным действиям, которые двигают вид: автофокус контрола ввода ставится в конце
|
|
5
|
+
* его инициализации, а она бывает и отложенной (ленивый content script, вставка разметки
|
|
6
|
+
* из кода). Если к этому моменту пользователь ушёл смотреть другое место страницы, забирать
|
|
7
|
+
* у него вид и клавиатуру нельзя.
|
|
8
|
+
*
|
|
9
|
+
* Считаем жесты прокрутки, а не событие scroll: scroll поднимает и программная прокрутка,
|
|
10
|
+
* и восстановление позиции браузером при перезагрузке или возврате назад — намерения
|
|
11
|
+
* пользователя в них нет.
|
|
12
|
+
*/
|
|
13
|
+
let userScrolled = false;
|
|
14
|
+
|
|
15
|
+
/** Клавиши, которыми прокручивают страницу. */
|
|
16
|
+
const SCROLL_KEYS = new Set(["PageUp", "PageDown", "Home", "End", "ArrowUp", "ArrowDown"]);
|
|
17
|
+
|
|
18
|
+
const setScrolled = () => (userScrolled = true);
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Попало ли нажатие мимо клиентской области — то есть в саму полосу прокрутки. Нулевые размеры
|
|
22
|
+
* не проверяем: страницы без разметки (и jsdom) иначе считали бы полосой любое нажатие.
|
|
23
|
+
*/
|
|
24
|
+
function isScrollbarPress(e: MouseEvent): boolean {
|
|
25
|
+
const root = window.document.documentElement;
|
|
26
|
+
|
|
27
|
+
return (
|
|
28
|
+
(root.clientWidth > 0 && e.clientX >= root.clientWidth) ||
|
|
29
|
+
(root.clientHeight > 0 && e.clientY >= root.clientHeight)
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Слушатели живут всё время работы приложения: признак общий на страницу, снимать его вместе
|
|
34
|
+
// с каким-то одним контролом нечего. Пассивные — прокрутку они только замечают, останавливать
|
|
35
|
+
// её не собираются.
|
|
36
|
+
if (typeof window !== "undefined") {
|
|
37
|
+
// Колесо и свайп — в фазе перехвата: докуда бы событие ни дошло, прокрутка уже случилась.
|
|
38
|
+
window.addEventListener("wheel", setScrolled, { passive: true, capture: true });
|
|
39
|
+
window.addEventListener("touchmove", setScrolled, { passive: true, capture: true });
|
|
40
|
+
|
|
41
|
+
// Полосу прокрутки и автопрокрутку средней кнопкой ни колесом, ни свайпом не поймать, а
|
|
42
|
+
// событие scroll поднимает и программная прокрутка. Обе начинаются с нажатия мыши: средняя
|
|
43
|
+
// кнопка — это автопрокрутка, а нажатие мимо клиентской области попадает в полосу.
|
|
44
|
+
window.addEventListener(
|
|
45
|
+
"mousedown",
|
|
46
|
+
(e: MouseEvent) => {
|
|
47
|
+
if (e.button === 1 || isScrollbarPress(e)) setScrolled();
|
|
48
|
+
},
|
|
49
|
+
{ passive: true, capture: true }
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
// Клавиатуру, в отличие от остального, слушаем на всплытии: страницу двигает только та
|
|
53
|
+
// клавиша, действие которой никто не отменил. Виджет, которому клавиша своя, — список
|
|
54
|
+
// dropdown, меню, карусель — гасит её сам, и это работа внутри виджета, а не прокрутка.
|
|
55
|
+
window.addEventListener("keydown", (e: KeyboardEvent) => {
|
|
56
|
+
if (e.defaultPrevented || !SCROLL_KEYS.has(e.key)) return;
|
|
57
|
+
|
|
58
|
+
// в поле ввода те же клавиши двигают каретку, а не страницу, и отменять их полю незачем
|
|
59
|
+
const target = e.target as HTMLElement | null;
|
|
60
|
+
if (
|
|
61
|
+
target?.isContentEditable ||
|
|
62
|
+
target instanceof HTMLInputElement ||
|
|
63
|
+
target instanceof HTMLTextAreaElement ||
|
|
64
|
+
target instanceof HTMLSelectElement
|
|
65
|
+
)
|
|
66
|
+
return;
|
|
67
|
+
|
|
68
|
+
setScrolled();
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Прокручивал ли пользователь страницу сам с момента её показа. */
|
|
73
|
+
export const hasUserScrolled = (): boolean => userScrolled;
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Забыть прокрутку пользователя — показана другая страница. Зовёт {@link UiKitMiddleware}
|
|
77
|
+
* при навигации; первую навигацию она пропускает: это показ той же страницы, с которой
|
|
78
|
+
* приложение стартовало, и прокрутка во время его загрузки остаётся в силе.
|
|
79
|
+
*/
|
|
80
|
+
export const resetUserScroll = (): void => {
|
|
81
|
+
userScrolled = false;
|
|
82
|
+
};
|