vanilla-jui 1.4.5 → 1.4.7

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.
Files changed (103) hide show
  1. package/README.md +6 -0
  2. package/dist/index.d.ts +1273 -1896
  3. package/dist/index.js +1 -1
  4. package/dist/index.umd.js +1 -1
  5. package/dist/style.css +1 -1
  6. package/package.json +21 -2
  7. package/src/components/accordion.ts +297 -216
  8. package/src/components/flow.ts +222 -258
  9. package/src/components/form.ts +44 -102
  10. package/src/components/menu.ts +268 -356
  11. package/src/components/modal.ts +181 -641
  12. package/src/components/offcanvas.ts +225 -106
  13. package/src/components/pagination.ts +296 -326
  14. package/src/components/sticky.ts +55 -54
  15. package/src/components/swiper.ts +237 -247
  16. package/src/components/tabs.ts +278 -271
  17. package/src/components/toc.ts +42 -46
  18. package/src/core/Component.ts +69 -47
  19. package/src/css/accordion.css +2 -2
  20. package/src/css/animation.css +1 -5
  21. package/src/css/avatar.css +1 -1
  22. package/src/css/badge.css +1 -1
  23. package/src/css/breadcrumb.css +9 -10
  24. package/src/css/button.css +1 -1
  25. package/src/css/card.css +2 -2
  26. package/src/css/color.css +974 -1002
  27. package/src/css/color_p3.css +974 -1004
  28. package/src/css/components.css +1 -1
  29. package/src/css/content.css +72 -63
  30. package/src/css/drop.css +1 -1
  31. package/src/css/editor.css +14 -14
  32. package/src/css/flow.css +4 -14
  33. package/src/css/font.css +5 -4
  34. package/src/css/form.css +76 -52
  35. package/src/css/gradient.css +299 -222
  36. package/src/css/icon.css +57 -57
  37. package/src/css/index.css +1 -1
  38. package/src/css/layout.css +13 -10
  39. package/src/css/loading.css +60 -12
  40. package/src/css/menu.css +262 -188
  41. package/src/css/modal.css +1 -1
  42. package/src/css/offcanvas.css +9 -13
  43. package/src/css/pagination.css +5 -1
  44. package/src/css/popup.css +1 -1
  45. package/src/css/skeleton.css +3 -2
  46. package/src/css/status.css +2 -3
  47. package/src/css/swiper.css +1 -1
  48. package/src/css/table.css +1 -1
  49. package/src/css/tabs.css +12 -11
  50. package/src/css/tag.css +1 -1
  51. package/src/css/theme-palette.css +19 -5
  52. package/src/css/themes/default/accordion.css +61 -61
  53. package/src/css/themes/default/avatar.css +54 -54
  54. package/src/css/themes/default/badge.css +62 -62
  55. package/src/css/themes/default/breadcrumb.css +23 -23
  56. package/src/css/themes/default/button.css +203 -203
  57. package/src/css/themes/default/content.css +53 -53
  58. package/src/css/themes/default/drop.css +10 -10
  59. package/src/css/themes/default/editor.css +9 -9
  60. package/src/css/themes/default/index.css +1 -1
  61. package/src/css/themes/default/loading.css +26 -20
  62. package/src/css/themes/default/menu.css +75 -11
  63. package/src/css/themes/default/modal.css +32 -36
  64. package/src/css/themes/default/pagination.css +7 -7
  65. package/src/css/themes/default/popup.css +6 -6
  66. package/src/css/themes/default/swiper.css +32 -32
  67. package/src/css/themes/default/table.css +16 -16
  68. package/src/css/themes/default/tabs.css +30 -30
  69. package/src/css/themes/default/tag.css +71 -71
  70. package/src/css/themes/default/theme-palette.css +11 -11
  71. package/src/css/themes/default/tip.css +42 -42
  72. package/src/css/themes/default/toast.css +60 -60
  73. package/src/css/themes/default/toolbar.css +23 -29
  74. package/src/css/themes/default/tooltip.css +43 -13
  75. package/src/css/themes/index.css +1 -1
  76. package/src/css/tip.css +2 -2
  77. package/src/css/toast.css +3 -3
  78. package/src/css/tokens.css +12 -17
  79. package/src/css/toolbar.css +9 -10
  80. package/src/css/tooltip.css +3 -2
  81. package/src/index.ts +22 -19
  82. package/src/{components → primitives}/drop.ts +58 -49
  83. package/src/{components → primitives}/icons.ts +1 -1
  84. package/src/primitives/loading.ts +23 -0
  85. package/src/primitives/parabola.ts +388 -0
  86. package/src/primitives/popup.ts +61 -0
  87. package/src/{components → primitives}/theme.ts +89 -57
  88. package/src/{components → primitives}/toast.ts +18 -25
  89. package/src/{components → primitives}/tooltip.ts +100 -25
  90. package/src/utilities/browser.ts +23 -49
  91. package/src/utilities/class-name.ts +5 -0
  92. package/src/utilities/dom.ts +14 -32
  93. package/src/utilities/function.ts +4 -0
  94. package/src/utilities/http.ts +7 -7
  95. package/src/utilities/id.ts +57 -0
  96. package/src/utilities/object.ts +5 -0
  97. package/src/utilities/timer.ts +38 -0
  98. package/src/utilities/types.ts +420 -0
  99. package/src/{components → validation}/validator.ts +64 -53
  100. package/src/components/parabola.ts +0 -335
  101. package/src/utilities/cache.ts +0 -57
  102. package/src/utilities/core.ts +0 -614
  103. package/src/utilities/storage.ts +0 -46
