@zeyos/zx 5.0.0 → 5.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.
Files changed (154) hide show
  1. package/CHANGELOG.md +164 -0
  2. package/README.md +2 -1
  3. package/dist/types/components/account-menu/account-menu.d.ts +8 -0
  4. package/dist/types/components/activity-list/activity-list.d.ts +9 -1
  5. package/dist/types/components/app-icon/app-icon.d.ts +14 -2
  6. package/dist/types/components/app-sidebar/app-sidebar.d.ts +288 -14
  7. package/dist/types/components/calendar/calendar.d.ts +67 -5
  8. package/dist/types/components/card-view/card-view.d.ts +7 -4
  9. package/dist/types/components/card-view/record-card.d.ts +11 -0
  10. package/dist/types/components/context-menu/context-menu.d.ts +6 -2
  11. package/dist/types/components/data-filter/data-filter.d.ts +4 -4
  12. package/dist/types/components/dropdown/dropdown.d.ts +22 -7
  13. package/dist/types/components/field/field.d.ts +30 -0
  14. package/dist/types/components/fieldset/external-adapter.d.ts +83 -0
  15. package/dist/types/components/fieldset/fieldset.d.ts +7 -0
  16. package/dist/types/components/file-list/file-list.d.ts +35 -3
  17. package/dist/types/components/filter/filter.d.ts +127 -3
  18. package/dist/types/components/filter-panel/filter-panel.d.ts +36 -7
  19. package/dist/types/components/launcher/launcher.d.ts +8 -0
  20. package/dist/types/components/list-toolbar/list-toolbar.d.ts +15 -6
  21. package/dist/types/components/menu-button/menu-button.d.ts +6 -0
  22. package/dist/types/components/message/message.d.ts +14 -1
  23. package/dist/types/components/modal/modal.d.ts +30 -0
  24. package/dist/types/components/module-bar/module-bar.d.ts +395 -0
  25. package/dist/types/components/pagination/pagination.d.ts +17 -3
  26. package/dist/types/components/select/select.d.ts +6 -0
  27. package/dist/types/components/sheet/sheet.d.ts +15 -0
  28. package/dist/types/components/table/table.d.ts +265 -17
  29. package/dist/types/components/view/record-view.d.ts +59 -2
  30. package/dist/types/components/view-settings/view-settings.d.ts +879 -0
  31. package/dist/types/components/workspace-switcher/workspace-switcher.d.ts +327 -0
  32. package/dist/types/core/component.d.ts +12 -2
  33. package/dist/types/core/i18n.d.ts +27 -3
  34. package/dist/types/core/keyboard.d.ts +98 -1
  35. package/dist/types/core/messages.d.ts +382 -0
  36. package/dist/types/core/overlay-host.d.ts +28 -1
  37. package/dist/types/core/overlay-stack.d.ts +260 -0
  38. package/dist/types/index.d.ts +5 -2
  39. package/dist/types/internal/app-rail.d.ts +38 -0
  40. package/dist/types/internal/menu-items.d.ts +71 -3
  41. package/dist/types/zeyos/index.d.ts +1 -0
  42. package/dist/types/zeyos/launcher.d.ts +9 -0
  43. package/dist/types/zeyos/module-bar.d.ts +176 -0
  44. package/dist/types/zeyos/query.d.ts +31 -5
  45. package/dist/types/zeyos/table.d.ts +245 -8
  46. package/dist/types/zeyos/view.d.ts +16 -2
  47. package/dist/zx-zeyos.esm.js +4422 -430
  48. package/dist/zx-zeyos.esm.min.js +5 -5
  49. package/dist/zx.css +1417 -134
  50. package/dist/zx.esm.js +6986 -830
  51. package/dist/zx.esm.min.js +8 -8
  52. package/dist/zx.global.js +6986 -830
  53. package/dist/zx.global.min.js +8 -8
  54. package/dist/zx.min.css +1 -1
  55. package/docs/api.json +794 -17
  56. package/docs/llms.md +503 -63
  57. package/docs/llms.txt +6 -2
  58. package/package.json +1 -1
  59. package/src/components/account-menu/account-menu.js +12 -0
  60. package/src/components/activity-list/activity-list.js +27 -1
  61. package/src/components/app-icon/app-icon.css +13 -2
  62. package/src/components/app-icon/app-icon.js +11 -1
  63. package/src/components/app-rail/app-rail.css +19 -0
  64. package/src/components/app-sidebar/app-sidebar.css +204 -3
  65. package/src/components/app-sidebar/app-sidebar.js +646 -40
  66. package/src/components/breadcrumb/breadcrumb.css +1 -1
  67. package/src/components/calendar/calendar.css +1 -1
  68. package/src/components/calendar/calendar.js +26 -52
  69. package/src/components/card/card.css +2 -2
  70. package/src/components/card-view/card-view.css +5 -5
  71. package/src/components/card-view/card-view.js +13 -9
  72. package/src/components/card-view/record-card.js +14 -3
  73. package/src/components/code-editor/code-editor.css +1 -1
  74. package/src/components/code-editor/field-adapter.js +9 -2
  75. package/src/components/context-menu/context-menu.js +8 -2
  76. package/src/components/copy/copy-input.css +1 -1
  77. package/src/components/data-filter/data-filter.js +2 -2
  78. package/src/components/date-picker/time-picker.css +2 -2
  79. package/src/components/dock/dock.css +5 -4
  80. package/src/components/dropdown/dropdown.js +157 -72
  81. package/src/components/field/field.js +33 -3
  82. package/src/components/fieldset/external-adapter.js +363 -0
  83. package/src/components/fieldset/fieldset.js +10 -59
  84. package/src/components/file-list/file-list.js +144 -21
  85. package/src/components/filter/filter.css +86 -2
  86. package/src/components/filter/filter.js +300 -8
  87. package/src/components/filter-panel/filter-panel.js +149 -32
  88. package/src/components/finder/finder.css +1 -1
  89. package/src/components/kanban-view/kanban-view.css +3 -3
  90. package/src/components/kanban-view/kanban-view.js +5 -2
  91. package/src/components/launcher/launcher.css +1 -1
  92. package/src/components/launcher/launcher.js +29 -3
  93. package/src/components/list-toolbar/list-toolbar.js +39 -13
  94. package/src/components/master-panel/master-panel.js +0 -1
  95. package/src/components/menu-button/menu-button.css +63 -0
  96. package/src/components/menu-button/menu-button.js +97 -11
  97. package/src/components/message/message.js +60 -20
  98. package/src/components/modal/modal.css +1 -1
  99. package/src/components/modal/modal.js +66 -13
  100. package/src/components/module-bar/module-bar.css +486 -0
  101. package/src/components/module-bar/module-bar.js +1074 -0
  102. package/src/components/multi-value-editor/multi-value-editor.css +1 -1
  103. package/src/components/number-field/field-adapter.js +2 -1
  104. package/src/components/pagination/pagination.css +5 -5
  105. package/src/components/pagination/pagination.js +20 -4
  106. package/src/components/panel/panel.css +3 -2
  107. package/src/components/questionnaire/questionnaire.css +4 -4
  108. package/src/components/rating/field-adapter.js +2 -1
  109. package/src/components/rating/rating.css +1 -1
  110. package/src/components/search/search.css +4 -1
  111. package/src/components/select/select.js +9 -0
  112. package/src/components/sheet/sheet.css +1 -1
  113. package/src/components/sheet/sheet.js +74 -32
  114. package/src/components/slider/field-adapter.js +6 -1
  115. package/src/components/slider/slider.css +2 -2
  116. package/src/components/split-view/split-view.css +1 -1
  117. package/src/components/stat-tile/stat-tile.css +1 -1
  118. package/src/components/stepper/stepper.css +1 -1
  119. package/src/components/tabbox/tabbox.css +9 -5
  120. package/src/components/table/table.css +86 -11
  121. package/src/components/table/table.js +628 -57
  122. package/src/components/tag-picker/field-adapter.js +2 -1
  123. package/src/components/tooltip/tooltip.js +35 -17
  124. package/src/components/tree/tree.css +1 -1
  125. package/src/components/view/record-view.css +1 -1
  126. package/src/components/view/record-view.js +178 -18
  127. package/src/components/view-settings/view-settings.css +285 -0
  128. package/src/components/view-settings/view-settings.js +1830 -0
  129. package/src/components/workspace-switcher/workspace-switcher.css +188 -0
  130. package/src/components/workspace-switcher/workspace-switcher.js +463 -0
  131. package/src/core/component.js +88 -17
  132. package/src/core/fontawesome.js +5 -1
  133. package/src/core/i18n.js +47 -4
  134. package/src/core/icons.js +12 -2
  135. package/src/core/keyboard.js +206 -11
  136. package/src/core/messages.js +427 -0
  137. package/src/core/overlay-host.js +64 -9
  138. package/src/core/overlay-stack.js +487 -0
  139. package/src/core/position.js +22 -3
  140. package/src/index.js +5 -2
  141. package/src/internal/app-rail.js +134 -53
  142. package/src/internal/menu-items.js +61 -3
  143. package/src/zeyos/form.js +5 -3
  144. package/src/zeyos/index.js +3 -0
  145. package/src/zeyos/launcher.js +8 -3
  146. package/src/zeyos/module-bar.js +181 -0
  147. package/src/zeyos/query.js +44 -13
  148. package/src/zeyos/table.js +220 -80
  149. package/src/zeyos/view.js +43 -93
  150. package/styles/base.css +16 -1
  151. package/styles/print.css +1 -1
  152. package/styles/tokens/dark.css +4 -0
  153. package/styles/tokens/semantic.css +56 -28
  154. package/styles/zx.css +3 -0
