@zeyos/zx 5.0.0 → 5.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +164 -0
- package/README.md +2 -1
- package/dist/types/components/account-menu/account-menu.d.ts +8 -0
- package/dist/types/components/activity-list/activity-list.d.ts +9 -1
- package/dist/types/components/app-icon/app-icon.d.ts +14 -2
- package/dist/types/components/app-sidebar/app-sidebar.d.ts +288 -14
- package/dist/types/components/calendar/calendar.d.ts +67 -5
- package/dist/types/components/card-view/card-view.d.ts +7 -4
- package/dist/types/components/card-view/record-card.d.ts +11 -0
- package/dist/types/components/context-menu/context-menu.d.ts +6 -2
- package/dist/types/components/data-filter/data-filter.d.ts +4 -4
- package/dist/types/components/dropdown/dropdown.d.ts +22 -7
- package/dist/types/components/field/field.d.ts +30 -0
- package/dist/types/components/fieldset/external-adapter.d.ts +83 -0
- package/dist/types/components/fieldset/fieldset.d.ts +7 -0
- package/dist/types/components/file-list/file-list.d.ts +35 -3
- package/dist/types/components/filter/filter.d.ts +127 -3
- package/dist/types/components/filter-panel/filter-panel.d.ts +36 -7
- package/dist/types/components/launcher/launcher.d.ts +8 -0
- package/dist/types/components/list-toolbar/list-toolbar.d.ts +15 -6
- package/dist/types/components/menu-button/menu-button.d.ts +6 -0
- package/dist/types/components/message/message.d.ts +14 -1
- package/dist/types/components/modal/modal.d.ts +30 -0
- package/dist/types/components/module-bar/module-bar.d.ts +395 -0
- package/dist/types/components/pagination/pagination.d.ts +17 -3
- package/dist/types/components/select/select.d.ts +6 -0
- package/dist/types/components/sheet/sheet.d.ts +15 -0
- package/dist/types/components/table/table.d.ts +265 -17
- package/dist/types/components/view/record-view.d.ts +59 -2
- package/dist/types/components/view-settings/view-settings.d.ts +879 -0
- package/dist/types/components/workspace-switcher/workspace-switcher.d.ts +327 -0
- package/dist/types/core/component.d.ts +12 -2
- package/dist/types/core/i18n.d.ts +27 -3
- package/dist/types/core/keyboard.d.ts +98 -1
- package/dist/types/core/messages.d.ts +382 -0
- package/dist/types/core/overlay-host.d.ts +28 -1
- package/dist/types/core/overlay-stack.d.ts +260 -0
- package/dist/types/index.d.ts +5 -2
- package/dist/types/internal/app-rail.d.ts +38 -0
- package/dist/types/internal/menu-items.d.ts +71 -3
- package/dist/types/zeyos/index.d.ts +1 -0
- package/dist/types/zeyos/launcher.d.ts +9 -0
- package/dist/types/zeyos/module-bar.d.ts +176 -0
- package/dist/types/zeyos/query.d.ts +31 -5
- package/dist/types/zeyos/table.d.ts +245 -8
- package/dist/types/zeyos/view.d.ts +16 -2
- package/dist/zx-zeyos.esm.js +4422 -430
- package/dist/zx-zeyos.esm.min.js +5 -5
- package/dist/zx.css +1417 -134
- package/dist/zx.esm.js +6986 -830
- package/dist/zx.esm.min.js +8 -8
- package/dist/zx.global.js +6986 -830
- package/dist/zx.global.min.js +8 -8
- package/dist/zx.min.css +1 -1
- package/docs/api.json +794 -17
- package/docs/llms.md +503 -63
- package/docs/llms.txt +6 -2
- package/package.json +1 -1
- package/src/components/account-menu/account-menu.js +12 -0
- package/src/components/activity-list/activity-list.js +27 -1
- package/src/components/app-icon/app-icon.css +13 -2
- package/src/components/app-icon/app-icon.js +11 -1
- package/src/components/app-rail/app-rail.css +19 -0
- package/src/components/app-sidebar/app-sidebar.css +204 -3
- package/src/components/app-sidebar/app-sidebar.js +646 -40
- package/src/components/breadcrumb/breadcrumb.css +1 -1
- package/src/components/calendar/calendar.css +1 -1
- package/src/components/calendar/calendar.js +26 -52
- package/src/components/card/card.css +2 -2
- package/src/components/card-view/card-view.css +5 -5
- package/src/components/card-view/card-view.js +13 -9
- package/src/components/card-view/record-card.js +14 -3
- package/src/components/code-editor/code-editor.css +1 -1
- package/src/components/code-editor/field-adapter.js +9 -2
- package/src/components/context-menu/context-menu.js +8 -2
- package/src/components/copy/copy-input.css +1 -1
- package/src/components/data-filter/data-filter.js +2 -2
- package/src/components/date-picker/time-picker.css +2 -2
- package/src/components/dock/dock.css +5 -4
- package/src/components/dropdown/dropdown.js +157 -72
- package/src/components/field/field.js +33 -3
- package/src/components/fieldset/external-adapter.js +363 -0
- package/src/components/fieldset/fieldset.js +10 -59
- package/src/components/file-list/file-list.js +144 -21
- package/src/components/filter/filter.css +86 -2
- package/src/components/filter/filter.js +300 -8
- package/src/components/filter-panel/filter-panel.js +149 -32
- package/src/components/finder/finder.css +1 -1
- package/src/components/kanban-view/kanban-view.css +3 -3
- package/src/components/kanban-view/kanban-view.js +5 -2
- package/src/components/launcher/launcher.css +1 -1
- package/src/components/launcher/launcher.js +29 -3
- package/src/components/list-toolbar/list-toolbar.js +39 -13
- package/src/components/master-panel/master-panel.js +0 -1
- package/src/components/menu-button/menu-button.css +63 -0
- package/src/components/menu-button/menu-button.js +97 -11
- package/src/components/message/message.js +60 -20
- package/src/components/modal/modal.css +1 -1
- package/src/components/modal/modal.js +66 -13
- package/src/components/module-bar/module-bar.css +486 -0
- package/src/components/module-bar/module-bar.js +1074 -0
- package/src/components/multi-value-editor/multi-value-editor.css +1 -1
- package/src/components/number-field/field-adapter.js +2 -1
- package/src/components/pagination/pagination.css +5 -5
- package/src/components/pagination/pagination.js +20 -4
- package/src/components/panel/panel.css +3 -2
- package/src/components/questionnaire/questionnaire.css +4 -4
- package/src/components/rating/field-adapter.js +2 -1
- package/src/components/rating/rating.css +1 -1
- package/src/components/search/search.css +4 -1
- package/src/components/select/select.js +9 -0
- package/src/components/sheet/sheet.css +1 -1
- package/src/components/sheet/sheet.js +74 -32
- package/src/components/slider/field-adapter.js +6 -1
- package/src/components/slider/slider.css +2 -2
- package/src/components/split-view/split-view.css +1 -1
- package/src/components/stat-tile/stat-tile.css +1 -1
- package/src/components/stepper/stepper.css +1 -1
- package/src/components/tabbox/tabbox.css +9 -5
- package/src/components/table/table.css +86 -11
- package/src/components/table/table.js +628 -57
- package/src/components/tag-picker/field-adapter.js +2 -1
- package/src/components/tooltip/tooltip.js +35 -17
- package/src/components/tree/tree.css +1 -1
- package/src/components/view/record-view.css +1 -1
- package/src/components/view/record-view.js +178 -18
- package/src/components/view-settings/view-settings.css +285 -0
- package/src/components/view-settings/view-settings.js +1830 -0
- package/src/components/workspace-switcher/workspace-switcher.css +188 -0
- package/src/components/workspace-switcher/workspace-switcher.js +463 -0
- package/src/core/component.js +88 -17
- package/src/core/fontawesome.js +5 -1
- package/src/core/i18n.js +47 -4
- package/src/core/icons.js +12 -2
- package/src/core/keyboard.js +206 -11
- package/src/core/messages.js +427 -0
- package/src/core/overlay-host.js +64 -9
- package/src/core/overlay-stack.js +487 -0
- package/src/core/position.js +22 -3
- package/src/index.js +5 -2
- package/src/internal/app-rail.js +134 -53
- package/src/internal/menu-items.js +61 -3
- package/src/zeyos/form.js +5 -3
- package/src/zeyos/index.js +3 -0
- package/src/zeyos/launcher.js +8 -3
- package/src/zeyos/module-bar.js +181 -0
- package/src/zeyos/query.js +44 -13
- package/src/zeyos/table.js +220 -80
- package/src/zeyos/view.js +43 -93
- package/styles/base.css +16 -1
- package/styles/print.css +1 -1
- package/styles/tokens/dark.css +4 -0
- package/styles/tokens/semantic.css +56 -28
- package/styles/zx.css +3 -0
|
@@ -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
|
+
}
|