pgkiln 0.85.0 → 0.87.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/db/migrations/155_mail_available.sql +23 -0
  2. package/docs/apex-feature-parity.md +3 -3
  3. package/docs/guide/04-pages-and-regions.md +24 -5
  4. package/docs/guide/05-items.md +20 -1
  5. package/docs/guide/06-processing.md +7 -3
  6. package/docs/guide/07-dynamic-actions.md +3 -3
  7. package/docs/guide/08-security.md +6 -2
  8. package/docs/guide/09-reference.md +1 -0
  9. package/docs/guide/11-from-apex.md +3 -0
  10. package/docs/guide/12-development.md +4 -0
  11. package/examples/gallery/README.md +23 -6
  12. package/examples/gallery/gallery.json +4 -0
  13. package/examples/gallery/samples/sample-approvals.sql +102 -11
  14. package/examples/gallery/samples/sample-calendar.sql +40 -5
  15. package/examples/gallery/samples/sample-dynamic-actions.sql +240 -9
  16. package/examples/gallery/samples/sample-grids.sql +16 -4
  17. package/examples/gallery/samples/sample-maps.sql +65 -7
  18. package/examples/gallery/samples/sample-processes.sql +466 -0
  19. package/examples/gallery/samples/sample-validations.sql +407 -0
  20. package/examples/gallery/samples/ux-patterns.sql +154 -14
  21. package/examples/hr/README.md +2 -0
  22. package/examples/hr/hr_80_notifications.sql +125 -0
  23. package/examples/hr/hr_81_vector_search_type.sql +33 -0
  24. package/package.json +1 -1
  25. package/public/app.css +10 -1
  26. package/public/app.js +30 -9
  27. package/src/builder/components.ts +1 -1
  28. package/src/builder/region-help.ts +2 -2
  29. package/src/runtime/authz.ts +7 -0
  30. package/src/runtime/engine.ts +6 -3
  31. package/src/runtime/grid.ts +69 -9
  32. package/src/runtime/items.ts +9 -1
  33. package/src/runtime/lists.ts +2 -2
  34. package/src/runtime/push.ts +7 -1
  35. package/src/runtime/regions.ts +25 -8
  36. package/src/runtime/signin.ts +5 -5
@@ -301,9 +301,10 @@ export async function validate(ctx: PageContext) {
301
301
  };
302
302
  try {
303
303
  const outcome = await validationPasses(ctx, v, value);
304
- if (outcome !== true) report(typeof outcome === 'string' ? outcome : v.message);
304
+ // (166) &ITEM. in the message, as in APEX (plain text: the message is escaped where it is shown)
305
+ if (outcome !== true) report(typeof outcome === 'string' ? outcome : substitute(v.message, ctx, (x) => x));
305
306
  } catch (e) {
306
- report(`${v.message} (${await publicError(ctx, e, `validation ${v.name}`)})`);
307
+ report(`${substitute(v.message, ctx, (x) => x)} (${await publicError(ctx, e, `validation ${v.name}`)})`);
307
308
  }
308
309
  }
309
310
 
@@ -683,7 +684,9 @@ export async function runProcesses(ctx: PageContext, point: 'submit' | 'load') {
683
684
  continue;
684
685
  }
685
686
  const msg = await runOne(ctx, p, names, 0);
686
- if (msg) messages.push(msg);
687
+ // (166) &ITEM. in a success message, with the values after the process (a new row's key, say), as in APEX;
688
+ // once here, so a chain's messages (joined by runOne) are filled in once
689
+ if (msg) messages.push(substitute(msg, ctx, (x) => x));
687
690
  }
688
691
  return messages;
689
692
  }
@@ -1,13 +1,13 @@
1
1
  import { componentSetting } from './component-settings.ts';
2
2
  import pg from 'pg';
3
- import { literal } from '../binds.ts';
3
+ import { applyBinds, literal } from '../binds.ts';
4
4
  import { savepoint, type Client } from '../db.ts';
5
5
  import { html, raw, type Raw } from '../html.ts';
6
6
  import { icon } from '../icons.ts';
7
7
  import type { Button, Process, Region } from '../metadata.ts';
8
8
  import { checksumValid, urlChecksum } from '../security.ts';
9
9
  import { isAuthorized, pageAllowed } from './authz.ts';