@@ -74,6 +74,11 @@ const FLOAT_STEP = 0.01;
74
74
  * applies every committed edit.
75
75
  * @property {FilterPanelSerialize} [serialize='seconds'] How a date leaves the component. A range's
76
76
  * lower bound takes the start of its day and its upper bound the end, so a one-day range matches.
77
+ * @property {string|null} [timeZone=null] IANA zone the calendar days are counted in, such as the
78
+ * ZeyOS instance's `Europe/Vienna`. A day serializes as the first (or last) instant of that day
79
+ * in this zone, and an incoming instant shows as the day it falls on there — the same rule
80
+ * `zeyosForm()` and `zeyosTable()` apply to date-only fields. Null uses the runtime's own zone.
81
+ * An unknown zone throws a `RangeError`.
77
82
  * @property {string|null} [emptyText=null] Text shown when `fields` is empty; null resolves
78
83
  * `filterPanel.empty`.
79
84
  * @property {Record<string, string>|Record<string, Record<string, string>>} [msg] Localized messages.
@@ -111,6 +116,7 @@ export class FilterPanel extends Component {
111
116
  value: {},
112
117
  mode: 'draft',
113
118
  serialize: 'seconds',
119
+ timeZone: null,
114
120
  emptyText: null
115
121
  };
116
122
 
