flexdesk 0.16.0 → 0.16.1
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/README.md +44 -0
- package/css/flexdesk.css +153 -0
- package/dist/chunk-B5Q53LAJ.js +111 -0
- package/dist/chunk-B5Q53LAJ.js.map +7 -0
- package/dist/{chunk-CWPMLX67.js → chunk-W6FBJLPW.js} +2 -110
- package/dist/{chunk-CWPMLX67.js.map → chunk-W6FBJLPW.js.map} +3 -3
- package/dist/flexdesk.css +153 -0
- package/dist/tiles.js +471 -16
- package/dist/tiles.js.map +4 -4
- package/dist/widgets.js +23 -9
- package/dist/widgets.js.map +2 -2
- package/dist/wm.js +4 -2
- package/dist/wm.js.map +1 -1
- package/package.json +1 -1
- package/src/tiles/list_tiles.js +422 -0
- package/src/tiles/scope_picker.js +96 -0
- package/src/tiles/tile_base.js +11 -0
- package/src/tiles/tile_grid.js +36 -1
- package/src/ui/components/sortable_list.js +26 -4
package/package.json
CHANGED
|
@@ -0,0 +1,422 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* list_tiles.js — a LIST tile and a PEOPLE tile for a `TileGrid` (0.16.1).
|
|
3
|
+
*
|
|
4
|
+
* Both are rows you hand over through `setData()`, the way the chart tile takes
|
|
5
|
+
* series: which records are in the list, how they sort and what a row says is
|
|
6
|
+
* the embedder's job, because only the embedder knows what a record is. What a
|
|
7
|
+
* tile here owns is everything a list on a board needs and every embedder would
|
|
8
|
+
* otherwise write again: the count, the empty line, the "open all of these"
|
|
9
|
+
* button, a click, the keyboard, and a right-click menu per row.
|
|
10
|
+
*
|
|
11
|
+
* import { registerListTiles } from '@flexdesk/tiles';
|
|
12
|
+
* registerListTiles(); // idempotent; nothing registers at import
|
|
13
|
+
* const tile = grid.addTile('flexdesk.list', {x: 0, y: 0, w: 6, h: 4},
|
|
14
|
+
* { title: 'Overdue', empty_text: 'Nothing is late.' });
|
|
15
|
+
* tile.onRowActivate = ({ row, event }) => open(row.key, event.ctrlKey);
|
|
16
|
+
* tile.rowMenu = ({ row }) => [{ label: 'Open', icon: 'open_in_new', action: 'open' }];
|
|
17
|
+
* tile.onRowMenuAction = ({ action, row }) => { … };
|
|
18
|
+
* tile.setData({
|
|
19
|
+
* rows: [{ key: '7', icon: 'task', lead: 'TASK-0007', title: 'Sign-off',
|
|
20
|
+
* cells: [{ text: 'priya' }, { text: '9 days late', tone: 'danger' }] }],
|
|
21
|
+
* total: 12, // "7 of 12" when only 7 are handed over
|
|
22
|
+
* action: { label: 'Open as a list' }, // the button in the bar; see onAction
|
|
23
|
+
* });
|
|
24
|
+
*
|
|
25
|
+
* ══ THE DATA ════════════════════════════════════════════════════════════
|
|
26
|
+
*
|
|
27
|
+
* list { rows: [Row], total?, note?, empty?, action?: {label, icon?} } (an array is {rows})
|
|
28
|
+
* Row = { key, title, icon?, iconTitle?, lead?, className?, tip?,
|
|
29
|
+
* cells?: [{ text, title?, tone?, icon?, className? }] }
|
|
30
|
+
* people { people: [Person], total?, note?, empty?, action? } (an array is {people})
|
|
31
|
+
* Person = { key, name, icon?, muted?, count, countLabel?, title?,
|
|
32
|
+
* pills?: [{ text, tone?, title? }] }
|
|
33
|
+
*
|
|
34
|
+
* `tone` is one of `danger`, `warning`, `ok`, `muted` and only picks a colour;
|
|
35
|
+
* the text says the same thing, because colour alone does not survive a
|
|
36
|
+
* monochrome display. `className` is the embedder's own class, added as it is,
|
|
37
|
+
* so a status pill can keep the app's look.
|
|
38
|
+
*
|
|
39
|
+
* AN EMPTY LIST IS NOT THE EMPTY STATE. `{rows: []}` keeps the bar (count 0,
|
|
40
|
+
* the button) and says `empty` (or `config.empty_text`) where the rows would be:
|
|
41
|
+
* "nothing is late" is the most useful thing a list of late work can say, and a
|
|
42
|
+
* tile that shows a grey inbox icon instead cannot say it. `setData(null)` is
|
|
43
|
+
* the empty state; `showEmpty(message)` is how an embedder says "not set up".
|
|
44
|
+
*
|
|
45
|
+
* ══ INPUT ═══════════════════════════════════════════════════════════════
|
|
46
|
+
*
|
|
47
|
+
* A row is `role="button"` with a tab stop. Click, Enter and Space call
|
|
48
|
+
* `onRowActivate({row, event, tile})` with the event, so the modifier keys
|
|
49
|
+
* decide how it opens. Up and Down move between rows, Home and End to the ends.
|
|
50
|
+
* Right-click, the context-menu key and Shift+F10 open the row's menu:
|
|
51
|
+
* `rowMenu({row, event, tile})` answers the items (`showContextMenu`'s; none or
|
|
52
|
+
* `[]` means no menu, and the browser's own is left alone) and the choice goes
|
|
53
|
+
* to `onRowMenuAction({action, row, tile})`. The bar's button calls
|
|
54
|
+
* `onAction({tile, event})`. Every hook can also be passed to the constructor.
|
|
55
|
+
* A row carries `data-row-key`, so an embedder can find it for its own
|
|
56
|
+
* listeners (a drag source, say); `rowsDraggable` makes rows `draggable`.
|
|
57
|
+
*
|
|
58
|
+
* The header reads `config.title`, else the type's title; the count is in the
|
|
59
|
+
* bar, not the header, so a title the embedder sets is never rewritten.
|
|
60
|
+
*/
|
|
61
|
+
|
|
62
|
+
import { TileBase } from './tile_base.js';
|
|
63
|
+
import { registerWidget, getWidget } from './tile_registry.js';
|
|
64
|
+
import { showContextMenu } from '../ui/components/context_menu.js';
|
|
65
|
+
|
|
66
|
+
const TONES = new Set(['danger', 'warning', 'ok', 'muted']);
|
|
67
|
+
|
|
68
|
+
/** `{items, total, note, empty, action}` from what `setData` was handed under
|
|
69
|
+
* `key` (`rows` or `people`), or null when there is nothing at all. */
|
|
70
|
+
function normalize(data, key) {
|
|
71
|
+
if (data == null) return null;
|
|
72
|
+
const d = Array.isArray(data) ? { [key]: data } : data;
|
|
73
|
+
if (typeof d !== 'object' || !Array.isArray(d[key])) return null;
|
|
74
|
+
const items = d[key].filter((r) => r && typeof r === 'object' && r.key != null);
|
|
75
|
+
const total = Number.isFinite(Number(d.total)) && d.total !== null && d.total !== '' ? Number(d.total) : items.length;
|
|
76
|
+
return {
|
|
77
|
+
items,
|
|
78
|
+
total: Math.max(total, items.length),
|
|
79
|
+
note: typeof d.note === 'string' ? d.note : '',
|
|
80
|
+
empty: typeof d.empty === 'string' ? d.empty : '',
|
|
81
|
+
action: d.action && typeof d.action === 'object' && d.action.label ? d.action : null,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Exported for tests and for an embedder that wants to know what the tile will make of its data. */
|
|
86
|
+
export const normalizeListData = (data) => normalize(data, 'rows');
|
|
87
|
+
export const normalizePeopleData = (data) => normalize(data, 'people');
|
|
88
|
+
|
|
89
|
+
function el(tag, cls, text) {
|
|
90
|
+
const e = document.createElement(tag);
|
|
91
|
+
if (cls) e.className = cls;
|
|
92
|
+
if (text != null) e.textContent = String(text);
|
|
93
|
+
return e;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function glyph(name, cls = '') {
|
|
97
|
+
const g = el('span', `material-symbols-outlined${cls ? ` ${cls}` : ''}`, name);
|
|
98
|
+
g.setAttribute('aria-hidden', 'true');
|
|
99
|
+
return g;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const toneClass = (base, tone) => (TONES.has(tone) ? ` ${base}--${tone}` : '');
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* The rows, the bar and the input, shared by both tiles. A subclass says what
|
|
106
|
+
* its items are called in the data (`_KEY`) and draws one (`_renderItem`).
|
|
107
|
+
* @abstract
|
|
108
|
+
*/
|
|
109
|
+
class RowsTile extends TileBase {
|
|
110
|
+
static _KEY = 'rows';
|
|
111
|
+
|
|
112
|
+
constructor(options = {}) {
|
|
113
|
+
super(options);
|
|
114
|
+
this.onRowActivate = typeof options.onRowActivate === 'function' ? options.onRowActivate : null;
|
|
115
|
+
this.rowMenu = typeof options.rowMenu === 'function' ? options.rowMenu : null;
|
|
116
|
+
this.onRowMenuAction = typeof options.onRowMenuAction === 'function' ? options.onRowMenuAction : null;
|
|
117
|
+
this.onAction = typeof options.onAction === 'function' ? options.onAction : null;
|
|
118
|
+
this.rowsDraggable = options.rowsDraggable === true;
|
|
119
|
+
this._items = new Map(); // key → the item drawn for it
|
|
120
|
+
this._onClick = (e) => this._click(e);
|
|
121
|
+
this._onKey = (e) => this._key(e);
|
|
122
|
+
this._onMenu = (e) => this._menu(e);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Pure and static-only — it runs inside `TileBase`'s constructor. */
|
|
126
|
+
getDefaultConfig() {
|
|
127
|
+
return { title: '', empty_text: '', max_rows: 0 };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
getConfigSchema() {
|
|
131
|
+
return {
|
|
132
|
+
fields: [
|
|
133
|
+
{ name: 'title', type: 'text', label: 'Tile title' },
|
|
134
|
+
{ name: 'empty_text', type: 'text', label: 'When there is nothing',
|
|
135
|
+
help: 'What the tile says when the list is empty, e.g. "Nothing is late."' },
|
|
136
|
+
{ name: 'max_rows', type: 'number', label: 'Rows at most', min: 0,
|
|
137
|
+
help: '0 shows them all; the tile scrolls.' },
|
|
138
|
+
],
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
mount(container) {
|
|
143
|
+
super.mount(container);
|
|
144
|
+
applyTitle(this);
|
|
145
|
+
const c = this.contentElement;
|
|
146
|
+
if (!c) return;
|
|
147
|
+
// On the content element, which outlives every render (they replace
|
|
148
|
+
// its children, never it), so one set of listeners serves them all.
|
|
149
|
+
c.addEventListener('click', this._onClick);
|
|
150
|
+
c.addEventListener('keydown', this._onKey);
|
|
151
|
+
c.addEventListener('contextmenu', this._onMenu);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** `null` is "the grid has no data" — see `ChartTile.update`. */
|
|
155
|
+
update(data, config) {
|
|
156
|
+
if (this._disposed) return;
|
|
157
|
+
super.update(data === null ? undefined : data, config);
|
|
158
|
+
if (config) applyTitle(this);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Hand this tile its rows; null is the empty state. */
|
|
162
|
+
setData(data) {
|
|
163
|
+
if (this._disposed) return;
|
|
164
|
+
this.data = data ?? null;
|
|
165
|
+
if (this.contentElement) this.render(this.data);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** The item drawn under `key`, or null. */
|
|
169
|
+
itemFor(key) { return this._items.get(String(key)) ?? null; }
|
|
170
|
+
|
|
171
|
+
/** The element of the item drawn under `key`, or null. */
|
|
172
|
+
rowElement(key) {
|
|
173
|
+
const want = String(key);
|
|
174
|
+
return [...(this.contentElement?.querySelectorAll('[data-row-key]') || [])]
|
|
175
|
+
.find((r) => r.dataset.rowKey === want) || null;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
render(data) {
|
|
179
|
+
const content = this.contentElement;
|
|
180
|
+
if (!content) return;
|
|
181
|
+
const norm = normalize(data, this.constructor._KEY);
|
|
182
|
+
this._items.clear();
|
|
183
|
+
if (!norm) { this.showEmpty('Nothing to show.'); return; }
|
|
184
|
+
|
|
185
|
+
const max = Math.max(0, Math.floor(Number(this.config.max_rows) || 0));
|
|
186
|
+
const shown = max ? norm.items.slice(0, max) : norm.items;
|
|
187
|
+
const box = el('div', `fd-list-tile fd-list-tile--${this.constructor._KEY}`);
|
|
188
|
+
|
|
189
|
+
const bar = el('div', 'fd-list-tile__bar');
|
|
190
|
+
const count = el('span', 'fd-list-tile__count',
|
|
191
|
+
shown.length < norm.total ? `${shown.length} of ${norm.total}` : String(norm.total));
|
|
192
|
+
count.title = `${norm.total} in all`;
|
|
193
|
+
bar.append(count, el('span', 'fd-list-tile__spacer'));
|
|
194
|
+
if (norm.action) {
|
|
195
|
+
const b = el('button', 'fd-list-tile__action');
|
|
196
|
+
b.type = 'button';
|
|
197
|
+
b.dataset.listAction = 'action';
|
|
198
|
+
if (norm.action.icon) b.append(glyph(norm.action.icon));
|
|
199
|
+
b.append(el('span', null, norm.action.label));
|
|
200
|
+
bar.append(b);
|
|
201
|
+
}
|
|
202
|
+
box.append(bar);
|
|
203
|
+
|
|
204
|
+
const list = el('div', 'fd-list-tile__rows');
|
|
205
|
+
list.setAttribute('role', 'list');
|
|
206
|
+
if (!shown.length) {
|
|
207
|
+
list.append(el('div', 'fd-list-tile__empty', norm.empty || this.config.empty_text || 'Nothing here.'));
|
|
208
|
+
}
|
|
209
|
+
for (const item of shown) {
|
|
210
|
+
const key = String(item.key);
|
|
211
|
+
if (this._items.has(key)) continue; // one row per key: a second would be unreachable
|
|
212
|
+
this._items.set(key, item);
|
|
213
|
+
const row = this._renderItem(item);
|
|
214
|
+
row.classList.add('fd-list-tile__row');
|
|
215
|
+
row.dataset.rowKey = key;
|
|
216
|
+
row.setAttribute('role', 'button');
|
|
217
|
+
row.tabIndex = 0;
|
|
218
|
+
if (this.rowsDraggable) row.draggable = true;
|
|
219
|
+
const wrap = el('div', 'fd-list-tile__item');
|
|
220
|
+
wrap.setAttribute('role', 'listitem');
|
|
221
|
+
wrap.append(row);
|
|
222
|
+
list.append(wrap);
|
|
223
|
+
}
|
|
224
|
+
box.append(list);
|
|
225
|
+
if (norm.note) {
|
|
226
|
+
const note = el('div', 'fd-list-tile__note', norm.note);
|
|
227
|
+
note.setAttribute('role', 'note');
|
|
228
|
+
note.title = norm.note;
|
|
229
|
+
box.append(note);
|
|
230
|
+
}
|
|
231
|
+
content.replaceChildren(box);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** One item's row element. @abstract */
|
|
235
|
+
_renderItem(_item) { return el('div'); }
|
|
236
|
+
|
|
237
|
+
// ── input ───────────────────────────────────────────────────────────
|
|
238
|
+
|
|
239
|
+
_rowOf(target) {
|
|
240
|
+
const row = target?.closest?.('[data-row-key]');
|
|
241
|
+
return row && this.contentElement?.contains(row) ? row : null;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
_click(e) {
|
|
245
|
+
if (e.target?.closest?.('[data-list-action]')) {
|
|
246
|
+
this._call(this.onAction, { tile: this, event: e });
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
const row = this._rowOf(e.target);
|
|
250
|
+
if (row) this._activate(row, e);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
_activate(rowEl, event) {
|
|
254
|
+
const row = this.itemFor(rowEl.dataset.rowKey);
|
|
255
|
+
if (row) this._call(this.onRowActivate, { row, event, tile: this });
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
_key(e) {
|
|
259
|
+
const rowEl = this._rowOf(e.target);
|
|
260
|
+
if (!rowEl) return;
|
|
261
|
+
if (e.key === 'ContextMenu' || (e.key === 'F10' && e.shiftKey)) {
|
|
262
|
+
const r = rowEl.getBoundingClientRect();
|
|
263
|
+
if (this._openMenu(rowEl, e, r.left + 16, r.bottom)) e.preventDefault();
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
if ((e.key === 'Enter' || e.key === ' ') && !e.altKey) {
|
|
267
|
+
e.preventDefault();
|
|
268
|
+
this._activate(rowEl, e);
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
const rows = [...this.contentElement.querySelectorAll('[data-row-key]')];
|
|
272
|
+
const i = rows.indexOf(rowEl);
|
|
273
|
+
const to = { ArrowDown: i + 1, ArrowUp: i - 1, Home: 0, End: rows.length - 1 }[e.key];
|
|
274
|
+
if (to === undefined || !rows[to]) return;
|
|
275
|
+
e.preventDefault();
|
|
276
|
+
rows[to].focus();
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
_menu(e) {
|
|
280
|
+
const rowEl = this._rowOf(e.target);
|
|
281
|
+
if (rowEl && this._openMenu(rowEl, e, e.clientX, e.clientY)) e.preventDefault();
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** Show the row's menu, if it has one. → whether it did. */
|
|
285
|
+
_openMenu(rowEl, event, x, y) {
|
|
286
|
+
const row = this.itemFor(rowEl.dataset.rowKey);
|
|
287
|
+
if (!row || typeof this.rowMenu !== 'function') return false;
|
|
288
|
+
let items = null;
|
|
289
|
+
try { items = this.rowMenu({ row, event, tile: this }); }
|
|
290
|
+
catch (err) { console.error(`[${this.constructor.name}] rowMenu threw:`, err); return false; }
|
|
291
|
+
if (!Array.isArray(items) || !items.length) return false;
|
|
292
|
+
showContextMenu(x, y, items, (action) => this._call(this.onRowMenuAction, { action, row, tile: this }));
|
|
293
|
+
return true;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/** A hook, called so that one that throws is reported and the tile goes on. */
|
|
297
|
+
_call(fn, arg) {
|
|
298
|
+
if (typeof fn !== 'function') return;
|
|
299
|
+
try { fn(arg); } catch (err) { console.error(`[${this.constructor.name}] a callback threw:`, err); }
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
dispose() {
|
|
303
|
+
const c = this.contentElement;
|
|
304
|
+
if (c) {
|
|
305
|
+
c.removeEventListener('click', this._onClick);
|
|
306
|
+
c.removeEventListener('keydown', this._onKey);
|
|
307
|
+
c.removeEventListener('contextmenu', this._onMenu);
|
|
308
|
+
}
|
|
309
|
+
this._items.clear();
|
|
310
|
+
super.dispose();
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* A LIST OF RECORDS: an icon, a lead (a reference, say), the title, and cells.
|
|
316
|
+
* See the header for the data and the input.
|
|
317
|
+
*/
|
|
318
|
+
export class ListTile extends RowsTile {
|
|
319
|
+
static TYPE = 'flexdesk.list';
|
|
320
|
+
static TITLE = 'List';
|
|
321
|
+
static ICON = 'list';
|
|
322
|
+
static DESCRIPTION = 'Rows you supply, with a count, a click and a right-click menu per row.';
|
|
323
|
+
static DEFAULT_SIZE = { w: 6, h: 4 };
|
|
324
|
+
static SIZE_CONSTRAINTS = { minW: 3, minH: 2, maxW: 12, maxH: 12 };
|
|
325
|
+
static _KEY = 'rows';
|
|
326
|
+
|
|
327
|
+
_renderItem(r) {
|
|
328
|
+
const row = el('div', r.className ? String(r.className) : null);
|
|
329
|
+
if (r.icon) {
|
|
330
|
+
const g = glyph(r.icon, 'fd-list-tile__icon');
|
|
331
|
+
if (r.iconTitle) { g.title = r.iconTitle; g.removeAttribute('aria-hidden'); }
|
|
332
|
+
row.append(g);
|
|
333
|
+
}
|
|
334
|
+
if (r.lead != null && r.lead !== '') row.append(el('span', 'fd-list-tile__lead', r.lead));
|
|
335
|
+
const title = el('span', 'fd-list-tile__title', r.title ?? '');
|
|
336
|
+
title.title = r.tip || String(r.title ?? '');
|
|
337
|
+
row.append(title);
|
|
338
|
+
for (const c of Array.isArray(r.cells) ? r.cells : []) {
|
|
339
|
+
if (!c) continue;
|
|
340
|
+
const cell = el('span', `fd-list-tile__cell${toneClass('fd-list-tile__cell', c.tone)}${c.className ? ` ${c.className}` : ''}`);
|
|
341
|
+
if (c.icon) cell.append(glyph(c.icon));
|
|
342
|
+
if (c.text != null && c.text !== '') cell.append(document.createTextNode(String(c.text)));
|
|
343
|
+
if (c.title) cell.title = c.title;
|
|
344
|
+
row.append(cell);
|
|
345
|
+
}
|
|
346
|
+
return row;
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* WHO HAS WHAT: one row per person, with a count and pills. The order is the
|
|
352
|
+
* embedder's (the list is read top-down and stopped at, so the order should BE
|
|
353
|
+
* the priority); `groupByPerson` is a starting point for building it.
|
|
354
|
+
*/
|
|
355
|
+
export class PeopleTile extends RowsTile {
|
|
356
|
+
static TYPE = 'flexdesk.people';
|
|
357
|
+
static TITLE = 'People';
|
|
358
|
+
static ICON = 'groups';
|
|
359
|
+
static DESCRIPTION = 'One row per person, with how much is on them.';
|
|
360
|
+
static DEFAULT_SIZE = { w: 6, h: 4 };
|
|
361
|
+
static SIZE_CONSTRAINTS = { minW: 3, minH: 2, maxW: 12, maxH: 12 };
|
|
362
|
+
static _KEY = 'people';
|
|
363
|
+
|
|
364
|
+
getDefaultConfig() {
|
|
365
|
+
return { ...super.getDefaultConfig(), count_label: 'open' };
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
getConfigSchema() {
|
|
369
|
+
const { fields } = super.getConfigSchema();
|
|
370
|
+
return { fields: [...fields, { name: 'count_label', type: 'text', label: 'Count reads',
|
|
371
|
+
help: 'After each person\'s number, e.g. "open" for "3 open".' }] };
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
_renderItem(p) {
|
|
375
|
+
const row = el('div', 'fd-people-tile__row');
|
|
376
|
+
row.append(glyph(p.icon || (p.muted ? 'person_off' : 'person'), 'fd-list-tile__icon'));
|
|
377
|
+
row.append(el('span', `fd-people-tile__name${p.muted ? ' fd-people-tile__name--muted' : ''}`, p.name ?? p.key));
|
|
378
|
+
const pills = el('span', 'fd-people-tile__pills');
|
|
379
|
+
for (const pill of Array.isArray(p.pills) ? p.pills : []) {
|
|
380
|
+
if (!pill?.text) continue;
|
|
381
|
+
const s = el('span', `fd-people-tile__pill${toneClass('fd-people-tile__pill', pill.tone)}`, pill.text);
|
|
382
|
+
if (pill.title) s.title = pill.title;
|
|
383
|
+
pills.append(s);
|
|
384
|
+
}
|
|
385
|
+
row.append(pills);
|
|
386
|
+
const label = p.countLabel ?? this.config.count_label ?? '';
|
|
387
|
+
row.append(el('span', 'fd-people-tile__count', `${Number(p.count) || 0}${label ? ` ${label}` : ''}`));
|
|
388
|
+
if (p.title) row.title = p.title;
|
|
389
|
+
return row;
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* Items grouped by person: `[{key, items}]`, most items first, then by key, the
|
|
395
|
+
* nobody group (key `''`) last among equals. `personOf(item)` answers the
|
|
396
|
+
* person's key, or a falsy value for nobody.
|
|
397
|
+
*/
|
|
398
|
+
export function groupByPerson(items, personOf) {
|
|
399
|
+
const by = new Map();
|
|
400
|
+
for (const item of Array.isArray(items) ? items : []) {
|
|
401
|
+
const who = String(personOf(item) || '');
|
|
402
|
+
if (!by.has(who)) by.set(who, { key: who, items: [] });
|
|
403
|
+
by.get(who).items.push(item);
|
|
404
|
+
}
|
|
405
|
+
return [...by.values()].sort((a, b) => b.items.length - a.items.length
|
|
406
|
+
|| (a.key === '') - (b.key === '') || a.key.localeCompare(b.key));
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/**
|
|
410
|
+
* Register both tiles. Idempotent, and nothing registers at import time — see
|
|
411
|
+
* `registerChartTiles`.
|
|
412
|
+
*/
|
|
413
|
+
export function registerListTiles() {
|
|
414
|
+
for (const Tile of [ListTile, PeopleTile]) {
|
|
415
|
+
if (getWidget(Tile.TYPE) !== Tile) registerWidget(Tile);
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/** The header shows `config.title`, else the type's own title. */
|
|
420
|
+
function applyTitle(tile) {
|
|
421
|
+
tile.setTitle(tile.config.title || tile.constructor.TITLE);
|
|
422
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* scope_picker.js — ONE FILTER FOR THE WHOLE BOARD (0.16.1).
|
|
3
|
+
*
|
|
4
|
+
* A row of buttons, one per scope ("Everyone", "Assigned by me", …), exactly
|
|
5
|
+
* one of them pressed. It is the control half of a dashboard-level filter; the
|
|
6
|
+
* other half is `TileGrid.setScope()`, which hands the value to every tile
|
|
7
|
+
* (`tile.scope`, `tile.onScopeChange`) and to the grid's `onScopeChange`:
|
|
8
|
+
*
|
|
9
|
+
* const picker = createScopePicker({
|
|
10
|
+
* options: [{ value: 'all', label: 'Everyone' },
|
|
11
|
+
* { value: 'mine', label: 'Mine', hint: 'only what is on me' }],
|
|
12
|
+
* value: 'all',
|
|
13
|
+
* onChange: (value) => grid.setScope(value),
|
|
14
|
+
* });
|
|
15
|
+
* header.append(picker.element);
|
|
16
|
+
*
|
|
17
|
+
* What a scope MEANS is the embedder's: the grid only carries the value, and a
|
|
18
|
+
* tile that knows nothing about scopes is unaffected by one.
|
|
19
|
+
*
|
|
20
|
+
* Each button has `aria-pressed`, the group a label. Left and Right move the
|
|
21
|
+
* focus along the row and Enter or Space presses, as on any button; a click on
|
|
22
|
+
* the button already pressed changes nothing and calls nothing.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* @param {Object} o
|
|
27
|
+
* @param {Array<{value: string, label: string, hint?: string}>} o.options
|
|
28
|
+
* @param {string} [o.value] - The pressed one; the first option when absent or unknown.
|
|
29
|
+
* @param {string} [o.label='Scope'] - The group's accessible name.
|
|
30
|
+
* @param {(value: string) => void} [o.onChange] - After the user presses another one.
|
|
31
|
+
* @returns {{element: HTMLElement, readonly value: string, setValue: (v: string) => void, dispose: () => void}}
|
|
32
|
+
*/
|
|
33
|
+
export function createScopePicker({ options = [], value, label = 'Scope', onChange } = {}) {
|
|
34
|
+
const opts = options.filter((o) => o && o.value != null).map((o) => ({ ...o, value: String(o.value) }));
|
|
35
|
+
const known = (v) => opts.some((o) => o.value === String(v));
|
|
36
|
+
let current = known(value) ? String(value) : opts[0]?.value ?? '';
|
|
37
|
+
|
|
38
|
+
const element = document.createElement('span');
|
|
39
|
+
element.className = 'fd-scope-picker';
|
|
40
|
+
element.setAttribute('role', 'group');
|
|
41
|
+
element.setAttribute('aria-label', label);
|
|
42
|
+
|
|
43
|
+
const buttons = opts.map((o) => {
|
|
44
|
+
const b = document.createElement('button');
|
|
45
|
+
b.type = 'button';
|
|
46
|
+
b.className = 'fd-scope-picker__btn';
|
|
47
|
+
b.dataset.scope = o.value;
|
|
48
|
+
b.textContent = o.label ?? o.value;
|
|
49
|
+
if (o.hint) b.title = o.hint;
|
|
50
|
+
element.append(b);
|
|
51
|
+
return b;
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
const paint = () => {
|
|
55
|
+
for (const b of buttons) {
|
|
56
|
+
const on = b.dataset.scope === current;
|
|
57
|
+
b.classList.toggle('fd-scope-picker__btn--on', on);
|
|
58
|
+
b.setAttribute('aria-pressed', String(on));
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
paint();
|
|
62
|
+
|
|
63
|
+
const onClick = (e) => {
|
|
64
|
+
const b = e.target?.closest?.('[data-scope]');
|
|
65
|
+
if (!b || !element.contains(b) || b.dataset.scope === current) return;
|
|
66
|
+
current = b.dataset.scope;
|
|
67
|
+
paint();
|
|
68
|
+
try { onChange?.(current); } catch (err) { console.error('[ScopePicker] onChange threw:', err); }
|
|
69
|
+
};
|
|
70
|
+
const onKey = (e) => {
|
|
71
|
+
const i = buttons.indexOf(e.target);
|
|
72
|
+
if (i < 0) return;
|
|
73
|
+
const to = { ArrowRight: i + 1, ArrowLeft: i - 1, Home: 0, End: buttons.length - 1 }[e.key];
|
|
74
|
+
if (to === undefined || !buttons[to]) return;
|
|
75
|
+
e.preventDefault();
|
|
76
|
+
buttons[to].focus();
|
|
77
|
+
};
|
|
78
|
+
element.addEventListener('click', onClick);
|
|
79
|
+
element.addEventListener('keydown', onKey);
|
|
80
|
+
|
|
81
|
+
return {
|
|
82
|
+
element,
|
|
83
|
+
get value() { return current; },
|
|
84
|
+
/** Press `v` without calling `onChange` (a value restored from elsewhere). */
|
|
85
|
+
setValue(v) {
|
|
86
|
+
if (!known(v)) return;
|
|
87
|
+
current = String(v);
|
|
88
|
+
paint();
|
|
89
|
+
},
|
|
90
|
+
dispose() {
|
|
91
|
+
element.removeEventListener('click', onClick);
|
|
92
|
+
element.removeEventListener('keydown', onKey);
|
|
93
|
+
element.remove();
|
|
94
|
+
},
|
|
95
|
+
};
|
|
96
|
+
}
|
package/src/tiles/tile_base.js
CHANGED
|
@@ -87,8 +87,19 @@ export class TileBase {
|
|
|
87
87
|
this._loadSeq = 0;
|
|
88
88
|
/** @type {Map<string, {name: string, analytics: Object, color: string}>|null} */
|
|
89
89
|
this.comparisonData = null;
|
|
90
|
+
/** The board's scope (`TileGrid.setScope`), or null. Set before mount. */
|
|
91
|
+
this.scope = null;
|
|
90
92
|
}
|
|
91
93
|
|
|
94
|
+
/**
|
|
95
|
+
* Called when the board's scope changes (`TileGrid.setScope`), after
|
|
96
|
+
* `this.scope` is set. A no-op here: a tile whose rows the grid's data
|
|
97
|
+
* source loads is reloaded by the grid, and one fed through `setData` is
|
|
98
|
+
* fed again by whoever owns its data.
|
|
99
|
+
* @param {*} _scope
|
|
100
|
+
*/
|
|
101
|
+
onScopeChange(_scope) {}
|
|
102
|
+
|
|
92
103
|
/**
|
|
93
104
|
* Set comparison data for overlay rendering.
|
|
94
105
|
* Only meaningful for widgets where SUPPORTS_COMPARISON = true.
|
package/src/tiles/tile_grid.js
CHANGED
|
@@ -24,6 +24,9 @@ export class TileGrid {
|
|
|
24
24
|
* @param {{resolve: Function}} [options.dataSource] - Per-tile data provider,
|
|
25
25
|
* handed to every tile. With none, the grid is the broadcast-only grid
|
|
26
26
|
* it has always been and no tile is ever asked to load anything.
|
|
27
|
+
* @param {*} [options.scope=null] - The board's scope, a filter every tile
|
|
28
|
+
* follows (0.16.1); see `setScope`.
|
|
29
|
+
* @param {Function} [options.onScopeChange] - `(scope)` after `setScope` changed it.
|
|
27
30
|
*/
|
|
28
31
|
constructor({ container, columns = 12, rowHeight = 80, gap = 16, eventBus = null,
|
|
29
32
|
showToolbar = true, readonly = false, onLayoutChange, onBeforeLayoutChange,
|
|
@@ -35,8 +38,10 @@ export class TileGrid {
|
|
|
35
38
|
host = null, stateGuard = null,
|
|
36
39
|
// Injected, never constructed here: the grid does not know what
|
|
37
40
|
// a binding is, only that a tile may have somewhere to ask.
|
|
38
|
-
dataSource = null }) {
|
|
41
|
+
dataSource = null, scope = null, onScopeChange = null }) {
|
|
39
42
|
this.host = host;
|
|
43
|
+
this.scope = scope;
|
|
44
|
+
this._onScopeChangeCallback = onScopeChange;
|
|
40
45
|
this.stateGuard = stateGuard;
|
|
41
46
|
this.dataSource = dataSource;
|
|
42
47
|
this.container = container;
|
|
@@ -192,6 +197,8 @@ export class TileGrid {
|
|
|
192
197
|
});
|
|
193
198
|
|
|
194
199
|
if (!tile) return null;
|
|
200
|
+
// Before mount, so a tile's first render and first load already follow it.
|
|
201
|
+
tile.scope = this.scope;
|
|
195
202
|
|
|
196
203
|
// Add to layout
|
|
197
204
|
const layoutEntry = {
|
|
@@ -289,6 +296,34 @@ export class TileGrid {
|
|
|
289
296
|
});
|
|
290
297
|
}
|
|
291
298
|
|
|
299
|
+
/**
|
|
300
|
+
* THE BOARD'S SCOPE: one filter every tile follows ("Everyone", "Assigned
|
|
301
|
+
* by me", …), set from a `createScopePicker` or from anything else.
|
|
302
|
+
*
|
|
303
|
+
* The grid only carries the value; what it means is the embedder's. Every
|
|
304
|
+
* tile gets it as `tile.scope` and through `tile.onScopeChange(scope)`, the
|
|
305
|
+
* grid's `onScopeChange(scope)` is called, and a grid with a data source
|
|
306
|
+
* reloads its tiles, since their rows depend on it. The scope is not part
|
|
307
|
+
* of the layout: it is how the board is being looked at, not what is on it.
|
|
308
|
+
*
|
|
309
|
+
* @param {*} scope - Compared by JSON: setting the same scope again does nothing.
|
|
310
|
+
* @returns {boolean} whether it changed
|
|
311
|
+
*/
|
|
312
|
+
setScope(scope) {
|
|
313
|
+
if (JSON.stringify(scope ?? null) === JSON.stringify(this.scope ?? null)) return false;
|
|
314
|
+
this.scope = scope ?? null;
|
|
315
|
+
this.tiles.forEach((tile) => {
|
|
316
|
+
tile.scope = this.scope;
|
|
317
|
+
try { tile.onScopeChange?.(this.scope); }
|
|
318
|
+
catch (err) { console.error('[TileGrid] a tile\'s onScopeChange threw:', err); }
|
|
319
|
+
});
|
|
320
|
+
try { this._onScopeChangeCallback?.(this.scope); }
|
|
321
|
+
catch (err) { console.error('[TileGrid] onScopeChange threw:', err); }
|
|
322
|
+
this.eventBus?.emit?.('grid:scope-changed', { scope: this.scope });
|
|
323
|
+
if (this.dataSource) this.reloadAll();
|
|
324
|
+
return true;
|
|
325
|
+
}
|
|
326
|
+
|
|
292
327
|
/**
|
|
293
328
|
* Get current layout.
|
|
294
329
|
* @returns {Array} Layout array
|
|
@@ -19,6 +19,12 @@
|
|
|
19
19
|
|
|
20
20
|
import { DragReorder } from './drag_reorder.js';
|
|
21
21
|
|
|
22
|
+
/** The class every row carries, and therefore what DragReorder must match. */
|
|
23
|
+
const ROW_CLASS = 'twm-sortable-list__row';
|
|
24
|
+
const ROW_SELECTOR = `.${ROW_CLASS}`;
|
|
25
|
+
const HANDLE_CLASS = 'twm-sortable-list__handle';
|
|
26
|
+
const HANDLE_SELECTOR = `.${HANDLE_CLASS}`;
|
|
27
|
+
|
|
22
28
|
export class SortableList {
|
|
23
29
|
/**
|
|
24
30
|
* Create a sortable list.
|
|
@@ -60,6 +66,7 @@ export class SortableList {
|
|
|
60
66
|
this.root = null;
|
|
61
67
|
this.listEl = null;
|
|
62
68
|
this._dragReorder = null;
|
|
69
|
+
this._pressedHandle = false;
|
|
63
70
|
}
|
|
64
71
|
|
|
65
72
|
/**
|
|
@@ -198,15 +205,30 @@ export class SortableList {
|
|
|
198
205
|
// placeholder). Created once, bound to the persistent list
|
|
199
206
|
// element; re-attached after each refresh to wire the rebuilt
|
|
200
207
|
// rows. Drag is restricted to the grip handle.
|
|
208
|
+
//
|
|
209
|
+
// The item selector is ROW_SELECTOR, the class #createRow puts on
|
|
210
|
+
// every row: a selector that matches no row makes nothing draggable.
|
|
211
|
+
//
|
|
212
|
+
// The handle restriction cannot read `dragstart`'s target: a browser
|
|
213
|
+
// fires `dragstart` at the draggable element itself (the row), never
|
|
214
|
+
// at the child the pointer went down on. So the press is recorded on
|
|
215
|
+
// `mousedown` (which does target the handle) and the guard reads that.
|
|
201
216
|
if (this.allowReorder) {
|
|
202
217
|
if (!this._dragReorder) {
|
|
218
|
+
this.listEl.addEventListener('mousedown', (e) => {
|
|
219
|
+
this._pressedHandle = !!e.target.closest?.(HANDLE_SELECTOR);
|
|
220
|
+
});
|
|
203
221
|
this._dragReorder = new DragReorder({
|
|
204
222
|
container: this.listEl,
|
|
205
|
-
itemSelector:
|
|
223
|
+
itemSelector: ROW_SELECTOR,
|
|
206
224
|
keyAttr: 'index',
|
|
207
225
|
axis: 'y',
|
|
208
226
|
indicatorClass: 'twm-sortable-list__drop',
|
|
209
|
-
draggableGuard: (
|
|
227
|
+
draggableGuard: () => {
|
|
228
|
+
const fromHandle = this._pressedHandle;
|
|
229
|
+
this._pressedHandle = false;
|
|
230
|
+
return fromHandle;
|
|
231
|
+
},
|
|
210
232
|
onReorder: (order) => this.#applyReorder(order),
|
|
211
233
|
});
|
|
212
234
|
}
|
|
@@ -229,7 +251,7 @@ export class SortableList {
|
|
|
229
251
|
|
|
230
252
|
#createRow(item, index) {
|
|
231
253
|
const row = document.createElement('div');
|
|
232
|
-
row.className =
|
|
254
|
+
row.className = ROW_CLASS;
|
|
233
255
|
row.dataset.index = index;
|
|
234
256
|
|
|
235
257
|
// Drag handle (if reordering enabled). The drag mechanics are
|
|
@@ -237,7 +259,7 @@ export class SortableList {
|
|
|
237
259
|
// handle is just the grab affordance the drag is restricted to.
|
|
238
260
|
if (this.allowReorder) {
|
|
239
261
|
const handle = document.createElement('div');
|
|
240
|
-
handle.className =
|
|
262
|
+
handle.className = HANDLE_CLASS;
|
|
241
263
|
handle.innerHTML = '<span class="material-symbols-outlined">drag_indicator</span>';
|
|
242
264
|
row.appendChild(handle);
|
|
243
265
|
}
|