10
- import { publicError, toState, type PageContext } from './context.ts';
10
+ import { bindValues, publicError, stripSemicolon, substitute, toState, type PageContext } from './context.ts';
11
11
  import { arrange, cleanLayout, currentLayout, defaultLayout, layoutJson, layoutParam, MAX_FROZEN, MAX_WIDTH, MIN_WIDTH, parseLayout, type GridLayout, type Placed } from './grid-layout.ts';
12
12
  import { appDateMask, dateField, lovContains, lovOptions, type LovOption } from './items.ts';
13
13
  import { parseDateMask } from './format.ts';
@@ -22,7 +22,8 @@ import { GRID_VIEWS, renderView, type View } from './report-views.ts';
22
22
  //
23
23
  // config: {"page_size": 25,
24
24
  // "allow": {"insert": true, "update": true, "delete": true},
25
- // "columns": {"deptno": {"lov": "LOV:DEPARTMENTS"}, "sal": {"required": true}},
25
+ // "columns": {"deptno": {"lov": "LOV:DEPARTMENTS"}, "sal": {"required": true},
26
+ // "hiredate": {"default_sql": "select current_date"}, "job": {"default": "&P3_JOB."}},
26
27
  // "readonly": ["hiredate"],
27
28
  // "aggregates": {"sal": ["sum", "avg"], "empno": "count"},
28
29
  // "layout": {"order": ["ename", "sal"], "hidden": ["comm"], "widths": {"ename": 180}, "frozen": 1},
