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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "flexdesk",
3
- "version": "0.16.0",
3
+ "version": "0.16.1",
4
4
  "description": "A tiling window manager and widget set for desktop-class web applications. Zero runtime dependencies.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -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
+ }
@@ -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.
@@ -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: '.sortable-list__row',
223
+ itemSelector: ROW_SELECTOR,
206
224
  keyAttr: 'index',
207
225
  axis: 'y',
208
226
  indicatorClass: 'twm-sortable-list__drop',
209
- draggableGuard: (e) => !!e.target.closest('.twm-sortable-list__handle'),
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 = 'twm-sortable-list__row';
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 = 'twm-sortable-list__handle';
262
+ handle.className = HANDLE_CLASS;
241
263
  handle.innerHTML = '<span class="material-symbols-outlined">drag_indicator</span>';
242
264
  row.appendChild(handle);
243
265
  }