pgkiln 0.42.0 → 0.43.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 (52) hide show
  1. package/db/migrations/099_layout_positions.sql +29 -0
  2. package/docs/apex-feature-parity.md +34 -7
  3. package/docs/guide/03-builder.md +67 -13
  4. package/docs/guide/04-pages-and-regions.md +46 -3
  5. package/docs/guide/05-items.md +1 -1
  6. package/docs/guide/09-reference.md +4 -1
  7. package/docs/guide/12-development.md +2 -1
  8. package/package.json +1 -1
  9. package/public/app.css +58 -0
  10. package/public/app.js +183 -0
  11. package/public/builder.css +112 -0
  12. package/public/builder.js +280 -3
  13. package/src/builder/arrange.ts +133 -17
  14. package/src/builder/components.ts +28 -22
  15. package/src/builder/designer.ts +67 -18
  16. package/src/builder/forms.ts +4 -1
  17. package/src/builder/object-browser.ts +342 -0
  18. package/src/builder/pwa.ts +3 -3
  19. package/src/builder/routes.ts +85 -26
  20. package/src/builder/shared.ts +103 -1
  21. package/src/builder/sql.ts +13 -65
  22. package/src/builder/ui.ts +21 -6
  23. package/src/i18n/ar.ts +16 -0
  24. package/src/i18n/cs.ts +16 -0
  25. package/src/i18n/da.ts +16 -0
  26. package/src/i18n/de.ts +16 -0
  27. package/src/i18n/el.ts +16 -0
  28. package/src/i18n/es.ts +16 -0
  29. package/src/i18n/fi.ts +16 -0
  30. package/src/i18n/fr.ts +16 -0
  31. package/src/i18n/he.ts +16 -0
  32. package/src/i18n/it.ts +16 -0
  33. package/src/i18n/ja.ts +16 -0
  34. package/src/i18n/ko.ts +16 -0
  35. package/src/i18n/nb.ts +16 -0
  36. package/src/i18n/pl.ts +16 -0
  37. package/src/i18n/pt.ts +16 -0
  38. package/src/i18n/ru.ts +16 -0
  39. package/src/i18n/sv.ts +16 -0
  40. package/src/i18n/tr.ts +16 -0
  41. package/src/i18n/uk.ts +16 -0
  42. package/src/i18n/zh.ts +16 -0
  43. package/src/i18n.ts +32 -0
  44. package/src/metadata.ts +5 -1
  45. package/src/runtime/engine.ts +6 -0
  46. package/src/runtime/format.ts +52 -0
  47. package/src/runtime/items.ts +27 -0
  48. package/src/runtime/locale.ts +3 -0
  49. package/src/runtime/regions.ts +36 -2
  50. package/src/runtime/render.ts +20 -4
  51. package/src/runtime/report.ts +134 -79
  52. package/src/runtime/routes.ts +34 -3
package/src/metadata.ts CHANGED
@@ -154,6 +154,8 @@ export interface Region {
154
154
  rest_source?: string | null;
155
155
  /** (053) template options: CSS classes from REGION_OPTIONS (others are ignored) */
156
156
  template_options?: string[];
157
+ /** (099) where the region shows: banner, breadcrumb, left, body (null), right or footer */
158
+ position?: 'banner' | 'breadcrumb' | 'left' | 'body' | 'right' | 'footer' | null;
157
159
  }
158
160
 
159
161
  export type ItemType =
@@ -205,6 +207,8 @@ export interface Button {
205
207
  badge_query?: string | null;
206
208
  /** (053) template options: CSS classes from BUTTON_OPTIONS (others are ignored) */
207
209
  template_options?: string[];
210
+ /** (099) a region button's position: create, edit, change, copy, help, up, previous, close, delete, next or region (null) */
211
+ slot?: string | null;
208
212
  }
209
213
 
210
214
  export interface MenuEntry {
@@ -328,7 +332,7 @@ export interface Page extends PageSummary {
328
332
  /** (077) ask before leaving the page with unsaved changes */
329
333
  warn_unsaved: boolean;
330
334
  /** (087) 'minimal': no header and navigation */
331
- page_template: 'standard' | 'minimal';
335
+ page_template: 'standard' | 'minimal' | 'left_side_column' | 'right_side_column';
332
336
  }
333
337
 
334
338
  /** The static files a list of names refers to, in that order, with their version. */
@@ -198,6 +198,12 @@ export async function validate(ctx: PageContext) {
198
198
  const example = mask ? formatNumber('1234.5', mask, ctx.locale.numbers) : undefined;
199
199
  fail(i.name, example && !example.startsWith('#') ? ctx.locale.t('error.number_format', { label, example }) : ctx.locale.t('error.not_number', { label }));
200
200
  }
201
+ // (sprint 76) a date item holds a date (one in the application's date format was read back on submit)
202
+ for (const i of deleting ? [] : ctx.page.items) {
203
+ const v = state[i.name];
204
+ if (i.type !== 'date' || !v || !vis.editable.has(i.name) || errors.items[i.name] || /^\d{4}-\d{2}-\d{2}$/.test(v)) continue;
205
+ fail(i.name, ctx.locale.t('error.not_date', { label: i.label ?? i.name }));
206
+ }
201
207
  for (const i of deleting ? [] : ctx.page.items) {
202
208
  const v = state[i.name];
203
209
  if (i.type !== 'location' || !v || !vis.editable.has(i.name) || errors.items[i.name]) continue;
@@ -74,6 +74,58 @@ export function applyMask(value: string, mask: string, lang: string) {
74
74
  });
75
75
  }
76
76
 