@@ -240,6 +241,14 @@ export async function renderGrid(ctx: PageContext, r: Region): Promise<Raw> {
240
241
  const insertable = savable && allow(r, 'insert') && cols.some((x) => writable.has(x.name));
241
242
  const deletable = savable && allow(r, 'delete');
242
243
  const g = `g${r.id}`;
244
+ // (167) the values a new row starts with (APEX: a column's Default)
245
+ let defaults: Record<number, string> = {};
246
+ if (insertable)
247
+ try {
248
+ defaults = await newRowDefaults(ctx, r, cols, writable);
249
+ } catch (e) {
250
+ return html`<div class="alert alert-error" role="alert">${await publicError(ctx, e, `grid "${r.title ?? r.id}" column defaults`)}</div>`;
251
+ }
243
252
  const posted = ctx.body && Object.keys(ctx.body).some((k) => k.startsWith(`${g}_`)) ? ctx.body : null;
244
253
  const postedByPk = new Map<string, number>();
245
254
  if (posted) for (const k of Object.keys(posted)) {
@@ -360,7 +369,10 @@ export async function renderGrid(ctx: PageContext, r: Region): Promise<Raw> {
360
369
  });
361
370
 
362
371
  // New rows: re-show posted ones after a failed save, a duplicated row, plus a blank template.
363
- const newRow = (j: number, values: Record<number, string> = {}) =>
372
+ // a new row posts the values it started with (_def): one left at them is blank, as an empty one (gridDml)
373
+ const defInput = (name: string, from: Record<number, string>) =>
374
+ Object.keys(from).length ? html`<input type="hidden" name="${name}" value="${JSON.stringify(from)}">` : '';
375
+ const newRow = (j: number, values: Record<number, string> = defaults, started: Record<number, string> = defaults) =>
364
376
  html`<tr class="grid-new" data-new-row="${j}">
365
377
  ${leads.map((k) => html`<td class="${leadCls(k)}" data-label="${k === 'sel' ? t('grid.new') : ''}">${k === 'sel' ? html`<span class="badge-pill">${t('grid.new')}</span>` : ''}</td>`)}
366
378
  ${placed.map((p, pIdx) => {
@@ -371,6 +383,7 @@ export async function renderGrid(ctx: PageContext, r: Region): Promise<Raw> {
371
383
  ? html`<td class="${colCls(p, pIdx)}" data-col="${col.ci}" data-label="${lbl}"${hiddenAttr(p)}>${control(ctx, { ...col, editable: true }, `${g}_n${j}_c${col.ci}`, values[col.ci] ?? '', `${lbl}, new row`)}</td>`
372
384
  : html`<td class="${colCls(p, pIdx)}" data-col="${col.ci}" data-label="${lbl}"${hiddenAttr(p)}></td>`;
373
385
  })}
386
+ ${Object.keys(started).length ? html`<td hidden>${defInput(`${g}_n${j}_def`, started)}</td>` : ''}
374
387
  </tr>`;
375
388
  const postedNew: Raw[] = [];
376
389
  const usedNew = posted ? newRowIndexes(posted, g) : [];
@@ -378,7 +391,8 @@ export async function renderGrid(ctx: PageContext, r: Region): Promise<Raw> {
378
391
  for (const j of usedNew) {
379
392
  const values: Record<number, string> = {};
380
393
  cols.forEach((col) => (values[col.ci] = pv(`${g}_n${j}_c${col.ci}`) ?? ''));
381
- if (Object.values(values).some((v) => v !== '' && v !== 'false')) postedNew.push(newRow(j, values));
394
+ const started = postedDefaults(pv(`${g}_n${j}_def`));
395
+ if (!blankRow(values, started)) postedNew.push(newRow(j, values, started));
382
396
  }
383
397
  let nextNew = usedNew.length ? Math.max(...usedNew) + 1 : 0;
384
398
  // Duplicate without JavaScript (r<id>_dup=<key>): a row of this page as a new row
@@ -387,7 +401,7 @@ export async function renderGrid(ctx: PageContext, r: Region): Promise<Raw> {
387
401
  if (dupRow) {
388
402
  const values: Record<number, string> = {};
389
403
  for (const col of cols) if (writable.has(col.name)) values[col.ci] = toState(dupRow[col.idx]) ?? '';
390
- postedNew.push(newRow(nextNew++, values));
404
+ postedNew.push(newRow(nextNew++, values, {}));
391
405
  }
392
406
 
393
407
  // The single row view: one row as a form, with the grid's field names (row 0, or new row 0)
@@ -440,9 +454,10 @@ export async function renderGrid(ctx: PageContext, r: Region): Promise<Raw> {
440
454
  const col = p.col;
441
455
  if (masterCol && col.name === masterCol) return [field(p, masterValue, false)];
442
456
  if (!writable.has(col.name)) return [];
443
- return [field(p, control(ctx, { ...col, editable: true }, `${g}_n0_c${col.ci}`, pv(`${g}_n0_c${col.ci}`) ?? '', label(col.name)), true)];
457
+ return [field(p, control(ctx, { ...col, editable: true }, `${g}_n0_c${col.ci}`, pv(`${g}_n0_c${col.ci}`) ?? defaults[col.ci] ?? '', label(col.name)), true)];
444
458
  });
445
- return html`<div class="grid-record grid-new" data-new-row="0"><div class="form-grid">${fieldsHtml}</div></div>
459
+ const started = pv(`${g}_n0_def`) !== undefined ? postedDefaults(pv(`${g}_n0_def`)) : defaults;
460
+ return html`<div class="grid-record grid-new" data-new-row="0"><div class="form-grid">${fieldsHtml}</div>${defInput(`${g}_n0_def`, started)}</div>
446
461
  ${recordNav()}`;
447
462
  };
448
463
 
@@ -681,6 +696,48 @@ export function layoutFromForm(b: Record<string, string | undefined>): GridLayou
681
696
  });
682
697
  }
683
698
 
699
+ /**
700
+ * (167) The values a new row starts with, by column position: config.columns.<col>.default (text with &ITEM.
701
+ * substitutions, e.g. "&P3_DEPTNO.") or .default_sql (a SELECT: its first column of the first row, with :BINDS),
702
+ * for the columns a new row may set.
703
+ */
704
+ async function newRowDefaults(ctx: PageContext, r: Region, cols: { name: string; ci: number }[], writable: Set<string>) {
705
+ const cfg = (r.config.columns ?? {}) as Record<string, { default?: unknown; default_sql?: unknown }>;
706
+ const out: Record<number, string> = {};
707
+ for (const col of cols) {
708
+ const c = cfg[col.name];
709
+ if (!c || !writable.has(col.name)) continue;
710
+ if (typeof c.default_sql === 'string' && c.default_sql.trim()) {
711
+ const sql = stripSemicolon(applyBinds(c.default_sql, bindValues(ctx)));
712
+ const client = ctx.client!;
713
+ const res = await savepoint(client, () => client.query({ text: sql, rowMode: 'array' }));
714
+ const v = toState(res.rows[0]?.[0]);
715
+ if (v !== null && v !== '') out[col.ci] = v;
716
+ } else if (typeof c.default === 'string' || typeof c.default === 'number' || typeof c.default === 'boolean') {
717
+ const v = substitute(String(c.default), ctx, (x) => x);
718
+ if (v !== '') out[col.ci] = v;
719
+ }
720
+ }
721
+ return out;
722
+ }
723
+
724
+ /** A new row's posted _def field: the values it started with (only short strings by column position are kept). */
725
+ function postedDefaults(raw: string | undefined): Record<number, string> {
726
+ if (!raw || raw.length > 20000) return {};
727
+ try {
728
+ const v = JSON.parse(raw) as unknown;
729
+ if (!v || typeof v !== 'object' || Array.isArray(v)) return {};
730
+ return Object.fromEntries(Object.entries(v).filter(([k, x]) => /^\d+$/.test(k) && typeof x === 'string'));
731
+ } catch {
732
+ return {};
733
+ }
734
+ }
735
+
736
+ /** A new row nobody filled in: every value empty (or an unticked checkbox) or still the value it started with. */
737
+ function blankRow(values: Record<number, string>, started: Record<number, string>) {
738
+ return Object.entries(values).every(([ci, v]) => v === '' || v === 'false' || (started[Number(ci)] !== undefined && v === started[Number(ci)]));
739
+ }
740
+
684
741
  function newRowIndexes(body: Record<string, unknown>, g: string) {
685
742
  const out = new Set<number>();
686
743
  for (const k of Object.keys(body)) {
@@ -797,12 +854,15 @@ export async function gridDml(ctx: PageContext, p: Process): Promise<string | nu
797
854
  if (allow(r, 'insert'))
798
855
  for (const j of newRowIndexes(body, g)) {
799
856
  const values: [string, string][] = [];
857
+ const posted: Record<number, string> = {};
800
858
  cols.forEach((col, ci) => {
801
859
  if (!writable.has(col.name)) return;
802
860
  const v = norm(ci, one(`${g}_n${j}_c${ci}`));
861
+ posted[ci] = v;
803
862
  if (v !== '' && !(col.dataTypeID === 16 && v === 'false')) values.push([col.name, v]);
804
863
  });
805
- if (!values.length) continue; // blank template row
864
+ // a blank template row, or one left at its default values (167)
865
+ if (!values.length || blankRow(posted, postedDefaults(one(`${g}_n${j}_def`)))) continue;
806
866
  const notOffered = [];
807
867
  for (const [n, v] of values) if (!(await offered(n, v))) notOffered.push(n);
808
868
  if (notOffered.length) {
@@ -260,8 +260,16 @@ function displayMap(ctx: PageContext, item: Item, value: string): Raw {
260
260
  </figure>`;
261
261
  }
262
262
 
263
+ /** (167) A hidden item the browser may set ({"value_protected": false}): a hidden input, its posted value taken like a field's. */
264
+ export const unprotectedHidden = (i: { type: string; config?: Record<string, any> | null }) => i.type === 'hidden' && i.config?.value_protected === false;
265
+
263
266
  export async function renderItem(ctx: PageContext, item: Item, hiddenByDa = false): Promise<Raw | ''> {
264
- if (item.type === 'hidden' || !ctx.vis!.items.has(item.name)) return '';
267
+ if (!ctx.vis!.items.has(item.name)) return '';
268
+ if (item.type === 'hidden') {
269
+ // (167) protected (the default): the value stays on the server; {"value_protected": false}: a hidden input
270
+ if (!unprotectedHidden(item)) return '';
271
+ return html`<input type="hidden" id="${item.name}" name="${item.name}" value="${ctx.session.state[item.name] ?? item.default_value ?? ''}" data-item="${item.name}">`;
272
+ }
265
273
  const value = ctx.session.state[item.name] ?? item.default_value ?? '';
266
274
  const id = item.name;
267
275
  const error = ctx.errors.items[item.name];
@@ -231,9 +231,9 @@ export function navbarMarkup(nodes: ListNode[], label: string, live?: string): R
231
231
  if (!nodes.length) return raw('');
232
232
  return html`<nav class="t-navbar" aria-label="${label}"><ul>${nodes.map((n) =>
233
233
  n.children.length
234
- ? html`<li><details class="menu"><summary${hasIcon(n) ? html` class="nb-icon" aria-label="${n.label}"` : ''}>${icon(n.icon)}<span>${n.label}</span>${badge(n, live)}</summary><div class="menu-panel align-right"><ul class="t-navbar-menu">${
234
+ ? html`<li><details class="menu"><summary${hasIcon(n) ? html` class="nb-icon"` : ''}>${icon(n.icon)}<span>${n.label}</span>${badge(n, live)}</summary><div class="menu-panel align-right"><ul class="t-navbar-menu">${
235
235
  [...(n.href ? [n] : []), ...n.children].map((c) => html`<li><a ${linkAttrs(c)}>${icon(c.icon)}<span>${c.label}</span>${badge(c, live)}</a></li>`)}</ul></div></details></li>`
236
- : html`<li><a ${linkAttrs(n, hasIcon(n) ? 'nb-icon' : undefined)}${hasIcon(n) ? html` aria-label="${n.label}"` : ''}>${icon(n.icon)}<span>${n.label}</span>${badge(n, live)}</a></li>`)}</ul></nav>`;
236
+ : html`<li><a ${linkAttrs(n, hasIcon(n) ? 'nb-icon' : undefined)}>${icon(n.icon)}<span>${n.label}</span>${badge(n, live)}</a></li>`)}</ul></nav>`;
237
237
  }
