@zeyos/zx 2.1.0 → 2.2.0
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/CHANGELOG.md +39 -0
- package/README.md +15 -0
- package/dist/types/compat/base.d.ts +156 -0
- package/dist/types/compat/globals.d.ts +20 -0
- package/dist/types/compat/index.d.ts +128 -0
- package/dist/types/compat/map/basic.d.ts +175 -0
- package/dist/types/compat/map/data.d.ts +102 -0
- package/dist/types/compat/map/date-time.d.ts +80 -0
- package/dist/types/compat/map/factory.d.ts +9 -0
- package/dist/types/compat/map/form.d.ts +98 -0
- package/dist/types/compat/map/helpers.d.ts +12 -0
- package/dist/types/compat/map/http.d.ts +36 -0
- package/dist/types/compat/map/layout.d.ts +106 -0
- package/dist/types/compat/map/options.d.ts +80 -0
- package/dist/types/compat/map/select.d.ts +95 -0
- package/dist/types/compat/map/stubs.d.ts +32 -0
- package/dist/types/compat/map/utilities.d.ts +37 -0
- package/dist/types/compat-entry.d.ts +5 -0
- package/dist/types/components/badge/badge.d.ts +46 -0
- package/dist/types/components/breadcrumb/breadcrumb.d.ts +200 -0
- package/dist/types/components/button/button.d.ts +42 -0
- package/dist/types/components/check-button/check-button.d.ts +98 -0
- package/dist/types/components/checklist/checklist.d.ts +143 -0
- package/dist/types/components/checklist/field-adapter.d.ts +5 -0
- package/dist/types/components/context-menu/context-menu.d.ts +130 -0
- package/dist/types/components/copy/copy-button.d.ts +48 -0
- package/dist/types/components/copy/copy-input.d.ts +91 -0
- package/dist/types/components/data-filter/data-filter.d.ts +250 -0
- package/dist/types/components/data-filter/filter-core.d.ts +52 -0
- package/dist/types/components/date-picker/date-picker.d.ts +137 -0
- package/dist/types/components/date-picker/date-range-picker.d.ts +350 -0
- package/dist/types/components/date-picker/field-adapter.d.ts +6 -0
- package/dist/types/components/date-picker/month-picker.d.ts +75 -0
- package/dist/types/components/date-picker/range-field-adapter.d.ts +7 -0
- package/dist/types/components/date-picker/time-picker.d.ts +127 -0
- package/dist/types/components/datebox/date-range-box.d.ts +218 -0
- package/dist/types/components/datebox/datebox.d.ts +160 -0
- package/dist/types/components/datebox/field-adapter.d.ts +5 -0
- package/dist/types/components/dialog/dialog.d.ts +284 -0
- package/dist/types/components/dropdown/dropdown.d.ts +118 -0
- package/dist/types/components/empty-state/empty-state.d.ts +38 -0
- package/dist/types/components/field/field.d.ts +231 -0
- package/dist/types/components/field-upload/field-adapter.d.ts +5 -0
- package/dist/types/components/field-upload/field-upload.d.ts +213 -0
- package/dist/types/components/fieldset/fieldset.d.ts +125 -0
- package/dist/types/components/finder/finder.d.ts +252 -0
- package/dist/types/components/form/form.d.ts +194 -0
- package/dist/types/components/groupbox/groupbox.d.ts +89 -0
- package/dist/types/components/layout/layout.d.ts +132 -0
- package/dist/types/components/loading/inline-loading.d.ts +86 -0
- package/dist/types/components/loading/progress-bar.d.ts +168 -0
- package/dist/types/components/loading/spinner.d.ts +34 -0
- package/dist/types/components/master-panel/master-panel.d.ts +112 -0
- package/dist/types/components/menu-button/menu-button.d.ts +142 -0
- package/dist/types/components/message/message.d.ts +212 -0
- package/dist/types/components/modal/modal.d.ts +105 -0
- package/dist/types/components/multi-value-editor/field-adapter.d.ts +5 -0
- package/dist/types/components/multi-value-editor/multi-value-editor.d.ts +66 -0
- package/dist/types/components/navigation-bar/navigation-bar.d.ts +229 -0
- package/dist/types/components/number-field/field-adapter.d.ts +5 -0
- package/dist/types/components/number-field/number-field.d.ts +292 -0
- package/dist/types/components/pagination/pagination.d.ts +267 -0
- package/dist/types/components/panel/panel.d.ts +194 -0
- package/dist/types/components/permission/permission.d.ts +93 -0
- package/dist/types/components/rating/field-adapter.d.ts +5 -0
- package/dist/types/components/rating/rating.d.ts +213 -0
- package/dist/types/components/search/search.d.ts +98 -0
- package/dist/types/components/select/field-adapter.d.ts +5 -0
- package/dist/types/components/select/filter.d.ts +11 -0
- package/dist/types/components/select/select.d.ts +235 -0
- package/dist/types/components/skeleton/skeleton.d.ts +97 -0
- package/dist/types/components/slider/field-adapter.d.ts +5 -0
- package/dist/types/components/slider/slider.d.ts +216 -0
- package/dist/types/components/split-view/split-view.d.ts +335 -0
- package/dist/types/components/stepper/stepper.d.ts +314 -0
- package/dist/types/components/stepper/steps.d.ts +131 -0
- package/dist/types/components/tabbox/tabbox.d.ts +230 -0
- package/dist/types/components/table/sort.d.ts +49 -0
- package/dist/types/components/table/table.d.ts +872 -0
- package/dist/types/components/tag-picker/field-adapter.d.ts +5 -0
- package/dist/types/components/tag-picker/tag-picker.d.ts +375 -0
- package/dist/types/components/timebox/field-adapter.d.ts +5 -0
- package/dist/types/components/timebox/timebox.d.ts +121 -0
- package/dist/types/components/toggle/field-adapter.d.ts +5 -0
- package/dist/types/components/toggle/toggle.d.ts +94 -0
- package/dist/types/components/toolbar/toolbar.d.ts +230 -0
- package/dist/types/components/tooltip/tooltip.d.ts +183 -0
- package/dist/types/components/tree/hierarchy.d.ts +113 -0
- package/dist/types/components/tree/tree.d.ts +383 -0
- package/dist/types/components/truncate/truncate.d.ts +61 -0
- package/dist/types/components/value-list/field-adapter.d.ts +5 -0
- package/dist/types/components/value-list/value-list.d.ts +143 -0
- package/dist/types/core/breakpoint.d.ts +68 -0
- package/dist/types/core/component.d.ts +115 -0
- package/dist/types/core/date.d.ts +50 -0
- package/dist/types/core/dom.d.ts +86 -0
- package/dist/types/core/export.d.ts +70 -0
- package/dist/types/core/fontawesome.d.ts +107 -0
- package/dist/types/core/format.d.ts +107 -0
- package/dist/types/core/http.d.ts +122 -0
- package/dist/types/core/i18n.d.ts +35 -0
- package/dist/types/core/icons.d.ts +137 -0
- package/dist/types/core/keyboard.d.ts +72 -0
- package/dist/types/core/position.d.ts +45 -0
- package/dist/types/core/storage.d.ts +46 -0
- package/dist/types/core/util.d.ts +117 -0
- package/dist/types/elements/define.d.ts +73 -0
- package/dist/types/elements/reflect.d.ts +27 -0
- package/dist/types/index.d.ts +79 -0
- package/dist/types/zeyos/connect.d.ts +65 -0
- package/dist/types/zeyos/form.d.ts +140 -0
- package/dist/types/zeyos/icons.d.ts +104 -0
- package/dist/types/zeyos/index.d.ts +12 -0
- package/dist/types/zeyos/modules.d.ts +110 -0
- package/dist/types/zeyos/query.d.ts +157 -0
- package/dist/types/zeyos/schema.d.ts +79 -0
- package/dist/types/zeyos/select.d.ts +167 -0
- package/dist/types/zeyos/table.d.ts +205 -0
- package/dist/zx-compat.esm.js +362 -33
- package/dist/zx-compat.esm.min.js +1 -1
- package/dist/zx-compat.global.js +359 -67
- package/dist/zx-compat.global.min.js +3 -3
- package/dist/zx-zeyos.esm.js +342 -43
- package/dist/zx-zeyos.esm.min.js +1 -1
- package/dist/zx.css +68 -6
- package/dist/zx.esm.js +346 -67
- package/dist/zx.esm.min.js +3 -3
- package/dist/zx.global.js +346 -67
- package/dist/zx.global.min.js +3 -3
- package/dist/zx.min.css +1 -1
- package/docs/api.json +48 -2
- package/docs/llms.md +28 -0
- package/package.json +37 -9
- package/src/compat/base.js +291 -0
- package/src/compat/globals.js +165 -0
- package/src/compat/index.js +84 -0
- package/src/compat/map/basic.js +464 -0
- package/src/compat/map/data.js +232 -0
- package/src/compat/map/date-time.js +177 -0
- package/src/compat/map/factory.js +45 -0
- package/src/compat/map/form.js +330 -0
- package/src/compat/map/helpers.js +46 -0
- package/src/compat/map/http.js +117 -0
- package/src/compat/map/layout.js +256 -0
- package/src/compat/map/options.js +267 -0
- package/src/compat/map/select.js +198 -0
- package/src/compat/map/stubs.js +26 -0
- package/src/compat/map/utilities.js +113 -0
- package/src/compat-entry.js +11 -0
- package/src/components/badge/badge.css +147 -0
- package/src/components/badge/badge.js +92 -0
- package/src/components/breadcrumb/breadcrumb.css +105 -0
- package/src/components/breadcrumb/breadcrumb.js +331 -0
- package/src/components/button/button.css +104 -0
- package/src/components/button/button.js +76 -0
- package/src/components/check-button/check-button.css +66 -0
- package/src/components/check-button/check-button.js +167 -0
- package/src/components/checklist/checklist.css +65 -0
- package/src/components/checklist/checklist.js +282 -0
- package/src/components/checklist/field-adapter.js +46 -0
- package/src/components/context-menu/context-menu.css +54 -0
- package/src/components/context-menu/context-menu.js +334 -0
- package/src/components/copy/copy-button.css +27 -0
- package/src/components/copy/copy-button.js +78 -0
- package/src/components/copy/copy-input.css +59 -0
- package/src/components/copy/copy-input.js +123 -0
- package/src/components/data-filter/data-filter.css +53 -0
- package/src/components/data-filter/data-filter.js +400 -0
- package/src/components/data-filter/filter-core.js +128 -0
- package/src/components/date-picker/date-picker.css +181 -0
- package/src/components/date-picker/date-picker.js +500 -0
- package/src/components/date-picker/date-range-picker.css +264 -0
- package/src/components/date-picker/date-range-picker.js +990 -0
- package/src/components/date-picker/field-adapter.js +36 -0
- package/src/components/date-picker/month-picker.js +223 -0
- package/src/components/date-picker/range-field-adapter.js +31 -0
- package/src/components/date-picker/time-picker.css +191 -0
- package/src/components/date-picker/time-picker.js +406 -0
- package/src/components/datebox/date-range-box.css +128 -0
- package/src/components/datebox/date-range-box.js +492 -0
- package/src/components/datebox/datebox.css +108 -0
- package/src/components/datebox/datebox.js +358 -0
- package/src/components/datebox/field-adapter.js +34 -0
- package/src/components/dialog/dialog.css +74 -0
- package/src/components/dialog/dialog.js +450 -0
- package/src/components/dropdown/dropdown.css +24 -0
- package/src/components/dropdown/dropdown.js +288 -0
- package/src/components/empty-state/empty-state.css +80 -0
- package/src/components/empty-state/empty-state.js +92 -0
- package/src/components/field/field.css +127 -0
- package/src/components/field/field.js +605 -0
- package/src/components/field-upload/field-adapter.js +21 -0
- package/src/components/field-upload/field-upload.css +108 -0
- package/src/components/field-upload/field-upload.js +475 -0
- package/src/components/fieldset/fieldset.css +37 -0
- package/src/components/fieldset/fieldset.js +307 -0
- package/src/components/finder/finder.css +98 -0
- package/src/components/finder/finder.js +509 -0
- package/src/components/form/form.css +32 -0
- package/src/components/form/form.js +302 -0
- package/src/components/groupbox/groupbox.css +51 -0
- package/src/components/groupbox/groupbox.js +199 -0
- package/src/components/layout/layout.css +117 -0
- package/src/components/layout/layout.js +136 -0
- package/src/components/loading/inline-loading.css +34 -0
- package/src/components/loading/inline-loading.js +140 -0
- package/src/components/loading/progress-bar.css +115 -0
- package/src/components/loading/progress-bar.js +259 -0
- package/src/components/loading/spinner.css +58 -0
- package/src/components/loading/spinner.js +57 -0
- package/src/components/master-panel/master-panel.css +51 -0
- package/src/components/master-panel/master-panel.js +203 -0
- package/src/components/menu-button/menu-button.css +49 -0
- package/src/components/menu-button/menu-button.js +294 -0
- package/src/components/message/message.css +122 -0
- package/src/components/message/message.js +499 -0
- package/src/components/modal/modal.css +47 -0
- package/src/components/modal/modal.js +203 -0
- package/src/components/multi-value-editor/field-adapter.js +44 -0
- package/src/components/multi-value-editor/multi-value-editor.css +56 -0
- package/src/components/multi-value-editor/multi-value-editor.js +170 -0
- package/src/components/navigation-bar/navigation-bar.css +130 -0
- package/src/components/navigation-bar/navigation-bar.js +450 -0
- package/src/components/number-field/field-adapter.js +40 -0
- package/src/components/number-field/number-field.css +77 -0
- package/src/components/number-field/number-field.js +545 -0
- package/src/components/pagination/pagination.css +95 -0
- package/src/components/pagination/pagination.js +481 -0
- package/src/components/panel/panel.css +123 -0
- package/src/components/panel/panel.js +336 -0
- package/src/components/permission/permission.css +47 -0
- package/src/components/permission/permission.js +198 -0
- package/src/components/rating/field-adapter.js +34 -0
- package/src/components/rating/rating.css +97 -0
- package/src/components/rating/rating.js +395 -0
- package/src/components/search/search.css +70 -0
- package/src/components/search/search.js +193 -0
- package/src/components/select/field-adapter.js +24 -0
- package/src/components/select/filter.js +39 -0
- package/src/components/select/select.css +180 -0
- package/src/components/select/select.js +663 -0
- package/src/components/skeleton/skeleton.css +76 -0
- package/src/components/skeleton/skeleton.js +139 -0
- package/src/components/slider/field-adapter.js +36 -0
- package/src/components/slider/slider.css +181 -0
- package/src/components/slider/slider.js +399 -0
- package/src/components/split-view/split-view.css +139 -0
- package/src/components/split-view/split-view.js +806 -0
- package/src/components/stepper/stepper.css +234 -0
- package/src/components/stepper/stepper.js +459 -0
- package/src/components/stepper/steps.js +131 -0
- package/src/components/tabbox/tabbox.css +281 -0
- package/src/components/tabbox/tabbox.js +484 -0
- package/src/components/table/sort.js +85 -0
- package/src/components/table/table.css +364 -0
- package/src/components/table/table.js +1693 -0
- package/src/components/tag-picker/field-adapter.js +35 -0
- package/src/components/tag-picker/tag-picker.css +169 -0
- package/src/components/tag-picker/tag-picker.js +837 -0
- package/src/components/timebox/field-adapter.js +24 -0
- package/src/components/timebox/timebox.css +51 -0
- package/src/components/timebox/timebox.js +276 -0
- package/src/components/toggle/field-adapter.js +24 -0
- package/src/components/toggle/toggle.css +66 -0
- package/src/components/toggle/toggle.js +150 -0
- package/src/components/toolbar/toolbar.css +81 -0
- package/src/components/toolbar/toolbar.js +511 -0
- package/src/components/tooltip/tooltip.css +36 -0
- package/src/components/tooltip/tooltip.js +420 -0
- package/src/components/tree/hierarchy.js +151 -0
- package/src/components/tree/tree.css +99 -0
- package/src/components/tree/tree.js +784 -0
- package/src/components/truncate/truncate.css +20 -0
- package/src/components/truncate/truncate.js +92 -0
- package/src/components/value-list/field-adapter.js +24 -0
- package/src/components/value-list/value-list.css +108 -0
- package/src/components/value-list/value-list.js +336 -0
- package/src/core/breakpoint.js +150 -0
- package/src/core/component.js +292 -0
- package/src/core/date.js +216 -0
- package/src/core/dom.js +260 -0
- package/src/core/export.js +204 -0
- package/src/core/fontawesome.js +177 -0
- package/src/core/format.js +283 -0
- package/src/core/http.js +246 -0
- package/src/core/i18n.js +83 -0
- package/src/core/icons.js +283 -0
- package/src/core/keyboard.js +240 -0
- package/src/core/position.js +177 -0
- package/src/core/storage.js +175 -0
- package/src/core/util.js +336 -0
- package/src/elements/define.js +715 -0
- package/src/elements/reflect.js +79 -0
- package/src/index.js +131 -0
- package/src/zeyos/connect.js +142 -0
- package/src/zeyos/form.js +254 -0
- package/src/zeyos/icons.js +92 -0
- package/src/zeyos/index.js +21 -0
- package/src/zeyos/modules.js +269 -0
- package/src/zeyos/query.js +199 -0
- package/src/zeyos/schema.js +474 -0
- package/src/zeyos/select.js +175 -0
- package/src/zeyos/table.js +272 -0
- package/styles/base.css +124 -0
- package/styles/icons.css +34 -0
- package/styles/tokens/dark.css +84 -0
- package/styles/tokens/density.css +9 -0
- package/styles/tokens/global.css +80 -0
- package/styles/tokens/modules.css +109 -0
- package/styles/tokens/semantic.css +49 -0
- package/styles/zx.css +61 -0
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import { clamp, uid } from './util.js';
|
|
3
|
+
|
|
4
|
+
const PLACEMENTS = new Set([
|
|
5
|
+
'bottom-start', 'bottom-end', 'top-start', 'top-end', 'bottom', 'top'
|
|
6
|
+
]);
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* @typedef {Object} PositionOptions
|
|
10
|
+
* @property {'bottom-start'|'bottom-end'|'top-start'|'top-end'|'bottom'|'top'} [placement='bottom-start']
|
|
11
|
+
* @property {number} [offset=4] Gap between anchor and floating element.
|
|
12
|
+
* @property {boolean} [flip=true] Flip vertically when the preferred side overflows.
|
|
13
|
+
* @property {boolean} [matchWidth=false] Set the floating element's minimum width to the anchor width.
|
|
14
|
+
*/
|
|
15
|
+
/**
|
|
16
|
+
* @typedef {Object} PositionController
|
|
17
|
+
* @property {() => void} update Recompute coordinates and matched width.
|
|
18
|
+
* @property {() => void} destroy Hide and clean up positioning resources.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Anchors and opens a manual popover, using CSS anchor positioning when available.
|
|
23
|
+
* @param {Element} anchor Anchor element.
|
|
24
|
+
* @param {HTMLElement} floating Floating popover element.
|
|
25
|
+
* @param {PositionOptions} [options={}] Positioning behavior.
|
|
26
|
+
* @returns {PositionController}
|
|
27
|
+
*/
|
|
28
|
+
export function position(anchor, floating, options = {}) {
|
|
29
|
+
if (!(anchor instanceof Element) || !(floating instanceof HTMLElement)) {
|
|
30
|
+
throw new TypeError('position() requires an Element anchor and HTMLElement floating element');
|
|
31
|
+
}
|
|
32
|
+
const placement = PLACEMENTS.has(options.placement) ? options.placement : 'bottom-start';
|
|
33
|
+
const offset = Number(options.offset ?? 4);
|
|
34
|
+
const flip = options.flip ?? true;
|
|
35
|
+
const matchWidth = options.matchWidth ?? false;
|
|
36
|
+
const anchorStyle = anchor.getAttribute('style');
|
|
37
|
+
const floatingStyle = floating.getAttribute('style');
|
|
38
|
+
const popover = floating.getAttribute('popover');
|
|
39
|
+
let destroyed = false;
|
|
40
|
+
let observer = null;
|
|
41
|
+
let update = () => {};
|
|
42
|
+
let cleanupMode = () => {};
|
|
43
|
+
|
|
44
|
+
floating.setAttribute('popover', 'manual');
|
|
45
|
+
const supportsAnchors = typeof CSS !== 'undefined' && CSS.supports?.('anchor-name: --zx-a');
|
|
46
|
+
if (supportsAnchors) {
|
|
47
|
+
const anchorName = `--${uid('zx-a')}`;
|
|
48
|
+
/** @type {HTMLElement} */ (anchor).style.setProperty('anchor-name', anchorName);
|
|
49
|
+
floating.style.position = 'fixed';
|
|
50
|
+
floating.style.inset = 'auto';
|
|
51
|
+
floating.style.margin = '0';
|
|
52
|
+
floating.style.setProperty('position-anchor', anchorName);
|
|
53
|
+
floating.style.setProperty('position-area', positionArea(placement));
|
|
54
|
+
floating.style.setProperty('inset-area', positionArea(placement));
|
|
55
|
+
floating.style.setProperty(
|
|
56
|
+
'position-try-fallbacks',
|
|
57
|
+
flip ? 'flip-block, flip-inline, flip-block flip-inline' : 'none'
|
|
58
|
+
);
|
|
59
|
+
floating.style.setProperty(placement.startsWith('top') ? 'margin-block-end' : 'margin-block-start', `${offset}px`);
|
|
60
|
+
update = () => {
|
|
61
|
+
if (matchWidth) floating.style.minWidth = `${anchor.getBoundingClientRect().width}px`;
|
|
62
|
+
};
|
|
63
|
+
if (typeof ResizeObserver === 'function') {
|
|
64
|
+
observer = new ResizeObserver(update);
|
|
65
|
+
observer.observe(anchor);
|
|
66
|
+
}
|
|
67
|
+
} else {
|
|
68
|
+
floating.style.position = 'fixed';
|
|
69
|
+
floating.style.inset = 'auto';
|
|
70
|
+
floating.style.margin = '0';
|
|
71
|
+
update = () => updateFallback(anchor, floating, { placement, offset, flip, matchWidth });
|
|
72
|
+
window.addEventListener('scroll', update, { capture: true, passive: true });
|
|
73
|
+
window.addEventListener('resize', update, { passive: true });
|
|
74
|
+
if (typeof ResizeObserver === 'function') {
|
|
75
|
+
observer = new ResizeObserver(update);
|
|
76
|
+
observer.observe(anchor);
|
|
77
|
+
}
|
|
78
|
+
cleanupMode = () => {
|
|
79
|
+
window.removeEventListener('scroll', update, true);
|
|
80
|
+
window.removeEventListener('resize', update);
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
showPopover(floating);
|
|
85
|
+
update();
|
|
86
|
+
|
|
87
|
+
return {
|
|
88
|
+
/** Recomputes fallback coordinates and matched width. @returns {void} */
|
|
89
|
+
update() {
|
|
90
|
+
if (!destroyed) update();
|
|
91
|
+
},
|
|
92
|
+
|
|
93
|
+
/** Hides the popover and restores changed attributes. @returns {void} */
|
|
94
|
+
destroy() {
|
|
95
|
+
if (destroyed) return;
|
|
96
|
+
destroyed = true;
|
|
97
|
+
observer?.disconnect();
|
|
98
|
+
cleanupMode();
|
|
99
|
+
hidePopover(floating);
|
|
100
|
+
restoreAttribute(anchor, 'style', anchorStyle);
|
|
101
|
+
restoreAttribute(floating, 'style', floatingStyle);
|
|
102
|
+
restoreAttribute(floating, 'popover', popover);
|
|
103
|
+
}
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** @param {string} placement @returns {string} */
|
|
108
|
+
function positionArea(placement) {
|
|
109
|
+
return {
|
|
110
|
+
'bottom-start': 'block-end span-inline-end',
|
|
111
|
+
'bottom-end': 'block-end span-inline-start',
|
|
112
|
+
'top-start': 'block-start span-inline-end',
|
|
113
|
+
'top-end': 'block-start span-inline-start',
|
|
114
|
+
bottom: 'block-end',
|
|
115
|
+
top: 'block-start'
|
|
116
|
+
}[placement];
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* @param {Element} anchor
|
|
121
|
+
* @param {HTMLElement} floating
|
|
122
|
+
* @param {Required<PositionOptions>} options
|
|
123
|
+
* @returns {void}
|
|
124
|
+
*/
|
|
125
|
+
function updateFallback(anchor, floating, { placement, offset, flip, matchWidth }) {
|
|
126
|
+
const anchorRect = anchor.getBoundingClientRect();
|
|
127
|
+
if (matchWidth) floating.style.minWidth = `${anchorRect.width}px`;
|
|
128
|
+
const floatingRect = floating.getBoundingClientRect();
|
|
129
|
+
const viewportWidth = document.documentElement.clientWidth || window.innerWidth;
|
|
130
|
+
const viewportHeight = document.documentElement.clientHeight || window.innerHeight;
|
|
131
|
+
let actualPlacement = placement;
|
|
132
|
+
const prefersBottom = placement.startsWith('bottom');
|
|
133
|
+
const bottomY = anchorRect.bottom + offset;
|
|
134
|
+
const topY = anchorRect.top - offset - floatingRect.height;
|
|
135
|
+
|
|
136
|
+
if (flip) {
|
|
137
|
+
if (prefersBottom && bottomY + floatingRect.height > viewportHeight && topY >= 0) {
|
|
138
|
+
actualPlacement = /** @type {typeof placement} */ (placement.replace('bottom', 'top'));
|
|
139
|
+
} else if (!prefersBottom && topY < 0 && bottomY + floatingRect.height <= viewportHeight) {
|
|
140
|
+
actualPlacement = /** @type {typeof placement} */ (placement.replace('top', 'bottom'));
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
let left;
|
|
145
|
+
if (actualPlacement.endsWith('-end')) left = anchorRect.right - floatingRect.width;
|
|
146
|
+
else if (actualPlacement.endsWith('-start')) left = anchorRect.left;
|
|
147
|
+
else left = anchorRect.left + (anchorRect.width - floatingRect.width) / 2;
|
|
148
|
+
const top = actualPlacement.startsWith('bottom') ? bottomY : topY;
|
|
149
|
+
floating.style.left = `${Math.round(clamp(left, 0, Math.max(0, viewportWidth - floatingRect.width)))}px`;
|
|
150
|
+
floating.style.top = `${Math.round(clamp(top, 0, Math.max(0, viewportHeight - floatingRect.height)))}px`;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** @param {HTMLElement} element @returns {void} */
|
|
154
|
+
function showPopover(element) {
|
|
155
|
+
if (typeof element.showPopover !== 'function') return;
|
|
156
|
+
try {
|
|
157
|
+
element.showPopover();
|
|
158
|
+
} catch {
|
|
159
|
+
// A disconnected, already-open, or transitioning popover cannot be shown.
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** @param {HTMLElement} element @returns {void} */
|
|
164
|
+
function hidePopover(element) {
|
|
165
|
+
if (typeof element.hidePopover !== 'function') return;
|
|
166
|
+
try {
|
|
167
|
+
element.hidePopover();
|
|
168
|
+
} catch {
|
|
169
|
+
// A disconnected or already-hidden popover is absent from the top layer.
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** @param {Element} element @param {string} name @param {string|null} value @returns {void} */
|
|
174
|
+
function restoreAttribute(element, name, value) {
|
|
175
|
+
if (value === null) element.removeAttribute(name);
|
|
176
|
+
else element.setAttribute(name, value);
|
|
177
|
+
}
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/**
|
|
3
|
+
* @typedef {Object} StorageOptions
|
|
4
|
+
* @property {'local'|'session'} [area='local'] Backing Web Storage area.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* @typedef {Object} NamespacedStorage
|
|
9
|
+
* @property {(key: string, fallback?: any) => any} get Reads and decodes a value.
|
|
10
|
+
* @property {(key: string, value: unknown) => void} set Encodes and writes a value.
|
|
11
|
+
* @property {(key: string) => void} remove Deletes a single key.
|
|
12
|
+
* @property {() => string[]} keys Lists the namespace's keys without their prefix.
|
|
13
|
+
* @property {() => void} clear Deletes every key in the namespace, leaving other namespaces alone.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/** Every key written by this module carries this prefix plus its namespace. */
|
|
17
|
+
const PREFIX = 'zx:';
|
|
18
|
+
/** Written and removed once to detect an area that exists but refuses writes. */
|
|
19
|
+
const PROBE_KEY = 'zx:__probe__';
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Creates a namespaced, JSON-encoded view of `localStorage` or `sessionStorage`.
|
|
23
|
+
*
|
|
24
|
+
* Every key is stored as `zx:<namespace>:<key>`, so several features can share one origin without
|
|
25
|
+
* colliding, and `clear()` only touches the namespace it belongs to. When the storage area is
|
|
26
|
+
* unavailable or refuses a write — private browsing, a full quota, cookies disabled — the view
|
|
27
|
+
* transparently falls back to an in-memory map that lives as long as the page, so callers never
|
|
28
|
+
* need a try/catch and never see an exception.
|
|
29
|
+
*
|
|
30
|
+
* @param {string} namespace Namespace segment, typically an application or feature name.
|
|
31
|
+
* @param {StorageOptions} [options={}] Area selection.
|
|
32
|
+
* @returns {NamespacedStorage}
|
|
33
|
+
*/
|
|
34
|
+
export function storage(namespace, options = {}) {
|
|
35
|
+
const { area = 'local' } = options;
|
|
36
|
+
const prefix = `${PREFIX}${String(namespace ?? '')}:`;
|
|
37
|
+
/** @type {Map<string, string>} */
|
|
38
|
+
const memory = new Map();
|
|
39
|
+
let backing = resolveArea(area);
|
|
40
|
+
|
|
41
|
+
/** @param {string} key @returns {string|null} */
|
|
42
|
+
const readRaw = (key) => {
|
|
43
|
+
if (backing !== null) {
|
|
44
|
+
try {
|
|
45
|
+
return backing.getItem(key);
|
|
46
|
+
} catch {
|
|
47
|
+
backing = null;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return memory.has(key) ? memory.get(key) : null;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
/** @param {string} key @param {string} raw @returns {void} */
|
|
54
|
+
const writeRaw = (key, raw) => {
|
|
55
|
+
if (backing !== null) {
|
|
56
|
+
try {
|
|
57
|
+
backing.setItem(key, raw);
|
|
58
|
+
return;
|
|
59
|
+
} catch {
|
|
60
|
+
// A quota error mid-session degrades this view to memory rather than throwing.
|
|
61
|
+
backing = null;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
memory.set(key, raw);
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
/** @param {string} key @returns {void} */
|
|
68
|
+
const removeRaw = (key) => {
|
|
69
|
+
if (backing !== null) {
|
|
70
|
+
try {
|
|
71
|
+
backing.removeItem(key);
|
|
72
|
+
} catch {
|
|
73
|
+
backing = null;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
memory.delete(key);
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
/** @returns {string[]} */
|
|
80
|
+
const rawKeys = () => {
|
|
81
|
+
if (backing !== null) {
|
|
82
|
+
try {
|
|
83
|
+
const found = [];
|
|
84
|
+
for (let index = 0; index < backing.length; index += 1) {
|
|
85
|
+
const key = backing.key(index);
|
|
86
|
+
if (typeof key === 'string' && key.startsWith(prefix)) found.push(key);
|
|
87
|
+
}
|
|
88
|
+
return found;
|
|
89
|
+
} catch {
|
|
90
|
+
backing = null;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return [...memory.keys()].filter((key) => key.startsWith(prefix));
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
return {
|
|
97
|
+
/**
|
|
98
|
+
* Reads a value, returning the fallback for a missing key or unparseable JSON.
|
|
99
|
+
* @param {string} key Key within the namespace.
|
|
100
|
+
* @param {any} [fallback] Value returned when nothing usable is stored.
|
|
101
|
+
* @returns {any}
|
|
102
|
+
*/
|
|
103
|
+
get(key, fallback) {
|
|
104
|
+
const raw = readRaw(prefix + key);
|
|
105
|
+
if (raw === null || raw === undefined) return fallback;
|
|
106
|
+
try {
|
|
107
|
+
return JSON.parse(raw);
|
|
108
|
+
} catch {
|
|
109
|
+
return fallback;
|
|
110
|
+
}
|
|
111
|
+
},
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Writes a JSON-encoded value. `undefined` and values JSON cannot represent remove the key.
|
|
115
|
+
* @param {string} key Key within the namespace.
|
|
116
|
+
* @param {unknown} value Value to store.
|
|
117
|
+
* @returns {void}
|
|
118
|
+
*/
|
|
119
|
+
set(key, value) {
|
|
120
|
+
let raw;
|
|
121
|
+
try {
|
|
122
|
+
raw = JSON.stringify(value);
|
|
123
|
+
} catch {
|
|
124
|
+
// Circular structures and BigInt are not storable; dropping the key beats throwing.
|
|
125
|
+
raw = undefined;
|
|
126
|
+
}
|
|
127
|
+
if (raw === undefined) removeRaw(prefix + key);
|
|
128
|
+
else writeRaw(prefix + key, raw);
|
|
129
|
+
},
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Removes a single key.
|
|
133
|
+
* @param {string} key Key within the namespace.
|
|
134
|
+
* @returns {void}
|
|
135
|
+
*/
|
|
136
|
+
remove(key) {
|
|
137
|
+
removeRaw(prefix + key);
|
|
138
|
+
},
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Lists the namespace's keys with the prefix stripped.
|
|
142
|
+
* @returns {string[]}
|
|
143
|
+
*/
|
|
144
|
+
keys() {
|
|
145
|
+
return rawKeys().map((key) => key.slice(prefix.length));
|
|
146
|
+
},
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Removes every key in the namespace and nothing else.
|
|
150
|
+
* @returns {void}
|
|
151
|
+
*/
|
|
152
|
+
clear() {
|
|
153
|
+
for (const key of rawKeys()) removeRaw(key);
|
|
154
|
+
}
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Returns a usable Web Storage area, or null when it is missing or rejects writes.
|
|
160
|
+
* @param {'local'|'session'} area Area name.
|
|
161
|
+
* @returns {Storage|null}
|
|
162
|
+
*/
|
|
163
|
+
function resolveArea(area) {
|
|
164
|
+
const name = area === 'session' ? 'sessionStorage' : 'localStorage';
|
|
165
|
+
try {
|
|
166
|
+
const candidate = globalThis[name];
|
|
167
|
+
if (!candidate || typeof candidate.getItem !== 'function') return null;
|
|
168
|
+
// Safari's private mode exposes the API and throws on write, so probe before trusting it.
|
|
169
|
+
candidate.setItem(PROBE_KEY, '1');
|
|
170
|
+
candidate.removeItem(PROBE_KEY);
|
|
171
|
+
return candidate;
|
|
172
|
+
} catch {
|
|
173
|
+
return null;
|
|
174
|
+
}
|
|
175
|
+
}
|
package/src/core/util.js
ADDED
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/**
|
|
3
|
+
* Returns a debounced wrapper that runs after calls have stopped for the delay.
|
|
4
|
+
* @template {(...args: any[]) => any} T
|
|
5
|
+
* @param {T} fn Function to debounce.
|
|
6
|
+
* @param {number} ms Delay in milliseconds.
|
|
7
|
+
* @returns {(...args: Parameters<T>) => void}
|
|
8
|
+
*/
|
|
9
|
+
export function debounce(fn, ms) {
|
|
10
|
+
let timer = null;
|
|
11
|
+
|
|
12
|
+
return function debounced(...args) {
|
|
13
|
+
const context = this;
|
|
14
|
+
if (timer !== null) clearTimeout(timer);
|
|
15
|
+
timer = setTimeout(() => {
|
|
16
|
+
timer = null;
|
|
17
|
+
fn.apply(context, args);
|
|
18
|
+
}, ms);
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* @typedef {Object} ThrottleOptions
|
|
24
|
+
* @property {boolean} [leading=true] Whether the first call in a window runs immediately.
|
|
25
|
+
* @property {boolean} [trailing=true] Whether a call made during a window runs when it closes.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Returns a throttled wrapper that runs at most once per interval, on the leading edge, the
|
|
30
|
+
* trailing edge, or both. The trailing call replays the most recent arguments and context.
|
|
31
|
+
* @template {(...args: any[]) => any} T
|
|
32
|
+
* @param {T} fn Function to throttle.
|
|
33
|
+
* @param {number} ms Interval in milliseconds.
|
|
34
|
+
* @param {ThrottleOptions} [options={}] Edge behaviour.
|
|
35
|
+
* @returns {(...args: Parameters<T>) => void}
|
|
36
|
+
*/
|
|
37
|
+
export function throttle(fn, ms, { leading = true, trailing = true } = {}) {
|
|
38
|
+
const interval = Math.max(0, Number(ms) || 0);
|
|
39
|
+
let timer = null;
|
|
40
|
+
let previous = 0;
|
|
41
|
+
/** @type {any[]|null} */
|
|
42
|
+
let pendingArgs = null;
|
|
43
|
+
let pendingContext = null;
|
|
44
|
+
|
|
45
|
+
const runTrailing = () => {
|
|
46
|
+
timer = null;
|
|
47
|
+
// Without a leading edge the next call must start a fresh window rather than fire at once.
|
|
48
|
+
previous = leading ? Date.now() : 0;
|
|
49
|
+
const args = pendingArgs;
|
|
50
|
+
const context = pendingContext;
|
|
51
|
+
pendingArgs = null;
|
|
52
|
+
pendingContext = null;
|
|
53
|
+
if (args !== null) fn.apply(context, args);
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
return function throttled(...args) {
|
|
57
|
+
const now = Date.now();
|
|
58
|
+
if (previous === 0 && !leading) previous = now;
|
|
59
|
+
const remaining = interval - (now - previous);
|
|
60
|
+
|
|
61
|
+
if (remaining <= 0 || remaining > interval) {
|
|
62
|
+
if (timer !== null) {
|
|
63
|
+
clearTimeout(timer);
|
|
64
|
+
timer = null;
|
|
65
|
+
}
|
|
66
|
+
previous = now;
|
|
67
|
+
pendingArgs = null;
|
|
68
|
+
pendingContext = null;
|
|
69
|
+
fn.apply(this, args);
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (!trailing) return;
|
|
74
|
+
pendingArgs = args;
|
|
75
|
+
pendingContext = this;
|
|
76
|
+
if (timer === null) timer = setTimeout(runTrailing, remaining);
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
let uidSequence = 0;
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Creates a process-unique identifier suitable for DOM IDs and CSS names.
|
|
84
|
+
* @param {string} [prefix='zx'] Identifier prefix.
|
|
85
|
+
* @returns {string}
|
|
86
|
+
*/
|
|
87
|
+
export function uid(prefix = 'zx') {
|
|
88
|
+
uidSequence += 1;
|
|
89
|
+
return `${prefix}-${Date.now().toString(36)}-${uidSequence.toString(36)}`;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Recursively merges plain objects without mutating either input.
|
|
94
|
+
* Arrays and other values are replaced rather than merged.
|
|
95
|
+
* @template T
|
|
96
|
+
* @template U
|
|
97
|
+
* @param {T} a Base value.
|
|
98
|
+
* @param {U} b Overriding value.
|
|
99
|
+
* @returns {T & U}
|
|
100
|
+
*/
|
|
101
|
+
export function deepMerge(a, b) {
|
|
102
|
+
const result = isPlainObject(a) ? clonePlainObject(a) : {};
|
|
103
|
+
if (!isPlainObject(b)) return /** @type {T & U} */ (cloneValue(b));
|
|
104
|
+
|
|
105
|
+
for (const [key, value] of Object.entries(b)) {
|
|
106
|
+
if (isPlainObject(value) && isPlainObject(result[key])) {
|
|
107
|
+
result[key] = deepMerge(result[key], value);
|
|
108
|
+
} else {
|
|
109
|
+
result[key] = cloneValue(value);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return /** @type {T & U} */ (result);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Reports whether a value is a DOM Element, including elements from another realm.
|
|
117
|
+
* @param {unknown} value Value to inspect.
|
|
118
|
+
* @returns {value is Element}
|
|
119
|
+
*/
|
|
120
|
+
export function isElement(value) {
|
|
121
|
+
return Boolean(value && typeof value === 'object'
|
|
122
|
+
&& /** @type {{nodeType?: unknown}} */ (value).nodeType === 1);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Restricts a number to an inclusive range.
|
|
127
|
+
* @param {number} n Value to restrict.
|
|
128
|
+
* @param {number} min Minimum value.
|
|
129
|
+
* @param {number} max Maximum value.
|
|
130
|
+
* @returns {number}
|
|
131
|
+
*/
|
|
132
|
+
export function clamp(n, min, max) {
|
|
133
|
+
return Math.min(max, Math.max(min, n));
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Normalizes a nullable, scalar, iterable, or array value to a new array.
|
|
138
|
+
* @template T
|
|
139
|
+
* @param {T|Iterable<T>|ArrayLike<T>|null|undefined} value Value to normalize.
|
|
140
|
+
* @returns {T[]}
|
|
141
|
+
*/
|
|
142
|
+
export function toArray(value) {
|
|
143
|
+
if (value == null) return [];
|
|
144
|
+
if (Array.isArray(value)) return value.slice();
|
|
145
|
+
// Both branches are runtime feature tests; the parameter type cannot express either.
|
|
146
|
+
const candidate = /** @type {any} */ (value);
|
|
147
|
+
if (typeof value !== 'string' && typeof candidate?.[Symbol.iterator] === 'function') {
|
|
148
|
+
return Array.from(candidate);
|
|
149
|
+
}
|
|
150
|
+
if (typeof value !== 'string' && typeof candidate?.length === 'number') {
|
|
151
|
+
return Array.from(candidate);
|
|
152
|
+
}
|
|
153
|
+
return [/** @type {T} */ (value)];
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* @template T
|
|
158
|
+
* @typedef {string|((item: T) => unknown)} KeySelector A property name or an accessor function.
|
|
159
|
+
*/
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* @template T
|
|
163
|
+
* @typedef {KeySelector<T>|{key: KeySelector<T>, dir?: 'asc'|'desc'}} SortKey
|
|
164
|
+
* A property name (prefix with `-` to sort descending), an accessor, or an explicit direction.
|
|
165
|
+
*/
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Groups items into a plain object of arrays, keyed by a property or accessor and ordered by first
|
|
169
|
+
* appearance.
|
|
170
|
+
* @template T
|
|
171
|
+
* @param {Iterable<T>|ArrayLike<T>|null|undefined} items Items to group.
|
|
172
|
+
* @param {KeySelector<T>} key Property name or accessor returning the group key.
|
|
173
|
+
* @returns {Record<string, T[]>}
|
|
174
|
+
*/
|
|
175
|
+
export function groupBy(items, key) {
|
|
176
|
+
const select = keySelector(key);
|
|
177
|
+
/** @type {Map<string, T[]>} */
|
|
178
|
+
const groups = new Map();
|
|
179
|
+
|
|
180
|
+
for (const item of toArray(items)) {
|
|
181
|
+
const name = String(select(item));
|
|
182
|
+
const bucket = groups.get(name);
|
|
183
|
+
if (bucket === undefined) groups.set(name, [item]);
|
|
184
|
+
else bucket.push(item);
|
|
185
|
+
}
|
|
186
|
+
// fromEntries defines own properties, so a group named "__proto__" stays plain data.
|
|
187
|
+
return Object.fromEntries(groups);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Sorts items by one or more keys into a NEW array, leaving the input untouched. Strings compare
|
|
192
|
+
* with `localeCompare` (digit runs compare numerically), numbers, booleans, and dates compare by
|
|
193
|
+
* value, and null, undefined, and NaN always sort last regardless of direction.
|
|
194
|
+
* @template T
|
|
195
|
+
* @param {Iterable<T>|ArrayLike<T>|null|undefined} items Items to sort.
|
|
196
|
+
* @param {...SortKey<T>} keys Sort keys applied in order.
|
|
197
|
+
* @returns {T[]}
|
|
198
|
+
*/
|
|
199
|
+
export function sortBy(items, ...keys) {
|
|
200
|
+
const list = toArray(items);
|
|
201
|
+
const comparators = keys.map(toComparator);
|
|
202
|
+
if (comparators.length === 0) return list;
|
|
203
|
+
|
|
204
|
+
return list.sort((a, b) => {
|
|
205
|
+
for (const compare of comparators) {
|
|
206
|
+
const result = compare(a, b);
|
|
207
|
+
if (result !== 0) return result;
|
|
208
|
+
}
|
|
209
|
+
return 0;
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Removes duplicates, keeping the first occurrence, and returns a new array.
|
|
215
|
+
* @template T
|
|
216
|
+
* @param {Iterable<T>|ArrayLike<T>|null|undefined} items Items to filter.
|
|
217
|
+
* @param {KeySelector<T>} [key] Property name or accessor; the item itself when omitted.
|
|
218
|
+
* @returns {T[]}
|
|
219
|
+
*/
|
|
220
|
+
export function uniqueBy(items, key) {
|
|
221
|
+
const select = key == null ? (/** @type {T} */ item) => item : keySelector(key);
|
|
222
|
+
const seen = new Set();
|
|
223
|
+
const result = [];
|
|
224
|
+
|
|
225
|
+
for (const item of toArray(items)) {
|
|
226
|
+
const identity = select(item);
|
|
227
|
+
if (seen.has(identity)) continue;
|
|
228
|
+
seen.add(identity);
|
|
229
|
+
result.push(item);
|
|
230
|
+
}
|
|
231
|
+
return result;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Escapes regular-expression metacharacters so a value can be used as a literal pattern.
|
|
236
|
+
* @param {unknown} value Value to escape.
|
|
237
|
+
* @returns {string}
|
|
238
|
+
*/
|
|
239
|
+
export function escapeRegExp(value) {
|
|
240
|
+
return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** @template T @param {KeySelector<T>} key @returns {(item: T) => unknown} */
|
|
244
|
+
function keySelector(key) {
|
|
245
|
+
if (typeof key === 'function') return key;
|
|
246
|
+
const name = String(key);
|
|
247
|
+
return (item) => (item == null ? undefined : item[name]);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** @template T @param {SortKey<T>} key @returns {(a: T, b: T) => number} */
|
|
251
|
+
function toComparator(key) {
|
|
252
|
+
let select;
|
|
253
|
+
let direction = 1;
|
|
254
|
+
|
|
255
|
+
if (typeof key === 'function') {
|
|
256
|
+
select = key;
|
|
257
|
+
} else if (key !== null && typeof key === 'object') {
|
|
258
|
+
select = keySelector(key.key);
|
|
259
|
+
if (String(key.dir).toLowerCase() === 'desc') direction = -1;
|
|
260
|
+
} else {
|
|
261
|
+
let name = String(key);
|
|
262
|
+
if (name.startsWith('-')) {
|
|
263
|
+
direction = -1;
|
|
264
|
+
name = name.slice(1);
|
|
265
|
+
} else if (name.startsWith('+')) {
|
|
266
|
+
name = name.slice(1);
|
|
267
|
+
}
|
|
268
|
+
select = keySelector(name);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
return (a, b) => {
|
|
272
|
+
const left = select(a);
|
|
273
|
+
const right = select(b);
|
|
274
|
+
const leftEmpty = isEmptyValue(left);
|
|
275
|
+
const rightEmpty = isEmptyValue(right);
|
|
276
|
+
// Empties are placed outside the direction flip so they stay at the end either way.
|
|
277
|
+
if (leftEmpty || rightEmpty) {
|
|
278
|
+
if (leftEmpty && rightEmpty) return 0;
|
|
279
|
+
return leftEmpty ? 1 : -1;
|
|
280
|
+
}
|
|
281
|
+
return direction * compareValues(left, right);
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** @param {unknown} value @returns {boolean} */
|
|
286
|
+
function isEmptyValue(value) {
|
|
287
|
+
return value == null || (typeof value === 'number' && Number.isNaN(value));
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/** @param {any} a @param {any} b @returns {number} */
|
|
291
|
+
function compareValues(a, b) {
|
|
292
|
+
if (a instanceof Date || b instanceof Date) {
|
|
293
|
+
return sign(toTimestamp(a) - toTimestamp(b));
|
|
294
|
+
}
|
|
295
|
+
if (typeof a === 'boolean' || typeof b === 'boolean') {
|
|
296
|
+
return sign(Number(a) - Number(b));
|
|
297
|
+
}
|
|
298
|
+
if (typeof a === 'number' && typeof b === 'number') {
|
|
299
|
+
return sign(a - b);
|
|
300
|
+
}
|
|
301
|
+
return String(a).localeCompare(String(b), undefined, { numeric: true });
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/** @param {any} value @returns {number} */
|
|
305
|
+
function toTimestamp(value) {
|
|
306
|
+
if (value instanceof Date) return value.getTime();
|
|
307
|
+
const number = Number(value);
|
|
308
|
+
return Number.isFinite(number) ? number : 0;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/** @param {number} value @returns {number} */
|
|
312
|
+
function sign(value) {
|
|
313
|
+
if (Number.isNaN(value)) return 0;
|
|
314
|
+
return value < 0 ? -1 : (value > 0 ? 1 : 0);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/** @param {unknown} value @returns {value is Record<string, any>} */
|
|
318
|
+
function isPlainObject(value) {
|
|
319
|
+
if (value === null || typeof value !== 'object') return false;
|
|
320
|
+
const prototype = Object.getPrototypeOf(value);
|
|
321
|
+
return prototype === Object.prototype || prototype === null;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/** @param {Record<string, any>} value @returns {Record<string, any>} */
|
|
325
|
+
function clonePlainObject(value) {
|
|
326
|
+
const result = {};
|
|
327
|
+
for (const [key, item] of Object.entries(value)) result[key] = cloneValue(item);
|
|
328
|
+
return result;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/** @param {any} value @returns {any} */
|
|
332
|
+
function cloneValue(value) {
|
|
333
|
+
if (isPlainObject(value)) return clonePlainObject(value);
|
|
334
|
+
if (Array.isArray(value)) return value.map(cloneValue);
|
|
335
|
+
return value;
|
|
336
|
+
}
|