flexdesk 0.17.17 → 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 +70 -0
- package/LICENSE +0 -0
- package/README.md +1 -0
- package/THIRD_PARTY_LICENSES.md +0 -0
- package/canvas.js +0 -0
- package/charts.js +0 -0
- package/core.js +0 -0
- package/css/flexdesk.css +7499 -5851
- package/css/reset.css +1 -1
- package/css/tokens.css +23 -1
- package/dist/canvas.js +0 -0
- package/dist/canvas.js.map +0 -0
- package/dist/charts.js +10 -6
- package/dist/charts.js.map +1 -1
- package/dist/chunk-3PHCPZHT.js +0 -0
- package/dist/chunk-3PHCPZHT.js.map +0 -0
- 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-FOOS3T5L.js +0 -0
- package/dist/chunk-FOOS3T5L.js.map +0 -0
- package/dist/chunk-GOQLR5WR.js +0 -0
- package/dist/chunk-GOQLR5WR.js.map +0 -0
- package/dist/{chunk-JKZLNKG6.js → chunk-HKPB6OFA.js} +90 -25
- package/dist/{chunk-JKZLNKG6.js.map → chunk-HKPB6OFA.js.map} +2 -2
- package/dist/chunk-HNU6MM2B.js +0 -0
- package/dist/chunk-HNU6MM2B.js.map +0 -0
- package/dist/{chunk-REJ5HLIM.js → chunk-HYJYYEPK.js} +2 -2
- package/dist/chunk-JYWURG5T.js +0 -0
- package/dist/chunk-JYWURG5T.js.map +0 -0
- 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-N7HC3ADV.js +0 -0
- package/dist/chunk-N7HC3ADV.js.map +0 -0
- 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 +7499 -5851
- 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/host.js +0 -0
- package/dist/host.js.map +0 -0
- package/dist/reset.css +1 -1
- package/dist/tile_registry-6WZPTUZV.js +0 -0
- package/dist/tile_registry-6WZPTUZV.js.map +0 -0
- package/dist/tiles.js +20 -15
- package/dist/tiles.js.map +2 -2
- package/dist/tokens.css +23 -1
- package/dist/widgets.js +3969 -1189
- package/dist/widgets.js.map +4 -4
- package/dist/wm.js +121 -33
- 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/command-palette.md +0 -0
- package/docs/components/conversation.md +10 -0
- package/docs/components/data-table-grid.md +0 -0
- package/docs/components/data-table.md +0 -0
- package/docs/components/editor.md +0 -0
- package/docs/components/filter-builder.md +246 -0
- package/docs/components/flow-canvas-editor.md +0 -0
- package/docs/components/flow-editors.md +0 -0
- package/docs/components/flow-lane-editor.md +0 -0
- package/docs/components/flow-outline-editor.md +0 -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/markdown-and-outline.md +0 -0
- package/docs/components/menus.md +120 -3
- package/docs/components/panels-and-desktops.md +16 -5
- package/docs/components/record-list-and-fact-table.md +0 -0
- package/docs/components/settings-view.md +0 -0
- package/docs/components/shell.md +104 -1
- package/docs/components/wizard.md +0 -0
- package/docs/components/workspace-picker.md +149 -0
- package/docs/concepts/building-and-packaging.md +2 -2
- package/docs/concepts/domain-neutrality.md +0 -0
- package/docs/concepts/host-port.md +0 -0
- package/docs/concepts/packages-and-entry-points.md +14 -13
- package/docs/concepts/peer-dependencies.md +0 -0
- package/docs/concepts/state-guard.md +3 -3
- package/docs/quickstart.md +1 -1
- package/editor.js +0 -0
- package/flow.js +0 -0
- package/git.js +13 -0
- package/host.js +0 -0
- package/package.json +6 -1
- package/src/canvas/canvas_adapter.js +0 -0
- package/src/canvas/edge_router.js +0 -0
- package/src/canvas/graph/connector_router.js +0 -0
- package/src/canvas/graph/history_service.js +0 -0
- package/src/canvas/graph/node_platform.js +0 -0
- package/src/canvas/index.js +0 -0
- package/src/canvas/layered_layout.js +0 -0
- package/src/charts/data_series_plot_window.js +0 -0
- package/src/charts/downsample.js +0 -0
- package/src/charts/plot_config.js +0 -0
- package/src/charts/plotly_wrapper.js +0 -0
- package/src/core/event_bus.js +0 -0
- package/src/core/logging.js +0 -0
- package/src/core/settings.js +0 -0
- package/src/core/state_guard.js +0 -0
- package/src/core/state_machine.js +0 -0
- package/src/editor/editor_pane.js +0 -0
- package/src/editor/monaco_editor_factory.js +0 -0
- package/src/editor/monaco_loader.js +0 -0
- package/src/editor/notebook_search.js +0 -0
- package/src/editor/notebook_undo_manager.js +0 -0
- package/src/editor/split_pane_container.js +0 -0
- package/src/flow/canvas_editor/connections.js +0 -0
- package/src/flow/canvas_editor/editor.js +0 -0
- package/src/flow/canvas_editor/index.js +0 -0
- package/src/flow/canvas_editor/layout.js +0 -0
- package/src/flow/canvas_editor/strings.js +0 -0
- package/src/flow/kit/always_before.js +0 -0
- package/src/flow/kit/catalogue.js +0 -0
- package/src/flow/kit/chip_input.js +0 -0
- package/src/flow/kit/dom.js +28 -119
- package/src/flow/kit/findings.js +0 -0
- package/src/flow/kit/history.js +0 -0
- package/src/flow/kit/index.js +0 -0
- package/src/flow/kit/keys.js +0 -0
- package/src/flow/kit/logic_graph.js +0 -0
- package/src/flow/kit/references.js +0 -0
- package/src/flow/kit/settings_panel.js +0 -0
- package/src/flow/kit/settings_schema.js +0 -0
- package/src/flow/kit/step_picker.js +1 -1
- package/src/flow/kit/strings.js +0 -0
- package/src/flow/kit/value_picker.js +1 -1
- package/src/flow/kit/widgets.js +0 -0
- package/src/flow/lanes/editor.js +0 -0
- package/src/flow/lanes/index.js +0 -0
- package/src/flow/lanes/layout.js +0 -0
- package/src/flow/lanes/pipeline.js +0 -0
- package/src/flow/lanes/preview.js +0 -0
- package/src/flow/lanes/strings.js +0 -0
- package/src/flow/outline/editor.js +0 -0
- package/src/flow/outline/index.js +0 -0
- package/src/flow/outline/mapping.js +0 -0
- package/src/flow/outline/menu.js +1 -1
- package/src/flow/outline/operations.js +0 -0
- package/src/flow/outline/recognise.js +0 -0
- package/src/flow/outline/strings.js +0 -0
- 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/help/help_modal.js +0 -0
- package/src/help/help_registry.js +0 -0
- package/src/host/external_links.js +0 -0
- package/src/host/file_picker.js +0 -0
- package/src/host/host.js +0 -0
- package/src/host/photino_host.js +0 -0
- package/src/host/photino_host.md +0 -0
- package/src/host/pywebview_host.js +0 -0
- package/src/tiles/chart_tiles.js +0 -0
- package/src/tiles/config_schema.js +0 -0
- package/src/tiles/figure_tile.js +0 -0
- package/src/tiles/layout_persistence.js +0 -0
- package/src/tiles/list_tiles.js +0 -0
- package/src/tiles/scope_picker.js +0 -0
- package/src/tiles/tile_base.js +5 -4
- package/src/tiles/tile_config_form.js +0 -0
- package/src/tiles/tile_registry.js +0 -0
- package/src/tiling/bottom_panel_tabs.js +0 -0
- package/src/tiling/command_palette.js +0 -0
- package/src/tiling/content_registry.js +0 -0
- package/src/tiling/desktops.js +10 -8
- package/src/tiling/entity_sources.js +0 -0
- package/src/tiling/keymap.js +6 -5
- package/src/tiling/kind_taxonomy.js +15 -2
- package/src/tiling/landing_table.js +0 -0
- package/src/tiling/loading_overlay.js +0 -0
- package/src/tiling/nav_panel.js +0 -0
- package/src/tiling/page_factory.js +0 -0
- package/src/tiling/panel_availability.js +0 -0
- package/src/tiling/panel_keys.js +0 -0
- package/src/tiling/shell.js +124 -17
- package/src/tiling/tab_strip.js +0 -0
- package/src/tiling/tile_renderer.js +2 -3
- package/src/tiling/tile_tab_menu.js +0 -0
- package/src/tiling/zoom.js +0 -0
- package/src/ui/base/component_base.js +0 -0
- package/src/ui/base/controller_base.js +0 -0
- package/src/ui/base/page_base.js +0 -0
- package/src/ui/components/about_dialog.js +0 -0
- package/src/ui/components/action_dropdown.js +4 -78
- package/src/ui/components/alert_log.js +0 -0
- package/src/ui/components/alerts_pane.js +0 -0
- package/src/ui/components/anchored_layer.js +255 -0
- package/src/ui/components/attribute_list_editor.js +0 -0
- package/src/ui/components/autocomplete_field.js +0 -0
- package/src/ui/components/cell_editor_host.js +0 -0
- package/src/ui/components/cell_editors.js +0 -0
- package/src/ui/components/cell_grid.js +0 -0
- package/src/ui/components/cell_paste.js +0 -0
- package/src/ui/components/cell_selection.js +0 -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/column_fit.js +0 -0
- package/src/ui/components/column_groups.js +0 -0
- package/src/ui/components/column_order.js +0 -0
- package/src/ui/components/combobox.js +422 -0
- package/src/ui/components/conversation_view.js +17 -2
- package/src/ui/components/crosstab.js +0 -0
- package/src/ui/components/data_table.js +2 -2
- package/src/ui/components/detail_header.js +0 -0
- package/src/ui/components/document_outline.js +0 -0
- package/src/ui/components/drag_reorder.js +0 -0
- package/src/ui/components/fact_table.js +0 -0
- package/src/ui/components/fill_handle.js +0 -0
- package/src/ui/components/filter_builder.js +766 -0
- package/src/ui/components/filter_model.js +614 -0
- package/src/ui/components/gallery_picker.js +0 -0
- package/src/ui/components/header_actions.js +0 -0
- package/src/ui/components/inline_renamer.js +0 -0
- package/src/ui/components/job_progress.js +0 -0
- package/src/ui/components/managed_window.js +24 -47
- package/src/ui/components/menu_bar.js +101 -24
- 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/progress.js +0 -0
- package/src/ui/components/recent_items.js +169 -0
- package/src/ui/components/record_list.js +0 -0
- package/src/ui/components/row_activation.js +0 -0
- package/src/ui/components/section_stack.js +0 -0
- package/src/ui/components/select.js +322 -0
- package/src/ui/components/series.js +0 -0
- package/src/ui/components/settings_view.js +0 -0
- package/src/ui/components/slide_out_panel.js +0 -0
- package/src/ui/components/slider_field.js +0 -0
- package/src/ui/components/sortable_list.js +0 -0
- package/src/ui/components/staged_edits.js +0 -0
- package/src/ui/components/staged_history.js +0 -0
- package/src/ui/components/table_filter_chips.js +0 -0
- package/src/ui/components/table_state_store.js +0 -0
- package/src/ui/components/table_structure.js +0 -0
- package/src/ui/components/table_values.js +0 -0
- package/src/ui/components/tabs.js +0 -0
- package/src/ui/components/tag_input.js +77 -8
- package/src/ui/components/toast.js +0 -0
- package/src/ui/components/toggle_field.js +0 -0
- package/src/ui/components/tsv.js +0 -0
- package/src/ui/components/window_taskbar.js +0 -0
- package/src/ui/components/workspace_picker.js +1155 -0
- package/src/ui/controllers/window_chrome_controller.js +4 -1
- package/src/ui/utils/auto_scrollbars.js +0 -0
- package/src/ui/utils/fire_event.js +17 -0
- package/src/ui/utils/focusable.js +25 -0
- package/src/ui/utils/markdown.js +0 -0
- package/src/ui/utils/modal_host.js +27 -0
- package/src/ui/utils/native_context_menu.js +0 -0
- package/src/ui/utils/overlay_scrollbar.js +0 -0
- package/src/ui/utils/raf_resize_observer.js +0 -0
- package/src/ui/utils/scroll_lock.js +0 -0
- package/src/ui/utils/strip_scroll.js +0 -0
- package/src/ui/utils/tooltip_service.js +0 -0
- package/tiles.js +0 -0
- package/widgets.js +12 -2
- package/wm.js +0 -0
- package/css/base.css +0 -10527
- 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
|
@@ -0,0 +1,1155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* workspace_picker.js — the field in a top bar that names what the window is
|
|
3
|
+
* working in, and the switcher that changes it.
|
|
4
|
+
*
|
|
5
|
+
* field switcher, opened in the field's place
|
|
6
|
+
* +-----------------------+ +------------------------------------+
|
|
7
|
+
* | (i) North Ctrl+P | | (o) nor| |
|
|
8
|
+
* +-----------------------+ | heading 2 of 9|
|
|
9
|
+
* | North (check)(x)|
|
|
10
|
+
* | Northwest |
|
|
11
|
+
* | Load 20 more · 2 of 9 shown |
|
|
12
|
+
* | + New "nor"… |
|
|
13
|
+
* | card: the current one's links |
|
|
14
|
+
* | status footer verbs |
|
|
15
|
+
* +------------------------------------+
|
|
16
|
+
*
|
|
17
|
+
* Three exports, each usable alone:
|
|
18
|
+
*
|
|
19
|
+
* mountPickerField the field: a name, an icon, a key hint; centred in
|
|
20
|
+
* its host at the width the host can spare, or inline
|
|
21
|
+
* createPickerSwitcher the switcher, on any anchor or on none
|
|
22
|
+
* createWorkspacePicker both, wired together, with an optional chord
|
|
23
|
+
*
|
|
24
|
+
* It knows nothing of what is being picked. Items are the caller's, `describe`
|
|
25
|
+
* turns one into what a row draws, and every string is the caller's or a
|
|
26
|
+
* neutral default the caller can replace through `labels`.
|
|
27
|
+
*
|
|
28
|
+
* ══ ITEMS ARE ASKED FOR ON EVERY OPEN ══════════════════════════════════════
|
|
29
|
+
*
|
|
30
|
+
* `load({ query, offset, limit, signal })` answers an array, or
|
|
31
|
+
* `{ items, total?, status? }`, now or in a promise. With `search: 'local'` it
|
|
32
|
+
* is asked once per open and typing filters what it answered, names ahead of
|
|
33
|
+
* other matches. With `search: 'remote'` typing is debounced and asked again,
|
|
34
|
+
* and a `total` above what is drawn offers Load more. Either way only the
|
|
35
|
+
* newest question's answer paints: every read carries a sequence number and an
|
|
36
|
+
* AbortSignal, and an answer to an older question is dropped however it
|
|
37
|
+
* arrives. The last unsearched answer, per `cacheKey()`, is drawn at once on
|
|
38
|
+
* the next open while it is asked for again.
|
|
39
|
+
*
|
|
40
|
+
* ══ THE KEYBOARD ═══════════════════════════════════════════════════════════
|
|
41
|
+
*
|
|
42
|
+
* The caret stays in the input, so typing always types. The arrows walk a
|
|
43
|
+
* highlight over every stop, wrapping: the rows a person can choose (a disabled
|
|
44
|
+
* one is stepped over), Load more, the action rows, the card's buttons, the
|
|
45
|
+
* footer's. Until they move, the highlight is the first row or action row of
|
|
46
|
+
* what is shown, and Enter takes it, or, while the answer to what was typed is
|
|
47
|
+
* on its way, takes the first one when it lands. Shift+Delete removes the
|
|
48
|
+
* highlighted row. Esc closes and gives focus back to where it was, as does a
|
|
49
|
+
* pick or a verb, before it runs, so a dialog it opens opens over nothing. A
|
|
50
|
+
* press outside closes and leaves focus where it was pressed. Tab leaves the
|
|
51
|
+
* input for the panel's buttons, and out of the panel closes it.
|
|
52
|
+
*
|
|
53
|
+
* ══ NOTHING IS REBUILT UNDER A POINTER ═════════════════════════════════════
|
|
54
|
+
*
|
|
55
|
+
* A click fires only when the press and the release land on one node. So every
|
|
56
|
+
* part is reconciled by key: an answer, a card or a `setCurrent` updates nodes
|
|
57
|
+
* in place, and a node is created or removed only for a key that came or went.
|
|
58
|
+
* Which node holds what lives in maps here, never in DOM attributes.
|
|
59
|
+
*
|
|
60
|
+
* Nor is a list moved under one. The list scrolls to the highlight only when the
|
|
61
|
+
* keyboard moved the highlight, `open({ select })` asked for a row, or the row
|
|
62
|
+
* the arrows chose went away; typing and a new query's answer go back to the top.
|
|
63
|
+
* A redraw, Load more by the mouse and a removal leave the scroll where it is.
|
|
64
|
+
*
|
|
65
|
+
* ══ AN ASYNC REMOVE MAY ASK ════════════════════════════════════════════════
|
|
66
|
+
*
|
|
67
|
+
* While the promise `onRemove` returned is unsettled the panel and its backdrop
|
|
68
|
+
* step below managed windows and do not close on a press outside, so a
|
|
69
|
+
* confirmation dialog can be answered and the switcher is still there afterwards.
|
|
70
|
+
*/
|
|
71
|
+
|
|
72
|
+
import { ActionDropdown } from './action_dropdown.js';
|
|
73
|
+
import { createRafResizeObserver } from '../utils/raf_resize_observer.js';
|
|
74
|
+
|
|
75
|
+
const DEFAULT_LABELS = Object.freeze({
|
|
76
|
+
loading: 'Loading…',
|
|
77
|
+
empty: () => 'Nothing to show.',
|
|
78
|
+
noMatch: (query) => `Nothing matches “${query}”.`,
|
|
79
|
+
failed: (err) => `The list could not be read. ${err?.message || ''}`.trim(),
|
|
80
|
+
failedMore: (err) => `The next page could not be read. ${err?.message || ''}`.trim(),
|
|
81
|
+
failedRemove: (err) => `Could not remove it. ${err?.message || ''}`.trim(),
|
|
82
|
+
loadMore: ({ next, shown, total }) => `Load ${next} more · ${shown} of ${total} shown`,
|
|
83
|
+
remove: (name) => `Remove ${name}`,
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
const PANEL_EDGE = 8;
|
|
87
|
+
/** How far the panel's input row stands above the field it covers. */
|
|
88
|
+
const OVER_LIFT = 3;
|
|
89
|
+
|
|
90
|
+
let nextUid = 0;
|
|
91
|
+
|
|
92
|
+
function part(tag, className, text = null) {
|
|
93
|
+
const node = document.createElement(tag);
|
|
94
|
+
if (className) node.className = className;
|
|
95
|
+
if (text != null) node.textContent = text;
|
|
96
|
+
return node;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function glyph(name, className = '') {
|
|
100
|
+
const node = part('span', `material-symbols-outlined ${className}`.trim(), name);
|
|
101
|
+
node.setAttribute('aria-hidden', 'true');
|
|
102
|
+
return node;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Write text only when it changed: a write that changes nothing is still a
|
|
106
|
+
* mutation, and a screen reader hears it. */
|
|
107
|
+
function setText(node, text) {
|
|
108
|
+
const value = String(text ?? '');
|
|
109
|
+
if (node.textContent !== value) node.textContent = value;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function show(node, shown) {
|
|
113
|
+
node.hidden = !shown;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function button(className) {
|
|
117
|
+
const node = part('button', className);
|
|
118
|
+
node.type = 'button';
|
|
119
|
+
return node;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Keep `container`'s children one per key: `build()` makes `{ node, ... }` for
|
|
124
|
+
* a new key, `update(entry, item)` brings any entry up to date, a key that went
|
|
125
|
+
* loses its node, and a node already in its place is not moved (moving one
|
|
126
|
+
* under a pressed pointer is half of rebuilding it).
|
|
127
|
+
* @returns {(items: object[], keyOf: (item: object) => unknown) => Map<string, object>}
|
|
128
|
+
*/
|
|
129
|
+
function keyedChildren(container, build, update) {
|
|
130
|
+
let drawn = new Map();
|
|
131
|
+
return (items, keyOf) => {
|
|
132
|
+
const next = new Map();
|
|
133
|
+
const nodes = items.map((item) => {
|
|
134
|
+
const key = String(keyOf(item));
|
|
135
|
+
const entry = drawn.get(key) ?? build();
|
|
136
|
+
update(entry, item);
|
|
137
|
+
next.set(key, entry);
|
|
138
|
+
return entry.node;
|
|
139
|
+
});
|
|
140
|
+
for (const [key, entry] of drawn) {
|
|
141
|
+
if (!next.has(key)) entry.node.remove();
|
|
142
|
+
}
|
|
143
|
+
drawn = next;
|
|
144
|
+
nodes.forEach((node, index) => {
|
|
145
|
+
if (container.children[index] !== node) {
|
|
146
|
+
container.insertBefore(node, container.children[index] || null);
|
|
147
|
+
}
|
|
148
|
+
});
|
|
149
|
+
return drawn;
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function parseChord(chord) {
|
|
154
|
+
const parts = String(chord).split('+').map((p) => p.trim().toLowerCase());
|
|
155
|
+
const key = parts.pop();
|
|
156
|
+
const known = new Set(['ctrl', 'cmd', 'meta', 'shift', 'alt']);
|
|
157
|
+
if (!key || parts.some((p) => !known.has(p))) {
|
|
158
|
+
throw new TypeError(`createWorkspacePicker: cannot read the chord "${chord}"`);
|
|
159
|
+
}
|
|
160
|
+
return {
|
|
161
|
+
key,
|
|
162
|
+
ctrl: parts.includes('ctrl') || parts.includes('cmd') || parts.includes('meta'),
|
|
163
|
+
shift: parts.includes('shift'),
|
|
164
|
+
alt: parts.includes('alt'),
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function chordMatches(parsed, event) {
|
|
169
|
+
return (event.ctrlKey || event.metaKey) === parsed.ctrl
|
|
170
|
+
&& event.shiftKey === parsed.shift
|
|
171
|
+
&& event.altKey === parsed.alt
|
|
172
|
+
&& String(event.key).toLowerCase() === parsed.key;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Mount the field into `hostEl`.
|
|
177
|
+
*
|
|
178
|
+
* `layout: 'centered'` places it in the middle of `hostEl` (which must be
|
|
179
|
+
* positioned) at the width the host can spare between the elements `avoid`
|
|
180
|
+
* selects: at most `maxWidth`, without the key hint below `tightWidth`, and
|
|
181
|
+
* hidden below `minWidth`. `layout: 'inline'` leaves its width to the host and
|
|
182
|
+
* only drops the key hint.
|
|
183
|
+
*
|
|
184
|
+
* @param {HTMLElement} hostEl
|
|
185
|
+
* @param {object} [options]
|
|
186
|
+
* @param {string} [options.name] - The name it shows
|
|
187
|
+
* @param {string} [options.icon] - A Material Symbols name
|
|
188
|
+
* @param {string} [options.keyHint] - Drawn as a key; none by default
|
|
189
|
+
* @param {boolean} [options.muted] - Draw the name as naming nothing
|
|
190
|
+
* @param {boolean} [options.caret] - A trailing caret, for an inline field
|
|
191
|
+
* @param {string} [options.tooltip]
|
|
192
|
+
* @param {string} [options.ariaLabel]
|
|
193
|
+
* @param {string} [options.className] - Extra classes
|
|
194
|
+
* @param {'centered'|'inline'} [options.layout]
|
|
195
|
+
* @param {string} [options.avoid] - Selector, within `hostEl`, of what it must not cover
|
|
196
|
+
* @param {number} [options.maxWidth]
|
|
197
|
+
* @param {number} [options.minWidth]
|
|
198
|
+
* @param {number} [options.tightWidth]
|
|
199
|
+
* @param {number} [options.gap] - Room kept free in all, shared between the two sides
|
|
200
|
+
* @param {() => void} [options.onActivate] - A click
|
|
201
|
+
* @returns {{ el: HTMLButtonElement, update: (patch: object) => void, setOpen: (open: boolean) => void, fit: () => void, destroy: () => void }}
|
|
202
|
+
*/
|
|
203
|
+
export function mountPickerField(hostEl, {
|
|
204
|
+
name = '',
|
|
205
|
+
icon = '',
|
|
206
|
+
keyHint = '',
|
|
207
|
+
muted = false,
|
|
208
|
+
caret = false,
|
|
209
|
+
tooltip = '',
|
|
210
|
+
ariaLabel = '',
|
|
211
|
+
className = '',
|
|
212
|
+
layout = 'inline',
|
|
213
|
+
avoid = '',
|
|
214
|
+
maxWidth = 440,
|
|
215
|
+
minWidth = 120,
|
|
216
|
+
tightWidth = 230,
|
|
217
|
+
gap = 24,
|
|
218
|
+
onActivate = null,
|
|
219
|
+
} = {}) {
|
|
220
|
+
const centered = layout === 'centered';
|
|
221
|
+
const el = button(['twm-wspicker-field', centered ? 'twm-wspicker-field--centered' : '', className]
|
|
222
|
+
.filter(Boolean).join(' '));
|
|
223
|
+
el.setAttribute('aria-haspopup', 'dialog');
|
|
224
|
+
el.setAttribute('aria-expanded', 'false');
|
|
225
|
+
const iconEl = glyph('', 'twm-wspicker-field__icon');
|
|
226
|
+
const nameEl = part('span', 'twm-wspicker-field__name');
|
|
227
|
+
const keyEl = part('kbd', 'twm-wspicker-field__key');
|
|
228
|
+
el.append(iconEl, nameEl, keyEl);
|
|
229
|
+
if (caret) el.append(glyph('expand_more', 'twm-wspicker-field__caret'));
|
|
230
|
+
|
|
231
|
+
const update = (patch) => {
|
|
232
|
+
if ('name' in patch) setText(nameEl, patch.name);
|
|
233
|
+
if ('icon' in patch) {
|
|
234
|
+
setText(iconEl, patch.icon);
|
|
235
|
+
show(iconEl, !!patch.icon);
|
|
236
|
+
}
|
|
237
|
+
if ('keyHint' in patch) {
|
|
238
|
+
setText(keyEl, patch.keyHint);
|
|
239
|
+
show(keyEl, !!patch.keyHint);
|
|
240
|
+
}
|
|
241
|
+
if ('muted' in patch) el.classList.toggle('twm-wspicker-field--muted', !!patch.muted);
|
|
242
|
+
if ('tooltip' in patch) {
|
|
243
|
+
el.classList.toggle('twm-has-tooltip', !!patch.tooltip);
|
|
244
|
+
if (patch.tooltip) {
|
|
245
|
+
el.setAttribute('data-tooltip', patch.tooltip);
|
|
246
|
+
el.setAttribute('data-tooltip-placement', 'bottom');
|
|
247
|
+
} else {
|
|
248
|
+
el.removeAttribute('data-tooltip');
|
|
249
|
+
el.removeAttribute('data-tooltip-placement');
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
if ('ariaLabel' in patch) {
|
|
253
|
+
if (patch.ariaLabel) el.setAttribute('aria-label', patch.ariaLabel);
|
|
254
|
+
else el.removeAttribute('aria-label');
|
|
255
|
+
}
|
|
256
|
+
};
|
|
257
|
+
update({ name, icon, keyHint, muted, tooltip, ariaLabel });
|
|
258
|
+
|
|
259
|
+
const fit = () => {
|
|
260
|
+
if (!centered) {
|
|
261
|
+
el.classList.toggle('twm-wspicker-field--tight', el.getBoundingClientRect().width < tightWidth);
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
const box = hostEl.getBoundingClientRect();
|
|
265
|
+
const middle = box.left + box.width / 2;
|
|
266
|
+
let leftEdge = box.left;
|
|
267
|
+
let rightEdge = box.right;
|
|
268
|
+
for (const other of avoid ? hostEl.querySelectorAll(avoid) : []) {
|
|
269
|
+
if (el.contains(other)) continue;
|
|
270
|
+
const r = other.getBoundingClientRect();
|
|
271
|
+
if (r.width <= 0) continue;
|
|
272
|
+
if (r.left + r.width / 2 < middle) leftEdge = Math.max(leftEdge, r.right);
|
|
273
|
+
else rightEdge = Math.min(rightEdge, r.left);
|
|
274
|
+
}
|
|
275
|
+
const width = Math.min(maxWidth, 2 * Math.min(middle - leftEdge, rightEdge - middle) - gap);
|
|
276
|
+
el.classList.toggle('twm-wspicker-field--hidden', width < minWidth);
|
|
277
|
+
el.classList.toggle('twm-wspicker-field--tight', width < tightWidth);
|
|
278
|
+
el.style.width = `${Math.max(minWidth, width)}px`;
|
|
279
|
+
};
|
|
280
|
+
|
|
281
|
+
let destroyed = false;
|
|
282
|
+
const onClick = () => onActivate?.();
|
|
283
|
+
el.addEventListener('click', onClick);
|
|
284
|
+
hostEl.appendChild(el);
|
|
285
|
+
const observer = createRafResizeObserver(() => {
|
|
286
|
+
if (!destroyed) fit();
|
|
287
|
+
});
|
|
288
|
+
observer.observe(centered ? hostEl : el);
|
|
289
|
+
fit();
|
|
290
|
+
document.fonts?.ready?.then(() => {
|
|
291
|
+
if (!destroyed) fit();
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
return {
|
|
295
|
+
el,
|
|
296
|
+
update,
|
|
297
|
+
fit,
|
|
298
|
+
setOpen(open) {
|
|
299
|
+
el.classList.toggle('twm-wspicker-field--open', open);
|
|
300
|
+
el.setAttribute('aria-expanded', String(open));
|
|
301
|
+
},
|
|
302
|
+
destroy() {
|
|
303
|
+
destroyed = true;
|
|
304
|
+
observer.disconnect();
|
|
305
|
+
el.removeEventListener('click', onClick);
|
|
306
|
+
el.remove();
|
|
307
|
+
},
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* The switcher. See the file's header for how it asks and how it behaves.
|
|
313
|
+
*
|
|
314
|
+
* @param {object} options
|
|
315
|
+
* @param {HTMLElement} [options.anchor] - What it opens from. With none, or one
|
|
316
|
+
* that takes no room, it opens centred over a dimmed page.
|
|
317
|
+
* @param {'over'|'below'} [options.placement] - `over` lays the input over the
|
|
318
|
+
* anchor; `below` hangs from it, flipped above when there is no room.
|
|
319
|
+
* @param {number} [options.minWidth] - The anchored panel's least width
|
|
320
|
+
* @param {number} [options.visibleRows] - Scroll past this many two-line rows
|
|
321
|
+
* @param {string} [options.className] - Extra classes on the panel
|
|
322
|
+
* @param {string} [options.label] - The panel's accessible name
|
|
323
|
+
* @param {string} [options.listLabel] - The list's accessible name
|
|
324
|
+
* @param {string} [options.placeholder] - The input's placeholder
|
|
325
|
+
* @param {string} [options.glyph] - The icon before the input
|
|
326
|
+
* @param {(request: {query: string, offset: number, limit: number, signal: AbortSignal}) => (object[]|{items: object[], total?: number, status?: string|{text: string, error?: boolean}}|Promise)} options.load
|
|
327
|
+
* @param {'local'|'remote'} [options.search]
|
|
328
|
+
* @param {number} [options.pageSize] - `limit`, in remote search
|
|
329
|
+
* @param {number} [options.debounceMs] - How long typing waits, in remote search
|
|
330
|
+
* @param {() => string} [options.cacheKey] - Whose list the kept answer is
|
|
331
|
+
* @param {(item: object, ctx: {current: boolean}) => {id: string|number, name: string, detail?: string, icon?: string, badge?: string, title?: string, keywords?: string|string[], disabled?: boolean, removable?: boolean}} options.describe
|
|
332
|
+
* @param {(item: object) => void} [options.onPick] - A row other than the current one chosen
|
|
333
|
+
* @param {(item: object) => (boolean|void|Promise<boolean|void>)} [options.onRemove] -
|
|
334
|
+
* Offers a bin on each row whose `removable` is not false. Answering `false`
|
|
335
|
+
* keeps the row; rejecting keeps it and shows the error.
|
|
336
|
+
* @param {(ctx: {query: string}) => Array<{id: string, label: string, detail?: string, icon?: string, always?: boolean, run: (ctx: {query: string}) => void}>} [options.actions] -
|
|
337
|
+
* Rows after the items, narrowed by typing unless `always`.
|
|
338
|
+
* @param {(ctx: {query: string, shown: number, total: number|null}) => ({title?: string, count?: string}|null)} [options.heading]
|
|
339
|
+
* @param {(ctx: {current: object|null, row: object|null}) => ({heading?: string, icon?: string, name: string, detail?: string, open?: {label: string, icon?: string, run: Function}, columns?: number, tiles?: Array<{id: string, label: string, icon?: string, run: Function}>}|null)} [options.card]
|
|
340
|
+
* @param {() => ({start?: object[], end?: object[], keys?: Array<{keys: string[], label: string}>}|null)} [options.footer] -
|
|
341
|
+
* Buttons are `{ id, label, icon?, trailing?, primary?, run }`. `keys` are drawn
|
|
342
|
+
* only when the panel is centred.
|
|
343
|
+
* @param {object} [options.labels] - Replaces any of the default strings
|
|
344
|
+
* @param {(open: boolean) => void} [options.onOpenChange]
|
|
345
|
+
*/
|
|
346
|
+
export function createPickerSwitcher({
|
|
347
|
+
anchor = null,
|
|
348
|
+
placement = 'over',
|
|
349
|
+
minWidth = 520,
|
|
350
|
+
visibleRows = 0,
|
|
351
|
+
className = '',
|
|
352
|
+
label = '',
|
|
353
|
+
listLabel = '',
|
|
354
|
+
placeholder = '',
|
|
355
|
+
glyph: inputGlyph = 'search',
|
|
356
|
+
load,
|
|
357
|
+
search = 'local',
|
|
358
|
+
pageSize = 20,
|
|
359
|
+
debounceMs = 200,
|
|
360
|
+
cacheKey = () => '',
|
|
361
|
+
describe,
|
|
362
|
+
onPick = null,
|
|
363
|
+
onRemove = null,
|
|
364
|
+
actions = null,
|
|
365
|
+
heading = null,
|
|
366
|
+
card = null,
|
|
367
|
+
footer = null,
|
|
368
|
+
labels: labelOverrides = {},
|
|
369
|
+
onOpenChange = null,
|
|
370
|
+
}) {
|
|
371
|
+
const labels = { ...DEFAULT_LABELS, ...labelOverrides };
|
|
372
|
+
const remote = search === 'remote';
|
|
373
|
+
const uid = `twm-wspicker-${++nextUid}`;
|
|
374
|
+
|
|
375
|
+
let isOpen = false;
|
|
376
|
+
let destroyed = false;
|
|
377
|
+
let current = null;
|
|
378
|
+
/** What the last answer held, in its order. */
|
|
379
|
+
let items = [];
|
|
380
|
+
let total = null;
|
|
381
|
+
let status = null;
|
|
382
|
+
/** The query the drawn items answer, and the newest one asked. */
|
|
383
|
+
let answered = '';
|
|
384
|
+
let asked = '';
|
|
385
|
+
let seq = 0;
|
|
386
|
+
let abort = null;
|
|
387
|
+
let loading = false;
|
|
388
|
+
let loadingMore = false;
|
|
389
|
+
let failure = null;
|
|
390
|
+
let timer = null;
|
|
391
|
+
/** Enter came before the answer to what was typed. */
|
|
392
|
+
let pickFirst = false;
|
|
393
|
+
/** Which row to highlight when the next answer lands. */
|
|
394
|
+
let selectId = null;
|
|
395
|
+
/** An `onRemove` promise is unsettled. */
|
|
396
|
+
let busy = false;
|
|
397
|
+
/** Where focus was when it opened. */
|
|
398
|
+
let opener = null;
|
|
399
|
+
/** Every place the arrows stop, in order. */
|
|
400
|
+
let stops = [];
|
|
401
|
+
let activeKey = null;
|
|
402
|
+
let activeNode = null;
|
|
403
|
+
/** The arrows chose the highlight; an answer does not move it. */
|
|
404
|
+
let moved = false;
|
|
405
|
+
let nextEntryId = 0;
|
|
406
|
+
const kept = new Map();
|
|
407
|
+
|
|
408
|
+
const idOf = (item) => String(describe(item, { current: false }).id);
|
|
409
|
+
const isCurrent = (item) => current != null && idOf(item) === idOf(current);
|
|
410
|
+
const modelOf = (item) => describe(item, { current: isCurrent(item) });
|
|
411
|
+
const typed = () => input.value.trim();
|
|
412
|
+
|
|
413
|
+
// ── the panel, built once ───────────────────────────────────────────
|
|
414
|
+
const backdrop = part('div', 'twm-wspicker-backdrop');
|
|
415
|
+
const panel = part('div', `twm-wspicker ${className}`.trim());
|
|
416
|
+
panel.setAttribute('role', 'dialog');
|
|
417
|
+
if (label) panel.setAttribute('aria-label', label);
|
|
418
|
+
|
|
419
|
+
const searchRow = part('div', 'twm-wspicker__search');
|
|
420
|
+
const input = part('input', 'twm-wspicker__input');
|
|
421
|
+
input.type = 'text';
|
|
422
|
+
input.autocomplete = 'off';
|
|
423
|
+
input.spellcheck = false;
|
|
424
|
+
input.placeholder = placeholder;
|
|
425
|
+
if (placeholder) input.setAttribute('aria-label', placeholder);
|
|
426
|
+
input.setAttribute('role', 'combobox');
|
|
427
|
+
input.setAttribute('aria-expanded', 'true');
|
|
428
|
+
input.setAttribute('aria-autocomplete', 'list');
|
|
429
|
+
input.setAttribute('aria-controls', `${uid}-list`);
|
|
430
|
+
searchRow.append(glyph(inputGlyph, 'twm-wspicker__glyph'), input);
|
|
431
|
+
|
|
432
|
+
const head = part('div', 'twm-wspicker__head');
|
|
433
|
+
const headTitle = part('span', 'twm-wspicker__title');
|
|
434
|
+
const headCount = part('span', 'twm-wspicker__count');
|
|
435
|
+
head.append(headTitle, headCount);
|
|
436
|
+
|
|
437
|
+
const scroller = part('div', 'twm-wspicker__scroll');
|
|
438
|
+
if (visibleRows > 0) {
|
|
439
|
+
scroller.classList.add('twm-wspicker__scroll--capped');
|
|
440
|
+
scroller.style.setProperty('--twm-wspicker-rows', String(visibleRows));
|
|
441
|
+
}
|
|
442
|
+
const list = part('div', 'twm-wspicker__list');
|
|
443
|
+
list.id = `${uid}-list`;
|
|
444
|
+
list.setAttribute('role', 'listbox');
|
|
445
|
+
if (listLabel) list.setAttribute('aria-label', listLabel);
|
|
446
|
+
const note = part('p', 'twm-wspicker__note');
|
|
447
|
+
note.setAttribute('role', 'status');
|
|
448
|
+
const more = button('twm-btn twm-wspicker__more');
|
|
449
|
+
const moreLabel = part('span', 'twm-wspicker__more-label');
|
|
450
|
+
more.append(glyph('expand_more'), moreLabel);
|
|
451
|
+
const actionList = part('div', 'twm-wspicker__actions');
|
|
452
|
+
actionList.setAttribute('role', 'listbox');
|
|
453
|
+
scroller.append(list, note, more, actionList);
|
|
454
|
+
|
|
455
|
+
const cardEl = part('section', 'twm-wspicker__card');
|
|
456
|
+
const cardHeading = part('h3', 'twm-wspicker__card-heading');
|
|
457
|
+
const cardHead = part('div', 'twm-wspicker__card-head');
|
|
458
|
+
const cardIcon = glyph('', 'twm-wspicker__card-icon');
|
|
459
|
+
const cardText = part('span', 'twm-wspicker__card-text');
|
|
460
|
+
const cardName = part('span', 'twm-wspicker__card-name');
|
|
461
|
+
const cardDetail = part('span', 'twm-wspicker__card-detail');
|
|
462
|
+
cardText.append(cardName, cardDetail);
|
|
463
|
+
const cardOpen = button('twm-btn twm-wspicker__card-open');
|
|
464
|
+
const cardOpenIcon = glyph('');
|
|
465
|
+
const cardOpenLabel = part('span', '');
|
|
466
|
+
cardOpen.append(cardOpenIcon, cardOpenLabel);
|
|
467
|
+
cardHead.append(cardIcon, cardText, cardOpen);
|
|
468
|
+
const tiles = part('div', 'twm-wspicker__tiles');
|
|
469
|
+
cardEl.append(cardHeading, cardHead, tiles);
|
|
470
|
+
|
|
471
|
+
const foot = part('div', 'twm-wspicker__foot');
|
|
472
|
+
const footStatus = part('span', 'twm-wspicker__status');
|
|
473
|
+
const footStart = part('div', 'twm-wspicker__foot-group');
|
|
474
|
+
const footEnd = part('div', 'twm-wspicker__foot-group');
|
|
475
|
+
const footKeys = part('span', 'twm-wspicker__keys');
|
|
476
|
+
foot.append(footStart, footStatus, footEnd, footKeys);
|
|
477
|
+
|
|
478
|
+
panel.append(searchRow, head, scroller, cardEl, foot);
|
|
479
|
+
|
|
480
|
+
// ── rows, the items' and the action rows' ───────────────────────────
|
|
481
|
+
function buildRow() {
|
|
482
|
+
const node = part('div', 'twm-wspicker__row');
|
|
483
|
+
node.id = `${uid}-row-${++nextEntryId}`;
|
|
484
|
+
node.setAttribute('role', 'option');
|
|
485
|
+
const icon = glyph('', 'twm-wspicker__row-icon');
|
|
486
|
+
const text = part('span', 'twm-wspicker__row-text');
|
|
487
|
+
const name = part('span', 'twm-wspicker__row-name');
|
|
488
|
+
const detail = part('span', 'twm-wspicker__row-detail');
|
|
489
|
+
text.append(name, detail);
|
|
490
|
+
const badge = part('span', 'twm-wspicker__row-badge');
|
|
491
|
+
const check = glyph('check', 'twm-wspicker__row-check');
|
|
492
|
+
const bin = button('twm-wspicker__row-remove');
|
|
493
|
+
bin.tabIndex = -1;
|
|
494
|
+
bin.append(glyph('delete'));
|
|
495
|
+
node.append(icon, text, badge, check, bin);
|
|
496
|
+
const entry = { node, icon, name, detail, badge, check, bin, run: null, remove: null };
|
|
497
|
+
node.addEventListener('click', () => entry.run?.());
|
|
498
|
+
bin.addEventListener('click', (event) => {
|
|
499
|
+
event.stopPropagation();
|
|
500
|
+
entry.remove?.();
|
|
501
|
+
});
|
|
502
|
+
return entry;
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
function paintRow(entry, { name, detail, icon, badge, title, disabled = false, isCurrentRow = false }) {
|
|
506
|
+
setText(entry.icon, icon);
|
|
507
|
+
setText(entry.name, name);
|
|
508
|
+
setText(entry.detail, detail);
|
|
509
|
+
show(entry.detail, !!detail);
|
|
510
|
+
setText(entry.badge, badge);
|
|
511
|
+
show(entry.badge, !!badge);
|
|
512
|
+
show(entry.check, isCurrentRow);
|
|
513
|
+
entry.node.classList.toggle('twm-wspicker__row--current', isCurrentRow);
|
|
514
|
+
entry.node.classList.toggle('twm-wspicker__row--disabled', disabled);
|
|
515
|
+
entry.node.setAttribute('aria-selected', String(isCurrentRow));
|
|
516
|
+
if (disabled) entry.node.setAttribute('aria-disabled', 'true');
|
|
517
|
+
else entry.node.removeAttribute('aria-disabled');
|
|
518
|
+
entry.node.title = title ?? (detail ? `${name} — ${detail}` : name);
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
const syncRows = keyedChildren(list, buildRow, (entry, item) => {
|
|
522
|
+
const model = modelOf(item);
|
|
523
|
+
const disabled = !!model.disabled;
|
|
524
|
+
paintRow(entry, { ...model, disabled, isCurrentRow: isCurrent(item) });
|
|
525
|
+
entry.run = disabled ? null : () => pick(item);
|
|
526
|
+
const removable = !!onRemove && model.removable !== false;
|
|
527
|
+
entry.remove = removable ? () => removeItem(item) : null;
|
|
528
|
+
show(entry.bin, removable);
|
|
529
|
+
if (removable) {
|
|
530
|
+
const words = labels.remove(model.name);
|
|
531
|
+
entry.bin.setAttribute('aria-label', words);
|
|
532
|
+
entry.bin.title = words;
|
|
533
|
+
}
|
|
534
|
+
});
|
|
535
|
+
|
|
536
|
+
const syncActions = keyedChildren(actionList, buildRow, (entry, action) => {
|
|
537
|
+
paintRow(entry, { name: action.label, detail: action.detail, icon: action.icon, title: action.title });
|
|
538
|
+
entry.run = () => runVerb(action.run);
|
|
539
|
+
show(entry.bin, false);
|
|
540
|
+
});
|
|
541
|
+
|
|
542
|
+
/** What typing leaves of the answer: names ahead of other matches. */
|
|
543
|
+
function shownItems() {
|
|
544
|
+
const query = typed().toLowerCase();
|
|
545
|
+
if (remote || !query) return items;
|
|
546
|
+
const names = [];
|
|
547
|
+
const others = [];
|
|
548
|
+
for (const item of items) {
|
|
549
|
+
const { name, detail, keywords } = modelOf(item);
|
|
550
|
+
if (String(name).toLowerCase().includes(query)) names.push(item);
|
|
551
|
+
else if ([detail, keywords].flat().some((text) => String(text ?? '').toLowerCase().includes(query))) others.push(item);
|
|
552
|
+
}
|
|
553
|
+
return [...names, ...others];
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
function shownActions() {
|
|
557
|
+
if (!actions) return [];
|
|
558
|
+
const query = typed();
|
|
559
|
+
const lower = query.toLowerCase();
|
|
560
|
+
return actions({ query }).filter((action) => action.always || !lower
|
|
561
|
+
|| String(action.label).toLowerCase().includes(lower));
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
// ── the card and the footer ─────────────────────────────────────────
|
|
565
|
+
function buildVerb(hook) {
|
|
566
|
+
return () => {
|
|
567
|
+
const node = button(`twm-btn ${hook}`);
|
|
568
|
+
const icon = glyph('');
|
|
569
|
+
const text = part('span', '');
|
|
570
|
+
const trailing = glyph('');
|
|
571
|
+
node.append(icon, text, trailing);
|
|
572
|
+
const entry = { node, icon, text, trailing, run: null };
|
|
573
|
+
node.addEventListener('click', () => entry.run?.());
|
|
574
|
+
return entry;
|
|
575
|
+
};
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
function paintVerb(entry, verb) {
|
|
579
|
+
setText(entry.icon, verb.icon);
|
|
580
|
+
setText(entry.text, verb.label);
|
|
581
|
+
setText(entry.trailing, verb.trailing);
|
|
582
|
+
entry.node.classList.toggle('twm-btn--primary', !!verb.primary);
|
|
583
|
+
entry.node.title = verb.label;
|
|
584
|
+
entry.run = () => runVerb(verb.run);
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
const syncTiles = keyedChildren(tiles, buildVerb('twm-wspicker__tile'), paintVerb);
|
|
588
|
+
const syncFootStart = keyedChildren(footStart, buildVerb('twm-wspicker__foot-btn'), paintVerb);
|
|
589
|
+
const syncFootEnd = keyedChildren(footEnd, buildVerb('twm-wspicker__foot-btn'), paintVerb);
|
|
590
|
+
let drawnTiles = new Map();
|
|
591
|
+
let drawnFoot = new Map();
|
|
592
|
+
let cardOpenRun = null;
|
|
593
|
+
|
|
594
|
+
cardOpen.addEventListener('click', () => cardOpenRun?.());
|
|
595
|
+
|
|
596
|
+
function renderCard() {
|
|
597
|
+
const row = current == null ? null : items.find((item) => isCurrent(item)) ?? null;
|
|
598
|
+
const model = card ? card({ current, row }) : null;
|
|
599
|
+
show(cardEl, !!model);
|
|
600
|
+
drawnTiles = syncTiles(model?.tiles || [], (tile) => tile.id);
|
|
601
|
+
cardOpenRun = model?.open ? () => runVerb(model.open.run) : null;
|
|
602
|
+
show(cardOpen, !!model?.open);
|
|
603
|
+
if (!model) return;
|
|
604
|
+
setText(cardHeading, model.heading);
|
|
605
|
+
show(cardHeading, !!model.heading);
|
|
606
|
+
setText(cardIcon, model.icon);
|
|
607
|
+
setText(cardName, model.name);
|
|
608
|
+
setText(cardDetail, model.detail);
|
|
609
|
+
cardName.title = model.name;
|
|
610
|
+
setText(cardOpenIcon, model.open?.icon);
|
|
611
|
+
setText(cardOpenLabel, model.open?.label);
|
|
612
|
+
tiles.style.setProperty('--twm-wspicker-columns', String(model.columns || 2));
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
function renderFooter() {
|
|
616
|
+
const model = footer ? footer() : null;
|
|
617
|
+
const start = syncFootStart(model?.start || [], (verb) => verb.id);
|
|
618
|
+
const end = syncFootEnd(model?.end || [], (verb) => verb.id);
|
|
619
|
+
drawnFoot = new Map([...[...start].map(([key, entry]) => [`start:${key}`, entry]),
|
|
620
|
+
...[...end].map(([key, entry]) => [`end:${key}`, entry])]);
|
|
621
|
+
footKeys.replaceChildren(...(model?.keys || []).map(({ keys, label: words }) => {
|
|
622
|
+
const hint = part('span', 'twm-wspicker__key-hint');
|
|
623
|
+
hint.append(...keys.map((key) => part('kbd', '', key)), ` ${words}`);
|
|
624
|
+
return hint;
|
|
625
|
+
}));
|
|
626
|
+
const { text = '', error = false } = typeof status === 'string' ? { text: status } : status ?? {};
|
|
627
|
+
setText(footStatus, text);
|
|
628
|
+
show(footStatus, !!text);
|
|
629
|
+
footStatus.classList.toggle('twm-wspicker__status--error', !!error);
|
|
630
|
+
const keysShown = footKeys.childElementCount > 0 && panel.classList.contains('twm-wspicker--centered');
|
|
631
|
+
show(foot, !!text || keysShown || drawnFoot.size > 0);
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
// ── the highlight ───────────────────────────────────────────────────
|
|
635
|
+
function collectStops(rowEntries, actionEntries, shown) {
|
|
636
|
+
const next = [];
|
|
637
|
+
for (const item of shown) {
|
|
638
|
+
const entry = rowEntries.get(idOf(item));
|
|
639
|
+
if (entry?.run) next.push({ key: `row:${idOf(item)}`, node: entry.node, run: entry.run, remove: entry.remove, primary: true });
|
|
640
|
+
}
|
|
641
|
+
if (!more.hidden) next.push({ key: 'more', node: more, run: () => loadMore({ byKey: true }) });
|
|
642
|
+
for (const [key, entry] of actionEntries) next.push({ key: `action:${key}`, node: entry.node, run: entry.run, primary: true });
|
|
643
|
+
if (!cardEl.hidden) {
|
|
644
|
+
if (!cardOpen.hidden) next.push({ key: 'card-open', node: cardOpen, run: () => cardOpenRun?.() });
|
|
645
|
+
for (const [key, entry] of drawnTiles) next.push({ key: `tile:${key}`, node: entry.node, run: entry.run });
|
|
646
|
+
}
|
|
647
|
+
for (const [key, entry] of drawnFoot) next.push({ key: `foot:${key}`, node: entry.node, run: entry.run });
|
|
648
|
+
stops = next;
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
function paintActive() {
|
|
652
|
+
const stop = stops.find((s) => s.key === activeKey) ?? null;
|
|
653
|
+
if (activeNode && activeNode !== stop?.node) activeNode.classList.remove('twm-wspicker__stop--active');
|
|
654
|
+
activeNode = stop?.node ?? null;
|
|
655
|
+
activeNode?.classList.add('twm-wspicker__stop--active');
|
|
656
|
+
if (activeNode?.id) input.setAttribute('aria-activedescendant', activeNode.id);
|
|
657
|
+
else input.removeAttribute('aria-activedescendant');
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
function revealActive() {
|
|
661
|
+
activeNode?.scrollIntoView?.({ block: 'nearest' });
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
function settleActive() {
|
|
665
|
+
let reveal = false;
|
|
666
|
+
if (selectId != null && stops.some((s) => s.key === `row:${selectId}`)) {
|
|
667
|
+
activeKey = `row:${selectId}`;
|
|
668
|
+
moved = true;
|
|
669
|
+
selectId = null;
|
|
670
|
+
reveal = true;
|
|
671
|
+
}
|
|
672
|
+
if (!moved || !stops.some((s) => s.key === activeKey)) {
|
|
673
|
+
reveal = moved;
|
|
674
|
+
moved = false;
|
|
675
|
+
activeKey = stops.find((s) => s.primary)?.key ?? null;
|
|
676
|
+
}
|
|
677
|
+
paintActive();
|
|
678
|
+
if (reveal) revealActive();
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
function move(step) {
|
|
682
|
+
if (!stops.length) return;
|
|
683
|
+
const at = stops.findIndex((s) => s.key === activeKey);
|
|
684
|
+
const next = at === -1 ? (step > 0 ? 0 : stops.length - 1) : (at + step + stops.length) % stops.length;
|
|
685
|
+
activeKey = stops[next].key;
|
|
686
|
+
moved = true;
|
|
687
|
+
paintActive();
|
|
688
|
+
revealActive();
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
// ── drawing ─────────────────────────────────────────────────────────
|
|
692
|
+
function render() {
|
|
693
|
+
const shown = shownItems();
|
|
694
|
+
const query = remote ? answered : typed();
|
|
695
|
+
const rowEntries = syncRows(shown, idOf);
|
|
696
|
+
const actionEntries = syncActions(shownActions(), (action) => action.id);
|
|
697
|
+
|
|
698
|
+
const words = heading ? heading({ query, shown: shown.length, total }) : null;
|
|
699
|
+
show(head, !!(words?.title || words?.count));
|
|
700
|
+
setText(headTitle, words?.title);
|
|
701
|
+
setText(headCount, words?.count);
|
|
702
|
+
|
|
703
|
+
let said = '';
|
|
704
|
+
if (failure) said = failure;
|
|
705
|
+
else if (!shown.length && (loading || total == null)) said = labels.loading;
|
|
706
|
+
else if (!shown.length) said = query ? labels.noMatch(query) : labels.empty();
|
|
707
|
+
setText(note, said);
|
|
708
|
+
show(note, !!said);
|
|
709
|
+
note.classList.toggle('twm-wspicker__note--error', !!failure);
|
|
710
|
+
|
|
711
|
+
const remaining = remote && total != null ? Math.max(0, total - items.length) : 0;
|
|
712
|
+
show(more, remaining > 0 && !failure);
|
|
713
|
+
if (remaining > 0) {
|
|
714
|
+
setText(moreLabel, labels.loadMore({ next: Math.min(pageSize, remaining), shown: items.length, total }));
|
|
715
|
+
}
|
|
716
|
+
more.disabled = loading || loadingMore;
|
|
717
|
+
|
|
718
|
+
collectStops(rowEntries, actionEntries, shown);
|
|
719
|
+
settleActive();
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
function renderEverything() {
|
|
723
|
+
renderCard();
|
|
724
|
+
renderFooter();
|
|
725
|
+
render();
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
// ── asking ──────────────────────────────────────────────────────────
|
|
729
|
+
function normalize(answer) {
|
|
730
|
+
if (Array.isArray(answer)) return { items: answer, total: answer.length, status: null };
|
|
731
|
+
const list_ = answer?.items || [];
|
|
732
|
+
return {
|
|
733
|
+
items: list_,
|
|
734
|
+
total: Number.isFinite(answer?.total) ? answer.total : list_.length,
|
|
735
|
+
status: answer?.status ?? null,
|
|
736
|
+
};
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
function unique(all) {
|
|
740
|
+
const seen = new Set();
|
|
741
|
+
return all.filter((item) => {
|
|
742
|
+
const id = idOf(item);
|
|
743
|
+
if (seen.has(id)) return false;
|
|
744
|
+
seen.add(id);
|
|
745
|
+
return true;
|
|
746
|
+
});
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
async function ask(query) {
|
|
750
|
+
const mine = ++seq;
|
|
751
|
+
asked = query;
|
|
752
|
+
abort?.abort();
|
|
753
|
+
abort = new AbortController();
|
|
754
|
+
const { signal } = abort;
|
|
755
|
+
loading = true;
|
|
756
|
+
failure = null;
|
|
757
|
+
loadingMore = false;
|
|
758
|
+
render();
|
|
759
|
+
try {
|
|
760
|
+
const answer = normalize(await load({ query, offset: 0, limit: pageSize, signal }));
|
|
761
|
+
if (destroyed || mine !== seq) return;
|
|
762
|
+
items = unique(answer.items);
|
|
763
|
+
total = answer.total;
|
|
764
|
+
status = answer.status;
|
|
765
|
+
if (query !== answered) scroller.scrollTop = 0;
|
|
766
|
+
answered = query;
|
|
767
|
+
loading = false;
|
|
768
|
+
if (!query) kept.set(cacheKey(), { items: [...items], total, status });
|
|
769
|
+
renderEverything();
|
|
770
|
+
if (placementNow() === 'below') place();
|
|
771
|
+
if (pickFirst) {
|
|
772
|
+
pickFirst = false;
|
|
773
|
+
stops.find((s) => s.primary)?.run();
|
|
774
|
+
}
|
|
775
|
+
} catch (err) {
|
|
776
|
+
if (destroyed || mine !== seq || err?.name === 'AbortError') return;
|
|
777
|
+
loading = false;
|
|
778
|
+
pickFirst = false;
|
|
779
|
+
items = [];
|
|
780
|
+
total = null;
|
|
781
|
+
status = null;
|
|
782
|
+
answered = query;
|
|
783
|
+
failure = labels.failed(err);
|
|
784
|
+
renderEverything();
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
async function loadMore({ byKey = false } = {}) {
|
|
789
|
+
if (loading || loadingMore || total == null || items.length >= total) return;
|
|
790
|
+
const mine = seq;
|
|
791
|
+
loadingMore = true;
|
|
792
|
+
render();
|
|
793
|
+
let added = [];
|
|
794
|
+
try {
|
|
795
|
+
const answer = normalize(await load({ query: answered, offset: items.length, limit: pageSize, signal: abort?.signal }));
|
|
796
|
+
if (destroyed || mine !== seq) return;
|
|
797
|
+
const seen = new Set(items.map(idOf));
|
|
798
|
+
added = unique(answer.items).filter((item) => !seen.has(idOf(item)));
|
|
799
|
+
items = items.concat(added);
|
|
800
|
+
// A server that ignores the paging hands back what is drawn: there
|
|
801
|
+
// is no more, whatever it said before.
|
|
802
|
+
total = added.length && Number.isFinite(answer.total) ? answer.total : items.length;
|
|
803
|
+
if (answer.status != null) status = answer.status;
|
|
804
|
+
} catch (err) {
|
|
805
|
+
if (destroyed || mine !== seq || err?.name === 'AbortError') return;
|
|
806
|
+
failure = labels.failedMore(err);
|
|
807
|
+
}
|
|
808
|
+
loadingMore = false;
|
|
809
|
+
// The highlight stays on Load more while there is more, else goes to
|
|
810
|
+
// the first row it brought that can be chosen.
|
|
811
|
+
const stayed = activeKey === 'more';
|
|
812
|
+
if (stayed && items.length >= total) {
|
|
813
|
+
const first = added.find((item) => !modelOf(item).disabled);
|
|
814
|
+
if (first) {
|
|
815
|
+
activeKey = `row:${idOf(first)}`;
|
|
816
|
+
moved = true;
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
renderEverything();
|
|
820
|
+
if (byKey && stayed) revealActive();
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
more.addEventListener('click', () => loadMore());
|
|
824
|
+
|
|
825
|
+
function setBusy(waiting) {
|
|
826
|
+
busy = waiting;
|
|
827
|
+
panel.classList.toggle('twm-wspicker--waiting', waiting);
|
|
828
|
+
backdrop.classList.toggle('twm-wspicker-backdrop--waiting', waiting);
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
async function removeItem(item) {
|
|
832
|
+
if (busy) return;
|
|
833
|
+
const removedKey = `row:${idOf(item)}`;
|
|
834
|
+
const at = stops.findIndex((s) => s.key === removedKey);
|
|
835
|
+
const following = stops.slice(at + 1).find((s) => s.key.startsWith('row:'))
|
|
836
|
+
?? stops.slice(0, Math.max(at, 0)).reverse().find((s) => s.key.startsWith('row:'));
|
|
837
|
+
// Before the call: a dialog it opens takes focus before it returns.
|
|
838
|
+
setBusy(true);
|
|
839
|
+
let outcome;
|
|
840
|
+
try {
|
|
841
|
+
outcome = await onRemove(item);
|
|
842
|
+
} catch (err) {
|
|
843
|
+
setBusy(false);
|
|
844
|
+
if (isOpen) {
|
|
845
|
+
failure = labels.failedRemove(err);
|
|
846
|
+
render();
|
|
847
|
+
input.focus();
|
|
848
|
+
}
|
|
849
|
+
return;
|
|
850
|
+
}
|
|
851
|
+
setBusy(false);
|
|
852
|
+
if (!isOpen) return;
|
|
853
|
+
if (outcome !== false) {
|
|
854
|
+
items = items.filter((other) => idOf(other) !== idOf(item));
|
|
855
|
+
if (total != null) total = Math.max(0, total - 1);
|
|
856
|
+
if (activeKey === removedKey && following) {
|
|
857
|
+
activeKey = following.key;
|
|
858
|
+
moved = true;
|
|
859
|
+
}
|
|
860
|
+
ask(remote ? answered : '');
|
|
861
|
+
}
|
|
862
|
+
input.focus();
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
// ── choosing ────────────────────────────────────────────────────────
|
|
866
|
+
function pick(item) {
|
|
867
|
+
const same = isCurrent(item);
|
|
868
|
+
close({ restoreFocus: true });
|
|
869
|
+
if (!same) onPick?.(item);
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
function runVerb(run) {
|
|
873
|
+
const query = typed();
|
|
874
|
+
close({ restoreFocus: true });
|
|
875
|
+
run?.({ query });
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
function enter() {
|
|
879
|
+
const query = typed();
|
|
880
|
+
if (remote && query !== asked) {
|
|
881
|
+
clearTimeout(timer);
|
|
882
|
+
timer = null;
|
|
883
|
+
pickFirst = true;
|
|
884
|
+
ask(query);
|
|
885
|
+
return;
|
|
886
|
+
}
|
|
887
|
+
if (loading && !moved) {
|
|
888
|
+
pickFirst = true;
|
|
889
|
+
return;
|
|
890
|
+
}
|
|
891
|
+
stops.find((s) => s.key === activeKey)?.run();
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
// ── placing ─────────────────────────────────────────────────────────
|
|
895
|
+
function placementNow() {
|
|
896
|
+
if (!anchor?.isConnected) return 'centered';
|
|
897
|
+
return anchor.getBoundingClientRect().width > 0 ? placement : 'centered';
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
function place() {
|
|
901
|
+
const mode = placementNow();
|
|
902
|
+
for (const name of ['over', 'below', 'centered']) {
|
|
903
|
+
panel.classList.toggle(`twm-wspicker--${name}`, mode === name);
|
|
904
|
+
}
|
|
905
|
+
show(backdrop, mode === 'centered');
|
|
906
|
+
for (const side of ['top', 'bottom', 'left', 'right', 'width', 'maxHeight', 'overflowY', 'position']) panel.style[side] = '';
|
|
907
|
+
if (mode === 'centered') return;
|
|
908
|
+
const r = anchor.getBoundingClientRect();
|
|
909
|
+
const width = Math.min(Math.max(r.width, minWidth), window.innerWidth - 2 * PANEL_EDGE);
|
|
910
|
+
panel.style.width = `${width}px`;
|
|
911
|
+
if (mode === 'below') {
|
|
912
|
+
ActionDropdown.position(anchor, panel);
|
|
913
|
+
return;
|
|
914
|
+
}
|
|
915
|
+
const left = r.left + r.width / 2 - width / 2;
|
|
916
|
+
panel.style.left = `${Math.max(PANEL_EDGE, Math.min(left, window.innerWidth - width - PANEL_EDGE))}px`;
|
|
917
|
+
// A field in the lower half (a bottom bar) grows upward, its bottom edge
|
|
918
|
+
// where the field's is, because a list hanging below it has no room.
|
|
919
|
+
if (r.top + r.height / 2 > window.innerHeight / 2) {
|
|
920
|
+
panel.style.bottom = `${Math.max(2, window.innerHeight - r.bottom - OVER_LIFT)}px`;
|
|
921
|
+
} else {
|
|
922
|
+
panel.style.top = `${Math.max(2, r.top - OVER_LIFT)}px`;
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
// ── opening and closing ─────────────────────────────────────────────
|
|
927
|
+
function openPanel({ select = null } = {}) {
|
|
928
|
+
if (isOpen || destroyed) return;
|
|
929
|
+
isOpen = true;
|
|
930
|
+
opener = document.activeElement;
|
|
931
|
+
selectId = select == null ? null : String(select);
|
|
932
|
+
input.value = '';
|
|
933
|
+
clearTimeout(timer);
|
|
934
|
+
timer = null;
|
|
935
|
+
pickFirst = false;
|
|
936
|
+
moved = false;
|
|
937
|
+
activeKey = null;
|
|
938
|
+
const last = kept.get(cacheKey());
|
|
939
|
+
items = last ? [...last.items] : [];
|
|
940
|
+
total = last ? last.total : null;
|
|
941
|
+
status = last ? last.status : null;
|
|
942
|
+
answered = '';
|
|
943
|
+
asked = '';
|
|
944
|
+
failure = null;
|
|
945
|
+
document.body.append(backdrop, panel);
|
|
946
|
+
place();
|
|
947
|
+
renderEverything();
|
|
948
|
+
scroller.scrollTop = 0;
|
|
949
|
+
document.addEventListener('mousedown', onDocumentDown, true);
|
|
950
|
+
document.addEventListener('keydown', onDocumentKey);
|
|
951
|
+
window.addEventListener('resize', place);
|
|
952
|
+
input.focus();
|
|
953
|
+
onOpenChange?.(true);
|
|
954
|
+
ask('');
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
function close({ restoreFocus = false } = {}) {
|
|
958
|
+
if (!isOpen) return;
|
|
959
|
+
isOpen = false;
|
|
960
|
+
clearTimeout(timer);
|
|
961
|
+
timer = null;
|
|
962
|
+
pickFirst = false;
|
|
963
|
+
selectId = null;
|
|
964
|
+
// An answer that lands after the panel closed paints nothing.
|
|
965
|
+
seq += 1;
|
|
966
|
+
abort?.abort();
|
|
967
|
+
loading = false;
|
|
968
|
+
loadingMore = false;
|
|
969
|
+
setBusy(false);
|
|
970
|
+
panel.remove();
|
|
971
|
+
backdrop.remove();
|
|
972
|
+
document.removeEventListener('mousedown', onDocumentDown, true);
|
|
973
|
+
document.removeEventListener('keydown', onDocumentKey);
|
|
974
|
+
window.removeEventListener('resize', place);
|
|
975
|
+
onOpenChange?.(false);
|
|
976
|
+
if (restoreFocus && !destroyed) {
|
|
977
|
+
const back = opener?.isConnected && opener !== document.body ? opener : anchor;
|
|
978
|
+
back?.focus?.();
|
|
979
|
+
}
|
|
980
|
+
opener = null;
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
/** In the capture phase, so a surface that stops the event still closes it. */
|
|
984
|
+
function onDocumentDown(event) {
|
|
985
|
+
if (busy || panel.contains(event.target) || anchor?.contains?.(event.target)) return;
|
|
986
|
+
close();
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
/** Esc with the focus outside the panel: on the anchor, say. */
|
|
990
|
+
function onDocumentKey(event) {
|
|
991
|
+
if (busy || event.key !== 'Escape' || panel.contains(event.target)) return;
|
|
992
|
+
event.preventDefault();
|
|
993
|
+
event.stopPropagation();
|
|
994
|
+
close({ restoreFocus: true });
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
// The caret stays in the input whatever is pressed in the panel.
|
|
998
|
+
panel.addEventListener('mousedown', (event) => {
|
|
999
|
+
if (event.target !== input) event.preventDefault();
|
|
1000
|
+
});
|
|
1001
|
+
|
|
1002
|
+
input.addEventListener('input', () => {
|
|
1003
|
+
moved = false;
|
|
1004
|
+
scroller.scrollTop = 0;
|
|
1005
|
+
if (remote) {
|
|
1006
|
+
clearTimeout(timer);
|
|
1007
|
+
const query = typed();
|
|
1008
|
+
timer = setTimeout(() => {
|
|
1009
|
+
timer = null;
|
|
1010
|
+
if (query !== asked) ask(query);
|
|
1011
|
+
}, debounceMs);
|
|
1012
|
+
}
|
|
1013
|
+
render();
|
|
1014
|
+
});
|
|
1015
|
+
|
|
1016
|
+
panel.addEventListener('keydown', (event) => {
|
|
1017
|
+
if (event.isComposing || busy) return;
|
|
1018
|
+
const { key, shiftKey, target } = event;
|
|
1019
|
+
const handled = () => {
|
|
1020
|
+
event.preventDefault();
|
|
1021
|
+
event.stopPropagation();
|
|
1022
|
+
};
|
|
1023
|
+
if (key === 'Escape') {
|
|
1024
|
+
handled();
|
|
1025
|
+
close({ restoreFocus: true });
|
|
1026
|
+
} else if (key === 'ArrowDown' || key === 'ArrowUp') {
|
|
1027
|
+
handled();
|
|
1028
|
+
if (document.activeElement !== input) input.focus();
|
|
1029
|
+
move(key === 'ArrowDown' ? 1 : -1);
|
|
1030
|
+
} else if (key === 'Enter' && target === input) {
|
|
1031
|
+
handled();
|
|
1032
|
+
enter();
|
|
1033
|
+
} else if (key === 'Delete' && shiftKey && target === input) {
|
|
1034
|
+
handled();
|
|
1035
|
+
stops.find((s) => s.key === activeKey)?.remove?.();
|
|
1036
|
+
} else if (key === 'Backspace' && target !== input) {
|
|
1037
|
+
// A window manager's keymap reads Backspace on anything that is not
|
|
1038
|
+
// a field as "up one level"; a button of this panel is not a field.
|
|
1039
|
+
handled();
|
|
1040
|
+
}
|
|
1041
|
+
});
|
|
1042
|
+
|
|
1043
|
+
panel.addEventListener('focusout', (event) => {
|
|
1044
|
+
const to = event.relatedTarget;
|
|
1045
|
+
if (!busy && to && !panel.contains(to) && !anchor?.contains?.(to)) close();
|
|
1046
|
+
});
|
|
1047
|
+
|
|
1048
|
+
return {
|
|
1049
|
+
panel,
|
|
1050
|
+
input,
|
|
1051
|
+
get isOpen() { return isOpen; },
|
|
1052
|
+
get current() { return current; },
|
|
1053
|
+
open: openPanel,
|
|
1054
|
+
close,
|
|
1055
|
+
toggle() {
|
|
1056
|
+
if (isOpen) close({ restoreFocus: true });
|
|
1057
|
+
else openPanel();
|
|
1058
|
+
},
|
|
1059
|
+
/** Which one the window is in, whether or not an answer holds it. */
|
|
1060
|
+
setCurrent(item) {
|
|
1061
|
+
current = item ?? null;
|
|
1062
|
+
if (isOpen) renderEverything();
|
|
1063
|
+
},
|
|
1064
|
+
/** Ask `actions`, `card` and `footer` again: who may do what changed. */
|
|
1065
|
+
refresh() {
|
|
1066
|
+
if (isOpen) renderEverything();
|
|
1067
|
+
},
|
|
1068
|
+
/** Ask `load` again: the list changed behind the switcher's back. */
|
|
1069
|
+
reload() {
|
|
1070
|
+
if (isOpen) ask(remote ? typed() : '');
|
|
1071
|
+
},
|
|
1072
|
+
destroy() {
|
|
1073
|
+
close();
|
|
1074
|
+
destroyed = true;
|
|
1075
|
+
},
|
|
1076
|
+
};
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
/**
|
|
1080
|
+
* The field and the switcher together: a click on the field, or the chord,
|
|
1081
|
+
* opens the switcher in the field's place; `setCurrent` names the current item
|
|
1082
|
+
* on the field and marks it in the list.
|
|
1083
|
+
*
|
|
1084
|
+
* `chord` is none by default. Given (`'Ctrl+P'`; Ctrl also answers to Cmd), it
|
|
1085
|
+
* is taken on `window` in the capture phase, ahead of an embedded editor and of
|
|
1086
|
+
* the browser's own use of the key, and it is the key hint unless
|
|
1087
|
+
* `field.keyHint` says otherwise. It still works while the field is hidden for
|
|
1088
|
+
* want of room.
|
|
1089
|
+
*
|
|
1090
|
+
* @param {HTMLElement} hostEl - Where the field mounts
|
|
1091
|
+
* @param {object} options - `createPickerSwitcher`'s, and:
|
|
1092
|
+
* @param {object} [options.field] - `mountPickerField`'s. `field.placeholder` is
|
|
1093
|
+
* its name while nothing is current; `field.icon`, when given, is its icon
|
|
1094
|
+
* whatever is current, and otherwise the current item's `icon` is.
|
|
1095
|
+
* @param {string} [options.chord]
|
|
1096
|
+
*/
|
|
1097
|
+
export function createWorkspacePicker(hostEl, { field: fieldOptions = {}, chord = null, ...switcherOptions }) {
|
|
1098
|
+
const { placeholder = '', icon: fixedIcon, ...mountOptions } = fieldOptions;
|
|
1099
|
+
const parsed = chord ? parseChord(chord) : null;
|
|
1100
|
+
const field = mountPickerField(hostEl, {
|
|
1101
|
+
ariaLabel: switcherOptions.label,
|
|
1102
|
+
keyHint: chord ?? '',
|
|
1103
|
+
...mountOptions,
|
|
1104
|
+
onActivate: () => switcher.toggle(),
|
|
1105
|
+
});
|
|
1106
|
+
const switcher = createPickerSwitcher({
|
|
1107
|
+
...switcherOptions,
|
|
1108
|
+
anchor: field.el,
|
|
1109
|
+
onOpenChange: (open) => {
|
|
1110
|
+
field.setOpen(open);
|
|
1111
|
+
switcherOptions.onOpenChange?.(open);
|
|
1112
|
+
},
|
|
1113
|
+
});
|
|
1114
|
+
|
|
1115
|
+
const paint = () => {
|
|
1116
|
+
const item = switcher.current;
|
|
1117
|
+
const model = item == null ? null : switcherOptions.describe(item, { current: true });
|
|
1118
|
+
field.update({
|
|
1119
|
+
name: model ? model.name : placeholder,
|
|
1120
|
+
icon: fixedIcon ?? model?.icon ?? '',
|
|
1121
|
+
muted: !model,
|
|
1122
|
+
});
|
|
1123
|
+
};
|
|
1124
|
+
paint();
|
|
1125
|
+
|
|
1126
|
+
const onKeydown = (event) => {
|
|
1127
|
+
if (!chordMatches(parsed, event)) return;
|
|
1128
|
+
event.preventDefault();
|
|
1129
|
+
event.stopPropagation();
|
|
1130
|
+
if (!event.repeat) switcher.toggle();
|
|
1131
|
+
};
|
|
1132
|
+
if (parsed) window.addEventListener('keydown', onKeydown, true);
|
|
1133
|
+
|
|
1134
|
+
return {
|
|
1135
|
+
field,
|
|
1136
|
+
switcher,
|
|
1137
|
+
get el() { return field.el; },
|
|
1138
|
+
get isOpen() { return switcher.isOpen; },
|
|
1139
|
+
get current() { return switcher.current; },
|
|
1140
|
+
open: switcher.open,
|
|
1141
|
+
close: switcher.close,
|
|
1142
|
+
toggle: switcher.toggle,
|
|
1143
|
+
refresh: switcher.refresh,
|
|
1144
|
+
reload: switcher.reload,
|
|
1145
|
+
setCurrent(item) {
|
|
1146
|
+
switcher.setCurrent(item);
|
|
1147
|
+
paint();
|
|
1148
|
+
},
|
|
1149
|
+
destroy() {
|
|
1150
|
+
if (parsed) window.removeEventListener('keydown', onKeydown, true);
|
|
1151
|
+
switcher.destroy();
|
|
1152
|
+
field.destroy();
|
|
1153
|
+
},
|
|
1154
|
+
};
|
|
1155
|
+
}
|