238
238
 
239
239
  function linksMarkup(nodes: ListNode[]): Raw {
@@ -12,6 +12,8 @@ import { loadContext, type Req } from './routes.ts';
12
12
  // action push_subscribe: both after a click, as browsers require) and posts
13
13
  // it here. The application's public key reaches the page as data-push.
14
14
 
15
+ let pushKeyWarned = false;
16
+
15
17
  /** Whether the app offers notifications. */
16
18
  export const pushOn = (a: Pick<App, 'pwa' | 'pwa_push'>) => a.pwa && a.pwa_push;
17
19
 
@@ -22,7 +24,11 @@ export async function pushBody(a: Pick<App, 'id' | 'pwa' | 'pwa_push'>, username
22
24
  return { 'data-push': await appPublicKey(a.id), 'data-push-user': username.toLowerCase() };
23
25
  } catch (e) {
24
26
  // no key yet and no PGKILN_SECRET_KEY to store one: the page works, without notifications
25
- console.error('push notifications:', (e as Error).message);
27
+ // (166) said once per server, not on every page
28
+ if (!pushKeyWarned) {
29
+ pushKeyWarned = true;
30
+ console.error('push notifications:', (e as Error).message);
31
+ }
26
32
  return {};
27
33
  }
28
34
  }
@@ -104,9 +104,11 @@ export async function renderButton(ctx: PageContext, b: Button) {
104
104
  return html`<button type="submit" class="${cls}" name="__request" value="${b.name}" data-button="${b.name}"${confirm}>${b.label}${badge}</button>`;
105
105
  }
