@ryuzaki13/react-foundation-lib 1.4.1 → 1.4.3
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 +154 -100
- package/dist/chunks/{formatters-CBgrBeSA.js → formatters-D33Uh3si.js} +7 -8
- package/dist/chunks/{formatters-CBgrBeSA.js.map → formatters-D33Uh3si.js.map} +1 -1
- package/dist/chunks/odata-service-CWkHASdw.js +2211 -0
- package/dist/chunks/odata-service-CWkHASdw.js.map +1 -0
- package/dist/chunks/{validate-BW-7BZy_.js → validate-DyzojM0w.js} +2 -2
- package/dist/chunks/{validate-BW-7BZy_.js.map → validate-DyzojM0w.js.map} +1 -1
- package/dist/error-report/index.js +2 -2
- package/dist/excel/index.js +20 -21
- package/dist/excel/index.js.map +1 -1
- package/dist/formatters/index.js +4 -5
- package/dist/{odata → odata-service}/buildSeparatedArrays.d.ts +1 -1
- package/dist/odata-service/buildSeparatedArrays.d.ts.map +1 -0
- package/dist/{odata/types.d.ts → odata-service/collectionTypes.d.ts} +1 -1
- package/dist/odata-service/collectionTypes.d.ts.map +1 -0
- package/dist/{odata → odata-service}/dateUtils.d.ts +1 -1
- package/dist/odata-service/dateUtils.d.ts.map +1 -0
- package/dist/{odata → odata-service}/findCollectionPairs.d.ts +1 -1
- package/dist/odata-service/findCollectionPairs.d.ts.map +1 -0
- package/dist/odata-service/index.d.ts +5 -0
- package/dist/odata-service/index.d.ts.map +1 -1
- package/dist/odata-service/index.js +2 -2
- package/dist/{odata/store.d.ts → odata-service/odataCollectionStore.d.ts} +1 -1
- package/dist/odata-service/odataCollectionStore.d.ts.map +1 -0
- package/dist/query-client/index.js.map +1 -1
- package/dist/query-client/queryClient.d.ts +1 -3
- package/dist/query-client/queryClient.d.ts.map +1 -1
- package/package.json +1 -5
- package/src/bounded-copy-stack/README.mdx +257 -0
- package/src/context-menu/README.mdx +326 -0
- package/src/currency/README.mdx +155 -0
- package/src/date-segments/README.mdx +319 -0
- package/src/error/README.mdx +95 -0
- package/src/error-report/README.mdx +612 -0
- package/src/excel/README.mdx +449 -0
- package/src/fingerprint/README.mdx +100 -0
- package/src/form/README.mdx +101 -0
- package/src/foundationLib.mdx +70 -54
- package/src/hooks/README.mdx +988 -0
- package/src/http/README.mdx +126 -0
- package/src/media/README.mdx +1853 -0
- package/src/notifications/README.mdx +323 -0
- package/src/number-scale/README.mdx +318 -0
- package/src/odata-service/README.mdx +2916 -0
- package/src/presets/README.mdx +136 -0
- package/src/pwa/README.mdx +394 -0
- package/src/query-client/README.mdx +2500 -0
- package/src/range-output/README.mdx +161 -0
- package/src/seo/README.mdx +279 -0
- package/src/session-storage/README.mdx +125 -0
- package/src/string-comparison/README.mdx +96 -0
- package/src/table/README.mdx +3106 -0
- package/src/three-scene/README.mdx +248 -0
- package/src/types/README.mdx +172 -0
- package/src/utils/README.mdx +1724 -0
- package/src/validators/README.mdx +239 -0
- package/src/virtualizer/README.mdx +138 -0
- package/src/xml/README.mdx +149 -0
- package/dist/chunks/dateUtils-B5V5aXB-.js +0 -847
- package/dist/chunks/dateUtils-B5V5aXB-.js.map +0 -1
- package/dist/chunks/odata-service-B6TxBSLm.js +0 -1277
- package/dist/chunks/odata-service-B6TxBSLm.js.map +0 -1
- package/dist/odata/buildSeparatedArrays.d.ts.map +0 -1
- package/dist/odata/dateUtils.d.ts.map +0 -1
- package/dist/odata/findCollectionPairs.d.ts.map +0 -1
- package/dist/odata/index.d.ts +0 -6
- package/dist/odata/index.d.ts.map +0 -1
- package/dist/odata/index.js +0 -102
- package/dist/odata/index.js.map +0 -1
- package/dist/odata/store.d.ts.map +0 -1
- package/dist/odata/types.d.ts.map +0 -1
- package/src/pwa/README.md +0 -638
- package/src/query-client/README.md +0 -565
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
import { Meta } from "@storybook/addon-docs/blocks";
|
|
2
|
+
|
|
3
|
+
<Meta title="Foundation Lib/Infrastructure/Notifications" />
|
|
4
|
+
|
|
5
|
+
# Notifications
|
|
6
|
+
|
|
7
|
+
Модуль `@ryuzaki13/react-foundation-lib/notifications` хранит очередь коротких уведомлений и предоставляет два способа управления ею:
|
|
8
|
+
|
|
9
|
+
- Zustand-store — для React-интерфейса, который отображает уведомления;
|
|
10
|
+
- объект `notify` — для императивных вызовов из обработчиков, сервисов и `try/catch`.
|
|
11
|
+
|
|
12
|
+
Модуль **не рисует интерфейс**. Компонент toast/snackbar создаёт приложение: он подписывается на `store`, отображает `items` и вызывает действия уведомления.
|
|
13
|
+
|
|
14
|
+
## Когда использовать
|
|
15
|
+
|
|
16
|
+
Подходит для сообщений вроде:
|
|
17
|
+
|
|
18
|
+
- «Изменения сохранены»;
|
|
19
|
+
- «Не удалось загрузить данные»;
|
|
20
|
+
- «Файл формируется…» с последующей заменой на результат;
|
|
21
|
+
- предупреждения с одной или несколькими кнопками-действиями.
|
|
22
|
+
|
|
23
|
+
Для ошибок внутри формы лучше показывать сообщение рядом с полем. Уведомление — дополнительный, а не единственный канал обратной связи.
|
|
24
|
+
|
|
25
|
+
## Установка и импорт
|
|
26
|
+
|
|
27
|
+
Модулю нужен peer dependency `zustand`.
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
npm install @ryuzaki13/react-foundation-lib zustand
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
```ts
|
|
34
|
+
import { bindNotifications, createNotificationsStore, notify, type Notification } from "@ryuzaki13/react-foundation-lib/notifications";
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Используйте только публичный subpath `/notifications`. Импорт внутренних файлов пакета не является публичным контрактом.
|
|
38
|
+
|
|
39
|
+
## Быстрый старт
|
|
40
|
+
|
|
41
|
+
### 1. Создайте один store приложения
|
|
42
|
+
|
|
43
|
+
```ts
|
|
44
|
+
// notificationsStore.ts
|
|
45
|
+
import { bindNotifications, createNotificationsStore } from "@ryuzaki13/react-foundation-lib/notifications";
|
|
46
|
+
|
|
47
|
+
export const notificationsStore = createNotificationsStore();
|
|
48
|
+
export const unbindNotifications = bindNotifications(notificationsStore);
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
`createNotificationsStore()` возвращает vanilla Zustand-store. Его следует создать вне React-компонента, иначе каждый рендер может создать новую независимую очередь.
|
|
52
|
+
|
|
53
|
+
`bindNotifications(store)` связывает глобальный фасад `notify` с этим store. Без привязки вызов `notify.success(...)` выбросит ошибку.
|
|
54
|
+
|
|
55
|
+
### 2. Подпишите UI на store
|
|
56
|
+
|
|
57
|
+
```tsx
|
|
58
|
+
import { useStore } from "zustand";
|
|
59
|
+
|
|
60
|
+
import { notificationsStore } from "./notificationsStore";
|
|
61
|
+
|
|
62
|
+
export function NotificationsViewport() {
|
|
63
|
+
const items = useStore(notificationsStore, (state) => state.items);
|
|
64
|
+
const dismiss = useStore(notificationsStore, (state) => state.actions.dismiss);
|
|
65
|
+
|
|
66
|
+
return (
|
|
67
|
+
<section aria-label="Уведомления" aria-live="polite">
|
|
68
|
+
{items.map((item) => (
|
|
69
|
+
<article key={item.id} role={item.type === "error" ? "alert" : "status"}>
|
|
70
|
+
{item.title && <strong>{item.title}</strong>}
|
|
71
|
+
<p>{item.message}</p>
|
|
72
|
+
|
|
73
|
+
{item.actions?.map((action) => (
|
|
74
|
+
<button key={action.label} type="button" onClick={action.onClick}>
|
|
75
|
+
{action.label}
|
|
76
|
+
</button>
|
|
77
|
+
))}
|
|
78
|
+
|
|
79
|
+
{item.dismissible !== false && (
|
|
80
|
+
<button type="button" onClick={() => dismiss(item.id)}>
|
|
81
|
+
Закрыть
|
|
82
|
+
</button>
|
|
83
|
+
)}
|
|
84
|
+
</article>
|
|
85
|
+
))}
|
|
86
|
+
</section>
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
### 3. Показывайте уведомления
|
|
92
|
+
|
|
93
|
+
```ts
|
|
94
|
+
notify.success("Данные сохранены");
|
|
95
|
+
notify.info("Началась синхронизация", { title: "Синхронизация" });
|
|
96
|
+
notify.warning("Соединение нестабильно", { ttlMs: 0 });
|
|
97
|
+
notify.error("Не удалось сохранить запись", { ttlMs: 15_000 });
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
Значение `ttlMs: 0` или отрицательное число отключает автоматическое закрытие.
|
|
101
|
+
|
|
102
|
+
## Модель уведомления
|
|
103
|
+
|
|
104
|
+
```ts
|
|
105
|
+
type NotificationType = "error" | "warning" | "info" | "success";
|
|
106
|
+
type NotificationTone = "neutral" | NotificationType;
|
|
107
|
+
type NotificationId = string;
|
|
108
|
+
|
|
109
|
+
type NotificationAction = {
|
|
110
|
+
label: string;
|
|
111
|
+
onClick: () => void | Promise<void>;
|
|
112
|
+
tone?: NotificationTone;
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
type Notification = {
|
|
116
|
+
id: NotificationId;
|
|
117
|
+
type: NotificationType;
|
|
118
|
+
title?: string;
|
|
119
|
+
message: string;
|
|
120
|
+
createdAt: number;
|
|
121
|
+
ttlMs?: number;
|
|
122
|
+
dismissible?: boolean;
|
|
123
|
+
actions?: NotificationAction[];
|
|
124
|
+
};
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
`createdAt` — Unix-время в миллисекундах. `dismissible: false` означает, что UI не должен показывать обычную кнопку закрытия. Это только договор с UI: store не запрещает программный `dismiss(id)`.
|
|
128
|
+
|
|
129
|
+
`actions` содержат функции и потому не сериализуются. Не сохраняйте очередь уведомлений в JSON, localStorage или серверной базе.
|
|
130
|
+
|
|
131
|
+
## API store
|
|
132
|
+
|
|
133
|
+
### `createNotificationsStore()`
|
|
134
|
+
|
|
135
|
+
Создаёт независимый store со структурой:
|
|
136
|
+
|
|
137
|
+
```ts
|
|
138
|
+
type NotificationsStore = {
|
|
139
|
+
items: Notification[];
|
|
140
|
+
actions: {
|
|
141
|
+
push(input): NotificationId;
|
|
142
|
+
update(id, patch): boolean;
|
|
143
|
+
upsert(inputWithId): NotificationId;
|
|
144
|
+
dismiss(id): void;
|
|
145
|
+
clear(): void;
|
|
146
|
+
};
|
|
147
|
+
};
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
Очередь ограничена шестью элементами. Новое уведомление помещается в начало. При добавлении с уже существующим `id` старый элемент удаляется, а новый получает новое `createdAt`.
|
|
151
|
+
|
|
152
|
+
### `push(input)`
|
|
153
|
+
|
|
154
|
+
Добавляет уведомление и возвращает его `id`.
|
|
155
|
+
|
|
156
|
+
```ts
|
|
157
|
+
const id = notificationsStore.getState().actions.push({
|
|
158
|
+
type: "warning",
|
|
159
|
+
title: "Проверка",
|
|
160
|
+
message: "Некоторые поля не заполнены",
|
|
161
|
+
ttlMs: 8_000,
|
|
162
|
+
dismissible: true
|
|
163
|
+
});
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
Если `id` не передан, он генерируется из текущего времени и случайной части. Это идентификатор UI, а не криптографический или бизнес-идентификатор.
|
|
167
|
+
|
|
168
|
+
Если `ttlMs` отсутствует, внутренний таймер всё равно запускается на 10 секунд. При этом в объекте уведомления поле остаётся `undefined`.
|
|
169
|
+
|
|
170
|
+
### `update(id, patch)`
|
|
171
|
+
|
|
172
|
+
Частично меняет существующий элемент. Возвращает `true`, если элемент найден, иначе `false`.
|
|
173
|
+
|
|
174
|
+
```ts
|
|
175
|
+
const updated = notificationsStore.getState().actions.update(id, {
|
|
176
|
+
type: "success",
|
|
177
|
+
message: "Проверка завершена",
|
|
178
|
+
ttlMs: 2_500
|
|
179
|
+
});
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
`id` и `createdAt` изменить нельзя. Таймер перестраивается только тогда, когда в patch явно присутствует ключ `ttlMs`. Изменение одного `message` не продлевает исходный срок жизни.
|
|
183
|
+
|
|
184
|
+
### `upsert(input)`
|
|
185
|
+
|
|
186
|
+
Обновляет элемент с заданным `id`, а если его нет — добавляет новый.
|
|
187
|
+
|
|
188
|
+
```ts
|
|
189
|
+
notificationsStore.getState().actions.upsert({
|
|
190
|
+
id: "import-users",
|
|
191
|
+
type: "info",
|
|
192
|
+
message: "Импортировано 40 из 100 строк",
|
|
193
|
+
dismissible: false
|
|
194
|
+
});
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
Важная деталь: при обновлении существующего элемента `upsert` меняет `type`, `title`, `message`, `ttlMs` и `dismissible`, но не заменяет `actions`. Для явной смены действий используйте `update(id, { actions })`.
|
|
198
|
+
|
|
199
|
+
### `dismiss(id)` и `clear()`
|
|
200
|
+
|
|
201
|
+
`dismiss(id)` удаляет один элемент и очищает его таймер. `clear()` удаляет всю очередь и все таймеры.
|
|
202
|
+
|
|
203
|
+
## Императивный API `notify`
|
|
204
|
+
|
|
205
|
+
### Привязка и жизненный цикл
|
|
206
|
+
|
|
207
|
+
```ts
|
|
208
|
+
const unbind = bindNotifications(notificationsStore);
|
|
209
|
+
|
|
210
|
+
// При полном демонтаже приложения:
|
|
211
|
+
unbind();
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
Одновременно фасад хранит только одну привязку. Повторный `bindNotifications(otherStore)` переключит вызовы на другой store. Старый cleanup не отвяжет новый store.
|
|
215
|
+
|
|
216
|
+
Пакет не экспортирует `NotificationsProvider`, несмотря на текст ошибки при отсутствии привязки. Provider или другой lifecycle-компонент является ответственностью приложения.
|
|
217
|
+
|
|
218
|
+
### Основные методы
|
|
219
|
+
|
|
220
|
+
```ts
|
|
221
|
+
notify.push(input);
|
|
222
|
+
notify.upsert(inputWithId);
|
|
223
|
+
notify.update(id, patch);
|
|
224
|
+
notify.dismiss(id);
|
|
225
|
+
notify.clear();
|
|
226
|
+
|
|
227
|
+
notify.success(message, options);
|
|
228
|
+
notify.info(message, options);
|
|
229
|
+
notify.warning(message, options);
|
|
230
|
+
notify.error(message, options);
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
Сокращённые методы автоматически подставляют `type`, но принимают остальные параметры `push`, включая `id`, `title`, `ttlMs`, `dismissible` и `actions`.
|
|
234
|
+
|
|
235
|
+
### Долгая операция: `notify.progress()`
|
|
236
|
+
|
|
237
|
+
```ts
|
|
238
|
+
async function importFile(file: File) {
|
|
239
|
+
const progress = notify.progress("Читаем файл…", {
|
|
240
|
+
id: "users-import",
|
|
241
|
+
title: "Импорт пользователей"
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
try {
|
|
245
|
+
progress.update("Отправляем данные…");
|
|
246
|
+
await upload(file);
|
|
247
|
+
progress.success("Импорт завершён");
|
|
248
|
+
} catch (error) {
|
|
249
|
+
progress.error("Импорт завершился с ошибкой");
|
|
250
|
+
throw error;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
Начальное уведомление имеет `type: "info"` и `dismissible: false`. Возвращаемый объект содержит:
|
|
256
|
+
|
|
257
|
+
- `id` — идентификатор уведомления;
|
|
258
|
+
- `update(message)` — меняет текст, не перезапуская таймер;
|
|
259
|
+
- `success(message, ttlMs = 2500)` — делает уведомление успешным и закрываемым;
|
|
260
|
+
- `error(message, ttlMs = 5000)` — делает уведомление ошибкой и закрываемым;
|
|
261
|
+
- `dismiss()` — удаляет уведомление.
|
|
262
|
+
|
|
263
|
+
При переданном стабильном `id` повторный `progress` продолжит существующий элемент. Это удобно для операции, которая может стартовать из нескольких мест.
|
|
264
|
+
|
|
265
|
+
## Browser и SSR
|
|
266
|
+
|
|
267
|
+
Store использует `window.setTimeout` и `window.clearTimeout`. Создать store можно заранее, но методы, которые запускают или очищают таймеры, предназначены для браузера. Не вызывайте `push`, `update` с `ttlMs`, `dismiss` или `clear` во время серверного рендера.
|
|
268
|
+
|
|
269
|
+
Практически безопасная схема: создавать и привязывать store в client entrypoint либо в модуле, который импортируется только браузерным приложением.
|
|
270
|
+
|
|
271
|
+
## Доступность интерфейса
|
|
272
|
+
|
|
273
|
+
Store не задаёт ARIA-разметку. В UI рекомендуется:
|
|
274
|
+
|
|
275
|
+
- использовать `aria-live="polite"` для обычных сообщений;
|
|
276
|
+
- использовать `role="alert"` только для действительно срочных ошибок;
|
|
277
|
+
- давать кнопке закрытия понятное доступное имя;
|
|
278
|
+
- не делать TTL слишком коротким, если уведомление содержит много текста или действия;
|
|
279
|
+
- обеспечить управление действиями с клавиатуры.
|
|
280
|
+
|
|
281
|
+
## Частые ошибки
|
|
282
|
+
|
|
283
|
+
### Вызов `notify` до `bindNotifications`
|
|
284
|
+
|
|
285
|
+
```ts
|
|
286
|
+
// Ошибка времени выполнения: store ещё не привязан.
|
|
287
|
+
notify.success("Готово");
|
|
288
|
+
```
|
|
289
|
+
|
|
290
|
+
Сначала создайте store и выполните `bindNotifications` в точке запуска приложения.
|
|
291
|
+
|
|
292
|
+
### Создание store внутри тела компонента
|
|
293
|
+
|
|
294
|
+
```tsx
|
|
295
|
+
function App() {
|
|
296
|
+
// Плохо: при повторном рендере можно потерять очередь и таймеры.
|
|
297
|
+
const store = createNotificationsStore();
|
|
298
|
+
return null;
|
|
299
|
+
}
|
|
300
|
+
```
|
|
301
|
+
|
|
302
|
+
Создайте singleton на уровне модуля или один раз через подходящий lifecycle-контейнер.
|
|
303
|
+
|
|
304
|
+
### Ожидание, что `dismissible: false` блокирует удаление
|
|
305
|
+
|
|
306
|
+
Это только поле модели для UI. `notify.dismiss(id)` всё равно удалит элемент.
|
|
307
|
+
|
|
308
|
+
### Использование уведомления вместо состояния операции
|
|
309
|
+
|
|
310
|
+
Очередь ограничена шестью элементами и живёт в памяти страницы. Не используйте её как журнал аудита, источник истины о фоновой задаче или долговременное хранилище ошибок.
|
|
311
|
+
|
|
312
|
+
## Краткая карта API
|
|
313
|
+
|
|
314
|
+
| Экспорт | Назначение |
|
|
315
|
+
| ---------------------------- | -------------------------------------------------------- |
|
|
316
|
+
| `createNotificationsStore()` | Создать независимый Zustand-store уведомлений |
|
|
317
|
+
| `bindNotifications(store)` | Привязать store к глобальному фасаду `notify` |
|
|
318
|
+
| `notify` | Императивно добавлять, обновлять и закрывать уведомления |
|
|
319
|
+
| `Notification` | Полная модель сохранённого элемента |
|
|
320
|
+
| `NotificationPushInput` | Параметры добавления элемента |
|
|
321
|
+
| `NotificationUpdatePatch` | Допустимые поля частичного обновления |
|
|
322
|
+
| `NotificationsStoreApi` | Тип vanilla Zustand-store |
|
|
323
|
+
| `NotificationAction` | Кнопка-действие уведомления |
|
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
import { Meta } from "@storybook/addon-docs/blocks";
|
|
2
|
+
|
|
3
|
+
<Meta title="Foundation Lib/Data/Number Scale" />
|
|
4
|
+
|
|
5
|
+
# Number Scale
|
|
6
|
+
|
|
7
|
+
`@ryuzaki13/react-foundation-lib/number-scale` содержит чистую математику для slider, range, числовой шкалы и управления значением с клавиатуры. Модуль не содержит React-компонентов и не рисует ползунок.
|
|
8
|
+
|
|
9
|
+
Он решает четыре связанные задачи:
|
|
10
|
+
|
|
11
|
+
1. нормализует границы и шаг;
|
|
12
|
+
2. ограничивает и округляет значение;
|
|
13
|
+
3. преобразует значение в позицию `0..100%` и обратно;
|
|
14
|
+
4. работает с дискретными точками `marks`.
|
|
15
|
+
|
|
16
|
+
## Установка и импорт
|
|
17
|
+
|
|
18
|
+
```ts
|
|
19
|
+
import {
|
|
20
|
+
offsetNumberScaleValue,
|
|
21
|
+
percentToSnappedNumberScaleValue,
|
|
22
|
+
prepareNumberScaleMarks,
|
|
23
|
+
snapNumberScaleValue,
|
|
24
|
+
valueToNumberScalePercent,
|
|
25
|
+
type NumberScaleMark,
|
|
26
|
+
type NumberScaleOptions
|
|
27
|
+
} from "@ryuzaki13/react-foundation-lib/number-scale";
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Все функции синхронные, не меняют DOM и подходят для браузера, сервера и тестов.
|
|
31
|
+
|
|
32
|
+
## Две модели шкалы
|
|
33
|
+
|
|
34
|
+
### Шаговая шкала
|
|
35
|
+
|
|
36
|
+
```ts
|
|
37
|
+
const options = {
|
|
38
|
+
min: 0,
|
|
39
|
+
max: 100,
|
|
40
|
+
step: 5
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
snapNumberScaleValue(17, options); // 15
|
|
44
|
+
snapNumberScaleValue(18, options); // 20
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Когда `marks` отсутствуют, допустимые точки идут от `min` с интервалом `step`.
|
|
48
|
+
|
|
49
|
+
### Шкала с marks
|
|
50
|
+
|
|
51
|
+
```ts
|
|
52
|
+
const marks = [
|
|
53
|
+
{ value: 1, label: "1 час" },
|
|
54
|
+
{ value: 3, label: "3 часа" },
|
|
55
|
+
{ value: 6, label: "6 часов" },
|
|
56
|
+
{ value: 12, label: "12 часов" },
|
|
57
|
+
{ value: 24, label: "Сутки" }
|
|
58
|
+
];
|
|
59
|
+
|
|
60
|
+
snapNumberScaleValue(10, { min: 1, max: 24, marks }); // 12
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Если после подготовки остаётся хотя бы один mark, `marks` полностью заменяют `step` для snap-логики.
|
|
64
|
+
|
|
65
|
+
## Базовые типы
|
|
66
|
+
|
|
67
|
+
```ts
|
|
68
|
+
type NumberScaleMarksPosition = "value" | "index";
|
|
69
|
+
|
|
70
|
+
interface NumberScaleMark {
|
|
71
|
+
value: number;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
interface NumberScaleOptions<TMark extends NumberScaleMark = NumberScaleMark> {
|
|
75
|
+
min: number;
|
|
76
|
+
max: number;
|
|
77
|
+
step?: number;
|
|
78
|
+
marks?: readonly TMark[];
|
|
79
|
+
marksPosition?: NumberScaleMarksPosition;
|
|
80
|
+
}
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
Mark может иметь дополнительные поля: `label`, `disabled`, `description` и любые данные UI. Функции требуют только числовой `value` и сохраняют исходный объект.
|
|
84
|
+
|
|
85
|
+
## Границы, clamp и шаг
|
|
86
|
+
|
|
87
|
+
### `resolveNumberScaleBounds(min, max)`
|
|
88
|
+
|
|
89
|
+
Возвращает `{ min, max }` в правильном порядке.
|
|
90
|
+
|
|
91
|
+
```ts
|
|
92
|
+
resolveNumberScaleBounds(0, 100); // { min: 0, max: 100 }
|
|
93
|
+
resolveNumberScaleBounds(100, 0); // { min: 0, max: 100 }
|
|
94
|
+
resolveNumberScaleBounds(NaN, 10); // { min: 0, max: 100 }
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
Если хотя бы одна граница не является конечным числом, используются безопасные значения `0` и `100`.
|
|
98
|
+
|
|
99
|
+
### `clampNumberScaleValue(value, min, max)`
|
|
100
|
+
|
|
101
|
+
Ограничивает число переданными границами.
|
|
102
|
+
|
|
103
|
+
```ts
|
|
104
|
+
clampNumberScaleValue(-10, 0, 100); // 0
|
|
105
|
+
clampNumberScaleValue(120, 0, 100); // 100
|
|
106
|
+
clampNumberScaleValue(NaN, 10, 100); // 10
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
Функция не переставляет `min` и `max`. Если они могут прийти в обратном порядке, сначала вызовите `resolveNumberScaleBounds`.
|
|
110
|
+
|
|
111
|
+
### `normalizeNumberScaleStep(step)`
|
|
112
|
+
|
|
113
|
+
Возвращает `step`, если это конечное положительное число. Для `undefined`, `NaN`, `Infinity`, нуля и отрицательного значения возвращает `1`.
|
|
114
|
+
|
|
115
|
+
## Подготовка marks
|
|
116
|
+
|
|
117
|
+
### `prepareNumberScaleMarks(marks, min, max)`
|
|
118
|
+
|
|
119
|
+
Функция:
|
|
120
|
+
|
|
121
|
+
- удаляет marks с `NaN`, `Infinity` и значениями вне диапазона;
|
|
122
|
+
- создаёт новый массив;
|
|
123
|
+
- сортирует его по `value` по возрастанию;
|
|
124
|
+
- удаляет повторные `value`, сохраняя первый объект.
|
|
125
|
+
|
|
126
|
+
```ts
|
|
127
|
+
const prepared = prepareNumberScaleMarks(
|
|
128
|
+
[
|
|
129
|
+
{ value: 75, label: "75" },
|
|
130
|
+
{ value: 25, label: "первый 25" },
|
|
131
|
+
{ value: 25, label: "повтор" },
|
|
132
|
+
{ value: 200, label: "вне шкалы" }
|
|
133
|
+
],
|
|
134
|
+
0,
|
|
135
|
+
100
|
|
136
|
+
);
|
|
137
|
+
|
|
138
|
+
// [{ value: 25, label: "первый 25" }, { value: 75, label: "75" }]
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
Исходный массив не сортируется и не изменяется. Границы считаются уже нормализованными.
|
|
142
|
+
|
|
143
|
+
## Привязка значения к допустимой точке
|
|
144
|
+
|
|
145
|
+
### `snapNumberScaleValueToStep(value, min, max, step?)`
|
|
146
|
+
|
|
147
|
+
Сначала ограничивает значение, затем округляет его к ближайшему шагу, отсчитываемому от `min`.
|
|
148
|
+
|
|
149
|
+
```ts
|
|
150
|
+
snapNumberScaleValueToStep(16, 0, 100, 5); // 15
|
|
151
|
+
snapNumberScaleValueToStep(18, 0, 100, 5); // 20
|
|
152
|
+
snapNumberScaleValueToStep(16.24, 0, 100, 0.25); // 16.25
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
Десятичная точность вычисляется из `min`, `max` и `step`, что уменьшает типичные артефакты вроде `0.30000000000000004`.
|
|
156
|
+
|
|
157
|
+
При точном равенстве расстояний используется `Math.round`: выбирается точка с большим смещением от `min`.
|
|
158
|
+
|
|
159
|
+
### `findClosestNumberScaleMark(value, marks)`
|
|
160
|
+
|
|
161
|
+
Возвращает ближайший объект mark или `undefined` для пустого массива. При равном расстоянии остаётся первый элемент массива. Обычно перед этим следует подготовить marks, чтобы первый означал меньшее значение.
|
|
162
|
+
|
|
163
|
+
### `snapNumberScaleValueToMarks(value, marks, min, max)`
|
|
164
|
+
|
|
165
|
+
Ограничивает значение и возвращает `value` ближайшего mark. Для пустого массива возвращает только ограниченное значение без округления к шагу.
|
|
166
|
+
|
|
167
|
+
### `snapNumberScaleValue(value, options)`
|
|
168
|
+
|
|
169
|
+
Высокоуровневая функция. Она сама нормализует границы и marks, затем выбирает:
|
|
170
|
+
|
|
171
|
+
- snap к `marks`, если после фильтрации они есть;
|
|
172
|
+
- snap к `step`, если marks нет.
|
|
173
|
+
|
|
174
|
+
Для большинства форм и reducer это основной метод.
|
|
175
|
+
|
|
176
|
+
## Значение и проценты
|
|
177
|
+
|
|
178
|
+
### `valueToNumberScalePercent(value, min, max)`
|
|
179
|
+
|
|
180
|
+
Преобразует число шкалы в позицию от `0` до `100`.
|
|
181
|
+
|
|
182
|
+
```ts
|
|
183
|
+
valueToNumberScalePercent(25, 0, 100); // 25
|
|
184
|
+
valueToNumberScalePercent(6, 1, 24); // около 21.739
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
Если `max <= min`, возвращается `0`.
|
|
188
|
+
|
|
189
|
+
### `percentToNumberScaleValue(percent, min, max)`
|
|
190
|
+
|
|
191
|
+
Выполняет обратное преобразование. Процент ограничивается диапазоном `0..100`. Если `max <= min`, возвращается `min`.
|
|
192
|
+
|
|
193
|
+
```ts
|
|
194
|
+
percentToNumberScaleValue(50, 0, 10); // 5
|
|
195
|
+
percentToNumberScaleValue(150, 0, 10); // 10
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
Эта функция не делает snap. Для значения от pointer обычно нужен `percentToSnappedNumberScaleValue`.
|
|
199
|
+
|
|
200
|
+
## Позиционирование marks
|
|
201
|
+
|
|
202
|
+
`marksPosition` определяет только визуальное расположение marks и выбор ближайшего mark по проценту.
|
|
203
|
+
|
|
204
|
+
### Режим `value`
|
|
205
|
+
|
|
206
|
+
Значения располагаются пропорционально числовой шкале. Например, `1, 3, 6, 12, 24` будут сгруппированы ближе к началу.
|
|
207
|
+
|
|
208
|
+
### Режим `index`
|
|
209
|
+
|
|
210
|
+
Все точки располагаются равномерно независимо от числового расстояния:
|
|
211
|
+
|
|
212
|
+
```text
|
|
213
|
+
1 3 6 12 24
|
|
214
|
+
0% 25% 50% 75% 100%
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
Это полезно для категориальной шкалы, где каждое значение — отдельный выбор, а не физическое расстояние.
|
|
218
|
+
|
|
219
|
+
### `getNumberScaleMarkPercent(index, marks, min, max, marksPosition?)`
|
|
220
|
+
|
|
221
|
+
Возвращает процент позиции одного mark. В режиме `index` единственный mark находится на `0%`. В режиме `value` используется `marks[index]?.value`; при неверном индексе — `min`.
|
|
222
|
+
|
|
223
|
+
### `findClosestNumberScaleMarkByPercent(...)`
|
|
224
|
+
|
|
225
|
+
Находит mark, визуально ближайший к переданному проценту. Процент ограничивается `0..100`; для пустого массива возвращается `undefined`. При равном расстоянии выбирается первый mark.
|
|
226
|
+
|
|
227
|
+
### `percentToSnappedNumberScaleValue(percent, options)`
|
|
228
|
+
|
|
229
|
+
Основная функция для pointer-события:
|
|
230
|
+
|
|
231
|
+
```ts
|
|
232
|
+
const nextValue = percentToSnappedNumberScaleValue(pointerPercent, {
|
|
233
|
+
min: 1,
|
|
234
|
+
max: 24,
|
|
235
|
+
marks,
|
|
236
|
+
marksPosition: "index"
|
|
237
|
+
});
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
С marks она выбирает ближайшую визуальную точку. Без marks преобразует процент в число и округляет его к `step`.
|
|
241
|
+
|
|
242
|
+
## Клавиатура и смещение
|
|
243
|
+
|
|
244
|
+
### `offsetNumberScaleValue(currentValue, offset, options, lowerBound?, upperBound?)`
|
|
245
|
+
|
|
246
|
+
Сдвигает значение на количество шагов или marks.
|
|
247
|
+
|
|
248
|
+
```ts
|
|
249
|
+
offsetNumberScaleValue(34, 1, {
|
|
250
|
+
min: 0,
|
|
251
|
+
max: 100,
|
|
252
|
+
marks: [{ value: 0 }, { value: 30 }, { value: 80 }]
|
|
253
|
+
}); // 80
|
|
254
|
+
|
|
255
|
+
offsetNumberScaleValue(34, 2, {
|
|
256
|
+
min: 0,
|
|
257
|
+
max: 100,
|
|
258
|
+
step: 10
|
|
259
|
+
}); // 50
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
`offset = -1` подходит для `ArrowLeft`/`ArrowDown`, `offset = 1` — для `ArrowRight`/`ArrowUp`.
|
|
263
|
+
|
|
264
|
+
`lowerBound` и `upperBound` позволяют временно сузить допустимую область, например чтобы левый thumb не пересёк правый.
|
|
265
|
+
|
|
266
|
+
Для шкалы с marks передавайте целое смещение. Дробный индекс не является корректной позицией массива и может привести к `undefined` на уровне JavaScript, хотя тип функции выводится как число.
|
|
267
|
+
|
|
268
|
+
## Пример reducer для slider
|
|
269
|
+
|
|
270
|
+
```ts
|
|
271
|
+
const options = { min: 0, max: 100, step: 5 } as const;
|
|
272
|
+
|
|
273
|
+
function fromPointer(clientX: number, left: number, width: number) {
|
|
274
|
+
const percent = width > 0 ? ((clientX - left) / width) * 100 : 0;
|
|
275
|
+
return percentToSnappedNumberScaleValue(percent, options);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function fromKeyboard(current: number, key: string) {
|
|
279
|
+
if (key === "ArrowLeft" || key === "ArrowDown") {
|
|
280
|
+
return offsetNumberScaleValue(current, -1, options);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
if (key === "ArrowRight" || key === "ArrowUp") {
|
|
284
|
+
return offsetNumberScaleValue(current, 1, options);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
return current;
|
|
288
|
+
}
|
|
289
|
+
```
|
|
290
|
+
|
|
291
|
+
UI отдельно отвечает за `role="slider"`, `aria-valuemin`, `aria-valuemax`, `aria-valuenow`, фокус и обработку клавиатуры.
|
|
292
|
+
|
|
293
|
+
## Частые ошибки
|
|
294
|
+
|
|
295
|
+
- Передавать ненормализованные обратные границы напрямую в низкоуровневый `clamp`.
|
|
296
|
+
- Ожидать, что `step` действует одновременно с непустыми `marks`.
|
|
297
|
+
- Считать `marksPosition: "index"` изменением самих числовых значений. Меняется только визуальная геометрия выбора.
|
|
298
|
+
- Изменять исходные marks после мемоизации UI. Подготовка возвращает новый массив, но сами объекты не клонирует.
|
|
299
|
+
- Передавать дробный `offset` при работе с marks.
|
|
300
|
+
|
|
301
|
+
## Краткая карта API
|
|
302
|
+
|
|
303
|
+
| Функция | Результат |
|
|
304
|
+
| ------------------------------------- | --------------------------------------------------- |
|
|
305
|
+
| `resolveNumberScaleBounds` | Безопасные границы в правильном порядке |
|
|
306
|
+
| `clampNumberScaleValue` | Значение внутри границ |
|
|
307
|
+
| `normalizeNumberScaleStep` | Положительный конечный шаг или `1` |
|
|
308
|
+
| `prepareNumberScaleMarks` | Отфильтрованные, отсортированные и уникальные marks |
|
|
309
|
+
| `snapNumberScaleValueToStep` | Ближайшая точка шага |
|
|
310
|
+
| `findClosestNumberScaleMark` | Ближайший объект mark |
|
|
311
|
+
| `snapNumberScaleValueToMarks` | Значение ближайшего mark |
|
|
312
|
+
| `snapNumberScaleValue` | Snap по marks либо step |
|
|
313
|
+
| `valueToNumberScalePercent` | Число → процент |
|
|
314
|
+
| `percentToNumberScaleValue` | Процент → число без snap |
|
|
315
|
+
| `getNumberScaleMarkPercent` | Визуальная позиция mark |
|
|
316
|
+
| `findClosestNumberScaleMarkByPercent` | Ближайший mark по track |
|
|
317
|
+
| `percentToSnappedNumberScaleValue` | Процент → готовое допустимое значение |
|
|
318
|
+
| `offsetNumberScaleValue` | Смещение по step или marks |
|