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,254 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What is on the other end of a reference, without going there.
|
|
3
|
+
*
|
|
4
|
+
* `sold_by 4` is not an answer. It is a promise that an answer exists in
|
|
5
|
+
* another table, and collecting it costs a navigation, a look, and a click
|
|
6
|
+
* back — by which time you have lost the row you were reading. That round
|
|
7
|
+
* trip is the tax this tool charges on the thing it is best at, and most of
|
|
8
|
+
* the time the question is small: *who is 4?*
|
|
9
|
+
*
|
|
10
|
+
* So a reference answers itself on hover. A card with the referenced row's
|
|
11
|
+
* identifying fields, near the chip, gone when the pointer leaves. Walking is
|
|
12
|
+
* still there for when you actually want to be over there; this is for when
|
|
13
|
+
* you only wanted to know.
|
|
14
|
+
*
|
|
15
|
+
* Three things make it bearable rather than irritating:
|
|
16
|
+
*
|
|
17
|
+
* - **It waits.** Crossing a column of forty references on the way to
|
|
18
|
+
* something else must not fire forty requests, so nothing happens until
|
|
19
|
+
* the pointer has settled.
|
|
20
|
+
* - **It remembers.** The same reference value repeats down a column, and
|
|
21
|
+
* the second hover should not ask again.
|
|
22
|
+
* - **It gives up quietly.** A row that has been deleted, a key that does
|
|
23
|
+
* not resolve, a reply that outlives the pointer — none of those are
|
|
24
|
+
* worth a message. The card simply does not appear.
|
|
25
|
+
*/
|
|
26
|
+
import { api, el, findTable, labelColumn, state } from './core.js';
|
|
27
|
+
|
|
28
|
+
/** How long the pointer must rest on a chip before anything is fetched. */
|
|
29
|
+
const SETTLE_MS = 320;
|
|
30
|
+
|
|
31
|
+
/** Fields shown before the card stops listing. */
|
|
32
|
+
const FIELDS = 6;
|
|
33
|
+
|
|
34
|
+
/** Rows already fetched, keyed by table, column and value. */
|
|
35
|
+
const cache = new Map();
|
|
36
|
+
|
|
37
|
+
let card = null;
|
|
38
|
+
/** `title` attributes taken off the chip while the card is up, to be put back. */
|
|
39
|
+
let suppressed = [];
|
|
40
|
+
let timer = 0;
|
|
41
|
+
/** Bumped on every open, so a slow reply for a chip you have left is dropped. */
|
|
42
|
+
let generation = 0;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Emptied when the schema changes underneath the cached rows.
|
|
46
|
+
*
|
|
47
|
+
* Not needed for a connection switch — the key names the connection, so the
|
|
48
|
+
* two databases' answers cannot be confused for each other. This is for a
|
|
49
|
+
* re-introspection, where the same connection may now describe different
|
|
50
|
+
* columns.
|
|
51
|
+
*/
|
|
52
|
+
export function clearPeekCache() {
|
|
53
|
+
cache.clear();
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function hide() {
|
|
57
|
+
clearTimeout(timer);
|
|
58
|
+
generation += 1;
|
|
59
|
+
card?.remove();
|
|
60
|
+
card = null;
|
|
61
|
+
for (const [node, title] of suppressed) node.setAttribute('title', title);
|
|
62
|
+
suppressed = [];
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Which columns are worth showing.
|
|
67
|
+
*
|
|
68
|
+
* The key, because it is what you hovered and seeing it confirms the card is
|
|
69
|
+
* about the right row; then the label column, which is the name of the thing;
|
|
70
|
+
* then whatever else fits. References are listed as plain values rather than
|
|
71
|
+
* as chips — a card you can walk out of is a menu, and this is a glance.
|
|
72
|
+
*/
|
|
73
|
+
function fieldsFor(table) {
|
|
74
|
+
const key = table.columns.filter((c) => c.primaryKey).map((c) => c.name);
|
|
75
|
+
const label = labelColumn(table);
|
|
76
|
+
const ordered = [
|
|
77
|
+
...key,
|
|
78
|
+
...(label && !key.includes(label) ? [label] : []),
|
|
79
|
+
...table.columns.map((c) => c.name).filter((n) => !key.includes(n) && n !== label),
|
|
80
|
+
];
|
|
81
|
+
return ordered.slice(0, FIELDS);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async function rowFor(table, column, value) {
|
|
85
|
+
/* The connection is part of the key.
|
|
86
|
+
|
|
87
|
+
Without it, hovering `sold_by 4` on staging and then switching to
|
|
88
|
+
production showed staging's employee for production's reference — the
|
|
89
|
+
same table, the same id, a different database, and nothing on the card to
|
|
90
|
+
say which one it came from. */
|
|
91
|
+
const id = `${state.activeConnection ?? ''} ${table.id} ${column} ${value}`;
|
|
92
|
+
if (cache.has(id)) return cache.get(id);
|
|
93
|
+
|
|
94
|
+
const data = await api('/api/view/run', {
|
|
95
|
+
view: {
|
|
96
|
+
id: `peek:${table.id}`,
|
|
97
|
+
name: table.name,
|
|
98
|
+
base: table.id,
|
|
99
|
+
columns: fieldsFor(table).map((path) => ({ path })),
|
|
100
|
+
filter: { groups: [[{ path: column, op: '=', value }]] },
|
|
101
|
+
limit: 1,
|
|
102
|
+
},
|
|
103
|
+
});
|
|
104
|
+
const row = data.rows?.[0] ?? null;
|
|
105
|
+
cache.set(id, row);
|
|
106
|
+
return row;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function draw(anchor, table, value, row) {
|
|
110
|
+
hide();
|
|
111
|
+
const lines = fieldsFor(table).map((name) => el('div', { class: 'peek-line' }, [
|
|
112
|
+
el('span', { class: 'peek-name', text: name }),
|
|
113
|
+
row[name] === null || row[name] === undefined
|
|
114
|
+
? el('span', { class: 'peek-value null', text: 'null' })
|
|
115
|
+
: el('span', { class: 'peek-value', text: String(row[name]) }),
|
|
116
|
+
]));
|
|
117
|
+
|
|
118
|
+
card = el('div', { class: 'peek', role: 'tooltip' }, [
|
|
119
|
+
el('div', { class: 'peek-head' }, [
|
|
120
|
+
el('span', { class: 'peek-table', text: table.name }),
|
|
121
|
+
el('span', { class: 'peek-key', text: String(value) }),
|
|
122
|
+
]),
|
|
123
|
+
el('div', { class: 'peek-body' }, lines),
|
|
124
|
+
/* Said once, at the foot: the card is a glance, and clicking the chip is
|
|
125
|
+
the walk. Without it the card looks like something you ought to be able
|
|
126
|
+
to click into. */
|
|
127
|
+
el('div', { class: 'peek-foot', text: 'click to open' }),
|
|
128
|
+
]);
|
|
129
|
+
/* The native tooltip has to go while the card is up.
|
|
130
|
+
|
|
131
|
+
A reference carries a `title` — "→ ledger_account", or a number's
|
|
132
|
+
unformatted value — which is a good cheap hint and a bad companion: the
|
|
133
|
+
browser draws it wherever it likes, about a second after the card has
|
|
134
|
+
already answered the same question, and lands it across the card's own
|
|
135
|
+
heading.
|
|
136
|
+
|
|
137
|
+
Taken off the anchor *and* anything inside it, because the two are not
|
|
138
|
+
the same element: the peek is anchored to the cell, since that is what
|
|
139
|
+
the reader is pointing at and what says which edge to hang from, while
|
|
140
|
+
the title belongs to the chip within it. Only what was actually there is
|
|
141
|
+
put back — a cell with no title should not acquire an empty one for
|
|
142
|
+
having been looked at. */
|
|
143
|
+
for (const node of [anchor, ...anchor.querySelectorAll('[title]')]) {
|
|
144
|
+
const title = node.getAttribute('title');
|
|
145
|
+
if (title === null) continue;
|
|
146
|
+
suppressed.push([node, title]);
|
|
147
|
+
node.removeAttribute('title');
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
document.body.append(card);
|
|
151
|
+
place(anchor, card);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Put the card against the value, and inside the window.
|
|
156
|
+
*
|
|
157
|
+
* Against the *value*, not the cell. A numeric foreign key is right-aligned
|
|
158
|
+
* in a column that may be sixty characters wide, so anchoring to the cell's
|
|
159
|
+
* left edge put the card most of a screen away from the number that summoned
|
|
160
|
+
* it — near enough to be the tooltip of something else. The cell's own text
|
|
161
|
+
* alignment says which edge the reader is looking at, so the card is hung
|
|
162
|
+
* from that edge.
|
|
163
|
+
*
|
|
164
|
+
* Below by default, because a card above the pointer covers the row being
|
|
165
|
+
* read. It flips up only when there is no room below, and is nudged sideways
|
|
166
|
+
* rather than allowed off the edge — a card whose left half is outside the
|
|
167
|
+
* viewport is worse than no card.
|
|
168
|
+
*/
|
|
169
|
+
function place(anchor, node) {
|
|
170
|
+
const at = anchor.getBoundingClientRect();
|
|
171
|
+
const box = node.getBoundingClientRect();
|
|
172
|
+
const margin = 8;
|
|
173
|
+
|
|
174
|
+
let top = at.bottom + 4;
|
|
175
|
+
if (top + box.height > window.innerHeight - margin) {
|
|
176
|
+
const above = at.top - box.height - 4;
|
|
177
|
+
top = above > margin ? above : Math.max(margin, window.innerHeight - box.height - margin);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const align = getComputedStyle(anchor).textAlign;
|
|
181
|
+
const wanted = align === 'right' || align === 'end'
|
|
182
|
+
? at.right - box.width
|
|
183
|
+
: align === 'center'
|
|
184
|
+
? at.left + at.width / 2 - box.width / 2
|
|
185
|
+
: at.left;
|
|
186
|
+
|
|
187
|
+
const left = Math.min(
|
|
188
|
+
Math.max(margin, wanted),
|
|
189
|
+
Math.max(margin, window.innerWidth - box.width - margin),
|
|
190
|
+
);
|
|
191
|
+
|
|
192
|
+
node.style.top = `${Math.round(top)}px`;
|
|
193
|
+
node.style.left = `${Math.round(left)}px`;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Start peeking, if what was entered is a reference.
|
|
198
|
+
*
|
|
199
|
+
* Delegated from the document rather than bound per chip: a table redraws its
|
|
200
|
+
* rows on every page, sort and filter, and a virtualised one replaces them as
|
|
201
|
+
* you scroll — so a listener per chip is thousands of registrations with no
|
|
202
|
+
* one place to take them off again.
|
|
203
|
+
*/
|
|
204
|
+
function onEnter(e) {
|
|
205
|
+
const chip = e.target.closest?.('[data-ref-table]');
|
|
206
|
+
if (!chip) return;
|
|
207
|
+
const table = findTable(chip.dataset.refTable);
|
|
208
|
+
const column = chip.dataset.refColumn;
|
|
209
|
+
const value = chip.dataset.refValue;
|
|
210
|
+
if (!table || !column || !value) return;
|
|
211
|
+
|
|
212
|
+
clearTimeout(timer);
|
|
213
|
+
const mine = ++generation;
|
|
214
|
+
timer = setTimeout(async () => {
|
|
215
|
+
try {
|
|
216
|
+
const row = await rowFor(table, column, value);
|
|
217
|
+
// The pointer has moved on, or a later hover has started: this answer
|
|
218
|
+
// is about something nobody is looking at any more.
|
|
219
|
+
if (mine !== generation || !row || !chip.isConnected) return;
|
|
220
|
+
draw(chip, table, value, row);
|
|
221
|
+
} catch {
|
|
222
|
+
/* A reference pointing at a row that is not there is a fact about the
|
|
223
|
+
database, and one tablewalk reports honestly elsewhere. It is not
|
|
224
|
+
worth a popup on the way past. */
|
|
225
|
+
}
|
|
226
|
+
}, SETTLE_MS);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function onLeave(e) {
|
|
230
|
+
if (e.target.closest?.('[data-ref-table]')) hide();
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
export function initPeek() {
|
|
234
|
+
document.addEventListener('mouseover', onEnter);
|
|
235
|
+
document.addEventListener('mouseout', onLeave);
|
|
236
|
+
/* Focus opens it too, so the card is reachable without a pointer — the grid
|
|
237
|
+
cursor moves through these cells, and a keyboard user should get the same
|
|
238
|
+
answer a mouse user gets. */
|
|
239
|
+
document.addEventListener('focusin', onEnter);
|
|
240
|
+
document.addEventListener('focusout', onLeave);
|
|
241
|
+
document.addEventListener('keydown', (e) => { if (e.key === 'Escape') hide(); });
|
|
242
|
+
// Anything that moves the page out from under the card invalidates it.
|
|
243
|
+
window.addEventListener('scroll', hide, true);
|
|
244
|
+
window.addEventListener('resize', hide);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** Mark an element as a reference worth peeking at. */
|
|
248
|
+
export function peekable(node, references, value) {
|
|
249
|
+
if (!references || value === null || value === undefined || value === '') return node;
|
|
250
|
+
node.dataset.refTable = references.table;
|
|
251
|
+
node.dataset.refColumn = references.column;
|
|
252
|
+
node.dataset.refValue = String(value);
|
|
253
|
+
return node;
|
|
254
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A searchable picker: type to narrow, arrow to choose.
|
|
3
|
+
*
|
|
4
|
+
* This exists because `<datalist>` does not. It is the right *idea* — a text
|
|
5
|
+
* field with a known set of answers behind it — but the browser draws its menu
|
|
6
|
+
* as a small system tooltip in the system font, ignoring the page, sized to
|
|
7
|
+
* nothing, and its matching is a plain prefix test. Next to a themed page it
|
|
8
|
+
* reads as a browser artefact rather than part of the interface.
|
|
9
|
+
*
|
|
10
|
+
* So the field stays a field and the menu is ours: same ranking as the command
|
|
11
|
+
* palette, so `pol` finds `purchase_order_line` here too, and the same
|
|
12
|
+
* keyboard contract as every other list in this app — arrows move, Enter
|
|
13
|
+
* picks, Escape closes.
|
|
14
|
+
*
|
|
15
|
+
* The menu is positioned in the viewport rather than in the flow, because the
|
|
16
|
+
* panels these live in scroll and clip, and a menu cut in half by its own
|
|
17
|
+
* container is worse than no menu.
|
|
18
|
+
*/
|
|
19
|
+
import { el } from './core.js';
|
|
20
|
+
import { score } from './palette.js';
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* @param {object} options
|
|
24
|
+
* @param {Array<{value: string, label: string, detail?: string}>} options.items
|
|
25
|
+
* @param {(value: string) => void} options.onPick
|
|
26
|
+
* @param {string} [options.placeholder]
|
|
27
|
+
* @param {string} [options.ariaLabel]
|
|
28
|
+
* @param {number} [options.max] how many suggestions to show at once
|
|
29
|
+
*/
|
|
30
|
+
export function picker({ items, onPick, placeholder = 'Search…', ariaLabel = 'Search', max = 8 }) {
|
|
31
|
+
const input = el('input', {
|
|
32
|
+
type: 'text',
|
|
33
|
+
class: 'picker-input',
|
|
34
|
+
placeholder,
|
|
35
|
+
'aria-label': ariaLabel,
|
|
36
|
+
autocomplete: 'off',
|
|
37
|
+
spellcheck: 'false',
|
|
38
|
+
role: 'combobox',
|
|
39
|
+
'aria-expanded': 'false',
|
|
40
|
+
'aria-autocomplete': 'list',
|
|
41
|
+
});
|
|
42
|
+
const list = el('div', { class: 'picker-list', role: 'listbox', hidden: true });
|
|
43
|
+
const root = el('div', { class: 'picker' }, [input, list]);
|
|
44
|
+
|
|
45
|
+
let shown = [];
|
|
46
|
+
let active = 0;
|
|
47
|
+
|
|
48
|
+
const rank = () => {
|
|
49
|
+
const query = input.value.trim();
|
|
50
|
+
shown = items
|
|
51
|
+
.map((item) => ({ item, points: score(`${item.label} ${item.detail ?? ''}`.trim(), query) }))
|
|
52
|
+
.filter(({ points }) => points > 0)
|
|
53
|
+
.sort((a, b) => b.points - a.points)
|
|
54
|
+
.slice(0, max)
|
|
55
|
+
.map(({ item }) => item);
|
|
56
|
+
active = 0;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const paint = () => {
|
|
60
|
+
if (!shown.length) {
|
|
61
|
+
list.replaceChildren(el('p', { class: 'picker-empty', text: 'No table matches.' }));
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
list.replaceChildren(...shown.map((item, i) => el('div', {
|
|
65
|
+
class: `picker-option${i === active ? ' active' : ''}`,
|
|
66
|
+
role: 'option',
|
|
67
|
+
'aria-selected': String(i === active),
|
|
68
|
+
// Pointerdown rather than click: the input is about to lose focus, and
|
|
69
|
+
// a blur handler that closed the list first would eat the click.
|
|
70
|
+
onpointerdown: (e) => { e.preventDefault(); choose(i); },
|
|
71
|
+
onmousemove: () => setActive(i),
|
|
72
|
+
}, [
|
|
73
|
+
el('span', { class: 'picker-label', text: item.label }),
|
|
74
|
+
item.detail ? el('span', { class: 'picker-detail', text: item.detail }) : null,
|
|
75
|
+
].filter(Boolean))));
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
const setActive = (index) => {
|
|
79
|
+
if (!shown.length) return;
|
|
80
|
+
active = (index + shown.length) % shown.length;
|
|
81
|
+
[...list.children].forEach((node, i) => {
|
|
82
|
+
node.classList.toggle('active', i === active);
|
|
83
|
+
node.setAttribute('aria-selected', String(i === active));
|
|
84
|
+
});
|
|
85
|
+
list.children[active]?.scrollIntoView({ block: 'nearest' });
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
function position() {
|
|
89
|
+
const box = input.getBoundingClientRect();
|
|
90
|
+
list.style.position = 'fixed';
|
|
91
|
+
list.style.minWidth = `${box.width}px`;
|
|
92
|
+
list.style.left = `${Math.max(8, Math.min(box.left, window.innerWidth - box.width - 12))}px`;
|
|
93
|
+
list.style.top = `${box.bottom + 4}px`;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const open = () => {
|
|
97
|
+
rank();
|
|
98
|
+
paint();
|
|
99
|
+
list.hidden = false;
|
|
100
|
+
input.setAttribute('aria-expanded', 'true');
|
|
101
|
+
position();
|
|
102
|
+
window.addEventListener('scroll', close, true);
|
|
103
|
+
window.addEventListener('resize', close);
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
function close() {
|
|
107
|
+
if (list.hidden) return;
|
|
108
|
+
list.hidden = true;
|
|
109
|
+
input.setAttribute('aria-expanded', 'false');
|
|
110
|
+
window.removeEventListener('scroll', close, true);
|
|
111
|
+
window.removeEventListener('resize', close);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const choose = (index) => {
|
|
115
|
+
const item = shown[index];
|
|
116
|
+
if (!item) return;
|
|
117
|
+
input.value = '';
|
|
118
|
+
close();
|
|
119
|
+
onPick(item.value);
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
input.addEventListener('input', () => { open(); });
|
|
123
|
+
input.addEventListener('focus', () => open());
|
|
124
|
+
input.addEventListener('blur', () => setTimeout(close, 80));
|
|
125
|
+
input.addEventListener('keydown', (e) => {
|
|
126
|
+
switch (e.key) {
|
|
127
|
+
case 'ArrowDown': e.preventDefault(); if (list.hidden) open(); else setActive(active + 1); return;
|
|
128
|
+
case 'ArrowUp': e.preventDefault(); setActive(active - 1); return;
|
|
129
|
+
case 'Enter': e.preventDefault(); choose(active); return;
|
|
130
|
+
case 'Escape':
|
|
131
|
+
// Closes the list, not the view behind it.
|
|
132
|
+
if (!list.hidden) { e.preventDefault(); e.stopPropagation(); close(); }
|
|
133
|
+
return;
|
|
134
|
+
default:
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
return root;
|
|
139
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Columns that stay put when a wide table scrolls sideways.
|
|
3
|
+
*
|
|
4
|
+
* A twenty-seven column table is wider than any window, so reading the right
|
|
5
|
+
* of it means scrolling the left of it away — and the left of it is where the
|
|
6
|
+
* key and the name live. Three columns in you are looking at values with
|
|
7
|
+
* nothing to attach them to, and the only way back is to scroll left, read the
|
|
8
|
+
* name, and scroll right again holding it in your head.
|
|
9
|
+
*
|
|
10
|
+
* Pinning is the fix every spreadsheet arrived at, and the reason it belongs
|
|
11
|
+
* here rather than in a preference nobody finds is that tablewalk already knows
|
|
12
|
+
* which columns identify a row: the primary key, and the label column it uses
|
|
13
|
+
* for breadcrumbs. Those are pinned without being asked for, on tables wide
|
|
14
|
+
* enough to need it, and anything else is one click in the column menu.
|
|
15
|
+
*/
|
|
16
|
+
import { labelColumn, loadJson, primaryKey, saveJson, state } from './core.js';
|
|
17
|
+
|
|
18
|
+
const KEY = 'tablewalk.pins.v1';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Below this many columns, pinning is a border and a shadow for no gain.
|
|
22
|
+
*
|
|
23
|
+
* The number is about the *window* rather than the table: eight columns of
|
|
24
|
+
* ordinary width fit, and the ones that do not are exactly the tables where
|
|
25
|
+
* losing the key hurts.
|
|
26
|
+
*/
|
|
27
|
+
const WIDE_ENOUGH = 8;
|
|
28
|
+
|
|
29
|
+
const connectionKey = () => state.activeConnection ?? 'default';
|
|
30
|
+
|
|
31
|
+
function stored() {
|
|
32
|
+
return loadJson(KEY, {})[connectionKey()] ?? {};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The columns pinned for a table: what was chosen, or what identifies a row.
|
|
37
|
+
*
|
|
38
|
+
* The derived default is not written to storage. Storing it would freeze
|
|
39
|
+
* today's guess against a schema that may gain a better label column, and it
|
|
40
|
+
* would make "I have never touched this" indistinguishable from "I chose
|
|
41
|
+
* exactly this".
|
|
42
|
+
*/
|
|
43
|
+
export function pinnedFor(table, columns) {
|
|
44
|
+
if (!table) return [];
|
|
45
|
+
const chosen = stored()[table.id];
|
|
46
|
+
if (chosen) return chosen.filter((c) => columns.includes(c));
|
|
47
|
+
if (columns.length < WIDE_ENOUGH) return [];
|
|
48
|
+
|
|
49
|
+
const key = primaryKey(table);
|
|
50
|
+
const label = labelColumn(table);
|
|
51
|
+
const wanted = [...key, ...(label && !key.includes(label) ? [label] : [])];
|
|
52
|
+
return wanted.filter((c) => columns.includes(c));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Whether this table has ever been decided about, for the menu's wording. */
|
|
56
|
+
export const isChosen = (table) => Boolean(table && stored()[table.id]);
|
|
57
|
+
|
|
58
|
+
export function togglePin(table, columns, name) {
|
|
59
|
+
if (!table) return;
|
|
60
|
+
const current = pinnedFor(table, columns);
|
|
61
|
+
const next = current.includes(name)
|
|
62
|
+
? current.filter((c) => c !== name)
|
|
63
|
+
: [...current, name];
|
|
64
|
+
|
|
65
|
+
/* Kept in column order rather than the order they were pinned in. Pinned
|
|
66
|
+
columns are rendered as a block on the left, and a block whose internal
|
|
67
|
+
order differs from the table's is a second thing to reconcile every time
|
|
68
|
+
you read it. */
|
|
69
|
+
const ordered = columns.filter((c) => next.includes(c));
|
|
70
|
+
|
|
71
|
+
const all = loadJson(KEY, {});
|
|
72
|
+
all[connectionKey()] = { ...(all[connectionKey()] ?? {}), [table.id]: ordered };
|
|
73
|
+
saveJson(KEY, all);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Freeze the pinned columns in place, measured rather than assumed.
|
|
78
|
+
*
|
|
79
|
+
* `position: sticky` needs a `left` for each column, and the offsets are the
|
|
80
|
+
* widths of the columns before it — which are not known until the browser has
|
|
81
|
+
* laid the table out. Measuring after render is the only honest way to get
|
|
82
|
+
* them; guessing produces a gap or an overlap at every zoom level.
|
|
83
|
+
*
|
|
84
|
+
* Called again on resize by the caller, because a column's width follows its
|
|
85
|
+
* content and its content follows the window.
|
|
86
|
+
*/
|
|
87
|
+
export function applyPins(wrap, columns, pinned) {
|
|
88
|
+
const table = wrap.querySelector('table');
|
|
89
|
+
if (!table || !pinned.length) return;
|
|
90
|
+
|
|
91
|
+
const head = table.tHead?.rows[0];
|
|
92
|
+
if (!head) return;
|
|
93
|
+
|
|
94
|
+
/* Cell indexes, not column indexes. An expander column is a cell with no
|
|
95
|
+
name in `columns`, so the two are offset by one wherever it is present —
|
|
96
|
+
and freezing the wrong cells is worse than freezing none. */
|
|
97
|
+
const shift = head.cells.length - columns.length;
|
|
98
|
+
const indexes = pinned
|
|
99
|
+
.map((name) => columns.indexOf(name))
|
|
100
|
+
.filter((i) => i >= 0)
|
|
101
|
+
.map((i) => i + shift)
|
|
102
|
+
.sort((a, b) => a - b);
|
|
103
|
+
|
|
104
|
+
/* Rounded *down*, so consecutive frozen columns overlap by a fraction of a
|
|
105
|
+
pixel rather than leaving one. Column widths are fractional — 82.0234px —
|
|
106
|
+
and a `left` that lands after the previous column ends opens a hairline
|
|
107
|
+
through which the scrolling content behind shows as a flickering sliver. An
|
|
108
|
+
overlap of the same size is invisible. */
|
|
109
|
+
let offset = 0;
|
|
110
|
+
const lefts = new Map();
|
|
111
|
+
for (const i of indexes) {
|
|
112
|
+
lefts.set(i, Math.floor(offset));
|
|
113
|
+
offset += head.cells[i]?.getBoundingClientRect().width ?? 0;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/* The seam appears only once there is something behind it. An edge shadow on
|
|
117
|
+
an unscrolled table draws a line through the middle of it for no reason. */
|
|
118
|
+
if (!wrap.dataset.pinWatch) {
|
|
119
|
+
wrap.dataset.pinWatch = '1';
|
|
120
|
+
const mark = () => {
|
|
121
|
+
if (wrap.scrollLeft > 0) wrap.dataset.scrolled = '1';
|
|
122
|
+
else delete wrap.dataset.scrolled;
|
|
123
|
+
};
|
|
124
|
+
wrap.addEventListener('scroll', mark, { passive: true });
|
|
125
|
+
mark();
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const last = indexes[indexes.length - 1];
|
|
129
|
+
for (const row of table.rows) {
|
|
130
|
+
for (const i of indexes) {
|
|
131
|
+
const cell = row.cells[i];
|
|
132
|
+
if (!cell) continue;
|
|
133
|
+
cell.classList.add('pinned');
|
|
134
|
+
// The rightmost pinned column carries the edge, so the seam between
|
|
135
|
+
// frozen and scrolling is one line rather than one per column.
|
|
136
|
+
cell.classList.toggle('pinned-edge', i === last);
|
|
137
|
+
cell.style.left = `${lefts.get(i)}px`;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A themed replacement for `window.prompt`.
|
|
3
|
+
*
|
|
4
|
+
* The native one cannot be styled at all: it is drawn by the browser, says
|
|
5
|
+
* "localhost:4111 says" above whatever you asked, and appears in the system
|
|
6
|
+
* font on the system chrome regardless of the page. It also blocks the main
|
|
7
|
+
* thread, which is why it is the one dialog that cannot animate or be
|
|
8
|
+
* dismissed by clicking away.
|
|
9
|
+
*
|
|
10
|
+
* `<dialog>` gives back everything that matters — modal focus trapping,
|
|
11
|
+
* Escape to close, backdrop — so this only has to supply the shape.
|
|
12
|
+
*/
|
|
13
|
+
import { el } from './core.js';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* @returns {Promise<string|null>} the trimmed value, or null if cancelled.
|
|
17
|
+
*/
|
|
18
|
+
export function promptFor({ title, label, value = '', hint, confirmLabel = 'Save', placeholder, type = 'text' }) {
|
|
19
|
+
return new Promise((resolve) => {
|
|
20
|
+
const input = el('input', {
|
|
21
|
+
/* `date` and `datetime-local` give the browser's own picker, which is
|
|
22
|
+
the difference between typing `2026-01-01` correctly and finding out
|
|
23
|
+
you did not. Everything else stays a text box: the query language
|
|
24
|
+
takes text, and a number input would refuse the `1e6` and the leading
|
|
25
|
+
zeroes that a database column is perfectly happy to hold. */
|
|
26
|
+
type,
|
|
27
|
+
class: 'cell-edit',
|
|
28
|
+
value,
|
|
29
|
+
placeholder: placeholder ?? '',
|
|
30
|
+
'aria-label': label,
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
let settled = false;
|
|
34
|
+
const finish = (result) => {
|
|
35
|
+
if (settled) return;
|
|
36
|
+
settled = true;
|
|
37
|
+
resolve(result);
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const submit = () => {
|
|
41
|
+
const trimmed = input.value.trim();
|
|
42
|
+
if (!trimmed) {
|
|
43
|
+
input.focus();
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
dialog.close();
|
|
47
|
+
finish(trimmed);
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const form = el('form', { method: 'dialog', onsubmit: (e) => { e.preventDefault(); submit(); } }, [
|
|
51
|
+
el('h2', { text: title }),
|
|
52
|
+
el('label', { class: 'field-stack' }, [el('span', { text: label }), input]),
|
|
53
|
+
hint ? el('p', { class: 'confirm-params', text: hint }) : null,
|
|
54
|
+
el('div', { class: 'confirm-actions' }, [
|
|
55
|
+
el('button', { type: 'button', class: 'ghost', text: 'Cancel', onclick: () => { dialog.close(); finish(null); } }),
|
|
56
|
+
el('button', { type: 'submit', text: confirmLabel }),
|
|
57
|
+
]),
|
|
58
|
+
]);
|
|
59
|
+
|
|
60
|
+
const dialog = el('dialog', { class: 'confirm' }, form);
|
|
61
|
+
// Covers Escape and any other close path, so cancelling always resolves.
|
|
62
|
+
dialog.addEventListener('close', () => { finish(null); dialog.remove(); });
|
|
63
|
+
document.body.append(dialog);
|
|
64
|
+
dialog.showModal();
|
|
65
|
+
input.focus();
|
|
66
|
+
input.select();
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Two values at once, for `between`.
|
|
72
|
+
*
|
|
73
|
+
* A range asked as two separate prompts is two chances to cancel halfway and
|
|
74
|
+
* a filter that means something different from what was intended if you do.
|
|
75
|
+
* It is one question — "from where to where" — so it is one dialog.
|
|
76
|
+
*
|
|
77
|
+
* Resolves `{ from, to }`, or null if cancelled. Both are required: `between`
|
|
78
|
+
* with one end is a comparison, and the menu already offers those.
|
|
79
|
+
*
|
|
80
|
+
* @returns {Promise<{from: string, to: string}|null>}
|
|
81
|
+
*/
|
|
82
|
+
export function promptForRange({ title, fromLabel, toLabel, hint, type = 'text', placeholder }) {
|
|
83
|
+
return new Promise((resolve) => {
|
|
84
|
+
const field = (label) => el('input', {
|
|
85
|
+
type,
|
|
86
|
+
class: 'cell-edit',
|
|
87
|
+
placeholder: placeholder ?? '',
|
|
88
|
+
'aria-label': label,
|
|
89
|
+
});
|
|
90
|
+
const from = field(fromLabel);
|
|
91
|
+
const to = field(toLabel);
|
|
92
|
+
|
|
93
|
+
let settled = false;
|
|
94
|
+
const finish = (result) => {
|
|
95
|
+
if (settled) return;
|
|
96
|
+
settled = true;
|
|
97
|
+
resolve(result);
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
const submit = () => {
|
|
101
|
+
const a = from.value.trim();
|
|
102
|
+
const b = to.value.trim();
|
|
103
|
+
/* Focus the end that is missing rather than refusing as a whole: with
|
|
104
|
+
two fields, "this form is incomplete" is a worse message than putting
|
|
105
|
+
the caret where the work is. */
|
|
106
|
+
if (!a) return from.focus();
|
|
107
|
+
if (!b) return to.focus();
|
|
108
|
+
dialog.close();
|
|
109
|
+
finish({ from: a, to: b });
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
const form = el('form', { method: 'dialog', onsubmit: (e) => { e.preventDefault(); submit(); } }, [
|
|
113
|
+
el('h2', { text: title }),
|
|
114
|
+
el('label', { class: 'field-stack' }, [el('span', { text: fromLabel }), from]),
|
|
115
|
+
el('label', { class: 'field-stack' }, [el('span', { text: toLabel }), to]),
|
|
116
|
+
hint ? el('p', { class: 'confirm-params', text: hint }) : null,
|
|
117
|
+
el('div', { class: 'confirm-actions' }, [
|
|
118
|
+
el('button', { type: 'button', class: 'ghost', text: 'Cancel', onclick: () => { dialog.close(); finish(null); } }),
|
|
119
|
+
el('button', { type: 'submit', text: 'Apply' }),
|
|
120
|
+
]),
|
|
121
|
+
]);
|
|
122
|
+
|
|
123
|
+
const dialog = el('dialog', { class: 'confirm' }, form);
|
|
124
|
+
dialog.addEventListener('close', () => { finish(null); dialog.remove(); });
|
|
125
|
+
document.body.append(dialog);
|
|
126
|
+
dialog.showModal();
|
|
127
|
+
from.focus();
|
|
128
|
+
});
|
|
129
|
+
}
|