106
106
 
107
- export async function buttonsFor(ctx: PageContext, regionId: number | null, only: (b: Button) => boolean = () => true) {
107
+ export async function buttonsFor(ctx: PageContext, regionId: number | null, only: (b: Button) => boolean = () => true, rank: (b: Button) => number = () => 0) {
108
108
  const out = [];
109
- for (const b of ctx.vis!.buttons.values()) if (b.region_id === regionId && b.id > 0 && only(b)) out.push(await renderButton(ctx, b));
109
+ // in sequence, within their rank (a stable sort: the buttons come in sequence order)
110
+ const shown = [...ctx.vis!.buttons.values()].filter((b) => b.region_id === regionId && b.id > 0 && only(b)).sort((a, b) => rank(a) - rank(b));
111
+ for (const b of shown) out.push(await renderButton(ctx, b));
110
112
  return out.some(Boolean) ? html`<div class="buttons">${out}</div>` : '';
111
113
  }
112
114
 
@@ -314,6 +316,9 @@ async function renderRegionNow(ctx: PageContext, r: Region, hidden: Set<string>)
314
316
  return regionShell(ctx, r, hidden, body);
315
317
  }
316
318
 
319
+ /** Region types that place their items themselves (and login, whose fields post to a form of their own). */
320
+ const ITEMS_IN_BODY = new Set(['report', 'classic_report', 'form', 'static', 'login']);
321
+
317
322
  /** The region's content (what the region's type renders), without its frame and buttons. */
