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,294 @@
1
+ # Forms and dialogs
2
+
3
+ > **Package:** `@flexdesk/widgets` · **See also:** [Input fields](input-fields.md), [Wizard](wizard.md) · [All docs](../README.md)
4
+
5
+ `openForm` opens a modal form and resolves with the values, or with `null` when the user cancels. `openConfirm` asks a yes-or-no question. The field types are `text`, `number`, `password`, `select`, `textarea`, `checkbox`, `toggle`, `picker`, `checklist`, `radio`, `color` and `colorList`.
6
+
7
+ ## Forms: `openForm`
8
+
9
+ `openForm({ title, fields, defaults, submitLabel })` (in `@flexdesk/widgets`)
10
+ opens a modal form and resolves with `{ [name]: value }`, or `null` when it is
11
+ cancelled. Fields are `text`, `number`, `password`, `select`, `textarea`,
12
+ `checkbox`, `toggle`, `picker`, `checklist`, `radio`, `color` and `colorList`;
13
+ `{ section: 'Label' }` entries are headings and `{ row: [...] }` entries put
14
+ fields side by side. The additions below are opt-in, with one change every form
15
+ sees, even one that uses none of them: a `select` or `radio` comes back with
16
+ its option's OWN value and type (a numeric option submits `2`, not `'2'`; an
17
+ option whose value is `null` submits `null`), a default of that type selects
18
+ its option (`defaults: { s: 2 }` selects the option whose value is `2`), and an
19
+ option object with no `label` shows its value. A select with no option
20
+ selected still reads and submits `''`.
21
+
22
+ ### A form that adapts: `onFieldChange`
23
+
24
+ ```js
25
+ openForm({
26
+ fields: [
27
+ { name: 'type', label: 'Type', type: 'select', options: ['bug', 'story'] },
28
+ { section: 'Story', name: 'story' }, // a NAMED section can be hidden
29
+ { name: 'points', label: 'Points', type: 'number', required: true },
30
+ ],
31
+ onFieldChange: (name, value, api) => {
32
+ const story = api.get('type') === 'story';
33
+ api.setVisible('story', story);
34
+ api.setVisible('points', story);
35
+ },
36
+ });
37
+ ```
38
+
39
+ `onFieldChange(name, value, api)` is called once when the form opens (with
40
+ `name === null`, so the form starts consistent with its defaults) and after
41
+ every field's `change`. `api` addresses fields and named sections by name:
42
+
43
+ - `setVisible(name, on)` hides or shows the row. A hidden row keeps its value
44
+ and is still submitted (a disabled option it holds submits as no value:
45
+ `''` for a select or combobox, `null` for a radio), but it is not asked
46
+ about: its `required` and its
47
+ `validator` stand down until it is shown again.
48
+ - `get(name)` reads the control as it stands: a checkbox as a boolean, a
49
+ select or radio as its option's own value (a select with no option selected
50
+ as `''`, a radio with none as `null`), a combobox as the value of the option
51
+ its text names — exactly what it submits — or as its text when it names
52
+ none, a checklist as the array of chosen values, anything else as its
53
+ string.
54
+ - `set(name, value)` writes it. It does not call `onFieldChange` back, so a
55
+ hook that fills one field from another cannot loop. A value a select or
56
+ radio does not offer, or offers only disabled, clears it (a select then
57
+ submits `''`, a radio `null`).
58
+ - `setOptions(name, options)` replaces the options of a `select` (a
59
+ combobox's suggestions too), `checklist` or `radio` while the form is open
60
+ — see below.
61
+
62
+ A hook that throws is logged and the form carries on.
63
+
64
+ ### Hidden rows stay hidden
65
+
66
+ `.twm-modal__row` is a grid, and a grid rule beats the `display: none` that
67
+ the `hidden` attribute gets from the browser, so a hidden row stayed on
68
+ screen. `flexdesk.css` now has `.twm-modal__row[hidden],
69
+ .twm-modal__section[hidden] { display: none !important }`, and `setVisible`
70
+ also sets the row's inline `display`, so it holds under a stylesheet of your
71
+ own as well.
72
+
73
+ ### A value you look up: `type: 'picker'`
74
+
75
+ ```js
76
+ { name: 'parent', label: 'Parent', type: 'picker', required: true,
77
+ pick: async (current) => ({ value: 'EPIC-0001', label: 'EPIC-0001 — Auth rewrite' }),
78
+ displayFor: (value) => titleOf(value) }
79
+ ```
80
+
81
+ The row shows a read-only box and a search button. The button, or a click on
82
+ the box, awaits `pick(currentValue)`. Return `{ value, label }` (the label is
83
+ shown, the value is submitted), a bare value (shown through `displayFor`, or
84
+ as itself), or `null` to leave the field as it was. `displayFor(value)` gives
85
+ the text for the value the form opens with and for `api.set`. The value is
86
+ kept in a hidden input, which the browser's own `required` check skips, so
87
+ `required: true` on a picker is checked by the form instead. The markup is
88
+ `.twm-picker` > `.twm-picker__display` + `.twm-btn.twm-picker__btn`.
89
+
90
+ ### A select you may type into: `create`
91
+
92
+ A `select` with a `create` spec is a type-ahead combobox: pick an option or
93
+ type a new value. `create: { onCreate, hint }` makes the typed value into an
94
+ entity before the form resolves, and shows "↳ will create *hint* …" while you
95
+ type. `create: true` is the plain case: the typed value is submitted as typed,
96
+ with no hint, because nothing is created. Until now `create: true` failed the
97
+ submit with "onCreate is not a function".
98
+
99
+ `tests/open_form.test.mjs` mounts the real `openForm` in jsdom and covers all
100
+ four, plus a plain form submitting as before.
101
+
102
+ ### Options: typed, unavailable, grouped, and changed while open (0.17.0)
103
+
104
+ ```js
105
+ openForm({
106
+ fields: [
107
+ { name: 'country', label: 'Country', type: 'select', options: ['no', 'it'] },
108
+ { name: 'town', label: 'Town', type: 'select', options: [] },
109
+ { name: 'plan', label: 'Plan', type: 'select', options: [
110
+ { value: 1, label: 'Free' },
111
+ { value: 2, label: 'Pro', disabled: true, reason: 'needs a licence' },
112
+ { group: 'Teams', options: [{ value: 10, label: 'Small team' }, { value: 50, label: 'Large team' }] },
113
+ ] },
114
+ { name: 'days', label: 'Days', type: 'checklist', required: true,
115
+ options: [{ value: 'mon', label: 'Mon' }, { value: 'tue', label: 'Tue' }] },
116
+ { name: 'mode', label: 'Mode', type: 'radio', options: [
117
+ { value: 'fast', label: 'Fast', description: 'Skips the checks.' },
118
+ { value: 'safe', label: 'Safe' },
119
+ ] },
120
+ ],
121
+ onFieldChange: (name, value, api) => {
122
+ if (name === null || name === 'country') api.setOptions('town', townsOf(api.get('country')));
123
+ },
124
+ });
125
+ // → { country: 'it', town: 'Rome', plan: 10, days: ['mon'], mode: 'fast' }
126
+ ```
127
+
128
+ An option is a bare value or `{ value, label?, disabled?, reason?,
129
+ description? }`.
130
+
131
+ - **Values keep their type.** An option whose value is the number 10 submits
132
+ 10, and `api.get` reads 10 — not `'10'`. A default matches an option of the
133
+ same text, so `defaults: { plan: '10' }` selects 10. A combobox entry typed
134
+ as an option's text submits (and `api.get` reads) that option's value. An
135
+ option whose value IS the given one wins over one that only has its text:
136
+ with options `[1, '1']`, a default `'1'` submits `'1'`.
137
+ - **A disabled option** is shown with its `reason` (in a select's option text
138
+ and tooltip, under a radio, on a chip) and is never the submitted value: a
139
+ default naming it does not select it, `api.set` to it clears a select,
140
+ radio or combobox and leaves a checklist's chip unpressed, the reader
141
+ cannot pick it, a combobox does not suggest it, and a submit
142
+ that finds one selected anyway (a script set `.value`, or every option is
143
+ disabled) is refused with "Not available: *reason*" on the field. In a
144
+ hidden row, which is not asked about, it submits as no value instead.
145
+ - **`{ group, options }`** in a select's list is an `<optgroup>` headed
146
+ *group*. In a checklist or radio it is a `.twm-choice__group` heading over
147
+ that group's chips or rows (a group with no options draws none). A combobox
148
+ suggests a group's enabled options, without headings.
149
+ - **`api.setOptions(name, options)`** replaces the list while the form is
150
+ open and keeps the current value when the new list offers it enabled.
151
+ Otherwise a select falls to its first enabled option, a radio to none and a
152
+ checklist drops what is gone. It does not call `onFieldChange` back.
153
+ - **`type: 'checklist'`** draws toggle chips and submits the array of chosen
154
+ values in the list's order (`[]` for none). `required` means at least one,
155
+ and nothing chosen is "Required." on the field.
156
+ - **`type: 'radio'`** draws one radio row per option, with its `description`
157
+ under it, and submits the chosen value, or `null`. `required` means one is
158
+ chosen.
159
+
160
+ The select, the chips and the radio list are the flow kit's
161
+ (`src/ui/components/choice_controls.js`): the settings panel's `enum` select,
162
+ `enum-chips`, `upstream-columns` and `choice-cards` draw with the same code, so
163
+ a form and a settings panel cannot drift apart. In a form they are classed
164
+ `.twm-flow-togglechips` / `.twm-flow-togglechip` and `.twm-flow-choices` /
165
+ `.twm-flow-choice`; an unavailable option's reason is `.twm-choice__reason`.
166
+
167
+ `tests/open_form_choices.test.mjs` covers each of these in jsdom.
168
+
169
+ ### Colours: `type: 'color'` and `type: 'colorList'` (0.17.0)
170
+
171
+ ```js
172
+ openForm({
173
+ fields: [
174
+ { name: 'accent', label: 'Accent', type: 'color', default: 'rgb(31,119,180)',
175
+ presets: ['#1f77b4', '#ff7f0e', '#2ca02c'] },
176
+ { name: 'palette', label: 'Palette', type: 'colorList', default: ['#1f77b4', '#ff7f0e'] },
177
+ ],
178
+ });
179
+ // -> { accent: '#1f77b4', palette: ['#1f77b4', '#ff7f0e'] }
180
+ ```
181
+
182
+ A `color` row is a swatch that opens the native picker, a hex box kept in step
183
+ with it (picking writes the box; typing a valid hex moves the swatch), and,
184
+ with `presets`, a strip of preset swatches. It submits lowercase `#rrggbb`, or
185
+ `''` when left empty. A `colorList` row is an ordered list of such fields: add,
186
+ remove, and reorder by drag or by the up / down buttons; `presets` are offered
187
+ on every row, and it submits the colours in their current order as an array of
188
+ `#rrggbb`. An `rgb()` / `rgba()` default (or `api.set` value) is shown as its
189
+ hex; alpha is dropped, because the native picker has none. A box that does not
190
+ hold `#rgb` or `#rrggbb` is a field error ("Not a hex colour", with the row
191
+ numbers for a list) and the form does not resolve; `required` on either is
192
+ checked by the form. `api.get` reads a `color` as its box text and a
193
+ `colorList` as the array of its rows' text.
194
+
195
+ Both are also components of their own in `@flexdesk/widgets`:
196
+ `new ColorField({ value, presets, name, label, onChange })` and
197
+ `new ColorListField({ value, presets, newColor, addLabel, name, onChange })`,
198
+ each with `render()`, `getValue()` (null while something typed is not a
199
+ colour), `setValue()`, `focus()` and `dispose()`; `onChange(value, { commit })`
200
+ reports every edit and, with `commit: true`, every committed one.
201
+ `normalizeHex(value)` (`#rgb`, `#rrggbb`, `rgb()` -> `#rrggbb` or null) and
202
+ `isHexColor(value)` are exported with them, and `validateField` (in
203
+ `@flexdesk/tiles`) now rejects a `FieldTypes.COLOR` value that is not a hex
204
+ colour. The markup is `.twm-color-field` > `.twm-color-field__swatch` +
205
+ `.twm-color-field__hex` (+ `.twm-color-field__presets`), and `.twm-color-list`
206
+ rows are `.twm-color-list__row`. `tests/color_field.test.mjs` covers all of it.
207
+
208
+ ### Number bounds and fields side by side: `min`, `max` and `{ row }` (0.17.0)
209
+
210
+ ```js
211
+ openForm({
212
+ fields: [
213
+ { name: 'steps', label: 'Steps', type: 'number', min: 2, max: 200, step: 1 },
214
+ { row: [
215
+ { name: 'lo', label: 'Min', type: 'number', hint: 'lowest value' },
216
+ { name: 'hi', label: 'Max', type: 'number', min: 0 },
217
+ ] },
218
+ ],
219
+ });
220
+ // → { steps: 50, lo: 0, hi: 10 }
221
+ ```
222
+
223
+ - **`min` / `max`** on a `number` field are the input's own `min` and `max`
224
+ attributes, and a value outside them is refused on submit with an error
225
+ under that field: "Must be between 2 and 200." (or "Must be at least 2." /
226
+ "Must be at most 200." with only one). The browser's own range check stops
227
+ such a submit first; the form cancels its bubble and shows the field error
228
+ instead, and it checks the range itself as well, before the field's
229
+ `validator`. An empty field that is not `required` is not out of range. A
230
+ row hidden by `setVisible` drops its bounds until it is shown again, as it
231
+ drops `required`. As with any `<input type="number">`, a `min` is also where
232
+ `step` counts from.
233
+ - **`{ row: [field, ...] }`** puts its fields on one line, sharing the width.
234
+ The row is layout only and has no name: each field in it is an ordinary
235
+ field with its own label, hint, error, `setVisible`, value and submitted
236
+ key, so hiding one hides only that one. The first keeps the form's label
237
+ column; the others size their label to its text. A form narrower than
238
+ 360px stacks them (a container query, `twm-form`, on a form that has a row;
239
+ a form without one is not made a container). A form with a row opens at the
240
+ wide (640px) layout. The row is `.twm-modal__field-group`.
241
+
242
+ A form with neither renders exactly as before.
243
+ `tests/open_form_rows_bounds.test.mjs` covers both in jsdom.
244
+
245
+ ### A switch: `ToggleField` and `type: 'toggle'` (0.17.0)
246
+
247
+ ```js
248
+ import { ToggleField } from '@flexdesk/widgets';
249
+
250
+ const autosave = new ToggleField({
251
+ label: 'Autosave', onText: 'On', offText: 'Off', // all optional
252
+ checked: true, compact: false, disabled: false,
253
+ onChange: (on) => settings.set('autosave', on),
254
+ });
255
+ host.appendChild(autosave.render());
256
+ autosave.get(); // true
257
+ autosave.set(false); // does not call onChange
258
+ autosave.setDisabled(true);
259
+ autosave.dispose();
260
+ ```
261
+
262
+ A track and a knob drawn over a real checkbox with `role="switch"` and an
263
+ `aria-checked` that follows it. Space and Enter toggle the focused switch, a
264
+ click on the track, the state text or the label toggles it, the focused input
265
+ draws a ring on the track, and a disabled switch ignores clicks and keys alike.
266
+ `ariaLabel` names a switch with no visible label. The markup is
267
+ `label.twm-toggle` (`--compact`, `--disabled`) > `.twm-toggle__control` >
268
+ `input.twm-toggle__input` + `.twm-toggle__track` > `.twm-toggle__knob`, then
269
+ `.twm-toggle__state` and `.twm-toggle__label`; `flexdesk.css` styles it from
270
+ tokens alone, with no colour literal.
271
+
272
+ In a form, `{ name, label, type: 'toggle', onText?, offText?, disabled?,
273
+ compact? }` mounts one and submits a boolean, like a checkbox; `api.get` reads
274
+ it as a boolean and `api.set` moves it. `tests/toggle_field.test.mjs` covers
275
+ both.
276
+
277
+ ## Confirms show text, not markup (0.17.0)
278
+
279
+ `openConfirm({ title, message, confirmLabel, cancelLabel, danger, icon })`
280
+ resolves `true` or `false`. Its `message` and both labels are **text**: they
281
+ are set with `textContent`, so a `<` shows as a `<`. Until 0.17.0 they went into
282
+ `innerHTML`, and `showDeleteConfirmDialog` / `showCloseConfirmDialog` put
283
+ `itemName` and `itemType` into that message raw: a flow named
284
+ `<img src=x onerror=…>` rendered an image and ran its handler, and a name with
285
+ an unclosed `<` swallowed the rest of the message.
286
+
287
+ A caller that passed markup in `message` now sees the tags; write a `\n` for a
288
+ line break instead (the message body is `white-space: pre-line`).
289
+ `additionalMessage` on `showDeleteConfirmDialog` follows on its own paragraph
290
+ exactly as before. A body that needs real markup is `openModal`'s job: it takes
291
+ the caller's own DOM.
292
+
293
+ `tests/confirm_escaping.test.mjs` opens each of them in jsdom with hostile
294
+ names and labels.
@@ -0,0 +1,72 @@
1
+ # Input fields: slider and tags
2
+
3
+ > **Package:** `@flexdesk/widgets` · **More fields:** the toggle and colour fields are in [Forms and dialogs](forms-and-dialogs.md) · [All docs](../README.md)
4
+
5
+ Two stand-alone inputs. `SliderField` draws a range input, with an optional number box, readout, default and enable toggle. `createTagInput` draws a list of short strings as removable chips followed by a text box.
6
+
7
+ ## SliderField: a number box, a readout, a default and an enable toggle (0.17.0)
8
+
9
+ `new SliderField({ fieldId, field, onChange })` (from `@flexdesk/widgets`)
10
+ draws a range input. Its companions are opt-in through `field`; with none of
11
+ them the DOM is the range and the helper line, as it always was.
12
+
13
+ ```js
14
+ import { SliderField } from '@flexdesk/widgets';
15
+
16
+ const slider = new SliderField({
17
+ fieldId: 'rate',
18
+ field: {
19
+ min: 0, max: 1, step: 0.01, value: 0.25,
20
+ numberInput: true, // an editable box, kept in sync both ways
21
+ readout: (v) => `${Math.round(v * 100)}%`, // or true for the plain value
22
+ defaultValue: 0.25, // --modified state and a reset button
23
+ toggleable: true, disabledValue: null, // an enable checkbox before the slider
24
+ },
25
+ onChange: (value, { immediate }) => save(value),
26
+ });
27
+ host.appendChild(slider.render());
28
+ ```
29
+
30
+ | Field / method | What it does |
31
+ | --- | --- |
32
+ | `numberInput` | A text box beside the slider. Dragging updates it on every frame; Enter (or leaving the box) commits what was typed, clamped to `[min, max]` and snapped to `step`. Text that is not a finite number (`abc`, empty, `Infinity`) changes nothing and the box shows the value again; Escape does the same |
33
+ | `readout` | A span after the slider: `true` shows the value, a function `(value: number) => string` formats it. It follows the drag live |
34
+ | `defaultValue` | While the value differs from it the root carries `twm-slider-field--modified` and the reset button shows; reset restores it and reports it through `onChange`, like a release |
35
+ | `toggleable`, `enabled`, `disabledValue` | A checkbox before the slider (`enabled: false` starts it off). While off, the inputs are disabled, the root carries `twm-slider-field--off`, and `onChange`/`getValue()` report `disabledValue` (default `null`) |
36
+ | `onChange(value, { immediate })` | Called once per commit: a drag release, a typed Enter, a reset, a toggle. The value is the slider's value as a string, as before. With `emitOnInput` it is also called on every drag frame with `immediate: true` |
37
+ | `setValue(x, { silent })`, `setRange({ min, max, step }, { silent })`, `setEnabled(on, { silent })`, `reset({ silent })` | Change the field from code. Each reports at most once, nothing with `silent: true`, and nothing when the reported value did not move, so a caller that writes back from its own `onChange` does not loop. `setValue` refuses a non-finite value (returns `false`); `setRange` refits the value and the default |
38
+ | `getValue()`, `isEnabled()`, `isModified()` | What `onChange` would report now, the toggle state, and whether the value differs from `defaultValue` |
39
+
40
+ ## A list of tags: `createTagInput` (0.17.0)
41
+
42
+ `createTagInput(host, { value, placeholder, readOnly, allowDuplicates, onChange })`
43
+ (in `@flexdesk/widgets`) draws a list of short strings as removable chips
44
+ followed by a text box, and appends it to `host`.
45
+
46
+ ```js
47
+ const tags = createTagInput(host, {
48
+ value: ['baseline'],
49
+ placeholder: 'Add a tag…',
50
+ onChange: (list) => save(list), // a fresh copy after each change the reader makes
51
+ });
52
+ tags.getValue(); // ['baseline']
53
+ tags.setValue(['a', 'b']); // redraws; does not call onChange
54
+ tags.dispose();
55
+ ```
56
+
57
+ - **Enter**, a **comma** and **blur** commit what was typed: it is split at
58
+ commas and each piece trimmed, so `a, b` then Enter adds two chips and
59
+ `onChange` receives `['a', 'b']`.
60
+ - Empty pieces are ignored, and so is a piece already in the list unless
61
+ `allowDuplicates` is set. A commit that adds nothing calls no `onChange`.
62
+ - **Backspace** in an empty box removes the last chip.
63
+ - A **paste** that carries a comma is split into chips at once; a paste
64
+ without one is ordinary text in the box.
65
+ - Each chip's remove button has an `aria-label`, "Remove <tag>" by default;
66
+ `removeLabel(tag)` words it (for another language). `label` names the text
67
+ box and the group.
68
+ - `readOnly` draws the chips alone: no remove buttons and no text box.
69
+
70
+ The handle is `{ element, input, getValue, setValue, focus, dispose }`
71
+ (`input` is `null` when read-only). The styles are the `twm-tag-input` block in
72
+ `flexdesk.css`. `tests/tag_input.test.mjs` covers each of the above in jsdom.
@@ -0,0 +1,143 @@
1
+ # Keys, Back and tab layout
2
+
3
+ > **Package:** `@flexdesk/wm` · **See also:** [Command palette](command-palette.md) · [All docs](../README.md)
4
+
5
+ The window manager binds a set of keyboard chords: F1 to F12, Backspace, Ctrl+K, Ctrl+Tab, Ctrl/Shift+Arrows, the Alt chords, `?` and Esc. `keymap.bindings` takes any of them back or rebinds it. The same page covers what depends on those keys and on how a user moves around: what Back and the breadcrumb do, the content zoom control (`chrome.zoom`), and the shell options `backToOpenList`, `floatActiveTab` and `tabLayout`.
6
+
7
+ ## Your keys, not the window manager's: `keymap` (0.17.0)
8
+
9
+ The window manager binds F1..F12, Backspace, Ctrl+K, Ctrl+Tab, Ctrl/Shift+Arrows,
10
+ the Alt chords, `?` and Esc, and swallows any other bare Alt+letter. Each of
11
+ those chords can be taken back or rebound:
12
+
13
+ ```js
14
+ await createShell({
15
+ // …everything above…
16
+ keymap: {
17
+ bindings: {
18
+ 'F1': false, // F1 is the page's: not prevented, nothing clicked
19
+ 'F2': 'nav:0', // F2 opens the first top-nav kind
20
+ 'Backspace': false, // Backspace never calls navigateBack
21
+ 'Alt+W': (e, { wm }) => { /* yours; return false to leave e unprevented */ },
22
+ },
23
+ },
24
+ });
25
+ ```
26
+
27
+ A value is `false`, an action name, or a function. The actions are `palette`,
28
+ `palette:close`, `help`, `back`, `nav:N`, `desktop:N`, `toDesktop:N` (all
29
+ counted from 0), `desktop:next|prev`, `tab:next|prev`, `panelTab:next|prev`,
30
+ `split:h|v`, `splitWith:h|v`, `openInTab`, `openInWindow`, `close`, `float`,
31
+ `focus:left|right|up|down`, `move:left|right|up|down` and `suppress`. The chord
32
+ ids are the defaults in `src/tiling/keymap.js`: `Ctrl+K`, `Meta+K`, `?`,
33
+ `Escape`, `F1`..`F12`, `Backspace`, `Alt+H`, `Alt+Shift+H`, `Alt+ArrowLeft`,
34
+ `Alt+Shift+1`, `Ctrl+Tab`, `Shift+ArrowRight` and so on, with `Alt+Letter` for
35
+ the Alt+letter suppression. Modifiers may come in any order and case. A chord id
36
+ or action the keymap does not know is warned about once, through `logger` (else
37
+ `console`), when the shell is built, and is then ignored. Only the window
38
+ manager's own chords can be rebound. To add a new key, put your own listener on
39
+ the page. `keymap` itself must be an object whose only key is `bindings`:
40
+ anything else (a string, a typo such as `{ binding: … }`) makes `createShell`
41
+ throw a `TypeError`, so a binding you meant to change never stays in force
42
+ unnoticed.
43
+
44
+ **A key press is read as loosely as before.** With no bindings every key does
45
+ what it did in 0.16, and a binding takes over every key press its chord was
46
+ read as. Backspace ignores Shift and Alt, so `{ Backspace: false }` also hands
47
+ Shift+Backspace and Alt+Backspace to the page. Alt+T/N/W/F ignore Shift. With
48
+ Alt held, Ctrl and Meta are ignored, so Alt+Ctrl+H is `Alt+H`. Ctrl+K and
49
+ Ctrl+Tab also match with Meta held, and Ctrl wins when both are held:
50
+ Ctrl+Meta+K is `Ctrl+K`, and Meta+K is `Meta+K` only without Ctrl. Esc and `?`
51
+ ignore every modifier. F1..F12, Shift+Left/Right and Ctrl+Left/Right match only
52
+ with exactly those modifiers.
53
+
54
+ **A key a component handled is the component's.** The keymap skips every
55
+ keydown that is already `defaultPrevented`. A list that deletes a row on
56
+ Backspace calls `preventDefault()`, and the window manager does not navigate
57
+ back as well.
58
+
59
+ **`?` is installed by the shell.** Importing `HelpModal` no longer adds a
60
+ `document` listener as a side effect. A shell binds `?` as one of its chords,
61
+ and `shell.dispose()` removes it with the rest. A page that uses the help modal
62
+ without a shell calls `installHelpShortcut()` from `@flexdesk/widgets`, which
63
+ returns its disposer. `HelpModal.toggle()` is what `?` does.
64
+
65
+ **The *Keyboard shortcuts* command shows the bindings in effect.** It lists
66
+ `keymapShortcuts(bindings)` from `@flexdesk/wm`. That is `KEYMAP_SHORTCUTS`
67
+ with each changed row replaced by one line per chord still bound, and no line
68
+ for a chord you disabled. `createCommandPalette({ shortcuts })` takes that table.
69
+ The command's hint names the keys that open help in it: `?` by default, `F9`
70
+ after `{ '?': false, F9: 'help' }`, and none when no key opens help (for
71
+ example after `{ '?': 'palette' }`).
72
+
73
+ **`chrome.zoom`** paints a − / track / + / readout control into the element you
74
+ pass, restores the saved zoom through the host's `state` capability, and scales
75
+ the content of tiles and floating windows. It deliberately scales *content only*
76
+ — never the root, a tile's frame or a window's frame — because CSS `zoom`
77
+ establishes a scaled coordinate space and every window drag, resize and snap
78
+ measures in real pixels. `shell.chrome.zoom` exposes `get()` and `set(percent)`
79
+ for a settings pane. An embedder that builds its own `WindowManager` instead of
80
+ calling `createShell` mounts the same control with `mountZoomControl(el, { root,
81
+ host })`.
82
+
83
+ **`backToOpenList`** is for apps that open a record from a list in a new tab.
84
+ Without it, Back in that record has no history to pop, so it walks the taxonomy
85
+ up and rewrites the record into its parent, and the tile now shows the list
86
+ twice. With it, Back closes the record tab and activates the list it came from:
87
+ the nearest tab to the left, else to the right, that is not a record (no
88
+ `props.id`) and belongs to the same top-nav section. Per-tab history still comes
89
+ first, and a record with no such list beside it still walks up in place.
90
+
91
+ **`floatActiveTab`** is for apps whose tabs are separate records rather than
92
+ views of one pane. The float button, Alt+F and the tile menu take only the tab
93
+ on screen and leave its siblings in the tile, so the window holds one tab and
94
+ draws no strip. Without it the whole pane floats, strip and all.
95
+
96
+ **`tabLayout`** says where a tile draws its tabs. `'bottom'` (the default) is
97
+ the spreadsheet strip under the body, shown from two tabs. `'top'` is the
98
+ editor tab bar between the header and the body, also from two tabs.
99
+ `'header'` (0.8.0) puts that tab bar inside the header in place of the title,
100
+ on every tile, one tab included, with a `+` after the last tab that opens the
101
+ taxonomy root in a new tab (`wm.newTab(leafId)`, the same seed a split uses).
102
+ Pass `newTabButton: false` (0.8.1) to leave the `+` out.
103
+ A panel tile has no tabs and keeps its title. Under `'header'` the tab, not
104
+ the header, is what you drag to move a single-tab pane; the empty part of the
105
+ header still floats the pane on a pull-down or a double-click. Set
106
+ `data-twm-tabs` on the shell's root, or call `wm.setTabLayout()`, to switch
107
+ live; nothing mounted in a tile is unmounted.
108
+
109
+ **Back and the breadcrumb are one rule.** `wm.navigateBack()` (Backspace) and
110
+ `wm.navigateUp(kind, props, { ctx })` (every breadcrumb crumb) share it. With
111
+ `backToOpenList`, a record whose section has a list open closes onto that list;
112
+ otherwise Back walks the taxonomy up and a crumb navigates to its level, in
113
+ place. A crumb naming an entity, such as an epic above a story, opens that
114
+ entity. `navigate` stays the verb for going to exactly a target; use
115
+ `navigateUp` for anything that means "up to this level".
116
+
117
+ A filter or other props passed to `openInPrimary` for a list that is already
118
+ showing are applied to it. A bare open, such as a top-nav click, keeps the tab
119
+ as you left it.
120
+
121
+ **Back in a floating window** always acts on that window, whichever option is
122
+ set. The last click decides: inside a window, Backspace walks that window up;
123
+ anywhere else, it walks the focused tile as before. With `backToOpenList`, a
124
+ record in a window closes onto an open list of its section, first among the
125
+ window's own tabs and then in any tile of its desktop.
126
+
127
+ **A page's own levels come first** (0.4.7). A page can have levels that are not
128
+ tabs, such as a section whose list opens one member in place of itself. Return
129
+ `navigateBack()` from the content factory's mount, and Backspace asks it before
130
+ anything above: it climbs one of the page's own levels and returns `true`. Only
131
+ a strict `true` counts. `false`, any other value, a missing function or a throw
132
+ (logged as a warning) all let Back walk the tab history, the open list and the
133
+ taxonomy exactly as before. It is asked in the same place as the rest of Back:
134
+ the window you last clicked in, else the focused tile. Return
135
+ `canNavigateBack()` as well, and `wm.canNavigateBack()` reports the page's own
136
+ levels, for that same window or tile. `TileRenderer.contentOf(leafId)` reads
137
+ what a tile's factory returned. To draw the page's levels, call
138
+ `renderBreadcrumb(container, segments)` from `@flexdesk/wm`, with
139
+ `segments = [{ label, icon?, onClick? }]`. It uses the tile breadcrumb's
140
+ markup, so it looks the same, and each `onClick` is yours: the last segment,
141
+ and any without `onClick`, is drawn as plain text. The current segment's item
142
+ carries `aria-current="page"`, and every label has a `title` with its full text,
143
+ so a label your CSS truncates still reads whole (0.15.1).