flexdesk 0.17.3 → 0.17.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/CHANGELOG.md +86 -65
  2. package/README.md +27 -4544
  3. package/css/base.css +18 -1
  4. package/css/flexdesk.css +18 -1
  5. package/dist/{chunk-4A7SJC3T.js → chunk-RKNFBX5K.js} +15 -4
  6. package/dist/{chunk-4A7SJC3T.js.map → chunk-RKNFBX5K.js.map} +2 -2
  7. package/dist/flexdesk.css +18 -1
  8. package/dist/tiles.js +1 -1
  9. package/dist/widgets.js +14 -2
  10. package/dist/widgets.js.map +2 -2
  11. package/docs/README.md +97 -0
  12. package/docs/best-practices.md +45 -0
  13. package/docs/components/charts.md +211 -0
  14. package/docs/components/command-palette.md +102 -0
  15. package/docs/components/conversation.md +72 -0
  16. package/docs/components/data-table-grid.md +328 -0
  17. package/docs/components/data-table.md +596 -0
  18. package/docs/components/editor.md +22 -0
  19. package/docs/components/flow-canvas-editor.md +482 -0
  20. package/docs/components/flow-editors.md +176 -0
  21. package/docs/components/flow-lane-editor.md +165 -0
  22. package/docs/components/flow-outline-editor.md +176 -0
  23. package/docs/components/forms-and-dialogs.md +294 -0
  24. package/docs/components/input-fields.md +72 -0
  25. package/docs/components/keymap.md +143 -0
  26. package/docs/components/layout-widgets.md +163 -0
  27. package/docs/components/markdown-and-outline.md +106 -0
  28. package/docs/components/menus.md +86 -0
  29. package/docs/components/notifications.md +194 -0
  30. package/docs/components/panels-and-desktops.md +127 -0
  31. package/docs/components/record-list-and-fact-table.md +128 -0
  32. package/docs/components/settings-view.md +112 -0
  33. package/docs/components/shell.md +189 -0
  34. package/docs/components/slide-out-panel.md +68 -0
  35. package/docs/components/tiles.md +210 -0
  36. package/docs/components/wizard.md +54 -0
  37. package/docs/concepts/building-and-packaging.md +33 -0
  38. package/docs/concepts/domain-neutrality.md +110 -0
  39. package/docs/concepts/host-port.md +137 -0
  40. package/docs/concepts/packages-and-entry-points.md +32 -0
  41. package/docs/concepts/peer-dependencies.md +42 -0
  42. package/docs/concepts/state-guard.md +58 -0
  43. package/docs/concepts/styling.md +47 -0
  44. package/docs/quickstart.md +179 -0
  45. package/package.json +2 -1
  46. package/src/ui/components/slide_out_panel.js +14 -3
  47. package/src/ui/utils/auto_scrollbars.js +16 -2