77
+ /** (sprint 76) The month names of a language (long and short), for the date picker. */
78
+ export function monthNames(lang: string) {
79
+ const n = localeNames(lang);
80
+ return { long: n.months, short: n.monthsShort };
81
+ }
82
+
83
+ /**
84
+ * (sprint 76) Read a date typed in a date mask back as "YYYY-MM-DD": YYYY, YY (2000–2099), MM, DD, MON and
85
+ * MONTH (the language's names, any case), DY and DAY (names, not checked), quoted text and other characters as
86
+ * they are. An ISO date is read too. Null when the text doesn't fit the mask or isn't a real date.
87
+ */
88
+ export function parseDateMask(value: string, mask: string, lang: string): string | null {
89
+ const v = value.trim();
90
+ const iso = /^(\d{4})-(\d{2})-(\d{2})$/.exec(v);
91
+ const n = localeNames(lang);
92
+ let y: number | undefined, mo: number | undefined, d: number | undefined;
93
+ if (iso) [y, mo, d] = iso.slice(1).map(Number);
94
+ else {
95
+ const parts: string[] = [];
96
+ const groups: string[] = [];
97
+ let last = 0;
98
+ const esc = (x: string) => x.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
99
+ for (const m of mask.matchAll(TOKEN)) {
100
+ parts.push(esc(mask.slice(last, m.index)));
101
+ last = m.index! + m[0].length;
102
+ const tok = m[0];
103
+ if (tok.startsWith('"')) { parts.push(esc(tok.slice(1, -1))); continue; }
104
+ const up = tok.toUpperCase();
105
+ const names = (list: string[]) => `(${list.map(esc).join('|')})`;
106
+ const re: Record<string, string> = { YYYY: '(\\d{4})', YY: '(\\d{2})', MM: '(\\d{1,2})', DD: '(\\d{1,2})', MONTH: names(n.months), MON: names(n.monthsShort), DAY: names(n.days), DY: names(n.daysShort) };
107
+ if (!re[up]) return null; // a time in a date mask: not a date item's
108
+ parts.push(re[up]);
109
+ groups.push(up);
110
+ }
111
+ parts.push(esc(mask.slice(last)));
112
+ const m = new RegExp(`^${parts.join('')}$`, 'i').exec(v);
113
+ if (!m) return null;
114
+ groups.forEach((g, i) => {
115
+ const x = m[i + 1];
116
+ if (g === 'YYYY') y = Number(x);
117
+ else if (g === 'YY') y = 2000 + Number(x);
118
+ else if (g === 'MM') mo = Number(x);
119
+ else if (g === 'DD') d = Number(x);
120
+ else if (g === 'MONTH' || g === 'MON') mo = (g === 'MONTH' ? n.months : n.monthsShort).findIndex((name) => name.toLowerCase() === x.toLowerCase()) + 1;
121
+ });
122
+ }
123
+ if (!y || !mo || !d) return null;
124
+ const date = new Date(Date.UTC(y, mo - 1, d));
125
+ if (date.getUTCFullYear() !== y || date.getUTCMonth() !== mo - 1 || date.getUTCDate() !== d) return null;
126
+ return `${String(y).padStart(4, '0')}-${pad(mo)}-${pad(d)}`;
127
+ }
128
+
77
129
  /** A formatter for dates and timestamps with the given masks (null: ISO, as sent by Postgres). */