318
323
  async function renderBody(ctx: PageContext, r: Region, hidden: Set<string>): Promise<Raw> {
319
324
  const items = ctx.page.items.filter((i) => i.region_id === r.id);
@@ -407,7 +412,7 @@ async function renderBody(ctx: PageContext, r: Region, hidden: Set<string>): Pro
407
412
  body = await renderSearch(ctx, r);
408
413
  break;
409
414
  case 'login':
410
- body = await renderLoginRegion(ctx);
415
+ body = await renderLoginRegion(ctx, r.config ?? {});
411
416
  break;
412
417
  case 'dynamic':
413
418
  // A SELECT returning HTML (like APEX "PL/SQL Dynamic Content"). The
@@ -425,6 +430,12 @@ async function renderBody(ctx: PageContext, r: Region, hidden: Set<string>): Pro
425
430
  break;
426
431
  }
427
432
  }
433
+ // (167) items placed in any other region (a tree, chart, map, cards …) are shown under its content, as in APEX;
434
+ // a login region's fields belong to a form of their own, so its items stay out
435
+ if (items.length && !ITEMS_IN_BODY.has(r.type) && !restFailed) {
436
+ const itemsHtml = await renderItems(ctx, items, hidden);
437
+ if (itemsHtml.length) body = html`${body!}<div class="form-grid region-items">${itemsHtml}</div>`;
438
+ }
428
439
  return body!;
429
440
  }
430
441
 
