@zeyos/zx 4.4.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 (252) hide show
  1. package/CHANGELOG.md +447 -0
  2. package/README.md +44 -11
  3. package/dist/types/components/account-menu/account-menu.d.ts +8 -0
  4. package/dist/types/components/activity-list/activity-list.d.ts +31 -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 +293 -17
  7. package/dist/types/components/avatar/avatar.d.ts +46 -7
  8. package/dist/types/components/badge/badge.d.ts +21 -1
  9. package/dist/types/components/button/button.d.ts +15 -0
  10. package/dist/types/components/calendar/calendar.d.ts +67 -5
  11. package/dist/types/components/card-view/card-view.d.ts +7 -4
  12. package/dist/types/components/card-view/record-card.d.ts +11 -0
  13. package/dist/types/components/chart/chart.d.ts +10 -0
  14. package/dist/types/components/checklist/checklist.d.ts +9 -0
  15. package/dist/types/components/code-editor/code-editor.d.ts +6 -17
  16. package/dist/types/components/context-menu/context-menu.d.ts +6 -2
  17. package/dist/types/components/data-filter/data-filter.d.ts +4 -4
  18. package/dist/types/components/datebox/date-range-box.d.ts +5 -1
  19. package/dist/types/components/datebox/datebox.d.ts +7 -2
  20. package/dist/types/components/dialog/dialog.d.ts +46 -6
  21. package/dist/types/components/dropdown/dropdown.d.ts +24 -9
  22. package/dist/types/components/entity-ref/entity-ref.d.ts +2 -2
  23. package/dist/types/components/field/field.d.ts +54 -2
  24. package/dist/types/components/fieldset/external-adapter.d.ts +83 -0
  25. package/dist/types/components/fieldset/fieldset.d.ts +26 -1
  26. package/dist/types/components/file-item/file-item.d.ts +34 -6
  27. package/dist/types/components/file-list/file-list.d.ts +35 -3
  28. package/dist/types/components/filter/filter.d.ts +127 -3
  29. package/dist/types/components/filter-panel/filter-panel.d.ts +36 -7
  30. package/dist/types/components/form/form.d.ts +12 -1
  31. package/dist/types/components/kanban-view/kanban-card.d.ts +8 -0
  32. package/dist/types/components/launcher/launcher.d.ts +268 -15
  33. package/dist/types/components/list-toolbar/list-toolbar.d.ts +16 -7
  34. package/dist/types/components/menu-button/menu-button.d.ts +39 -5
  35. package/dist/types/components/message/message.d.ts +132 -17
  36. package/dist/types/components/modal/modal.d.ts +43 -2
  37. package/dist/types/components/module-bar/module-bar.d.ts +395 -0
  38. package/dist/types/components/pagination/pagination.d.ts +17 -3
  39. package/dist/types/components/select/color.d.ts +12 -0
  40. package/dist/types/components/select/glyphs.d.ts +124 -0
  41. package/dist/types/components/select/select.d.ts +17 -3
  42. package/dist/types/components/sheet/sheet.d.ts +15 -0
  43. package/dist/types/components/stat-tile/stat-tile.d.ts +16 -5
  44. package/dist/types/components/tabbox/tabbox.d.ts +9 -0
  45. package/dist/types/components/table/table.d.ts +311 -18
  46. package/dist/types/components/tag-picker/tag-picker.d.ts +21 -4
  47. package/dist/types/components/tree/tree.d.ts +13 -0
  48. package/dist/types/components/view/record-view.d.ts +59 -2
  49. package/dist/types/components/view-settings/view-settings.d.ts +879 -0
  50. package/dist/types/components/workspace-switcher/workspace-switcher.d.ts +327 -0
  51. package/dist/types/core/breakpoint.d.ts +1 -1
  52. package/dist/types/core/component.d.ts +12 -2
  53. package/dist/types/core/i18n.d.ts +27 -3
  54. package/dist/types/core/icons.d.ts +23 -25
  55. package/dist/types/core/keyboard.d.ts +98 -1
  56. package/dist/types/core/messages.d.ts +382 -0
  57. package/dist/types/core/motion.d.ts +59 -0
  58. package/dist/types/core/overlay-host.d.ts +42 -1
  59. package/dist/types/core/overlay-stack.d.ts +260 -0
  60. package/dist/types/index.d.ts +10 -5
  61. package/dist/types/internal/app-rail.d.ts +38 -0
  62. package/dist/types/internal/menu-items.d.ts +71 -3
  63. package/dist/types/zeyos/connect.d.ts +6 -0
  64. package/dist/types/zeyos/form.d.ts +50 -9
  65. package/dist/types/zeyos/index.d.ts +2 -1
  66. package/dist/types/zeyos/launcher.d.ts +9 -0
  67. package/dist/types/zeyos/module-bar.d.ts +176 -0
  68. package/dist/types/zeyos/query.d.ts +110 -5
  69. package/dist/types/zeyos/schema.d.ts +24 -1
  70. package/dist/types/zeyos/table.d.ts +345 -22
  71. package/dist/types/zeyos/view.d.ts +26 -2
  72. package/dist/zx-zeyos.esm.js +6178 -1008
  73. package/dist/zx-zeyos.esm.min.js +53 -5
  74. package/dist/zx.css +2861 -827
  75. package/dist/zx.esm.js +8909 -1480
  76. package/dist/zx.esm.min.js +55 -7
  77. package/dist/zx.global.js +8911 -1482
  78. package/dist/zx.global.min.js +55 -7
  79. package/dist/zx.min.css +1 -1
  80. package/docs/api.json +1079 -117
  81. package/docs/llms.md +956 -154
  82. package/docs/llms.txt +9 -5
  83. package/package.json +1 -1
  84. package/src/components/account-menu/account-menu.css +35 -10
  85. package/src/components/account-menu/account-menu.js +12 -0
  86. package/src/components/activity-item/activity-item.css +1 -1
  87. package/src/components/activity-item/activity-item.js +3 -0
  88. package/src/components/activity-list/activity-list.css +38 -3
  89. package/src/components/activity-list/activity-list.js +61 -1
  90. package/src/components/app-icon/app-icon.css +14 -3
  91. package/src/components/app-icon/app-icon.js +11 -1
  92. package/src/components/app-rail/app-rail.css +59 -79
  93. package/src/components/app-sidebar/app-sidebar.css +304 -168
  94. package/src/components/app-sidebar/app-sidebar.js +667 -46
  95. package/src/components/aurora/aurora.css +5 -3
  96. package/src/components/avatar/avatar.css +32 -7
  97. package/src/components/avatar/avatar.js +72 -8
  98. package/src/components/badge/badge.css +54 -2
  99. package/src/components/badge/badge.js +29 -2
  100. package/src/components/breadcrumb/breadcrumb.css +1 -1
  101. package/src/components/button/button.css +67 -9
  102. package/src/components/button/button.js +38 -0
  103. package/src/components/calendar/calendar.css +47 -47
  104. package/src/components/calendar/calendar.js +26 -52
  105. package/src/components/card/card.css +4 -4
  106. package/src/components/card-view/card-view.css +14 -12
  107. package/src/components/card-view/card-view.js +13 -9
  108. package/src/components/card-view/record-card.js +14 -3
  109. package/src/components/chart/chart.css +4 -1
  110. package/src/components/chart/chart.js +12 -0
  111. package/src/components/check-button/check-button.css +10 -2
  112. package/src/components/checklist/checklist.css +9 -2
  113. package/src/components/checklist/checklist.js +11 -5
  114. package/src/components/checklist/field-adapter.js +3 -1
  115. package/src/components/code-editor/code-editor.css +7 -6
  116. package/src/components/code-editor/code-editor.js +19 -1
  117. package/src/components/code-editor/field-adapter.js +9 -2
  118. package/src/components/context-menu/context-menu.css +9 -4
  119. package/src/components/context-menu/context-menu.js +8 -2
  120. package/src/components/copy/copy-button.css +6 -0
  121. package/src/components/copy/copy-input.css +9 -4
  122. package/src/components/data-filter/data-filter.css +5 -4
  123. package/src/components/data-filter/data-filter.js +2 -2
  124. package/src/components/date-picker/date-picker.css +6 -6
  125. package/src/components/date-picker/date-range-picker.css +3 -3
  126. package/src/components/date-picker/time-picker.css +3 -3
  127. package/src/components/datebox/date-range-box.css +79 -2
  128. package/src/components/datebox/date-range-box.js +13 -2
  129. package/src/components/datebox/datebox.css +79 -2
  130. package/src/components/datebox/datebox.js +13 -2
  131. package/src/components/dialog/dialog.css +26 -0
  132. package/src/components/dialog/dialog.js +99 -17
  133. package/src/components/dock/dock.css +5 -4
  134. package/src/components/dropdown/dropdown.css +1 -1
  135. package/src/components/dropdown/dropdown.js +159 -72
  136. package/src/components/empty-state/empty-state.css +2 -2
  137. package/src/components/entity-ref/entity-ref.css +1 -1
  138. package/src/components/entity-ref/entity-ref.js +1 -1
  139. package/src/components/field/field.css +28 -9
  140. package/src/components/field/field.js +101 -29
  141. package/src/components/field-upload/field-upload.css +1 -1
  142. package/src/components/fieldset/external-adapter.js +363 -0
  143. package/src/components/fieldset/fieldset.css +5 -3
  144. package/src/components/fieldset/fieldset.js +38 -64
  145. package/src/components/file-item/file-item.js +144 -10
  146. package/src/components/file-list/file-list.js +144 -21
  147. package/src/components/filter/filter.css +86 -2
  148. package/src/components/filter/filter.js +307 -11
  149. package/src/components/filter-panel/filter-panel.css +1 -1
  150. package/src/components/filter-panel/filter-panel.js +149 -32
  151. package/src/components/finder/finder.css +16 -14
  152. package/src/components/form/form.css +5 -4
  153. package/src/components/form/form.js +11 -2
  154. package/src/components/grid/grid.css +1 -1
  155. package/src/components/groupbox/groupbox.css +1 -1
  156. package/src/components/kanban-view/kanban-card.js +11 -3
  157. package/src/components/kanban-view/kanban-view.css +42 -18
  158. package/src/components/kanban-view/kanban-view.js +9 -6
  159. package/src/components/launcher/launcher.css +108 -26
  160. package/src/components/launcher/launcher.js +530 -42
  161. package/src/components/list-toolbar/list-toolbar.css +13 -8
  162. package/src/components/list-toolbar/list-toolbar.js +40 -14
  163. package/src/components/master-panel/master-panel.css +2 -0
  164. package/src/components/master-panel/master-panel.js +0 -1
  165. package/src/components/menu-button/menu-button.css +72 -4
  166. package/src/components/menu-button/menu-button.js +160 -18
  167. package/src/components/message/message.css +33 -3
  168. package/src/components/message/message.js +264 -22
  169. package/src/components/modal/modal.css +1 -1
  170. package/src/components/modal/modal.js +83 -15
  171. package/src/components/module-bar/module-bar.css +486 -0
  172. package/src/components/module-bar/module-bar.js +1074 -0
  173. package/src/components/multi-value-editor/multi-value-editor.css +1 -1
  174. package/src/components/navigation-bar/navigation-bar.css +18 -9
  175. package/src/components/number-field/field-adapter.js +3 -1
  176. package/src/components/number-field/number-field.css +70 -0
  177. package/src/components/number-field/number-field.js +2 -0
  178. package/src/components/pagination/pagination.css +13 -11
  179. package/src/components/pagination/pagination.js +20 -4
  180. package/src/components/panel/panel.css +4 -3
  181. package/src/components/questionnaire/questionnaire.css +4 -4
  182. package/src/components/rating/field-adapter.js +2 -1
  183. package/src/components/rating/rating.css +8 -1
  184. package/src/components/search/search.css +4 -1
  185. package/src/components/select/color.js +22 -0
  186. package/src/components/select/field-adapter.js +2 -0
  187. package/src/components/select/glyphs.js +165 -0
  188. package/src/components/select/select.css +138 -45
  189. package/src/components/select/select.js +92 -47
  190. package/src/components/sheet/sheet.css +12 -9
  191. package/src/components/sheet/sheet.js +74 -32
  192. package/src/components/slider/field-adapter.js +6 -1
  193. package/src/components/slider/slider.css +18 -4
  194. package/src/components/slider/slider.js +1 -1
  195. package/src/components/sort-control/sort-control.css +4 -1
  196. package/src/components/split-view/split-view.css +1 -1
  197. package/src/components/stat-tile/stat-tile.css +42 -47
  198. package/src/components/stat-tile/stat-tile.js +17 -6
  199. package/src/components/stepper/stepper.css +1 -1
  200. package/src/components/tabbox/tabbox.css +41 -32
  201. package/src/components/tabbox/tabbox.js +5 -1
  202. package/src/components/table/table.css +187 -37
  203. package/src/components/table/table.js +718 -73
  204. package/src/components/tag-picker/field-adapter.js +2 -1
  205. package/src/components/tag-picker/tag-picker.css +83 -5
  206. package/src/components/tag-picker/tag-picker.js +25 -8
  207. package/src/components/timebox/timebox.css +22 -2
  208. package/src/components/toggle/toggle.css +12 -1
  209. package/src/components/toolbar/toolbar.js +2 -2
  210. package/src/components/tooltip/tooltip.css +4 -0
  211. package/src/components/tooltip/tooltip.js +44 -19
  212. package/src/components/tree/tree.css +26 -17
  213. package/src/components/tree/tree.js +31 -0
  214. package/src/components/value-list/value-list.css +3 -3
  215. package/src/components/view/record-view.css +1 -1
  216. package/src/components/view/record-view.js +178 -18
  217. package/src/components/view-settings/view-settings.css +285 -0
  218. package/src/components/view-settings/view-settings.js +1830 -0
  219. package/src/components/workspace-switcher/workspace-switcher.css +188 -0
  220. package/src/components/workspace-switcher/workspace-switcher.js +463 -0
  221. package/src/core/breakpoint.js +1 -1
  222. package/src/core/component.js +88 -17
  223. package/src/core/fontawesome.js +5 -1
  224. package/src/core/i18n.js +47 -4
  225. package/src/core/icons.js +204 -61
  226. package/src/core/keyboard.js +206 -11
  227. package/src/core/messages.js +427 -0
  228. package/src/core/motion.js +147 -0
  229. package/src/core/overlay-host.js +77 -3
  230. package/src/core/overlay-stack.js +487 -0
  231. package/src/core/position.js +22 -3
  232. package/src/index.js +10 -5
  233. package/src/internal/app-rail.js +135 -55
  234. package/src/internal/menu-items.js +61 -3
  235. package/src/zeyos/connect.js +6 -2
  236. package/src/zeyos/form.js +69 -27
  237. package/src/zeyos/index.js +4 -1
  238. package/src/zeyos/launcher.js +8 -3
  239. package/src/zeyos/module-bar.js +181 -0
  240. package/src/zeyos/query.js +197 -13
  241. package/src/zeyos/schema.js +144 -27
  242. package/src/zeyos/table.js +287 -84
  243. package/src/zeyos/view.js +49 -93
  244. package/styles/base.css +72 -1
  245. package/styles/icons.css +11 -1
  246. package/styles/print.css +1 -1
  247. package/styles/tokens/dark.css +117 -37
  248. package/styles/tokens/global.css +121 -27
  249. package/styles/tokens/semantic.css +140 -43
  250. package/styles/tokens/themes.css +7 -6
  251. package/styles/zx.css +3 -1
  252. package/styles/tokens/density.css +0 -9