@@ -1,24 +1,23 @@
1
1
  import {
2
- bindAttr,
3
2
  createDeepStore,
4
3
  createEffect,
5
4
  createRoot,
6
5
  flushSync,
7
6
  jsx,
7
+ untrack,
8
8
  } from 'vanilla-signal';
9
9
 
10
10
  import Component, {
11
11
  type ComponentDOM,
12
12
  type ComponentRuntime,
13
13
  } from '../core/Component.ts';
14
+ import { icon } from '../primitives/icons.ts';
15
+ import { isPlainObject } from '../utilities/object.ts';
14
16
  import {
15
17
  type ResolveSchema,
16
- hasOwn,
17
18
  resolveProps,
18
19
  validateParam,
19
- } from '../utilities/core.ts';
20
- import { type DOMReference, q, requireContainer } from '../utilities/dom.ts';
21
- import { icon } from './icons.ts';
20
+ } from '../utilities/types.ts';
22
21
 
23
22
  export interface PaginationPage {
24
23
  size: number;
@@ -36,9 +35,7 @@ export interface PaginationClassNames {
36
35
  item: string;
37
36
  more: string;
38
37
  button: string;
39
- buttonIcon: string;
40
- buttonGhost: string;
41
- active: string;
38
+ current: string;
42
39
  loading: string;
43
40
  }
44
41
 
@@ -85,17 +82,10 @@ interface MoreItem {
85
82
 
86
83
  type PaginationItem = PageItem | MoreItem;
87
84
  type PageAction = 'prev' | 'next';
88
- type Dispose = () => void;
89
- type EffectDispose = { dispose: () => void };
90
85
 
91
86
  interface PaginationDOM extends ComponentDOM {
92
87
  root: HTMLElement | null;
93
- container: Element | null;
94
88
  list: HTMLElement | null;
95
- prev: HTMLElement | null;
96
- next: HTMLElement | null;
97
- pageNodes: HTMLElement[];
98
- items: PaginationItem[];
99
89
  }
100
90
 
101
91
  interface PaginationRuntime extends ComponentRuntime {
@@ -105,70 +95,81 @@ interface PaginationRuntime extends ComponentRuntime {
105
95
  }
106
96
 
107
97
  interface PaginationCleanupExtras {
108
- controls?: Dispose | null;
109
- itemsEffect?: EffectDispose | null;
98
+ state?: (() => void) | null;
110
99
  }
111
100
 
101
+ interface PaginationSnapshot {
102
+ total: number;
103
+ size: number;
104
+ current: number;
105
+ sibling: number;
106
+ boundary: number;
107
+ locked: boolean;
108
+ }
109
+
110
+ const DEFAULT_PAGE: PaginationPage = {
111
+ size: 10,
112
+ current: 1,
113
+ };
114
+
115
+ const DEFAULT_COUNT: PaginationCount = {
116
+ sibling: 1,
117
+ boundary: 1,
118
+ };
119
+
112
120
  const DEFAULT_CLASS_NAMES: PaginationClassNames = {
113
121
  root: 'j-pagination',
114
122
  list: 'pagination',
115
123
  item: 'item',
116
124
  more: 'more',
117
- button: 'j-button',
118
- buttonIcon: 'is-icon',
119
- buttonGhost: 'is-ghost',
120
- active: 'is-active',
125
+ button: 'j-button is-icon is-ghost',
126
+ current: 'j-button is-icon is-active',
121
127
  loading: 'animate-spin',
122
128
  };
123
129
 
130
+ const TOTAL_RULE = {
131
+ default: 0,
132
+ type: 'number',
133
+ finite: true,
134
+ min: 0,
135
+ };
136
+
124
137
  const PAGE_RULE = {
125
- type: 'object',
138
+ type: 'plainObject',
126
139
  required: true,
127
- validate: (value: unknown) => {
128
- const page = value as Partial<PaginationPage>;
129
- return (
130
- !!page &&
131
- Number.isInteger(page.size) &&
132
- Number(page.size) > 0 &&
133
- Number.isInteger(page.current) &&
134
- Number(page.current) > 0
135
- );
140
+ shape: {
141
+ size: { type: 'number', integer: true, greaterThan: 0 },
142
+ current: { type: 'number', integer: true, greaterThan: 0 },
136
143
  },
137
- message: 'expects { size, current } with positive integers.',
138
144
  };
139
145
 
140
146
  const COUNT_RULE = {
141
- type: 'object',
147
+ type: 'plainObject',
142
148
  required: true,
143
- validate: (value: unknown) => {
144
- const count = value as Partial<PaginationCount>;
145
- return (
146
- !!count &&
147
- Number.isInteger(count.sibling) &&
148
- Number(count.sibling) >= 0 &&
149
- Number.isInteger(count.boundary) &&
150
- Number(count.boundary) >= 0
151
- );
149
+ shape: {
150
+ sibling: { type: 'number', integer: true, min: 0 },
151
+ boundary: { type: 'number', integer: true, min: 0 },
152
152
  },
153
- message:
154
- 'expects { sibling, boundary } with integers greater than or equal 0.',
155
153
  };
156
154
 
157
155
  const PAGINATION_PROPS_SCHEMA = {
158
- total: {
159
- default: 0,
160
- type: 'number',
161
- validate: (value: unknown) => Number.isFinite(value) && Number(value) >= 0,
162
- message: 'expects a non-negative finite number.',
163
- },
156
+ total: TOTAL_RULE,
164
157
  page: {
165
- default: () => ({ size: 10, current: 1 }),
158
+ default: () => ({ ...DEFAULT_PAGE }),
166
159
  factory: true,
160
+ normalize: (value: unknown) => ({
161
+ ...DEFAULT_PAGE,
162
+ ...(value && typeof value === 'object' ? value : {}),
163
+ }),
167
164
  ...PAGE_RULE,
168
165
  },
169
166
  count: {
170
- default: () => ({ sibling: 1, boundary: 1 }),
167
+ default: () => ({ ...DEFAULT_COUNT }),
171
168
  factory: true,
169
+ normalize: (value: unknown) => ({
170
+ ...DEFAULT_COUNT,
171
+ ...(value && typeof value === 'object' ? value : {}),
172
+ }),
172
173
  ...COUNT_RULE,
173
174
  },
174
175
  lock: { default: true, type: 'boolean' },
@@ -183,20 +184,43 @@ const PAGINATION_PROPS_SCHEMA = {
183
184
  },
184
185
  } satisfies ResolveSchema<PaginationProps>;
185
186
 
187
+ const PAGINATION_STATE_SCHEMA = {
188
+ total: TOTAL_RULE,
189
+ page: PAGE_RULE,
190
+ count: COUNT_RULE,
191
+ };
192
+
186
193
  function clamp(value: number, min: number, max: number): number {
187
194
  return Math.min(Math.max(value, min), max);
188
195
  }
189
196
 
197
+ function getPageCount(total: number, size: number): number {
198
+ return Math.max(1, Math.ceil(total / size));
199
+ }
200
+
201
+ function clonePage(page: PaginationPage): PaginationPage {
202
+ return { size: page.size, current: page.current };
203
+ }
204
+
205
+ function cloneCount(count: PaginationCount): PaginationCount {
206
+ return { sibling: count.sibling, boundary: count.boundary };
207
+ }
208
+
190
209
  function normalizeProps(input: PaginationProps = {}): ResolvedPaginationProps {
191
210
  const props = resolveProps(
192
211
  input,
193
212
  PAGINATION_PROPS_SCHEMA,
194
213
  'Pagination.props'
195
214
  ) as ResolvedPaginationProps;
215
+ const pageCount = getPageCount(props.total, props.page.size);
216
+
196
217
  return {
197
218
  ...props,
198
- page: { ...props.page },
199
- count: { ...props.count },
219
+ page: {
220
+ size: props.page.size,
221
+ current: clamp(props.page.current, 1, pageCount),
222
+ },
223
+ count: cloneCount(props.count),
200
224
  className: { ...props.className },
201
225
  };
202
226
  }
@@ -270,36 +294,21 @@ function resolvePageItems(
270
294
  return items;
271
295
  }
272
296
 
273
- /**
274
- * 分页组件。
275
- *
276
- * 构造器只验证和保存配置;调用 build() 后才挂载 DOM 和绑定交互。
277
- */
278
- export class Pagination extends Component {
279
- declare props: ResolvedPaginationProps;
280
- declare state: PaginationState;
281
- declare dom: PaginationDOM;
297
+ class PaginationComponent extends Component<
298
+ ResolvedPaginationProps,
299
+ PaginationState,
300
+ PaginationDOM
301
+ > {
282
302
  declare runtime: PaginationRuntime;
303
+ declare state: PaginationState;
283
304
  declare cleanup: Component['cleanup'] & PaginationCleanupExtras;
284
305
 
285
- /**
286
- * @param {Element|Node|string|Array} container 挂载容器。
287
- * @param {object} [input={}] 分页配置。
288
- */
289
- constructor(container: DOMReference, input: PaginationProps = {}) {
290
- const el = requireContainer(container, 'Pagination.container');
306
+ constructor(input: PaginationProps = {}) {
291
307
  const props = normalizeProps(input);
292
308
  super(props);
293
309
 
294
- const pageCount = this._getPageCount(props.total, props.page.size);
295
- const current = clamp(props.page.current, 1, pageCount);
296
-
297
- this.dom.container = el;
298
310
  this.dom.list = null;
299
- this.dom.prev = null;
300
- this.dom.next = null;
301
- this.dom.pageNodes = [];
302
- this.dom.items = [];
311
+ this.cleanup.state = null;
303
312
 
304
313
  this.runtime.built = false;
305
314
  this.runtime.itemsKey = '';
@@ -307,31 +316,20 @@ export class Pagination extends Component {
307
316
 
308
317
  this.state = createDeepStore({
309
318
  total: props.total,
310
- page: {
311
- size: props.page.size,
312
- current,
313
- },
314
- count: {
315
- sibling: props.count.sibling,
316
- boundary: props.count.boundary,
317
- },
318
- pageCount,
319
+ page: clonePage(props.page),
320
+ count: cloneCount(props.count),
321
+ pageCount: getPageCount(props.total, props.page.size),
319
322
  locked: false,
320
- });
321
-
322
- this.props.page.current = current;
323
+ }) as PaginationState;
323
324
  }
324
325
 
325
- /**
326
- * 构建分页 DOM 并绑定事件。
327
- * @returns {Pagination} 当前实例。
328
- */
329
326
  build(): this {
330
- this._assertActive('build');
327
+ if (this.runtime.destroyed) {
328
+ throw new Error('Pagination.build: instance has been destroyed.');
329
+ }
331
330
  if (this.runtime.built) return this;
332
331
 
333
- this.init(this.props);
334
- this.root = jsx('div', {
332
+ this.dom.root = jsx('div', {
335
333
  className: this.props.className.root,
336
334
  role: 'navigation',
337
335
  'aria-label': 'Pagination',
@@ -343,42 +341,27 @@ export class Pagination extends Component {
343
341
  }),
344
342
  }) as HTMLElement;
345
343
 
346
- this.dom.list = q<HTMLElement>('[data-pagination-list]', this.root);
347
- this.dom.prev = this._buildControlItem('prev');
348
- this.dom.next = this._buildControlItem('next');
349
- this.dom.list?.append(this.dom.prev, this.dom.next);
350
-
351
- const container = this.dom.container;
352
- if (!container) return this;
353
- container.textContent = '';
354
- container.appendChild(this.root);
344
+ this.dom.list = this.dom.root.querySelector('[data-pagination-list]');
355
345
  this.runtime.built = true;
356
-
357
- this._bindControlState();
358
- this._bindPageItems();
359
- this._bindEvents();
346
+ this.bindState();
347
+ this.bindEvents();
348
+ this.emit('init', this.props);
360
349
  return this;
361
350
  }
362
351
 
363
- /**
364
- * 跳转到指定页码。
365
- * @param {number} page 新页码。
366
- * @returns {Pagination} 当前实例。
367
- */
368
352
  go(page: number): this {
369
- this._assertActive('go');
353
+ this.assertActive('go');
370
354
  validateParam(
371
355
  'page',
372
356
  page,
373
357
  {
374
358
  type: 'number',
375
- validate: Number.isInteger,
376
- message: 'expects an integer.',
359
+ integer: true,
377
360
  },
378
361
  'Pagination.go'
379
362
  );
380
363
 
381
- if (this._isLocked()) return this;
364
+ if (this.isLocked()) return this;
382
365
 
383
366
  const nextPage = clamp(page, 1, this.state.pageCount);
384
367
  if (nextPage === this.state.page.current) return this;
@@ -393,146 +376,140 @@ export class Pagination extends Component {
393
376
  this.state.page.current = nextPage;
394
377
  if (shouldLock) this.state.locked = true;
395
378
  });
396
- this.props.page.current = nextPage;
397
379
 
398
380
  if (typeof this.props.onChange === 'function') {
399
381
  let result: void | Promise<unknown>;
400
382
  try {
401
383
  result = this.props.onChange(nextPage, this);
402
384
  } catch (error) {
403
- if (shouldLock && changeId === this.runtime.changeId) {
404
- flushSync(() => {
405
- this.state.locked = false;
406
- });
407
- }
385
+ this.unlock(changeId);
408
386
  throw error;
409
387
  }
410
388
 
411
389
  if (shouldLock && result && typeof result.then === 'function') {
412
- const unlock = () => {
413
- if (!this.runtime.destroyed && changeId === this.runtime.changeId) {
414
- flushSync(() => {
415
- this.state.locked = false;
416
- });
417
- }
418
- };
390
+ const unlock = () => this.unlock(changeId);
419
391
  void Promise.resolve(result).then(unlock, unlock);
420
- } else if (shouldLock && changeId === this.runtime.changeId) {
421
- flushSync(() => {
422
- this.state.locked = false;
423
- });
392
+ } else {
393
+ this.unlock(changeId);
424
394
  }
425
395
  }
426
396
 
427
397
  return this;
428
398
  }
429
399
 
430
- /**
431
- * 更新分页配置。
432
- * @param {object} [newProps={}] 新配置,会与当前 props 合并。
433
- * @returns {Pagination} 当前实例。
434
- */
435
- update(newProps: PaginationProps = {}): this {
436
- this._assertActive('update');
400
+ private bindState(): void {
401
+ if (this.cleanup.state) return;
402
+
403
+ this.cleanup.state = createRoot((dispose) => {
404
+ createEffect(() => {
405
+ const snapshot: PaginationSnapshot = {
406
+ total: this.state.total,
407
+ size: this.state.page.size,
408
+ current: this.state.page.current,
409
+ sibling: this.state.count.sibling,
410
+ boundary: this.state.count.boundary,
411
+ locked: this.state.locked,
412
+ };
413
+ untrack(() => this.syncState(snapshot));
414
+ });
415
+ return dispose;
416
+ });
417
+ }
418
+
419
+ private syncState(snapshot: PaginationSnapshot): void {
420
+ validateParam('total', snapshot.total, TOTAL_RULE, 'Pagination.state');
437
421
  validateParam(
438
- 'newProps',
439
- newProps,
440
- { type: 'object' },
441
- 'Pagination.update'
422
+ 'page',
423
+ { size: snapshot.size, current: snapshot.current },
424
+ PAGE_RULE,
425
+ 'Pagination.state'
426
+ );
427
+ validateParam(
428
+ 'count',
429
+ { sibling: snapshot.sibling, boundary: snapshot.boundary },
430
+ COUNT_RULE,
431
+ 'Pagination.state'
442
432
  );
443
433
 
444
- const props = normalizeProps({
445
- total: newProps.total ?? this.props.total,
446
- lock: hasOwn(newProps, 'lock') ? newProps.lock : this.props.lock,
447
- onChange: hasOwn(newProps, 'onChange')
448
- ? newProps.onChange
449
- : this.props.onChange,
450
- className: {
451
- ...this.props.className,
452
- ...newProps.className,
453
- },
454
- page: {
455
- ...this.props.page,
456
- ...newProps.page,
457
- },
458
- count: {
459
- ...this.props.count,
460
- ...newProps.count,
461
- },
462
- });
463
- const pageCount = this._getPageCount(props.total, props.page.size);
464
- props.page.current = clamp(props.page.current, 1, pageCount);
465
-
466
- this.props = props;
467
- flushSync(() => {
468
- this.state.total = props.total;
469
- this.state.page.size = props.page.size;
470
- this.state.page.current = props.page.current;
471
- this.state.count.sibling = props.count.sibling;
472
- this.state.count.boundary = props.count.boundary;
473
- this.state.pageCount = pageCount;
474
- if (!props.lock) this.state.locked = false;
475
- });
434
+ const pageCount = getPageCount(snapshot.total, snapshot.size);
435
+ const current = clamp(snapshot.current, 1, pageCount);
436
+
437
+ if (
438
+ this.state.pageCount !== pageCount ||
439
+ this.state.page.current !== current
440
+ ) {
441
+ flushSync(() => {
442
+ this.state.pageCount = pageCount;
443
+ this.state.page.current = current;
444
+ });
445
+ }
476
446
 
477
- return this;
447
+ this.renderItems();
478
448
  }
479
449
 
480
- _getPageCount(total: number, size: number): number {
481
- return Math.max(1, Math.ceil(total / size));
482
- }
450
+ private renderItems(): void {
451
+ if (!this.dom.list) return;
483
452
 
484
- _getPageItems(): PaginationItem[] {
485
- return resolvePageItems(
453
+ const items = this.getPageItems();
454
+ const itemsKey = [
486
455
  this.state.page.current,
487
456
  this.state.pageCount,
488
- this.state.count
457
+ this.isLocked() ? 'locked' : 'unlocked',
458
+ ...items.map((item) => item.key),
459
+ ].join('|');
460
+
461
+ if (itemsKey === this.runtime.itemsKey) return;
462
+
463
+ this.runtime.itemsKey = itemsKey;
464
+ this.dom.list.textContent = '';
465
+ this.dom.list.append(
466
+ this.buildControlItem('prev'),
467
+ ...items.map((item) => this.buildPageItem(item)),
468
+ this.buildControlItem('next')
489
469
  );
490
470
  }
491
471
 
492
- _getItemsKey(): string {
493
- return [
494
- this.state.page.current,
495
- this._isLocked() ? 'locked' : 'unlocked',
496
- ...this._getPageItems().map((item) => item.key),
497
- ].join('|');
472
+ private getPageItems(): PaginationItem[] {
473
+ return resolvePageItems(this.state.page.current, this.state.pageCount, {
474
+ sibling: this.state.count.sibling,
475
+ boundary: this.state.count.boundary,
476
+ });
498
477
  }
499
478
 
500
- _isLocked(): boolean {
479
+ private isLocked(): boolean {
501
480
  return this.props.lock && this.state.locked;
502
481
  }
503
482
 
504
- _isPrevDisabled(): boolean {
505
- return this._isLocked() || this.state.page.current <= 1;
506
- }
507
-
508
- _isNextDisabled(): boolean {
509
- return this._isLocked() || this.state.page.current >= this.state.pageCount;
483
+ private isPrevDisabled(): boolean {
484
+ return this.isLocked() || this.state.page.current <= 1;
510
485
  }
511
486
 
512
- _buttonClass(...extra: string[]): string {
513
- const { button, buttonIcon, buttonGhost } = this.props.className;
514
- return [button, buttonIcon, buttonGhost, ...extra]
515
- .filter(Boolean)
516
- .join(' ');
487
+ private isNextDisabled(): boolean {
488
+ return this.isLocked() || this.state.page.current >= this.state.pageCount;
517
489
  }
518
490
 
519
- _buildControlItem(type: PageAction): HTMLElement {
520
- const item = jsx('li', {
491
+ private buildControlItem(type: PageAction): HTMLElement {
492
+ const disabled =
493
+ type === 'prev' ? this.isPrevDisabled() : this.isNextDisabled();
494
+ return jsx('li', {
521
495
  className: this.props.className.item,
522
496
  'data-pagination-control': type,
523
497
  children: jsx('button', {
524
- className: this._buttonClass(),
498
+ className: this.props.className.button,
525
499
  type: 'button',
526
500
  'data-page-action': type,
501
+ 'aria-label':
502
+ type === 'prev' ? 'Go to previous page' : 'Go to next page',
503
+ 'aria-disabled': disabled ? 'true' : 'false',
504
+ tabindex: disabled ? '-1' : null,
505
+ disabled,
527
506
  children: icon(type === 'prev' ? 'arrow-left' : 'arrow-right'),
528
507
  }),
529
508
  }) as HTMLElement;
530
-
531
- return item;
532
509
  }
533
510
 
534
- _buildPageItem(item: PaginationItem): HTMLElement {
535
- const disabled = this._isLocked();
511
+ private buildPageItem(item: PaginationItem): HTMLElement {
512
+ const disabled = this.isLocked();
536
513
 
537
514
  if (item.type === 'more') {
538
515
  return jsx('li', {
@@ -540,114 +517,53 @@ export class Pagination extends Component {
540
517
  .filter(Boolean)
541
518
  .join(' '),
542
519
  'data-pagination-more': item.key,
543
- 'aria-hidden': 'true',
544
- children: jsx('button', {
545
- className: this._buttonClass(),
546
- type: 'button',
547
- disabled,
520
+ children: jsx('span', {
521
+ className: this.props.className.button,
548
522
  children: icon('more'),
549
523
  }),
550
524
  }) as HTMLElement;
551
525
  }
552
526
 
527
+ if (item.page === this.state.page.current) {
528
+ return jsx('li', {
529
+ className: this.props.className.item,
530
+ 'data-pagination-item': String(item.page),
531
+ children: jsx('span', {
532
+ className: this.props.className.current,
533
+ 'data-current-page': String(item.page),
534
+ 'aria-current': 'page',
535
+ 'aria-label': `Page ${item.page}, current page`,
536
+ children: disabled
537
+ ? jsx('i', {
538
+ className: this.props.className.loading,
539
+ children: icon('loader'),
540
+ })
541
+ : String(item.page),
542
+ }),
543
+ }) as HTMLElement;
544
+ }
545
+
553
546
  return jsx('li', {
554
547
  className: this.props.className.item,
555
548
  'data-pagination-item': String(item.page),
556
- children:
557
- item.page === this.state.page.current
558
- ? jsx('span', {
559
- className: [
560
- this.props.className.button,
561
- this.props.className.buttonIcon,
562
- this.props.className.active,
563
- ]
564
- .filter(Boolean)
565
- .join(' '),
566
- 'data-current-page': String(item.page),
567
- 'aria-current': 'page',
568
- 'aria-label': `Page ${item.page}, current page`,
569
- children: disabled
570
- ? jsx('i', {
571
- className: this.props.className.loading,
572
- children: icon('loader'),
573
- })
574
- : String(item.page),
575
- })
576
- : jsx('button', {
577
- className: this._buttonClass(),
578
- type: 'button',
579
- 'data-page': String(item.page),
580
- 'aria-label': `Go to page ${item.page}`,
581
- 'aria-disabled': disabled ? 'true' : null,
582
- disabled,
583
- tabindex: disabled ? '-1' : null,
584
- children: String(item.page),
585
- }),
549
+ children: jsx('button', {
550
+ className: this.props.className.button,
551
+ type: 'button',
552
+ 'data-page': String(item.page),
553
+ 'aria-label': `Go to page ${item.page}`,
554
+ 'aria-disabled': disabled ? 'true' : 'false',
555
+ tabindex: disabled ? '-1' : null,
556
+ disabled,
557
+ children: String(item.page),
558
+ }),
586
559
  }) as HTMLElement;
587
560
  }
588
561
 
589
- _bindControlState(): void {
590
- this.cleanup.controls?.();
591
- this.cleanup.controls = createRoot((dispose) => {
592
- const prev = this.dom.prev
593
- ? q<HTMLButtonElement>('[data-page-action]', this.dom.prev)
594
- : null;
595
- const next = this.dom.next
596
- ? q<HTMLButtonElement>('[data-page-action]', this.dom.next)
597
- : null;
598
-
599
- if (prev) {
600
- bindAttr(prev, 'disabled', () => this._isPrevDisabled());
601
- bindAttr(prev, 'aria-disabled', () =>
602
- this._isPrevDisabled() ? 'true' : 'false'
603
- );
604
- bindAttr(prev, 'tabindex', () =>
605
- this._isPrevDisabled() ? '-1' : null
606
- );
607
- bindAttr(prev, 'aria-label', () => 'Go to previous page');
608
- }
609
-
610
- if (next) {
611
- bindAttr(next, 'disabled', () => this._isNextDisabled());
612
- bindAttr(next, 'aria-disabled', () =>
613
- this._isNextDisabled() ? 'true' : 'false'
614
- );
615
- bindAttr(next, 'tabindex', () =>
616
- this._isNextDisabled() ? '-1' : null
617
- );
618
- bindAttr(next, 'aria-label', () => 'Go to next page');
619
- }
620
-
621
- return dispose;
622
- });
623
- }
624
-
625
- _bindPageItems(): void {
626
- this.cleanup.itemsEffect?.dispose();
627
- this.cleanup.itemsEffect = createEffect(() => {
628
- const nextKey = this._getItemsKey();
629
- if (nextKey === this.runtime.itemsKey) return;
630
-
631
- this.runtime.itemsKey = nextKey;
632
- this._renderPageItems();
633
- });
634
- }
635
-
636
- _renderPageItems(): void {
637
- this.dom.items = [];
638
- if (!this.dom.list || !this.dom.next) return;
639
-
640
- const items = this._getPageItems();
641
- const nodes = items.map((item) => this._buildPageItem(item));
642
-
643
- for (const node of this.dom.pageNodes) node.remove();
644
- this.dom.pageNodes = nodes;
645
- for (const node of nodes) this.dom.list.insertBefore(node, this.dom.next);
646
- }
647
-
648
- _bindEvents(): void {
649
- const root = this.root;
562
+ private bindEvents(): void {
563
+ this.unbindEvents();
564
+ const root = this.dom.root;
650
565
  if (!root) return;
566
+
651
567
  this.cleanup.events.on('click', root, 'click', (event) => {
652
568
  const source = event.target;
653
569
  if (!(source instanceof Element)) return;
@@ -660,12 +576,12 @@ export class Pagination extends Component {
660
576
  event.preventDefault();
661
577
 
662
578
  const action = target.dataset.pageAction;
663
- if (action === 'prev' && !this._isPrevDisabled()) {
579
+ if (action === 'prev' && !this.isPrevDisabled()) {
664
580
  this.go(this.state.page.current - 1);
665
581
  return;
666
582
  }
667
583
 
668
- if (action === 'next' && !this._isNextDisabled()) {
584
+ if (action === 'next' && !this.isNextDisabled()) {
669
585
  this.go(this.state.page.current + 1);
670
586
  return;
671
587
  }
@@ -676,36 +592,90 @@ export class Pagination extends Component {
676
592
  });
677
593
  }
678
594
 
679
- _assertActive(method: string): void {
595
+ private unbindEvents(): void {
596
+ this.cleanup.events.clear();
597
+ }
598
+
599
+ private unlock(changeId: number): void {
600
+ if (
601
+ this.props.lock &&
602
+ !this.runtime.destroyed &&
603
+ changeId === this.runtime.changeId
604
+ ) {
605
+ flushSync(() => {
606
+ this.state.locked = false;
607
+ });
608
+ }
609
+ }
610
+
611
+ private assertActive(method: string): void {
680
612
  if (this.runtime.destroyed) {
681
613
  throw new Error(`Pagination.${method}: instance has been destroyed.`);
682
614
  }
615
+ if (!this.runtime.built) {
616
+ throw new Error(`Pagination.${method}: call build() first.`);
617
+ }
683
618
  }
684
619
 
685
- protected onDestroy(): void {
686
- this.cleanup.events.clear();
687
- this.cleanup.controls?.();
688
- this.cleanup.itemsEffect?.dispose();
689
- this.cleanup.controls = null;
690
- this.cleanup.itemsEffect = null;
620
+ protected override normalizeStatePatch(
621
+ patch: Partial<PaginationState>
622
+ ): Partial<PaginationState> {
623
+ const nextPatch = { ...patch };
624
+ if (Object.hasOwn(nextPatch, 'page') && isPlainObject(nextPatch.page)) {
625
+ nextPatch.page = {
626
+ ...this.state.page,
627
+ ...(nextPatch.page as Partial<PaginationPage>),
628
+ };
629
+ }
630
+ if (Object.hasOwn(nextPatch, 'count') && isPlainObject(nextPatch.count)) {
631
+ nextPatch.count = {
632
+ ...this.state.count,
633
+ ...(nextPatch.count as Partial<PaginationCount>),
634
+ };
635
+ }
636
+ return nextPatch;
637
+ }
691
638
 
692
- if (this.dom.container) this.dom.container.textContent = '';
639
+ protected override validateStatePatch(patch: Partial<PaginationState>): void {
640
+ validateParam(
641
+ 'state',
642
+ patch,
643
+ {
644
+ type: 'plainObject',
645
+ },
646
+ 'Pagination.setState'
647
+ );
693
648
 
649
+ for (const key of Object.keys(patch)) {
650
+ if (!Object.hasOwn(PAGINATION_STATE_SCHEMA, key)) {
651
+ throw new Error(
652
+ `Pagination.setState: "${key}" is not a supported state key.`
653
+ );
654
+ }
655
+ const stateKey = key as keyof typeof PAGINATION_STATE_SCHEMA;
656
+ validateParam(
657
+ key,
658
+ patch[key as keyof PaginationState],
659
+ PAGINATION_STATE_SCHEMA[stateKey],
660
+ 'Pagination.setState'
661
+ );
662
+ }
663
+ }
664
+
665
+ protected onDestroy(): void {
666
+ this.unbindEvents();
667
+ this.cleanup.state?.();
668
+ this.cleanup.state = null;
669
+ this.dom.root?.remove();
694
670
  this.runtime.built = false;
695
671
  this.runtime.itemsKey = '';
696
672
  this.runtime.changeId = 0;
697
- this.dom.container = null;
698
673
  this.dom.list = null;
699
- this.dom.prev = null;
700
- this.dom.next = null;
701
- this.dom.pageNodes = [];
702
- this.dom.items = [];
703
674
  }
704
675
  }
705
676
 
706
- export function createPagination(
707
- container: DOMReference,
708
- props: PaginationProps = {}
709
- ): Pagination {
710
- return new Pagination(container, props);
677
+ export type Pagination = PaginationComponent;
678
+
679
+ export function createPagination(input: PaginationProps = {}): Pagination {
680
+ return new PaginationComponent(input);
711
681
  }