@zeyos/zx 4.3.0 → 4.4.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 +266 -1
- package/README.md +7 -3
- package/dist/types/components/activity-item/activity-item.d.ts +351 -0
- package/dist/types/components/activity-list/activity-list.d.ts +195 -0
- package/dist/types/components/app-sidebar/app-sidebar.d.ts +28 -0
- package/dist/types/components/button/button.d.ts +8 -0
- package/dist/types/components/calendar/calendar-model.d.ts +22 -0
- package/dist/types/components/calendar/calendar.d.ts +19 -0
- package/dist/types/components/card/card.d.ts +7 -0
- package/dist/types/components/card-view/card-view.d.ts +12 -4
- package/dist/types/components/card-view/record-card.d.ts +53 -5
- package/dist/types/components/code-editor/code-editor.d.ts +160 -0
- package/dist/types/components/code-editor/field-adapter.d.ts +5 -0
- package/dist/types/components/context-menu/context-menu.d.ts +18 -2
- package/dist/types/components/date-picker/date-picker.d.ts +8 -0
- package/dist/types/components/date-picker/time-picker.d.ts +7 -0
- package/dist/types/components/dialog/dialog.d.ts +29 -0
- package/dist/types/components/entity-ref/entity-ref.d.ts +331 -0
- package/dist/types/components/file-item/file-item.d.ts +234 -0
- package/dist/types/components/file-list/file-list.d.ts +171 -0
- package/dist/types/components/filter/filter.d.ts +91 -36
- package/dist/types/components/filter-panel/filter-panel.d.ts +449 -0
- package/dist/types/components/kanban-view/kanban-card.d.ts +138 -0
- package/dist/types/components/kanban-view/kanban-policy.d.ts +268 -0
- package/dist/types/components/kanban-view/kanban-view.d.ts +837 -26
- package/dist/types/components/launcher/launcher.d.ts +19 -12
- package/dist/types/components/list-toolbar/list-toolbar.d.ts +599 -0
- package/dist/types/components/menu-button/menu-button.d.ts +4 -37
- package/dist/types/components/message/message.d.ts +7 -0
- package/dist/types/components/modal/modal.d.ts +48 -0
- package/dist/types/components/navigation-bar/navigation-bar.d.ts +105 -21
- package/dist/types/components/panel/panel.d.ts +7 -0
- package/dist/types/components/search/search.d.ts +7 -0
- package/dist/types/components/select/select.d.ts +4 -0
- package/dist/types/components/sort-control/sort-control.d.ts +105 -0
- package/dist/types/components/stat-tile/stat-tile.d.ts +277 -0
- package/dist/types/components/tabbox/tabbox.d.ts +7 -0
- package/dist/types/components/table/table.d.ts +144 -0
- package/dist/types/components/table-view/table-view.d.ts +11 -5
- package/dist/types/components/timebox/timebox.d.ts +7 -0
- package/dist/types/components/value-list/value-list.d.ts +8 -0
- package/dist/types/components/view/record-view.d.ts +96 -5
- package/dist/types/core/component.d.ts +6 -1
- package/dist/types/core/icons.d.ts +17 -0
- package/dist/types/index.d.ts +13 -1
- package/dist/types/internal/app-rail.d.ts +10 -0
- package/dist/types/internal/menu-items.d.ts +134 -0
- package/dist/zx-zeyos.esm.js +651 -88
- package/dist/zx-zeyos.esm.min.js +8 -1
- package/dist/zx.css +6266 -3900
- package/dist/zx.esm.js +8487 -1805
- package/dist/zx.esm.min.js +10 -3
- package/dist/zx.global.js +8487 -1805
- package/dist/zx.global.min.js +10 -3
- package/dist/zx.min.css +1 -1
- package/docs/api.json +1811 -247
- package/docs/llms.md +345 -40
- package/docs/llms.txt +11 -7
- package/package.json +1 -2
- package/src/components/activity-item/activity-item.css +137 -0
- package/src/components/activity-item/activity-item.js +599 -0
- package/src/components/activity-list/activity-list.css +64 -0
- package/src/components/activity-list/activity-list.js +375 -0
- package/src/components/app-rail/app-rail.css +227 -19
- package/src/components/app-sidebar/app-sidebar.css +414 -24
- package/src/components/app-sidebar/app-sidebar.js +65 -5
- package/src/components/button/button.css +8 -0
- package/src/components/button/button.js +6 -1
- package/src/components/calendar/calendar-model.js +45 -0
- package/src/components/calendar/calendar.css +30 -10
- package/src/components/calendar/calendar.js +41 -13
- package/src/components/card/card.js +12 -1
- package/src/components/card-view/card-view.css +212 -9
- package/src/components/card-view/card-view.js +32 -16
- package/src/components/card-view/record-card.js +98 -21
- package/src/components/code-editor/code-editor.css +67 -0
- package/src/components/code-editor/code-editor.js +288 -0
- package/src/components/code-editor/field-adapter.js +34 -0
- package/src/components/context-menu/context-menu.css +80 -3
- package/src/components/context-menu/context-menu.js +22 -30
- package/src/components/date-picker/date-picker.js +22 -8
- package/src/components/date-picker/month-picker.js +3 -3
- package/src/components/date-picker/time-picker.js +20 -8
- package/src/components/dialog/dialog.js +88 -66
- package/src/components/dropdown/dropdown.js +2 -2
- package/src/components/entity-ref/entity-ref.css +182 -0
- package/src/components/entity-ref/entity-ref.js +496 -0
- package/src/components/file-item/file-item.css +88 -0
- package/src/components/file-item/file-item.js +371 -0
- package/src/components/file-list/file-list.css +51 -0
- package/src/components/file-list/file-list.js +320 -0
- package/src/components/filter/filter.css +27 -1
- package/src/components/filter/filter.js +199 -45
- package/src/components/filter-panel/filter-panel.css +104 -0
- package/src/components/filter-panel/filter-panel.js +964 -0
- package/src/components/kanban-view/kanban-card.js +239 -0
- package/src/components/kanban-view/kanban-policy.js +412 -0
- package/src/components/kanban-view/kanban-view.css +674 -36
- package/src/components/kanban-view/kanban-view.js +1762 -240
- package/src/components/launcher/launcher.js +59 -27
- package/src/components/list-toolbar/list-toolbar.css +189 -0
- package/src/components/list-toolbar/list-toolbar.js +851 -0
- package/src/components/menu-button/menu-button.css +80 -3
- package/src/components/menu-button/menu-button.js +21 -37
- package/src/components/message/message.css +14 -12
- package/src/components/message/message.js +13 -2
- package/src/components/modal/modal.css +15 -0
- package/src/components/modal/modal.js +141 -0
- package/src/components/navigation-bar/navigation-bar.css +77 -2
- package/src/components/navigation-bar/navigation-bar.js +171 -4
- package/src/components/panel/panel.js +13 -2
- package/src/components/search/search.js +17 -5
- package/src/components/select/field-adapter.js +4 -1
- package/src/components/select/select.js +3 -0
- package/src/components/sort-control/sort-control.css +17 -0
- package/src/components/sort-control/sort-control.js +140 -0
- package/src/components/stat-tile/stat-tile.css +213 -0
- package/src/components/stat-tile/stat-tile.js +590 -0
- package/src/components/tabbox/tabbox.js +12 -1
- package/src/components/table/table.css +51 -2
- package/src/components/table/table.js +298 -19
- package/src/components/table-view/table-view.css +8 -2
- package/src/components/table-view/table-view.js +21 -6
- package/src/components/tag-picker/tag-picker.css +3 -1
- package/src/components/timebox/timebox.js +18 -5
- package/src/components/value-list/value-list.js +22 -6
- package/src/components/view/record-view.css +5 -0
- package/src/components/view/record-view.js +100 -7
- package/src/core/component.js +8 -3
- package/src/core/icons.js +74 -5
- package/src/index.js +33 -3
- package/src/internal/app-rail.js +19 -1
- package/src/internal/menu-items.js +142 -0
- package/styles/print.css +192 -0
- package/styles/zx.css +11 -0
- package/DESIGN-SYSTEM.md +0 -182
|
@@ -35,9 +35,29 @@ import { MenuButton } from '../menu-button/menu-button.js';
|
|
|
35
35
|
* @property {NavigationItem[]} [items=[]] Navigation items.
|
|
36
36
|
* @property {string|null} [active=null] Initially active item, or the first enabled item.
|
|
37
37
|
* @property {NavigationAction[]} [actions=[]] Right-aligned action elements or descriptors.
|
|
38
|
+
* @property {boolean} [overflow=true] Whether items collapse into the overflow menu at all.
|
|
39
|
+
* `false` keeps every item in the bar at every width and never shows the More button.
|
|
40
|
+
* @property {string|number|false} [overflowBelow='44rem'] Container inline size at or below which
|
|
41
|
+
* items collapse, as a `px` or `rem` length (a number is read as `px`); `false` disables
|
|
42
|
+
* collapsing exactly as `overflow: false` does. The default is the threshold the stylesheet's
|
|
43
|
+
* own container query carries, and a bar that keeps it collapses in CSS without measuring
|
|
44
|
+
* itself; any other value is watched with a `ResizeObserver`.
|
|
45
|
+
* @property {number} [minVisible=0] How many items, counted from the start, never collapse. The
|
|
46
|
+
* default collapses all of them, which is right for a top app bar and wrong for a phone bottom
|
|
47
|
+
* bar — `minVisible: 4` is what makes that bar usable.
|
|
38
48
|
* @property {(event: CustomEvent<{name: string}>) => void} [onchange] Change callback.
|
|
39
49
|
*/
|
|
40
50
|
|
|
51
|
+
/**
|
|
52
|
+
* The container width `navigation-bar.css` collapses at. A bar that keeps this exact threshold is
|
|
53
|
+
* served by the stylesheet's container query, so it needs no measurement and no script at all;
|
|
54
|
+
* anything else has to be watched. Keep it in step with the `@container` rule.
|
|
55
|
+
*/
|
|
56
|
+
export const OVERFLOW_BELOW = '44rem';
|
|
57
|
+
|
|
58
|
+
/** The lengths a threshold may be written in — the two a `ResizeObserver` can resolve alone. */
|
|
59
|
+
const OVERFLOW_LENGTH = /^\d+(?:\.\d+)?(?:px|rem)$/;
|
|
60
|
+
|
|
41
61
|
/**
|
|
42
62
|
* @typedef {Object} NavigationRecord
|
|
43
63
|
* @property {NavigationItem} definition Normalized item definition.
|
|
@@ -60,7 +80,10 @@ export class NavigationBar extends Component {
|
|
|
60
80
|
title: '',
|
|
61
81
|
items: [],
|
|
62
82
|
active: null,
|
|
63
|
-
actions: []
|
|
83
|
+
actions: [],
|
|
84
|
+
overflow: true,
|
|
85
|
+
overflowBelow: OVERFLOW_BELOW,
|
|
86
|
+
minVisible: 0
|
|
64
87
|
};
|
|
65
88
|
|
|
66
89
|
/**
|
|
@@ -85,6 +108,7 @@ export class NavigationBar extends Component {
|
|
|
85
108
|
if (record.definition.href) globalThis.location?.assign(record.definition.href);
|
|
86
109
|
});
|
|
87
110
|
this._syncOverflow();
|
|
111
|
+
this._observeWidth();
|
|
88
112
|
}
|
|
89
113
|
|
|
90
114
|
/** @returns {HTMLElement} */
|
|
@@ -100,6 +124,8 @@ export class NavigationBar extends Component {
|
|
|
100
124
|
this._activeName = null;
|
|
101
125
|
this._tabMode = false;
|
|
102
126
|
this._overflow = null;
|
|
127
|
+
this._observer = null;
|
|
128
|
+
this._frame = 0;
|
|
103
129
|
|
|
104
130
|
const bar = h('header', { class: 'zx-navigation-bar__bar' },
|
|
105
131
|
h('div', { class: 'zx-navigation-bar__brand', ref: 'title' }),
|
|
@@ -257,6 +283,10 @@ export class NavigationBar extends Component {
|
|
|
257
283
|
destroy() {
|
|
258
284
|
if (this._cleaned) return;
|
|
259
285
|
this._cleaned = true;
|
|
286
|
+
if (this._frame && typeof cancelAnimationFrame === 'function') cancelAnimationFrame(this._frame);
|
|
287
|
+
this._frame = 0;
|
|
288
|
+
this._observer?.disconnect();
|
|
289
|
+
this._observer = null;
|
|
260
290
|
this._overflow?.destroy();
|
|
261
291
|
super.destroy();
|
|
262
292
|
if (!this._createdRoot && this._original) restore(this.el, this._original);
|
|
@@ -382,11 +412,34 @@ export class NavigationBar extends Component {
|
|
|
382
412
|
}
|
|
383
413
|
}
|
|
384
414
|
|
|
385
|
-
/**
|
|
415
|
+
/**
|
|
416
|
+
* Republishes the overflow state: which items may collapse, which mode the stylesheet should
|
|
417
|
+
* apply, and what the More menu contains. With the default `minVisible: 0` every item collapses
|
|
418
|
+
* and the menu lists them all, which is what it has always done.
|
|
419
|
+
* @returns {void}
|
|
420
|
+
*/
|
|
386
421
|
_syncOverflow() {
|
|
422
|
+
const below = resolveOverflowBelow(this.options);
|
|
423
|
+
const minVisible = below === null
|
|
424
|
+
? this._items.length
|
|
425
|
+
: resolveMinVisible(this.options, this._items.length);
|
|
426
|
+
const collapsing = this._items.slice(minVisible);
|
|
427
|
+
const root = /** @type {HTMLElement} */ (this.el);
|
|
428
|
+
|
|
429
|
+
for (const [index, record] of this._items.entries()) {
|
|
430
|
+
record.item.dataset.collapsed = String(index >= minVisible);
|
|
431
|
+
}
|
|
432
|
+
root.dataset.overflow = below === null ? 'off' : (minVisible === 0 ? 'all' : 'partial');
|
|
433
|
+
/*
|
|
434
|
+
* The attribute is the stylesheet's own switch: present means "a threshold I do not know", so
|
|
435
|
+
* the container query stands down and the observer below drives `data-narrow` instead.
|
|
436
|
+
*/
|
|
437
|
+
if (below === null || below === OVERFLOW_BELOW) delete root.dataset.overflowBelow;
|
|
438
|
+
else root.dataset.overflowBelow = below;
|
|
439
|
+
|
|
387
440
|
if (!this._overflow) return;
|
|
388
|
-
this.refs.more.dataset.empty =
|
|
389
|
-
this._overflow.setItems(
|
|
441
|
+
this.refs.more.dataset.empty = collapsing.length === 0 ? 'true' : 'false';
|
|
442
|
+
this._overflow.setItems(collapsing.map((record) => ({
|
|
390
443
|
label: record.definition.badge === null ? record.definition.title :
|
|
391
444
|
`${record.definition.title} (${record.definition.badge})`,
|
|
392
445
|
value: record.definition.name,
|
|
@@ -394,6 +447,53 @@ export class NavigationBar extends Component {
|
|
|
394
447
|
})));
|
|
395
448
|
}
|
|
396
449
|
|
|
450
|
+
/**
|
|
451
|
+
* Watches the bar's own inline size when `overflowBelow` names a threshold the stylesheet cannot
|
|
452
|
+
* express. A bar on the default threshold installs nothing: its container query has already
|
|
453
|
+
* decided before the first frame of script, which is why the default path stays exactly as it
|
|
454
|
+
* was.
|
|
455
|
+
* @returns {void}
|
|
456
|
+
*/
|
|
457
|
+
_observeWidth() {
|
|
458
|
+
const below = resolveOverflowBelow(this.options);
|
|
459
|
+
if (below === null || below === OVERFLOW_BELOW) return;
|
|
460
|
+
if (typeof ResizeObserver !== 'function') return;
|
|
461
|
+
const root = /** @type {HTMLElement} */ (this.el);
|
|
462
|
+
|
|
463
|
+
const measure = () => {
|
|
464
|
+
const width = root.getBoundingClientRect().width;
|
|
465
|
+
/*
|
|
466
|
+
* Zero is not a narrow bar, it is no measurement at all — what an element reports before it
|
|
467
|
+
* has been laid out. Recording it would latch the collapsed state and never notice the real
|
|
468
|
+
* width, which is the failure `src/core/breakpoint.js` documents.
|
|
469
|
+
*/
|
|
470
|
+
if (!width) return;
|
|
471
|
+
const rootElement = globalThis.document?.documentElement;
|
|
472
|
+
const rootFontSize = rootElement
|
|
473
|
+
? Number.parseFloat(globalThis.getComputedStyle(rootElement).fontSize)
|
|
474
|
+
: Number.NaN;
|
|
475
|
+
// `<=`, because the container query this replaces is a `max-width` and that is inclusive.
|
|
476
|
+
root.dataset.narrow = String(width <= lengthToPixels(below, rootFontSize));
|
|
477
|
+
};
|
|
478
|
+
|
|
479
|
+
this._observer = new ResizeObserver(() => {
|
|
480
|
+
/*
|
|
481
|
+
* Answered on the next frame rather than inside the callback: collapsing the bar resizes the
|
|
482
|
+
* very element being observed, which the browser reports as an undelivered-notification loop.
|
|
483
|
+
*/
|
|
484
|
+
if (this._frame || typeof requestAnimationFrame !== 'function') {
|
|
485
|
+
if (typeof requestAnimationFrame !== 'function') measure();
|
|
486
|
+
return;
|
|
487
|
+
}
|
|
488
|
+
this._frame = requestAnimationFrame(() => {
|
|
489
|
+
this._frame = 0;
|
|
490
|
+
measure();
|
|
491
|
+
});
|
|
492
|
+
});
|
|
493
|
+
this._observer.observe(root);
|
|
494
|
+
measure();
|
|
495
|
+
}
|
|
496
|
+
|
|
397
497
|
/** @param {NavigationRecord} record @returns {void} */
|
|
398
498
|
_focusTab(record) {
|
|
399
499
|
for (const item of this._items) item.item.tabIndex = item === record ? 0 : -1;
|
|
@@ -424,6 +524,73 @@ export class NavigationBar extends Component {
|
|
|
424
524
|
* @type {CustomEvent<{name: string}>}
|
|
425
525
|
*/
|
|
426
526
|
|
|
527
|
+
/**
|
|
528
|
+
* Resolves the container inline size at or below which items collapse into the overflow menu.
|
|
529
|
+
*
|
|
530
|
+
* Two options can switch collapsing off, because they answer two different questions: `overflow`
|
|
531
|
+
* is whether the bar has an overflow menu at all, and `overflowBelow` is where it starts using it.
|
|
532
|
+
* Either being `false` means the same thing to the stylesheet — nothing ever collapses — so both
|
|
533
|
+
* resolve to `null` here and the caller has one state to handle rather than two.
|
|
534
|
+
* @param {NavigationBarOptions} [options={}] Navigation options, whole or partial.
|
|
535
|
+
* @returns {string|null} A `px` or `rem` length, or `null` when nothing ever collapses.
|
|
536
|
+
*/
|
|
537
|
+
export function resolveOverflowBelow(options = {}) {
|
|
538
|
+
const enabled = options.overflow ?? true;
|
|
539
|
+
if (typeof enabled !== 'boolean') {
|
|
540
|
+
throw new TypeError('NavigationBar overflow must be a boolean');
|
|
541
|
+
}
|
|
542
|
+
const below = options.overflowBelow ?? OVERFLOW_BELOW;
|
|
543
|
+
if (!enabled || below === false) return null;
|
|
544
|
+
if (typeof below === 'number') {
|
|
545
|
+
if (!Number.isFinite(below) || below < 0) {
|
|
546
|
+
throw new RangeError(`NavigationBar overflowBelow must not be negative: ${below}`);
|
|
547
|
+
}
|
|
548
|
+
return `${below}px`;
|
|
549
|
+
}
|
|
550
|
+
if (typeof below !== 'string' || !OVERFLOW_LENGTH.test(below.trim())) {
|
|
551
|
+
throw new TypeError(
|
|
552
|
+
`NavigationBar overflowBelow must be a px or rem length, a number of pixels, or false: ${String(below)}`
|
|
553
|
+
);
|
|
554
|
+
}
|
|
555
|
+
return below.trim();
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
/**
|
|
559
|
+
* How many items, counted from the start, stay in the bar when it collapses.
|
|
560
|
+
*
|
|
561
|
+
* Clamping to the item count is the whole arithmetic: `minVisible: 4` on a three-item bar keeps
|
|
562
|
+
* three, so the overflow menu is empty rather than holding a phantom fourth entry, and the bar
|
|
563
|
+
* hides its More button instead of opening onto nothing.
|
|
564
|
+
* @param {NavigationBarOptions} [options={}] Navigation options, whole or partial.
|
|
565
|
+
* @param {number} [count=0] How many items the bar currently has.
|
|
566
|
+
* @returns {number} A count between 0 and `count`.
|
|
567
|
+
*/
|
|
568
|
+
export function resolveMinVisible(options = {}, count = 0) {
|
|
569
|
+
const requested = options.minVisible ?? 0;
|
|
570
|
+
if (!Number.isInteger(requested) || requested < 0) {
|
|
571
|
+
throw new TypeError(`NavigationBar minVisible must be a non-negative integer: ${String(requested)}`);
|
|
572
|
+
}
|
|
573
|
+
const total = Number.isInteger(count) && count > 0 ? count : 0;
|
|
574
|
+
return Math.min(requested, total);
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
/**
|
|
578
|
+
* Converts an overflow threshold to pixels, so a measured width can be compared with it.
|
|
579
|
+
* @param {string|number} length A `px` or `rem` length, or a number of pixels.
|
|
580
|
+
* @param {number} [rootFontSize=16] Computed root font size; anything unusable falls back to 16.
|
|
581
|
+
* @returns {number} The threshold in pixels.
|
|
582
|
+
*/
|
|
583
|
+
export function lengthToPixels(length, rootFontSize = 16) {
|
|
584
|
+
const value = typeof length === 'number' ? `${length}px` : String(length).trim();
|
|
585
|
+
if (!OVERFLOW_LENGTH.test(value)) {
|
|
586
|
+
throw new TypeError(`NavigationBar cannot measure the length "${String(length)}"`);
|
|
587
|
+
}
|
|
588
|
+
const size = Number.parseFloat(value);
|
|
589
|
+
if (!value.endsWith('rem')) return size;
|
|
590
|
+
const root = Number(rootFontSize);
|
|
591
|
+
return size * (Number.isFinite(root) && root > 0 ? root : 16);
|
|
592
|
+
}
|
|
593
|
+
|
|
427
594
|
/** @param {unknown} content @returns {content is NavigationPanelContent} */
|
|
428
595
|
function isPanelContent(content) {
|
|
429
596
|
if (typeof content === 'function' || content instanceof Component) return true;
|
|
@@ -103,7 +103,7 @@ export class Panel extends Component {
|
|
|
103
103
|
class: 'zx-panel__buttons',
|
|
104
104
|
ref: 'buttons',
|
|
105
105
|
role: 'group',
|
|
106
|
-
ariaLabel: 'Panel actions'
|
|
106
|
+
ariaLabel: this._message('panel.actions', 'Panel actions')
|
|
107
107
|
})
|
|
108
108
|
);
|
|
109
109
|
const content = h('div', {
|
|
@@ -125,7 +125,7 @@ export class Panel extends Component {
|
|
|
125
125
|
class: 'zx-panel__footer-buttons',
|
|
126
126
|
ref: 'footerButtons',
|
|
127
127
|
role: 'group',
|
|
128
|
-
ariaLabel: 'Panel footer actions'
|
|
128
|
+
ariaLabel: this._message('panel.footerActions', 'Panel footer actions')
|
|
129
129
|
}));
|
|
130
130
|
root.replaceChildren(header, body, footer);
|
|
131
131
|
|
|
@@ -286,6 +286,17 @@ export class Panel extends Component {
|
|
|
286
286
|
return element;
|
|
287
287
|
});
|
|
288
288
|
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Resolves a message through the host translator, falling back to the built-in English text.
|
|
292
|
+
* @param {string} key Message key.
|
|
293
|
+
* @param {string} fallback English default.
|
|
294
|
+
* @returns {string}
|
|
295
|
+
*/
|
|
296
|
+
_message(key, fallback) {
|
|
297
|
+
const message = this.msg(key);
|
|
298
|
+
return message === key ? fallback : message;
|
|
299
|
+
}
|
|
289
300
|
}
|
|
290
301
|
|
|
291
302
|
/**
|
|
@@ -39,27 +39,28 @@ export class Search extends Component {
|
|
|
39
39
|
this._original = created ? null : snapshot(root);
|
|
40
40
|
this._inputTimer = null;
|
|
41
41
|
root.setAttribute('role', 'search');
|
|
42
|
+
const clearLabel = this._message('search.clear', 'Clear search');
|
|
42
43
|
const input = h('input', {
|
|
43
44
|
class: 'zx-search__input',
|
|
44
45
|
ref: 'input',
|
|
45
46
|
type: 'search',
|
|
46
47
|
placeholder: String(this.options.placeholder),
|
|
47
48
|
value: String(this.options.value),
|
|
48
|
-
ariaLabel: String(this.options.placeholder || 'Search')
|
|
49
|
+
ariaLabel: String(this.options.placeholder || this._message('search.label', 'Search'))
|
|
49
50
|
});
|
|
50
51
|
const clear = this.options.clearable ? h('button', {
|
|
51
52
|
class: 'zx-search__clear',
|
|
52
53
|
ref: 'clear',
|
|
53
54
|
type: 'button',
|
|
54
|
-
ariaLabel:
|
|
55
|
-
title:
|
|
55
|
+
ariaLabel: clearLabel,
|
|
56
|
+
title: clearLabel
|
|
56
57
|
}, icon('x', { size: 15 })) : null;
|
|
57
58
|
const submit = h('button', {
|
|
58
59
|
class: 'zx-search__submit',
|
|
59
60
|
ref: 'submit',
|
|
60
61
|
type: root.localName === 'form' ? 'submit' : 'button',
|
|
61
|
-
ariaLabel: 'Submit search',
|
|
62
|
-
title: 'Search'
|
|
62
|
+
ariaLabel: this._message('search.submit', 'Submit search'),
|
|
63
|
+
title: this._message('search.submitTitle', 'Search')
|
|
63
64
|
}, icon('search', { size: 16 }));
|
|
64
65
|
root.replaceChildren(...[input, clear, submit].filter(Boolean));
|
|
65
66
|
this._syncClear();
|
|
@@ -157,6 +158,17 @@ export class Search extends Component {
|
|
|
157
158
|
/** @type {HTMLButtonElement} */ (this.refs.clear).hidden = this.get() === '';
|
|
158
159
|
}
|
|
159
160
|
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Resolves a message through the host translator, falling back to the built-in English text.
|
|
164
|
+
* @param {string} key Message key.
|
|
165
|
+
* @param {string} fallback English default.
|
|
166
|
+
* @returns {string}
|
|
167
|
+
*/
|
|
168
|
+
_message(key, fallback) {
|
|
169
|
+
const message = this.msg(key);
|
|
170
|
+
return message === key ? fallback : message;
|
|
171
|
+
}
|
|
160
172
|
}
|
|
161
173
|
|
|
162
174
|
/**
|
|
@@ -9,7 +9,10 @@ export function registerSelectFieldAdapter() {
|
|
|
9
9
|
Field.register('zxselect', (field, options) => {
|
|
10
10
|
const props = options.props ?? {};
|
|
11
11
|
const preset = props.preset;
|
|
12
|
-
const selectOptions = { ...props, value: options.value };
|
|
12
|
+
const selectOptions = { ...props, value: options.value, label: options.label || props.label, placeholder: props.placeholder ?? options.placeholder };
|
|
13
|
+
field.listen(field.el, 'click', (event) => {
|
|
14
|
+
if (event.target.closest?.('.zx-field__label')) field.adapter.focus();
|
|
15
|
+
});
|
|
13
16
|
delete selectOptions.preset;
|
|
14
17
|
const select = field.own(preset === 'priority'
|
|
15
18
|
? Select.priority(null, selectOptions)
|
|
@@ -27,6 +27,7 @@ import { matchItems } from './filter.js';
|
|
|
27
27
|
* @property {unknown} [value=null] Initially selected ID or item.
|
|
28
28
|
* @property {boolean} [disabled=false] Whether interaction is disabled.
|
|
29
29
|
* @property {string} [placeholder=''] Empty control text.
|
|
30
|
+
* @property {string} [label=''] Accessible combobox name; falls back to the placeholder.
|
|
30
31
|
* @property {boolean} [clearable=false] Whether selection may be cleared.
|
|
31
32
|
* @property {false|'local'|((query: string) => Promise<SelectItem[]>|SelectItem[])} [filter=false] Filtering mode.
|
|
32
33
|
* @property {Array<SelectValueReader>|null} [searchKeys=null] Fields searched by local filtering.
|
|
@@ -109,6 +110,7 @@ export class Select extends Component {
|
|
|
109
110
|
value: null,
|
|
110
111
|
disabled: false,
|
|
111
112
|
placeholder: '',
|
|
113
|
+
label: '',
|
|
112
114
|
clearable: false,
|
|
113
115
|
filter: false,
|
|
114
116
|
searchKeys: null,
|
|
@@ -136,6 +138,7 @@ export class Select extends Component {
|
|
|
136
138
|
class: 'zx-select__input',
|
|
137
139
|
type: 'text',
|
|
138
140
|
role: 'combobox',
|
|
141
|
+
ariaLabel: String(this.options.label || this.options.placeholder || 'Select'),
|
|
139
142
|
autocomplete: 'off',
|
|
140
143
|
spellcheck: false,
|
|
141
144
|
ariaExpanded: 'false',
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
.zx-sort-control {
|
|
2
|
+
display: inline-grid;
|
|
3
|
+
grid-template-columns: auto minmax(0, 1fr);
|
|
4
|
+
align-items: center;
|
|
5
|
+
gap: var(--zx-space-1);
|
|
6
|
+
min-inline-size: 0;
|
|
7
|
+
inline-size: 100%;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
.zx-sort-control__direction {
|
|
11
|
+
inline-size: var(--zx-control-height);
|
|
12
|
+
padding: 0;
|
|
13
|
+
border-radius: var(--zx-radius-full);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
.zx-sort-control__direction:focus-visible { box-shadow: var(--zx-focus-ring); }
|
|
17
|
+
.zx-sort-control .zx-select__control { background: var(--zx-color-bg-control); }
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { Component } from '../../core/component.js';
|
|
2
|
+
import { h, restoreTarget, snapshotTarget } from '../../core/dom.js';
|
|
3
|
+
import { icon } from '../../core/icons.js';
|
|
4
|
+
import { button } from '../button/button.js';
|
|
5
|
+
import { Select } from '../select/select.js';
|
|
6
|
+
|
|
7
|
+
/** @typedef {{id:string,label:string}} SortField */
|
|
8
|
+
/** @typedef {{id:string,dir:'asc'|'desc'}} SortValue */
|
|
9
|
+
/**
|
|
10
|
+
* @typedef {Object} SortControlOptions
|
|
11
|
+
* @property {SortField[]} [fields=[]] Available sort fields, in display order.
|
|
12
|
+
* @property {SortValue|null} [value=null] Initial sort; null chooses the first field ascending.
|
|
13
|
+
* @property {string} [label='Sort by'] Accessible field label and direction-name prefix.
|
|
14
|
+
* @property {{asc?:string,desc?:string}} [labels={}] Current/next direction labels, defaulting to Ascending/Descending.
|
|
15
|
+
* @property {boolean} [disabled=false] Whether interaction is disabled.
|
|
16
|
+
* @property {(event:CustomEvent<{value:SortValue}>)=>void} [onchange] Atomic field/direction change.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* A searchable field selector with an adjacent direction toggle and one shared sort value.
|
|
21
|
+
* @fires SortControl#change
|
|
22
|
+
* @extends {Component<SortControlOptions>}
|
|
23
|
+
*/
|
|
24
|
+
export class SortControl extends Component {
|
|
25
|
+
static cssName = 'sort-control';
|
|
26
|
+
/** @type {SortControlOptions} */
|
|
27
|
+
static defaults = { fields: [], value: null, label: 'Sort by', labels: {}, disabled: false };
|
|
28
|
+
|
|
29
|
+
/** @returns {HTMLElement} */
|
|
30
|
+
render() {
|
|
31
|
+
const root = /** @type {HTMLElement} */ (this.el ?? h('div'));
|
|
32
|
+
this._createdRoot = this.el === null;
|
|
33
|
+
this._snapshot = this._createdRoot ? null : snapshotTarget(root);
|
|
34
|
+
this.el = root;
|
|
35
|
+
this._value = normalizeSortValue(this.options.value, this.options.fields);
|
|
36
|
+
this._disabled = Boolean(this.options.disabled || !this.options.fields.length);
|
|
37
|
+
this._direction = button({ kind: 'ghost', disabled: this._disabled });
|
|
38
|
+
this._direction.classList.add('zx-sort-control__direction');
|
|
39
|
+
this.listen(this._direction, 'click', () => {
|
|
40
|
+
if (this._disabled || !this._value) return;
|
|
41
|
+
this.setValue({ id: this._value.id, dir: this._value.dir === 'asc' ? 'desc' : 'asc' });
|
|
42
|
+
});
|
|
43
|
+
this._select = new Select(null, {
|
|
44
|
+
items: sortChoices(this.options.fields), valueKey: 'id', labelKey: 'label',
|
|
45
|
+
value: this._value?.id ?? null, label: this.options.label,
|
|
46
|
+
filter: 'local', disabled: this._disabled,
|
|
47
|
+
onchange: ({ detail }) => {
|
|
48
|
+
if (!detail.item) return;
|
|
49
|
+
this.setValue({ id: detail.item.id, dir: this._value?.dir ?? 'asc' });
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
// The owned Select's DOM event must not duplicate the composite's atomic event.
|
|
53
|
+
this.listen(this._select.el, 'zx-change', (event) => event.stopPropagation());
|
|
54
|
+
root.replaceChildren(this._direction, this._select.el);
|
|
55
|
+
this._syncDirection();
|
|
56
|
+
return root;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Returns a defensive copy of the current sort. @returns {SortValue|null} */
|
|
60
|
+
getValue() { return this._value ? { ...this._value } : null; }
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Replaces the field and direction atomically; rejects unavailable fields and directions.
|
|
64
|
+
* @param {SortValue|null} value Sort state, or null for the first field ascending.
|
|
65
|
+
* @param {{silent?:boolean}} [options={}] Suppress change when silent.
|
|
66
|
+
* @returns {this}
|
|
67
|
+
*/
|
|
68
|
+
setValue(value, { silent = false } = {}) {
|
|
69
|
+
const next = normalizeSortValue(value, this.options.fields);
|
|
70
|
+
const changed = sortKey(next) !== sortKey(this._value);
|
|
71
|
+
this._value = next;
|
|
72
|
+
this._select.set(next?.id ?? null, { silent: true });
|
|
73
|
+
this._syncDirection();
|
|
74
|
+
if (changed && !silent) this.emit('change', { value: this.getValue() });
|
|
75
|
+
return this;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Enables both controls when fields are available. @returns {this} */
|
|
79
|
+
enable() {
|
|
80
|
+
this._disabled = !this.options.fields.length;
|
|
81
|
+
if (!this._disabled) this._select.enable();
|
|
82
|
+
this._direction.disabled = this._disabled;
|
|
83
|
+
return this;
|
|
84
|
+
}
|
|
85
|
+
/** Disables both controls and closes the option panel. @returns {this} */
|
|
86
|
+
disable() {
|
|
87
|
+
this._disabled = true;
|
|
88
|
+
this._select.disable();
|
|
89
|
+
this._direction.disabled = true;
|
|
90
|
+
return this;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Updates the icon and accessible current/next direction text. @private @returns {void} */
|
|
94
|
+
_syncDirection() {
|
|
95
|
+
const dir = this._value?.dir ?? 'asc';
|
|
96
|
+
const next = dir === 'asc' ? 'desc' : 'asc';
|
|
97
|
+
const labels = { asc: 'Ascending', desc: 'Descending', ...this.options.labels };
|
|
98
|
+
this._direction.replaceChildren(icon(dir === 'asc' ? 'arrow-up-wide-short' : 'arrow-down-wide-short'));
|
|
99
|
+
this._direction.dataset.direction = dir;
|
|
100
|
+
this._direction.setAttribute('aria-label', `${this.options.label}: ${labels[dir]}`);
|
|
101
|
+
this._direction.title = `${labels[dir]} → ${labels[next]}`;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Focuses the searchable control. @returns {this} */
|
|
105
|
+
focus() { this._select.focus(); return this; }
|
|
106
|
+
/** Releases the owned Select and restores an enhanced target. @returns {void} */
|
|
107
|
+
destroy() {
|
|
108
|
+
this._select?.destroy();
|
|
109
|
+
super.destroy();
|
|
110
|
+
if (!this._createdRoot) restoreTarget(this.el, this._snapshot);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Validates a sort against available fields without retaining caller-owned state.
|
|
116
|
+
* @param {SortValue|null} value Requested sort.
|
|
117
|
+
* @param {SortField[]} fields Available fields.
|
|
118
|
+
* @returns {SortValue|null} Normalized sort, or null for an empty catalogue.
|
|
119
|
+
*/
|
|
120
|
+
export function normalizeSortValue(value, fields) {
|
|
121
|
+
if (!fields.length) return null;
|
|
122
|
+
if (value == null) return { id: String(fields[0].id), dir: 'asc' };
|
|
123
|
+
if (!fields.some((field) => String(field.id) === String(value.id))) throw new RangeError('Unknown sort field');
|
|
124
|
+
if (value.dir !== 'asc' && value.dir !== 'desc') throw new RangeError('Unknown sort direction');
|
|
125
|
+
return { id: String(value.id), dir: value.dir };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Builds one searchable record per field without retaining caller-owned objects.
|
|
130
|
+
* @param {SortField[]} fields Available fields.
|
|
131
|
+
* @returns {SortField[]} Choice records.
|
|
132
|
+
*/
|
|
133
|
+
export function sortChoices(fields) {
|
|
134
|
+
return fields.map((field) => ({ id: String(field.id), label: field.label }));
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** @param {SortValue|null} value Sort state. @returns {string} Stable choice key. */
|
|
138
|
+
function sortKey(value) { return value ? JSON.stringify([value.id, value.dir]) : ''; }
|
|
139
|
+
|
|
140
|
+
/** @event SortControl#change @type {CustomEvent<{value:SortValue}>} */
|