@@ -0,0 +1,127 @@
1
+ # Panels and desktops
2
+
3
+ > **Package:** `@flexdesk/wm` · **See also:** [Shell and navigation](shell.md) · [All docs](../README.md)
4
+
5
+ The shell's left, right and bottom panels, and its virtual desktops. A kind says which panels a page offers; each desktop remembers how large the user dragged them; `mountNavPanel` builds a navigation panel you can drive from code; and an app that keeps desktops of its own can mark them so the desktop bar treats them as the app's.
6
+
7
+ **On this page**
8
+
9
+ - [Panels a page does not offer (0.17.0)](#panels-a-page-does-not-offer-0170)
10
+ - [Panels keep the size you dragged them to (0.17.0)](#panels-keep-the-size-you-dragged-them-to-0170)
11
+ - [A navigation panel you drive yourself: `mountNavPanel` (0.17.0)](#a-navigation-panel-you-drive-yourself-mountnavpanel-0170)
12
+ - [Your desktops beside the app's (0.10.0)](#your-desktops-beside-the-apps-0100)
13
+
14
+ ## Panels a page does not offer (0.17.0)
15
+
16
+ A kind says which panels it offers while it is the primary tile. Each side is
17
+ `true`, `false` or `{ reason }`; a side it does not name is offered, and a kind
18
+ that does not name a side takes it from its top-nav kind, so one declaration on
19
+ a page covers every record opened under it:
20
+
21
+ ```js
22
+ kinds: {
23
+ paper: { label: 'Paper', isTopNav: true, panels: { bottom: { reason: 'No console here' } } },
24
+ sheet: { label: 'Sheet', topNav: 'paper' }, // withholds it too
25
+ slide: { label: 'Slide', topNav: 'paper', panels: { bottom: true } }, // offers it
26
+ },
27
+ ```
28
+
29
+ While a panel is withheld it is not in the layout, `wm.togglePanel(side)`
30
+ answers `false` and changes nothing (nor what is saved), `wm.isPanelOpen(side)`
31
+ is `false`, and the shell's toggle button and the palette chip carry
32
+ `aria-disabled="true"` with the reason as their tooltip (`data-tooltip` on a
33
+ `twm-has-tooltip` button, `title` otherwise; the button's own comes back after).
34
+ The desktop still remembers the panel was open, and it comes back open when the
35
+ user returns to a page that offers it. With no declarations every panel is
36
+ offered, exactly as before.
37
+
38
+ Content overrides its kind at runtime, until it clears the override with `null`:
39
+
40
+ ```js
41
+ ctx.wm.setPanelAvailability('bottom', { reason: 'Run the model first' });
42
+ ctx.wm.setPanelAvailability('bottom', null); // e.g. in destroy()
43
+ ```
44
+
45
+ `wm.isPanelAvailable(side)` and `wm.getPanelAvailability(side)` (`{ available,
46
+ reason }`) read it, and a change is announced once — whatever caused it and
47
+ however many sides it moved — to `wm.onPanelAvailabilityChange(fn)` (which
48
+ returns the unsubscribe) and on the bus as `wm:panels:availability`, both with
49
+ `{ panels, changed }`. `togglePanel` now answers `true` when it flipped a panel.
50
+
51
+ `PanelStateMachine`, `PANEL_STATES` and `PANEL_TRANSITIONS` are gone from
52
+ `@flexdesk/widgets`: nothing in the library used them, and which panels a page
53
+ offers is now said once, in the taxonomy.
54
+
55
+ `tests/panel_availability.test.mjs` drives all of it against a real shell.
56
+
57
+ ## Panels keep the size you dragged them to (0.17.0)
58
+
59
+ Each desktop remembers every panel's share of the window-manager area along
60
+ its axis (width for left and right, height for bottom), taken when a splitter
61
+ drag ends, and saves it with the layout as `panelShares`. A reload, a switch to
62
+ another desktop and back, and toggling one panel all rebuild the panels at
63
+ those shares, and a closed panel comes back at the size it had. Only a panel
64
+ that has never been sized on that desktop opens at the default 1:4. No split is
65
+ built tighter than a drag allows (`TileRenderer.MIN_SLOT_FRACTION` of it per
66
+ slot). A `TileRenderer` built by hand takes `onSplitResized(splitId)`, called
67
+ once when a drag that changed a split's sizes ends.
68
+
69
+ `tests/panel_sizes_kept.test.mjs` covers each of these.
70
+
71
+ ## A navigation panel you drive yourself: `mountNavPanel` (0.17.0)
72
+
73
+ `mountNavPanel(hostEl, { modes })` (from `@flexdesk/wm`) builds a mode strip, a
74
+ shared filter box and one lazily-mounted pane per mode. A strip click is no
75
+ longer the only way to switch: the handle's `select(modeId)` takes the same
76
+ path — it mounts the mode on first use, runs its `activate()`, restores that
77
+ mode's filter text — and its promise resolves once `activate()` has run. An
78
+ unknown id is ignored. `active()` names the mode shown.
79
+
80
+ ```js
81
+ const nav = mountNavPanel(hostEl, {
82
+ modes: [
83
+ { id: 'files', icon: 'folder', label: 'Files', mount: mountFiles },
84
+ { id: 'runs', icon: 'history', label: 'Runs', mount: mountRuns,
85
+ filter: false }, // a pane with controls of its own
86
+ ],
87
+ showModeStrip: false, // no strip: the app switches with select()
88
+ initialMode: 'runs', // the mode shown at mount (default: the first)
89
+ });
90
+ await nav.select('files'); // mounted and activated
91
+ nav.active(); // 'files'
92
+ ```
93
+
94
+ A mode with `filter: false` hides the filter box while it is active, and its
95
+ pane is never handed a query; the next mode with a filter shows its own saved
96
+ text again. `showFilter: false` renders no filter box at all. All three options
97
+ default to the old behaviour, so a panel built without them renders exactly as
98
+ before.
99
+
100
+ ## Your desktops beside the app's (0.10.0)
101
+
102
+ Pass `chrome.desktops` and the shell paints a desktop bar: a chip per desktop
103
+ and a `+`. An app that keeps desktops of its own (one per page, say) marks them
104
+ on the desktop objects themselves:
105
+
106
+ ```js
107
+ const shell = await createShell({ /* … */ eventBus,
108
+ chrome: { desktops: barEl }, desktopBar: { switchOnAdd: true } });
109
+ shell.wm.desktops.ensureCount(pages.length);
110
+ shell.wm.desktops.desktops.slice(0, pages.length)
111
+ .forEach((d) => { d.group = 'Workbench'; d.fixed = true; });
112
+ eventBus.emit('wm:changed', {}); // repaint the bar
113
+ ```
114
+
115
+ Desktops that share a **`group`** are one chip, labelled by the group, and one
116
+ stop for Ctrl+Left/Right; the chip goes back to the member that was on screen
117
+ last (`wm.desktops.chips()` says what the bar draws). A **`fixed`** desktop is
118
+ the app's: the bar offers no rename and no delete, no menu offers it as a
119
+ *Move to desktop* target, and `removeDesktop`, `moveFocusedToDesktop` and
120
+ `moveWindowToDesktop` refuse it. Both are persisted. A desktop the user adds is
121
+ numbered among the ungrouped ones, and `desktopBar.switchOnAdd` puts it on
122
+ screen. With neither set anywhere, the bar is what it always was.
123
+
124
+ *Move to desktop* now carries every tab of the pane and adds them to the target's
125
+ main pane; it used to replace that pane with the active tab alone. A target still
126
+ holding only its seed is replaced, as before. `dispose()` removes the bar, so a
127
+ rebuilt shell paints a fresh one.
@@ -0,0 +1,128 @@
1
+ # Record lists and fact sheets
2
+
3
+ > **Package:** `@flexdesk/widgets` · **For a table of rows:** [DataTable](data-table.md) · [All docs](../README.md)
4
+
5
+ Two read-only components for the parts of an application page that are not a table of rows. `createRecordList` draws a list of a few records, each a name with a quiet line of facts, pills and verbs beside them, and a state. `createFactTable` draws a sheet of facts, a name and its value row after row, and can show a before and an after. Neither needs a height: each is as tall as its contents, in any parent.
6
+
7
+ ## Record lists and fact sheets (0.7.0)
8
+
9
+ Two read-only components for the parts of an application page that are not a
10
+ table of rows. Both are new: nothing that exists changes, and a page that does
11
+ not call them looks as it did. Neither needs a height: each is exactly as tall
12
+ as its contents, in any parent.
13
+
14
+ **`createRecordList(host, config)`** draws a list of records, each as a name
15
+ with a quiet line of facts under it, pills and verbs beside them, and a state.
16
+ It is for the administration list of a few records with their own actions
17
+ (keys, connections, deliveries, members), where a table would be the wrong
18
+ shape.
19
+
20
+ ```js
21
+ import { createRecordList } from '@flexdesk/widgets';
22
+
23
+ const list = createRecordList(host, {
24
+ records: keys,
25
+ describe: (k) => ({
26
+ name: k.name,
27
+ facts: [k.key_id, k.role, k.expires ? `expires ${k.expires}` : 'never expires'],
28
+ pills: [k.paused ? { text: 'paused', tone: 'warn' } : { text: 'live', tone: 'success' }],
29
+ state: k.revoked ? 'off' : k.paused ? 'paused' : 'active',
30
+ verbs: [
31
+ { label: k.paused ? 'Resume' : 'Pause', onClick: (key) => toggle(key),
32
+ refusal: k.revoked ? 'This key is revoked.' : null },
33
+ { label: 'Revoke', danger: true, onClick: (key) => revoke(key) },
34
+ ],
35
+ }),
36
+ getKey: (k) => k.key_id,
37
+ rowAttrs: (k) => ({ 'data-key-id': k.key_id }),
38
+ onActivate: (k) => open(k), // one click, or Enter
39
+ activeKey: openKeyId,
40
+ emptyState: 'No keys yet.',
41
+ });
42
+ list.setRecords(next); // focus stays on the same record's same verb
43
+ ```
44
+
45
+ What `describe(record, idx)` returns (without `describe`, each record is
46
+ this object itself):
47
+
48
+ | Field | What it is |
49
+ |---|---|
50
+ | `name` | The first line: text or a node. `mono: true` draws it in the monospace face. |
51
+ | `facts` | The quiet line: text, a node, or an array whose `null`, `false` and `''` parts are left out and the rest joined with `factSeparator` (`' — '`). A part may be a node. |
52
+ | `pills` | Strings or `{ text, tone, title }`. The tones drawn are `muted`, `info`, `success`, `warn` and `danger`. |
53
+ | `verbs` | `{ label, onClick(record, ev), refusal, danger, primary, icon, title, name }`. A verb with a `refusal` is a disabled button with the reason on its `title` and `data-refusal`, **and the reason is a line of text under the facts** (`twm-record__refusal`, named by `data-refusal-for`, which the button is `aria-describedby`). A reason shared by every verb is said once. `null` and `false` verbs are left out. |
54
+ | `state` | A word: `data-state` and `twm-record--state-<word>` on the row. `paused`, `off` and `dead` dim the words and pills but never the verbs; `dead` and `error` mark the left edge in the danger colour. |
55
+ | `error` | A line in the error colour under the facts. |
56
+ | `key` | The record's key, over `getKey`. |
57
+
58
+ | Option | What it does |
59
+ |---|---|
60
+ | `getKey(record, idx)` | The key (`data-record-key`). Defaults to `spec.key`, then `record.key`, then `record.id`, then the index. Keys compare as strings. |
61
+ | `onActivate(record, idx, ev)` | Opens a record. It follows `DataTable`'s `onRowActivate` rule (`row_activation.js`): not for a press on a verb or any control, not for the click that ends a drag selecting text in the list, not for the second click of a double-click. Rows get a pointer and a hover. |
62
+ | `activateOn` | `'click'`, `'dblclick'`, `'enter'`, or an array. Default `['click', 'enter']`. |
63
+ | `keyboard` | Defaults to on exactly when `onActivate` is set. The records are then one tab stop (the last focused, else the open one, else the first), each enabled verb keeps its own, ArrowUp/ArrowDown move between records (from a record or one of its verbs), Home/End go to the ends, and Enter on a record opens it. |
64
+ | `activeKey`, `setActiveKey(key)`, `getActiveKey()` | The record that is open: `twm-record--active` and `aria-current="true"`, drawn as an accent on the left edge. It survives `setRecords`. |
65
+ | `rowClass(record, idx)`, `rowAttrs(record, idx)` | Classes and attributes for the record's `<li>`, as `DataTable`'s (`null`/`false` leaves an attribute off, `true` sets it empty). |
66
+ | `emptyState` | Text, a node, or a function returning either, shown when there are no records. |
67
+ | `facts: 'ellipsis'` | Keeps every fact line to one line and puts the whole line on its `title`. By default a long fact line wraps. |
68
+ | `verbClass` | A class string, or `(verb) => string`, for a consumer with its own buttons. By default verbs are FlexDesk's `twm-btn`, with `--primary` and `--danger`. |
69
+ | `label`, `className` | The list's `aria-label`; a class on the root. |
70
+
71
+ Methods: `setRecords(records)` (keeps the focus on the same record's same verb,
72
+ by key), `getRecords()`, `refresh(key)` (asks `describe` again for that record
73
+ only; with no key, all), `getItem(key)`, `focusRecord(key)`, `dispose()`, and
74
+ `element` (the root).
75
+
76
+ **`createFactTable(host, config)`** draws a sheet of facts: a name and its
77
+ value, row after row. A `DataTable` is the wrong tool for this, because it
78
+ draws a header row, clips a long value to one line and needs a box with a
79
+ height. A fact sheet has no header row, never truncates (a value wraps at
80
+ spaces, inside a long word or URL when it must, and keeps its own line breaks),
81
+ and is as tall as its rows. The label column stays on one line.
82
+
83
+ ```js
84
+ import { createFactTable } from '@flexdesk/widgets';
85
+
86
+ createFactTable(host, {
87
+ rows: [
88
+ { label: 'Server', value: 'db-main-01.internal' },
89
+ { label: 'Connection string', value: dsn, mono: true },
90
+ { label: 'Options', value: { pool: { min: 2, max: 16 } } }, // drawn as indented JSON
91
+ { label: 'Replica', value: null },
92
+ ],
93
+ nullDisplay: '—',
94
+ });
95
+
96
+ // Before and after:
97
+ createFactTable(host, {
98
+ compare: true,
99
+ headers: ['Field', 'Now', 'After the restore'],
100
+ rows: [{ label: 'Amount', before: 1200, after: 950 }, { label: 'Reviewer', after: 'Bea Beta' }],
101
+ });
102
+ ```
103
+
104
+ | Option | What it does |
105
+ |---|---|
106
+ | `rows` | Objects `{ label, value }` (or `{ label, before, after }` with `compare`), with optional `mono`, `numeric`, `labelTitle` (a tooltip on the label), `key` (`data-key`) and `status`; or arrays of cells, `[label, value, …]`. A value is text, a number, a node, or an object (drawn as indented JSON). |
107
+ | `compare: true` | Each row draws its label, its before, an arrow and its after. A row is `twm-facts__row--changed`, `--same`, `--added` (no `before`) or `--removed` (no `after`), worked out from the two sides (objects by their JSON, a node always changed) unless the row's `status` says otherwise; `data-status` says the same. An absent side reads as `missing` (`'—'`). A row with a plain `value` spans both sides. |
108
+ | `headers` | Adds a header row (there is none by default). With `compare`, three names stand over the label, before and after columns. |
109
+ | `numeric` | Column indices drawn in tabular numerals (`twm-facts__num`), header included. |
110
+ | `mono` | Every value in the monospace face. A row's own `mono` does it for that row. |
111
+ | `nullDisplay` | What `null` and `undefined` read as (`twm-facts__null`). By default the cell is empty. |
112
+ | `labelColumn: false` | The first column is ordinary data, not a label. |
113
+ | `wrapLabels: true` | Lets a long label wrap. |
114
+ | `wrap` | How a value wraps. `'anywhere'` (the default) wraps at spaces, and inside a long word or URL when it must. `'words'` wraps at spaces only, so a long word stays whole and the sheet grows wider; use it inside a sideways scroller. `'none'` keeps only the value's own line breaks. |
115
+ | `rowClass(row, idx)`, `rowAttrs(row, idx)` | Classes and attributes for each `<tr>`. |
116
+ | `emptyState`, `label`, `className` | Shown (spanning the sheet) when there are no rows; the `aria-label`; a class on the `<table>`. |
117
+
118
+ Methods: `setRows(rows)`, `getRows()`, `getRow(idx)` (the `<tr>`), `dispose()`,
119
+ and `element` (the `<table>`).
120
+
121
+ Both were measured in headless Edge with real input (`demo/records.html`,
122
+ driven by `demo/records_probe.mjs`): no list, record, sheet or row has zero
123
+ height and none overflows its card; a long fact line, a long URL, a connection
124
+ string and a JSON value wrap inside their column while labels stay on one line;
125
+ every verb, refused ones included, is what `elementFromPoint` finds at its
126
+ centre, and a real press runs it without opening the record; real Tab,
127
+ ArrowDown and Enter open a record; at 300px the verbs go under the record's
128
+ words, inside the card.
@@ -0,0 +1,112 @@
1
+ # Settings page
2
+
3
+ > **Package:** `@flexdesk/widgets` (the store is in `@flexdesk/core`) · [All docs](../README.md)
4
+
5
+ `SettingsView` draws a settings store's schema as a page: a category list, a filter, and the settings themselves. `SettingsCategories` draws the sidebar parts for a view somewhere else, such as an app's left navigation. Where the settings are *saved* is a separate choice, covered under [storage keys](../concepts/domain-neutrality.md#storage-keys-and-names-are-yours-0170).
6
+
7
+ ## A settings page: `SettingsView` (0.17.0)
8
+
9
+ `new SettingsView({ store, eventBus })` (in `@flexdesk/widgets`) draws a
10
+ settings store's schema as a page. `store` is a `createSettingsStore()` handle
11
+ or the app-global binding's own, `getSettingsStore()` from `@flexdesk/core`
12
+ (its `set` is the one `setSetting` calls); `eventBus` is the bus that store
13
+ emits on (`registerSettingsEventBus(bus)` for the binding).
14
+
15
+ ```js
16
+ import { getSettingsStore, registerSettingsEventBus } from '@flexdesk/core';
17
+ import { SettingsView, settingsViewContent } from '@flexdesk/widgets';
18
+
19
+ registerSettingsEventBus(bus);
20
+ const view = new SettingsView({ store: getSettingsStore(), eventBus: bus });
21
+ view.mount(host);
22
+ view.selectCategory('window');
23
+ view.setQuery('shadow'); // what the filter box does
24
+ await view.resetAll(); // asks first; true when it reset
25
+ view.dispose();
26
+
27
+ // or as tile content (props.category picks the category shown first):
28
+ createShell({ content: { settings: settingsViewContent({ store: getSettingsStore(), eventBus: bus }) } });
29
+ ```
30
+
31
+ The sidebar holds a filter box, every category in `CategoryDef.order` and
32
+ *Reset all*. Beside it the chosen category's settings are listed under its
33
+ title, description and *Reset category*, grouped by `SettingDef.group` in
34
+ schema order. A row shows the label, a *Modified* marker while the value
35
+ differs from its default, the description, the path, its control and a reset
36
+ button. Text in the filter searches every category's labels, descriptions and
37
+ paths; the matches are listed under "Category › Group", and choosing a
38
+ category clears it.
39
+
40
+ | `type` | Control |
41
+ | --- | --- |
42
+ | `boolean` | a `ToggleField` |
43
+ | `select` | a select over `options`; a value the options lack (stored, or set elsewhere while the view is open) is kept and shown as *value (not found)* |
44
+ | `number` | a number box with `min`, `max` and `step`; a written value is always in the range and on the step grid (`min + k·step`, or `k·step` with no `min`): snapped to the nearest grid point, and one outside the range moved inward to the nearest grid point inside it (with `max` below `min` the range holds none, and the value is `min`); with no `step` it is only clamped; an empty or unreadable box goes back to the value |
45
+ | `text` | a text box, written `textDebounceMs` (default `SETTINGS_TEXT_DEBOUNCE_MS`, 300) after the last keystroke, or at once on Enter or blur; emptied, it writes `null` when the default is `null` |
46
+ | `colorList` | a `ColorListField`, written on each committed edit while every row holds a colour |
47
+
48
+ A type it does not know shows the value as text, read only.
49
+
50
+ The view is live both ways: a control writes with `store.set`, and the view
51
+ listens for `settings:<path>:changed` on every schema path, `settings:changed`
52
+ with a null path (the resets) and `settings:registered`, which the store now
53
+ emits from `extend()` / `registerSettings()` with `{ paths, categories }`. So a
54
+ `setSetting` made anywhere moves the open view, and a slice registered after
55
+ mount adds its categories and rows without a remount. A row's reset button
56
+ resets that one setting; *Reset category* and *Reset all* first ask
57
+ `confirm({ title, message, confirmLabel, danger })` (default `openConfirm`) and
58
+ change nothing unless it resolves true. Every label, description, category name
59
+ and query is set as text, so markup in any of them is shown as typed.
60
+
61
+ Options: `store`, `eventBus` (both required), `confirm`, `category`,
62
+ `textDebounceMs`, `title` (the sidebar heading, and the content factory's tab
63
+ title; default "Settings"). The markup is `.twm-settings` >
64
+ `.twm-settings__sidebar` + `.twm-settings__content`, with rows
65
+ `.twm-settings__row` (`--modified`); `flexdesk.css` styles it from tokens.
66
+ `tests/settings_view.test.mjs` covers it in jsdom.
67
+
68
+ ## The settings categories outside the page: `SettingsCategories` (0.17.2)
69
+
70
+ A `SettingsView` made with `sidebar: false` draws no sidebar: no heading, no
71
+ filter box, no category list and no *Reset all*, only the chosen category's
72
+ settings (or the filter's matches). `new SettingsCategories({ view })` (in
73
+ `@flexdesk/widgets`) draws those parts for that view wherever it is mounted,
74
+ for example in an app's left navigator:
75
+
76
+ ```js
77
+ import { SettingsView, SettingsCategories } from '@flexdesk/widgets';
78
+
79
+ const view = new SettingsView({ store, eventBus: bus, sidebar: false });
80
+ view.mount(pageHost);
81
+ const cats = new SettingsCategories({ view });
82
+ cats.mount(navigatorHost);
83
+
84
+ const off = view.subscribe(({ category, query }) => { /* e.g. a tab title */ });
85
+ // …
86
+ off();
87
+ cats.dispose();
88
+ view.dispose();
89
+ ```
90
+
91
+ It keeps no state of its own. Typing in its filter box is `view.setQuery()`,
92
+ clicking a category is `view.selectCategory()` and its *Reset all* is
93
+ `view.resetAll()`, which asks the view's `confirm` first and changes nothing
94
+ unless it resolves true. It redraws from the view whenever the view's category,
95
+ filter or registered categories change, so the two agree whichever side moved
96
+ them: `view.selectCategory('window')` marks that category in the list, and
97
+ `view.setQuery('shadow')` puts the text in its filter box. As in the view's own
98
+ sidebar, choosing a category clears the filter, and no category is marked while
99
+ the filter is in effect. Its clicks and keystrokes act once the view is mounted.
100
+
101
+ Two methods on `SettingsView` serve it and anything else that follows the view:
102
+ `subscribe(fn)` calls `fn({ category, query })` after each such change and
103
+ answers the unsubscribe; `isFiltering()` is true while the filter's trimmed
104
+ text is not empty. `settingsViewContent({ …, sidebar: false })` passes the
105
+ option through. A view made without `sidebar` (or with `sidebar: true`) draws
106
+ exactly as in 0.17.1.
107
+
108
+ The markup is `.twm-settings-categories` > `.twm-settings__search`,
109
+ `.twm-settings__nav` (`button.twm-settings__cat`, `--active`) and
110
+ `.twm-settings__reset-all`, the same parts and classes as the sidebar's;
111
+ `flexdesk.css` styles it, leaving its width and padding to the host.
112
+ `tests/settings_view.test.mjs` §11–§14 cover it.
@@ -0,0 +1,189 @@
1
+ # Shell and navigation
2
+
3
+ > **Package:** `@flexdesk/wm` · **Next:** [Panels and desktops](panels-and-desktops.md), [Command palette](command-palette.md), [Keys, Back and tab layout](keymap.md) · [All docs](../README.md)
4
+
5
+ `createShell` boots the tiling window manager into an element of your page: tiles that split and tab, the top navigation, dockable panels, virtual desktops and the command palette. It knows nothing about your domain. You give it a taxonomy of *kinds*, a content factory for each kind and a [host](../concepts/host-port.md), and it draws and persists the rest.
6
+
7
+ A complete first boot is in the [quickstart](../quickstart.md#hello-shell). This page covers what you can add to it: the opt-in shell features, kinds that exist only once, what happens when a link names a kind nobody registered, and what a pane can tell its own chrome.
8
+
9
+ **On this page**
10
+
11
+ - [Opt-in shell features](#opt-in-shell-features)
12
+ - [A kind that exists once: `single` (0.17.0)](#a-kind-that-exists-once-single-0170)
13
+ - [An open single instance takes new props: `navigate(props)` (0.17.2)](#an-open-single-instance-takes-new-props-navigateprops-0172)
14
+ - [A link to a kind nobody registered (0.17.2)](#a-link-to-a-kind-nobody-registered-0172)
15
+ - [A pane that names what it shows: `ctx.setTitle` (0.12.1)](#a-pane-that-names-what-it-shows-ctxsettitle-0121)
16
+ - [Buttons that change with the pane: `ctx.setChromeActions` (0.17.0)](#buttons-that-change-with-the-pane-ctxsetchromeactions-0170)
17
+
18
+ ## Opt-in shell features
19
+
20
+ These change what the user sees, so all of them are off until you ask:
21
+
22
+ ```js
23
+ await createShell({
24
+ // …everything above…
25
+ snapPromotion: true, // drag a floating window onto a tile: it snaps, and docks back in
26
+ promoteInPlace: true, // a window floated out of a tile stays inside that tile's pane
27
+ backToOpenList: true, // Back in a record opened beside its list closes onto that list
28
+ floatActiveTab: true, // floating a tile takes the tab on screen, not the whole pane
29
+ tabLayout: 'header', // tabs in the tile header, in place of its title, with a + (0.8.0)
30
+ newTabButton: false, // …and without the + (0.8.1)
31
+ chrome: {
32
+ topNav: document.getElementById('nav'),
33
+ zoom: document.getElementById('status-bar'), // content zoom, 50–200%
34
+ },
35
+ });
36
+ ```
37
+
38
+ ## A kind that exists once: `single` (0.17.0)
39
+
40
+ Some content holds heavy app-wide state and must not be mounted twice. Mark its
41
+ kind `single` in the taxonomy:
42
+
43
+ ```js
44
+ createTaxonomy({
45
+ root: 'cosmos',
46
+ kinds: {
47
+ cosmos: { label: 'Cosmos', isTopNav: true },
48
+ observatory: { label: 'Observatory', isTopNav: true, single: true }, // one, ever
49
+ planet: { label: 'Planet', single: 'id' }, // one per props.id
50
+ },
51
+ });
52
+ ```
53
+
54
+ - **Every open path reveals the instance that exists** — `navigate`, the top
55
+ nav, `openInPrimary`, `openInTab*`, `splitLeafWith`, Back. It switches to the
56
+ desktop it is on, activates its tab (bringing back a top-nav page the tile
57
+ had switched away from) and focuses its tile, or raises its window. Nothing
58
+ is mounted twice, and a reveal is one `onChange` / `wm:changed`
59
+ (`'single-reveal'`), however many steps it took.
60
+ - **A page restore never brings back a copy.** A tile's archived top-nav page
61
+ can still list an instance that has since moved to another tile or a window;
62
+ switching back to that page (top nav, `openInPrimary`, Back) leaves it out.
63
+ - **The copy verbs refuse.** Alt+T, Alt+N, Alt+Shift+H/V do nothing; the tile
64
+ menu's *Open in new tab* and *Open a copy in a window* are greyed with the
65
+ reason as their tooltip (`taxonomy.singleReason(kind)`); the palette draws its
66
+ split and window hints greyed, and Ctrl/Shift+Enter on such a row does nothing.
67
+ - **The move verbs still work**: float, dock, drag a tab, move to a desktop.
68
+ - **A restored layout keeps the first** of any duplicates (desktop order, then
69
+ tile order): the other leaf shows its next tab, or closes if it held nothing
70
+ else.
71
+
72
+ The root kind cannot be `single` — every new pane, tab and desktop is seeded
73
+ with it. The taxonomy answers `isSingle(kind)`, `instanceKey(kind, props)` and
74
+ `singleReason(kind)`; the window manager answers `isSingleKind(kind)`,
75
+ `findInstance(kind, props)` and `revealInstance(kind, props)`.
76
+
77
+ ## An open single instance takes new props: `navigate(props)` (0.17.2)
78
+
79
+ Revealing a `single` instance used to drop the props the open asked for: the
80
+ observatory kept showing the planet it was opened with. A content factory that
81
+ returns a `navigate(props)` hook from its mount is now handed them:
82
+
83
+ ```js
84
+ content: {
85
+ observatory: (el, props) => {
86
+ const view = mountObservatory(el, props);
87
+ return {
88
+ destroy: () => view.destroy(),
89
+ navigate: (next) => view.focusPlanet(next.planet), // already open: go there
90
+ };
91
+ },
92
+ },
93
+
94
+ wm.navigate('observatory', { planet: 'mars' }); // open: revealed, then navigate({ planet: 'mars' })
95
+ ```
96
+
97
+ - **Every open path that reveals the instance calls it once**, after the
98
+ reveal, with a copy of the new props — `navigate`, the top nav,
99
+ `openInPrimary`, `openInTab*`, `splitLeafWith`, Back. The same props twice
100
+ are two calls; nothing is deduplicated, and no second instance is mounted.
101
+ - **The props are recorded on the instance's tab** (or its window's), so a
102
+ top-nav page restore or a reload mounts it with the latest. The tile is not
103
+ rebuilt by the write.
104
+ - **An open that names no props only reveals** — the top-nav button,
105
+ `openInPrimary(kind)`, `navigate(kind)`: it asks to see the instance, not to
106
+ empty it, so the hook is not called and the tab keeps its props.
107
+ - **A content without the hook is revealed as before**: its tab keeps its props
108
+ and nothing is called. A hook that throws is a console warning.
109
+
110
+ ## A link to a kind nobody registered (0.17.2)
111
+
112
+ `wm.navigate(kind, props, opts)` returns `true` once it has routed the
113
+ navigation. A `kind` the shell's `content` map has no factory for is refused
114
+ instead, on every destination (`main`, `origin`, a new tab, a window, a split,
115
+ the legacy `target`):
116
+
117
+ ```js
118
+ if (!wm.navigate(link.kind, link.props)) {
119
+ // nothing opened: tell the user the link is dead
120
+ }
121
+ ```
122
+
123
+ - **One `console.warn` naming the kind**, per call, and `false` back.
124
+ - **Nothing changes**: no tile, tab, window or desktop, no per-tab history, no
125
+ `onChange` — and no "no factory registered yet" placeholder tile, which is
126
+ what such a link used to leave behind.
127
+ - **No exception reaches the caller**, whatever `kind` is (`undefined`, `null`
128
+ and `''` included).
129
+
130
+ A shell that only navigates to kinds it registered sees no change; the return
131
+ value is new, so code that ignored it is unaffected. A split destination used
132
+ to hand back the new leaf's id; it now returns `true` like the others — call
133
+ `wm.splitLeafWith(...)` for the id. A restored layout that names an
134
+ unregistered kind still draws its placeholder: only `navigate` refuses.
135
+
136
+ ## A pane that names what it shows: `ctx.setTitle` (0.12.1)
137
+
138
+ A content factory receives `(hostEl, props, ctx)`, and `ctx` carries, per pane,
139
+ `setTitle(text)`. It renames the pane's chrome title, at mount or any time
140
+ after, and the title survives repaints — including a panel's, whose tree title
141
+ the shell resets to Navigator / Inspector / Console on every layout pass:
142
+
143
+ ```js
144
+ content: {
145
+ 'panel:bottom': (hostEl, props, ctx) => {
146
+ // …a panel with tabs of its own…
147
+ const show = (tab) => { /* swap the body */ ctx.setTitle(tab.label); };
148
+ show(tabs[0]);
149
+ return { destroy() {} };
150
+ },
151
+ },
152
+ ```
153
+
154
+ `setTitle(null)` or `''` gives the title back to the tree. It changes what the
155
+ chrome shows only — the tree, the tab labels and the saved layout are left
156
+ alone — and a call from content whose pane has since been rebuilt does nothing.
157
+
158
+ ## Buttons that change with the pane: `ctx.setChromeActions` (0.17.0)
159
+
160
+ A factory may return `chromeActions` — `[{icon, title, onClick}]`, drawn in the
161
+ pane's chrome left of the split/float/close buttons — but those are read once.
162
+ `ctx.setChromeActions(specs)` replaces them at any time, the way `setTitle`
163
+ replaces the title: called during the mount it wins over the returned list, the
164
+ later call wins, the list survives repaints, `null` or `[]` clears it, and a
165
+ call from content whose pane has since been rebuilt does nothing. The content is
166
+ never re-mounted.
167
+
168
+ ```js
169
+ content: {
170
+ page: (hostEl, props, ctx) => {
171
+ let wide = false;
172
+ const paint = () => ctx.setChromeActions([
173
+ { icon: wide ? 'width_normal' : 'width_wide', title: 'Page width',
174
+ pressed: wide, onClick: () => { wide = !wide; paint(); } },
175
+ { separator: true },
176
+ { icon: 'add', label: 'New', title: 'Add a page', onClick: addPage },
177
+ { icon: 'publish', title: 'Nothing to publish yet', disabled: true },
178
+ ]);
179
+ paint();
180
+ return { destroy() {} };
181
+ },
182
+ },
183
+ ```
184
+
185
+ A spec may add **`label`** (text beside the glyph, or instead of it),
186
+ **`pressed`** (`aria-pressed`, plus a pressed style; leave it out for a button
187
+ that is not a toggle), **`disabled`** (greyed and inert, with its tooltip kept —
188
+ it is `aria-disabled`, not the `disabled` attribute) and **`{separator: true}`**
189
+ (a divider). A spec with none of them renders exactly as before.
@@ -0,0 +1,68 @@
1
+ # Slide-out panel
2
+
3
+ > **Package:** `@flexdesk/widgets` · **Used by:** [the tile settings form](tiles.md#a-tiles-settings-as-a-form-mounttileconfigform-0170) · [All docs](../README.md)
4
+
5
+ `SlideOutPanel` is an inline panel that slides in over its host container from the right edge. A page uses it to show the settings of the element the user picked (a plot, a widget, a node) without taking over the shell's right panel.
6
+
7
+ ## A slide-out panel: `SlideOutPanel`
8
+
9
+ ```js
10
+ import { SlideOutPanel } from '@flexdesk/widgets';
11
+
12
+ const panel = new SlideOutPanel({ width: 480, onClose: () => commit() });
13
+ panel.mount(hostEl); // hostEl: position: relative; overflow: hidden
14
+
15
+ const body = panel.open('Plot settings', 'tune'); // a title and a Material Symbols icon name
16
+ body.append(settingsForm); // you draw the content
17
+
18
+ panel.close(); // or Esc, the × button, or a click outside the panel
19
+ panel.dispose(); // closes it if open, then removes it from the host
20
+ ```
21
+
22
+ - **The host.** It must be `position: relative; overflow: hidden`. `mount(host)` adds a transparent, click-catching backdrop and the panel to it. A second `mount` does nothing.
23
+ - **Options.** `width` is in px and defaults to 700. The stylesheet gives the panel `min-width: 400px`, so a smaller width is held at 400, and no width goes past the host's own (see [A slide-out stays inside its host](#a-slide-out-stays-inside-its-host-0174)). `onClose` is called after the panel has closed and its content has been cleared.
24
+ - **`open(title, icon)`** clears the content, sets the header and returns the content element, which is also `panel.contentEl` and scrolls vertically. With no `icon` the header draws none. Opening a panel that is already open replaces its title, icon and content without sliding it in again. Before `mount`, `open` returns `null`. `isOpen` says whether it is open.
25
+ - **Closing.** The close button, a click on the backdrop and Esc all call `close()`. Esc is heard on the document while the panel is open, and the panel calls `stopPropagation()` on it. `close()` on a closed panel does nothing. `dispose()` closes first, so `onClose` runs if the panel was open.
26
+
27
+ ## Where it sits
28
+
29
+ While it is open the panel is `position: fixed`, pinned to the right edge of its host's visible frame, at the host's full height. A host may be a scroll container, and an absolutely positioned child of one would scroll away with the content. Fixed positioning keeps it in place, and the panel measures its host again whenever the geometry can have changed:
30
+
31
+ - on window scroll and window resize;
32
+ - when the host, or any of its ancestors, is resized. A tile splitter drag or a dock panel toggling moves the host without any window event, and a toggle beside a fixed-width tile resizes an ancestor while the tile only shifts;
33
+ - under the shell's content zoom the host's rectangle is read in screen pixels, so the panel divides it by its effective zoom (`currentCSSZoom`). It stays flush with the host's right edge, top and height at 50% and at 150%. An engine without `currentCSSZoom` is treated as 100%.
34
+
35
+ `tests/slide_out_host_resize.test.mjs` and `tests/slide_out_panel_zoom.test.mjs` drive the tracking in jsdom.
36
+
37
+ ## Styling
38
+
39
+ | Class | What it is |
40
+ |---|---|
41
+ | `.twm-slide-out-panel` | The panel. `--open` is added while it is open; it slides in over 200 ms. While open it carries the host's width as `--twm-slide-out-host-w`, its `max-width` and the ceiling of its 400px `min-width` (0.17.4) |
42
+ | `.twm-slide-out-panel__header` | The header row: the icon, the title and the close button |
43
+ | `.twm-slide-out-panel__icon` | The header icon, a `material-symbols-outlined` glyph at **18px** (0.17.4) |
44
+ | `.twm-slide-out-panel__title` | The title, on one line, cut with an ellipsis |
45
+ | `.twm-slide-out-panel__close` | The close button, also a `.twm-btn-icon`. Its `material-symbols-outlined` icon is **18px** (0.17.4) |
46
+ | `.twm-slide-out-panel__body` | The content area, `overflow-y: auto` |
47
+ | `.twm-slide-out-panel__backdrop` | The transparent click-catcher over the host. `--visible` is added while the panel is open |
48
+
49
+ The header icon and the close button are `material-symbols-outlined` glyphs. Their 18px rules use two classes each, so they win over the icon font's own 24px (the icon-font rule in [Theming and CSS](../concepts/styling.md)).
50
+
51
+ ## A slide-out stays inside its host (0.17.4)
52
+
53
+ A `SlideOutPanel` is never wider than the host it is mounted in. Its right edge
54
+ is pinned to the host's right edge, and while it is open it carries the host's
55
+ width as `--twm-slide-out-host-w`, which `.twm-slide-out-panel` uses as its
56
+ `max-width` and as the ceiling of its 400px `min-width`. In a host narrower than
57
+ the panel's `width` (700px by default) the panel is the host's width and starts
58
+ at the host's left edge, at any content zoom, and it is capped again whenever
59
+ the host is resized (a splitter drag, a dock toggling). In a wider host nothing
60
+ changes.
61
+
62
+ The header icon (`.twm-slide-out-panel__icon`) and the close button's icon are
63
+ **18px**, by rules that win over `.material-symbols-outlined`'s 24px.
64
+
65
+ Until 0.17.4 a panel in a narrow tile reached past the tile's left edge across
66
+ the neighbouring pane, and neither icon had a size of its own: both took
67
+ whatever the page's global icon size was. `tests/slide_out_panel_clamp.test.mjs`
68
+ holds it.