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.
Files changed (97) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +553 -0
  3. package/dist/adapters/adapter.js +372 -0
  4. package/dist/adapters/connect.js +33 -0
  5. package/dist/adapters/mysql.js +951 -0
  6. package/dist/adapters/postgres.js +1000 -0
  7. package/dist/adapters/sqlite.js +781 -0
  8. package/dist/client/agent.js +262 -0
  9. package/dist/client/app.js +973 -0
  10. package/dist/client/arrange.js +254 -0
  11. package/dist/client/ask.js +133 -0
  12. package/dist/client/breakdown.js +317 -0
  13. package/dist/client/clauses.js +390 -0
  14. package/dist/client/columns.js +98 -0
  15. package/dist/client/complete.js +437 -0
  16. package/dist/client/compose.js +166 -0
  17. package/dist/client/composer.css +495 -0
  18. package/dist/client/composer.js +1972 -0
  19. package/dist/client/connections.js +234 -0
  20. package/dist/client/connmanager.js +962 -0
  21. package/dist/client/connurl.js +188 -0
  22. package/dist/client/core.js +893 -0
  23. package/dist/client/deeplink.js +270 -0
  24. package/dist/client/delete.js +144 -0
  25. package/dist/client/diagram.js +885 -0
  26. package/dist/client/dropdown.js +279 -0
  27. package/dist/client/export.js +456 -0
  28. package/dist/client/features.css +524 -0
  29. package/dist/client/findvalue.js +169 -0
  30. package/dist/client/grid.js +205 -0
  31. package/dist/client/handoff.js +153 -0
  32. package/dist/client/help.css +145 -0
  33. package/dist/client/help.js +881 -0
  34. package/dist/client/history.js +222 -0
  35. package/dist/client/index.html +116 -0
  36. package/dist/client/insert.js +151 -0
  37. package/dist/client/menu.js +160 -0
  38. package/dist/client/nested.js +255 -0
  39. package/dist/client/page.css +713 -0
  40. package/dist/client/page.js +1345 -0
  41. package/dist/client/pagebuilder.js +1222 -0
  42. package/dist/client/pagemarks.js +95 -0
  43. package/dist/client/palette.js +374 -0
  44. package/dist/client/peek.js +254 -0
  45. package/dist/client/picker.js +139 -0
  46. package/dist/client/pins.js +140 -0
  47. package/dist/client/prompt.js +129 -0
  48. package/dist/client/record.js +707 -0
  49. package/dist/client/schemaexport.js +242 -0
  50. package/dist/client/schematext.js +125 -0
  51. package/dist/client/shape.js +178 -0
  52. package/dist/client/shapecheck.js +129 -0
  53. package/dist/client/skeleton.js +139 -0
  54. package/dist/client/sql.css +126 -0
  55. package/dist/client/sql.js +398 -0
  56. package/dist/client/sqlcomplete.js +163 -0
  57. package/dist/client/sqlsaved.js +107 -0
  58. package/dist/client/style.css +2711 -0
  59. package/dist/client/summary.js +259 -0
  60. package/dist/client/table.js +1035 -0
  61. package/dist/client/template.js +539 -0
  62. package/dist/client/theme.js +74 -0
  63. package/dist/client/tour.js +324 -0
  64. package/dist/client/undo.js +105 -0
  65. package/dist/client/url.js +166 -0
  66. package/dist/client/value.js +223 -0
  67. package/dist/client/views.js +215 -0
  68. package/dist/client/virtual.js +176 -0
  69. package/dist/client/welcome.js +170 -0
  70. package/dist/client/write.js +414 -0
  71. package/dist/server/changeimpact.js +195 -0
  72. package/dist/server/connections.js +615 -0
  73. package/dist/server/constraints.js +62 -0
  74. package/dist/server/credentials.js +230 -0
  75. package/dist/server/fixture.js +199 -0
  76. package/dist/server/graph.js +194 -0
  77. package/dist/server/impact.js +48 -0
  78. package/dist/server/index.js +2204 -0
  79. package/dist/server/journal.js +173 -0
  80. package/dist/server/layouts.js +128 -0
  81. package/dist/server/mcp.js +2840 -0
  82. package/dist/server/shapeonly.js +91 -0
  83. package/dist/shared/breakdown.js +231 -0
  84. package/dist/shared/breakdowntext.js +257 -0
  85. package/dist/shared/diff.js +130 -0
  86. package/dist/shared/like.js +29 -0
  87. package/dist/shared/lint.js +149 -0
  88. package/dist/shared/order.js +133 -0
  89. package/dist/shared/page.js +932 -0
  90. package/dist/shared/query.js +831 -0
  91. package/dist/shared/recordview.js +343 -0
  92. package/dist/shared/schema.js +377 -0
  93. package/dist/shared/sqlsaved.js +67 -0
  94. package/dist/shared/view.js +981 -0
  95. package/dist/shared/viewtext.js +273 -0
  96. package/dist/shared/vocabulary.js +164 -0
  97. 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
+ }