tablewalk 0.0.1
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/LICENSE +21 -0
- package/README.md +553 -0
- package/dist/adapters/adapter.js +372 -0
- package/dist/adapters/connect.js +33 -0
- package/dist/adapters/mysql.js +951 -0
- package/dist/adapters/postgres.js +1000 -0
- package/dist/adapters/sqlite.js +781 -0
- package/dist/client/agent.js +262 -0
- package/dist/client/app.js +973 -0
- package/dist/client/arrange.js +254 -0
- package/dist/client/ask.js +133 -0
- package/dist/client/breakdown.js +317 -0
- package/dist/client/clauses.js +390 -0
- package/dist/client/columns.js +98 -0
- package/dist/client/complete.js +437 -0
- package/dist/client/compose.js +166 -0
- package/dist/client/composer.css +495 -0
- package/dist/client/composer.js +1972 -0
- package/dist/client/connections.js +234 -0
- package/dist/client/connmanager.js +962 -0
- package/dist/client/connurl.js +188 -0
- package/dist/client/core.js +893 -0
- package/dist/client/deeplink.js +270 -0
- package/dist/client/delete.js +144 -0
- package/dist/client/diagram.js +885 -0
- package/dist/client/dropdown.js +279 -0
- package/dist/client/export.js +456 -0
- package/dist/client/features.css +524 -0
- package/dist/client/findvalue.js +169 -0
- package/dist/client/grid.js +205 -0
- package/dist/client/handoff.js +153 -0
- package/dist/client/help.css +145 -0
- package/dist/client/help.js +881 -0
- package/dist/client/history.js +222 -0
- package/dist/client/index.html +116 -0
- package/dist/client/insert.js +151 -0
- package/dist/client/menu.js +160 -0
- package/dist/client/nested.js +255 -0
- package/dist/client/page.css +713 -0
- package/dist/client/page.js +1345 -0
- package/dist/client/pagebuilder.js +1222 -0
- package/dist/client/pagemarks.js +95 -0
- package/dist/client/palette.js +374 -0
- package/dist/client/peek.js +254 -0
- package/dist/client/picker.js +139 -0
- package/dist/client/pins.js +140 -0
- package/dist/client/prompt.js +129 -0
- package/dist/client/record.js +707 -0
- package/dist/client/schemaexport.js +242 -0
- package/dist/client/schematext.js +125 -0
- package/dist/client/shape.js +178 -0
- package/dist/client/shapecheck.js +129 -0
- package/dist/client/skeleton.js +139 -0
- package/dist/client/sql.css +126 -0
- package/dist/client/sql.js +398 -0
- package/dist/client/sqlcomplete.js +163 -0
- package/dist/client/sqlsaved.js +107 -0
- package/dist/client/style.css +2711 -0
- package/dist/client/summary.js +259 -0
- package/dist/client/table.js +1035 -0
- package/dist/client/template.js +539 -0
- package/dist/client/theme.js +74 -0
- package/dist/client/tour.js +324 -0
- package/dist/client/undo.js +105 -0
- package/dist/client/url.js +166 -0
- package/dist/client/value.js +223 -0
- package/dist/client/views.js +215 -0
- package/dist/client/virtual.js +176 -0
- package/dist/client/welcome.js +170 -0
- package/dist/client/write.js +414 -0
- package/dist/server/changeimpact.js +195 -0
- package/dist/server/connections.js +615 -0
- package/dist/server/constraints.js +62 -0
- package/dist/server/credentials.js +230 -0
- package/dist/server/fixture.js +199 -0
- package/dist/server/graph.js +194 -0
- package/dist/server/impact.js +48 -0
- package/dist/server/index.js +2204 -0
- package/dist/server/journal.js +173 -0
- package/dist/server/layouts.js +128 -0
- package/dist/server/mcp.js +2840 -0
- package/dist/server/shapeonly.js +91 -0
- package/dist/shared/breakdown.js +231 -0
- package/dist/shared/breakdowntext.js +257 -0
- package/dist/shared/diff.js +130 -0
- package/dist/shared/like.js +29 -0
- package/dist/shared/lint.js +149 -0
- package/dist/shared/order.js +133 -0
- package/dist/shared/page.js +932 -0
- package/dist/shared/query.js +831 -0
- package/dist/shared/recordview.js +343 -0
- package/dist/shared/schema.js +377 -0
- package/dist/shared/sqlsaved.js +67 -0
- package/dist/shared/view.js +981 -0
- package/dist/shared/viewtext.js +273 -0
- package/dist/shared/vocabulary.js +164 -0
- package/package.json +57 -0
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pages you pinned, and pages you were just on.
|
|
3
|
+
*
|
|
4
|
+
* A page is the most expensive thing anyone builds in tablewalk — sections
|
|
5
|
+
* chosen, columns picked, an order decided — and until now the only way back
|
|
6
|
+
* to one was to remember its name and type it into ⌘K. Everything else the
|
|
7
|
+
* tool remembers for you (the trail, the last root of each page, saved
|
|
8
|
+
* filters) it remembers because retyping it is the tax on coming back
|
|
9
|
+
* tomorrow, and this is the same tax on the most expensive thing.
|
|
10
|
+
*
|
|
11
|
+
* Two lists, because they answer two different questions. Pinned is "the
|
|
12
|
+
* pages I work in", stated once. Recent is "where was I", which nobody
|
|
13
|
+
* states — and a recent entry carries the record as well as the page, since
|
|
14
|
+
* being sent back to "choose a customer" is not being sent back to where you
|
|
15
|
+
* were.
|
|
16
|
+
*
|
|
17
|
+
* Per connection, like every other memory here: the same page name means a
|
|
18
|
+
* different thing on staging and on production, and the last customer you
|
|
19
|
+
* looked at on one is not a record on the other.
|
|
20
|
+
*/
|
|
21
|
+
import { loadJson, saveJson, state } from './core.js';
|
|
22
|
+
|
|
23
|
+
const PINNED_KEY = 'tablewalk.pages.pinned.v1';
|
|
24
|
+
const VISITS_KEY = 'tablewalk.pages.visits.v1';
|
|
25
|
+
|
|
26
|
+
/** How many positions are worth keeping. Enough for a day's work, not a log. */
|
|
27
|
+
const VISITS_MAX = 12;
|
|
28
|
+
|
|
29
|
+
const conn = () => state.activeConnection ?? 'default';
|
|
30
|
+
const read = (key) => loadJson(key, {})[conn()] ?? [];
|
|
31
|
+
|
|
32
|
+
function write(key, value) {
|
|
33
|
+
const all = loadJson(key, {});
|
|
34
|
+
all[conn()] = value;
|
|
35
|
+
saveJson(key, all);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/* ---------- pinned ---------- */
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The pinned pages, each carrying its own name and base table.
|
|
42
|
+
*
|
|
43
|
+
* Stored rather than looked up, because the list of pages this connection
|
|
44
|
+
* *has* does not include the suggested one — the page every table has and
|
|
45
|
+
* nobody built — and pinning "the customer page" is exactly what someone
|
|
46
|
+
* does before they have built anything. A pin that could only name pages
|
|
47
|
+
* from a file would be a pin that silently dropped the commonest case.
|
|
48
|
+
*/
|
|
49
|
+
export function pinnedPages() {
|
|
50
|
+
return read(PINNED_KEY).filter((p) => p && p.id);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function isPinned(pageId) {
|
|
54
|
+
return pinnedPages().some((p) => p.id === pageId);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Pin or unpin, and answer what it is now. */
|
|
58
|
+
export function togglePinned(page) {
|
|
59
|
+
const pinned = pinnedPages();
|
|
60
|
+
const next = pinned.some((p) => p.id === page.id)
|
|
61
|
+
? pinned.filter((p) => p.id !== page.id)
|
|
62
|
+
: [...pinned, { id: page.id, name: page.name, base: page.base }];
|
|
63
|
+
write(PINNED_KEY, next);
|
|
64
|
+
return next.some((p) => p.id === page.id);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/* ---------- where you were ---------- */
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Record a position: this page, and the record it was about.
|
|
71
|
+
*
|
|
72
|
+
* Keyed by page *and* record, so opening three customers leaves three
|
|
73
|
+
* entries rather than one that keeps changing — "the last three customers I
|
|
74
|
+
* looked at" is the useful list, and a single slot per page throws away two
|
|
75
|
+
* of them. A page with no root chosen still counts: getting back to the
|
|
76
|
+
* chooser is worth one line.
|
|
77
|
+
*/
|
|
78
|
+
export function rememberVisit({ pageId, name, base, key, label }) {
|
|
79
|
+
if (!pageId) return;
|
|
80
|
+
const stamp = JSON.stringify(key ?? null);
|
|
81
|
+
const visits = read(VISITS_KEY)
|
|
82
|
+
.filter((v) => !(v.pageId === pageId && JSON.stringify(v.key ?? null) === stamp));
|
|
83
|
+
visits.unshift({ pageId, name, base, key, label });
|
|
84
|
+
write(VISITS_KEY, visits.slice(0, VISITS_MAX));
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function recentVisits() {
|
|
88
|
+
return read(VISITS_KEY);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Forget a page entirely — it was deleted, or renamed out of existence. */
|
|
92
|
+
export function forgetPage(pageId) {
|
|
93
|
+
write(PINNED_KEY, pinnedPages().filter((p) => p.id !== pageId));
|
|
94
|
+
write(VISITS_KEY, recentVisits().filter((v) => v.pageId !== pageId));
|
|
95
|
+
}
|
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cmd+K: go anywhere by typing its name.
|
|
3
|
+
*
|
|
4
|
+
* The sidebar is a fine way to browse eleven tables and a poor way to reach
|
|
5
|
+
* one of sixty-six. Past a screenful, finding a table means scrolling a list
|
|
6
|
+
* you already know the answer in — you know it is called `purchase_order_line`
|
|
7
|
+
* and the interface still makes you look for it.
|
|
8
|
+
*
|
|
9
|
+
* So this is the keyboard route to everything nameable: tables, views, saved
|
|
10
|
+
* filters, and the connections themselves. One list, ranked, because having
|
|
11
|
+
* to know which *kind* of thing you are looking for before you can search for
|
|
12
|
+
* it defeats the purpose.
|
|
13
|
+
*
|
|
14
|
+
* Matching is subsequence-based rather than substring: `pol` finds
|
|
15
|
+
* `purchase_order_line`, which is how anyone who knows the schema would type
|
|
16
|
+
* it. Exact and prefix matches still win, so typing a full name never ranks
|
|
17
|
+
* it below something that merely contains the same letters.
|
|
18
|
+
*/
|
|
19
|
+
import { $, el, findTable, go, state, tableView, svgEl,
|
|
20
|
+
} from './core.js';
|
|
21
|
+
import { allSavedFilters } from './views.js';
|
|
22
|
+
import { historyFor } from './history.js';
|
|
23
|
+
import { detailOf, menuItems, whenMenuChanges } from './menu.js';
|
|
24
|
+
import { savedStatements } from './sqlsaved.js';
|
|
25
|
+
import { openSql } from './sql.js';
|
|
26
|
+
import { selectConnection } from './connections.js';
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Score `text` against a typed query. Higher is better; 0 means no match.
|
|
30
|
+
*
|
|
31
|
+
* The tiers matter more than the numbers: an exact name beats a prefix beats
|
|
32
|
+
* a substring beats a scattered subsequence. Within a tier, shorter names win
|
|
33
|
+
* — `site` should outrank `site_visit_note` for the query "site", because the
|
|
34
|
+
* shorter one is what someone typing four letters usually meant.
|
|
35
|
+
*/
|
|
36
|
+
export function score(text, query) {
|
|
37
|
+
if (!query) return 1;
|
|
38
|
+
const haystack = text.toLowerCase();
|
|
39
|
+
const needle = query.toLowerCase();
|
|
40
|
+
|
|
41
|
+
if (haystack === needle) return 1000;
|
|
42
|
+
if (haystack.startsWith(needle)) return 800 - haystack.length;
|
|
43
|
+
const at = haystack.indexOf(needle);
|
|
44
|
+
if (at >= 0) return 600 - at - haystack.length / 10;
|
|
45
|
+
|
|
46
|
+
/* Subsequence: every character of the query, in order, anywhere.
|
|
47
|
+
|
|
48
|
+
Two things are rewarded, and between them they are what make `pol` mean
|
|
49
|
+
`purchase_order_line` rather than `employee_skill`:
|
|
50
|
+
|
|
51
|
+
- a match at the start of a word — the beginning of the name, or the
|
|
52
|
+
letter after an underscore — because initials are how people
|
|
53
|
+
abbreviate a snake_case name in their head;
|
|
54
|
+
- runs of adjacent characters, so `porder` prefers `purchase_order`
|
|
55
|
+
over a name where those letters merely occur in that order.
|
|
56
|
+
|
|
57
|
+
Length is a small penalty rather than a large one: it breaks ties between
|
|
58
|
+
equally good matches without letting a long exact match lose to a short
|
|
59
|
+
accidental one. */
|
|
60
|
+
let i = 0;
|
|
61
|
+
let run = 0;
|
|
62
|
+
let points = 0;
|
|
63
|
+
for (let at = 0; at < haystack.length; at += 1) {
|
|
64
|
+
const char = haystack[at];
|
|
65
|
+
if (char !== needle[i]) { run = 0; continue; }
|
|
66
|
+
const boundary = at === 0 || /[^a-z0-9]/.test(haystack[at - 1]);
|
|
67
|
+
run += 1;
|
|
68
|
+
points += run + (boundary ? 12 : 0);
|
|
69
|
+
i += 1;
|
|
70
|
+
if (i === needle.length) break;
|
|
71
|
+
}
|
|
72
|
+
return i === needle.length ? 100 + points - haystack.length / 20 : 0;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Everything the palette can take you to, before filtering. */
|
|
76
|
+
function entries() {
|
|
77
|
+
const items = [];
|
|
78
|
+
|
|
79
|
+
for (const table of state.schema?.tables ?? []) {
|
|
80
|
+
items.push({
|
|
81
|
+
kind: table.isView ? 'view' : 'table',
|
|
82
|
+
label: table.name,
|
|
83
|
+
// The id only adds anything when it differs — a qualified name like
|
|
84
|
+
// `app.invoice`. Repeating the name doubles its length for no gain and
|
|
85
|
+
// lets a match run across the join between the two copies.
|
|
86
|
+
search: table.id === table.name ? table.name : `${table.name} ${table.id}`,
|
|
87
|
+
detail: `${table.columns.length} column${table.columns.length === 1 ? '' : 's'}`,
|
|
88
|
+
run: () => go(tableView(table.id), 'reset'),
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
for (const filter of allSavedFilters()) {
|
|
93
|
+
// A saved filter is only reachable if its table still is.
|
|
94
|
+
if (!findTable(filter.table)) continue;
|
|
95
|
+
items.push({
|
|
96
|
+
kind: 'filter',
|
|
97
|
+
label: filter.name,
|
|
98
|
+
search: `${filter.name} ${filter.table} ${filter.query}`,
|
|
99
|
+
detail: filter.table,
|
|
100
|
+
run: () => go(tableView(filter.table, filter.query), 'reset'),
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/* Recent queries, alongside everything else.
|
|
105
|
+
|
|
106
|
+
A query you ran ten minutes ago is a place you were, in exactly the sense
|
|
107
|
+
a table is — and searching for it by the words you typed is how you would
|
|
108
|
+
look for it anyway. Bounded to the most recent handful so the list stays
|
|
109
|
+
a shortlist rather than becoming a log. */
|
|
110
|
+
for (const entry of historyFor().slice(0, 12)) {
|
|
111
|
+
const table = entry.q.split(/\s+/)[0];
|
|
112
|
+
if (!findTable(table) && !state.schema?.tables.some((t) => t.name === table)) continue;
|
|
113
|
+
items.push({
|
|
114
|
+
kind: 'recent',
|
|
115
|
+
label: entry.q,
|
|
116
|
+
search: entry.q,
|
|
117
|
+
run: () => go(tableView(findTable(table) ? table : state.schema.tables.find((t) => t.name === table).id, entry.q), 'reset'),
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/* Saved SQL, alongside everything else. A statement someone committed to
|
|
122
|
+
tablewalk.json is a place you go, in the same sense a table is. */
|
|
123
|
+
for (const statement of savedStatements()) {
|
|
124
|
+
items.push({
|
|
125
|
+
kind: 'sql',
|
|
126
|
+
label: statement.name,
|
|
127
|
+
search: `${statement.name} ${statement.description ?? ''} ${statement.sql}`,
|
|
128
|
+
detail: statement.source === 'config' ? 'from tablewalk.json' : 'saved SQL',
|
|
129
|
+
run: () => openSql(statement.sql),
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/* The overflow menu's actions, searchable. Registered in one place and
|
|
134
|
+
offered in two, so nothing can be in the menu and missing from here. */
|
|
135
|
+
for (const action of menuItems()) {
|
|
136
|
+
items.push({
|
|
137
|
+
/* A page is a page, not an "action". The registry groups what it holds,
|
|
138
|
+
and the group is the closest thing to a kind that a registered
|
|
139
|
+
command has — a row labelled ACTION beside three labelled TABLE told
|
|
140
|
+
the reader nothing about what they were about to open. */
|
|
141
|
+
kind: action.group === 'Pages' ? 'page' : 'action',
|
|
142
|
+
label: action.label.replace(/…$/, ''),
|
|
143
|
+
search: `${action.label} ${detailOf(action) ?? ''}`.trim(),
|
|
144
|
+
detail: detailOf(action),
|
|
145
|
+
run: () => action.onSelect(),
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
for (const connection of state.connections ?? []) {
|
|
150
|
+
if (connection.id === state.activeConnection) continue;
|
|
151
|
+
items.push({
|
|
152
|
+
kind: 'database',
|
|
153
|
+
label: connection.name,
|
|
154
|
+
search: `${connection.name} ${connection.dialect}`,
|
|
155
|
+
detail: connection.detail ?? connection.dialect,
|
|
156
|
+
run: () => selectConnection(connection.id),
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return items;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
let open = false;
|
|
164
|
+
|
|
165
|
+
export function openPalette() {
|
|
166
|
+
if (open) return;
|
|
167
|
+
open = true;
|
|
168
|
+
|
|
169
|
+
const input = el('input', {
|
|
170
|
+
type: 'text',
|
|
171
|
+
class: 'palette-input',
|
|
172
|
+
placeholder: 'Go to a table, view, saved filter or database…',
|
|
173
|
+
'aria-label': 'Search',
|
|
174
|
+
autocomplete: 'off',
|
|
175
|
+
spellcheck: 'false',
|
|
176
|
+
});
|
|
177
|
+
const list = el('div', { class: 'palette-list', role: 'listbox' });
|
|
178
|
+
const box = el('div', { class: 'palette' }, [input, list]);
|
|
179
|
+
const backdrop = el('div', { class: 'palette-backdrop' }, box);
|
|
180
|
+
|
|
181
|
+
let all = entries();
|
|
182
|
+
let shown = [];
|
|
183
|
+
let active = 0;
|
|
184
|
+
|
|
185
|
+
/* Commands can arrive while the palette is open — the pages group is
|
|
186
|
+
registered asynchronously on load — and a list snapshotted at open would
|
|
187
|
+
answer "nothing matches" about a page the menu already shows. */
|
|
188
|
+
const stopWatching = whenMenuChanges(() => {
|
|
189
|
+
all = entries();
|
|
190
|
+
paint();
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
const paint = () => {
|
|
194
|
+
const query = input.value.trim();
|
|
195
|
+
shown = all
|
|
196
|
+
.map((item) => ({ item, points: score(item.search, query) }))
|
|
197
|
+
.filter(({ points }) => points > 0)
|
|
198
|
+
.sort((a, b) => b.points - a.points)
|
|
199
|
+
// Bounded because a palette is a shortlist. Anything past twenty is
|
|
200
|
+
// reached by typing another character, not by scrolling.
|
|
201
|
+
.slice(0, 20)
|
|
202
|
+
.map(({ item }) => item);
|
|
203
|
+
active = 0;
|
|
204
|
+
|
|
205
|
+
if (!shown.length) {
|
|
206
|
+
list.replaceChildren(el('p', { class: 'palette-empty', text: query ? `Nothing matches "${query}".` : 'Nothing to go to.' }));
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
list.replaceChildren(...shown.map((item, i) => el('div', {
|
|
210
|
+
class: `palette-item${i === active ? ' active' : ''}`,
|
|
211
|
+
role: 'option',
|
|
212
|
+
'aria-selected': String(i === active),
|
|
213
|
+
onclick: () => choose(i),
|
|
214
|
+
onmousemove: () => setActive(i),
|
|
215
|
+
}, [
|
|
216
|
+
el('span', { class: `palette-kind kind-${item.kind}`, text: item.kind }),
|
|
217
|
+
el('span', { class: 'palette-label', text: item.label }),
|
|
218
|
+
item.detail ? el('span', { class: 'palette-detail', text: item.detail }) : null,
|
|
219
|
+
])));
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
const setActive = (index) => {
|
|
223
|
+
if (!shown.length) return;
|
|
224
|
+
active = (index + shown.length) % shown.length;
|
|
225
|
+
[...list.children].forEach((node, i) => {
|
|
226
|
+
node.classList.toggle('active', i === active);
|
|
227
|
+
node.setAttribute('aria-selected', String(i === active));
|
|
228
|
+
});
|
|
229
|
+
list.children[active]?.scrollIntoView({ block: 'nearest' });
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
const close = () => {
|
|
233
|
+
open = false;
|
|
234
|
+
stopWatching();
|
|
235
|
+
backdrop.remove();
|
|
236
|
+
document.removeEventListener('keydown', onKey, true);
|
|
237
|
+
};
|
|
238
|
+
|
|
239
|
+
const choose = (index) => {
|
|
240
|
+
const item = shown[index];
|
|
241
|
+
if (!item) return;
|
|
242
|
+
close();
|
|
243
|
+
item.run();
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
function onKey(e) {
|
|
247
|
+
switch (e.key) {
|
|
248
|
+
case 'Escape':
|
|
249
|
+
/* Stopped as well as prevented. The composer and the page listen for
|
|
250
|
+
Escape at the document, so one press closed the palette *and* the
|
|
251
|
+
surface underneath it — which is never what a single Escape means.
|
|
252
|
+
A modal takes the key it handles. */
|
|
253
|
+
e.preventDefault();
|
|
254
|
+
e.stopPropagation();
|
|
255
|
+
close();
|
|
256
|
+
return;
|
|
257
|
+
case 'ArrowDown': e.preventDefault(); setActive(active + 1); return;
|
|
258
|
+
case 'ArrowUp': e.preventDefault(); setActive(active - 1); return;
|
|
259
|
+
case 'Enter': e.preventDefault(); choose(active); return;
|
|
260
|
+
default:
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
input.addEventListener('input', paint);
|
|
265
|
+
backdrop.addEventListener('pointerdown', (e) => { if (e.target === backdrop) close(); });
|
|
266
|
+
document.addEventListener('keydown', onKey, true);
|
|
267
|
+
|
|
268
|
+
paint();
|
|
269
|
+
document.body.append(backdrop);
|
|
270
|
+
input.focus();
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Bind Cmd+K / Ctrl+K.
|
|
275
|
+
*
|
|
276
|
+
* Bound on the document with capture so it works from anywhere including the
|
|
277
|
+
* query bar, which is where the hands usually are.
|
|
278
|
+
*/
|
|
279
|
+
export function initPalette() {
|
|
280
|
+
document.addEventListener('keydown', (e) => {
|
|
281
|
+
if (e.key !== 'k' || !(e.metaKey || e.ctrlKey)) return;
|
|
282
|
+
e.preventDefault();
|
|
283
|
+
openPalette();
|
|
284
|
+
}, true);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/* ---------- the sidebar toggle ---------- */
|
|
288
|
+
|
|
289
|
+
const SIDEBAR_KEY = 'tablewalk.sidebar-hidden';
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Hide the table list.
|
|
293
|
+
*
|
|
294
|
+
* Worth having once the palette exists: with Cmd+K reaching every table by
|
|
295
|
+
* name, the sidebar stops being the only way in and becomes 232 pixels that
|
|
296
|
+
* a wide record could use instead. The state is remembered, because a
|
|
297
|
+
* preference about screen space that resets on reload is not a preference.
|
|
298
|
+
*/
|
|
299
|
+
export function initSidebarToggle() {
|
|
300
|
+
const hidden = window.localStorage?.getItem(SIDEBAR_KEY) === '1';
|
|
301
|
+
|
|
302
|
+
const remember = (next) => {
|
|
303
|
+
try {
|
|
304
|
+
window.localStorage?.setItem(SIDEBAR_KEY, next ? '1' : '0');
|
|
305
|
+
} catch {
|
|
306
|
+
/* Private browsing. The toggle still works for this session. */
|
|
307
|
+
}
|
|
308
|
+
};
|
|
309
|
+
|
|
310
|
+
/* Two buttons, because a control that hides its own container cannot also
|
|
311
|
+
be the way to bring it back. The collapse button lives in the sidebar,
|
|
312
|
+
next to the filter field and directly above the list it hides; the reveal
|
|
313
|
+
button appears at the left edge where the sidebar was, which is where the
|
|
314
|
+
hand goes looking for it. */
|
|
315
|
+
/* Drawn rather than typed, for the same reason the home crumb is: `«` and
|
|
316
|
+
`»` are guillemets, which every platform sets at a different weight and
|
|
317
|
+
several set as punctuation too small to be a target. And they were
|
|
318
|
+
`--text-faint` — a mid-grey at half alpha, which is a token for a rule
|
|
319
|
+
between things, not for a control somebody is meant to find. */
|
|
320
|
+
const chevrons = (facing) => svgEl('svg', {
|
|
321
|
+
viewBox: '0 0 16 16', width: '15', height: '15', 'aria-hidden': 'true',
|
|
322
|
+
fill: 'none', stroke: 'currentColor', 'stroke-width': '1.6',
|
|
323
|
+
'stroke-linecap': 'round', 'stroke-linejoin': 'round',
|
|
324
|
+
}, facing === 'left'
|
|
325
|
+
? [svgEl('path', { d: 'M9.5 4L5.5 8l4 4' }), svgEl('path', { d: 'M13 4L9 8l4 4' })]
|
|
326
|
+
: [svgEl('path', { d: 'M6.5 4l4 4-4 4' }), svgEl('path', { d: 'M3 4l4 4-4 4' })]);
|
|
327
|
+
|
|
328
|
+
const collapse = el('button', {
|
|
329
|
+
type: 'button',
|
|
330
|
+
class: 'sidebar-collapse',
|
|
331
|
+
'aria-label': 'Hide the table list',
|
|
332
|
+
'aria-controls': 'table-list',
|
|
333
|
+
title: 'Hide the table list (⌘B)',
|
|
334
|
+
onclick: () => apply(true),
|
|
335
|
+
}, chevrons('left'));
|
|
336
|
+
|
|
337
|
+
const reveal = el('button', {
|
|
338
|
+
type: 'button',
|
|
339
|
+
class: 'sidebar-reveal',
|
|
340
|
+
'aria-label': 'Show the table list',
|
|
341
|
+
'aria-controls': 'table-list',
|
|
342
|
+
title: 'Show the table list (⌘B)',
|
|
343
|
+
onclick: () => apply(false),
|
|
344
|
+
}, chevrons('right'));
|
|
345
|
+
|
|
346
|
+
function apply(next) {
|
|
347
|
+
document.body.classList.toggle('sidebar-hidden', next);
|
|
348
|
+
remember(next);
|
|
349
|
+
collapse.setAttribute('aria-expanded', String(!next));
|
|
350
|
+
reveal.setAttribute('aria-expanded', String(!next));
|
|
351
|
+
// Focus follows the control that survives, so the keyboard is never left
|
|
352
|
+
// on a button that has just been hidden.
|
|
353
|
+
if (document.activeElement === collapse || document.activeElement === reveal) {
|
|
354
|
+
(next ? reveal : collapse).focus({ preventScroll: true });
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/* Sharing the filter field's row rather than taking one of its own: the
|
|
359
|
+
sidebar's vertical space is the list, and a button on a line by itself
|
|
360
|
+
would cost a table's worth of it. */
|
|
361
|
+
const filter = $('table-filter');
|
|
362
|
+
const row = el('div', { class: 'sidebar-filter-row' });
|
|
363
|
+
filter.replaceWith(row);
|
|
364
|
+
row.append(filter, collapse);
|
|
365
|
+
document.querySelector('.layout')?.prepend(reveal);
|
|
366
|
+
|
|
367
|
+
apply(hidden);
|
|
368
|
+
|
|
369
|
+
document.addEventListener('keydown', (e) => {
|
|
370
|
+
if (e.key !== 'b' || !(e.metaKey || e.ctrlKey)) return;
|
|
371
|
+
e.preventDefault();
|
|
372
|
+
apply(!document.body.classList.contains('sidebar-hidden'));
|
|
373
|
+
}, true);
|
|
374
|
+
}
|