@@ -0,0 +1,1830 @@
1
+ import { Component } from '../../core/component.js';
2
+ import { h, highlightMatch, restoreTarget, snapshotTarget } from '../../core/dom.js';
3
+ import { printf } from '../../core/i18n.js';
4
+ import { icon } from '../../core/icons.js';
5
+ import { uid } from '../../core/util.js';
6
+ import { Search } from '../search/search.js';
7
+ import { Select } from '../select/select.js';
8
+ import { Toggle } from '../toggle/toggle.js';
9
+
10
+ /**
11
+ * Built-in field types: the glyph a row shows before the label and the word it is read as. The
12
+ * vocabulary is the one `Filter` and the record views already speak; a host extends or overrides
13
+ * it with the `types` option.
14
+ * @type {Readonly<Record<string, {icon: string, key: string, label: string}>>}
15
+ */
16
+ const FIELD_TYPES = Object.freeze({
17
+ text: { icon: 'type', key: 'viewSettings.typeText', label: 'Text' },
18
+ number: { icon: 'hash', key: 'viewSettings.typeNumber', label: 'Number' },
19
+ money: { icon: 'hash', key: 'viewSettings.typeMoney', label: 'Money' },
20
+ date: { icon: 'calendar', key: 'viewSettings.typeDate', label: 'Date' },
21
+ datetime: { icon: 'calendar', key: 'viewSettings.typeDatetime', label: 'Date and time' },
22
+ time: { icon: 'clock', key: 'viewSettings.typeTime', label: 'Time' },
23
+ boolean: { icon: 'check', key: 'viewSettings.typeBoolean', label: 'Checkbox' },
24
+ select: { icon: 'circle-chevron-down', key: 'viewSettings.typeSelect', label: 'Select' },
25
+ status: { icon: 'circle-chevron-down', key: 'viewSettings.typeStatus', label: 'Status' },
26
+ priority: { icon: 'circle-chevron-down', key: 'viewSettings.typePriority', label: 'Priority' },
27
+ entity: { icon: 'folder', key: 'viewSettings.typeEntity', label: 'Linked record' },
28
+ tags: { icon: 'tag', key: 'viewSettings.typeTags', label: 'Tags' },
29
+ url: { icon: 'link-2', key: 'viewSettings.typeUrl', label: 'URL' },
30
+ email: { icon: 'mail', key: 'viewSettings.typeEmail', label: 'Email' },
31
+ image: { icon: 'image', key: 'viewSettings.typeImage', label: 'Image' },
32
+ file: { icon: 'file', key: 'viewSettings.typeFile', label: 'File' },
33
+ rating: { icon: 'star', key: 'viewSettings.typeRating', label: 'Rating' }
34
+ });
35
+
36
+ /** Other spellings of the built-in types, as servers and older field schemas send them. */
37
+ const TYPE_ALIASES = Object.freeze({
38
+ string: 'text',
39
+ textarea: 'text',
40
+ int: 'number',
41
+ integer: 'number',
42
+ float: 'number',
43
+ decimal: 'number',
44
+ percent: 'number',
45
+ currency: 'money',
46
+ enum: 'select',
47
+ bool: 'boolean',
48
+ checkbox: 'boolean',
49
+ link: 'url'
50
+ });
51
+
52
+ /**
53
+ * Types whose values fall into a handful of named buckets, which is what grouping, board columns,
54
+ * and swim lanes need. Free text is included: a short text field such as a city groups well, and
55
+ * a host that disagrees passes `groupable: false` on the field.
56
+ */
57
+ const DISCRETE_TYPES = new Set(['text', 'select', 'status', 'priority', 'boolean', 'entity']);
58
+
59
+ /** The sections in their default order. */
60
+ const SECTION_IDS = Object.freeze(['fields', 'grouping', 'board']);
61
+
62
+ /** Pixels a pointer must travel before a press on a handle becomes a drag. */
63
+ const DRAG_THRESHOLD = 4;
64
+
65
+ /** Distance from a scroll container's edge, in pixels, at which a drag starts scrolling it. */
66
+ const SCROLL_EDGE = 32;
67
+
68
+ /** Pixels a drag scrolls its container per frame while the pointer is in the edge band. */
69
+ const SCROLL_STEP = 12;
70
+
71
+ /**
72
+ * One field as the settings know it: a record-view field descriptor plus what only the settings
73
+ * need. Any other `ViewField` property (`get`, `render`, `width`, …) is carried along untouched.
74
+ * @typedef {import('../view/record-view.js').ViewField & ViewSettingsFieldExtras} ViewSettingsField
75
+ */
76
+
77
+ /**
78
+ * @typedef {Object} ViewSettingsFieldExtras
79
+ * @property {string} [type='text'] Field type; decides the glyph, the type name, and whether the
80
+ * field is offered for grouping. See the built-in type map in the documentation.
81
+ * @property {boolean} [groupable] `false` keeps the field out of the grouping and board choices;
82
+ * `true` offers it whatever its type. Omitted offers it when its type is discrete.
83
+ * @property {boolean} [locked=false] The panel offers no handle and no hide button for it, such as
84
+ * the record title; it keeps its place while the other fields arrange around it. This governs
85
+ * only what the panel offers: a bound view has no such notion, and when the view hides a locked
86
+ * field itself, the panel shows it under Hidden with Add like any other.
87
+ */
88
+
89
+ /**
90
+ * @typedef {Object} ViewSettingsBoard
91
+ * @property {string|null} columnBy Field whose values are the board's columns.
92
+ * @property {string|null} swimlaneBy Field whose values are the swim lanes, or null for none.
93
+ * @property {boolean} [showEmptyColumns=true] Whether columns without cards stay visible.
94
+ */
95
+
96
+ /**
97
+ * The `board` option: the initial board settings plus the fields the board may use.
98
+ * @typedef {Object} ViewSettingsBoardOptions
99
+ * @property {string|null} [columnBy=null] Initial column field.
100
+ * @property {string|null} [swimlaneBy=null] Initial swim-lane field, or null for none.
101
+ * @property {boolean} [showEmptyColumns=true] Whether columns without cards stay visible.
102
+ * @property {string[]} [fields] The field ids offered for columns and swim lanes, in this order.
103
+ * Omitted offers the same fields as the Group section.
104
+ */
105
+
106
+ /**
107
+ * @typedef {Object} ViewSettingsState
108
+ * @property {string[]} fieldOrder Every field id, in order, hidden ones included.
109
+ * @property {string[]} hiddenFields Hidden field ids, in field order.
110
+ * @property {string|null} groupBy Grouping field, or null for none.
111
+ * @property {ViewSettingsBoard|null} board Board settings, or null when the section is omitted.
112
+ */
113
+
114
+ /**
115
+ * @typedef {Object} ViewSettingsGrouping
116
+ * @property {string[]} [fields] The field ids offered for grouping, in this order. Omitted offers
117
+ * every field with `groupable !== false` and a discrete type.
118
+ */
119
+
120
+ /** @typedef {'fields'|'grouping'|'board'} ViewSettingsSection */
121
+ /** @typedef {'order'|'visibility'|'group'|'board'} ViewSettingsChangeReason */
122
+
123
+ /**
124
+ * @typedef {Object} ViewSettingsOptions
125
+ * @property {ViewSettingsField[]} [fields=[]] Field descriptors, in their default order. Bound to a
126
+ * view, a non-empty list is also the allow-list: the view's other fields are neither listed nor
127
+ * touched. An empty list takes every field the view has.
128
+ * @property {string[]} [fieldOrder=[]] Preferred field order; missing fields append.
129
+ * @property {string[]} [hiddenFields=[]] Hidden field ids. A field with `visible: false` starts
130
+ * hidden too; a locked field never does.
131
+ * @property {string|null} [groupBy=null] Initial grouping field.
132
+ * @property {boolean|ViewSettingsGrouping} [grouping=true] The Group section. `false` omits it;
133
+ * an object restricts the fields it offers.
134
+ * @property {ViewSettingsBoardOptions|false} [board=false] The Board section (column field,
135
+ * swim-lane field, show empty columns) with its initial values and, in `fields`, the fields it
136
+ * offers; `false` omits it.
137
+ * @property {ViewSettingsSection[]} [sections] Order and presence of the sections. Defaults to
138
+ * `['fields', 'grouping', 'board']`; a section whose option is `false` is always left out.
139
+ * @property {Record<string, {icon?: string, label?: string}>} [types={}] Extends or overrides the
140
+ * built-in type map, keyed by type name.
141
+ * @property {number} [searchThreshold=8] Field count above which the field search is shown.
142
+ * @property {2|3|4|5|6} [headingLevel=3] Heading level of the section headings.
143
+ * @property {string|null} [label=null] Accessible name of the region; omitted uses the
144
+ * `viewSettings.label` message ("View settings").
145
+ * @property {Record<string, string>|Record<string, Record<string, string>>} [msg] Localized messages.
146
+ * @property {(event: CustomEvent<{state: ViewSettingsState, reason: ViewSettingsChangeReason}>) => void} [onchange]
147
+ * Any change the reader made.
148
+ * @property {(event: CustomEvent<{order: string[]}>) => void} [onfieldorderchange] Field order changed.
149
+ * @property {(event: CustomEvent<{visible: string[], hidden: string[]}>) => void} [onfieldvisibilitychange]
150
+ * A field was added or hidden.
151
+ * @property {(event: CustomEvent<{field: string|null}>) => void} [ongroupchange] Grouping field changed.
152
+ * @property {(event: CustomEvent<{board: ViewSettingsBoard}>) => void} [onboardchange] Board settings changed.
153
+ */
154
+
155
+ /**
156
+ * The content of a "View settings" panel for a record collection: which fields are shown and in
157
+ * what order, which hidden field to bring back, what the records are grouped by, and which fields
158
+ * a board uses for its columns and swim lanes. It is content, not a container — the host puts it
159
+ * in a `Sheet`, a `Dropdown`, or a settings page.
160
+ *
161
+ * - **Fields** — the shown fields, in order, each with its type glyph. Rows are rearranged by
162
+ * dragging the handle (pointer, touch, or pen) or from the keyboard, and hidden with one button.
163
+ * The hidden fields are listed below with an explicit Add button; with more than
164
+ * `searchThreshold` fields a search narrows them, and Enter adds the first match.
165
+ * - **Group by** — a `Select` of the groupable fields, with None.
166
+ * - **Board** — column field, swim-lane field, and whether empty columns stay.
167
+ *
168
+ * Every move is announced through a polite live region ("Owner moved to position 3 of 6").
169
+ *
170
+ * `bind(view)` connects a `TableView`, `CardView`, or `KanbanView`: order and visibility are
171
+ * pushed into the view and the view's own changes come back. Grouping and board settings are only
172
+ * reported — the host decides how its views apply them.
173
+ * @fires ViewSettings#change
174
+ * @fires ViewSettings#fieldorderchange
175
+ * @fires ViewSettings#fieldvisibilitychange
176
+ * @fires ViewSettings#groupchange
177
+ * @fires ViewSettings#boardchange
178
+ * @extends {Component<ViewSettingsOptions>}
179
+ */
180
+ export class ViewSettings extends Component {
181
+ static cssName = 'view-settings';
182
+
183
+ /** @type {Readonly<ViewSettingsOptions>} */
184
+ static defaults = {
185
+ fields: [],
186
+ fieldOrder: [],
187
+ hiddenFields: [],
188
+ groupBy: null,
189
+ grouping: true,
190
+ board: false,
191
+ types: {},
192
+ searchThreshold: 8,
193
+ headingLevel: 3,
194
+ label: null
195
+ };
196
+
197
+ /**
198
+ * Builds the panel. Runs inside the base constructor, so every piece of instance state is
199
+ * initialized here rather than in a class field.
200
+ * @returns {HTMLElement}
201
+ */
202
+ render() {
203
+ this._createdRoot = this.el === null;
204
+ this._snapshot = this._createdRoot ? null : snapshotTarget(this.el);
205
+ this._destroyed = false;
206
+ /** @type {ViewSettingsField[]} */
207
+ this._fields = normalizeViewSettingsFields(this.options.fields);
208
+ // The ids the host named: bound to a view, the panel lists only these, so a field the view
209
+ // keeps for another purpose — the title a card draws — is not offered as a column.
210
+ this._ownIds = new Set(this._fields.map((field) => field.id));
211
+ this._order = reconcileOrder(this._fields, this.options.fieldOrder);
212
+ this._hidden = normalizeHidden(this._fields, this.options.hiddenFields, true);
213
+ const ids = this._fields.length ? this._fields.map((field) => field.id) : null;
214
+ this._groupBy = normalizeFieldId(this.options.groupBy, ids);
215
+ this._board = normalizeBoard(this.options.board, ids);
216
+ const boardOption = /** @type {ViewSettingsBoardOptions|false} */ (this.options.board);
217
+ /** @type {string[]|null} */
218
+ this._boardFields = boardOption && Array.isArray(boardOption.fields) ? boardOption.fields.map(String) : null;
219
+ this._sections = normalizeSections(this.options.sections, this.options);
220
+ this._query = '';
221
+ /** @type {Search|null} */
222
+ this._search = null;
223
+ /** @type {Select|null} */
224
+ this._groupSelect = null;
225
+ /** @type {Select|null} */
226
+ this._columnSelect = null;
227
+ /** @type {Select|null} */
228
+ this._laneSelect = null;
229
+ /** @type {Toggle|null} */
230
+ this._emptyToggle = null;
231
+ /** @type {{view: Record<string, any>, controller: AbortController}|null} */
232
+ this._bound = null;
233
+ this._syncing = false;
234
+ this._rendering = false;
235
+ /** @type {{id: string, original: string[]}|null} */
236
+ this._grab = null;
237
+ /** @type {DragState|null} */
238
+ this._drag = null;
239
+ this._announceToken = 0;
240
+ this._level = normalizeHeadingLevel(this.options.headingLevel);
241
+
242
+ const root = /** @type {HTMLElement} */ (this.el ?? h('div'));
243
+ this.el = root;
244
+ root.setAttribute('role', 'region');
245
+ root.setAttribute('aria-label', this.options.label ?? this._message('viewSettings.label', 'View settings'));
246
+
247
+ this._instructionsId = uid('zx-view-settings-help');
248
+ /** @type {Node[]} */
249
+ const children = [];
250
+ for (const section of this._sections) {
251
+ if (section === 'fields') children.push(this._buildFieldsSection());
252
+ if (section === 'grouping') children.push(this._buildGroupSection());
253
+ if (section === 'board') children.push(this._buildBoardSection());
254
+ }
255
+ children.push(h('span', {
256
+ class: 'zx-view-settings__instructions',
257
+ id: this._instructionsId,
258
+ hidden: true
259
+ }, this._message('viewSettings.instructions',
260
+ 'Press Space to pick the field up, the arrow keys to move it, Space again to drop it, or Escape to cancel.')));
261
+ // Always present and never hidden: a live region must be in the tree before its text changes.
262
+ children.push(h('div', {
263
+ class: 'zx-view-settings__status',
264
+ ref: 'status',
265
+ role: 'status',
266
+ ariaLive: 'polite',
267
+ ariaAtomic: 'true'
268
+ }));
269
+ root.replaceChildren(...children);
270
+
271
+ if (this.refs.list) {
272
+ const list = this.refs.list;
273
+ this.listen(list, 'click', (event) => this._onShownClick(/** @type {MouseEvent} */ (event)));
274
+ this.listen(list, 'keydown', (event) => this._onHandleKeydown(/** @type {KeyboardEvent} */ (event)));
275
+ this.listen(list, 'focusout', (event) => this._onHandleFocusout(/** @type {FocusEvent} */ (event)));
276
+ this.listen(list, 'pointerdown', (event) => this._onPointerDown(/** @type {PointerEvent} */ (event)));
277
+ this.listen(list, 'pointermove', (event) => this._onPointerMove(/** @type {PointerEvent} */ (event)));
278
+ this.listen(list, 'pointerup', (event) => this._onPointerUp(/** @type {PointerEvent} */ (event)));
279
+ this.listen(list, 'pointercancel', () => this._cancelDrag());
280
+ this.listen(list, 'lostpointercapture', () => {
281
+ if (this._drag && !this._drag.ending) this._cancelDrag();
282
+ });
283
+ this.listen(this.refs.hidden, 'click', (event) => this._onHiddenClick(/** @type {MouseEvent} */ (event)));
284
+ // Escape ends a drag wherever focus is; captured, so a surrounding Sheet does not close too.
285
+ this.listen(root.ownerDocument ?? document, 'keydown', (event) => {
286
+ const key = /** @type {KeyboardEvent} */ (event);
287
+ if (key.key !== 'Escape' || !this._drag?.active) return;
288
+ key.preventDefault();
289
+ key.stopPropagation();
290
+ this._cancelDrag(true);
291
+ }, { capture: true });
292
+ this._syncSearch();
293
+ this._renderFields();
294
+ }
295
+ this._syncGroupControls();
296
+ return root;
297
+ }
298
+
299
+ /**
300
+ * Returns the current settings as a JSON-safe snapshot.
301
+ * @returns {ViewSettingsState}
302
+ */
303
+ getState() {
304
+ return {
305
+ fieldOrder: [...this._order],
306
+ hiddenFields: this._order.filter((id) => this._hidden.has(id)),
307
+ groupBy: this._groupBy,
308
+ board: this._board ? { ...this._board } : null
309
+ };
310
+ }
311
+
312
+ /**
313
+ * Applies part of a state. Unknown field ids are ignored, missing ones append, and a locked
314
+ * field stays shown. Each part that actually changes emits its own event unless `silent`.
315
+ * A keyboard pick-up or pointer drag in progress is canceled first.
316
+ * @param {Partial<ViewSettingsState>} partial State to apply.
317
+ * @param {{silent?: boolean}} [options={}] Set `silent` to suppress events.
318
+ * @returns {this}
319
+ * @fires ViewSettings#fieldorderchange
320
+ * @fires ViewSettings#fieldvisibilitychange
321
+ * @fires ViewSettings#groupchange
322
+ * @fires ViewSettings#boardchange
323
+ */
324
+ setState(partial, { silent = false } = {}) {
325
+ this._settle();
326
+ const source = partial && typeof partial === 'object' ? partial : {};
327
+ const ids = this._fields.length ? this._fields.map((field) => field.id) : null;
328
+ let orderChanged = false;
329
+ let visibilityChanged = false;
330
+ if (Array.isArray(source.fieldOrder)) {
331
+ const next = reconcileOrder(this._fields, source.fieldOrder);
332
+ orderChanged = !sameList(next, this._order);
333
+ this._order = next;
334
+ }
335
+ if (Array.isArray(source.hiddenFields)) {
336
+ const next = normalizeHidden(this._fields, source.hiddenFields, false);
337
+ visibilityChanged = !sameSet(next, this._hidden);
338
+ this._hidden = next;
339
+ }
340
+ let groupChanged = false;
341
+ if (Object.hasOwn(source, 'groupBy')) {
342
+ const next = normalizeFieldId(source.groupBy, ids);
343
+ groupChanged = next !== this._groupBy;
344
+ this._groupBy = next;
345
+ }
346
+ let boardChanged = false;
347
+ if (Object.hasOwn(source, 'board') && this._board) {
348
+ const next = normalizeBoard({ ...this._board, ...(source.board ?? {}) }, ids);
349
+ boardChanged = !sameBoard(next, this._board);
350
+ this._board = next;
351
+ }
352
+ this._renderFields();
353
+ this._syncGroupControls();
354
+ if (orderChanged || visibilityChanged) this._pushToView();
355
+ if (silent) return this;
356
+ if (orderChanged) this._emitOrder();
357
+ if (visibilityChanged) this._emitVisibility();
358
+ if (groupChanged) this._emitGroup();
359
+ if (boardChanged) this._emitBoard();
360
+ return this;
361
+ }
362
+
363
+ /**
364
+ * Replaces the field descriptors, keeping order, visibility, grouping, and board settings for
365
+ * every id that still exists.
366
+ *
367
+ * While a view is bound, the view owns the field list: change it with the view's own
368
+ * `setFields()` and the panel follows through `statechange`. A `setFields()` here is not pushed
369
+ * to the view and lasts only until the view's next change.
370
+ * @param {ViewSettingsField[]} fields Next fields.
371
+ * @returns {this}
372
+ */
373
+ setFields(fields) {
374
+ this._settle();
375
+ const previousHidden = this._order.filter((id) => this._hidden.has(id));
376
+ const previousOrder = [...this._order];
377
+ this._fields = normalizeViewSettingsFields(fields);
378
+ this._ownIds = new Set(this._fields.map((field) => field.id));
379
+ this._order = reconcileOrder(this._fields, previousOrder);
380
+ // Fields new to the list start the way their descriptor says.
381
+ const known = new Set(previousOrder);
382
+ const hidden = [...previousHidden,
383
+ ...this._fields.filter((field) => !known.has(field.id) && field.visible === false).map((field) => field.id)];
384
+ this._hidden = normalizeHidden(this._fields, hidden, false);
385
+ const ids = this._fields.map((field) => field.id);
386
+ this._groupBy = normalizeFieldId(this._groupBy, ids);
387
+ if (this._board) this._board = normalizeBoard(this._board, ids);
388
+ this._syncSearch();
389
+ this._renderFields();
390
+ this._syncGroupControls();
391
+ return this;
392
+ }
393
+
394
+ /**
395
+ * Returns the field descriptors in their current order.
396
+ * @returns {ViewSettingsField[]}
397
+ */
398
+ getFields() {
399
+ const byId = new Map(this._fields.map((field) => [field.id, field]));
400
+ return this._order.map((id) => ({ .../** @type {ViewSettingsField} */ (byId.get(id)) }));
401
+ }
402
+
403
+ /**
404
+ * Shows a hidden field at the end of the shown list.
405
+ * @param {string} id Field id.
406
+ * @returns {this}
407
+ * @fires ViewSettings#fieldvisibilitychange
408
+ */
409
+ showField(id) {
410
+ const fieldId = String(id);
411
+ if (!this._hidden.has(fieldId)) return this;
412
+ this._settle();
413
+ const next = showFieldInOrder(this._order, this._hidden, fieldId);
414
+ const orderChanged = !sameList(next.order, this._order);
415
+ this._order = next.order;
416
+ this._hidden = new Set(next.hidden);
417
+ this._renderFields();
418
+ this._pushToView();
419
+ if (orderChanged) this._emitOrder(false);
420
+ this._emitVisibility();
421
+ return this;
422
+ }
423
+
424
+ /**
425
+ * Hides a shown field. A locked field and the last shown field cannot be hidden.
426
+ * @param {string} id Field id.
427
+ * @returns {this}
428
+ * @fires ViewSettings#fieldvisibilitychange
429
+ */
430
+ hideField(id) {
431
+ const fieldId = String(id);
432
+ if (!this._canHide(fieldId)) return this;
433
+ this._settle();
434
+ this._hidden.add(fieldId);
435
+ this._renderFields();
436
+ this._pushToView();
437
+ this._emitVisibility();
438
+ return this;
439
+ }
440
+
441
+ /**
442
+ * Moves a shown field to a position among the movable shown fields (locked ones keep theirs).
443
+ * @param {string} id Field id.
444
+ * @param {number} index Zero-based target position among the movable shown fields.
445
+ * @returns {this}
446
+ * @fires ViewSettings#fieldorderchange
447
+ */
448
+ moveField(id, index) {
449
+ this._settle();
450
+ const next = moveVisibleField(this._order, this._hidden, this._lockedIds(), String(id), index);
451
+ if (sameList(next, this._order)) return this;
452
+ this._order = next;
453
+ this._renderFields();
454
+ this._pushToView();
455
+ this._emitOrder();
456
+ return this;
457
+ }
458
+
459
+ /**
460
+ * Connects a record view (`TableView`, `CardView`, `KanbanView`, or anything exposing the
461
+ * `RecordView` state surface). The view's fields and state are read now; order and visibility
462
+ * changes made here are pushed through `setFieldOrder()` and `setFieldVisible()`; and the view's
463
+ * own `statechange` is followed, so a column hidden from a table header disappears here too.
464
+ * The view's labels and types win over the panel's own descriptors, which supply only what the
465
+ * view lacks (`locked`, `groupable`, a type the view leaves at its `'text'` default). While
466
+ * bound, the view owns the field list; see `setFields()`.
467
+ *
468
+ * Grouping and board settings are never applied to the view — they are emitted, and the host
469
+ * decides (a `CardView` is re-created or given a new `groupBy`, a `KanbanView` its axes). A
470
+ * view's own `groupBy`, `columnBy`, and `swimlaneBy` options are adopted when they name fields.
471
+ * @param {object} view Record view.
472
+ * @param {{board?: ViewSettingsBoard|false}} [options={}] `board` replaces the board settings
473
+ * read from the view.
474
+ * @returns {this}
475
+ */
476
+ bind(view, options = {}) {
477
+ const candidate = /** @type {Record<string, any>} */ (view);
478
+ if (!candidate || typeof candidate.getFields !== 'function' || typeof candidate.setFieldOrder !== 'function'
479
+ || typeof candidate.setFieldVisible !== 'function' || typeof candidate.getHiddenFields !== 'function') {
480
+ throw new TypeError('ViewSettings.bind() needs a record view');
481
+ }
482
+ this.unbind();
483
+ // One controller per binding, aborted by `unbind()`, so a view the host has moved on from is
484
+ // not kept alive by this panel until it is destroyed.
485
+ const controller = new AbortController();
486
+ this._bound = { view: candidate, controller };
487
+ candidate.addEventListener('statechange', (/** @type {Event} */ event) => {
488
+ if (this._syncing) return;
489
+ const reason = /** @type {CustomEvent} */ (event).detail?.reason;
490
+ this._pullFromView(reason === 'fields' || reason === 'restore');
491
+ }, { signal: controller.signal });
492
+ this._pullFromView(true);
493
+ const viewOptions = candidate.options ?? {};
494
+ const ids = this._fields.map((field) => field.id);
495
+ if (typeof viewOptions.groupBy === 'string') this._groupBy = normalizeFieldId(viewOptions.groupBy, ids);
496
+ if (this._board) {
497
+ if (options.board) {
498
+ this._board = normalizeBoard(options.board, ids);
499
+ } else if (typeof viewOptions.columnBy === 'string') {
500
+ this._board = normalizeBoard({
501
+ columnBy: viewOptions.columnBy,
502
+ swimlaneBy: typeof viewOptions.swimlaneBy === 'string' ? viewOptions.swimlaneBy : null,
503
+ showEmptyColumns: viewOptions.showEmptyColumns !== false
504
+ }, ids);
505
+ }
506
+ }
507
+ this._syncGroupControls();
508
+ return this;
509
+ }
510
+
511
+ /**
512
+ * Releases a bound view. The settings keep their current state.
513
+ * @returns {this}
514
+ */
515
+ unbind() {
516
+ if (this._bound) {
517
+ this._bound.controller.abort();
518
+ this._bound = null;
519
+ }
520
+ return this;
521
+ }
522
+
523
+ /**
524
+ * Focuses the field search, or the first control of the shown list when there is no search.
525
+ * @returns {this}
526
+ */
527
+ focus() {
528
+ if (this._search) {
529
+ this._search.focus();
530
+ return this;
531
+ }
532
+ const first = /** @type {HTMLElement|null} */ (this.el.querySelector(
533
+ '.zx-view-settings__list button:not(:disabled), .zx-view-settings__hidden button:not(:disabled), '
534
+ + '.zx-select__input, .zx-toggle'));
535
+ first?.focus();
536
+ return this;
537
+ }
538
+
539
+ /**
540
+ * Destroys the nested controls, releases a bound view, and restores an enhanced target.
541
+ * @returns {void}
542
+ */
543
+ destroy() {
544
+ if (this._destroyed) return;
545
+ this._destroyed = true;
546
+ this.unbind();
547
+ if (this._drag) this._endDrag(this._drag);
548
+ for (const control of [this._search, this._groupSelect, this._columnSelect, this._laneSelect, this._emptyToggle]) {
549
+ control?.destroy();
550
+ }
551
+ this._search = null;
552
+ this._groupSelect = null;
553
+ this._columnSelect = null;
554
+ this._laneSelect = null;
555
+ this._emptyToggle = null;
556
+ const root = this.el;
557
+ super.destroy();
558
+ if (!this._createdRoot) restoreTarget(root, this._snapshot);
559
+ }
560
+
561
+ /* ------------------------------------------------------------------ building -- */
562
+
563
+ /**
564
+ * @param {string} text Heading text.
565
+ * @param {number} [offset=0] Levels below the section heading.
566
+ * @param {string} [className='zx-view-settings__heading'] Heading class.
567
+ * @returns {{element: HTMLElement, id: string}}
568
+ */
569
+ _heading(text, offset = 0, className = 'zx-view-settings__heading') {
570
+ const id = uid('zx-view-settings-heading');
571
+ const level = Math.min(6, this._level + offset);
572
+ return { element: h(`h${level}`, { class: className, id }, text), id };
573
+ }
574
+
575
+ /** @returns {HTMLElement} The Fields section. */
576
+ _buildFieldsSection() {
577
+ const heading = this._heading(this._message('viewSettings.fields', 'Fields'));
578
+ const shown = this._heading('', 1, 'zx-view-settings__subheading');
579
+ const hidden = this._heading('', 1, 'zx-view-settings__subheading');
580
+ this.refs.shownHeading = shown.element;
581
+ this.refs.hiddenHeading = hidden.element;
582
+ return h('section', {
583
+ class: 'zx-view-settings__section',
584
+ ref: 'fieldsSection',
585
+ dataset: { section: 'fields' },
586
+ ariaLabelledby: heading.id
587
+ },
588
+ heading.element,
589
+ shown.element,
590
+ h('div', { class: 'zx-view-settings__frame', ref: 'frame' },
591
+ h('ul', { class: 'zx-view-settings__list', ref: 'list', ariaLabelledby: shown.id }),
592
+ h('div', { class: 'zx-view-settings__drop-indicator', ref: 'indicator', ariaHidden: 'true', hidden: true })),
593
+ hidden.element,
594
+ h('div', { class: 'zx-view-settings__search-slot', ref: 'searchSlot' }),
595
+ h('ul', { class: 'zx-view-settings__hidden', ref: 'hidden', ariaLabelledby: hidden.id }),
596
+ h('p', { class: 'zx-view-settings__empty', ref: 'empty', hidden: true }));
597
+ }
598
+
599
+ /** @returns {HTMLElement} The Group section. */
600
+ _buildGroupSection() {
601
+ const text = this._message('viewSettings.group', 'Group by');
602
+ const heading = this._heading(text);
603
+ this._groupSelect = new Select(null, {
604
+ items: [],
605
+ clearable: true,
606
+ noneLabel: this._message('viewSettings.none', 'None'),
607
+ placeholder: this._message('viewSettings.none', 'None'),
608
+ label: text,
609
+ renderItem: (item) => this._optionContent(/** @type {SelectFieldItem} */ (item)),
610
+ renderValueAdornment: (item) => this._typeGlyph(/** @type {SelectFieldItem} */ (item).type),
611
+ msg: this.options.msg
612
+ });
613
+ this._groupSelect.on('change', (event) => {
614
+ const value = /** @type {CustomEvent} */ (event).detail.value;
615
+ this._setGroup(value == null ? null : String(value));
616
+ });
617
+ const control = /** @type {HTMLElement} */ (this._groupSelect.toElement());
618
+ control.classList.add('zx-view-settings__group');
619
+ return h('section', {
620
+ class: 'zx-view-settings__section',
621
+ dataset: { section: 'grouping' },
622
+ ariaLabelledby: heading.id
623
+ }, heading.element, control);
624
+ }
625
+
626
+ /** @returns {HTMLElement} The Board section. */
627
+ _buildBoardSection() {
628
+ const heading = this._heading(this._message('viewSettings.board', 'Board'));
629
+ const columnsText = this._message('viewSettings.columns', 'Columns');
630
+ const lanesText = this._message('viewSettings.swimlanes', 'Swim lanes');
631
+ const renderItem = (item) => this._optionContent(/** @type {SelectFieldItem} */ (item));
632
+ const adornment = (item) => this._typeGlyph(/** @type {SelectFieldItem} */ (item).type);
633
+ this._columnSelect = new Select(null, {
634
+ items: [],
635
+ placeholder: this._message('viewSettings.chooseField', 'Choose a field'),
636
+ label: columnsText,
637
+ renderItem,
638
+ renderValueAdornment: adornment,
639
+ msg: this.options.msg
640
+ });
641
+ this._laneSelect = new Select(null, {
642
+ items: [],
643
+ clearable: true,
644
+ noneLabel: this._message('viewSettings.none', 'None'),
645
+ placeholder: this._message('viewSettings.none', 'None'),
646
+ label: lanesText,
647
+ renderItem,
648
+ renderValueAdornment: adornment,
649
+ msg: this.options.msg
650
+ });
651
+ this._emptyToggle = new Toggle(null, {
652
+ label: this._message('viewSettings.emptyColumns', 'Show empty columns'),
653
+ checked: this._board?.showEmptyColumns !== false
654
+ });
655
+ this._columnSelect.on('change', (event) => {
656
+ const value = /** @type {CustomEvent} */ (event).detail.value;
657
+ this._setBoard({ columnBy: value == null ? null : String(value) });
658
+ });
659
+ this._laneSelect.on('change', (event) => {
660
+ const value = /** @type {CustomEvent} */ (event).detail.value;
661
+ this._setBoard({ swimlaneBy: value == null ? null : String(value) });
662
+ });
663
+ this._emptyToggle.on('change', (event) => {
664
+ this._setBoard({ showEmptyColumns: Boolean(/** @type {CustomEvent} */ (event).detail.checked) });
665
+ });
666
+ const toggle = /** @type {HTMLElement} */ (this._emptyToggle.toElement());
667
+ toggle.classList.add('zx-view-settings__toggle');
668
+ return h('section', {
669
+ class: 'zx-view-settings__section zx-view-settings__board',
670
+ dataset: { section: 'board' },
671
+ ariaLabelledby: heading.id
672
+ },
673
+ heading.element,
674
+ h('div', { class: 'zx-view-settings__setting' },
675
+ h('span', { class: 'zx-view-settings__setting-label', ariaHidden: 'true' }, columnsText),
676
+ this._columnSelect.toElement()),
677
+ h('div', { class: 'zx-view-settings__setting' },
678
+ h('span', { class: 'zx-view-settings__setting-label', ariaHidden: 'true' }, lanesText),
679
+ this._laneSelect.toElement()),
680
+ toggle);
681
+ }
682
+
683
+ /**
684
+ * Creates or removes the field search to match the field count.
685
+ * @returns {void}
686
+ */
687
+ _syncSearch() {
688
+ const slot = this.refs.searchSlot;
689
+ if (!slot) return;
690
+ const wanted = this._fields.length > Math.max(0, Number(this.options.searchThreshold) || 0);
691
+ if (wanted && !this._search) {
692
+ const placeholder = this._message('viewSettings.search', 'Find a field');
693
+ this._search = new Search(null, { placeholder, debounce: 0, msg: this.options.msg });
694
+ const element = /** @type {HTMLElement} */ (this._search.toElement());
695
+ element.classList.add('zx-view-settings__search');
696
+ // `Search` names its input from the placeholder but leaves its search landmark unnamed.
697
+ element.setAttribute('aria-label', placeholder);
698
+ const input = /** @type {HTMLInputElement} */ (this._search.refs.input);
699
+ // The native event rather than the component's debounced one: the list narrows per keystroke.
700
+ this.listen(input, 'input', () => this._setQuery(input.value));
701
+ this._search.on('clear', () => this._setQuery(''));
702
+ this._search.on('submit', () => this._addFirstMatch());
703
+ slot.replaceChildren(element);
704
+ } else if (!wanted && this._search) {
705
+ this._search.destroy();
706
+ this._search = null;
707
+ slot.replaceChildren();
708
+ this._query = '';
709
+ }
710
+ }
711
+
712
+ /* ------------------------------------------------------------------ rendering -- */
713
+
714
+ /**
715
+ * Rebuilds both field lists, then puts focus back: on the control asked for, or else on the same
716
+ * control of the same field when focus was inside the lists. A pointer drag in progress ends —
717
+ * the rows it measured are about to be replaced — and so does a keyboard pick-up, unless the
718
+ * redraw is that pick-up's own step.
719
+ * @param {{id: string, part: 'handle'|'hide'|'add'}|null} [focus=null] Control to focus afterwards.
720
+ * @param {{keepGrab?: boolean}} [options={}] `keepGrab` marks a redraw made by the pick-up itself.
721
+ * @returns {void}
722
+ */
723
+ _renderFields(focus = null, { keepGrab = false } = {}) {
724
+ const list = this.refs.list;
725
+ if (!list) return;
726
+ if (this._drag) this._endDrag(this._drag);
727
+ if (this._grab && !keepGrab) {
728
+ const id = this._grab.id;
729
+ this._grab = null;
730
+ this._announceCanceled(id);
731
+ }
732
+ const restore = focus ?? this._activePart();
733
+ this._rendering = true;
734
+ const byId = new Map(this._fields.map((field) => [field.id, field]));
735
+ const visible = this._order.filter((id) => !this._hidden.has(id));
736
+ const hidden = this._order.filter((id) => this._hidden.has(id));
737
+ const canHideAny = visible.length > 1;
738
+
739
+ list.replaceChildren(...visible.map((id) => this._shownRow(/** @type {ViewSettingsField} */ (byId.get(id)), canHideAny)));
740
+ this.refs.shownHeading.textContent = this._message('viewSettings.shown', 'Shown (%1)', visible.length);
741
+ this.refs.hiddenHeading.textContent = this._message('viewSettings.hidden', 'Hidden (%1)', hidden.length);
742
+
743
+ const matches = hidden
744
+ .map((id) => /** @type {ViewSettingsField} */ (byId.get(id)))
745
+ .filter((field) => matchesFieldQuery(field, this._query, this._typeLabel(field.type)));
746
+ this.refs.hidden.replaceChildren(...matches.map((field) => this._hiddenRow(field)));
747
+ const empty = /** @type {HTMLElement} */ (this.refs.empty);
748
+ if (matches.length === 0) {
749
+ empty.textContent = hidden.length === 0
750
+ ? this._message('viewSettings.allShown', 'Every field is shown.')
751
+ : this._message('viewSettings.noMatch', 'No hidden field matches “%1”.', this._query.trim());
752
+ empty.hidden = false;
753
+ } else {
754
+ empty.hidden = true;
755
+ }
756
+ this.refs.hidden.hidden = matches.length === 0;
757
+ if (this._grab) {
758
+ const row = this._row(this._grab.id);
759
+ if (row) row.dataset.state = 'grabbed';
760
+ row?.querySelector('.zx-view-settings__handle')?.setAttribute('aria-pressed', 'true');
761
+ }
762
+ this._rendering = false;
763
+ if (restore) this._focusPart(restore);
764
+ }
765
+
766
+ /**
767
+ * @param {ViewSettingsField} field Field.
768
+ * @param {boolean} canHideAny Whether more than one field is shown.
769
+ * @returns {HTMLElement}
770
+ */
771
+ _shownRow(field, canHideAny) {
772
+ const locked = Boolean(field.locked);
773
+ const reorder = this._message('viewSettings.reorder', 'Reorder %1', field.label);
774
+ const hide = this._message('viewSettings.hide', 'Hide %1', field.label);
775
+ const handle = locked
776
+ ? h('span', { class: 'zx-view-settings__handle', dataset: { placeholder: 'true' }, ariaHidden: 'true' })
777
+ : h('button', {
778
+ class: 'zx-view-settings__handle',
779
+ type: 'button',
780
+ ariaLabel: reorder,
781
+ ariaDescribedby: this._instructionsId,
782
+ ariaPressed: 'false',
783
+ title: reorder,
784
+ dataset: { action: 'handle' }
785
+ }, icon('drag', { size: 16 }));
786
+ const end = locked
787
+ ? h('span', {
788
+ class: 'zx-view-settings__lock',
789
+ title: this._message('viewSettings.locked', 'Always shown')
790
+ }, icon('lock', { size: 14 }),
791
+ h('span', { class: 'zx-view-settings__sr' }, this._message('viewSettings.locked', 'Always shown')))
792
+ : h('button', {
793
+ class: 'zx-view-settings__hide',
794
+ type: 'button',
795
+ ariaLabel: hide,
796
+ title: canHideAny ? hide : this._message('viewSettings.lastField', 'At least one field stays shown'),
797
+ disabled: !canHideAny,
798
+ dataset: { action: 'hide' }
799
+ }, icon('eye', { size: 16 }));
800
+ return h('li', {
801
+ class: 'zx-view-settings__row',
802
+ dataset: { fieldId: field.id, locked: locked ? 'true' : null }
803
+ }, handle, h('span', { class: 'zx-view-settings__label' }, field.label), this._typeBadge(field.type), end);
804
+ }
805
+
806
+ /**
807
+ * @param {ViewSettingsField} field Field.
808
+ * @returns {HTMLElement}
809
+ */
810
+ _hiddenRow(field) {
811
+ const add = this._message('viewSettings.addField', 'Add %1', field.label);
812
+ return h('li', { class: 'zx-view-settings__row', dataset: { fieldId: field.id } },
813
+ h('span', { class: 'zx-view-settings__label' }, highlightMatch(field.label, this._query)),
814
+ this._typeBadge(field.type),
815
+ h('button', {
816
+ class: 'zx-btn zx-view-settings__add',
817
+ type: 'button',
818
+ ariaLabel: add,
819
+ title: add,
820
+ dataset: { kind: 'ghost', size: 'sm', action: 'add' }
821
+ }, icon('plus', { size: 14 }), h('span', {}, this._message('viewSettings.add', 'Add'))));
822
+ }
823
+
824
+ /**
825
+ * The type glyph a row shows before its label. The glyph is decorative; the type name is the
826
+ * element's `title` for the pointer and visually hidden text for everyone else. It follows the
827
+ * label in the source, so a row reads "Owner, Text" — the glyph is placed first by CSS.
828
+ * @param {string|undefined} type Field type.
829
+ * @returns {HTMLElement}
830
+ */
831
+ _typeBadge(type) {
832
+ const name = this._typeLabel(type);
833
+ return h('span', { class: 'zx-view-settings__type', title: name },
834
+ this._typeGlyph(type),
835
+ h('span', { class: 'zx-view-settings__sr' }, `, ${name}`));
836
+ }
837
+
838
+ /** @param {string|undefined} type @returns {Element} The type's decorative glyph. */
839
+ _typeGlyph(type) {
840
+ return icon(resolveFieldType(type, this.options.types).icon, { size: 14 });
841
+ }
842
+
843
+ /** @param {string|undefined} type @returns {string} The translated type name. */
844
+ _typeLabel(type) {
845
+ const resolved = resolveFieldType(type, this.options.types);
846
+ return resolved.key ? this._message(resolved.key, resolved.label) : resolved.label;
847
+ }
848
+
849
+ /** @param {SelectFieldItem} item @returns {HTMLElement} Option content with the type glyph. */
850
+ _optionContent(item) {
851
+ return h('span', { class: 'zx-view-settings__option', title: this._typeLabel(item.type) },
852
+ this._typeGlyph(item.type), h('span', {}, item.name));
853
+ }
854
+
855
+ /** Refreshes the grouping and board controls from state. @returns {void} */
856
+ _syncGroupControls() {
857
+ const groupIds = groupingFieldIds(this._fields, this.options.grouping);
858
+ const boardIds = groupingFieldIds(this._fields,
859
+ this._boardFields ? { fields: this._boardFields } : this.options.grouping);
860
+ const items = (ids) => ids.map((id) => {
861
+ const field = /** @type {ViewSettingsField} */ (this._fields.find((candidate) => candidate.id === id));
862
+ return { ID: field.id, name: field.label, type: field.type };
863
+ });
864
+ if (this._groupSelect) {
865
+ this._groupSelect.setItems(items(groupIds));
866
+ this._groupSelect.set(this._groupBy, { silent: true });
867
+ }
868
+ if (this._board && this._columnSelect && this._laneSelect && this._emptyToggle) {
869
+ this._columnSelect.setItems(items(boardIds));
870
+ this._columnSelect.set(this._board.columnBy, { silent: true });
871
+ this._laneSelect.setItems(items(boardIds.filter((id) => id !== this._board?.columnBy)));
872
+ this._laneSelect.set(this._board.swimlaneBy, { silent: true });
873
+ this._emptyToggle.set(this._board.showEmptyColumns !== false, { silent: true });
874
+ }
875
+ }
876
+
877
+ /* ------------------------------------------------------------------ interaction -- */
878
+
879
+ /** @param {string} query @returns {void} */
880
+ _setQuery(query) {
881
+ if (query === this._query) return;
882
+ this._query = query;
883
+ this._renderFields();
884
+ }
885
+
886
+ /** Adds the first hidden field matching the query; Enter in the search. @returns {void} */
887
+ _addFirstMatch() {
888
+ const first = this.refs.hidden?.querySelector('.zx-view-settings__row');
889
+ const id = /** @type {HTMLElement|null} */ (first)?.dataset.fieldId;
890
+ if (!id) return;
891
+ this._addFromPanel(id, false);
892
+ }
893
+
894
+ /**
895
+ * Shows a field from the hidden list and moves focus somewhere sensible.
896
+ * @param {string} id Field id.
897
+ * @param {boolean} fromButton Whether the Add button was used (focus moves to the next one).
898
+ * @returns {void}
899
+ */
900
+ _addFromPanel(id, fromButton) {
901
+ const rows = [...this.refs.hidden.querySelectorAll('.zx-view-settings__row')];
902
+ const index = rows.findIndex((row) => /** @type {HTMLElement} */ (row).dataset.fieldId === id);
903
+ const following = /** @type {HTMLElement|undefined} */ (rows[index + 1] ?? rows[index - 1])?.dataset.fieldId;
904
+ this.showField(id);
905
+ const field = this._field(id);
906
+ const visible = this._order.filter((candidate) => !this._hidden.has(candidate));
907
+ this._announce(this._message('viewSettings.added', '%1 added at position %2 of %3',
908
+ field?.label ?? id, visible.indexOf(id) + 1, visible.length));
909
+ if (!fromButton) return;
910
+ if (following) this._focusPart({ id: following, part: 'add' });
911
+ else if (this._search) this._search.focus();
912
+ else this._focusPart({ id, part: 'hide' });
913
+ }
914
+
915
+ /** @param {MouseEvent} event @returns {void} */
916
+ _onHiddenClick(event) {
917
+ const button = /** @type {Element|null} */ (event.target)?.closest?.('[data-action="add"]');
918
+ const row = button?.closest('.zx-view-settings__row');
919
+ const id = /** @type {HTMLElement|null} */ (row)?.dataset.fieldId;
920
+ if (id) this._addFromPanel(id, true);
921
+ }
922
+
923
+ /** @param {MouseEvent} event @returns {void} */
924
+ _onShownClick(event) {
925
+ const button = /** @type {Element|null} */ (event.target)?.closest?.('[data-action="hide"]');
926
+ const row = button?.closest('.zx-view-settings__row');
927
+ const id = /** @type {HTMLElement|null} */ (row)?.dataset.fieldId;
928
+ if (!id || !this._canHide(id)) return;
929
+ const visible = this._order.filter((candidate) => !this._hidden.has(candidate));
930
+ const index = visible.indexOf(id);
931
+ const neighbours = [...visible.slice(index + 1), ...visible.slice(0, index).reverse()];
932
+ const next = neighbours.find((candidate) => this._canHideAfter(candidate, id));
933
+ this.hideField(id);
934
+ this._announce(this._message('viewSettings.hiddenField', '%1 hidden', this._field(id)?.label ?? id));
935
+ if (next) this._focusPart({ id: next, part: 'hide' });
936
+ else this.focus();
937
+ }
938
+
939
+ /** @param {KeyboardEvent} event @returns {void} */
940
+ _onHandleKeydown(event) {
941
+ const handle = /** @type {Element|null} */ (event.target)?.closest?.('[data-action="handle"]');
942
+ const id = /** @type {HTMLElement|null} */ (handle?.closest('.zx-view-settings__row'))?.dataset.fieldId;
943
+ if (!id) return;
944
+ const movable = this._movableIds();
945
+ const index = movable.indexOf(id);
946
+ if (index < 0) return;
947
+ const grabbed = this._grab?.id === id;
948
+ let target = null;
949
+ if (event.key === 'ArrowUp') target = index - 1;
950
+ else if (event.key === 'ArrowDown') target = index + 1;
951
+ else if (event.key === 'Home') target = 0;
952
+ else if (event.key === 'End') target = movable.length - 1;
953
+ if (target !== null) {
954
+ event.preventDefault();
955
+ if (target < 0 || target >= movable.length || target === index) return;
956
+ this._moveFromKeyboard(id, target, !grabbed);
957
+ return;
958
+ }
959
+ if (event.key === ' ' || event.key === 'Enter') {
960
+ event.preventDefault();
961
+ if (grabbed) this._drop();
962
+ else this._pickUp(id);
963
+ return;
964
+ }
965
+ if (event.key === 'Escape' && grabbed) {
966
+ event.preventDefault();
967
+ event.stopPropagation();
968
+ this._cancelGrab();
969
+ }
970
+ }
971
+
972
+ /** A picked-up field is dropped where it is when focus leaves it. @param {FocusEvent} event @returns {void} */
973
+ _onHandleFocusout(event) {
974
+ if (this._rendering || !this._grab) return;
975
+ const handle = /** @type {Element|null} */ (event.target)?.closest?.('[data-action="handle"]');
976
+ const id = /** @type {HTMLElement|null} */ (handle?.closest('.zx-view-settings__row'))?.dataset.fieldId;
977
+ // Focus is on its way to `relatedTarget`; the drop redraws the rows, so a destination inside
978
+ // them is looked up again afterwards rather than lost with the old row.
979
+ if (id === this._grab.id) this._drop(this._partOf(/** @type {Element|null} */ (event.relatedTarget)));
980
+ }
981
+
982
+ /**
983
+ * @param {string} id Field id.
984
+ * @param {number} target Target index among movable fields.
985
+ * @param {boolean} commit Whether the move is final (emitted) or part of a pick-up.
986
+ * @returns {void}
987
+ */
988
+ _moveFromKeyboard(id, target, commit) {
989
+ const next = moveVisibleField(this._order, this._hidden, this._lockedIds(), id, target);
990
+ if (sameList(next, this._order)) return;
991
+ this._order = next;
992
+ this._renderFields({ id, part: 'handle' }, { keepGrab: !commit });
993
+ this._announceMove(id);
994
+ if (commit) {
995
+ this._pushToView();
996
+ this._emitOrder();
997
+ }
998
+ }
999
+
1000
+ /** @param {string} id @returns {void} */
1001
+ _pickUp(id) {
1002
+ this._grab = { id, original: [...this._order] };
1003
+ this._renderFields({ id, part: 'handle' }, { keepGrab: true });
1004
+ const { position, total } = this._position(id);
1005
+ this._announce(this._message('viewSettings.grabbed',
1006
+ '%1 picked up, position %2 of %3. Use the arrow keys to move it, Space to drop it, Escape to cancel.',
1007
+ this._field(id)?.label ?? id, position, total));
1008
+ }
1009
+
1010
+ /**
1011
+ * @param {{id: string, part: 'handle'|'hide'|'add'}|null} [focus] Control to focus afterwards;
1012
+ * omitted keeps focus on the dropped row's handle when it had it.
1013
+ * @returns {void}
1014
+ */
1015
+ _drop(focus) {
1016
+ const grab = this._grab;
1017
+ if (!grab) return;
1018
+ this._grab = null;
1019
+ const changed = !sameList(grab.original, this._order);
1020
+ const focused = this._row(grab.id)?.contains(this.el.ownerDocument?.activeElement ?? null);
1021
+ this._renderFields(focus !== undefined ? focus : focused ? { id: grab.id, part: 'handle' } : null);
1022
+ const { position, total } = this._position(grab.id);
1023
+ this._announce(this._message('viewSettings.dropped', '%1 dropped at position %2 of %3',
1024
+ this._field(grab.id)?.label ?? grab.id, position, total));
1025
+ if (changed) {
1026
+ this._pushToView();
1027
+ this._emitOrder();
1028
+ }
1029
+ }
1030
+
1031
+ /** @returns {void} */
1032
+ _cancelGrab() {
1033
+ const grab = this._grab;
1034
+ if (!grab) return;
1035
+ this._grab = null;
1036
+ this._order = grab.original;
1037
+ this._renderFields({ id: grab.id, part: 'handle' });
1038
+ this._announceCanceled(grab.id);
1039
+ }
1040
+
1041
+ /**
1042
+ * Ends whatever reordering is in progress before state changes from outside: a keyboard
1043
+ * pick-up returns the row to where it was, and a pointer drag is dropped nowhere.
1044
+ * @returns {void}
1045
+ */
1046
+ _settle() {
1047
+ if (this._drag) this._cancelDrag(true);
1048
+ if (this._grab) this._cancelGrab();
1049
+ }
1050
+
1051
+ /** @param {string} id Field whose move was canceled. @returns {void} */
1052
+ _announceCanceled(id) {
1053
+ const { position, total } = this._position(id);
1054
+ this._announce(this._message('viewSettings.canceled', 'Move canceled. %1 is back at position %2 of %3',
1055
+ this._field(id)?.label ?? id, position, total));
1056
+ }
1057
+
1058
+ /** @param {PointerEvent} event @returns {void} */
1059
+ _onPointerDown(event) {
1060
+ if (event.button !== 0 || this._drag) return;
1061
+ let handle = /** @type {Element|null} */ (event.target)?.closest?.('[data-action="handle"]');
1062
+ let row = /** @type {HTMLElement|null} */ (handle?.closest('.zx-view-settings__row') ?? null);
1063
+ const id = row?.dataset.fieldId;
1064
+ if (!handle || !row || !id) return;
1065
+ if (this._grab) {
1066
+ // Dropping a keyboard pick-up rebuilds the rows, so the pressed handle is looked up again.
1067
+ this._drop();
1068
+ row = this._row(id);
1069
+ handle = row?.querySelector('[data-action="handle"]') ?? null;
1070
+ if (!row || !handle) return;
1071
+ }
1072
+ this._drag = {
1073
+ id, pointerId: event.pointerId, startX: event.clientX, startY: event.clientY, y: event.clientY,
1074
+ active: false, ending: false, handle: /** @type {HTMLElement} */ (handle), row,
1075
+ target: null, scroller: null, startScroll: 0, lastScroll: 0, frame: 0
1076
+ };
1077
+ try {
1078
+ /** @type {HTMLElement} */ (handle).setPointerCapture(event.pointerId);
1079
+ } catch {
1080
+ // A pointer that already ended cannot be captured; the press finishes on its pointerup.
1081
+ }
1082
+ }
1083
+
1084
+ /** @param {PointerEvent} event @returns {void} */
1085
+ _onPointerMove(event) {
1086
+ const drag = this._drag;
1087
+ if (!drag || event.pointerId !== drag.pointerId) return;
1088
+ // A row redrawn out from under the pointer is a drag that can no longer land anywhere.
1089
+ if (!drag.row.isConnected) {
1090
+ this._cancelDrag();
1091
+ return;
1092
+ }
1093
+ drag.y = event.clientY;
1094
+ if (!drag.active) {
1095
+ if (Math.hypot(event.clientX - drag.startX, event.clientY - drag.startY) <= DRAG_THRESHOLD) return;
1096
+ this._beginDrag(drag);
1097
+ }
1098
+ event.preventDefault();
1099
+ this._positionDrag(drag);
1100
+ }
1101
+
1102
+ /** @param {PointerEvent} event @returns {void} */
1103
+ _onPointerUp(event) {
1104
+ const drag = this._drag;
1105
+ if (!drag || event.pointerId !== drag.pointerId) return;
1106
+ if (!drag.active || !drag.row.isConnected) {
1107
+ this._endDrag(drag);
1108
+ return;
1109
+ }
1110
+ event.preventDefault();
1111
+ drag.y = event.clientY;
1112
+ this._positionDrag(drag);
1113
+ const target = drag.target;
1114
+ this._endDrag(drag);
1115
+ const next = target === null ? this._order
1116
+ : moveVisibleField(this._order, this._hidden, this._lockedIds(), drag.id, target);
1117
+ if (sameList(next, this._order)) {
1118
+ this._renderFields({ id: drag.id, part: 'handle' });
1119
+ return;
1120
+ }
1121
+ this._order = next;
1122
+ this._renderFields({ id: drag.id, part: 'handle' });
1123
+ this._announceMove(drag.id);
1124
+ this._pushToView();
1125
+ this._emitOrder();
1126
+ }
1127
+
1128
+ /**
1129
+ * Starts a pointer drag: the dragged row follows the pointer, and a frame loop scrolls the
1130
+ * nearest scroll container while the pointer rests near its top or bottom edge.
1131
+ * @param {DragState} drag Press being promoted.
1132
+ * @returns {void}
1133
+ */
1134
+ _beginDrag(drag) {
1135
+ drag.active = true;
1136
+ drag.row.dataset.dragging = 'true';
1137
+ this.el.setAttribute('data-dragging', 'true');
1138
+ this.el.ownerDocument?.getSelection?.()?.removeAllRanges();
1139
+ drag.scroller = scrollContainer(this.refs.list);
1140
+ drag.startScroll = drag.scroller.scrollTop;
1141
+ drag.lastScroll = drag.startScroll;
1142
+ this._scheduleAutoScroll(drag);
1143
+ const { position, total } = this._position(drag.id);
1144
+ this._announce(this._message('viewSettings.dragging', '%1 picked up, position %2 of %3',
1145
+ this._field(drag.id)?.label ?? drag.id, position, total));
1146
+ }
1147
+
1148
+ /** @param {DragState} drag Active drag. @returns {void} */
1149
+ _scheduleAutoScroll(drag) {
1150
+ drag.frame = requestAnimationFrame(() => {
1151
+ drag.frame = 0;
1152
+ if (this._drag !== drag || !drag.active || !drag.scroller) return;
1153
+ const delta = autoScrollDelta(drag.y, scrollEdges(drag.scroller),
1154
+ this.refs.list.getBoundingClientRect(), SCROLL_EDGE, SCROLL_STEP);
1155
+ if (delta !== 0) drag.scroller.scrollTop += delta;
1156
+ // Also catches a wheel scroll the reader made mid-drag, which moves the rows under a pointer
1157
+ // that has not moved.
1158
+ if (drag.scroller.scrollTop !== drag.lastScroll) this._positionDrag(drag);
1159
+ this._scheduleAutoScroll(drag);
1160
+ });
1161
+ }
1162
+
1163
+ /**
1164
+ * Moves the dragged row under the pointer — compensating for any scroll since the drag began —
1165
+ * and updates the insertion line.
1166
+ * @param {DragState} drag Active drag.
1167
+ * @returns {void}
1168
+ */
1169
+ _positionDrag(drag) {
1170
+ const scroll = drag.scroller ? drag.scroller.scrollTop : 0;
1171
+ drag.lastScroll = scroll;
1172
+ const offset = drag.y - drag.startY + (scroll - drag.startScroll);
1173
+ drag.row.style.transform = `translateY(${Math.round(offset)}px)`;
1174
+ this._updateDropTarget(drag, drag.y);
1175
+ }
1176
+
1177
+ /**
1178
+ * Resolves and marks the insertion point. The other rows are measured on every call rather than
1179
+ * once at the start, so a list scrolled mid-drag still drops where the line says.
1180
+ * @param {DragState} drag Active drag.
1181
+ * @param {number} y Pointer client y.
1182
+ * @returns {void}
1183
+ */
1184
+ _updateDropTarget(drag, y) {
1185
+ const frame = this.refs.frame.getBoundingClientRect();
1186
+ const slots = [...this.refs.list.children]
1187
+ .filter((row) => row !== drag.row)
1188
+ .map((row) => {
1189
+ const rect = row.getBoundingClientRect();
1190
+ return {
1191
+ id: /** @type {HTMLElement} */ (row).dataset.fieldId ?? '',
1192
+ top: rect.top - frame.top,
1193
+ bottom: rect.bottom - frame.top,
1194
+ middle: rect.top + rect.height / 2
1195
+ };
1196
+ });
1197
+ const gap = dropGapIndex(slots.map((slot) => slot.middle), y);
1198
+ const locked = this._lockedIds();
1199
+ const target = slots.slice(0, gap).filter((slot) => !locked.includes(slot.id)).length;
1200
+ drag.target = target;
1201
+ // Where the field would land, which locked fields can make differ from the gap under the pointer.
1202
+ const next = moveVisibleField(this._order, this._hidden, locked, drag.id, target)
1203
+ .filter((id) => !this._hidden.has(id));
1204
+ const after = next[next.indexOf(drag.id) + 1];
1205
+ const slot = slots.find((candidate) => candidate.id === after);
1206
+ const last = slots.at(-1);
1207
+ const indicator = /** @type {HTMLElement} */ (this.refs.indicator);
1208
+ const top = slot ? slot.top : last ? last.bottom : 0;
1209
+ indicator.style.insetBlockStart = `${Math.round(top)}px`;
1210
+ indicator.hidden = false;
1211
+ }
1212
+
1213
+ /** @param {DragState} drag @returns {void} */
1214
+ _endDrag(drag) {
1215
+ drag.ending = true;
1216
+ if (drag.frame) cancelAnimationFrame(drag.frame);
1217
+ drag.frame = 0;
1218
+ try {
1219
+ drag.handle.releasePointerCapture(drag.pointerId);
1220
+ } catch {
1221
+ // Already released when the pointer was cancelled or the node detached.
1222
+ }
1223
+ drag.row.style.transform = '';
1224
+ delete drag.row.dataset.dragging;
1225
+ this.el.removeAttribute('data-dragging');
1226
+ const indicator = /** @type {HTMLElement|undefined} */ (this.refs.indicator);
1227
+ if (indicator) indicator.hidden = true;
1228
+ if (this._drag === drag) this._drag = null;
1229
+ }
1230
+
1231
+ /** @param {boolean} [announce=false] Whether to say so. @returns {void} */
1232
+ _cancelDrag(announce = false) {
1233
+ const drag = this._drag;
1234
+ if (!drag) return;
1235
+ const wasActive = drag.active;
1236
+ this._endDrag(drag);
1237
+ if (!wasActive) return;
1238
+ if (announce) {
1239
+ const { position, total } = this._position(drag.id);
1240
+ this._announce(this._message('viewSettings.canceled', 'Move canceled. %1 is back at position %2 of %3',
1241
+ this._field(drag.id)?.label ?? drag.id, position, total));
1242
+ }
1243
+ }
1244
+
1245
+ /* ------------------------------------------------------------------ state and events -- */
1246
+
1247
+ /** @param {string|null} field @returns {void} */
1248
+ _setGroup(field) {
1249
+ if (field === this._groupBy) return;
1250
+ this._groupBy = field;
1251
+ this._emitGroup();
1252
+ }
1253
+
1254
+ /** @param {Partial<ViewSettingsBoard>} patch @returns {void} */
1255
+ _setBoard(patch) {
1256
+ if (!this._board) return;
1257
+ const ids = this._fields.map((field) => field.id);
1258
+ const next = normalizeBoard({ ...this._board, ...patch }, ids);
1259
+ if (sameBoard(next, this._board)) return;
1260
+ this._board = next;
1261
+ this._syncGroupControls();
1262
+ this._emitBoard();
1263
+ }
1264
+
1265
+ /** @param {boolean} [withChange=true] Whether `change` follows. @returns {void} */
1266
+ _emitOrder(withChange = true) {
1267
+ this.emit('fieldorderchange', { order: [...this._order] });
1268
+ if (withChange) this._emitChange('order');
1269
+ }
1270
+
1271
+ /** @returns {void} */
1272
+ _emitVisibility() {
1273
+ const state = this.getState();
1274
+ this.emit('fieldvisibilitychange', {
1275
+ visible: state.fieldOrder.filter((id) => !this._hidden.has(id)),
1276
+ hidden: state.hiddenFields
1277
+ });
1278
+ this._emitChange('visibility');
1279
+ }
1280
+
1281
+ /** @returns {void} */
1282
+ _emitGroup() {
1283
+ this.emit('groupchange', { field: this._groupBy });
1284
+ this._emitChange('group');
1285
+ }
1286
+
1287
+ /** @returns {void} */
1288
+ _emitBoard() {
1289
+ if (!this._board) return;
1290
+ this.emit('boardchange', { board: { ...this._board } });
1291
+ this._emitChange('board');
1292
+ }
1293
+
1294
+ /** @param {ViewSettingsChangeReason} reason @returns {void} */
1295
+ _emitChange(reason) {
1296
+ this.emit('change', { state: this.getState(), reason });
1297
+ }
1298
+
1299
+ /** Pushes order and visibility into the bound view. @returns {void} */
1300
+ _pushToView() {
1301
+ const bound = this._bound;
1302
+ if (!bound) return;
1303
+ const view = bound.view;
1304
+ this._syncing = true;
1305
+ try {
1306
+ const known = new Set((view.getFieldOrder?.() ?? []).map(String));
1307
+ const order = this._order.filter((id) => known.size === 0 || known.has(id));
1308
+ view.setFieldOrder(order);
1309
+ const viewHidden = new Set((view.getHiddenFields() ?? []).map(String));
1310
+ // Shown first, hidden second: a view refuses to hide its last visible field, and hiding
1311
+ // before showing could momentarily be exactly that.
1312
+ for (const id of order) if (!this._hidden.has(id) && viewHidden.has(id)) view.setFieldVisible(id, true);
1313
+ for (const id of order) if (this._hidden.has(id) && !viewHidden.has(id)) view.setFieldVisible(id, false);
1314
+ } finally {
1315
+ this._syncing = false;
1316
+ }
1317
+ }
1318
+
1319
+ /**
1320
+ * Reads fields, order, and visibility from the bound view. Silent: the view reported its own
1321
+ * change already, and echoing it from here would hand the host the same change twice.
1322
+ * @param {boolean} withFields Whether the field descriptors are re-read too.
1323
+ * @returns {void}
1324
+ */
1325
+ _pullFromView(withFields) {
1326
+ const bound = this._bound;
1327
+ if (!bound) return;
1328
+ const view = bound.view;
1329
+ this._settle();
1330
+ if (withFields) {
1331
+ const own = new Map(this._fields.map((field) => [field.id, field]));
1332
+ // A panel given its own fields lists those and no others; one given none lists the view's.
1333
+ const fields = /** @type {ViewSettingsField[]} */ (view.getFields())
1334
+ .filter((field) => this._ownIds.size === 0 || this._ownIds.has(String(field.id)))
1335
+ .map((field) => mergeViewField(own.get(String(field.id)) ?? null, field));
1336
+ this._fields = normalizeViewSettingsFields(fields);
1337
+ this._syncSearch();
1338
+ }
1339
+ this._order = reconcileOrder(this._fields, view.getFieldOrder?.() ?? this._order);
1340
+ // The view's visibility is mirrored as it is, locked fields included: `locked` is a promise
1341
+ // about what this panel offers, and the view has no such notion.
1342
+ this._hidden = normalizeHidden(this._fields, view.getHiddenFields(), false, true);
1343
+ const ids = this._fields.map((field) => field.id);
1344
+ this._groupBy = normalizeFieldId(this._groupBy, ids);
1345
+ if (this._board) this._board = normalizeBoard(this._board, ids);
1346
+ this._renderFields();
1347
+ if (withFields) this._syncGroupControls();
1348
+ }
1349
+
1350
+ /* ------------------------------------------------------------------ helpers -- */
1351
+
1352
+ /** @returns {string[]} Locked field ids. */
1353
+ _lockedIds() {
1354
+ return this._fields.filter((field) => field.locked).map((field) => field.id);
1355
+ }
1356
+
1357
+ /** @returns {string[]} Shown, movable field ids in order. */
1358
+ _movableIds() {
1359
+ const locked = new Set(this._lockedIds());
1360
+ return this._order.filter((id) => !this._hidden.has(id) && !locked.has(id));
1361
+ }
1362
+
1363
+ /** @param {string} id @returns {ViewSettingsField|undefined} */
1364
+ _field(id) {
1365
+ return this._fields.find((field) => field.id === id);
1366
+ }
1367
+
1368
+ /** @param {string} id @returns {boolean} Whether the field may be hidden now. */
1369
+ _canHide(id) {
1370
+ const field = this._field(id);
1371
+ if (!field || field.locked || this._hidden.has(id)) return false;
1372
+ return this._order.filter((candidate) => !this._hidden.has(candidate)).length > 1;
1373
+ }
1374
+
1375
+ /**
1376
+ * Whether `candidate` keeps a hide button once `removed` is hidden.
1377
+ * @param {string} candidate Field id.
1378
+ * @param {string} removed Field being hidden.
1379
+ * @returns {boolean}
1380
+ */
1381
+ _canHideAfter(candidate, removed) {
1382
+ const field = this._field(candidate);
1383
+ if (!field || field.locked) return false;
1384
+ return this._order.filter((id) => !this._hidden.has(id) && id !== removed).length > 1;
1385
+ }
1386
+
1387
+ /** @param {string} id @returns {HTMLElement|null} The shown row for a field. */
1388
+ _row(id) {
1389
+ return /** @type {HTMLElement|undefined} */ ([...(this.refs.list?.children ?? [])]
1390
+ .find((row) => /** @type {HTMLElement} */ (row).dataset.fieldId === id)) ?? null;
1391
+ }
1392
+
1393
+ /** @param {string} id @returns {{position: number, total: number}} 1-based place in the shown list. */
1394
+ _position(id) {
1395
+ const visible = this._order.filter((candidate) => !this._hidden.has(candidate));
1396
+ return { position: visible.indexOf(id) + 1, total: visible.length };
1397
+ }
1398
+
1399
+ /** @param {string} id @returns {void} */
1400
+ _announceMove(id) {
1401
+ const { position, total } = this._position(id);
1402
+ this._announce(this._message('viewSettings.moved', '%1 moved to position %2 of %3',
1403
+ this._field(id)?.label ?? id, position, total));
1404
+ }
1405
+
1406
+ /** @returns {{id: string, part: 'handle'|'hide'|'add'}|null} The focused row control, if any. */
1407
+ _activePart() {
1408
+ return this._partOf(this.el.ownerDocument?.activeElement ?? null);
1409
+ }
1410
+
1411
+ /**
1412
+ * @param {Element|null} element A focus target.
1413
+ * @returns {{id: string, part: 'handle'|'hide'|'add'}|null} The row control it is, if any.
1414
+ */
1415
+ _partOf(element) {
1416
+ const active = /** @type {HTMLElement|null} */ (element);
1417
+ if (!active || !this.el.contains(active)) return null;
1418
+ const part = active.dataset?.action;
1419
+ const id = /** @type {HTMLElement|null} */ (active.closest('.zx-view-settings__row'))?.dataset.fieldId;
1420
+ if (!id || (part !== 'handle' && part !== 'hide' && part !== 'add')) return null;
1421
+ return { id, part };
1422
+ }
1423
+
1424
+ /** @param {{id: string, part: 'handle'|'hide'|'add'}} focus @returns {void} */
1425
+ _focusPart(focus) {
1426
+ const scope = focus.part === 'add' ? this.refs.hidden : this.refs.list;
1427
+ const row = [...(scope?.children ?? [])]
1428
+ .find((candidate) => /** @type {HTMLElement} */ (candidate).dataset.fieldId === focus.id);
1429
+ let control = /** @type {HTMLElement|null} */ (row?.querySelector(`[data-action="${focus.part}"]`) ?? null);
1430
+ if (control && /** @type {HTMLButtonElement} */ (control).disabled) control = null;
1431
+ if (!control && row) control = /** @type {HTMLElement|null} */ (row.querySelector('button:not(:disabled)'));
1432
+ control?.focus();
1433
+ }
1434
+
1435
+ /** @param {string} message @returns {void} */
1436
+ _announce(message) {
1437
+ const status = this.refs.status;
1438
+ if (!status) return;
1439
+ const token = ++this._announceToken;
1440
+ status.textContent = '';
1441
+ queueMicrotask(() => {
1442
+ if (token === this._announceToken && status.isConnected) status.textContent = message;
1443
+ });
1444
+ }
1445
+
1446
+ /**
1447
+ * Resolves a message through the host translator, falling back to the built-in English text.
1448
+ * @param {string} key Message key.
1449
+ * @param {string} fallback Built-in text, with `%1`-style placeholders.
1450
+ * @param {...unknown} args Interpolation values.
1451
+ * @returns {string}
1452
+ */
1453
+ _message(key, fallback, ...args) {
1454
+ const message = this.msg(key, ...args);
1455
+ return message === key ? printf(fallback, args) : message;
1456
+ }
1457
+ }
1458
+
1459
+ /**
1460
+ * Any change the reader made, after the specific event.
1461
+ * @event ViewSettings#change
1462
+ * @type {CustomEvent<{state: ViewSettingsState, reason: ViewSettingsChangeReason}>}
1463
+ */
1464
+
1465
+ /**
1466
+ * The field order changed.
1467
+ * @event ViewSettings#fieldorderchange
1468
+ * @type {CustomEvent<{order: string[]}>}
1469
+ */
1470
+
1471
+ /**
1472
+ * A field was added or hidden.
1473
+ * @event ViewSettings#fieldvisibilitychange
1474
+ * @type {CustomEvent<{visible: string[], hidden: string[]}>}
1475
+ */
1476
+
1477
+ /**
1478
+ * The grouping field changed; null is no grouping.
1479
+ * @event ViewSettings#groupchange
1480
+ * @type {CustomEvent<{field: string|null}>}
1481
+ */
1482
+
1483
+ /**
1484
+ * A board setting changed.
1485
+ * @event ViewSettings#boardchange
1486
+ * @type {CustomEvent<{board: ViewSettingsBoard}>}
1487
+ */
1488
+
1489
+ /**
1490
+ * @typedef {Object} DragState
1491
+ * @property {string} id Field being dragged.
1492
+ * @property {number} pointerId Pointer that pressed the handle.
1493
+ * @property {number} startX Pointer-down client x.
1494
+ * @property {number} startY Pointer-down client y.
1495
+ * @property {boolean} active Whether the press has travelled far enough to be a drag.
1496
+ * @property {boolean} ending Whether the drag is being torn down.
1497
+ * @property {HTMLElement} handle Captured handle.
1498
+ * @property {number} y Latest pointer client y.
1499
+ * @property {HTMLElement} row Dragged row.
1500
+ * @property {number|null} target Target index among the movable fields.
1501
+ * @property {Element|null} scroller Nearest scroll container of the list.
1502
+ * @property {number} startScroll Its scroll position when the drag began.
1503
+ * @property {number} lastScroll Its scroll position when the row was last placed.
1504
+ * @property {number} frame Pending auto-scroll frame, or 0.
1505
+ */
1506
+
1507
+ /** @typedef {{ID: string, name: string, type: string}} SelectFieldItem */
1508
+
1509
+ /* -------------------------------------------------------------------- pure helpers -- */
1510
+
1511
+ /**
1512
+ * Validates and clones field descriptors: ids become trimmed strings, a missing label is derived
1513
+ * from the id, `type` defaults to `'text'`, and `locked` and `groupable` are read as booleans.
1514
+ * @param {ViewSettingsField[]} fields Raw descriptors.
1515
+ * @returns {ViewSettingsField[]}
1516
+ */
1517
+ export function normalizeViewSettingsFields(fields) {
1518
+ if (fields == null) return [];
1519
+ if (!Array.isArray(fields)) throw new TypeError('ViewSettings fields must be an array');
1520
+ const seen = new Set();
1521
+ /** @type {ViewSettingsField[]} */
1522
+ const normalized = [];
1523
+ fields.forEach((field, index) => {
1524
+ if (!field || typeof field !== 'object' || Array.isArray(field)) {
1525
+ throw new TypeError(`ViewSettings field at index ${index} must be an object`);
1526
+ }
1527
+ const id = String(field.id ?? '').trim();
1528
+ if (!id) throw new TypeError(`ViewSettings field at index ${index} requires an id`);
1529
+ if (seen.has(id)) throw new TypeError(`Duplicate ViewSettings field: ${id}`);
1530
+ seen.add(id);
1531
+ /** @type {ViewSettingsField} */
1532
+ const entry = {
1533
+ ...field,
1534
+ id,
1535
+ label: String(field.label ?? humanize(id)),
1536
+ type: String(field.type ?? 'text').trim() || 'text',
1537
+ locked: Boolean(field.locked)
1538
+ };
1539
+ if (field.groupable !== undefined) entry.groupable = Boolean(field.groupable);
1540
+ normalized.push(entry);
1541
+ });
1542
+ return normalized;
1543
+ }
1544
+
1545
+ /**
1546
+ * Resolves a field type to its glyph and English name, through the host's `types` overrides, the
1547
+ * alias table (`int` → number, `enum` → select, …), and the built-in map. An unknown type keeps
1548
+ * its own name and the text glyph, so a server's new type still shows something sensible.
1549
+ * @param {string|null|undefined} type Field type.
1550
+ * @param {Record<string, {icon?: string, label?: string}>|null} [overrides=null] Host overrides.
1551
+ * @returns {{type: string, icon: string, label: string, key: string|null}} `key` is the message
1552
+ * key of a built-in label, null for a host-supplied or derived one.
1553
+ */
1554
+ export function resolveFieldType(type, overrides = null) {
1555
+ const raw = String(type ?? '').trim() || 'text';
1556
+ const id = Object.hasOwn(TYPE_ALIASES, raw) ? TYPE_ALIASES[raw] : raw;
1557
+ const builtin = Object.hasOwn(FIELD_TYPES, id) ? FIELD_TYPES[id] : null;
1558
+ const custom = overrides && typeof overrides === 'object'
1559
+ ? (Object.hasOwn(overrides, raw) ? overrides[raw] : Object.hasOwn(overrides, id) ? overrides[id] : null)
1560
+ : null;
1561
+ const resolvedType = builtin ? id : raw;
1562
+ const glyph = String(custom?.icon ?? builtin?.icon ?? 'type');
1563
+ if (custom?.label != null) return { type: resolvedType, icon: glyph, label: String(custom.label), key: null };
1564
+ if (builtin) return { type: resolvedType, icon: glyph, label: builtin.label, key: builtin.key };
1565
+ return { type: resolvedType, icon: glyph, label: humanize(raw), key: null };
1566
+ }
1567
+
1568
+ /**
1569
+ * Whether a type sorts records into a few named buckets — what grouping, board columns, and swim
1570
+ * lanes need: text, select (and enum), status, priority, boolean, and entity.
1571
+ * @param {string|null|undefined} type Field type.
1572
+ * @returns {boolean}
1573
+ */
1574
+ export function isDiscreteFieldType(type) {
1575
+ const raw = String(type ?? '').trim() || 'text';
1576
+ return DISCRETE_TYPES.has(Object.hasOwn(TYPE_ALIASES, raw) ? TYPE_ALIASES[raw] : raw);
1577
+ }
1578
+
1579
+ /**
1580
+ * The field ids offered for grouping. `grouping.fields` restricts and orders them; otherwise every
1581
+ * field is offered whose `groupable` is `true`, or is not `false` and whose type is discrete.
1582
+ * @param {ViewSettingsField[]} fields Normalized fields.
1583
+ * @param {boolean|ViewSettingsGrouping|null|undefined} grouping The `grouping` option.
1584
+ * @returns {string[]}
1585
+ */
1586
+ export function groupingFieldIds(fields, grouping) {
1587
+ if (grouping && typeof grouping === 'object' && Array.isArray(grouping.fields)) {
1588
+ const known = new Set(fields.map((field) => field.id));
1589
+ return [...new Set(grouping.fields.map(String))].filter((id) => known.has(id));
1590
+ }
1591
+ return fields
1592
+ .filter((field) => field.groupable === true || (field.groupable !== false && isDiscreteFieldType(field.type)))
1593
+ .map((field) => field.id);
1594
+ }
1595
+
1596
+ /**
1597
+ * Whether a field matches a search query. Case and accents are ignored, every word must appear,
1598
+ * and the label, the id, and the type name are all searched — "date" finds every date field.
1599
+ * @param {{id: string, label?: string}} field Field.
1600
+ * @param {string} query Search text.
1601
+ * @param {string} [typeLabel=''] The field's type name.
1602
+ * @returns {boolean}
1603
+ */
1604
+ export function matchesFieldQuery(field, query, typeLabel = '') {
1605
+ const terms = fold(query).split(/\s+/).filter(Boolean);
1606
+ if (terms.length === 0) return true;
1607
+ const haystack = fold(`${field.label ?? ''} ${field.id} ${typeLabel}`);
1608
+ return terms.every((term) => haystack.includes(term));
1609
+ }
1610
+
1611
+ /**
1612
+ * Moves one shown field to a position among the movable shown fields. Locked fields keep their
1613
+ * slots and hidden fields keep their place in the full order; the result is a new full order.
1614
+ * An unknown, hidden, or locked id returns an unchanged copy.
1615
+ * @param {string[]} order Full field order.
1616
+ * @param {Iterable<string>} hidden Hidden field ids.
1617
+ * @param {Iterable<string>} locked Locked field ids.
1618
+ * @param {string} id Field to move.
1619
+ * @param {number} index Target position among the movable shown fields; clamped.
1620
+ * @returns {string[]}
1621
+ */
1622
+ export function moveVisibleField(order, hidden, locked, id, index) {
1623
+ const hiddenSet = new Set(hidden);
1624
+ const lockedSet = new Set(locked);
1625
+ const visible = order.filter((candidate) => !hiddenSet.has(candidate));
1626
+ const movable = visible.filter((candidate) => !lockedSet.has(candidate));
1627
+ const from = movable.indexOf(id);
1628
+ if (from < 0) return [...order];
1629
+ const requested = Number.isFinite(Number(index)) ? Math.round(Number(index)) : from;
1630
+ const target = Math.max(0, Math.min(movable.length - 1, requested));
1631
+ if (target === from) return [...order];
1632
+ movable.splice(from, 1);
1633
+ movable.splice(target, 0, id);
1634
+ let next = 0;
1635
+ const nextVisible = visible.map((candidate) => (lockedSet.has(candidate) ? candidate : movable[next++]));
1636
+ let slot = 0;
1637
+ return order.map((candidate) => (hiddenSet.has(candidate) ? candidate : nextVisible[slot++]));
1638
+ }
1639
+
1640
+ /**
1641
+ * Shows a hidden field at the end of the shown fields: it leaves the hidden set and moves to just
1642
+ * after the last shown field, so "Add" puts it where the reader is looking.
1643
+ * @param {string[]} order Full field order.
1644
+ * @param {Iterable<string>} hidden Hidden field ids.
1645
+ * @param {string} id Field to show.
1646
+ * @returns {{order: string[], hidden: string[]}}
1647
+ */
1648
+ export function showFieldInOrder(order, hidden, id) {
1649
+ const hiddenSet = new Set(hidden);
1650
+ if (!order.includes(id) || !hiddenSet.has(id)) return { order: [...order], hidden: [...hiddenSet] };
1651
+ hiddenSet.delete(id);
1652
+ const rest = order.filter((candidate) => candidate !== id);
1653
+ let last = -1;
1654
+ rest.forEach((candidate, index) => {
1655
+ if (!hiddenSet.has(candidate)) last = index;
1656
+ });
1657
+ rest.splice(last + 1, 0, id);
1658
+ return { order: rest, hidden: rest.filter((candidate) => hiddenSet.has(candidate)) };
1659
+ }
1660
+
1661
+ /**
1662
+ * The gap a pointer is over, given the vertical midpoints of the rows: 0 is above the first row,
1663
+ * `middles.length` below the last.
1664
+ * @param {number[]} middles Row midpoints, top to bottom.
1665
+ * @param {number} y Pointer position.
1666
+ * @returns {number}
1667
+ */
1668
+ export function dropGapIndex(middles, y) {
1669
+ const index = middles.findIndex((middle) => y < middle);
1670
+ return index < 0 ? middles.length : index;
1671
+ }
1672
+
1673
+ /**
1674
+ * Merges a bound view's field with the panel's own descriptor for it. The view's label and type
1675
+ * win; the panel's descriptor supplies only what the view lacks — `locked`, `groupable`, and a
1676
+ * type where the view reports just the `'text'` default a record view fills in for none.
1677
+ * @param {ViewSettingsField|null} own The panel's descriptor, if it has one.
1678
+ * @param {ViewSettingsField} field The view's descriptor.
1679
+ * @returns {ViewSettingsField}
1680
+ */
1681
+ export function mergeViewField(own, field) {
1682
+ if (!own) return { ...field };
1683
+ const viewType = field.type == null ? '' : String(field.type).trim();
1684
+ const type = viewType && viewType !== 'text' ? viewType : own.type ?? (viewType || 'text');
1685
+ return { ...own, ...field, label: field.label ?? own.label, type };
1686
+ }
1687
+
1688
+ /**
1689
+ * How far a drag should scroll its container this frame: up when the pointer is within `edge`
1690
+ * pixels of the top and the list continues above, down likewise at the bottom, otherwise 0.
1691
+ * @param {number} y Pointer client y.
1692
+ * @param {{top: number, bottom: number}} edges The container's visible top and bottom.
1693
+ * @param {{top: number, bottom: number}} list The list's client rectangle.
1694
+ * @param {number} [edge=32] Width of the edge band in pixels.
1695
+ * @param {number} [step=12] Pixels per frame.
1696
+ * @returns {number}
1697
+ */
1698
+ export function autoScrollDelta(y, edges, list, edge = SCROLL_EDGE, step = SCROLL_STEP) {
1699
+ if (y < edges.top + edge && list.top < edges.top) return -step;
1700
+ if (y > edges.bottom - edge && list.bottom > edges.bottom) return step;
1701
+ return 0;
1702
+ }
1703
+
1704
+ /**
1705
+ * The nearest ancestor that scrolls vertically, or the document's scrolling element.
1706
+ * @param {Element} element Start element.
1707
+ * @returns {Element}
1708
+ */
1709
+ function scrollContainer(element) {
1710
+ const documentScroller = element.ownerDocument.scrollingElement ?? element.ownerDocument.documentElement;
1711
+ let node = element.parentElement;
1712
+ while (node && node !== documentScroller && node !== element.ownerDocument.body) {
1713
+ const overflow = getComputedStyle(node).overflowY;
1714
+ if (/(auto|scroll|overlay)/.test(overflow) && node.scrollHeight > node.clientHeight) return node;
1715
+ node = node.parentElement;
1716
+ }
1717
+ return documentScroller;
1718
+ }
1719
+
1720
+ /** @param {Element} scroller @returns {{top: number, bottom: number}} Its visible top and bottom. */
1721
+ function scrollEdges(scroller) {
1722
+ const view = scroller.ownerDocument.defaultView;
1723
+ if (scroller === scroller.ownerDocument.scrollingElement || scroller === scroller.ownerDocument.documentElement) {
1724
+ return { top: 0, bottom: view?.innerHeight ?? scroller.clientHeight };
1725
+ }
1726
+ const rect = scroller.getBoundingClientRect();
1727
+ return { top: rect.top, bottom: rect.bottom };
1728
+ }
1729
+
1730
+ /**
1731
+ * Resolves the sections to render, in order: the `sections` option (or the default order) with
1732
+ * unknown and duplicate names dropped, and the grouping or board section removed when its option
1733
+ * is `false`.
1734
+ * @param {unknown} sections The `sections` option.
1735
+ * @param {{grouping?: unknown, board?: unknown}} options The `grouping` and `board` options.
1736
+ * @returns {ViewSettingsSection[]}
1737
+ */
1738
+ export function normalizeSections(sections, options = {}) {
1739
+ const requested = Array.isArray(sections) ? sections.map(String) : [...SECTION_IDS];
1740
+ const result = /** @type {ViewSettingsSection[]} */ ([...new Set(requested)]
1741
+ .filter((id) => SECTION_IDS.includes(id)));
1742
+ return result.filter((id) => {
1743
+ if (id === 'grouping') return options.grouping !== false && options.grouping != null;
1744
+ if (id === 'board') return Boolean(options.board) && typeof options.board === 'object';
1745
+ return true;
1746
+ });
1747
+ }
1748
+
1749
+ /**
1750
+ * Normalizes board settings. `false` and null mean no board. With `ids`, a field that does not
1751
+ * exist becomes null, and a swim-lane field equal to the column field is dropped.
1752
+ * @param {unknown} board Raw board settings.
1753
+ * @param {string[]|null} [ids=null] Known field ids, or null to skip the check.
1754
+ * @returns {ViewSettingsBoard|null}
1755
+ */
1756
+ export function normalizeBoard(board, ids = null) {
1757
+ if (!board || typeof board !== 'object') return null;
1758
+ const source = /** @type {Partial<ViewSettingsBoard>} */ (board);
1759
+ const columnBy = normalizeFieldId(source.columnBy, ids);
1760
+ let swimlaneBy = normalizeFieldId(source.swimlaneBy, ids);
1761
+ if (swimlaneBy !== null && swimlaneBy === columnBy) swimlaneBy = null;
1762
+ return { columnBy, swimlaneBy, showEmptyColumns: source.showEmptyColumns !== false };
1763
+ }
1764
+
1765
+ /** @param {unknown} value @param {string[]|null} ids @returns {string|null} */
1766
+ function normalizeFieldId(value, ids) {
1767
+ if (value == null || value === '') return null;
1768
+ const id = String(value);
1769
+ return ids && !ids.includes(id) ? null : id;
1770
+ }
1771
+
1772
+ /** @param {ViewSettingsField[]} fields @param {unknown} order @returns {string[]} */
1773
+ function reconcileOrder(fields, order) {
1774
+ const available = fields.map((field) => field.id);
1775
+ const allowed = new Set(available);
1776
+ const preferred = Array.isArray(order) ? [...new Set(order.map(String))].filter((id) => allowed.has(id)) : [];
1777
+ return [...preferred, ...available.filter((id) => !preferred.includes(id))];
1778
+ }
1779
+
1780
+ /**
1781
+ * @param {ViewSettingsField[]} fields
1782
+ * @param {unknown} hidden
1783
+ * @param {boolean} includeDefaults Whether `visible: false` descriptors count as hidden.
1784
+ * @param {boolean} [allowLocked=false] Whether locked fields may be hidden (a bound view's state).
1785
+ * @returns {Set<string>}
1786
+ */
1787
+ function normalizeHidden(fields, hidden, includeDefaults, allowLocked = false) {
1788
+ const movable = new Set(fields.filter((field) => allowLocked || !field.locked).map((field) => field.id));
1789
+ const result = new Set(Array.isArray(hidden) ? hidden.map(String).filter((id) => movable.has(id)) : []);
1790
+ if (includeDefaults) for (const field of fields) if (field.visible === false && movable.has(field.id)) result.add(field.id);
1791
+ // One field always stays shown, the same rule a record view applies.
1792
+ if (fields.length && result.size >= fields.length) result.delete(fields[0].id);
1793
+ return result;
1794
+ }
1795
+
1796
+ /** @param {unknown} value @returns {2|3|4|5|6} */
1797
+ function normalizeHeadingLevel(value) {
1798
+ const number = Number(value ?? 3);
1799
+ return /** @type {2|3|4|5|6} */ (Number.isInteger(number) && number >= 2 && number <= 6 ? number : 3);
1800
+ }
1801
+
1802
+ /** @param {unknown} value @returns {string} Lower-cased text without accents. */
1803
+ function fold(value) {
1804
+ return String(value ?? '').normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase().trim();
1805
+ }
1806
+
1807
+ /** @param {string} value @returns {string} A label derived from an id or type name. */
1808
+ function humanize(value) {
1809
+ const source = String(value).split('.').at(-1) ?? '';
1810
+ if (source === 'ID') return 'ID';
1811
+ const text = source.replace(/([a-z\d])([A-Z])/g, '$1 $2').replace(/[_-]+/g, ' ').trim();
1812
+ return text ? text.charAt(0).toUpperCase() + text.slice(1) : source;
1813
+ }
1814
+
1815
+ /** @param {string[]} left @param {string[]} right @returns {boolean} */
1816
+ function sameList(left, right) {
1817
+ return left.length === right.length && left.every((value, index) => value === right[index]);
1818
+ }
1819
+
1820
+ /** @param {Set<string>} left @param {Set<string>} right @returns {boolean} */
1821
+ function sameSet(left, right) {
1822
+ return left.size === right.size && [...left].every((value) => right.has(value));
1823
+ }
1824
+
1825
+ /** @param {ViewSettingsBoard|null} left @param {ViewSettingsBoard|null} right @returns {boolean} */
1826
+ function sameBoard(left, right) {
1827
+ if (!left || !right) return left === right;
1828
+ return left.columnBy === right.columnBy && left.swimlaneBy === right.swimlaneBy
1829
+ && left.showEmptyColumns === right.showEmptyColumns;
1830
+ }