flexdesk 0.17.18 → 0.18.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +53 -0
- package/README.md +1 -0
- package/css/flexdesk.css +7544 -5916
- package/css/reset.css +1 -1
- package/css/tokens.css +23 -1
- package/dist/charts.js +10 -6
- package/dist/charts.js.map +1 -1
- package/dist/chunk-5W4EEKSK.js +778 -0
- package/dist/chunk-5W4EEKSK.js.map +7 -0
- package/dist/chunk-DNJ3N4T4.js +82 -0
- package/dist/chunk-DNJ3N4T4.js.map +7 -0
- package/dist/{chunk-REJ5HLIM.js → chunk-HYJYYEPK.js} +2 -2
- package/dist/{chunk-7U4IB65H.js → chunk-LLBJ2SJH.js} +5 -3
- package/dist/{chunk-7U4IB65H.js.map → chunk-LLBJ2SJH.js.map} +1 -1
- package/dist/{chunk-7OL6F2AM.js → chunk-P74F6I6P.js} +6 -4
- package/dist/{chunk-7OL6F2AM.js.map → chunk-P74F6I6P.js.map} +1 -1
- package/dist/chunk-PAI2ZUWV.js +260 -0
- package/dist/chunk-PAI2ZUWV.js.map +7 -0
- package/dist/chunk-Q7BALEPX.js +527 -0
- package/dist/chunk-Q7BALEPX.js.map +7 -0
- package/dist/chunk-QXAOTGCC.js +40 -0
- package/dist/chunk-QXAOTGCC.js.map +7 -0
- package/dist/{chunk-XJNADRGR.js → chunk-RKVYIXDC.js} +664 -91
- package/dist/chunk-RKVYIXDC.js.map +7 -0
- package/dist/{chunk-2Z2ENHR3.js → chunk-U5GKJUOC.js} +3 -3
- package/dist/{chunk-2Z2ENHR3.js.map → chunk-U5GKJUOC.js.map} +1 -1
- package/dist/chunk-UKP22RUG.js +219 -0
- package/dist/chunk-UKP22RUG.js.map +7 -0
- package/dist/chunk-VLOAI3HS.js +43 -0
- package/dist/chunk-VLOAI3HS.js.map +7 -0
- package/dist/{chunk-WUFLGETN.js → chunk-WVFGV5FT.js} +2 -39
- package/dist/{chunk-WUFLGETN.js.map → chunk-WVFGV5FT.js.map} +3 -3
- package/dist/{chunk-PGZ4EHSE.js → chunk-ZL3VKINP.js} +41 -390
- package/dist/chunk-ZL3VKINP.js.map +7 -0
- package/dist/editor.js +3 -2
- package/dist/editor.js.map +1 -1
- package/dist/flexdesk.css +7544 -5916
- package/dist/flow.js +29 -97
- package/dist/flow.js.map +3 -3
- package/dist/git.js +1356 -0
- package/dist/git.js.map +7 -0
- package/dist/reset.css +1 -1
- package/dist/tiles.js +20 -15
- package/dist/tiles.js.map +2 -2
- package/dist/tokens.css +23 -1
- package/dist/widgets.js +3967 -1188
- package/dist/widgets.js.map +4 -4
- package/dist/wm.js +120 -32
- package/dist/wm.js.map +2 -2
- package/docs/README.md +9 -4
- package/docs/best-practices.md +2 -2
- package/docs/components/chat-shell.md +198 -0
- package/docs/components/conversation.md +10 -0
- package/docs/components/filter-builder.md +246 -0
- package/docs/components/forms-and-dialogs.md +22 -0
- package/docs/components/git.md +355 -0
- package/docs/components/input-fields.md +167 -2
- package/docs/components/keymap.md +8 -0
- package/docs/components/menus.md +82 -2
- package/docs/components/panels-and-desktops.md +16 -5
- package/docs/components/shell.md +104 -1
- package/docs/components/workspace-picker.md +149 -0
- package/docs/concepts/building-and-packaging.md +2 -2
- package/docs/concepts/packages-and-entry-points.md +14 -13
- package/docs/concepts/state-guard.md +3 -3
- package/docs/quickstart.md +1 -1
- package/git.js +13 -0
- package/package.json +6 -1
- package/src/flow/kit/dom.js +28 -119
- package/src/flow/kit/step_picker.js +1 -1
- package/src/flow/kit/value_picker.js +1 -1
- package/src/flow/outline/menu.js +1 -1
- package/src/git/diff_viewer.js +212 -0
- package/src/git/diff_window.js +51 -0
- package/src/git/git_graph.js +401 -0
- package/src/git/git_ops.js +61 -0
- package/src/git/git_panel.js +755 -0
- package/src/git/index.js +9 -0
- package/src/git/line_diff.js +202 -0
- package/src/tiles/tile_base.js +5 -4
- package/src/tiling/desktops.js +10 -8
- package/src/tiling/keymap.js +6 -5
- package/src/tiling/kind_taxonomy.js +15 -2
- package/src/tiling/shell.js +124 -17
- package/src/tiling/tile_renderer.js +2 -3
- package/src/ui/components/action_dropdown.js +4 -78
- package/src/ui/components/anchored_layer.js +255 -0
- package/src/ui/components/chat_shell.js +920 -0
- package/src/ui/components/chat_state_machine.js +223 -0
- package/src/ui/components/choice_controls.js +45 -2
- package/src/ui/components/color_field.js +3 -2
- package/src/ui/components/color_list_field.js +3 -2
- package/src/ui/components/combobox.js +422 -0
- package/src/ui/components/conversation_view.js +17 -2
- package/src/ui/components/data_table.js +2 -2
- package/src/ui/components/filter_builder.js +766 -0
- package/src/ui/components/filter_model.js +614 -0
- package/src/ui/components/managed_window.js +24 -47
- package/src/ui/components/modal.js +79 -109
- package/src/ui/components/option_list.js +160 -0
- package/src/ui/components/popover.js +94 -0
- package/src/ui/components/recent_items.js +169 -0
- package/src/ui/components/select.js +322 -0
- package/src/ui/components/tag_input.js +77 -8
- package/src/ui/components/workspace_picker.js +1155 -0
- package/src/ui/utils/fire_event.js +17 -0
- package/src/ui/utils/focusable.js +25 -0
- package/src/ui/utils/modal_host.js +27 -0
- package/widgets.js +12 -2
- package/css/base.css +0 -10547
- package/css/overrides.css +0 -2596
- package/dist/chunk-7JI7HHSY.js +0 -107
- package/dist/chunk-7JI7HHSY.js.map +0 -7
- package/dist/chunk-MIW7D6WC.js +0 -526
- package/dist/chunk-MIW7D6WC.js.map +0 -7
- package/dist/chunk-PGZ4EHSE.js.map +0 -7
- package/dist/chunk-XJNADRGR.js.map +0 -7
- /package/dist/{chunk-REJ5HLIM.js.map → chunk-HYJYYEPK.js.map} +0 -0
|
@@ -157,8 +157,7 @@ Text that is not JSON was always handled: the host logs `[host] bad JSON for
|
|
|
157
157
|
state key desktops` and answers `null`. A layout that is valid JSON but not a
|
|
158
158
|
layout (a split whose `sizes` is a number, a `null` desktop, a split with no
|
|
159
159
|
`children`) used to make `createShell` throw, on every start, until the file was
|
|
160
|
-
deleted by hand, so an app had to vet the file before booting.
|
|
161
|
-
through half understood: a desktop with no `tree` came back as an empty one.
|
|
160
|
+
deleted by hand, so an app had to vet the file before booting.
|
|
162
161
|
|
|
163
162
|
Now such a layout is ignored. `createShell` resolves, the shell opens its
|
|
164
163
|
default layout exactly as it does when nothing was saved, and one warning names
|
|
@@ -181,8 +180,8 @@ loggers) records that line as the message, with the key and the cause in it.
|
|
|
181
180
|
(any change to it) that save replaces the file, and until then every start
|
|
182
181
|
warns again.
|
|
183
182
|
- **What is unreadable** is a shape `serialize()` could not have written:
|
|
184
|
-
`desktops` not a non-empty array; a desktop that is not an object, or
|
|
185
|
-
`tree`
|
|
183
|
+
`desktops` not a non-empty array; a desktop that is not an object, or whose
|
|
184
|
+
`tree` is something other than `{ root }`, `null` or absent; a
|
|
186
185
|
`panels` or `panelShares` that is not an object; a node that is not a `leaf`
|
|
187
186
|
or a `split`; a split without a `dir` of `h` or `v`, without children, or
|
|
188
187
|
whose `sizes` are not one non-negative number per child; a tab without a
|
|
@@ -193,7 +192,7 @@ loggers) records that line as the message, with the key and the cause in it.
|
|
|
193
192
|
`group` and `fixed`, a panel's flag, a tab's props. A layout saved by an
|
|
194
193
|
earlier release loads too: the keys that came later (`panelShares`, `group`,
|
|
195
194
|
`fixed`, a leaf's `pageTabs`) may be missing, and so may a desktop's `id`,
|
|
196
|
-
`label` and `panels`, and a leaf's `tabs`. Keys it does not know are ignored, a
|
|
195
|
+
`label`, `tree` and `panels`, and a leaf's `tabs`. Keys it does not know are ignored, a
|
|
197
196
|
tab of a kind with no factory registered is the usual placeholder tile, and an
|
|
198
197
|
`activeIdx` past the last desktop is clamped.
|
|
199
198
|
|
|
@@ -201,5 +200,17 @@ loggers) records that line as the message, with the key and the cause in it.
|
|
|
201
200
|
throws the `TypeError` above for a blob that cannot be read, which is what
|
|
202
201
|
`wm.load()` catches; `wm.load()` itself does not throw for one.
|
|
203
202
|
|
|
203
|
+
### A desktop with no `tree` is an empty desktop (0.18.0)
|
|
204
|
+
|
|
205
|
+
A desktop whose `tree` is `null` or absent has nothing on it. It loads, its
|
|
206
|
+
siblings load with it, and the shell seeds it with the root kind. A host that
|
|
207
|
+
composes the blob can leave the key out: one that rebuilds a desktop per page on
|
|
208
|
+
every boot, say, and keeps only the desktops the user made.
|
|
209
|
+
|
|
210
|
+
From 0.17.14 to 0.17.18 an absent `tree` counted as unreadable. All or nothing
|
|
211
|
+
then set the user's desktops aside with it, and the next layout change saved the
|
|
212
|
+
default layout over them for good. A host like that must not ship on those
|
|
213
|
+
releases unless it writes `tree: null`.
|
|
214
|
+
|
|
204
215
|
`tests/saved_layout_unreadable.test.mjs` boots a real shell over each of 78
|
|
205
216
|
shapes of broken layout and over the readable ones.
|
package/docs/components/shell.md
CHANGED
|
@@ -2,13 +2,14 @@
|
|
|
2
2
|
|
|
3
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
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.
|
|
5
|
+
`createShell` boots the tiling window manager into an element of your page: tiles that split and tab, the top navigation or a page rail, 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
6
|
|
|
7
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
8
|
|
|
9
9
|
**On this page**
|
|
10
10
|
|
|
11
11
|
- [Opt-in shell features](#opt-in-shell-features)
|
|
12
|
+
- [The pages as a vertical rail: `chrome.rail` (0.18.0)](#the-pages-as-a-vertical-rail-chromerail-0180)
|
|
12
13
|
- [A kind that exists once: `single` (0.17.0)](#a-kind-that-exists-once-single-0170)
|
|
13
14
|
- [A kind that exists once per desktop: `single: 'desktop'` (0.17.17)](#a-kind-that-exists-once-per-desktop-single-desktop-01717)
|
|
14
15
|
- [An open single instance takes new props: `navigate(props)` (0.17.2)](#an-open-single-instance-takes-new-props-navigateprops-0172)
|
|
@@ -36,6 +37,108 @@ await createShell({
|
|
|
36
37
|
});
|
|
37
38
|
```
|
|
38
39
|
|
|
40
|
+
## The pages as a vertical rail: `chrome.rail` (0.18.0)
|
|
41
|
+
|
|
42
|
+
`chrome.topNav` paints the pages as a strip in the top bar. `chrome.rail` paints
|
|
43
|
+
them as a fixed-left icon bar instead. Give it the element, and put the element
|
|
44
|
+
beside the window manager's host in a `.twm-workspace`:
|
|
45
|
+
|
|
46
|
+
```html
|
|
47
|
+
<body class="twm-body">
|
|
48
|
+
<div class="twm-global-top-bar">…</div>
|
|
49
|
+
<div class="twm-workspace">
|
|
50
|
+
<nav id="rail" aria-label="Sections"></nav>
|
|
51
|
+
<main id="root" class="twm-host"></main>
|
|
52
|
+
</div>
|
|
53
|
+
<div class="twm-global-bottom-bar">…</div>
|
|
54
|
+
</body>
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
```js
|
|
58
|
+
const taxonomy = createTaxonomy({
|
|
59
|
+
root: 'start',
|
|
60
|
+
kinds: {
|
|
61
|
+
start: { label: 'Start', icon: 'apps' },
|
|
62
|
+
atlas: { label: 'Atlas', icon: 'map', isTopNav: true, order: 10 },
|
|
63
|
+
log: { label: 'Ship log', icon: 'article', isTopNav: true, order: 20 },
|
|
64
|
+
settings: { label: 'Settings', icon: 'settings', isTopNav: true, railPin: 'bottom' },
|
|
65
|
+
},
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
await createShell({
|
|
69
|
+
root: document.getElementById('root'),
|
|
70
|
+
taxonomy, entities, content,
|
|
71
|
+
chrome: { rail: document.getElementById('rail') },
|
|
72
|
+
});
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
- **The look.** 50px wide, 44px items, 22px icons at weight 300. An item is grey
|
|
76
|
+
at rest and white on hover; the active one is white, with a white 2px bar at
|
|
77
|
+
its left on a transparent background; keyboard focus draws the accent outline.
|
|
78
|
+
A window too short for the items scrolls the bar, with no scrollbar. No
|
|
79
|
+
stylesheet of yours is needed.
|
|
80
|
+
- **`railPin: 'bottom'`** on a top-nav kind holds it at the bottom of the bar.
|
|
81
|
+
Pinned kinds come after the rest, in their own `order`, and stay together.
|
|
82
|
+
A `railPin` of any other value, or on a kind that is not `isTopNav`, is a boot
|
|
83
|
+
error. The top strip ignores it. A kind such as a settings page can be
|
|
84
|
+
`isTopNav` and `appGlobal` at once.
|
|
85
|
+
- **F1..F12 press the rail's buttons in the order they are drawn**, pinned ones
|
|
86
|
+
last, with no `navSelector`. `navSelector` remains for page buttons in markup
|
|
87
|
+
of your own.
|
|
88
|
+
- **Tooltips open to the right** of the bar, flipping at the window's edge, with
|
|
89
|
+
no attribute or patch of yours. They name the kind's `label`, and so do the
|
|
90
|
+
buttons' `aria-label`s (a `shortLabel` is for the strip's chips). As for every
|
|
91
|
+
`twm-has-tooltip` element, `TooltipService` draws them, and it comes with
|
|
92
|
+
`@flexdesk/widgets`: a page that imports only `@flexdesk/wm` has the labels
|
|
93
|
+
and no tooltips.
|
|
94
|
+
- **The layout.** `.twm-workspace` takes the host's place in `body.twm-body`'s
|
|
95
|
+
column, and a `.twm-host` inside it fills what the rail leaves, with the rule
|
|
96
|
+
a host directly under the body has. Outside `body.twm-body`, a `.twm-workspace`
|
|
97
|
+
needs a parent that gives it a height, as a host does.
|
|
98
|
+
- **The element is yours.** It gains `.twm-fl-bar` and, if it has none, the
|
|
99
|
+
`aria-label` "Pages". `shell.chrome.railEl` hands it back. `shell.dispose()`
|
|
100
|
+
empties it and takes the listener off, so a rebuilt shell paints its own.
|
|
101
|
+
- **One row of pages.** `chrome.rail` with `chrome.topNav` is a boot error.
|
|
102
|
+
|
|
103
|
+
Retheme or resize it from `tokens.css`:
|
|
104
|
+
|
|
105
|
+
| Token | Default | |
|
|
106
|
+
|---|---|---|
|
|
107
|
+
| `--twm-rail-bg` | `#333333` | the bar |
|
|
108
|
+
| `--twm-rail-icon` | `#9e9e9e` | an item at rest |
|
|
109
|
+
| `--twm-rail-icon-on` | `var(--text-bright)` | an item hovered, the active item and its bar |
|
|
110
|
+
| `--twm-rail-width` | `50px` | the bar's width |
|
|
111
|
+
| `--twm-rail-item-height` | `44px` | an item's height |
|
|
112
|
+
| `--twm-rail-icon-size` | `22px` | the icon's size |
|
|
113
|
+
|
|
114
|
+
If you supply your own token definitions instead of `tokens.css`, define these
|
|
115
|
+
too: the rules that read them have no fallback.
|
|
116
|
+
|
|
117
|
+
### Without `createShell`: `mountPageRail(el, { wm, activeKind?, decorate? })`
|
|
118
|
+
|
|
119
|
+
`@flexdesk/wm` exports the painter, for an embedder that constructs its own
|
|
120
|
+
`WindowManager`. It answers `{ el, sync(), dispose() }`; call `sync()` when the
|
|
121
|
+
window manager changes (`onChange`, `wm:changed`), once after the first load, and
|
|
122
|
+
`dispose()` when the rail goes. The keymap's default finds these buttons too.
|
|
123
|
+
|
|
124
|
+
```js
|
|
125
|
+
const rail = mountPageRail(document.getElementById('rail'), {
|
|
126
|
+
wm,
|
|
127
|
+
// Which page to light, or null for none. Default: the primary tile's page.
|
|
128
|
+
activeKind: (w) => pageOf(w),
|
|
129
|
+
// Once per button, with its topNavEntries() entry.
|
|
130
|
+
decorate: (btn, entry) => {
|
|
131
|
+
if (entry.kind === 'archive') btn.dataset.tooltip = 'Archive: shared by every crew';
|
|
132
|
+
},
|
|
133
|
+
});
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
**What changed for existing markup.** `.twm-fl-bar` and `.twm-fl-item` had rules
|
|
137
|
+
and no code that drew them. `.twm-fl-bar` now has the rail's width, background
|
|
138
|
+
and overflow and no `height: 100%` (it stretches in its flex row), and
|
|
139
|
+
`.twm-fl-item`'s grey comes from `--twm-rail-icon`. Nothing changes for a shell
|
|
140
|
+
that uses `chrome.topNav`. `topNavEntries()` gains `railPin` (null unless set).
|
|
141
|
+
|
|
39
142
|
## A kind that exists once: `single` (0.17.0)
|
|
40
143
|
|
|
41
144
|
Some content holds heavy app-wide state and must not be mounted twice. Mark its
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
# Workspace picker
|
|
2
|
+
|
|
3
|
+
> **Package:** `@flexdesk/widgets` · **See also:** [Menus](menus.md) · [Host port](../concepts/host-port.md) · [All docs](../README.md)
|
|
4
|
+
|
|
5
|
+
A field in the top bar that names what the window is working in, a switcher that changes it, and a store for "recently opened". FlexDesk does not know what is being picked: the items, the words and what a pick does are yours.
|
|
6
|
+
|
|
7
|
+
## A field and a switcher: `createWorkspacePicker` (0.18.0)
|
|
8
|
+
|
|
9
|
+
```js
|
|
10
|
+
import { createWorkspacePicker } from '@flexdesk/widgets';
|
|
11
|
+
|
|
12
|
+
const picker = createWorkspacePicker(topBar, {
|
|
13
|
+
field: {
|
|
14
|
+
layout: 'centered',
|
|
15
|
+
avoid: '.bar-left > *, .bar-right > *',
|
|
16
|
+
icon: 'folder',
|
|
17
|
+
placeholder: 'Nothing open',
|
|
18
|
+
tooltip: 'Click to switch workspace',
|
|
19
|
+
},
|
|
20
|
+
chord: 'Ctrl+P', // none by default
|
|
21
|
+
label: 'Switch workspace',
|
|
22
|
+
placeholder: 'Switch workspace: type to filter, Enter to open',
|
|
23
|
+
glyph: 'folder_open',
|
|
24
|
+
load: () => recent.list(),
|
|
25
|
+
describe: (item, { current }) => ({
|
|
26
|
+
id: item.id,
|
|
27
|
+
name: item.name,
|
|
28
|
+
detail: item.location,
|
|
29
|
+
icon: current ? 'folder_open' : 'folder',
|
|
30
|
+
badge: current ? 'current' : '',
|
|
31
|
+
}),
|
|
32
|
+
onPick: (item) => openWorkspace(item),
|
|
33
|
+
onRemove: (item) => recent.remove(item),
|
|
34
|
+
actions: () => [
|
|
35
|
+
{ id: 'new', label: 'New workspace', icon: 'add', run: () => createOne() },
|
|
36
|
+
{ id: 'open', label: 'Open…', icon: 'folder_open', run: () => openOne() },
|
|
37
|
+
],
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
picker.setCurrent(openNow); // named on the field, marked in the list
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
A click on the field or the chord opens the switcher in the field's place, with its input over the field, so the field reads as a search box growing its results. A pick closes it, gives focus back to where it was, and calls `onPick`, or nothing if the row is the current one. What `onPick` does is yours: navigate the whole window, or swap what is open in place.
|
|
44
|
+
|
|
45
|
+
It returns `{ el, field, switcher, isOpen, current, open({select}), close(), toggle(), setCurrent(item), refresh(), reload(), destroy() }`. `createWorkspacePicker(hostEl, options)` takes everything `createPickerSwitcher` takes, and:
|
|
46
|
+
|
|
47
|
+
| Option | |
|
|
48
|
+
|---|---|
|
|
49
|
+
| `field` | `mountPickerField`'s options, plus `placeholder`: its name while nothing is current (drawn muted). `field.icon` is its icon whatever is current; without it the current item's `icon` is used. |
|
|
50
|
+
| `chord` | `'Ctrl+P'`, `'Ctrl+Shift+O'`. None by default. Ctrl also answers to Cmd, modifiers are exact, and a held key does not repeat. It is taken on `window` in the capture phase, ahead of an embedded editor and the browser's own use of the key, and it becomes the field's key hint unless `field.keyHint` says otherwise. It still works while the field is hidden for want of room. |
|
|
51
|
+
|
|
52
|
+
## The field: `mountPickerField` (0.18.0)
|
|
53
|
+
|
|
54
|
+
`mountPickerField(hostEl, options)` appends a real `<button>` to `hostEl` and returns `{ el, update(patch), setOpen(open), fit(), destroy() }`.
|
|
55
|
+
|
|
56
|
+
| Option | Default | |
|
|
57
|
+
|---|---|---|
|
|
58
|
+
| `name`, `icon`, `keyHint`, `tooltip`, `ariaLabel` | empty | Drawn when given. `update` changes any of them, and `muted`. |
|
|
59
|
+
| `muted` | `false` | The name drawn as naming nothing (`update({ name: 'n/a', muted: true })`). |
|
|
60
|
+
| `caret` | `false` | A trailing caret, for a field that reads as a dropdown. |
|
|
61
|
+
| `className` | | Extra classes. |
|
|
62
|
+
| `layout` | `'inline'` | `'centered'` stands it in the middle of `hostEl`, whatever stands left and right. |
|
|
63
|
+
| `avoid` | | Centred: a selector, within `hostEl`, of what it must not cover. An element counts on the side its centre is on; one taking no room does not count. |
|
|
64
|
+
| `maxWidth`, `minWidth`, `tightWidth`, `gap` | 440, 120, 230, 24 | Centred, its width is the room the tighter side leaves, twice over, less `gap`, up to `maxWidth`. Below `tightWidth` the key hint goes. Below `minWidth` the field is hidden. |
|
|
65
|
+
| `onActivate` | | A click. |
|
|
66
|
+
|
|
67
|
+
A centred field's `hostEl` must be positioned. It measures again whenever `hostEl` resizes and when the fonts arrive; call `fit()` after you change what stands beside it. An inline field's width is its host's, and only the key hint responds, to its own width.
|
|
68
|
+
|
|
69
|
+
## The switcher: `createPickerSwitcher` (0.18.0)
|
|
70
|
+
|
|
71
|
+
`createPickerSwitcher(options)` is the panel alone, on any anchor or on none. `createWorkspacePicker` is this plus a field.
|
|
72
|
+
|
|
73
|
+
**Where it opens.** `placement: 'over'` (the default) lays the input over `anchor`, at least `minWidth` (520) wide, kept inside the window; an anchor in the lower half of the window grows the panel upward instead. `placement: 'below'` hangs from it through `ActionDropdown.position`, flipped above when there is no room. With no `anchor`, or one that takes no room, it stands centred over a dimmed page. A window resize places it again.
|
|
74
|
+
|
|
75
|
+
**What it lists.** `load({ query, offset, limit, signal })` answers an array or `{ items, total?, status? }`, now or in a promise, and is asked on every open, so a list edited behind the page is never stale for long. The last unsearched answer per `cacheKey()` is drawn at once while it is asked again. `status` is the footer's left end, a string or `{ text, error }`. Only the newest question's answer paints; `signal` aborts the older ones.
|
|
76
|
+
|
|
77
|
+
| `search` | |
|
|
78
|
+
|---|---|
|
|
79
|
+
| `'local'` (default) | Asked once per open. Typing filters what it answered, on the name, `detail` and `keywords`, with name matches first. |
|
|
80
|
+
| `'remote'` | Typing is debounced (`debounceMs`, 200) and asked again. A `total` above what is drawn offers **Load more**, `pageSize` (20) at a time. A server that hands back only what is drawn has no more, whatever it said before. A failed page keeps the rows and says so. |
|
|
81
|
+
|
|
82
|
+
`describe(item, { current })` turns an item into a row:
|
|
83
|
+
|
|
84
|
+
| Field | |
|
|
85
|
+
|---|---|
|
|
86
|
+
| `id` | Required. Two items with one `id` are one row. Make it the form you compare by: `setCurrent` and `open({ select })` go through it. |
|
|
87
|
+
| `name` | Required. |
|
|
88
|
+
| `detail` | A second line. |
|
|
89
|
+
| `icon`, `badge` | An icon, and a short word on the right (the word for the current row, if you want one; the check mark is always drawn). |
|
|
90
|
+
| `keywords` | A string or an array: searched, not drawn. |
|
|
91
|
+
| `disabled` | Drawn greyed, stepped over by the arrows. |
|
|
92
|
+
| `removable` | `false` takes the bin off this row. |
|
|
93
|
+
| `title` | The row's tooltip; by default its name and detail. |
|
|
94
|
+
|
|
95
|
+
**The rest of the panel**, every part optional:
|
|
96
|
+
|
|
97
|
+
| Option | |
|
|
98
|
+
|---|---|
|
|
99
|
+
| `onRemove(item)` | A bin on each row, and Shift+Delete on the highlighted one. May answer a promise: while it is unsettled the panel stays open and it and its dimmed page step below managed windows, so a confirmation dialog can be answered with the mouse. Answering `false` keeps the row, rejecting keeps it and shows the error, anything else drops it and asks `load` again. The highlight moves to the next row. |
|
|
100
|
+
| `actions({ query })` | Rows after the items: `{ id, label, detail?, icon?, always?, run({ query }) }`. Typing narrows them on their `label` unless `always`, which is how a row offering to create what was typed stays; when nothing else matches, it is what Enter takes. |
|
|
101
|
+
| `heading({ query, shown, total })` | `{ title, count }` over the list. |
|
|
102
|
+
| `card({ current, row })` | A panel under the list for the current item: `{ heading?, icon?, name, detail?, open?: { label, icon?, run }, columns?, tiles?: [{ id, label, icon?, run }] }`, or `null`. `row` is the current item as the list read it, when it has. |
|
|
103
|
+
| `footer()` | `{ start?, end?, keys? }`. `start` and `end` are buttons, `{ id, label, icon?, trailing?, primary?, run }`. `keys`, `[{ keys: ['Shift', 'Del'], label: 'remove' }]`, are drawn only when the panel is centred. |
|
|
104
|
+
| `visibleRows` | Scroll past this many two-line rows. |
|
|
105
|
+
| `labels` | Any of `loading`, `empty()`, `noMatch(query)`, `failed(err)`, `failedMore(err)`, `failedRemove(err)`, `loadMore({ next, shown, total })`, `remove(name)`. |
|
|
106
|
+
| `label`, `listLabel`, `placeholder`, `glyph`, `className`, `onOpenChange(open)` | The panel's and the list's accessible names, the input's placeholder and icon, extra classes, and a callback. |
|
|
107
|
+
|
|
108
|
+
`refresh()` asks `actions`, `card` and `footer` again. `reload()` asks `load` again. `open({ select })` highlights the row with that `id` when the answer lands. `setCurrent(item)` takes the item whether or not any page holds it.
|
|
109
|
+
|
|
110
|
+
**The keyboard.** The caret stays in the input. The arrows walk a highlight over the rows, Load more, the action rows, the card's buttons and the footer's, wrapping. Until they move, the highlight is the first row or action row, and Enter takes it, or, while the answer to what was typed is on its way, the first one when it lands. Esc closes and gives focus back to where it was. A press outside closes and leaves focus where it was pressed. Tab goes to the panel's buttons, and out of the panel closes it.
|
|
111
|
+
|
|
112
|
+
**Nothing is rebuilt under a pointer.** Every part is reconciled by key, so an answer landing while a row is being pressed leaves that row's node in place and the click still fires.
|
|
113
|
+
|
|
114
|
+
## Recently opened: `createRecentItems` (0.18.0)
|
|
115
|
+
|
|
116
|
+
A most-recent-first list kept through the [host's](../concepts/host-port.md) `state` capability.
|
|
117
|
+
|
|
118
|
+
```js
|
|
119
|
+
import { createRecentItems } from '@flexdesk/widgets';
|
|
120
|
+
|
|
121
|
+
const recent = createRecentItems({
|
|
122
|
+
host,
|
|
123
|
+
key: 'recent-workspaces',
|
|
124
|
+
identity: (entry) => entry.path.replace(/\\/g, '/'),
|
|
125
|
+
isValid: (entry) => typeof entry?.path === 'string' && entry.path !== '',
|
|
126
|
+
onChange: () => bus.emit('recent:changed'),
|
|
127
|
+
logger,
|
|
128
|
+
});
|
|
129
|
+
await recent.load();
|
|
130
|
+
|
|
131
|
+
recent.add({ path, name }); // stamped `openedAt`, moved to the top
|
|
132
|
+
recent.remove({ path }); // by identity
|
|
133
|
+
recent.clear();
|
|
134
|
+
recent.list(); // frozen copies, most recent first
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
| Option | Default | |
|
|
138
|
+
|---|---|---|
|
|
139
|
+
| `host`, `key` | required | The host, and the state key. |
|
|
140
|
+
| `identity` | `entry.id` | Which two entries are the same one. |
|
|
141
|
+
| `isValid` | `identity` answers something | Stored entries failing this are dropped on read, as is one that makes it, `identity` or `groupOf` throw, so `identity` may assume an entry's shape. |
|
|
142
|
+
| `capacity` | 10 | The most entries kept, per group. |
|
|
143
|
+
| `groupOf` | one group | Counted per group, so one busy kind of entry never pushes another out. |
|
|
144
|
+
| `onChange` | | After every change, and after a read that replayed changes. |
|
|
145
|
+
| `logger` | | Its `warn` hears a failed read or write. |
|
|
146
|
+
|
|
147
|
+
**A read that fails is not an empty list.** While `host.state.read` rejects, `load()` answers `false` and the stored list is never written, because writing would replace entries nobody has seen. Changes made meanwhile show in `list()` at once, each asks for the read again, and they are replayed onto the stored list when it is read, which is then written. A read answering something other than an array is a read of an empty store. Once a read has succeeded, `load()` does not read again.
|
|
148
|
+
|
|
149
|
+
Writes run one at a time and the last carries the latest list, so a host answering out of order cannot leave the older list stored. A host with no `state` keeps the list in memory for the session.
|
|
@@ -19,8 +19,8 @@ map. See `.gitignore` — it says so there too, because it is the kind of thing
|
|
|
19
19
|
Every JavaScript entry in the `exports` map has two conditions: `import`, the
|
|
20
20
|
prebuilt bundle under `dist/`, and `development`, the source barrel at the
|
|
21
21
|
package root (`core.js`, `host.js`, `wm.js`, `widgets.js`, `charts.js`,
|
|
22
|
-
`tiles.js`, `editor.js`, `canvas.js`, `flow.js`), which re-exports `src/`.
|
|
23
|
-
Up to 0.17.0 those
|
|
22
|
+
`tiles.js`, `editor.js`, `canvas.js`, `flow.js`, `git.js`), which re-exports `src/`.
|
|
23
|
+
Up to 0.17.0 those barrels were missing from `files`, so the published
|
|
24
24
|
package did not contain them and a resolver that took the `development` branch
|
|
25
25
|
(`require.resolve` under `--conditions=development`, for one) got a path to
|
|
26
26
|
nothing. From 0.17.1 they ship, beside the `src/` they import.
|
|
@@ -2,26 +2,27 @@
|
|
|
2
2
|
|
|
3
3
|
> **Install:** see the [quickstart](../quickstart.md) · **Component pages:** [docs index](../README.md#components) · [All docs](../README.md)
|
|
4
4
|
|
|
5
|
-
##
|
|
5
|
+
## Ten entry points
|
|
6
6
|
|
|
7
7
|
Because someone who wants a DataTable should not have to download a window
|
|
8
8
|
manager, and neither of them should drag in a plotting engine or a code editor.
|
|
9
9
|
|
|
10
10
|
| Entry | Size | What is in it |
|
|
11
11
|
|---|---|---|
|
|
12
|
-
| `@flexdesk/core` |
|
|
13
|
-
| `@flexdesk/host` |
|
|
14
|
-
| `@flexdesk/wm` |
|
|
15
|
-
| `@flexdesk/widgets` |
|
|
16
|
-
| `@flexdesk/charts` |
|
|
17
|
-
| `@flexdesk/tiles` |
|
|
18
|
-
| `@flexdesk/editor` |
|
|
19
|
-
| `@flexdesk/canvas` |
|
|
20
|
-
| `@flexdesk/flow` |
|
|
12
|
+
| `@flexdesk/core` | 32 kB | Event bus, settings store, logging, state machine, state guard |
|
|
13
|
+
| `@flexdesk/host` | 18 kB | The [host port](host-port.md) |
|
|
14
|
+
| `@flexdesk/wm` | 403 kB | Tiling window manager: tabs, splits, desktops, command palette, taxonomy, `createShell()` |
|
|
15
|
+
| `@flexdesk/widgets` | 413 kB | DataTable, modals, managed windows, toasts, trees, panels, context menus, menu bars, a document outline, progress, a conversation view, an assistant chat shell, a rich select, a combobox, a popover, a filter builder, a workspace picker |
|
|
16
|
+
| `@flexdesk/charts` | 49 kB | Plotly wrapper, chart types, subplot layouts, downsampling, a plot grid, a plot-config editor, plot windows, injected series transforms |
|
|
17
|
+
| `@flexdesk/tiles` | 133 kB | Draggable/resizable widget grid, tile base class, widget registry, layout persistence, figure, chart and KPI tiles |
|
|
18
|
+
| `@flexdesk/editor` | 49 kB | Monaco loader + factory, multi-file tab bar, search, split panes, undo manager |
|
|
19
|
+
| `@flexdesk/canvas` | 5 kB | The canvas kernel: a pannable, zoomable surface, the edge router, a generic graph layer ([Flow editors](../components/flow-editors.md#flow-editors)) |
|
|
20
|
+
| `@flexdesk/flow` | 480 kB | The flow-editor kit and the canvas, outline and lane editors ([Flow editors](../components/flow-editors.md#flow-editors)) |
|
|
21
|
+
| `@flexdesk/git` | 47 kB | A source-control panel, an SVG commit graph and a diff viewer, over a written adapter contract ([Git panel](../components/git.md)) |
|
|
21
22
|
|
|
22
|
-
The sizes
|
|
23
|
-
|
|
24
|
-
|
|
23
|
+
The sizes are the entry files as `node build.mjs` reports them for 0.18.0,
|
|
24
|
+
unminified. Every entry also imports shared chunks, which load once however many
|
|
25
|
+
entries use them; the build prints their total.
|
|
25
26
|
|
|
26
27
|
They share code through chunks, so importing two of them does **not** give you two
|
|
27
28
|
event buses — and therefore not two copies of every module-level singleton. A test
|
|
@@ -40,7 +40,7 @@ global; choose strict mode in your own code.
|
|
|
40
40
|
write, so the shell, dialogs, forms, tooltips, tiles and flow editors run under
|
|
41
41
|
`mode: 'strict'` with no violation. The list also carries keys that FlexDesk
|
|
42
42
|
itself does not write, and it has not been audited key by key
|
|
43
|
-
([what 0.
|
|
43
|
+
([what 0.17.14 took out and what stayed](#thirteen-application-keys-leave-the-defaults-01714)).
|
|
44
44
|
A key that names your application's concepts is yours to add, on top:
|
|
45
45
|
|
|
46
46
|
```js
|
|
@@ -59,13 +59,13 @@ capital letter becomes `-` plus its lower-case form. So `rowID` is `row-i-d`
|
|
|
59
59
|
Before 0.17.0 a camelCase entry never matched, and some entries named keys no
|
|
60
60
|
element carries.
|
|
61
61
|
|
|
62
|
-
### Thirteen application keys leave the defaults (0.
|
|
62
|
+
### Thirteen application keys leave the defaults (0.17.14)
|
|
63
63
|
|
|
64
64
|
**Breaking for an application that writes one of these keys under a strict
|
|
65
65
|
guard.** `DEFAULT_STATE_GUARD_CONFIG.allowedDatasetKeys` no longer lists
|
|
66
66
|
thirteen keys that belonged to one embedding application, not to FlexDesk. No
|
|
67
67
|
FlexDesk widget writes any of them, so nothing FlexDesk draws changes; what
|
|
68
|
-
changes is that a write of one is now on no allow-list. Before 0.
|
|
68
|
+
changes is that a write of one is now on no allow-list. Before 0.17.14 the
|
|
69
69
|
defaults let it through silently, for every application that used them.
|
|
70
70
|
|
|
71
71
|
| Group, as the list labelled it | Keys removed |
|
package/docs/quickstart.md
CHANGED
|
@@ -14,7 +14,7 @@ The package ships a prebuilt `dist/`. Copy it into your site (the examples below
|
|
|
14
14
|
mkdir -p vendor && cp -r node_modules/flexdesk/dist vendor/flexdesk
|
|
15
15
|
```
|
|
16
16
|
|
|
17
|
-
Under a bundler you need none of that. Import the entries as `flexdesk/core`, `flexdesk/host`, `flexdesk/wm`, `flexdesk/widgets` (and `flexdesk/charts`, `flexdesk/tiles`, `flexdesk/editor`, `flexdesk/canvas`, `flexdesk/flow`), and the stylesheets as `flexdesk/css/tokens`, `flexdesk/css/reset` and `flexdesk/css`. The rest of these docs write the entries as `@flexdesk/wm` and so on, which is the name the import map below gives them.
|
|
17
|
+
Under a bundler you need none of that. Import the entries as `flexdesk/core`, `flexdesk/host`, `flexdesk/wm`, `flexdesk/widgets` (and `flexdesk/charts`, `flexdesk/tiles`, `flexdesk/editor`, `flexdesk/canvas`, `flexdesk/flow`, `flexdesk/git`), and the stylesheets as `flexdesk/css/tokens`, `flexdesk/css/reset` and `flexdesk/css`. The rest of these docs write the entries as `@flexdesk/wm` and so on, which is the name the import map below gives them.
|
|
18
18
|
|
|
19
19
|
## The page
|
|
20
20
|
|
package/git.js
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @flexdesk/git — a source-control panel, an SVG swimlane commit graph and a
|
|
3
|
+
* diff viewer, over a written adapter contract (docs/components/git.md).
|
|
4
|
+
*
|
|
5
|
+
* It runs no git and reaches no backend by itself: every repository operation
|
|
6
|
+
* is a method of the adapter object the application passes in.
|
|
7
|
+
*
|
|
8
|
+
* HAND-WRITTEN, not generated. The barrels that say "GENERATED by
|
|
9
|
+
* scripts/gen_barrels.py" name a script that is not in this repository: add a
|
|
10
|
+
* module to src/git/index.js when you add one under src/git/.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export * from './src/git/index.js';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "flexdesk",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.18.0",
|
|
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": {
|
|
@@ -27,6 +27,7 @@
|
|
|
27
27
|
"editor.js",
|
|
28
28
|
"canvas.js",
|
|
29
29
|
"flow.js",
|
|
30
|
+
"git.js",
|
|
30
31
|
"css",
|
|
31
32
|
"docs",
|
|
32
33
|
"README.md",
|
|
@@ -73,6 +74,10 @@
|
|
|
73
74
|
"./flow": {
|
|
74
75
|
"import": "./dist/flow.js",
|
|
75
76
|
"development": "./flow.js"
|
|
77
|
+
},
|
|
78
|
+
"./git": {
|
|
79
|
+
"import": "./dist/git.js",
|
|
80
|
+
"development": "./git.js"
|
|
76
81
|
}
|
|
77
82
|
},
|
|
78
83
|
"scripts": {
|
package/src/flow/kit/dom.js
CHANGED
|
@@ -1,27 +1,16 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* THE KIT'S SMALL DOM HELPERS, AND ITS
|
|
2
|
+
* THE KIT'S SMALL DOM HELPERS, AND ITS POPOVER.
|
|
3
3
|
*
|
|
4
4
|
* Everything here builds elements; nothing here reads the network, storage or
|
|
5
|
-
* a consumer's vocabulary. The popover
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
* - it is `position: fixed`, placed under its anchor (above when there is no
|
|
12
|
-
* room below) and CLAMPED to the viewport;
|
|
13
|
-
* - it is dismissed by a press outside it, by Escape, or by `close()`, and
|
|
14
|
-
* **it returns focus to its anchor** when it closes with focus inside it —
|
|
15
|
-
* a picker that drops focus on `<body>` drops it where an editor's keys
|
|
16
|
-
* never arrive — except when focus has just left it by Tab, which the
|
|
17
|
-
* pickers close it for (`'blur'`): that focus is going where it was sent;
|
|
18
|
-
* - there is ONE at a time: opening a second closes the first, and opening one
|
|
19
|
-
* from the anchor that holds the open one closes it (a toggle);
|
|
20
|
-
* - `closeFlowPopovers()` closes whichever is open, which is what an editor's
|
|
21
|
-
* `destroy()` calls so that nothing it opened outlives it.
|
|
5
|
+
* a consumer's vocabulary. The popover, the step picker's and *Insert a value*'s
|
|
6
|
+
* shell (36 §3.5, §3.7), is FlexDesk's `openPopover`, which says how it is placed,
|
|
7
|
+
* dismissed and toggled and that it returns focus to its anchor: a picker that
|
|
8
|
+
* drops focus on `<body>` drops it where an editor's keys never arrive. What the
|
|
9
|
+
* kit adds is `closeFlowPopovers()`, which closes whichever of its own is open:
|
|
10
|
+
* what an editor's `destroy()` calls so that nothing it opened outlives it.
|
|
22
11
|
*/
|
|
23
12
|
|
|
24
|
-
import {
|
|
13
|
+
import { openPopover as openAnchoredPopover } from '../../ui/components/popover.js';
|
|
25
14
|
|
|
26
15
|
/** The category tones (36 §3.1, §3.12); the stylesheet maps each to tokens. */
|
|
27
16
|
export const TONES = Object.freeze(['violet', 'teal', 'amber', 'grey', 'blue', 'indigo']);
|
|
@@ -81,118 +70,38 @@ export function uid(stem = 'flow') {
|
|
|
81
70
|
|
|
82
71
|
// ── the popover ──────────────────────────────────────────────────────────
|
|
83
72
|
|
|
84
|
-
|
|
85
|
-
const
|
|
86
|
-
let ACTIVE = null;
|
|
73
|
+
/** The kit's open popovers, oldest first. */
|
|
74
|
+
const OPEN = [];
|
|
87
75
|
|
|
88
|
-
/** Close
|
|
76
|
+
/** Close every kit popover that is open. */
|
|
89
77
|
export function closeFlowPopovers() {
|
|
90
|
-
|
|
78
|
+
OPEN[0]?.close('closed');
|
|
91
79
|
}
|
|
92
80
|
|
|
93
|
-
/** The open popover's element, or null — for a test. */
|
|
81
|
+
/** The open popover's element (the innermost's), or null — for a test. */
|
|
94
82
|
export function openFlowPopover() {
|
|
95
|
-
return
|
|
83
|
+
return OPEN.length ? OPEN[OPEN.length - 1].el : null;
|
|
96
84
|
}
|
|
97
85
|
|
|
98
86
|
/**
|
|
99
|
-
*
|
|
87
|
+
* FlexDesk's popover (`openPopover` in `ui/components/popover.js`), wearing the
|
|
88
|
+
* kit's class and remembered so that an editor can close what it opened.
|
|
100
89
|
*
|
|
101
|
-
* @param {object} o
|
|
102
|
-
* @
|
|
103
|
-
*
|
|
104
|
-
*
|
|
105
|
-
* @param {string} [o.className] an extra block class
|
|
106
|
-
* @param {(reason: string) => void} [o.onClose] after it is gone
|
|
107
|
-
* @param {HTMLElement} [o.focus] what takes focus on open
|
|
108
|
-
* @returns {{el: HTMLElement, close: (reason?: string) => void, reposition: () => void, toggled: boolean}}
|
|
109
|
-
* `toggled` is true when the call only CLOSED the popover its anchor held.
|
|
90
|
+
* @param {object} o What `openPopover` takes.
|
|
91
|
+
* @returns {{el: HTMLElement|null, close: (reason?: string) => void, reposition: () => void, toggled: boolean}}
|
|
92
|
+
* `el` is null when nothing opened; `toggled` is true when the call only
|
|
93
|
+
* CLOSED the popover its anchor held.
|
|
110
94
|
*/
|
|
111
|
-
export function openPopover({
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
const pop = el('div', `twm-flow-popover${className ? ` ${className}` : ''}`);
|
|
119
|
-
pop.setAttribute('role', 'dialog');
|
|
120
|
-
pop.setAttribute('aria-label', label || '');
|
|
121
|
-
pop.tabIndex = -1;
|
|
122
|
-
pop.appendChild(content);
|
|
123
|
-
(modalHost() || document.body).appendChild(pop);
|
|
124
|
-
|
|
125
|
-
let closed = false;
|
|
126
|
-
const place = () => {
|
|
127
|
-
if (closed) return;
|
|
128
|
-
// The CLIENT width: a page scrollbar is not room to draw in.
|
|
129
|
-
const view = { w: document.documentElement.clientWidth || window.innerWidth || 1024,
|
|
130
|
-
h: document.documentElement.clientHeight || window.innerHeight || 768 };
|
|
131
|
-
const a = anchor?.getBoundingClientRect?.() || { left: 0, top: 0, bottom: 0, right: 0 };
|
|
132
|
-
const box = pop.getBoundingClientRect();
|
|
133
|
-
// Under the anchor, its left edge on the anchor's; an anchor near the
|
|
134
|
-
// right edge gets the popover's RIGHT edge on its own instead.
|
|
135
|
-
let left = a.left;
|
|
136
|
-
if (left + box.width > view.w - EDGE && a.right - box.width >= EDGE) left = a.right - box.width;
|
|
137
|
-
let top = a.bottom + GAP;
|
|
138
|
-
if (top + box.height > view.h - EDGE && a.top - GAP - box.height >= EDGE) top = a.top - GAP - box.height;
|
|
139
|
-
left = Math.max(EDGE, Math.min(left, view.w - box.width - EDGE));
|
|
140
|
-
top = Math.max(EDGE, Math.min(top, view.h - box.height - EDGE));
|
|
141
|
-
pop.style.left = `${Math.round(left)}px`;
|
|
142
|
-
pop.style.top = `${Math.round(top)}px`;
|
|
143
|
-
};
|
|
144
|
-
|
|
145
|
-
const onPress = (ev) => {
|
|
146
|
-
if (pop.contains(ev.target)) return;
|
|
147
|
-
if (anchor && anchor.contains?.(ev.target)) return; // the anchor's own click toggles
|
|
148
|
-
handle.close('outside');
|
|
149
|
-
};
|
|
150
|
-
const onKey = (ev) => {
|
|
151
|
-
if (ev.key !== 'Escape') return;
|
|
152
|
-
ev.preventDefault();
|
|
153
|
-
ev.stopPropagation();
|
|
154
|
-
handle.close('escape');
|
|
155
|
-
};
|
|
156
|
-
const onResize = () => place();
|
|
157
|
-
// Placed before its content is drawn — a picker fills its list after this
|
|
158
|
-
// returns — so it is placed again whenever its size changes, or it would
|
|
159
|
-
// run past the window's edge.
|
|
160
|
-
const sized = typeof ResizeObserver === 'function' ? new ResizeObserver(() => place()) : null;
|
|
161
|
-
|
|
162
|
-
const handle = {
|
|
163
|
-
el: pop,
|
|
164
|
-
anchor,
|
|
165
|
-
toggled: false,
|
|
166
|
-
reposition: place,
|
|
167
|
-
close(reason = 'closed') {
|
|
168
|
-
if (closed) return;
|
|
169
|
-
closed = true;
|
|
170
|
-
const held = pop.contains(document.activeElement) || document.activeElement === document.body;
|
|
171
|
-
document.removeEventListener('pointerdown', onPress, true);
|
|
172
|
-
document.removeEventListener('mousedown', onPress, true);
|
|
173
|
-
pop.removeEventListener('keydown', onKey);
|
|
174
|
-
window.removeEventListener('resize', onResize);
|
|
175
|
-
sized?.disconnect();
|
|
176
|
-
pop.remove();
|
|
177
|
-
if (ACTIVE === handle) ACTIVE = null;
|
|
178
|
-
// Focus that LEFT the popover (a Tab out of it) is going somewhere the
|
|
179
|
-
// reader sent it; taking it back to the anchor would fight that move.
|
|
180
|
-
if (held && reason !== 'blur' && anchor?.isConnected) anchor.focus?.({ preventScroll: true });
|
|
95
|
+
export function openPopover({ className = '', onClose = null, ...rest }) {
|
|
96
|
+
const handle = openAnchoredPopover({
|
|
97
|
+
...rest,
|
|
98
|
+
className: `twm-flow-popover${className ? ` ${className}` : ''}`,
|
|
99
|
+
onClose(reason) {
|
|
100
|
+
OPEN.splice(OPEN.indexOf(handle), 1);
|
|
181
101
|
onClose?.(reason);
|
|
182
102
|
},
|
|
183
|
-
};
|
|
184
|
-
|
|
185
|
-
// The press that OPENED it is still being dispatched; listen from the next turn.
|
|
186
|
-
setTimeout(() => {
|
|
187
|
-
if (closed) return;
|
|
188
|
-
document.addEventListener('pointerdown', onPress, true);
|
|
189
|
-
document.addEventListener('mousedown', onPress, true);
|
|
190
|
-
}, 0);
|
|
191
|
-
pop.addEventListener('keydown', onKey);
|
|
192
|
-
window.addEventListener('resize', onResize);
|
|
193
|
-
sized?.observe(pop);
|
|
194
|
-
place();
|
|
195
|
-
(focus || pop).focus?.({ preventScroll: true });
|
|
103
|
+
});
|
|
104
|
+
if (handle.el) OPEN.push(handle);
|
|
196
105
|
return handle;
|
|
197
106
|
}
|
|
198
107
|
|