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,169 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* recent_items.js — a most-recent-first list kept through the Host's `state`
|
|
3
|
+
* capability, so it survives a restart.
|
|
4
|
+
*
|
|
5
|
+
* const recent = createRecentItems({ host, key: 'recent-workspaces' });
|
|
6
|
+
* await recent.load();
|
|
7
|
+
* recent.add({ id: 'w1', name: 'North' });
|
|
8
|
+
* recent.list(); // [{ id: 'w1', name: 'North', openedAt: 1730... }]
|
|
9
|
+
*
|
|
10
|
+
* An entry is any plain object; `identity` says which two are the same one, and
|
|
11
|
+
* `add` stamps `openedAt`. `capacity` is per `groupOf(entry)` when that is
|
|
12
|
+
* given, so one busy kind of entry never pushes another kind out.
|
|
13
|
+
*
|
|
14
|
+
* ══ A READ THAT FAILS IS NOT AN EMPTY LIST ═════════════════════════════════
|
|
15
|
+
*
|
|
16
|
+
* A `host.state.read` that rejects says nothing about what is stored, so until
|
|
17
|
+
* a read succeeds the stored list is never written: writing would replace
|
|
18
|
+
* entries nobody has seen. Changes made in the meantime show in `list()` at
|
|
19
|
+
* once, are kept, are replayed onto the stored list when it is read, and each
|
|
20
|
+
* of them asks for the read again. A read that answers something other than an
|
|
21
|
+
* array is a read: the store is empty.
|
|
22
|
+
*
|
|
23
|
+
* Writes run one at a time and the last one carries the latest list, because a
|
|
24
|
+
* host may answer them out of order and the older list must not win.
|
|
25
|
+
*
|
|
26
|
+
* A host with no `state` capability keeps the list in memory for this session.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* @param {object} options
|
|
31
|
+
* @param {object} options.host - The Host; its `state` keeps the list
|
|
32
|
+
* @param {string} options.key - The state key the list is stored under
|
|
33
|
+
* @param {(entry: object) => string} [options.identity] - Which entries are the same one
|
|
34
|
+
* @param {(entry: unknown) => boolean} [options.isValid] - Stored entries that fail this
|
|
35
|
+
* are dropped on read, as is one that makes it, `identity` or `groupOf` throw, so
|
|
36
|
+
* `identity` may assume an entry's shape.
|
|
37
|
+
* @param {number} [options.capacity=10] - The most entries kept, per group
|
|
38
|
+
* @param {(entry: object) => string} [options.groupOf] - Groups the capacity counts within
|
|
39
|
+
* @param {() => void} [options.onChange] - After every change, and after a read that
|
|
40
|
+
* replayed changes
|
|
41
|
+
* @param {object} [options.logger] - Its `warn` hears a failed read or write
|
|
42
|
+
*/
|
|
43
|
+
export function createRecentItems({
|
|
44
|
+
host,
|
|
45
|
+
key,
|
|
46
|
+
identity = (entry) => entry.id,
|
|
47
|
+
isValid = (entry) => !!entry && typeof entry === 'object' && !!identity(entry),
|
|
48
|
+
capacity = 10,
|
|
49
|
+
groupOf = () => '',
|
|
50
|
+
onChange = null,
|
|
51
|
+
logger = null,
|
|
52
|
+
}) {
|
|
53
|
+
const state = host?.state ?? null;
|
|
54
|
+
let entries = [];
|
|
55
|
+
let loaded = !state;
|
|
56
|
+
/** The changes made while the stored list is unread, oldest first: list => list. */
|
|
57
|
+
let pending = [];
|
|
58
|
+
let reading = null;
|
|
59
|
+
let writing = false;
|
|
60
|
+
let writeAgain = false;
|
|
61
|
+
|
|
62
|
+
const capped = (list) => {
|
|
63
|
+
const counts = new Map();
|
|
64
|
+
return list.filter((entry) => {
|
|
65
|
+
const group = groupOf(entry);
|
|
66
|
+
const count = (counts.get(group) ?? 0) + 1;
|
|
67
|
+
counts.set(group, count);
|
|
68
|
+
return count <= capacity;
|
|
69
|
+
});
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
const without = (list, entry) => {
|
|
73
|
+
const id = identity(entry);
|
|
74
|
+
return list.filter((other) => identity(other) !== id);
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
async function write() {
|
|
78
|
+
if (!state) return;
|
|
79
|
+
if (writing) {
|
|
80
|
+
writeAgain = true;
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
writing = true;
|
|
84
|
+
do {
|
|
85
|
+
writeAgain = false;
|
|
86
|
+
try {
|
|
87
|
+
await state.write(key, entries);
|
|
88
|
+
} catch (err) {
|
|
89
|
+
logger?.warn?.(`Could not save the recent list "${key}"`, err);
|
|
90
|
+
}
|
|
91
|
+
} while (writeAgain);
|
|
92
|
+
writing = false;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** A stored entry the list can hold: everything it goes through accepts it. */
|
|
96
|
+
const usable = (entry) => {
|
|
97
|
+
try {
|
|
98
|
+
if (!isValid(entry)) return false;
|
|
99
|
+
identity(entry);
|
|
100
|
+
groupOf(entry);
|
|
101
|
+
return true;
|
|
102
|
+
} catch (err) {
|
|
103
|
+
logger?.warn?.(`Dropped an unreadable entry of the recent list "${key}"`, err);
|
|
104
|
+
return false;
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
async function read() {
|
|
109
|
+
let stored;
|
|
110
|
+
try {
|
|
111
|
+
const saved = await state.read(key);
|
|
112
|
+
stored = Array.isArray(saved) ? capped(saved.filter(usable)) : [];
|
|
113
|
+
} catch (err) {
|
|
114
|
+
logger?.warn?.(`Could not read the recent list "${key}"; the stored list is left unchanged until it can be read`, err);
|
|
115
|
+
return false;
|
|
116
|
+
}
|
|
117
|
+
const replay = pending;
|
|
118
|
+
pending = [];
|
|
119
|
+
loaded = true;
|
|
120
|
+
entries = replay.reduce((list, apply) => apply(list), stored);
|
|
121
|
+
if (replay.length > 0) {
|
|
122
|
+
onChange?.();
|
|
123
|
+
void write();
|
|
124
|
+
}
|
|
125
|
+
return true;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Read the stored list. Call it once at startup, before anything lists; a
|
|
130
|
+
* change made while the list is unread calls it again. Once a read has
|
|
131
|
+
* succeeded it does not read again.
|
|
132
|
+
* @returns {Promise<boolean>} Whether the stored list has been read
|
|
133
|
+
*/
|
|
134
|
+
function load() {
|
|
135
|
+
if (!state) return Promise.resolve(false);
|
|
136
|
+
if (loaded) return Promise.resolve(true);
|
|
137
|
+
reading ??= read().finally(() => { reading = null; });
|
|
138
|
+
return reading;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function change(apply) {
|
|
142
|
+
entries = apply(entries);
|
|
143
|
+
onChange?.();
|
|
144
|
+
if (loaded) {
|
|
145
|
+
void write();
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
pending.push(apply);
|
|
149
|
+
void load();
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return Object.freeze({
|
|
153
|
+
load,
|
|
154
|
+
/** @returns {ReadonlyArray<object>} Most recent first */
|
|
155
|
+
list: () => Object.freeze(entries.map((entry) => Object.freeze({ ...entry }))),
|
|
156
|
+
/** Put an entry at the top, dropping the older one with its identity. */
|
|
157
|
+
add(entry) {
|
|
158
|
+
const stamped = { ...entry, openedAt: Date.now() };
|
|
159
|
+
change((list) => capped([stamped, ...without(list, stamped)]));
|
|
160
|
+
},
|
|
161
|
+
/** Drop the entry with this one's identity. */
|
|
162
|
+
remove(entry) {
|
|
163
|
+
change((list) => without(list, entry));
|
|
164
|
+
},
|
|
165
|
+
clear() {
|
|
166
|
+
change(() => []);
|
|
167
|
+
},
|
|
168
|
+
});
|
|
169
|
+
}
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* createSelect — A SELECT DRAWN IN THE DOM. (0.18.0)
|
|
3
|
+
*
|
|
4
|
+
* A native `<select>` styles down to its closed box: the list is the operating
|
|
5
|
+
* system's, so it cannot show a glyph or a second line, ignores the tokens and
|
|
6
|
+
* opens at the system's type size. `selectBox` (`choice_controls.js`) is the
|
|
7
|
+
* native one, for a list with nothing to say in a tight strip. This one is for
|
|
8
|
+
* the rest: a trigger showing the current value, over a list whose rows have a
|
|
9
|
+
* glyph, a second line and a hint, under group headings, with a search box and,
|
|
10
|
+
* with `allowNew`, a way to type a value nobody listed.
|
|
11
|
+
*
|
|
12
|
+
* THE VALUE IS NEVER SILENTLY CHANGED. A value in none of the options, or naming a
|
|
13
|
+
* disabled one, stays the value and is shown (a disabled option under its label,
|
|
14
|
+
* any other through `notFound`): a list that arrives late or has moved on must not
|
|
15
|
+
* turn it into something else on the next save. Only `null`, `undefined` and `''`
|
|
16
|
+
* with no option of their own read as "nothing chosen", showing `placeholder`.
|
|
17
|
+
*
|
|
18
|
+
* Values keep their type, as in `choice_controls.js`; `loose` lets a stored `'2'`
|
|
19
|
+
* name option `2`.
|
|
20
|
+
*
|
|
21
|
+
* Focus stays on the trigger unless there is a search box, which then holds it.
|
|
22
|
+
* Either way the arrow keys move the highlight, named by `aria-activedescendant`,
|
|
23
|
+
* and Tab closes the list and goes on from the trigger.
|
|
24
|
+
*/
|
|
25
|
+
import { openAnchoredLayer } from './anchored_layer.js';
|
|
26
|
+
import { fireEvent } from '../utils/fire_event.js';
|
|
27
|
+
import { choiceSections, filterSections, findChoice, flatChoices, shownValue } from './choice_controls.js';
|
|
28
|
+
import { createOptionList } from './option_list.js';
|
|
29
|
+
|
|
30
|
+
/** More options than this and a select draws a search box unless told otherwise. */
|
|
31
|
+
const SEARCH_ABOVE = 7;
|
|
32
|
+
/** Printable keys within this many ms are one word for the type-ahead. */
|
|
33
|
+
const TYPEAHEAD_MS = 500;
|
|
34
|
+
|
|
35
|
+
function node(tag, className, text) {
|
|
36
|
+
const n = document.createElement(tag);
|
|
37
|
+
if (className) n.className = className;
|
|
38
|
+
if (text !== undefined) n.textContent = String(text);
|
|
39
|
+
return n;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function glyphNode(className, text = '') {
|
|
43
|
+
const n = node('span', `material-symbols-outlined ${className}`.trim(), text);
|
|
44
|
+
n.setAttribute('aria-hidden', 'true');
|
|
45
|
+
return n;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const isEmptyValue = (v) => v === null || v === undefined || v === '';
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* @param {object} config
|
|
52
|
+
* @param {Array|(() => Array)} [config.options] Bare values, `{ value, label?, icon?,
|
|
53
|
+
* description?, hint?, keywords?, disabled?, reason? }`, or `{ group, options }`.
|
|
54
|
+
* A function is read each time the list opens and the trigger is painted, for a
|
|
55
|
+
* list that grows while the page lives.
|
|
56
|
+
* @param {*} [config.value]
|
|
57
|
+
* @param {(value: *, option: object|null) => void} [config.onChange] After the reader
|
|
58
|
+
* chooses a different value. `option` is the entry as you gave it, `null` for a
|
|
59
|
+
* new value (`allowNew`). Not called for `value =` or `setOptions`.
|
|
60
|
+
* @param {string} [config.className] Extra classes on the trigger.
|
|
61
|
+
* @param {string} [config.panelClassName] Extra classes on the list's panel.
|
|
62
|
+
* @param {string} [config.placeholder] Shown while nothing is chosen.
|
|
63
|
+
* @param {string} [config.title] The trigger's tooltip; else it says the value.
|
|
64
|
+
* @param {string} [config.ariaLabel] When no `<label>` names the control.
|
|
65
|
+
* @param {boolean} [config.disabled]
|
|
66
|
+
* @param {boolean} [config.showIcon=true] Draw the chosen option's glyph on the trigger too.
|
|
67
|
+
* @param {boolean|{placeholder?: string}} [config.search] The search box. By default it
|
|
68
|
+
* is there with `allowNew` or more than seven options.
|
|
69
|
+
* @param {boolean} [config.allowNew] A value in no option may be typed into the
|
|
70
|
+
* search box; it is offered as its own row at the end of the list.
|
|
71
|
+
* @param {(typed: string) => string} [config.newLabel] That row's words.
|
|
72
|
+
* @param {(value: string) => string} [config.notFound] How an unlisted value reads on the trigger.
|
|
73
|
+
* @param {(typed: string) => string} [config.emptyText] What a list with no rows says.
|
|
74
|
+
* @param {boolean} [config.loose] A stored value names an option of the same text.
|
|
75
|
+
* @param {string} [config.name] A hidden input of this name carries the value
|
|
76
|
+
* as text, for a surrounding form; it fires a bubbling `change` when the reader chooses.
|
|
77
|
+
* @param {HTMLInputElement} [config.carrier] That hidden input, yours, for a field something
|
|
78
|
+
* finds by more than a name (`[data-f]`, an id). Instead of `name`; its attributes are left as they are.
|
|
79
|
+
* @param {'start'|'end'} [config.align]
|
|
80
|
+
* @returns {{el: HTMLElement, input: HTMLInputElement|null, value: *, isOpen: boolean,
|
|
81
|
+
* setOptions: Function, setPlaceholder: Function, setDisabled: Function,
|
|
82
|
+
* open: Function, close: Function, focus: Function, destroy: Function}}
|
|
83
|
+
* `el` is the control to put in your layout: the trigger, or a wrapper round it
|
|
84
|
+
* and the hidden input when there is a `name` or a `carrier`.
|
|
85
|
+
*/
|
|
86
|
+
export function createSelect({
|
|
87
|
+
options = [], value = null, onChange = null, className = '', panelClassName = '', placeholder = 'Choose…',
|
|
88
|
+
title = '', ariaLabel = '', disabled = false, showIcon = true, search = undefined, allowNew = false,
|
|
89
|
+
newLabel = (typed) => `Use “${typed}”`, notFound = (v) => v,
|
|
90
|
+
emptyText = (typed) => (typed ? `Nothing matches “${typed}”.` : 'Nothing to choose from.'),
|
|
91
|
+
loose = false, name = null, carrier = null, align = 'start',
|
|
92
|
+
} = {}) {
|
|
93
|
+
let source = options;
|
|
94
|
+
let current = value;
|
|
95
|
+
let emptyLabel = placeholder;
|
|
96
|
+
let layer = null;
|
|
97
|
+
let typedKeys = '';
|
|
98
|
+
let typedTimer = null;
|
|
99
|
+
|
|
100
|
+
const given = () => (typeof source === 'function' ? source() : source) || [];
|
|
101
|
+
|
|
102
|
+
const trigger = node('button', `twm-select${className ? ` ${className}` : ''}`);
|
|
103
|
+
trigger.type = 'button';
|
|
104
|
+
trigger.setAttribute('role', 'combobox');
|
|
105
|
+
trigger.setAttribute('aria-haspopup', 'listbox');
|
|
106
|
+
trigger.setAttribute('aria-expanded', 'false');
|
|
107
|
+
if (ariaLabel) trigger.setAttribute('aria-label', ariaLabel);
|
|
108
|
+
const icon = glyphNode('twm-select__icon');
|
|
109
|
+
const label = node('span', 'twm-select__label');
|
|
110
|
+
trigger.append(icon, label, glyphNode('twm-select__caret', 'expand_more'));
|
|
111
|
+
|
|
112
|
+
let root = trigger;
|
|
113
|
+
let hidden = carrier;
|
|
114
|
+
if (name && !hidden) {
|
|
115
|
+
hidden = node('input');
|
|
116
|
+
hidden.type = 'hidden';
|
|
117
|
+
hidden.name = name;
|
|
118
|
+
}
|
|
119
|
+
if (hidden) {
|
|
120
|
+
root = node('div', 'twm-select-field');
|
|
121
|
+
root.append(trigger, hidden);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Whichever holds focus names the active row.
|
|
125
|
+
let focusHolder = trigger;
|
|
126
|
+
const list = createOptionList({
|
|
127
|
+
onPick: (option) => choose(option),
|
|
128
|
+
onActive: (id) => {
|
|
129
|
+
if (id) focusHolder.setAttribute('aria-activedescendant', id);
|
|
130
|
+
else focusHolder.removeAttribute('aria-activedescendant');
|
|
131
|
+
},
|
|
132
|
+
});
|
|
133
|
+
trigger.setAttribute('aria-controls', list.id);
|
|
134
|
+
|
|
135
|
+
const panel = node('div', `twm-option-panel${panelClassName ? ` ${panelClassName}` : ''}`);
|
|
136
|
+
const searchRow = node('div', 'twm-option-panel__search');
|
|
137
|
+
searchRow.appendChild(glyphNode('', 'search'));
|
|
138
|
+
const searchInput = node('input', 'twm-option-panel__search-input');
|
|
139
|
+
searchInput.type = 'text';
|
|
140
|
+
searchInput.autocomplete = 'off';
|
|
141
|
+
searchInput.spellcheck = false;
|
|
142
|
+
searchInput.setAttribute('role', 'combobox');
|
|
143
|
+
searchInput.setAttribute('aria-expanded', 'true');
|
|
144
|
+
searchInput.setAttribute('aria-autocomplete', 'list');
|
|
145
|
+
searchInput.setAttribute('aria-controls', list.id);
|
|
146
|
+
searchRow.appendChild(searchInput);
|
|
147
|
+
panel.append(searchRow, list.el);
|
|
148
|
+
|
|
149
|
+
const hasSearch = () => (search === undefined
|
|
150
|
+
? allowNew || flatChoices(given()).length > SEARCH_ABOVE
|
|
151
|
+
: !!search);
|
|
152
|
+
|
|
153
|
+
function paint() {
|
|
154
|
+
const flat = flatChoices(given());
|
|
155
|
+
const chosen = findChoice(flat, current, loose);
|
|
156
|
+
const none = !chosen && isEmptyValue(current);
|
|
157
|
+
label.textContent = chosen ? chosen.label : none ? emptyLabel : notFound(shownValue(current));
|
|
158
|
+
trigger.classList.toggle('twm-select--empty', none);
|
|
159
|
+
trigger.title = title || label.textContent;
|
|
160
|
+
const glyph = showIcon ? (chosen?.icon || '') : '';
|
|
161
|
+
icon.textContent = glyph;
|
|
162
|
+
icon.hidden = !glyph;
|
|
163
|
+
if (hidden) hidden.value = isEmptyValue(current) ? '' : shownValue(current);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** The rows for what is typed, a new-value row after them when it is offered. */
|
|
167
|
+
function draw(typed) {
|
|
168
|
+
const text = typed.trim();
|
|
169
|
+
const all = choiceSections(given());
|
|
170
|
+
const flat = all.flatMap((s) => s.items);
|
|
171
|
+
const taken = (o) => [o.value, o.label].some((v) => shownValue(v).toLowerCase() === text.toLowerCase());
|
|
172
|
+
const shown = filterSections(all, text);
|
|
173
|
+
if (allowNew && text !== '' && !flat.some(taken)) {
|
|
174
|
+
shown.push({ heading: null, items: [{ value: text, label: newLabel(text), icon: 'add', description: null,
|
|
175
|
+
hint: null, disabled: false, reason: null, keywords: null,
|
|
176
|
+
source: null }] });
|
|
177
|
+
}
|
|
178
|
+
const chosen = findChoice(flat, current, loose);
|
|
179
|
+
if (shown.length) {
|
|
180
|
+
list.setRows(shown, { mark: text, isCurrent: (o) => o === chosen });
|
|
181
|
+
// On the current value when it is showing, else on the first row that may be chosen.
|
|
182
|
+
const at = list.options().indexOf(chosen);
|
|
183
|
+
if (at >= 0 && !chosen.disabled) list.setActive(at);
|
|
184
|
+
else list.moveToEnd(1);
|
|
185
|
+
} else {
|
|
186
|
+
list.setMessage(emptyText(text));
|
|
187
|
+
}
|
|
188
|
+
layer?.place();
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function choose(option) {
|
|
192
|
+
const changed = !(option.value === current || (loose && String(option.value) === String(current)));
|
|
193
|
+
current = option.value;
|
|
194
|
+
paint();
|
|
195
|
+
layer?.close('pick');
|
|
196
|
+
if (!changed) return;
|
|
197
|
+
if (hidden) fireEvent(hidden, 'change', { bubbles: true });
|
|
198
|
+
onChange?.(current, option.source);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function open() {
|
|
202
|
+
if (layer || trigger.disabled) return;
|
|
203
|
+
const searching = hasSearch();
|
|
204
|
+
searchRow.hidden = !searching;
|
|
205
|
+
focusHolder = searching ? searchInput : trigger;
|
|
206
|
+
if (searching) {
|
|
207
|
+
const word = (typeof search === 'object' && search?.placeholder)
|
|
208
|
+
|| (allowNew ? 'Search, or type a new one…' : 'Search…');
|
|
209
|
+
searchInput.placeholder = word;
|
|
210
|
+
searchInput.setAttribute('aria-label', word);
|
|
211
|
+
searchInput.value = '';
|
|
212
|
+
}
|
|
213
|
+
panel.style.width = '';
|
|
214
|
+
layer = openAnchoredLayer({
|
|
215
|
+
anchor: trigger, el: panel, align, gap: 4, matchWidth: true,
|
|
216
|
+
onClose() {
|
|
217
|
+
layer = null;
|
|
218
|
+
trigger.classList.remove('twm-is-open');
|
|
219
|
+
trigger.setAttribute('aria-expanded', 'false');
|
|
220
|
+
focusHolder.removeAttribute('aria-activedescendant');
|
|
221
|
+
},
|
|
222
|
+
});
|
|
223
|
+
if (!layer) return; // the trigger is not on the page
|
|
224
|
+
trigger.classList.add('twm-is-open');
|
|
225
|
+
trigger.setAttribute('aria-expanded', 'true');
|
|
226
|
+
// Once it is on the page: the current value has to scroll into view.
|
|
227
|
+
draw('');
|
|
228
|
+
if (searching) {
|
|
229
|
+
// PINNED, or the panel narrows and widens under the pointer as each
|
|
230
|
+
// keystroke hides or shows the longest name in it.
|
|
231
|
+
const width = Math.round(panel.getBoundingClientRect().width);
|
|
232
|
+
if (width > 0) panel.style.width = `${width}px`;
|
|
233
|
+
searchInput.focus({ preventScroll: true });
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const close = () => layer?.close('closed');
|
|
238
|
+
|
|
239
|
+
/** Printable keys on a trigger with no search box walk to the label they begin. */
|
|
240
|
+
function typeAhead(ch) {
|
|
241
|
+
clearTimeout(typedTimer);
|
|
242
|
+
typedTimer = setTimeout(() => { typedKeys = ''; }, TYPEAHEAD_MS);
|
|
243
|
+
typedKeys += ch.toLowerCase();
|
|
244
|
+
const at = list.options().findIndex((o) => !o.disabled && o.label.toLowerCase().startsWith(typedKeys));
|
|
245
|
+
if (at >= 0) list.setActive(at);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function onKey(ev) {
|
|
249
|
+
if (ev.isComposing) return;
|
|
250
|
+
const inSearch = ev.target === searchInput;
|
|
251
|
+
if (!layer) {
|
|
252
|
+
if (ev.key === 'ArrowDown' || ev.key === 'ArrowUp' || ev.key === 'Enter' || ev.key === ' ') {
|
|
253
|
+
ev.preventDefault();
|
|
254
|
+
open();
|
|
255
|
+
}
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
if (ev.key === 'Tab') {
|
|
259
|
+
// Not taken: focus goes back to the trigger, and the Tab goes on from there.
|
|
260
|
+
layer.close('tab');
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
if (ev.key === 'ArrowDown') list.move(1);
|
|
264
|
+
else if (ev.key === 'ArrowUp') list.move(-1);
|
|
265
|
+
else if (ev.key === 'Home' && !inSearch) list.moveToEnd(1);
|
|
266
|
+
else if (ev.key === 'End' && !inSearch) list.moveToEnd(-1);
|
|
267
|
+
else if (ev.key === 'Enter' || (ev.key === ' ' && !inSearch)) {
|
|
268
|
+
const option = list.activeOption();
|
|
269
|
+
if (option) choose(option);
|
|
270
|
+
else if (!inSearch) close();
|
|
271
|
+
} else if (!inSearch && ev.key.length === 1 && !ev.ctrlKey && !ev.metaKey && !ev.altKey) {
|
|
272
|
+
typeAhead(ev.key);
|
|
273
|
+
} else {
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
ev.preventDefault();
|
|
277
|
+
ev.stopPropagation();
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
trigger.addEventListener('click', () => { if (layer) close(); else open(); });
|
|
281
|
+
trigger.addEventListener('keydown', onKey);
|
|
282
|
+
// Some browsers click a button on Space's keyup whatever its keydown did.
|
|
283
|
+
trigger.addEventListener('keyup', (ev) => { if (ev.key === ' ') ev.preventDefault(); });
|
|
284
|
+
searchInput.addEventListener('keydown', onKey);
|
|
285
|
+
searchInput.addEventListener('input', () => draw(searchInput.value));
|
|
286
|
+
|
|
287
|
+
function setDisabled(off) {
|
|
288
|
+
trigger.disabled = !!off;
|
|
289
|
+
if (off) close();
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
paint();
|
|
293
|
+
setDisabled(disabled);
|
|
294
|
+
|
|
295
|
+
return {
|
|
296
|
+
el: root,
|
|
297
|
+
/** The hidden input carrying the value, when there is one. */
|
|
298
|
+
input: hidden,
|
|
299
|
+
get value() { return current; },
|
|
300
|
+
set value(next) { current = next; paint(); },
|
|
301
|
+
get isOpen() { return !!layer; },
|
|
302
|
+
/** Replace the list. Pass `nextValue` to change the value as well; left out, it stays. */
|
|
303
|
+
setOptions(nextOptions, nextValue) {
|
|
304
|
+
source = nextOptions;
|
|
305
|
+
if (arguments.length > 1) current = nextValue;
|
|
306
|
+
paint();
|
|
307
|
+
if (layer) draw(searchInput.value);
|
|
308
|
+
},
|
|
309
|
+
/** The text shown while nothing is chosen: "Loading…", the failure, "Choose…". */
|
|
310
|
+
setPlaceholder(text) { emptyLabel = text; paint(); },
|
|
311
|
+
setDisabled,
|
|
312
|
+
open,
|
|
313
|
+
close,
|
|
314
|
+
focus() { trigger.focus(); },
|
|
315
|
+
/** Closes the list and takes the control off the page. */
|
|
316
|
+
destroy() {
|
|
317
|
+
clearTimeout(typedTimer);
|
|
318
|
+
close();
|
|
319
|
+
root.remove();
|
|
320
|
+
},
|
|
321
|
+
};
|
|
322
|
+
}
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
@@ -28,12 +28,20 @@
|
|
|
28
28
|
* tags.getValue(); // ['baseline']
|
|
29
29
|
* tags.setValue(['a', 'b']);
|
|
30
30
|
* tags.dispose();
|
|
31
|
+
*
|
|
32
|
+
* `suggestions` (0.18.0) offers known tags under the box, on `attachInputOptions`:
|
|
33
|
+
* the arrow keys walk the rows, and Enter or a comma takes the active one; with none
|
|
34
|
+
* active they commit what was typed. Whatever the list's source, a row is in the one
|
|
35
|
+
* spelling, says a tag once and is not a tag already in the list. An array is filtered
|
|
36
|
+
* and ordered here, the earliest match first and then alphabetically; a function
|
|
37
|
+
* answers for itself and keeps its own order, and may be async.
|
|
38
|
+
* `normalise` (0.18.0) keeps one spelling of a tag: `Path Finding` and `path-finding`
|
|
39
|
+
* cannot both exist, whichever way one arrives. It is handed the trimmed text.
|
|
31
40
|
*/
|
|
41
|
+
import { attachInputOptions } from './combobox.js';
|
|
42
|
+
import { shownValue } from './choice_controls.js';
|
|
32
43
|
|
|
33
|
-
|
|
34
|
-
const normalise = (list) => (Array.isArray(list) ? list : [])
|
|
35
|
-
.map((v) => (v == null ? '' : String(v).trim()))
|
|
36
|
-
.filter((v) => v !== '');
|
|
44
|
+
const optionValue = (o) => (o !== null && typeof o === 'object' ? o.value : o);
|
|
37
45
|
|
|
38
46
|
/**
|
|
39
47
|
* @typedef {Object} TagInputOptions
|
|
@@ -45,6 +53,9 @@ const normalise = (list) => (Array.isArray(list) ? list : [])
|
|
|
45
53
|
* @property {string} [label] - The text box's accessible name.
|
|
46
54
|
* @property {(tag: string) => string} [removeLabel] - A remove button's `aria-label`.
|
|
47
55
|
* @property {string} [className] - Extra classes on the root.
|
|
56
|
+
* @property {Array|((query: string, ctx: {signal: AbortSignal}) => Array|Promise<Array>)} [suggestions] -
|
|
57
|
+
* Known tags: strings or `{ value, label?, description?, icon?, hint? }`.
|
|
58
|
+
* @property {(text: string) => string} [normalise] - The one spelling of a tag.
|
|
48
59
|
*/
|
|
49
60
|
|
|
50
61
|
/**
|
|
@@ -59,8 +70,16 @@ export function createTagInput(host, options = {}) {
|
|
|
59
70
|
const allowDuplicates = !!options.allowDuplicates;
|
|
60
71
|
const removeLabel = typeof options.removeLabel === 'function'
|
|
61
72
|
? options.removeLabel : (tag) => `Remove ${tag}`;
|
|
73
|
+
const spell = typeof options.normalise === 'function'
|
|
74
|
+
? (text) => String(options.normalise(text.trim()) ?? '').trim() : (text) => text.trim();
|
|
75
|
+
|
|
76
|
+
/** The list as spelled, the empty ones left out, and a repeat unless it is allowed. */
|
|
77
|
+
const cleaned = (list) => {
|
|
78
|
+
const out = (Array.isArray(list) ? list : []).map((v) => (v == null ? '' : spell(String(v)))).filter((v) => v !== '');
|
|
79
|
+
return allowDuplicates ? out : [...new Set(out)];
|
|
80
|
+
};
|
|
62
81
|
|
|
63
|
-
let tags =
|
|
82
|
+
let tags = cleaned(options.value);
|
|
64
83
|
let disposed = false;
|
|
65
84
|
|
|
66
85
|
const root = document.createElement('div');
|
|
@@ -134,7 +153,7 @@ export function createTagInput(host, options = {}) {
|
|
|
134
153
|
if (disposed || readOnly) return false;
|
|
135
154
|
const next = [...tags];
|
|
136
155
|
for (const piece of String(text).split(',')) {
|
|
137
|
-
const tag = piece
|
|
156
|
+
const tag = spell(piece);
|
|
138
157
|
if (tag === '') continue;
|
|
139
158
|
if (!allowDuplicates && next.includes(tag)) continue;
|
|
140
159
|
next.push(tag);
|
|
@@ -150,17 +169,66 @@ export function createTagInput(host, options = {}) {
|
|
|
150
169
|
return true;
|
|
151
170
|
};
|
|
152
171
|
|
|
172
|
+
/** Known tags as rows: in the one spelling, each once, less those already in the list. */
|
|
173
|
+
const offered = (list) => {
|
|
174
|
+
const seen = new Set();
|
|
175
|
+
const rows = [];
|
|
176
|
+
for (const o of list || []) {
|
|
177
|
+
const tag = spell(shownValue(optionValue(o)));
|
|
178
|
+
if (tag === '' || seen.has(tag) || (!allowDuplicates && tags.includes(tag))) continue;
|
|
179
|
+
seen.add(tag);
|
|
180
|
+
rows.push(o !== null && typeof o === 'object' ? { ...o, value: tag } : tag);
|
|
181
|
+
}
|
|
182
|
+
return rows;
|
|
183
|
+
};
|
|
184
|
+
// What is typed is the query however the list opened: an arrow key or a click
|
|
185
|
+
// reopening it must not offer rows that have nothing to do with the text in the box.
|
|
186
|
+
/** An array of known tags: those holding what is typed, the earliest match first, then alphabetically. */
|
|
187
|
+
const rankedFor = () => {
|
|
188
|
+
const needle = spell(input.value).toLowerCase();
|
|
189
|
+
const word = (o) => shownValue(optionValue(o));
|
|
190
|
+
const at = (o) => word(o).toLowerCase().indexOf(needle);
|
|
191
|
+
return offered(options.suggestions).filter((o) => at(o) >= 0)
|
|
192
|
+
.sort((x, y) => at(x) - at(y) || word(x).localeCompare(word(y)));
|
|
193
|
+
};
|
|
194
|
+
/** A function's answer, whenever it comes. */
|
|
195
|
+
const askedFor = (_query, ctx) => {
|
|
196
|
+
const answer = options.suggestions(input.value, ctx);
|
|
197
|
+
return typeof answer?.then === 'function' ? answer.then(offered) : offered(answer);
|
|
198
|
+
};
|
|
199
|
+
const asking = typeof options.suggestions === 'function';
|
|
200
|
+
|
|
201
|
+
// Before the box's own keydown listener: a row taken by Enter is not also a commit.
|
|
202
|
+
const menu = input && options.suggestions
|
|
203
|
+
? attachInputOptions(input, {
|
|
204
|
+
options: asking ? askedFor : rankedFor,
|
|
205
|
+
anchor: root,
|
|
206
|
+
normalise: spell,
|
|
207
|
+
openOnFocus: true,
|
|
208
|
+
passEnter: true,
|
|
209
|
+
debounce: asking ? undefined : 0,
|
|
210
|
+
onPick(option) {
|
|
211
|
+
input.value = '';
|
|
212
|
+
commitText(shownValue(option.value));
|
|
213
|
+
input.focus();
|
|
214
|
+
},
|
|
215
|
+
})
|
|
216
|
+
: null;
|
|
217
|
+
|
|
153
218
|
const commitInput = () => {
|
|
154
219
|
if (!input) return;
|
|
155
220
|
const text = input.value;
|
|
156
221
|
input.value = '';
|
|
222
|
+
menu?.close();
|
|
157
223
|
commitText(text);
|
|
158
224
|
};
|
|
159
225
|
|
|
160
226
|
if (input) {
|
|
161
227
|
input.addEventListener('keydown', (ev) => {
|
|
162
228
|
if (ev.isComposing) return;
|
|
163
|
-
if (ev.key === '
|
|
229
|
+
if (ev.key === ',' && menu?.takeActive()) {
|
|
230
|
+
ev.preventDefault();
|
|
231
|
+
} else if (ev.key === 'Enter' || ev.key === ',') {
|
|
164
232
|
ev.preventDefault();
|
|
165
233
|
commitInput();
|
|
166
234
|
} else if (ev.key === 'Backspace' && input.value === '' && tags.length) {
|
|
@@ -202,13 +270,14 @@ export function createTagInput(host, options = {}) {
|
|
|
202
270
|
/** Replaces the list. Does not call `onChange`. */
|
|
203
271
|
setValue(list) {
|
|
204
272
|
if (disposed) return;
|
|
205
|
-
tags =
|
|
273
|
+
tags = cleaned(list);
|
|
206
274
|
paint();
|
|
207
275
|
},
|
|
208
276
|
focus() { input?.focus(); },
|
|
209
277
|
dispose() {
|
|
210
278
|
if (disposed) return;
|
|
211
279
|
disposed = true;
|
|
280
|
+
menu?.destroy();
|
|
212
281
|
root.remove();
|
|
213
282
|
},
|
|
214
283
|
};
|
|
File without changes
|
|
File without changes
|
package/src/ui/components/tsv.js
CHANGED
|
File without changes
|