@@ -481,9 +492,12 @@ async function regionShell(ctx: PageContext, r: Region, hidden: Set<string>, bod
481
492
  </dialog>`;
482
493
 
483
494
  const header = html`<h2 id="${titleId}">${r.title}</h2>${buttonsOnTop ? buttons : ''}`;
495
+ // (166) the header buttons sit beside the summary, not in it: a summary is the toggle, and a button inside it is
496
+ // nested interactive content (axe); they come first so they are reached before the region's body
484
497
  if (r.template === 'collapsible')
485
- return html`<section class="${cls}" id="R${r.id}"${staticAttr}${hiddenAttr} aria-labelledby="${titleId}">
486
- <details open><summary class="region-header">${header}</summary>
498
+ return html`<section class="${cls}${buttonsOnTop && buttons ? ' has-header-buttons' : ''}" id="R${r.id}"${staticAttr}${hiddenAttr} aria-labelledby="${titleId}">
499
+ ${buttonsOnTop && buttons ? html`<div class="region-header-buttons">${buttons}</div>` : ''}
500
+ <details open><summary class="region-header"><h2 id="${titleId}">${r.title}</h2></summary>
487
501
  <div class="region-body"${scrollable(r)}>${body}</div>
488
502
  ${!buttonsOnTop && buttons ? html`<footer class="region-footer">${buttons}</footer>` : ''}
489
503
  </details></section>`;
@@ -508,7 +522,8 @@ async function slottedShell(ctx: PageContext, r: Region, hidden: Set<string>, bo
508
522
  const plain = (b: Button) => !b.slot || b.slot === 'region';
509
523
  const top = await buttonsFor(ctx, r.id, (b) => TOP_SLOTS.has(b.slot ?? '') || (onTop && plain(b)));
510
524
  const left = await buttonsFor(ctx, r.id, (b) => LEFT_SLOTS.has(b.slot ?? ''));
511
- const right = await buttonsFor(ctx, r.id, (b) => b.slot === 'next' || (!onTop && plain(b)));
525
+ // (167) Next is the last button of the bar, as in APEX: the region's own buttons come before it
526
+ const right = await buttonsFor(ctx, r.id, (b) => b.slot === 'next' || (!onTop && plain(b)), (b) => (b.slot === 'next' ? 1 : 0));
512
527
  const cls = `region region-${r.type} region-${r.template} col-${r.columns}${templateClasses('region', r.template_options)}`;
513
528
  const hiddenAttr = hidden.has(`R${r.id}`) ? raw(' hidden') : '';
514
529
  const staticAttr = r.static_id ? raw(` data-static-id="${esc(r.static_id)}"`) : '';
@@ -518,9 +533,11 @@ async function slottedShell(ctx: PageContext, r: Region, hidden: Set<string>, bo
518
533
  return html`<section class="${cls}" id="R${r.id}"${staticAttr}${hiddenAttr} aria-label="${r.title}">
519
534
  ${top ? html`<div class="region-toolbar">${top}</div>` : ''}${body}${footer}</section>`;
520
535
  const header = html`<h2 id="${titleId}">${r.title}</h2>${top}`;
536
+ // (166) as above: the header buttons beside the summary, not in it
521
537
  if (r.template === 'collapsible')
522
- return html`<section class="${cls}" id="R${r.id}"${staticAttr}${hiddenAttr} aria-labelledby="${titleId}">
523
- <details open><summary class="region-header">${header}</summary><div class="region-body"${scrollable(r)}>${body}</div>${footer}</details></section>`;
538
+ return html`<section class="${cls}${top ? ' has-header-buttons' : ''}" id="R${r.id}"${staticAttr}${hiddenAttr} aria-labelledby="${titleId}">
539
+ ${top ? html`<div class="region-header-buttons">${top}</div>` : ''}
540
+ <details open><summary class="region-header"><h2 id="${titleId}">${r.title}</h2></summary><div class="region-body"${scrollable(r)}>${body}</div>${footer}</details></section>`;
524
541
  return html`<section class="${cls}" id="R${r.id}"${staticAttr}${hiddenAttr}${r.title ? raw(` aria-labelledby="${titleId}"`) : ''}>
525
542
  ${r.title || top ? html`<header class="region-header">${header}</header>` : ''}
526
543
  <div class="region-body"${scrollable(r)}>${body}</div>${footer}
@@ -34,7 +34,7 @@ export const LOGIN_USER = '__LOGIN_USER';
34
34
 
35
35
  export async function signInForm(
36
36
  app: App, locale: Locale, session: Session, next: string,
37
- o: { error?: string; username?: string; formId?: string } = {},
37
+ o: { error?: string; username?: string; formId?: string; autofocus?: boolean } = {},
38
38
  ): Promise<Raw> {
39
39
  const t = locale.t;
40
40
  // database accounts and custom authentication: the user name and password form only
@@ -48,7 +48,7 @@ export async function signInForm(
48
48
  const fields = html`<input type="hidden" name="__csrf" value="${session.csrf_token}"${fa}>
49
49
  <input type="hidden" name="next" value="${next}"${fa}>
50
50
  ${app.time_zone_auto ? html`<input type="hidden" name="__tz" value=""${fa}>` : ''}
51
- <div class="field"><label class="label" for="username">${t('login.username')}</label><input id="username" name="username" value="${o.username ?? ''}" autocomplete="username" autofocus required maxlength="100"${fa}></div>
51
+ <div class="field"><label class="label" for="username">${t('login.username')}</label><input id="username" name="username" value="${o.username ?? ''}" autocomplete="username"${o.autofocus === false ? '' : raw(' autofocus')} required maxlength="100"${fa}></div>
52
52
  <div class="field"><label class="label" for="password">${t('login.password')}</label><input id="password" name="password" type="password" autocomplete="current-password" required maxlength="200"${fa}></div>
53
53
  ${remember ? html`<label class="check"><input type="checkbox" name="remember" value="true"${fa}> ${t('login.remember', { days: remember })}</label>` : ''}
54
54
  <button class="btn btn-hot"${fa}>${t('login.submit')}</button>
@@ -65,8 +65,8 @@ export async function signInForm(
65
65
  : providers.length ? '' : html`<p>${t('login.none')}</p>`}`;
66
66
  }
67
67
 
68
- /** The "login" region of an application's own login page. */
69
- export async function renderLoginRegion(ctx: PageContext): Promise<Raw> {
68
+ /** The "login" region of an application's own login page; (167) {"autofocus": false} leaves the focus where the page puts it. */
69
+ export async function renderLoginRegion(ctx: PageContext, config: { autofocus?: unknown } = {}): Promise<Raw> {
70
70
  const s = ctx.session.state;
71
71
  const error = s[LOGIN_ERROR] ?? undefined;
72
72
  const username = s[LOGIN_USER] ?? undefined;
@@ -76,6 +76,6 @@ export async function renderLoginRegion(ctx: PageContext): Promise<Raw> {
76
76
  ctx.detached.push(html`<form id="${formId}" method="post" action="/a/${ctx.app.alias}/login"></form>`);
77
77
  const next = safeNext(ctx.app, ctx.params.get('next') ?? undefined);
78
78
  const here = `${ctx.base}/${ctx.page.page_no}${ctx.params.get('next') ? `?next=${encodeURIComponent(next)}` : ''}`;
79
- return html`<div class="login-region">${await signInForm(ctx.app, ctx.locale, ctx.session, next, { error, username, formId })}
79
+ return html`<div class="login-region">${await signInForm(ctx.app, ctx.locale, ctx.session, next, { error, username, formId, autofocus: config.autofocus !== false })}
80
80
  ${languagePicker(ctx.app, ctx.locale, here)}</div>`;
81
81
  }