@@ -130,9 +136,10 @@ export class FilterPanel extends Component {
130
136
  const root = /** @type {HTMLElement} */ (this.el ?? h('div'));
131
137
  this.el = root;
132
138
 
139
+ this._timeZone = normalizePanelTimeZone(this.options.timeZone);
133
140
  this._fields = orderFields(this.options.fields, this.options.order);
134
- this._draft = toDraft(this.options.value, this.options.fields);
135
- this._applied = serializeFilterValue(this._draft, this.options.fields, this.options.serialize);
141
+ this._draft = toDraft(this.options.value, this.options.fields, this._timeZone);
142
+ this._applied = serializeFilterValue(this._draft, this.options.fields, this.options.serialize, this._timeZone);
136
143
  this._appliedDraft = draftKey(this._draft);
137
144
 
138
145
  const children = [];
@@ -187,7 +194,7 @@ export class FilterPanel extends Component {
187
194
  * @fires FilterPanel#change
188
195
  */
189
196
  setValue(value) {
190
- this._draft = toDraft(value, this.options.fields);
197
+ this._draft = toDraft(value, this.options.fields, this._timeZone);
191
198
  this._commit(this._draftValue());
192
199
  this._writeControls();
193
200
  this._syncActions();
@@ -219,7 +226,7 @@ export class FilterPanel extends Component {
219
226
  * @fires FilterPanel#apply
220
227
  */
221
228
  reset() {
222
- return this._replaceDraft(toDraft(this.options.value, this.options.fields));
229
+ return this._replaceDraft(toDraft(this.options.value, this.options.fields, this._timeZone));
223
230
  }
224
231
 
225
232
  /**
@@ -233,7 +240,7 @@ export class FilterPanel extends Component {
233
240
  * @fires FilterPanel#apply
234
241
  */
235
242
  clear() {
236
- return this._replaceDraft(toDraft({}, this.options.fields), { commit: true });
243
+ return this._replaceDraft(toDraft({}, this.options.fields, this._timeZone), { commit: true });
237
244
  }
238
245
 
239
246
  /** Destroys the composed controls and puts an enhanced target back as found. @returns {void} */
@@ -584,7 +591,7 @@ export class FilterPanel extends Component {
584
591
 
585
592
  /** @returns {Record<string, unknown>} The draft, serialized and pruned. */
586
593
  _draftValue() {
587
- return serializeFilterValue(this._draft, this.options.fields, this.options.serialize);
594
+ return serializeFilterValue(this._draft, this.options.fields, this.options.serialize, this._timeZone);
588
595
  }
589
596
 
590
597
  /** Pushes the draft back into the controls without re-entering the edit path. @returns {void} */
@@ -703,42 +710,48 @@ export function pruneValue(value) {
703
710
 
704
711
  /**
705
712
  * Converts anything a calendar day can arrive as — a `YYYY-MM-DD` string, an ISO timestamp, a
706
- * `Date`, Unix seconds or milliseconds — into the local calendar day a native date input holds.
713
+ * `Date`, Unix seconds or milliseconds — into the calendar day a native date input holds.
707
714
  * Numbers below 1e11 are read as seconds, which is the boundary between the two epochs in use.
715
+ * An instant — Unix seconds or milliseconds, an ISO timestamp — names the day it falls on in
716
+ * `timeZone`. A `Date` is read as a picked day and keeps its local calendar day, matching
717
+ * `calendarDayToUnixSeconds()` in the ZeyOS binding.
708
718
  * @param {unknown} value Candidate day.
719
+ * @param {string|null} [timeZone=null] IANA zone instants are read in; null for the runtime's own.
709
720
  * @returns {string} `YYYY-MM-DD`, or an empty string when there is no day in it.
710
721
  */
711
- export function toDateInput(value) {
722
+ export function toDateInput(value, timeZone = null) {
712
723
  if (value === null || value === undefined || value === '') return '';
713
- if (value instanceof Date) return Number.isNaN(value.getTime()) ? '' : localDay(value);
724
+ // A Date is a picked day — what Datebox and DatePicker hold — so its local day counts, as in
725
+ // `calendarDayToUnixSeconds()`; numbers and timestamps are instants, read in the zone.
726
+ if (value instanceof Date) return Number.isNaN(value.getTime()) ? '' : zoneDay(value.getTime(), null);
714
727
  if (typeof value === 'number') {
715
728
  if (!Number.isFinite(value)) return '';
716
- return localDay(new Date(Math.abs(value) < SECONDS_CUTOFF ? value * 1000 : value));
729
+ return zoneDay(Math.abs(value) < SECONDS_CUTOFF ? value * 1000 : value, timeZone);
717
730
  }
718
731
  const text = String(value).trim();
719
732
  if (/^\d{4}-\d{2}-\d{2}$/.test(text)) return text;
720
- if (/^-?\d+(\.\d+)?$/.test(text)) return toDateInput(Number(text));
733
+ if (/^-?\d+(\.\d+)?$/.test(text)) return toDateInput(Number(text), timeZone);
721
734
  const parsed = new Date(text);
722
- return Number.isNaN(parsed.getTime()) ? '' : localDay(parsed);
735
+ return Number.isNaN(parsed.getTime()) ? '' : zoneDay(parsed.getTime(), timeZone);
723
736
  }
724
737
 
725
738
  /**
726
739
  * Serializes one calendar day at one edge of a range. The `from` edge takes the first instant of
727
- * its local day and the `to` edge the last, so a range whose ends name the same day still matches
728
- * everything recorded on it.
740
+ * its day and the `to` edge the last, so a range whose ends name the same day still matches
741
+ * everything recorded on it. The day is counted in `timeZone`: with `Europe/Vienna`, 2026-03-10
742
+ * starts at 23:00 UTC on the 9th whatever zone the browser is in.
729
743
  * @param {unknown} day Calendar day in any form `toDateInput()` accepts.
730
744
  * @param {FilterPanelEdge} edge Which end of the range the day is.
731
745
  * @param {FilterPanelSerialize} [serialize='seconds'] Output format, or a function receiving the
732
746
  * edge instant as a `Date`.
747
+ * @param {string|null} [timeZone=null] IANA zone the day is counted in; null for the runtime's own.
733
748
  * @returns {unknown} The serialized day, or null when there is no day.
734
749
  */
735
- export function serializeDate(day, edge, serialize = 'seconds') {
736
- const text = toDateInput(day);
750
+ export function serializeDate(day, edge, serialize = 'seconds', timeZone = null) {
751
+ const text = toDateInput(day, timeZone);
737
752
  if (!text) return null;
738
753
  const [year, month, date] = text.split('-').map(Number);
739
- const instant = edge === 'to'
740
- ? new Date(year, month - 1, date, 23, 59, 59, 999)
741
- : new Date(year, month - 1, date, 0, 0, 0, 0);
754
+ const instant = new Date(dayEdge(year, month - 1, date, edge, timeZone));
742
755
  if (typeof serialize === 'function') return serialize(instant, edge);
743
756
  if (serialize === 'ms') return instant.getTime();
744
757
  if (serialize === 'iso') return instant.toISOString();
@@ -775,9 +788,10 @@ export function normalizeOptions(options) {
775
788
  * Running it on a draft returns the same draft, so it is safe on either shape.
776
789
  * @param {Record<string, unknown>|null|undefined} value Incoming values keyed by field.
777
790
  * @param {Record<string, FilterPanelField>} fields Field metadata.
791
+ * @param {string|null} [timeZone=null] IANA zone incoming instants are read in.
778
792
  * @returns {Record<string, unknown>}
779
793
  */
780
- export function toDraft(value, fields) {
794
+ export function toDraft(value, fields, timeZone = null) {
781
795
  const source = isPlainObject(value) ? value : {};
782
796
  /** @type {Record<string, unknown>} */
783
797
  const draft = {};
@@ -792,8 +806,8 @@ export function toDraft(value, fields) {
792
806
  draft[key] = selectionOf(raw, normalizeOptions(field.options));
793
807
  } else if (spec.kind === 'date') {
794
808
  draft[key] = spec.range
795
- ? { from: toDateInput(readEdge(raw, 'from')), to: toDateInput(readEdge(raw, 'to')) }
796
- : toDateInput(raw);
809
+ ? { from: toDateInput(readEdge(raw, 'from'), timeZone), to: toDateInput(readEdge(raw, 'to'), timeZone) }
810
+ : toDateInput(raw, timeZone);
797
811
  } else {
798
812
  draft[key] = spec.range
799
813
  ? { from: toNumberValue(readEdge(raw, 'from'), spec), to: toNumberValue(readEdge(raw, 'to'), spec) }
@@ -809,10 +823,11 @@ export function toDraft(value, fields) {
809
823
  * @param {Record<string, unknown>|null|undefined} value Values keyed by field, raw or drafted.
810
824
  * @param {Record<string, FilterPanelField>} fields Field metadata.
811
825
  * @param {FilterPanelSerialize} [serialize='seconds'] Date output format.
826
+ * @param {string|null} [timeZone=null] IANA zone calendar days are counted in.
812
827
  * @returns {Record<string, unknown>}
813
828
  */
814
- export function serializeFilterValue(value, fields, serialize = 'seconds') {
815
- const draft = toDraft(value, fields);
829
+ export function serializeFilterValue(value, fields, serialize = 'seconds', timeZone = null) {
830
+ const draft = toDraft(value, fields, timeZone);
816
831
  /** @type {Record<string, unknown>} */
817
832
  const result = {};
818
833
  for (const [key, declared] of Object.entries(fields ?? {})) {
@@ -832,14 +847,14 @@ export function serializeFilterValue(value, fields, serialize = 'seconds') {
832
847
  if (spec.kind === 'date') {
833
848
  if (!spec.range) {
834
849
  // A single date names a day, so it serializes as that day's first instant.
835
- if (raw) result[key] = serializeDate(raw, 'from', serialize);
850
+ if (raw) result[key] = serializeDate(raw, 'from', serialize, timeZone);
836
851
  continue;
837
852
  }
838
853
  const days = /** @type {{from: string, to: string}} */ (raw);
839
854
  /** @type {Record<string, unknown>} */
840
855
  const range = {};
841
- if (days.from) range.from = serializeDate(days.from, 'from', serialize);
842
- if (days.to) range.to = serializeDate(days.to, 'to', serialize);
856
+ if (days.from) range.from = serializeDate(days.from, 'from', serialize, timeZone);
857
+ if (days.to) range.to = serializeDate(days.to, 'to', serialize, timeZone);
843
858
  if (Object.keys(range).length > 0) result[key] = range;
844
859
  continue;
845
860
  }
@@ -917,11 +932,113 @@ function toNumberValue(raw, spec) {
917
932
  return spec.integer ? Math.trunc(parsed) : parsed;
918
933
  }
919
934
 
920
- /** @param {Date} date Instant. @returns {string} Local `YYYY-MM-DD`. */
921
- function localDay(date) {
922
- const month = String(date.getMonth() + 1).padStart(2, '0');
923
- const day = String(date.getDate()).padStart(2, '0');
924
- return `${date.getFullYear()}-${month}-${day}`;
935
+ /**
936
+ * Validates the `timeZone` option.
937
+ * @param {unknown} timeZone IANA zone name; nullish or empty means the runtime's own zone.
938
+ * @returns {string|null} Canonical zone name, or null for the runtime's own zone.
939
+ * @throws {RangeError} When the runtime does not know the zone.
940
+ */
941
+ export function normalizePanelTimeZone(timeZone) {
942
+ if (timeZone === null || timeZone === undefined || timeZone === '') return null;
943
+ try {
944
+ return new Intl.DateTimeFormat('en-US', { timeZone: String(timeZone) }).resolvedOptions().timeZone;
945
+ } catch {
946
+ throw new RangeError(`Unknown time zone "${String(timeZone)}"`);
947
+ }
948
+ }
949
+
950
+ /**
951
+ * The calendar day an instant falls on, as `YYYY-MM-DD`, in a zone or the runtime's own.
952
+ * @param {number} milliseconds Epoch milliseconds.
953
+ * @param {string|null} timeZone IANA zone, or null.
954
+ * @returns {string}
955
+ */
956
+ function zoneDay(milliseconds, timeZone) {
957
+ if (!timeZone) {
958
+ const date = new Date(milliseconds);
959
+ return isoDay(date.getFullYear(), date.getMonth(), date.getDate());
960
+ }
961
+ const wall = wallClock(milliseconds, timeZone);
962
+ return isoDay(wall.year, wall.month, wall.day);
963
+ }
964
+
965
+ /**
966
+ * Epoch milliseconds of the first (`from`) or last (`to`) instant of a calendar day in a zone.
967
+ * The last instant is one millisecond before the next day starts, which keeps a day that a
968
+ * daylight-saving change shortens or lengthens whole.
969
+ * @param {number} year Full year.
970
+ * @param {number} month Zero-based month.
971
+ * @param {number} day Day of the month.
972
+ * @param {FilterPanelEdge} edge Edge of the day.
973
+ * @param {string|null} timeZone IANA zone, or null for the runtime's own.
974
+ * @returns {number}
975
+ */
976
+ function dayEdge(year, month, day, edge, timeZone) {
977
+ if (!timeZone) {
978
+ return (edge === 'to'
979
+ ? new Date(year, month, day, 23, 59, 59, 999)
980
+ : new Date(year, month, day, 0, 0, 0, 0)).getTime();
981
+ }
982
+ if (edge !== 'to') return zoneMidnight(year, month, day, timeZone);
983
+ const next = new Date(Date.UTC(year, month, day + 1));
984
+ return zoneMidnight(next.getUTCFullYear(), next.getUTCMonth(), next.getUTCDate(), timeZone) - 1;
985
+ }
986
+
987
+ /**
988
+ * The first instant of a calendar day in a zone: its midnight, or the first minute after a
989
+ * daylight-saving gap that swallows midnight. Two offset corrections settle it on either side of
990
+ * a change. This is the rule `zeyos/query.js` `calendarZone().midnightOf()` follows, so a day the
991
+ * panel serializes is the day a ZeyOS form stores.
992
+ * @param {number} year Full year.
993
+ * @param {number} month Zero-based month.
994
+ * @param {number} day Day of the month.
995
+ * @param {string} timeZone IANA zone.
996
+ * @returns {number} Epoch milliseconds.
997
+ */
998
+ function zoneMidnight(year, month, day, timeZone) {
999
+ const target = Date.UTC(year, month, day);
1000
+ const offsetAt = (milliseconds) => {
1001
+ const wall = wallClock(milliseconds, timeZone);
1002
+ return Date.UTC(wall.year, wall.month, wall.day) + wall.seconds * 1000
1003
+ - Math.floor(milliseconds / 1000) * 1000;
1004
+ };
1005
+ const first = target - offsetAt(target);
1006
+ const second = target - offsetAt(first);
1007
+ let best = null;
1008
+ for (const candidate of [second, first]) {
1009
+ const wall = wallClock(candidate, timeZone);
1010
+ if (wall.year !== year || wall.month !== month || wall.day !== day) continue;
1011
+ if (!best || wall.seconds < best.seconds) best = { candidate, seconds: wall.seconds };
1012
+ }
1013
+ return best ? best.candidate : second;
1014
+ }
1015
+
1016
+ /**
1017
+ * The wall clock of an instant in a zone.
1018
+ * @param {number} milliseconds Epoch milliseconds.
1019
+ * @param {string} timeZone IANA zone.
1020
+ * @returns {{year: number, month: number, day: number, seconds: number}} Zero-based month and
1021
+ * seconds since the zone's midnight.
1022
+ */
1023
+ function wallClock(milliseconds, timeZone) {
1024
+ const formatter = new Intl.DateTimeFormat('en-US', {
1025
+ timeZone, hourCycle: 'h23', year: 'numeric', month: 'numeric', day: 'numeric',
1026
+ hour: 'numeric', minute: 'numeric', second: 'numeric'
1027
+ });
1028
+ /** @type {Record<string, number>} */
1029
+ const parts = {};
1030
+ for (const part of formatter.formatToParts(new Date(milliseconds))) parts[part.type] = Number(part.value);
1031
+ return {
1032
+ year: parts.year,
1033
+ month: parts.month - 1,
1034
+ day: parts.day,
1035
+ seconds: (parts.hour % 24) * 3600 + parts.minute * 60 + parts.second
1036
+ };
1037
+ }
1038
+
1039
+ /** @param {number} year @param {number} month Zero-based. @param {number} day @returns {string} `YYYY-MM-DD`. */
1040
+ function isoDay(year, month, day) {
1041
+ return `${String(year).padStart(4, '0')}-${String(month + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
925
1042
  }
926
1043
 
927
1044
  /**
@@ -54,7 +54,7 @@
54
54
  }
55
55
 
56
56
  .zx-finder__row:focus-visible {
57
- outline: 0;
57
+ outline-color: transparent;
58
58
  box-shadow: var(--zx-focus-ring);
59
59
  }
60
60
 
@@ -134,7 +134,7 @@
134
134
  .zx-kanban-view__collapse:focus-visible,
135
135
  .zx-kanban-view__move-handle:focus-visible,
136
136
  .zx-kanban-view .zx-record-card:focus-visible {
137
- outline: none;
137
+ outline-color: transparent;
138
138
  box-shadow: var(--zx-focus-ring);
139
139
  }
140
140
 
@@ -188,7 +188,7 @@
188
188
  }
189
189
 
190
190
  .zx-kanban-view :where(.zx-record-card[data-variant="outlined"]) {
191
- box-shadow: var(--zx-shadow-1);
191
+ box-shadow: var(--zx-surface-shadow);
192
192
  }
193
193
 
194
194
  /* The shared card anatomy stays intact; Kanban constrains media to a compact thumbnail. */
@@ -563,7 +563,7 @@
563
563
  }
564
564
 
565
565
  .zx-kanban-view__add:focus-visible {
566
- outline: none;
566
+ outline-color: transparent;
567
567
  box-shadow: var(--zx-focus-ring);
568
568
  }
569
569
 
@@ -434,10 +434,11 @@ export class KanbanView extends RecordView {
434
434
 
435
435
  this._toolbar = h('div', { class: 'zx-record-view__toolbar zx-kanban-view__toolbar' });
436
436
  // A label the host passed in the object form wins; otherwise Kanban's own
437
- // message bag supplies it, which is where this label has always come from.
437
+ // message bag supplies it, which is where this label has always come from. Mounted through
438
+ // the shared path so `fieldControls.target` places it in a host element, as in every view.
438
439
  if (options.fieldControls) {
439
440
  const configured = normalizeViewFieldControls(options.fieldControls).label;
440
- this._toolbar.append(this._createViewFieldControls(configured ?? this._text('fields')));
441
+ this._mountViewFieldControls(this._toolbar, configured ?? this._text('fields'));
441
442
  }
442
443
  if (options.searchControl) this._toolbar.append(this._createSearchControl());
443
444
  if (options.historyControls) this._toolbar.append(this._createHistoryControls());
@@ -468,6 +469,8 @@ export class KanbanView extends RecordView {
468
469
  if (options.contextMenu) this._createContextMenu();
469
470
  return root;
470
471
  } catch (error) {
472
+ // An external `fieldControls.target` must not keep a chooser whose view never came to be.
473
+ this._removeViewFieldControls();
471
474
  if (this._kanbanSnapshot) restoreTarget(root, this._kanbanSnapshot);
472
475
  throw error;
473
476
  }
@@ -85,7 +85,7 @@
85
85
  flex: 1 1 0;
86
86
  min-inline-size: 0;
87
87
  border: 0;
88
- outline: 0;
88
+ outline-color: transparent;
89
89
  background: transparent;
90
90
  color: inherit;
91
91
  font-size: var(--zx-text-lg);
@@ -2,7 +2,8 @@ import { Component } from '../../core/component.js';
2
2
  import { h, safeHref } from '../../core/dom.js';
3
3
  import { icon } from '../../core/icons.js';
4
4
  import { printf } from '../../core/i18n.js';
5
- import { connectedOverlayHost, overlayHost } from '../../core/overlay-host.js';
5
+ import { connectedOverlayHost, overlayScope } from '../../core/overlay-host.js';
6
+ import { pushOverlay } from '../../core/overlay-stack.js';
6
7
  import { uid } from '../../core/util.js';
7
8
 
8
9
  /**
@@ -158,6 +159,8 @@ export class Launcher extends Component {
158
159
  throw new TypeError('Launcher target must be a <dialog> element');
159
160
  }
160
161
  this._createdRoot = this.el === null;
162
+ /** @type {import('../../core/overlay-stack.js').OverlayHandle|null} Entry on the overlay stack while open. */
163
+ this._overlay = null;
161
164
  const dialog = /** @type {HTMLDialogElement} */ (this.el ?? h('dialog'));
162
165
  this.el = dialog;
163
166
  this._original = this._createdRoot ? null : {
@@ -266,7 +269,9 @@ export class Launcher extends Component {
266
269
  dialog.dataset.state = 'closed';
267
270
  // A scope that is not in the document yet cannot say which themed region owns the dialog; it
268
271
  // stays detached until open() hosts it.
269
- if (this._createdRoot) connectedOverlayHost(this.options.scope)?.append(dialog);
272
+ // The theme scope, never an open dialog: a modal opened from inside another stacks above it in
273
+ // the top layer wherever it lives, and nested it would vanish if the outer one closed first.
274
+ if (this._createdRoot) connectedOverlayHost(this.options.scope, { dialog: false })?.append(dialog);
270
275
 
271
276
  this.listen(input, 'input', () => {
272
277
  this._query = /** @type {HTMLInputElement} */ (input).value;
@@ -299,6 +304,7 @@ export class Launcher extends Component {
299
304
  this.close();
300
305
  });
301
306
  this.listen(dialog, 'close', () => {
307
+ if (!dialog.open) this._releaseOverlay();
302
308
  dialog.dataset.state = 'closed';
303
309
  input.setAttribute('aria-expanded', 'false');
304
310
  this._abortRequest();
@@ -336,11 +342,18 @@ export class Launcher extends Component {
336
342
  return this;
337
343
  }
338
344
  if (this._createdRoot) {
339
- const host = overlayHost(this.options.scope);
345
+ const host = overlayScope(this.options.scope);
340
346
  if (this.el.parentElement !== host) host.append(this.el);
341
347
  }
342
348
  const before = this._pages.map((entry) => entry.page);
349
+ const opener = document.activeElement;
343
350
  this.el.showModal();
351
+ /*
352
+ * A modal on the overlay stack, like every Modal: Escape and presses inside it then reach no
353
+ * popup left open behind it, and toast regions move into it while it is open.
354
+ */
355
+ this._releaseOverlay();
356
+ this._overlay = pushOverlay({ kind: 'modal', root: this.el, owner: this, anchor: opener });
344
357
  this.el.dataset.state = 'open';
345
358
  this.refs.input.setAttribute('aria-expanded', 'true');
346
359
  this._goTo(target == null ? [] : this._pagePath(target), { emit: false });
@@ -443,6 +456,17 @@ export class Launcher extends Component {
443
456
  return this;
444
457
  }
445
458
 
459
+ /**
460
+ * Takes the launcher off the overlay stack. Safe to call repeatedly.
461
+ * @returns {void}
462
+ * @private
463
+ */
464
+ _releaseOverlay() {
465
+ const overlay = this._overlay;
466
+ this._overlay = null;
467
+ overlay?.release();
468
+ }
469
+
446
470
  /** Aborts sources and removes the owned dialog. @returns {void} */
447
471
  destroy() {
448
472
  if (this._destroyed) return;
@@ -450,6 +474,8 @@ export class Launcher extends Component {
450
474
  const original = this._original;
451
475
  this._abortRequest();
452
476
  if (this.isOpen()) this.el.close();
477
+ // The queued close that would release it never arrives once listeners are aborted.
478
+ this._releaseOverlay();
453
479
  super.destroy();
454
480
  if (!this._createdRoot && original) {
455
481
  for (const attribute of Array.from(this.el.attributes)) this.el.removeAttribute(attribute.name);
@@ -7,8 +7,10 @@ import { badge } from '../badge/badge.js';
7
7
  import { Search } from '../search/search.js';
8
8
 
9
9
  /**
10
- * Built-in view shapes, so `views: ['auto', 'cards', 'table']` needs no labels of its own. A host
11
- * that wants other words hands descriptors instead.
10
+ * Built-in view shapes, so `views: ['table', 'list', 'cards', 'gallery', 'board']` needs no labels
11
+ * of its own. A host that wants other words hands descriptors instead. Each shape has the glyph
12
+ * the overview tools people already use give it: a ruled grid for the table, lines for the list,
13
+ * tiles for cards, a picture for the gallery, and columns for the board.
12
14
  *
13
15
  * `auto` deliberately carries no icon: no glyph in the set says "whatever fits", and an icon that
14
16
  * has to be explained is worse than the word.
@@ -16,9 +18,11 @@ import { Search } from '../search/search.js';
16
18
  */
17
19
  const VIEW_PRESETS = Object.freeze({
18
20
  auto: { icon: null, key: 'listToolbar.viewAuto', fallback: 'Auto' },
19
- cards: { icon: 'copy', key: 'listToolbar.viewCards', fallback: 'Cards' },
20
- table: { icon: 'list', key: 'listToolbar.viewTable', fallback: 'Table' },
21
- list: { icon: 'list', key: 'listToolbar.viewList', fallback: 'List' }
21
+ table: { icon: 'table', key: 'listToolbar.viewTable', fallback: 'Table' },
22
+ list: { icon: 'list', key: 'listToolbar.viewList', fallback: 'List' },
23
+ cards: { icon: 'grid', key: 'listToolbar.viewCards', fallback: 'Cards' },
24
+ gallery: { icon: 'image', key: 'listToolbar.viewGallery', fallback: 'Gallery' },
25
+ board: { icon: 'columns', key: 'listToolbar.viewBoard', fallback: 'Board' }
22
26
  });
23
27
 
24
28
  /**
@@ -378,8 +382,10 @@ export class ListToolbar extends Component {
378
382
  }
379
383
 
380
384
  /**
381
- * Optional sugar for a `TableView` or `CardView`: it wires the shape switch to the view and
382
- * mounts the view's field chooser into this bar.
385
+ * Optional sugar for a `TableView`, `CardView`, or `KanbanView`: it wires the shape switch to
386
+ * the view and mounts the view's field chooser into this bar — through the view's public
387
+ * `mountFieldControls()` when it has one, which every `RecordView` does. A `TableView` with the
388
+ * default `fieldControls: true` contributes its Table's column chooser.
383
389
  *
384
390
  * Nothing is imported and nothing is required — every capability is feature-detected, so a view
385
391
  * that offers none of them still works and still gets `viewchange`, and the host swaps the view
@@ -409,10 +415,19 @@ export class ListToolbar extends Component {
409
415
  * @returns {this}
410
416
  */
411
417
  unbind() {
418
+ const bound = this._bound;
412
419
  this._bound = null;
413
420
  if (this._chooser) {
414
- restorePosition(this._chooser, this.el);
421
+ const chooser = this._chooser;
415
422
  this._chooser = null;
423
+ const view = bound?.view;
424
+ // The view put it here and knows where it came from; hand it back only while it is still
425
+ // ours, since a destroyed view has already taken it away.
426
+ if (chooser.viaView && view && typeof view.mountFieldControls === 'function') {
427
+ if (this.el.contains(chooser.element)) view.mountFieldControls(null);
428
+ } else {
429
+ restorePosition(chooser, this.el);
430
+ }
416
431
  }
417
432
  return this;
418
433
  }
@@ -597,21 +612,28 @@ export class ListToolbar extends Component {
597
612
  if (view.options?.fieldControls === false) return;
598
613
  const element = this._findFieldControls(view);
599
614
  if (!element || this.el.contains(element)) return;
615
+ if (typeof view.mountFieldControls === 'function') {
616
+ const mounted = view.mountFieldControls(this.refs.tools);
617
+ if (isElement(mounted) && this.el.contains(mounted)) {
618
+ this._chooser = { element: mounted, parent: null, next: null, viaView: true };
619
+ }
620
+ return;
621
+ }
600
622
  this._chooser = capturePosition(element);
601
623
  this.refs.tools.append(element);
602
624
  }
603
625
 
604
626
  /**
605
- * Finds a view's field chooser without importing the view, newest accessor first.
627
+ * Finds a view's field chooser without importing the view: its public `getFieldControls()`,
628
+ * else — for an object that only looks like a record view — the shared chooser's class.
606
629
  * @param {Record<string, any>} view Bound view.
607
630
  * @returns {Element|null}
608
631
  */
609
632
  _findFieldControls(view) {
610
633
  if (typeof view.getFieldControls === 'function') {
611
634
  const element = view.getFieldControls();
612
- if (isElement(element)) return element;
635
+ return isElement(element) ? element : null;
613
636
  }
614
- if (isElement(view._viewFieldControls)) return view._viewFieldControls;
615
637
  const root = typeof view.toElement === 'function' ? view.toElement() : null;
616
638
  return isElement(root) ? root.querySelector('.zx-record-view__field-controls') : null;
617
639
  }
@@ -661,7 +683,10 @@ export class ListToolbar extends Component {
661
683
  * @property {((event: MouseEvent) => void)|null} onclick Descriptor callback.
662
684
  */
663
685
 
664
- /** @typedef {{element: Element, parent: Node|null, next: Node|null}} AdoptedElement */
686
+ /**
687
+ * @typedef {{element: Element, parent: Node|null, next: Node|null, viaView?: boolean}} AdoptedElement
688
+ * `viaView` marks a field chooser the bound view moved here itself and takes back on `unbind()`.
689
+ */
665
690
 
666
691
  /**
667
692
  * @typedef {Object} RecordViewCapabilities
@@ -669,7 +694,8 @@ export class ListToolbar extends Component {
669
694
  * @property {string|null} setShape Method that changes the view shape, if any.
670
695
  * @property {string|null} getShape Method that reads the view shape, if any.
671
696
  * @property {boolean} events Whether the object emits component events.
672
- * @property {boolean} fieldControls Whether the object offers its field chooser through a method.
697
+ * @property {boolean} fieldControls Whether the object offers its field chooser through a method
698
+ * (`getFieldControls()`, which every `RecordView` has).
673
699
  */
674
700
 
675
701
  /**
@@ -89,7 +89,6 @@ export class MasterPanel extends Component {
89
89
 
90
90
  const moduleName = normalizeModule(this.options.module);
91
91
  root.dataset.module = moduleName;
92
- root.style.setProperty('--zx-master-panel-accent', `var(--zx-module-${moduleName})`);
93
92
  this.setTitle(this.options.title);
94
93
  if (this.options.content !== null) this.setContent(this.options.content);
95
94
  else content.append(...originalContent);
@@ -129,3 +129,66 @@
129
129
  margin: var(--zx-space-1) var(--zx-space-2);
130
130
  background: var(--zx-color-overlay-divider);
131
131
  }
132
+
133
+ /*
134
+ * A choice: a setting chosen in place — its label, then a small segmented control of options, the
135
+ * checked one lifted out of the track. Neutral, not the accent: it is a state, not an action.
136
+ */
137
+ .zx-menu-button__choice {
138
+ display: flex;
139
+ align-items: center;
140
+ justify-content: space-between;
141
+ gap: var(--zx-space-3);
142
+ min-block-size: var(--zx-control-height);
143
+ padding: var(--zx-space-1) var(--zx-space-2);
144
+ }
145
+
146
+ .zx-menu-button__choice-label {
147
+ color: var(--zx-color-text);
148
+ font-size: var(--zx-text-md);
149
+ }
150
+
151
+ .zx-menu-button__choice-options {
152
+ display: inline-flex;
153
+ flex: none;
154
+ gap: 2px;
155
+ border-radius: var(--zx-radius-md);
156
+ background: var(--zx-color-bg-hover);
157
+ padding: 2px;
158
+ }
159
+
160
+ .zx-menu-button__choice-option {
161
+ display: grid;
162
+ place-items: center;
163
+ inline-size: var(--zx-space-6);
164
+ block-size: var(--zx-space-6);
165
+ border: 0;
166
+ border-radius: var(--zx-radius-sm);
167
+ background: transparent;
168
+ padding: 0;
169
+ color: var(--zx-color-text-muted);
170
+ cursor: pointer;
171
+ }
172
+
173
+ .zx-menu-button__choice-option:hover { color: var(--zx-color-text); }
174
+
175
+ .zx-menu-button__choice-option[aria-checked="true"] {
176
+ background: var(--zx-color-bg-surface);
177
+ box-shadow: 0 0 0 1px var(--zx-color-border-subtle);
178
+ color: var(--zx-color-text);
179
+ }
180
+
181
+ .zx-menu-button__choice-option[aria-disabled="true"] { cursor: not-allowed; opacity: .55; }
182
+
183
+ .zx-menu-button__choice-option:focus-visible {
184
+ outline-color: transparent;
185
+ box-shadow: var(--zx-focus-ring);
186
+ }
187
+
188
+ @media (forced-colors: active) {
189
+ .zx-menu-button__choice-option[aria-checked="true"] {
190
+ background: Highlight;
191
+ color: HighlightText;
192
+ forced-color-adjust: none;
193
+ }
194
+ }