78
130
  export function dateFormatter(lang: string, dateMask: string | null, timestampMask: string | null): Formatter {
79
131
  return (v, oid) => {
@@ -12,6 +12,7 @@ import { markdownHtml, sanitizeHtml } from '../richtext.ts';
12
12
  import { qrSvg, type Ecc } from '../qrcode.ts';
13
13
  import { formatNumber, isPlainNumber, maskError } from '../numformat.ts';
14
14
  import { addressControl, imageControl } from './data-items.ts';
15
+ import { applyMask, formatMaskError, monthNames } from './format.ts';
15
16
 
16
17
  const TRUTHY = new Set(['true', 't', 'on', '1', 'yes', 'y']);
17
18
  export const isTruthy = (v: string | null | undefined) => !!v && TRUTHY.has(v.toLowerCase());
@@ -180,6 +181,17 @@ export const splitRange = (v: string | null | undefined): [string, string] => {
180
181
  return [from, to];
181
182
  };
182
183
 
184
+ /**
185
+ * (sprint 76) The date mask a date item shows its value in: its own format mask (a date mask), else the
186
+ * application's date format; null: the browser's date field (in the browser's notation).
187
+ */
188
+ export function itemDateMask(ctx: PageContext, item: Item): string | null {
189
+ if (item.type !== 'date') return null;
190
+ const own = typeof item.config?.format_mask === 'string' ? item.config.format_mask.trim() : '';
191
+ if (own && /(YYYY|YY|MM|DD|MON)/i.test(own) && !formatMaskError(own)) return own;
192
+ return ctx.locale.dateMask?.trim() || null;
193
+ }
194
+
183
195
  /** A number or display item's number format mask ({"format_mask": "999G990D00"}), when it is a valid one. */
184
196
  export function itemMask(item: Item): string | null {
185
197
  if (item.type !== 'number' && item.type !== 'display') return null;
@@ -383,6 +395,21 @@ export async function renderItem(ctx: PageContext, item: Item, hiddenByDa = fals
383
395
  control = html`<div class="input-with-button"><input type="text" id="${id}" name="${id}" value="${value}" inputmode="decimal" placeholder="52.01160,4.35710"${aria}>
384
396
  <button type="button" class="btn" data-locate="${id}">${icon('map')} ${ctx.locale.t('item.locate')}</button></div>`;
385
397
  break;
398
+ case 'date': {
399
+ // (sprint 76) in the application's date format: a text field and a calendar button (app.js), read back on submit
400
+ const mask = itemDateMask(ctx, item);
401
+ if (!mask) {
402
+ control = html`<input type="date" id="${id}" name="${id}" value="${value}"${aria}${editable ? '' : raw(' readonly')}>`;
403
+ break;
404
+ }
405
+ const iso = /^\d{4}-\d{2}-\d{2}/.test(value) ? value.slice(0, 10) : '';
406
+ const shown = iso ? applyMask(iso, mask, ctx.locale.lang) : value;
407
+ const months = monthNames(ctx.locale.lang);
408
+ control = html`<div class="input-with-button date-field"><input type="text" id="${id}" name="${id}" value="${shown}" placeholder="${mask}" autocomplete="off" data-date-mask="${mask}" data-months="${JSON.stringify(months)}"${aria}${editable ? '' : raw(' readonly')}>
409
+ ${editable ? html`<input type="date" class="date-native" value="${iso}" tabindex="-1" aria-hidden="true" data-date-for="${id}">
410
+ <button type="button" class="btn" data-date-pick="${id}" aria-label="${ctx.locale.t('item.choose_date')}" title="${ctx.locale.t('item.choose_date')}">${icon('calendar')}</button>` : ''}</div>`;
411
+ break;
412
+ }
386
413
  default: {
387
414
  const typed: Record<string, string> = { number: 'number', date: 'date', password: 'password', email: 'email', tel: 'tel', url: 'url' };
388
415
  // a number with a format mask is text in the language's notation (1.234,50), read back on submit
@@ -31,6 +31,8 @@ export interface Locale {
31
31
  themeChoice: boolean;
32
32
  /** dates and timestamps for display (undefined: not a date) */
33
33
  format: Formatter;
34
+ /** (sprint 76) the application's own date format (or its FORMAT.DATE text message): date items show it with a picker; null: the browser's date field */
35
+ dateMask: string | null;
34
36
  number: Intl.NumberFormat;
35
37
  /** separators and currency for number format masks */
36
38
  numbers: NumberSymbols;
@@ -202,6 +204,7 @@ export async function resolveLocale(req: FastifyRequest, app: App, session?: Ses
202
204
  style: baseStyleOf(app.theme) === 'standard' ? undefined : (baseStyleOf(app.theme) as 'iris' | 'lavender'),
203
205
  themeChoice: app.theme?.user_choice !== false,
204
206
  format,
207
+ dateMask: messages['FORMAT.DATE'] ?? app.date_format ?? null,
205
208
  number,
206
209
  numbers,
207
210
  masked: (m) => maskedFormatter(format, lang, numbers, m),
@@ -100,9 +100,9 @@ export async function renderButton(ctx: PageContext, b: Button) {
100
100
  return html`<button type="submit" class="${cls}" name="__request" value="${b.name}" data-button="${b.name}"${confirm}>${b.label}${badge}</button>`;
101
101
  }
102
102
 
103
- export async function buttonsFor(ctx: PageContext, regionId: number | null) {
103
+ export async function buttonsFor(ctx: PageContext, regionId: number | null, only: (b: Button) => boolean = () => true) {
104
104
  const out = [];
105
- for (const b of ctx.vis!.buttons.values()) if (b.region_id === regionId && b.id > 0) out.push(await renderButton(ctx, b));
105
+ for (const b of ctx.vis!.buttons.values()) if (b.region_id === regionId && b.id > 0 && only(b)) out.push(await renderButton(ctx, b));
106
106
  return out.some(Boolean) ? html`<div class="buttons">${out}</div>` : '';
107
107
  }
108
108
 
@@ -408,7 +408,13 @@ async function renderBody(ctx: PageContext, r: Region, hidden: Set<string>): Pro
408
408
  return body!;
409
409
  }
410
410
 
411
+ /** (099) Button positions in a region: its top bar, the bottom bar's left end, the bottom bar's right end. */
412
+ const TOP_SLOTS = new Set(['create', 'edit', 'change', 'copy', 'help', 'up']);
413
+ const LEFT_SLOTS = new Set(['previous', 'close', 'delete']);
414
+
411
415
  async function regionShell(ctx: PageContext, r: Region, hidden: Set<string>, body: Raw) {
416
+ const slotted = [...ctx.vis!.buttons.values()].some((b) => b.region_id === r.id && b.slot && b.slot !== 'region');
417
+ if (slotted && (r.template === 'standard' || r.template === 'collapsible' || r.template === 'plain')) return slottedShell(ctx, r, hidden, body);
412
418
  const buttons = await buttonsFor(ctx, r.id);
413
419
  const buttonsOnTop = r.type !== 'form' && r.type !== 'static';
414
420
  // (a grid renders its own Save button in its toolbar)
@@ -469,3 +475,31 @@ async function regionShell(ctx: PageContext, r: Region, hidden: Set<string>, bod
469
475
  </section>`;
470
476
  }
471
477
 
478
+ /**
479
+ * (099) A region whose buttons have positions (APEX: Button Position): create, edit, change, copy, help and up in
480
+ * the header; previous, close and delete at the left end of the bottom bar, next at its right end; the others
481
+ * where they always go (the header for reports and charts, the bottom bar for forms and static content).
482
+ */
483
+ async function slottedShell(ctx: PageContext, r: Region, hidden: Set<string>, body: Raw) {
484
+ const onTop = r.type !== 'form' && r.type !== 'static';
485
+ const plain = (b: Button) => !b.slot || b.slot === 'region';
486
+ const top = await buttonsFor(ctx, r.id, (b) => TOP_SLOTS.has(b.slot ?? '') || (onTop && plain(b)));
487
+ const left = await buttonsFor(ctx, r.id, (b) => LEFT_SLOTS.has(b.slot ?? ''));
488
+ const right = await buttonsFor(ctx, r.id, (b) => b.slot === 'next' || (!onTop && plain(b)));
489
+ const cls = `region region-${r.type} region-${r.template} col-${r.columns}${templateClasses('region', r.template_options)}`;
490
+ const hiddenAttr = hidden.has(`R${r.id}`) ? raw(' hidden') : '';
491
+ const staticAttr = r.static_id ? raw(` data-static-id="${esc(r.static_id)}"`) : '';
492
+ const titleId = `R${r.id}_title`;
493
+ const footer = left || right ? html`<footer class="region-footer region-footer-split"><div class="region-footer-start">${left}</div><div class="region-footer-end">${right}</div></footer>` : '';
494
+ if (r.template === 'plain')
495
+ return html`<section class="${cls}" id="R${r.id}"${staticAttr}${hiddenAttr} aria-label="${r.title}">
496
+ ${top ? html`<div class="region-toolbar">${top}</div>` : ''}${body}${footer}</section>`;
497
+ const header = html`<h2 id="${titleId}">${r.title}</h2>${top}`;
498
+ if (r.template === 'collapsible')
499
+ return html`<section class="${cls}" id="R${r.id}"${staticAttr}${hiddenAttr} aria-labelledby="${titleId}">
500
+ <details open><summary class="region-header">${header}</summary><div class="region-body">${body}</div>${footer}</details></section>`;
501
+ return html`<section class="${cls}" id="R${r.id}"${staticAttr}${hiddenAttr}${r.title ? raw(` aria-labelledby="${titleId}"`) : ''}>
502
+ ${r.title || top ? html`<header class="region-header">${header}</header>` : ''}
503
+ <div class="region-body">${body}</div>${footer}
504
+ </section>`;
505
+ }
@@ -331,7 +331,9 @@ const CLIENT_TEXTS = ['pwa.offline_banner', 'pwa.queued', 'pwa.queue_waiting', '
331
331
  'pwa.status.signin', 'pwa.status.invalid', 'pwa.status.error', 'item.locate_error', 'item.address_none', 'item.address_error', 'item.scan_close', 'common.dismiss',
332
332
  'crop.title', 'crop.apply', 'crop.skip', 'crop.help',
333
333
  'push.turn_on', 'push.turn_off', 'push.on', 'push.off', 'push.blocked', 'push.unsupported', 'push.failed', 'page.unsaved_changes', 'page.link_copied',
334
- 'session.warning', 'session.stay', 'session.max', 'session.ended', 'login.sign_in_link'] as const;
334
+ 'session.warning', 'session.stay', 'session.max', 'session.ended', 'login.sign_in_link',
335
+ // (sprint 76) a report column heading's menu
336
+ 'report.sort_asc', 'report.sort_desc', 'report.hide', 'report.break', 'report.filter_values', 'report.no_values'] as const;
335
337
  export const clientTexts = (ctx: PageContext) => Object.fromEntries(CLIENT_TEXTS.map((k) => [k, ctx.locale.t(k)]));
336
338
 
337
339
  /**
@@ -360,8 +362,21 @@ export async function renderPage(ctx: PageContext) {
360
362
  const hidden = initiallyHidden(ctx);
361
363
  const defaultButton = [...ctx.vis!.buttons.values()].find((b) => b.hot && b.action === 'submit');
362
364
  const pageItems = await renderItems(ctx, ctx.page.items.filter((i) => i.region_id === null), hidden);
363
- const regions = [];
364
- for (const r of ctx.page.regions) regions.push(await renderRegion(ctx, r, hidden));
365
+ // (099) regions by display position; the side column of a side-column page template, else its regions in the body
366
+ const side = ctx.page.page_template === 'left_side_column' ? 'left' : ctx.page.page_template === 'right_side_column' ? 'right' : null;
367
+ const at: Record<string, Raw[]> = { banner: [], breadcrumb: [], side: [], body: [], footer: [] };
368
+ for (const r of ctx.page.regions) {
369
+ const pos = r.position ?? 'body';
370
+ const into = pos === 'left' || pos === 'right' ? (pos === side ? 'side' : 'body') : pos in at ? pos : 'body';
371
+ // a region its condition or authorization leaves out ('') leaves no empty position behind
372
+ const out = await renderRegion(ctx, r, hidden);
373
+ if (out) at[into].push(out);
374
+ }
375
+ const area = (cls: string, list: Raw[]) => (list.length ? html`<div class="t-regions ${cls}">${list}</div>` : '');
376
+ const regions = side && at.side.length
377
+ ? html`<div class="t-columns t-columns-${side}"><aside class="t-side" aria-label="${side === 'left' ? 'Left column' : 'Right column'}"><div class="t-regions t-pos-side">${at.side}</div></aside>
378
+ <div class="t-regions t-pos-body">${at.body}</div></div>`
379
+ : html`<div class="t-regions">${at.body}</div>`;
365
380
  // (078) the origins of the page's URL regions (also those a refresh or lazy load shows later)
366
381
  for (const o of frameOrigins(ctx.page.regions.filter((r) => ctx.vis!.regions.has(r.id)))) ctx.frameSrc?.add(o);
367
382
  const title = substitute(ctx.page.title ?? ctx.page.name, ctx, (v) => v);
@@ -391,8 +406,9 @@ export async function renderPage(ctx: PageContext) {
391
406
  ${formKeys(ctx)}
392
407
  ${ctx.dialog ? html`<input type="hidden" name="__dialog" value="1">` : ''}
393
408
  ${defaultButton ? html`<button type="submit" name="__request" value="${defaultButton.name}" class="default-submit" tabindex="-1" aria-hidden="true"></button>` : ''}
409
+ ${area('t-pos-banner', at.banner)}${area('t-pos-breadcrumb', at.breadcrumb)}
394
410
  ${pageItems.length ? html`<div class="page-items form-grid">${pageItems}</div>` : ''}
395
- <div class="t-regions">${regions}</div>
411
+ ${regions}${area('t-pos-footer', at.footer)}
396
412
  ${await buttonsFor(ctx, null)}
397
413
  </form>
398
414
  ${ctx.detached}
@@ -186,6 +186,10 @@ interface Highlight extends Filter {
186
186
 
187
187
  export interface ReportState {
188
188
  search: string;
189
+ /** (sprint 76) the column the search is limited to (the search field's column list), or null: all */
190
+ searchCol: string | null;
191
+ /** (sprint 76) columns the user hid (a column heading's menu → Hide) */
192
+ hideCols: string[];
189
193
  sort: number;
190
194
  desc: boolean;
191
195
  page: number;
@@ -236,6 +240,8 @@ export function reportState(ctx: PageContext, r: Region): ReportState {
236
240
  const size = parseInt(p.get(key(r, 'n')) ?? '', 10) || Number(r.config.page_size) || 15;
237
241
  const st: ReportState = {
238
242
  search: (p.get(key(r, 'q')) ?? '').trim(),
243
+ searchCol: (p.get(key(r, 'qc')) ?? '').trim() || null,
244
+ hideCols: p.getAll(key(r, 'x')).filter(Boolean).slice(0, 100),
239
245
  sort: r.config.sortable === false ? 0 : Math.max(0, Math.min(1000, parseInt(p.get(key(r, 's')) ?? '0', 10) || 0)),
240
246
  desc: p.get(key(r, 'd')) === 'desc',
241
247
  page: Math.max(1, Math.min(MAX_PAGE, parseInt(p.get(key(r, 'p')) ?? '1', 10) || 1)),
@@ -415,13 +421,14 @@ export async function filtered(ctx: PageContext, r: Region, st: ReportState) {
415
421
  const p = new SqlParams();
416
422
  const hides = hidesColumns(r);
417
423
  const facets = facetFilters(ctx.params, r.id, reportFacetDefs(ctx.page.regions, r.id, ctx.vis?.regions));
418
- const needCols = st.filters.length || facets.length || st.breakCol || st.aggregates.length || st.highlights.length || st.view !== 'report' || st.area || st.near || keysetColumns(r) || (hides && (st.search || st.sort));
424
+ const needCols = st.filters.length || facets.length || st.breakCol || st.aggregates.length || st.highlights.length || st.view !== 'report' || st.area || st.near || keysetColumns(r) || (hides && (st.search || st.sort)) || (st.search && st.searchCol);
419
425
  // column name → type oid
420
426
  const fields = needCols ? await fieldsOf(ctx, src) : [];
421
427
  const cols = new Map<string, number>(fields.map((f) => [f.name, f.dataTypeID]));
422
428
  // the columns the user may choose (filters, highlights, control break, aggregates, views, sorting): not the hidden ones
423
429
  const userCols = new Map<string, number>(userColumnNames(r, [...cols.keys()]).map((n) => [n, cols.get(n)!]));
424
- if (st.search) where.push(searchSql(st.search, p, hides ? [...userCols.keys()] : undefined));
430
+ // (sprint 76) the search in one column the user may see, or in all
431
+ if (st.search) where.push(searchSql(st.search, p, st.searchCol && userCols.has(st.searchCol) ? [st.searchCol] : hides ? [...userCols.keys()] : undefined));
425
432
  for (const f of st.filters) if (userCols.has(f.column)) where.push(OPERATORS[f.op].sql(q(f.column), f.value));
426
433
  for (const f of facets) {
427
434
  const cond = facetFilterSql(f, cols, p);
@@ -536,7 +543,9 @@ export function chartSection(ctx: PageContext, r: Region, st: ReportState, names
536
543
  /** The pager under a report or grid: "X–Y of N" (or "Rows X–Y"), Previous and Next. */
537
544
  export function pagerNav(ctx: PageContext, r: Region, info: PageInfo, linkAttr: Raw | '' = '') {
538
545
  const t = ctx.locale.t;
539
- if (!info.more && info.page <= 1) return info.total ? html`<div class="pager"><span>${t('report.rows')}: ${info.total}</span></div>` : '';
546
+ // (sprint 76) "1 – 14 of 14" and the rows per page, also on a single page (APEX's pagination)
547
+ const sizes = sizeChooser(ctx, r, info);
548
+ if (!info.more && info.page <= 1) return info.total ? html`<div class="pager"><span>${t('report.range', { from: 1, to: info.total, total: info.total })}</span>${sizes}</div>` : '';
540
549
  // a page number, and the keyset position when there is one
541
550
  const go = (page: number, seek: string | null) => (p: URLSearchParams) => {
542
551
  p.set(key(r, 'p'), String(page));
@@ -546,13 +555,51 @@ export function pagerNav(ctx: PageContext, r: Region, info: PageInfo, linkAttr:
546
555
  const text =
547
556
  info.total === null ? t('report.range_open', { from: info.from, to: info.to })
548
557
  : t(info.capped ? 'report.range_more' : 'report.range', { from: info.from, to: info.to, total: info.total });
558
+ // the last page, when the total is known and pages are counted (not keyset paging)
559
+ const lastPage = info.total !== null && !info.capped && !info.next && !info.prev ? Math.ceil(info.total / reportState(ctx, r).size) : null;
549
560
  return html`<nav class="pager" aria-label="${t('report.pagination')}">
550
561
  <span>${text}</span>
562
+ ${info.page > 2 ? html`<a class="btn" href="${regionUrl(ctx, r, go(1, null))}"${linkAttr} aria-label="${t('report.first')}" title="${t('report.first')}">«</a>` : ''}
551
563
  ${info.page > 1 ? html`<a class="btn" href="${regionUrl(ctx, r, go(info.page - 1, info.prev))}"${linkAttr}>‹ ${t('report.previous')}</a>` : ''}
552
564
  ${info.more ? html`<a class="btn" href="${regionUrl(ctx, r, go(info.page + 1, info.next))}"${linkAttr}>${t('report.next')} ›</a>` : ''}
565
+ ${info.more && lastPage && lastPage > info.page + 1 ? html`<a class="btn" href="${regionUrl(ctx, r, go(lastPage, null))}"${linkAttr} aria-label="${t('report.last')}" title="${t('report.last')}">»</a>` : ''}
566
+ ${sizes}
553
567
  </nav>`;
554
568
  }
555
569
 
570
+ /** (sprint 76) Rows per page as a select in the pager (a GET form; app.js sends it on change, else its button). */
571
+ function sizeChooser(ctx: PageContext, r: Region, info: PageInfo): Raw | '' {
572
+ if (r.type !== 'report' || r.config.interactive === false) return '';
573
+ const st = reportState(ctx, r);
574
+ const keep = [...ctx.params.entries()].filter(([k]) => k !== key(r, 'n') && k !== key(r, 'p') && k !== key(r, 'k') && !['clear', 'cs'].includes(k));
575
+ const t = ctx.locale.t;
576
+ if (info.total !== null && info.total <= PAGE_SIZES[0] && st.size === PAGE_SIZES[0]) return '';
577
+ return html`<form method="get" action="${ctx.base}/${ctx.page.page_no}" class="pager-size">${keep.map(([k, v]) => html`<input type="hidden" name="${k}" value="${v}">`)}${ctx.dialog ? html`<input type="hidden" name="dialog" value="1">` : ''}
578
+ <label for="r${r.id}_size">${t('report.rows_per_page')}</label>
579
+ <select id="r${r.id}_size" name="${key(r, 'n')}" data-go>${[...new Set([...PAGE_SIZES, st.size])].sort((a, b) => a - b).map((n) => html`<option value="${n}"${n === st.size ? raw(' selected') : ''}>${n}</option>`)}</select>
580
+ <button class="btn btn-sm no-js-only">${t('report.go')}</button></form>`;
581
+ }
582
+
583
+ /** (sprint 76) The most values a column heading's menu lists. */
584
+ export const MAX_COLUMN_VALUES = 200;
585
+
586
+ /**
587
+ * (sprint 76) A column's values with their counts, for its heading's menu (APEX: the column heading's quick
588
+ * filter list): the report's rows with the user's search and filters, only a column the user may see.
589
+ */
590
+ export async function columnValues(ctx: PageContext, r: Region, column: string) {
591
+ const st = reportState(ctx, r);
592
+ const { src, where, userCols, values } = await filtered(ctx, r, { ...st, filters: [...st.filters] });
593
+ // the column's own type is needed: filtered() reads the columns when anything asks for them
594
+ const names = userCols.size ? userCols : new Map((await fieldsOf(ctx, src)).filter((f) => userColumnNames(r, [f.name]).length).map((f) => [f.name, f.dataTypeID]));
595
+ if (!names.has(column)) return null;
596
+ const c = ctx.client!;
597
+ const res = await savepoint(c, () =>
598
+ c.query({ text: `select ${q(column)}::text as v, count(*)::int as n from (\n${src}\n) "__q"${where} group by 1 order by 1 nulls first limit ${MAX_COLUMN_VALUES}`, values }),
599
+ );
600
+ return res.rows.map((row: { v: string | null; n: number }) => ({ value: row.v, count: row.n }));
601
+ }
602
+
556
603
  /**
557
604
  * The aggregates over all filtered rows (not just the page): the totals,
558
605
  * and per control-break value when there is a break column.
@@ -783,7 +830,9 @@ export async function renderReport(ctx: PageContext, r: Region, filterItems: Raw
783
830
  if (info.page > 2) info.prev = seekToken(ctx, r, st, info.page - 1, 'p', at(info.rows[0]));
784
831
  }
785
832
  const hlIdx = st.highlights.map((_, i) => res.fields.findIndex((f) => f.name === `__h${i}`));
786
- const allCols = visibleColumns(r, fields);
833
+ // (sprint 76) without the columns the user hid (a heading's menu → Hide)
834
+ const userHidden = new Set(st.hideCols.map((c) => c.toLowerCase()));
835
+ const allCols = visibleColumns(r, fields).filter(({ f }) => !userHidden.has(f.name.toLowerCase()));
787
836
  // a control break on a shown column only (its values head the groups)
788
837
  const breakIdx = st.breakCol ? (allCols.find(({ f }) => f.name === st.breakCol)?.i ?? -1) : -1;
789
838
  const cols = allCols.filter(({ i }) => i !== breakIdx);
@@ -840,7 +889,13 @@ export async function renderReport(ctx: PageContext, r: Region, filterItems: Raw
840
889
  else p.delete(key(r, 'd'));
841
890
  p.delete(key(r, 'p'));
842
891
  });
843
- return html`<th scope="col" class="${cls}" aria-sort="${active ? (st.desc ? 'descending' : 'ascending') : 'none'}"><a href="${href}">${label}<span class="sort-ind" aria-hidden="true">${active ? (st.desc ? '▼' : '▲') : ''}</span></a></th>`;
892
+ // (sprint 76) a heading's menu (app.js): sort either way, hide, control break and the column's values to filter by;
893
+ // without JavaScript the heading sorts
894
+ const set = (fn: (p: URLSearchParams) => void) => regionUrl(ctx, r, (p) => { fn(p); p.delete(key(r, 'p')); p.delete(key(r, 'k')); });
895
+ const menu = interactive
896
+ ? raw(` data-col="${esc(f.name)}" data-label="${esc(label)}" data-asc="${esc(set((p) => { p.set(key(r, 's'), String(pos)); p.delete(key(r, 'd')); }))}" data-desc="${esc(set((p) => { p.set(key(r, 's'), String(pos)); p.set(key(r, 'd'), 'desc'); }))}" data-hide="${esc(set((p) => p.append(key(r, 'x'), f.name)))}" data-break="${esc(set((p) => p.set(key(r, 'b'), f.name)))}" data-filter="${esc(key(r, 'f'))}" data-values="${esc(`${ctx.base}/${ctx.page.page_no}/report/${r.id}/values`)}"`)
897
+ : '';
898
+ return html`<th scope="col" class="${cls}" aria-sort="${active ? (st.desc ? 'descending' : 'ascending') : 'none'}"${menu}><a href="${href}">${label}<span class="sort-ind" aria-hidden="true">${active ? (st.desc ? '▼' : '▲') : ''}</span></a></th>`;
844
899
  });
845
900
  if (editIcon) header.unshift(html`<th scope="col" class="row-edit" aria-label="${t('report.edit')}"></th>`);
846
901
  if (selIdx >= 0) header.unshift(html`<th scope="col" class="row-select"><label><input type="checkbox" data-select-all="${selection!.item}" aria-label="${t('report.select_all')}"><span class="row-select-label" aria-hidden="true">${t('report.select_all')}</span></label></th>`);
@@ -901,7 +956,7 @@ export async function renderReport(ctx: PageContext, r: Region, filterItems: Raw
901
956
  .map(([k, v]) => html`<input type="hidden" name="${k}" value="${v}">`);
902
957
  const action = `${ctx.base}/${ctx.page.page_no}`;
903
958
  if (searchable) {
904
- ctx.detached.push(html`<form id="${searchForm}" method="get" action="${action}">${hiddenInputs([key(r, 'q'), key(r, 'p')])}${ctx.dialog ? html`<input type="hidden" name="dialog" value="1">` : ''}</form>`);
959
+ ctx.detached.push(html`<form id="${searchForm}" method="get" action="${action}">${hiddenInputs([key(r, 'q'), key(r, 'qc'), key(r, 'p')])}${ctx.dialog ? html`<input type="hidden" name="dialog" value="1">` : ''}</form>`);
905
960
  }
906
961
  const breakForm = `rb${r.id}`;
907
962
  const aggForm = `ra${r.id}`;
@@ -971,101 +1026,96 @@ export async function renderReport(ctx: PageContext, r: Region, filterItems: Raw
971
1026
  if (st.area) chips.push(chip(t('report.map_area'), 'bb'));
972
1027
  if (st.near) chips.push(chip(t('report.near', { km: ctx.locale.number.format(st.near.km) }), 'near'));
973
1028
  }
1029
+ for (const c of st.hideCols)
1030
+ chips.push(html`<span class="chip">${t('report.hidden_column')}: <b>${headingOf(r, c, ctx.locale.tr)}</b>
1031
+ <a href="${regionUrl(ctx, r, (p) => { const rest = p.getAll(key(r, 'x')).filter((x) => x !== c); p.delete(key(r, 'x')); for (const x of rest) p.append(key(r, 'x'), x); })}" aria-label="${t('report.show_column')}">×</a></span>`);
974
1032
  if (st.search)
975
1033
  chips.unshift(
976
- html`<span class="chip">${t('report.search_chip')} <b>${st.search}</b> <a href="${regionUrl(ctx, r, (p) => { p.delete(key(r, 'q')); p.delete(key(r, 'p')); })}" aria-label="${t('report.clear_search')}">×</a></span>`,
1034
+ html`<span class="chip">${t('report.search_chip')}${st.searchCol ? html` ${headingOf(r, st.searchCol, ctx.locale.tr)}:` : ''} <b>${st.search}</b> <a href="${regionUrl(ctx, r, (p) => { p.delete(key(r, 'q')); p.delete(key(r, 'qc')); p.delete(key(r, 'p')); })}" aria-label="${t('report.clear_search')}">×</a></span>`,
977
1035
  );
978
1036
 
1037
+ // (sprint 76) Actions is a short menu like APEX's; each entry opens a dialog (app.js), or unfolds in place without JavaScript
1038
+ const columnsForm = `rx${r.id}`;
1039
+ if (interactive)
1040
+ ctx.detached.push(html`<form id="${columnsForm}" method="get" action="${ctx.base}/${ctx.page.page_no}">${hiddenInputs([key(r, 'x'), key(r, 'p')])}${ctx.dialog ? html`<input type="hidden" name="dialog" value="1">` : ''}</form>`);
1041
+ const item = (k: string, ic: string, label: string, body: Raw | '') =>
1042
+ html`<li><details class="ir-item" data-ir-dialog="${k}"><summary>${icon(ic)}<span>${label}</span></summary><div class="ir-body" role="group" aria-label="${label}">${body}</div></details></li>`;
1043
+ const sub = (ic: string, label: string, items: (Raw | '')[]) =>
1044
+ html`<li><details class="ir-item ir-sub"><summary>${icon(ic)}<span>${label}</span><span class="ir-more" aria-hidden="true">›</span></summary><ul class="ir-menu">${items}</ul></details></li>`;
1045
+ const menuLink = (ic: string, label: Raw | string, href: string, attrs: Raw | '' = '') => html`<li><a class="ir-link" href="${href}"${attrs}>${icon(ic)}<span>${label}</span></a></li>`;
1046
+ const sortLinks = r.config.sortable === false
1047
+ ? ''
1048
+ : html`<div class="seg">${cols.map(({ f, i }) => {
1049
+ const pos = i + 1;
1050
+ const active = st.sort === pos;
1051
+ const href = regionUrl(ctx, r, (p) => {
1052
+ p.set(key(r, 's'), String(pos));
1053
+ if (active && !st.desc) p.set(key(r, 'd'), 'desc');
1054
+ else p.delete(key(r, 'd'));
1055
+ p.delete(key(r, 'p'));
1056
+ });
1057
+ return html`<a href="${href}"${active ? raw(' aria-current="true"') : ''}>${headingOf(r, f.name, ctx.locale.tr)}${active ? (st.desc ? ' ▼' : ' ▲') : ''}</a>`;
1058
+ })}</div>`;
979
1059
  const actionsMenu = interactive
980
1060
  ? html`<details class="menu actions-menu">
981
1061
  <summary class="btn">${t('report.actions')} <span aria-hidden="true">▾</span></summary>
982
- <div class="menu-panel">
983
- <div class="menu-section">
984
- <strong>${t('report.filter')}</strong>
985
- <div class="filter-row">
1062
+ <div class="menu-panel"><ul class="ir-menu">
1063
+ ${item('columns', 'eye-off', t('report.columns'), html`<fieldset class="ir-columns"><legend>${t('report.hide_columns')}</legend>
1064
+ ${visibleColumns(r, fields).map(({ f }) => html`<label class="check"><input type="checkbox" name="${key(r, 'x')}" value="${f.name}" form="${columnsForm}"${userHidden.has(f.name.toLowerCase()) ? raw(' checked') : ''}> ${headingOf(r, f.name, ctx.locale.tr)}</label>`)}</fieldset>
1065
+ <button class="btn btn-hot" form="${columnsForm}">${t('report.apply')}</button>`)}
1066
+ ${item('filter', 'filter', t('report.filter'), html`<div class="filter-row">
986
1067
  <select name="${key(r, 'fc')}" form="${filterForm}" aria-label="${t('report.column')}">${cols.map(({ f }) => html`<option value="${f.name}">${headingOf(r, f.name, ctx.locale.tr)}</option>`)}</select>
987
1068
  <select name="${key(r, 'fo')}" form="${filterForm}" aria-label="${t('report.operator')}">${Object.keys(OPERATORS).map((k) => html`<option value="${k}"${k === 'contains' ? raw(' selected') : ''}>${opLabel(t, k)}</option>`)}</select>
988
1069
  <input name="${key(r, 'fv')}" form="${filterForm}" aria-label="${t('report.value')}" placeholder="${t('report.value')}">
989
1070
  <button class="btn btn-hot" form="${filterForm}">${t('report.apply')}</button>
990
- </div>
991
- </div>
992
- ${r.config.sortable === false
993
- ? ''
994
- : html`<div class="menu-section"><strong>${t('report.sort')}</strong>
995
- <div class="seg">${cols.map(({ f, i }) => {
996
- const pos = i + 1;
997
- const active = st.sort === pos;
998
- const href = regionUrl(ctx, r, (p) => {
999
- p.set(key(r, 's'), String(pos));
1000
- if (active && !st.desc) p.set(key(r, 'd'), 'desc');
1001
- else p.delete(key(r, 'd'));
1002
- p.delete(key(r, 'p'));
1003
- });
1004
- return html`<a href="${href}"${active ? raw(' aria-current="true"') : ''}>${headingOf(r, f.name, ctx.locale.tr)}${active ? (st.desc ? ' ▼' : ' ▲') : ''}</a>`;
1005
- })}</div>
1006
- </div>`}
1007
- <div class="menu-section">
1008
- <strong>${t('report.break')}</strong>
1009
- <div class="filter-row">
1071
+ </div>`)}
1072
+ ${sub('table', t('report.data'), [
1073
+ r.config.sortable === false ? '' : item('sort', 'arrow-down', t('report.sort'), sortLinks),
1074
+ item('aggregate', 'calculator', t('report.aggregate'), html`<div class="filter-row">
1075
+ <select name="${key(r, 'af')}" form="${aggForm}" aria-label="${t('report.function')}">${Object.keys(AGGREGATES).map((fn) => html`<option value="${fn}">${t(`agg.${fn}`)}</option>`)}</select>
1076
+ <select name="${key(r, 'ac')}" form="${aggForm}" aria-label="${t('report.column')}">${cols.map(({ f }) => html`<option value="${f.name}">${headingOf(r, f.name, ctx.locale.tr)}</option>`)}</select>
1077
+ <button class="btn btn-hot" form="${aggForm}">${t('report.apply')}</button></div>`),
1078
+ item('compute', 'percent', t('report.compute'), html`<div class="filter-row">
1079
+ <input name="${key(r, 'cn')}" form="${computeForm}" maxlength="40" aria-label="${t('report.compute_name')}" placeholder="${t('report.compute_name')}">
1080
+ <input name="${key(r, 'ce')}" form="${computeForm}" maxlength="500" aria-label="${t('report.compute_expr')}" placeholder="${t('report.compute_expr')}">
1081
+ <button class="btn btn-hot" form="${computeForm}">${t('report.apply')}</button></div>
1082
+ <small class="help">${t('report.compute_help')}</small>`),
1083
+ item('break', 'list', t('report.break'), html`<div class="filter-row">
1010
1084
  <select name="${key(r, 'bc')}" form="${breakForm}" aria-label="${t('report.break')}">
1011
1085
  <option value="">${t('report.none')}</option>
1012
1086
  ${allCols.map(({ f }) => html`<option value="${f.name}"${f.name === st.breakCol ? raw(' selected') : ''}>${headingOf(r, f.name, ctx.locale.tr)}</option>`)}
1013
1087
  </select>
1014
- <button class="btn" form="${breakForm}">${t('report.apply')}</button>
1015
- </div>
1016
- </div>
1017
- <div class="menu-section">
1018
- <strong>${t('report.aggregate')}</strong>
1019
- <div class="filter-row">
1020
- <select name="${key(r, 'af')}" form="${aggForm}" aria-label="${t('report.function')}">${Object.keys(AGGREGATES).map((fn) => html`<option value="${fn}">${t(`agg.${fn}`)}</option>`)}</select>
1021
- <select name="${key(r, 'ac')}" form="${aggForm}" aria-label="${t('report.column')}">${cols.map(({ f }) => html`<option value="${f.name}">${headingOf(r, f.name, ctx.locale.tr)}</option>`)}</select>
1022
- <button class="btn" form="${aggForm}">${t('report.apply')}</button>
1023
- </div>
1024
- </div>
1025
- <div class="menu-section">
1026
- <strong>${t('report.highlight')}</strong>
1027
- <div class="filter-row">
1088
+ <button class="btn btn-hot" form="${breakForm}">${t('report.apply')}</button></div>`),
1089
+ ])}
1090
+ ${sub('sliders', t('report.format'), [
1091
+ item('highlight', 'tag', t('report.highlight'), html`<div class="filter-row">
1028
1092
  <select name="${key(r, 'hc')}" form="${hlForm}" aria-label="${t('report.column')}">${allCols.map(({ f }) => html`<option value="${f.name}">${headingOf(r, f.name, ctx.locale.tr)}</option>`)}</select>
1029
1093
  <select name="${key(r, 'ho')}" form="${hlForm}" aria-label="${t('report.operator')}">${Object.keys(OPERATORS).map((k) => html`<option value="${k}"${k === 'eq' ? raw(' selected') : ''}>${opLabel(t, k)}</option>`)}</select>
1030
1094
  <input name="${key(r, 'hv')}" form="${hlForm}" aria-label="${t('report.value')}" placeholder="${t('report.value')}">
1031
1095
  <select name="${key(r, 'hk')}" form="${hlForm}" aria-label="${t('report.color')}">${HIGHLIGHT_COLORS.map((c) => html`<option value="${c}">${t(`color.${c}`)}</option>`)}</select>
1032
- <button class="btn" form="${hlForm}">${t('report.apply')}</button>
1033
- </div>
1034
- </div>
1035
- <div class="menu-section">
1036
- <strong>${t('report.compute')}</strong>
1037
- <div class="filter-row">
1038
- <input name="${key(r, 'cn')}" form="${computeForm}" maxlength="40" aria-label="${t('report.compute_name')}" placeholder="${t('report.compute_name')}">
1039
- <input name="${key(r, 'ce')}" form="${computeForm}" maxlength="500" aria-label="${t('report.compute_expr')}" placeholder="${t('report.compute_expr')}">
1040
- <button class="btn" form="${computeForm}">${t('report.apply')}</button>
1041
- </div>
1042
- <small class="help">${t('report.compute_help')}</small>
1043
- </div>
1044
- ${groupBySection(ctx, r, st, allCols.map(({ f }) => f.name), groupForm)}
1045
- <div class="menu-section">
1046
- <strong>${t('report.pivot')}</strong>
1047
- <div class="filter-row">
1096
+ <button class="btn btn-hot" form="${hlForm}">${t('report.apply')}</button></div>`),
1097
+ item('rows', 'hash', t('report.rows_per_page'), html`<div class="seg">${PAGE_SIZES.map((n) =>
1098
+ html`<a href="${regionUrl(ctx, r, (p) => { p.set(key(r, 'n'), String(n)); p.delete(key(r, 'p')); })}"${n === st.size ? raw(' aria-current="true"') : ''}>${n}</a>`)}</div>`),
1099
+ ])}
1100
+ ${item('chart', 'chart', t('report.chart'), chartSection(ctx, r, st, allCols.map(({ f }) => f.name), chartForm))}
1101
+ ${item('group', 'layers', t('report.group_by'), groupBySection(ctx, r, st, allCols.map(({ f }) => f.name), groupForm))}
1102
+ ${item('pivot', 'grid', t('report.pivot'), html`<div class="filter-row">
1048
1103
  <select name="${key(r, 'pr')}" form="${pivotForm}" aria-label="${t('report.pivot_row')}">${allCols.map(({ f }) => html`<option value="${f.name}"${f.name === st.pivot?.row ? raw(' selected') : ''}>${headingOf(r, f.name, ctx.locale.tr)}</option>`)}</select>
1049
1104
  <select name="${key(r, 'pp')}" form="${pivotForm}" aria-label="${t('report.pivot_column')}">${allCols.map(({ f }) => html`<option value="${f.name}"${f.name === st.pivot?.column ? raw(' selected') : ''}>${headingOf(r, f.name, ctx.locale.tr)}</option>`)}</select>
1050
1105
  <select name="${key(r, 'pf')}" form="${pivotForm}" aria-label="${t('report.function')}">${Object.keys(AGGREGATES).map((fn) => html`<option value="${fn}"${fn === (st.pivot?.fn ?? 'count') ? raw(' selected') : ''}>${t(`agg.${fn}`)}</option>`)}</select>
1051
1106
  <select name="${key(r, 'pc')}" form="${pivotForm}" aria-label="${t('report.value_column')}">${allCols.map(({ f }) => html`<option value="${f.name}"${f.name === st.pivot?.value ? raw(' selected') : ''}>${headingOf(r, f.name, ctx.locale.tr)}</option>`)}</select>
1052
- <button class="btn" form="${pivotForm}">${t('report.apply')}</button>
1053
- </div>
1054
- </div>
1055
- ${chartSection(ctx, r, st, allCols.map(({ f }) => f.name), chartForm)}
1056
- ${saved ?? ''}
1057
- <div class="menu-section"><strong>${t('report.rows_per_page')}</strong>
1058
- <div class="seg">${PAGE_SIZES.map((n) =>
1059
- html`<a href="${regionUrl(ctx, r, (p) => { p.set(key(r, 'n'), String(n)); p.delete(key(r, 'p')); })}"${n === st.size ? raw(' aria-current="true"') : ''}>${n}</a>`)}</div>
1060
- </div>
1061
- <div class="menu-section menu-links">
1062
- <a href="${regionUrl(ctx, r, (p) => p.set(key(r, 'csv'), '1'))}" download>${icon('download')} ${t('report.download')}</a>
1063
- <a href="${regionUrl(ctx, r, (p) => p.set(key(r, 'xlsx'), '1'))}" download>${icon('download')} ${t('report.download_xlsx')}</a>
1064
- <a href="${regionUrl(ctx, r, (p) => p.set(key(r, 'pdf'), '1'))}" download>${icon('download')} ${t('report.download_pdf')}</a>
1065
- <button type="button" data-print>${icon('printer')} ${t('report.print')}</button>
1066
- <a href="${regionUrl(ctx, r, (p) => { for (const k of [...p.keys()]) if (k.startsWith(`r${r.id}_`)) p.delete(k); })}">${icon('history')} ${t('report.reset')}</a>
1067
- </div>
1068
- </div>
1107
+ <button class="btn btn-hot" form="${pivotForm}">${t('report.apply')}</button></div>`)}
1108
+ ${sub('bookmark', t('report.report'), [
1109
+ saved ? item('saved', 'save', t('report.saved_reports'), saved) : '',
1110
+ menuLink('history', t('report.reset'), regionUrl(ctx, r, (p) => { for (const k of [...p.keys()]) if (k.startsWith(`r${r.id}_`)) p.delete(k); })),
1111
+ ])}
1112
+ ${sub('download', t('report.download_menu'), [
1113
+ menuLink('download', t('report.download'), regionUrl(ctx, r, (p) => p.set(key(r, 'csv'), '1')), raw(' download')),
1114
+ menuLink('download', t('report.download_xlsx'), regionUrl(ctx, r, (p) => p.set(key(r, 'xlsx'), '1')), raw(' download')),
1115
+ menuLink('download', t('report.download_pdf'), regionUrl(ctx, r, (p) => p.set(key(r, 'pdf'), '1')), raw(' download')),
1116
+ html`<li><button type="button" class="ir-link" data-print>${icon('printer')}<span>${t('report.print')}</span></button></li>`,
1117
+ ])}
1118
+ </ul></div>
1069
1119
  </details>`
1070
1120
  : '';
1071
1121
 
@@ -1075,7 +1125,12 @@ export async function renderReport(ctx: PageContext, r: Region, filterItems: Raw
1075
1125
  ${filterItems}
1076
1126
  ${searchable
1077
1127
  ? html`<div class="search" role="search">
1078
- <input type="search" name="${key(r, 'q')}" value="${st.search}" placeholder="${t('report.search_all')}" form="${searchForm}" aria-label="${t('report.search')} ${r.title ?? ''}">
1128
+ <div class="search-field">${interactive
1129
+ ? html`<label class="sr-only" for="r${r.id}_qc">${t('report.search_column')}</label>
1130
+ <select id="r${r.id}_qc" class="search-scope" name="${key(r, 'qc')}" form="${searchForm}">
1131
+ <option value="">${t('report.all_columns')}</option>${cols.map(({ f }) => html`<option value="${f.name}"${f.name === st.searchCol ? raw(' selected') : ''}>${headingOf(r, f.name, ctx.locale.tr)}</option>`)}</select>`
1132
+ : ''}
1133
+ <input type="search" name="${key(r, 'q')}" value="${st.search}" placeholder="${t('report.search_all')}" form="${searchForm}" aria-label="${t('report.search')} ${r.title ?? ''}"></div>
1079
1134
  <button class="btn" form="${searchForm}">${t('report.go')}</button>
1080
1135
  ${actionsMenu}
1081
1136
  </div>`