pgkiln 0.72.0 → 0.73.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 (54) hide show
  1. package/db/migrations/140_item_region_types.sql +27 -0
  2. package/docs/apex-feature-parity.md +84 -8
  3. package/docs/guide/04-pages-and-regions.md +41 -1
  4. package/docs/guide/05-items.md +43 -3
  5. package/docs/guide/11-from-apex.md +3 -0
  6. package/docs/guide/12-development.md +10 -2
  7. package/examples/hr/hr_75_item_region_types.sql +81 -0
  8. package/package.json +1 -1
  9. package/public/app.css +48 -0
  10. package/public/app.js +377 -1
  11. package/src/builder/advisor.ts +5 -1
  12. package/src/builder/arrange.ts +7 -0
  13. package/src/builder/components.ts +5 -5
  14. package/src/builder/designer.ts +8 -2
  15. package/src/builder/i18n/de.ts +6 -0
  16. package/src/builder/i18n/en.ts +6 -0
  17. package/src/builder/i18n/es.ts +6 -0
  18. package/src/builder/i18n/fr.ts +6 -0
  19. package/src/builder/i18n/it.ts +6 -0
  20. package/src/builder/i18n/ja.ts +6 -0
  21. package/src/builder/i18n/ko.ts +6 -0
  22. package/src/builder/i18n/nl.ts +6 -0
  23. package/src/builder/i18n/pt.ts +6 -0
  24. package/src/builder/i18n/zh-cn.ts +6 -0
  25. package/src/builder/i18n/zh-tw.ts +6 -0
  26. package/src/builder/region-help.ts +8 -2
  27. package/src/i18n/ar.ts +13 -0
  28. package/src/i18n/cs.ts +13 -0
  29. package/src/i18n/da.ts +13 -0
  30. package/src/i18n/de.ts +13 -0
  31. package/src/i18n/el.ts +13 -0
  32. package/src/i18n/es.ts +13 -0
  33. package/src/i18n/fi.ts +13 -0
  34. package/src/i18n/fr.ts +13 -0
  35. package/src/i18n/he.ts +13 -0
  36. package/src/i18n/it.ts +13 -0
  37. package/src/i18n/ja.ts +13 -0
  38. package/src/i18n/ko.ts +13 -0
  39. package/src/i18n/nb.ts +13 -0
  40. package/src/i18n/pl.ts +13 -0
  41. package/src/i18n/pt.ts +13 -0
  42. package/src/i18n/ru.ts +13 -0
  43. package/src/i18n/sv.ts +13 -0
  44. package/src/i18n/tr.ts +13 -0
  45. package/src/i18n/uk.ts +13 -0
  46. package/src/i18n/zh.ts +13 -0
  47. package/src/i18n.ts +26 -0
  48. package/src/metadata.ts +5 -2
  49. package/src/runtime/items.ts +32 -3
  50. package/src/runtime/page-regions.ts +50 -0
  51. package/src/runtime/region-cache.ts +1 -1
  52. package/src/runtime/regions.ts +11 -0
  53. package/src/runtime/render.ts +3 -17
  54. package/src/runtime/report.ts +55 -3
package/public/app.css CHANGED
@@ -1693,6 +1693,54 @@ td.tc-cell { overflow-wrap: anywhere; }
1693
1693
  }
1694
1694
  .tag-remove:hover, .tag-remove:focus-visible { background: var(--surface-3); }
1695
1695
 
1696
+ /* (140) shuttle: two lists, the move buttons between them and the order buttons after them (stacked on phones) */
1697
+ .shuttle-ui { display: grid; grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr) auto; gap: 0.5rem; align-items: stretch; }
1698
+ .shuttle-col { display: flex; flex-direction: column; gap: 0.35rem; min-width: 0; }
1699
+ .shuttle-col .shuttle-list { flex: 1; min-height: 9rem; }
1700
+ .shuttle-moves, .shuttle-order { display: flex; flex-direction: column; justify-content: center; gap: 0.3rem; }
1701
+ .shuttle-btn { min-width: 2.4rem; justify-content: center; font-size: 1rem; line-height: 1; }
1702
+ [dir=rtl] .shuttle-moves .shuttle-btn span { display: inline-block; transform: scaleX(-1); }
1703
+ @media (max-width: 640px) {
1704
+ .shuttle-ui { grid-template-columns: minmax(0, 1fr) auto; }
1705
+ .shuttle-col:first-child { grid-column: 1 / -1; }
1706
+ .shuttle-moves { grid-column: 1 / -1; flex-direction: row; justify-content: center; }
1707
+ .shuttle-moves .shuttle-btn span { display: inline-block; transform: rotate(90deg); }
1708
+ }
1709
+ /* (140) select one / select many: a search field with a list box under it; chips above it */
1710
+ .select-pick-ui { position: relative; display: flex; flex-direction: column; gap: 0.3rem; min-width: 0; }
1711
+ .select-pick-list {
1712
+ position: absolute; z-index: 30; top: 100%; inset-inline: 0; margin: 0.15rem 0 0; padding: 0.25rem 0; list-style: none; max-height: 16rem; overflow-y: auto;
1713
+ background: var(--surface); border: 1px solid var(--border); border-radius: calc(var(--radius) * 0.75); box-shadow: var(--shadow);
1714
+ }
1715
+ .select-pick-list[hidden] { display: none; }
1716
+ .select-pick-list li { padding: 0.4rem 0.7rem; cursor: pointer; overflow-wrap: anywhere; }
1717
+ .select-pick-list li[aria-selected=true] { font-weight: 600; }
1718
+ .select-pick-list li[aria-selected=true]::before { content: "✓ "; }
1719
+ .select-pick-list li.is-active, .select-pick-list li:hover { background: var(--surface-3); }
1720
+ .select-pick-list li.select-pick-none { cursor: default; color: var(--muted); }
1721
+ /* (140) a breadcrumb region and a help text region */
1722
+ .region-crumbs .crumbs { font-size: 0.9rem; margin: 0; }
1723
+ .region-crumbs .crumbs [aria-current] { color: var(--text); font-weight: 600; }
1724
+ .help-text { overflow-wrap: anywhere; }
1725
+ .help-text > :first-child { margin-top: 0; }
1726
+ .help-text > :last-child { margin-bottom: 0; }
1727
+ /* (140) classic report templates */
1728
+ .cr-pairs { display: flex; flex-direction: column; gap: 0.75rem; }
1729
+ .cr-pairs dl { display: grid; grid-template-columns: minmax(6rem, max-content) minmax(0, 1fr); gap: 0.25rem 1rem; margin: 0; padding-bottom: 0.75rem; border-bottom: 1px solid var(--border); }
1730
+ .cr-pairs dl:last-child { border-bottom: 0; padding-bottom: 0; }
1731
+ .cr-pairs dt { font-weight: 600; color: var(--muted); overflow-wrap: anywhere; }
1732
+ .cr-pairs dd { margin: 0; overflow-wrap: anywhere; }
1733
+ .cr-badges { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(100%, 9rem), 1fr)); gap: 0.6rem; list-style: none; margin: 0; padding: 0; }
1734
+ .cr-badges li { display: flex; flex-direction: column; align-items: center; gap: 0.2rem; padding: 0.7rem; border: 1px solid var(--border); border-radius: var(--radius); text-align: center; overflow-wrap: anywhere; }
1735
+ .cr-badges .cr-badge-value { font-size: 1.4rem; font-weight: 700; }
1736
+ .cr-badges .cr-badge-label { font-size: 0.85rem; color: var(--muted); }
1737
+ .cr-media { list-style: none; margin: 0; padding: 0; }
1738
+ .cr-media li { padding: 0.6rem 0; border-bottom: 1px solid var(--border); overflow-wrap: anywhere; }
1739
+ .cr-media li:last-child { border-bottom: 0; }
1740
+ .cr-media .cr-media-title { font-weight: 600; }
1741
+ .cr-media .cr-media-text { margin: 0.15rem 0 0; color: var(--muted); }
1742
+ @media (max-width: 640px) { .cr-pairs dl { grid-template-columns: minmax(0, 1fr); } }
1743
+
1696
1744
  .date-range { display: flex; flex-wrap: wrap; gap: 0.4rem 0.8rem; }
1697
1745
  .range-part { display: flex; align-items: center; gap: 0.4rem; flex: 1 1 12rem; min-width: 0; }
1698
1746
  .range-part .sub { font-size: 0.85rem; color: var(--muted); min-width: 2.2rem; }
package/public/app.js CHANGED
@@ -264,6 +264,8 @@ const pgkilnUnsaved = (() => {
264
264
  if (el.closest('[data-range]')) return els.some((e) => e.value) ? els.slice(0, 2).map((e) => e.value).join(':') : '';
265
265
  if (el.type === 'radio') return (els.find((e) => e.checked) || {}).value || '';
266
266
  if (el.type === 'checkbox') return el.checked ? 'true' : 'false';
267
+ // (140) a multi-value list (multiselect, shuttle, select many): its values colon-separated, in order
268
+ if (el.tagName === 'SELECT' && el.multiple) return [...el.selectedOptions].map((o) => o.value).join(':');
267
269
  return el.value;
268
270
  }
269
271
 
@@ -281,7 +283,10 @@ const pgkilnUnsaved = (() => {
281
283
  els.slice(0, 2).forEach((e, i) => (e.value = parts[i] || ''));
282
284
  } else if (el.type === 'radio') els.forEach((e) => (e.checked = e.value === value));
283
285
  else if (el.type === 'checkbox') el.checked = ['true', 't', 'on', '1', 'yes', 'y'].includes(String(value).toLowerCase());
284
- else el.value = value ?? '';
286
+ else if (el.tagName === 'SELECT' && el.multiple) {
287
+ const values = String(value ?? '').split(':').filter(Boolean);
288
+ for (const o of el.options) o.selected = values.includes(o.value);
289
+ } else el.value = value ?? '';
285
290
  if (depth < 5) {
286
291
  depth++;
287
292
  el.dispatchEvent(new Event('change', { bubbles: true }));
@@ -2162,6 +2167,377 @@ function popupLov(sel) {
2162
2167
  });
2163
2168
  }
2164
2169
 
2170
+ // (140; APEX: Shuttle) Two lists, available and chosen, with buttons to move one or all values across and to
2171
+ // order the chosen ones. The item's own <select multiple> (it works without JavaScript) stays in the form, hidden:
2172
+ // its selected options are the value, in the order of the chosen list (they come first in it).
2173
+ function enhanceShuttles(root) {
2174
+ const boxes = root.matches?.('[data-shuttle]') ? [root] : [...(root.querySelectorAll?.('[data-shuttle]') ?? [])];
2175
+ boxes.forEach(shuttle);
2176
+ }
2177
+ document.addEventListener('pgkiln:replaced', (e) => e.detail && enhanceShuttles(e.detail));
2178
+ enhanceShuttles(document);
2179
+ function shuttle(box) {
2180
+ const sel = box.querySelector('select[multiple]');
2181
+ if (!sel || box.dataset.enhanced) return;
2182
+ box.dataset.enhanced = '1';
2183
+ const d = box.dataset;
2184
+ const id = sel.id;
2185
+ const label = document.querySelector(`label[for="${CSS.escape(id)}"]`);
2186
+ const name = (label?.textContent || sel.name).replace(/\*\s*$/, '').trim();
2187
+ const el = (tag, cls) => {
2188
+ const x = document.createElement(tag);
2189
+ if (cls) x.className = cls;
2190
+ return x;
2191
+ };
2192
+ const list = (suffix, text) => {
2193
+ const l = el('select', 'shuttle-list');
2194
+ l.multiple = true;
2195
+ l.id = `${id}_${suffix}`;
2196
+ l.size = Math.max(sel.size || 6, 6);
2197
+ l.setAttribute('aria-label', `${name}: ${text}`);
2198
+ for (const a of ['aria-describedby', 'aria-invalid']) if (sel.hasAttribute(a)) l.setAttribute(a, sel.getAttribute(a));
2199
+ return l;
2200
+ };
2201
+ const avail = list('available', d.availableLabel || 'Available');
2202
+ const chosen = list('chosen', d.chosenLabel || 'Selected');
2203
+ const filter = el('input', 'shuttle-filter');
2204
+ filter.type = 'search';
2205
+ filter.id = `${id}_filter`;
2206
+ filter.autocomplete = 'off';
2207
+ filter.placeholder = d.filterLabel || 'Filter';
2208
+ filter.setAttribute('aria-label', `${name}: ${d.filterLabel || 'Filter'}`);
2209
+ filter.setAttribute('aria-controls', avail.id);
2210
+ const button = (cmd, text, glyph) => {
2211
+ const b = el('button', 'btn btn-sm shuttle-btn');
2212
+ b.type = 'button';
2213
+ b.dataset.cmd = cmd;
2214
+ b.title = text;
2215
+ b.setAttribute('aria-label', text);
2216
+ const g = el('span');
2217
+ g.setAttribute('aria-hidden', 'true');
2218
+ g.textContent = glyph;
2219
+ b.append(g);
2220
+ return b;
2221
+ };
2222
+ const moves = el('div', 'shuttle-moves');
2223
+ moves.setAttribute('role', 'group');
2224
+ moves.append(button('all', d.moveAllLabel || 'Move all', '»'), button('move', d.moveLabel || 'Move', '›'), button('remove', d.removeLabel || 'Remove', '‹'), button('none', d.removeAllLabel || 'Remove all', '«'));
2225
+ const order = el('div', 'shuttle-order');
2226
+ order.setAttribute('role', 'group');
2227
+ order.append(button('top', d.topLabel || 'Move to top', '⤒'), button('up', d.upLabel || 'Move up', '↑'), button('down', d.downLabel || 'Move down', '↓'), button('bottom', d.bottomLabel || 'Move to bottom', '⤓'));
2228
+ const left = el('div', 'shuttle-col');
2229
+ left.append(filter, avail);
2230
+ const right = el('div', 'shuttle-col');
2231
+ right.append(chosen);
2232
+ const ui = el('div', 'shuttle-ui');
2233
+ ui.append(left, moves, right, order);
2234
+ sel.hidden = true;
2235
+ box.append(ui);
2236
+ if (label) label.htmlFor = avail.id;
2237
+
2238
+ const src = new Map(); // a shown option → the item's option
2239
+ const lovIndex = (o) => Number(o.dataset.i ?? 0);
2240
+ // the item's options: the chosen ones first (in their order), then the others in the list of values' order
2241
+ const arrange = (picked) => {
2242
+ const rest = [...sel.options].filter((o) => !picked.includes(o)).sort((a, b) => lovIndex(a) - lovIndex(b));
2243
+ for (const o of sel.options) o.selected = picked.includes(o);
2244
+ sel.append(...picked, ...rest);
2245
+ };
2246
+ const picked = () => [...sel.options].filter((o) => o.selected);
2247
+ const draw = (keep = new Set()) => {
2248
+ const q = filter.value.trim().toLowerCase();
2249
+ const fill = (l, opts) => {
2250
+ l.replaceChildren(
2251
+ ...opts.map((o) => {
2252
+ const x = new Option(o.text, o.value);
2253
+ x.selected = keep.has(o);
2254
+ src.set(x, o);
2255
+ return x;
2256
+ }),
2257
+ );
2258
+ };
2259
+ src.clear();
2260
+ fill(avail, [...sel.options].filter((o) => !o.selected && (!q || o.text.toLowerCase().includes(q))));
2261
+ fill(chosen, picked());
2262
+ };
2263
+ let own = false;
2264
+ const changed = (keep) => {
2265
+ draw(keep);
2266
+ own = true;
2267
+ sel.dispatchEvent(new Event('change', { bubbles: true }));
2268
+ own = false;
2269
+ };
2270
+ const marked = (l) => [...l.selectedOptions].map((x) => src.get(x)).filter(Boolean);
2271
+ const run = (cmd) => {
2272
+ let now = picked();
2273
+ let keep = new Set();
2274
+ if (cmd === 'move' || cmd === 'all') {
2275
+ const add = cmd === 'all' ? [...avail.options].map((x) => src.get(x)) : marked(avail);
2276
+ if (!add.length) return;
2277
+ now = [...now, ...add];
2278
+ keep = new Set(add);
2279
+ } else if (cmd === 'remove' || cmd === 'none') {
2280
+ const drop = cmd === 'none' ? now : marked(chosen);
2281
+ if (!drop.length) return;
2282
+ now = now.filter((o) => !drop.includes(o));
2283
+ keep = new Set(drop);
2284
+ } else {
2285
+ const m = marked(chosen);
2286
+ if (!m.length) return;
2287
+ keep = new Set(m);
2288
+ const others = now.filter((o) => !keep.has(o));
2289
+ if (cmd === 'top') now = [...m, ...others];
2290
+ else if (cmd === 'bottom') now = [...others, ...m];
2291
+ else {
2292
+ const step = cmd === 'up' ? -1 : 1;
2293
+ const idx = (cmd === 'up' ? m : [...m].reverse()).map((o) => now.indexOf(o));
2294
+ for (const i of idx) {
2295
+ const j = i + step;
2296
+ if (j < 0 || j >= now.length || keep.has(now[j])) continue;
2297
+ [now[i], now[j]] = [now[j], now[i]];
2298
+ }
2299
+ }
2300
+ }
2301
+ arrange(now);
2302
+ changed(keep);
2303
+ };
2304
+ ui.addEventListener('click', (e) => {
2305
+ const b = e.target.closest('button[data-cmd]');
2306
+ if (b) run(b.dataset.cmd);
2307
+ });
2308
+ avail.addEventListener('dblclick', () => run('move'));
2309
+ chosen.addEventListener('dblclick', () => run('remove'));
2310
+ // the keyboard: Enter moves the marked values across; in the chosen list Alt+arrows order them
2311
+ avail.addEventListener('keydown', (e) => {
2312
+ if (e.key === 'Enter') {
2313
+ e.preventDefault();
2314
+ run('move');
2315
+ }
2316
+ });
2317
+ chosen.addEventListener('keydown', (e) => {
2318
+ if (e.key === 'Enter' || e.key === 'Delete') {
2319
+ e.preventDefault();
2320
+ run('remove');
2321
+ } else if (e.altKey && (e.key === 'ArrowUp' || e.key === 'ArrowDown')) {
2322
+ e.preventDefault();
2323
+ run(e.key === 'ArrowUp' ? (e.shiftKey ? 'top' : 'up') : e.shiftKey ? 'bottom' : 'down');
2324
+ }
2325
+ });
2326
+ // the lists' own change events are not the item's
2327
+ for (const l of [avail, chosen]) l.addEventListener('change', (e) => e.stopPropagation());
2328
+ filter.addEventListener('input', () => draw(new Set(marked(avail))));
2329
+ filter.addEventListener('change', (e) => e.stopPropagation());
2330
+ filter.addEventListener('keydown', (e) => {
2331
+ if (e.key === 'Enter') e.preventDefault();
2332
+ });
2333
+ // a dynamic action (Set value, Clear) changed the item
2334
+ sel.addEventListener('change', () => {
2335
+ if (!own) draw();
2336
+ });
2337
+ arrange(picked());
2338
+ draw();
2339
+ }
2340
+
2341
+ // (140; APEX 24.2: Select One, Select Many) A search field over the item's list of values: typing filters a list
2342
+ // box below it (arrow keys, Enter, Escape); Select Many shows the chosen values as chips with a remove button.
2343
+ // The item's <select> (single or multiple; it works without JavaScript) stays in the form, hidden, as the value.
2344
+ function enhanceSelectPicks(root) {
2345
+ const boxes = root.matches?.('[data-select-pick]') ? [root] : [...(root.querySelectorAll?.('[data-select-pick]') ?? [])];
2346
+ boxes.forEach(selectPick);
2347
+ }
2348
+ let selectPickSeq = 0;
2349
+ document.addEventListener('pgkiln:replaced', (e) => e.detail && enhanceSelectPicks(e.detail));
2350
+ enhanceSelectPicks(document);
2351
+ function selectPick(box) {
2352
+ const sel = box.querySelector('select');
2353
+ if (!sel || box.dataset.enhanced) return;
2354
+ box.dataset.enhanced = '1';
2355
+ const d = box.dataset;
2356
+ const many = sel.multiple;
2357
+ const id = sel.id;
2358
+ const label = document.querySelector(`label[for="${CSS.escape(id)}"]`);
2359
+ const input = document.createElement('input');
2360
+ input.type = 'text';
2361
+ input.id = `${id}_search`;
2362
+ input.className = 'select-pick-input';
2363
+ input.autocomplete = 'off';
2364
+ input.spellcheck = false;
2365
+ input.placeholder = d.searchLabel || 'Search…';
2366
+ input.setAttribute('role', 'combobox');
2367
+ input.setAttribute('aria-autocomplete', 'list');
2368
+ input.setAttribute('aria-expanded', 'false');
2369
+ for (const a of ['aria-describedby', 'aria-invalid', 'aria-required']) if (sel.hasAttribute(a)) input.setAttribute(a, sel.getAttribute(a));
2370
+ const listId = `${id}_listbox_${++selectPickSeq}`;
2371
+ input.setAttribute('aria-controls', listId);
2372
+ const list = document.createElement('ul');
2373
+ list.id = listId;
2374
+ list.className = 'select-pick-list';
2375
+ list.setAttribute('role', 'listbox');
2376
+ if (label) list.setAttribute('aria-label', label.textContent.replace(/\*\s*$/, '').trim());
2377
+ if (many) list.setAttribute('aria-multiselectable', 'true');
2378
+ list.hidden = true;
2379
+ const chips = document.createElement('ul');
2380
+ chips.className = 'tag-list select-pick-chips';
2381
+ const ui = document.createElement('div');
2382
+ ui.className = 'select-pick-ui';
2383
+ if (many) ui.append(chips);
2384
+ ui.append(input, list);
2385
+ sel.hidden = true;
2386
+ box.append(ui);
2387
+ if (label) label.htmlFor = input.id;
2388
+ const status = document.createElement('span');
2389
+ status.className = 'sr-only';
2390
+ status.setAttribute('aria-live', 'polite');
2391
+ ui.append(status);
2392
+
2393
+ let active = -1;
2394
+ let shown = [];
2395
+ const options = () => [...sel.options];
2396
+ const textOf = (o) => o.text;
2397
+ const syncInput = () => {
2398
+ if (!many) input.value = sel.value ? (sel.selectedOptions[0]?.text ?? '') : '';
2399
+ };
2400
+ const drawChips = () => {
2401
+ if (!many) return;
2402
+ chips.replaceChildren(
2403
+ ...[...sel.selectedOptions].map((o) => {
2404
+ const li = document.createElement('li');
2405
+ li.className = 'tag';
2406
+ li.append(o.text);
2407
+ const x = document.createElement('button');
2408
+ x.type = 'button';
2409
+ x.className = 'tag-remove';
2410
+ x.textContent = '×';
2411
+ x.setAttribute('aria-label', (d.removeLabel || 'Remove {value}').replace('{value}', o.text));
2412
+ x.addEventListener('click', () => {
2413
+ o.selected = false;
2414
+ changed();
2415
+ input.focus();
2416
+ });
2417
+ li.append(x);
2418
+ return li;
2419
+ }),
2420
+ );
2421
+ };
2422
+ let own = false;
2423
+ const changed = () => {
2424
+ drawChips();
2425
+ syncInput();
2426
+ if (!list.hidden) draw();
2427
+ own = true;
2428
+ sel.dispatchEvent(new Event('change', { bubbles: true }));
2429
+ own = false;
2430
+ };
2431
+ const setActive = (i) => {
2432
+ active = i;
2433
+ [...list.children].forEach((li, n) => li.classList.toggle('is-active', n === i));
2434
+ const li = list.children[i];
2435
+ if (li && li.id) {
2436
+ input.setAttribute('aria-activedescendant', li.id);
2437
+ li.scrollIntoView?.({ block: 'nearest' });
2438
+ } else input.removeAttribute('aria-activedescendant');
2439
+ };
2440
+ const draw = () => {
2441
+ const q = (many || input.dataset.typed ? input.value : '').trim().toLowerCase();
2442
+ shown = options().filter((o) => (many ? o.value !== '' : true) && (!q || textOf(o).toLowerCase().includes(q)));
2443
+ list.replaceChildren(
2444
+ ...shown.map((o, n) => {
2445
+ const li = document.createElement('li');
2446
+ li.id = `${listId}_${n}`;
2447
+ li.setAttribute('role', 'option');
2448
+ li.setAttribute('aria-selected', String(o.selected && (many || o.value !== '')));
2449
+ li.textContent = textOf(o);
2450
+ li.addEventListener('mousedown', (e) => e.preventDefault()); // keep the focus in the field
2451
+ li.addEventListener('click', () => pick(n));
2452
+ return li;
2453
+ }),
2454
+ );
2455
+ if (!shown.length) {
2456
+ const li = document.createElement('li');
2457
+ li.className = 'select-pick-none';
2458
+ li.textContent = d.noneLabel || 'Nothing found.';
2459
+ list.append(li);
2460
+ }
2461
+ status.textContent = shown.length ? '' : d.noneLabel || 'Nothing found.';
2462
+ setActive(shown.length ? Math.min(Math.max(active, 0), shown.length - 1) : -1);
2463
+ };
2464
+ const open = () => {
2465
+ if (!list.hidden) return;
2466
+ list.hidden = false;
2467
+ input.setAttribute('aria-expanded', 'true');
2468
+ active = many ? 0 : Math.max(0, options().findIndex((o) => o.selected));
2469
+ draw();
2470
+ };
2471
+ const close = () => {
2472
+ list.hidden = true;
2473
+ input.setAttribute('aria-expanded', 'false');
2474
+ input.removeAttribute('aria-activedescendant');
2475
+ delete input.dataset.typed;
2476
+ if (many) input.value = '';
2477
+ else syncInput();
2478
+ };
2479
+ const pick = (n) => {
2480
+ const o = shown[n];
2481
+ if (!o) return;
2482
+ if (many) {
2483
+ o.selected = !o.selected;
2484
+ input.value = '';
2485
+ active = n;
2486
+ changed();
2487
+ } else {
2488
+ sel.value = o.value;
2489
+ close();
2490
+ changed();
2491
+ }
2492
+ };
2493
+ input.addEventListener('focus', () => {
2494
+ if (!many) input.select?.();
2495
+ });
2496
+ input.addEventListener('click', open);
2497
+ input.addEventListener('input', () => {
2498
+ input.dataset.typed = '1';
2499
+ if (list.hidden) open();
2500
+ active = 0;
2501
+ draw();
2502
+ });
2503
+ input.addEventListener('change', (e) => e.stopPropagation()); // the item's value is the select's
2504
+ input.addEventListener('keydown', (e) => {
2505
+ if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
2506
+ e.preventDefault();
2507
+ if (list.hidden) return open();
2508
+ if (shown.length) setActive((active + (e.key === 'ArrowDown' ? 1 : -1) + shown.length) % shown.length);
2509
+ } else if (e.key === 'Enter') {
2510
+ if (list.hidden) return;
2511
+ e.preventDefault();
2512
+ if (active >= 0) pick(active);
2513
+ } else if (e.key === 'Escape') {
2514
+ if (list.hidden) return;
2515
+ e.preventDefault();
2516
+ close();
2517
+ } else if (e.key === 'Backspace' && many && !input.value && sel.selectedOptions.length) {
2518
+ sel.selectedOptions[sel.selectedOptions.length - 1].selected = false;
2519
+ changed();
2520
+ } else if (e.key === 'Tab') close();
2521
+ });
2522
+ input.addEventListener('blur', () => {
2523
+ if (list.hidden) return;
2524
+ // Select One: a field emptied by the user chooses no value
2525
+ if (!many && input.dataset.typed && !input.value.trim() && [...sel.options].some((o) => o.value === '')) {
2526
+ sel.value = '';
2527
+ close();
2528
+ changed();
2529
+ } else close();
2530
+ });
2531
+ // a dynamic action (Set value, Clear) changed the item
2532
+ sel.addEventListener('change', () => {
2533
+ if (own) return;
2534
+ drawChips();
2535
+ if (document.activeElement !== input) syncInput();
2536
+ });
2537
+ drawChips();
2538
+ syncInput();
2539
+ }
2540
+
2165
2541
  // Builder: role suggestions. Clicking a role adds it to the roles field of
2166
2542
  // the same form; in a form with an application select, only that app's
2167
2543
  // suggestions are shown.
@@ -15,6 +15,7 @@ import { configColumns, searchConfigProblems, textSearchConfig, tsvectorSql } fr
15
15
  import { COMPONENTS } from './components.ts';
16
16
  import { DISTANCES, PGVECTOR_MISSING, vectorColumnDims, vectorSchema, type Distance } from '../vector.ts';
17
17
  import { appEntries, type Entry } from './search.ts';
18
+ import { LOV_CHECKED } from '../runtime/items.ts';
18
19
  import { appHeader, BASE, developer, region, send, shell, type Req } from './ui.ts';
19
20
 
20
21
  // App Builder → Advisor (APEX: Advisor): checks an application without
@@ -48,7 +49,7 @@ export function sqlFields(kind: string, row: any): { name: string; shape: SqlSha
48
49
  switch (kind) {
49
50
  case 'region':
50
51
  return [
51
- ...(['report', 'grid', 'chart', 'cards', 'calendar', 'map', 'tree', 'dynamic'].includes(row.type) && row.source?.trim() && !row.rest_source ? [{ name: 'source', shape: 'select' as const }] : []),
52
+ ...(['report', 'classic_report', 'grid', 'chart', 'cards', 'calendar', 'map', 'tree', 'dynamic'].includes(row.type) && row.source?.trim() && !row.rest_source ? [{ name: 'source', shape: 'select' as const }] : []),
52
53
  { name: 'condition', shape: 'boolean' },
53
54
  ];
54
55
  case 'item':
@@ -428,6 +429,9 @@ export async function advise(appId: number, tx?: pg.ClientBase, opts: { db?: Db
428
429
  if (kind === 'region' && row.type === 'grid' && !all.some((x) => x.kind === 'process' && x.row.type === 'grid_dml' && x.row.region_id === row.id))
429
430
  missing(e, 'Source', 'No grid_dml process saves this grid, so it is read-only.', 'info');
430
431
  if (kind === 'region' && row.type === 'form' && row.pk_item && !items.has(String(row.pk_item).toUpperCase())) missing(e, 'Primary key item', `Item ${row.pk_item} doesn't exist.`);
432
+ // (140) a list item (select list, radio group, shuttle, select one or many …) shows no choices without a list of values
433
+ if (kind === 'item' && LOV_CHECKED.has(row.type) && !String(row.lov ?? '').trim())
434
+ missing(e, 'List of values', `A ${row.type} item needs a list of values: it has no choices, and no value passes the check on submit.`, 'warning');
431
435
  // (078) a URL region: a literal origin, valid attributes
432
436
  if (kind === 'region' && row.type === 'url') {
433
437
  const target = urlTarget(row.source);
@@ -45,6 +45,7 @@ export const REGION_LABELS: Record<string, [string, string]> = {
45
45
  workflows: ['Workflow Console', 'activity'], map: ['Map', 'map'], tree: ['Tree', 'org'], static: ['Static Content', 'region'], dynamic: ['Dynamic Content', 'code'],
46
46
  template_component: ['Template Component', 'layers'], list: ['List', 'list'], data_reporter: ['Data Reporter', 'table'], form_designer: ['Form Designer', 'file'],
47
47
  ai_assistant: ['AI Assistant', 'bolt'], url: ['URL', 'link'], search: ['Search', 'search'], login: ['Login', 'lock'], plugin: ['Plug-in', 'layers'],
48
+ classic_report: ['Classic Report', 'table'], breadcrumb: ['Breadcrumb', 'chevron'], help_text: ['Help Text', 'help'],
48
49
  };
49
50
  export const ITEM_LABELS: Record<string, [string, string]> = {
50
51
  text: ['Text Field', 'item'], textarea: ['Text Area', 'item'], number: ['Number Field', 'item'], date: ['Date Picker', 'calendar'],
@@ -53,6 +54,7 @@ export const ITEM_LABELS: Record<string, [string, string]> = {
53
54
  email: ['E-mail', 'inbox'], tel: ['Phone Number', 'item'], url: ['URL', 'item'], color: ['Color Picker', 'item'], file: ['File Upload', 'upload'],
54
55
  location: ['Location', 'map'], richtext: ['Rich Text Editor', 'edit'], markdown: ['Markdown Editor', 'code'], rating: ['Star Rating', 'check'],
55
56
  combobox: ['Combobox (Tags)', 'list'], daterange: ['Date Range', 'calendar'], qrcode: ['QR Code', 'scan'], image: ['Display Image', 'file'], address: ['Geocoded Address', 'map'], hidden: ['Hidden', 'item'], display: ['Display Only', 'file'], password: ['Password', 'key'],
57
+ shuttle: ['Shuttle', 'arrow-right'], select_one: ['Select One', 'search'], select_many: ['Select Many', 'check'],
56
58
  };
57
59
  export const BUTTON_LABELS: Record<string, [string, string]> = {
58
60
  submit: ['Submit Page', 'button'], redirect: ['Redirect to Page', 'button'], da: ['Dynamic Action', 'bolt'], document: ['Download Document', 'download'],
@@ -212,6 +214,11 @@ export async function createComponent(c: Q, pid: number, kind: LayoutKind, type:
212
214
  if (!REGION_TYPES().includes(type)) throw new LayoutError(400, 'Unknown region type.');
213
215
  id = (await c.query(`insert into meta.region (page_id, seq, title, type, source) values ($1, 999999, $2, $3, $4) returning id`,
214
216
  [pid, `New ${REGION_LABELS[type]?.[0].toLowerCase() ?? type}`, type, NEW_REGION_SOURCE[type] ?? null])).rows[0].id;
217
+ // (140) a breadcrumb goes in the breadcrumb bar, without a frame of its own (unless it is dropped elsewhere)
218
+ if (type === 'breadcrumb') {
219
+ await c.query(`update meta.region set template = 'plain', title = 'Breadcrumb' where id = $1`, [id]);
220
+ if (!o.position && !o.before) o = { ...o, position: 'breadcrumb' };
221
+ }
215
222
  } else {
216
223
  const region = await targetRegion(c, pid, o.region ?? '');
217
224
  if (kind === 'item') {
@@ -116,9 +116,9 @@ export const COMPONENTS: Record<string, ComponentSpec> = {
116
116
  { name: 'title', common: true, label: 'Title', kind: 'text', group: 'Identification' },
117
117
  { name: 'static_id', label: 'Static ID', kind: 'text', group: 'Identification',
118
118
  help: 'Optional, unique on the page: lower case letters, digits, _ and -, starting with a letter. Names the region in exported files (so renaming it keeps them) and is data-static-id on the page, for CSS and JavaScript.' },
119
- { name: 'type', common: true, label: 'Type', kind: 'select', options: ['report', 'grid', 'form', 'login', 'chart', 'cards', 'calendar', 'facets', 'smart_filters', 'display_selector', 'tasks', 'workflows', 'map', 'tree', 'template_component', 'list', 'static', 'dynamic', 'data_reporter', 'form_designer', 'ai_assistant', 'plugin', 'url', 'search'], group: 'Identification' },
119
+ { name: 'type', common: true, label: 'Type', kind: 'select', options: ['report', 'grid', 'form', 'login', 'chart', 'cards', 'calendar', 'facets', 'smart_filters', 'display_selector', 'tasks', 'workflows', 'map', 'tree', 'template_component', 'list', 'static', 'dynamic', 'data_reporter', 'form_designer', 'ai_assistant', 'plugin', 'url', 'search', 'classic_report', 'breadcrumb', 'help_text'], group: 'Identification' },
120
120
  { name: 'source', common: true, label: 'Source', kind: 'code', wide: true, group: 'Source',
121
- help: 'report/grid: a SELECT (use :ITEM binds) · chart: label column + one numeric column per series · cards: title, subtitle, body, badge, icon · calendar: start_date, end_date, title · map: lat and lng (or location "lat,lng"), title, body, geojson · tree: id, parent_id, label, icon · template_component: any SELECT (its columns are #COLUMN# in the template), or empty for one instance · dynamic: a SELECT returning HTML (escape with meta.html_escape) · static: HTML with &ITEM. substitutions · url: the page to show, https://host/path?q=&P1_ITEM. (the host is written out; items are URL-encoded) or a path on this server (/a/other/1).' },
121
+ help: 'report/grid/classic_report: a SELECT (use :ITEM binds) · chart: label column + one numeric column per series · cards: title, subtitle, body, badge, icon · calendar: start_date, end_date, title · map: lat and lng (or location "lat,lng"), title, body, geojson · tree: id, parent_id, label, icon · template_component: any SELECT (its columns are #COLUMN# in the template), or empty for one instance · dynamic: a SELECT returning HTML (escape with meta.html_escape) · static: HTML with &ITEM. substitutions · url: the page to show, https://host/path?q=&P1_ITEM. (the host is written out; items are URL-encoded) or a path on this server (/a/other/1).' },
122
122
  { name: 'rest_source', label: 'REST data source', kind: 'rest_source', group: 'Source',
123
123
  help: 'Read the rows of a REST data source (Shared Components) instead of a table: the source above is then optional SQL over them, e.g. select * from rest where price > 10. Parameters: {"rest_params": {"city": "&P1_CITY."}} in the attributes.' },
124
124
  { name: 'table_name', common: true, label: 'Table (form, grid)', kind: 'text', help: 'e.g. sales.orders', group: 'Source' },
@@ -149,13 +149,13 @@ export const COMPONENTS: Record<string, ComponentSpec> = {
149
149
  validate: (v) => formatSettingsProblem(v.config),
150
150
  fields: [
151
151
  { name: 'name', common: true, label: 'Name', kind: 'upper', help: 'Referenced in SQL as :NAME, e.g. P3_NAME', group: 'Identification' },
152
- { name: 'type', common: true, label: 'Type', kind: 'select', options: ['text', 'textarea', 'number', 'date', 'datetime', 'select', 'popup_lov', 'radio', 'checkbox', 'switch', 'checkbox_group', 'multiselect', 'email', 'tel', 'url', 'color', 'file', 'location', 'richtext', 'markdown', 'rating', 'combobox', 'daterange', 'qrcode', 'image', 'address', 'hidden', 'display', 'password', 'plugin'], group: 'Identification' },
152
+ { name: 'type', common: true, label: 'Type', kind: 'select', options: ['text', 'textarea', 'number', 'date', 'datetime', 'select', 'popup_lov', 'radio', 'checkbox', 'switch', 'checkbox_group', 'multiselect', 'email', 'tel', 'url', 'color', 'file', 'location', 'richtext', 'markdown', 'rating', 'combobox', 'daterange', 'qrcode', 'image', 'address', 'hidden', 'display', 'password', 'plugin', 'shuttle', 'select_one', 'select_many'], group: 'Identification' },
153
153
  { name: 'label', common: true, label: 'Label', kind: 'text', group: 'Identification' },
154
154
  { name: 'region_id', common: true, label: 'Region', kind: 'region', group: 'Layout' },
155
155
  { name: 'seq', label: 'Sequence', kind: 'int', group: 'Layout' },
156
156
  { name: 'source_column', common: true, label: 'Source column (form)', kind: 'text', group: 'Source' },
157
157
  { name: 'default_value', common: true, label: 'Default value', kind: 'text', group: 'Source' },
158
- { name: 'lov', common: true, label: 'List of values', kind: 'code', wide: true, group: 'List of values', help: 'select display, return from … · STATIC:Yes;Y,No;N · LOV:NAME (shared) · checkbox_group/multiselect store values colon-separated: string_to_array(:P1_X, \':\')' },
158
+ { name: 'lov', common: true, label: 'List of values', kind: 'code', wide: true, group: 'List of values', help: 'select display, return from … · STATIC:Yes;Y,No;N · LOV:NAME (shared) · checkbox_group/multiselect/shuttle/select_many store values colon-separated: string_to_array(:P1_X, \':\')' },
159
159
  { name: 'template_options', label: 'Template options', kind: 'options', choices: ITEM_OPTIONS, group: 'Appearance',
160
160
  help: 'CSS classes from a fixed list, added to the item (APEX: Template Options).' },
161
161
  { name: 'required', common: true, label: 'Value required', kind: 'bool', group: 'Validation' },
@@ -163,7 +163,7 @@ export const COMPONENTS: Record<string, ComponentSpec> = {
163
163
  { name: 'readonly_condition', label: 'Read-only condition (SQL)', kind: 'code', group: 'Security', help: 'When true the item is shown read-only and ignored on submit.' },
164
164
  { name: 'authz', label: 'Authorization', kind: 'authz', group: 'Security', help: AUTHZ_HELP },
165
165
  buildOption('Security'),
166
- { name: 'config', label: 'Attributes (JSON)', kind: 'json', group: 'Attributes', help: '{"submit_on_change":true,"null_label":"- All -","cascade_parents":"P3_DEPTNO","wide":true}. {"warn_unsaved":false}: a change of this item doesn\'t count for the page\'s "Warn on unsaved changes". Lists of values read at most {"max_rows":5000} rows. File items: {"filename_column":"photo_name","mime_column":"photo_mime","size_column":"photo_size","updated_column":"photo_updated","accept":"image/*,.pdf","max_mb":2,"capture":"environment","max_px":1600} (capture opens the camera on phones; max_px makes photos smaller before upload). {"crop":"1:1"} lets the user crop a picture before upload (free, 1:1, 4:3, 3:4, 16:9, 3:2, 2:3). Several files: {"multiple":true,"max_files":5,"table":"doc.attachment","parent_column":"ticket_id","key_column":"id"} with the content column as source (one row per file). Text items: {"scan":true} adds a barcode/QR scan button where the browser can read codes. Location items hold "lat,lng" with a "Use my location" button. Rich text: sanitised HTML; markdown: Markdown text ({"rows":10}). Rating: {"max":5} stars, stored as 1..max. Combobox: free text with list-of-values suggestions, values colon-separated ({"multiple":false} for one value). Date range: "from:to" (ISO dates; split_part(:P1_X, \':\', 1)). QR code: shows the value as a QR code ({"ecc":"M","size":200,"show_value":true}). Password: {"reveal":true} adds a show/hide button. Image (display image): the value is an https URL, a path or a data:image URL; or {"sql":"select photo, photo_mime from hr.emp where id = :P3_ID"} shows the first column of the first row (a BLOB); {"alt":"Photo of &P3_NAME.","width":160,"height":160}. Address: {"coordinates_item":"P3_LOCATION","country":"nl"} (a "Check address" button where the installation has a geocoder, GEOCODER_URL; the chosen address\'s position goes into the item as "lat,lng"). Any item: {"persist":"user"} keeps its value per user across sessions (a user preference named like the item). Number and display items: {"format_mask":"999G999G990D00"} shows the number in the language\'s notation (FML999G990D00 adds the currency) and reads it back on submit. · plugin: {"plugin":"name","attributes":{"NAME":"value"}} (an item plug-in: a text field its JavaScript enhances)' },
166
+ { name: 'config', label: 'Attributes (JSON)', kind: 'json', group: 'Attributes', help: '{"submit_on_change":true,"null_label":"- All -","cascade_parents":"P3_DEPTNO","wide":true}. {"warn_unsaved":false}: a change of this item doesn\'t count for the page\'s "Warn on unsaved changes". Lists of values read at most {"max_rows":5000} rows. File items: {"filename_column":"photo_name","mime_column":"photo_mime","size_column":"photo_size","updated_column":"photo_updated","accept":"image/*,.pdf","max_mb":2,"capture":"environment","max_px":1600} (capture opens the camera on phones; max_px makes photos smaller before upload). {"crop":"1:1"} lets the user crop a picture before upload (free, 1:1, 4:3, 3:4, 16:9, 3:2, 2:3). Several files: {"multiple":true,"max_files":5,"table":"doc.attachment","parent_column":"ticket_id","key_column":"id"} with the content column as source (one row per file). Text items: {"scan":true} adds a barcode/QR scan button where the browser can read codes. Location items hold "lat,lng" with a "Use my location" button. Rich text: sanitised HTML; markdown: Markdown text ({"rows":10}). Rating: {"max":5} stars, stored as 1..max. Combobox: free text with list-of-values suggestions, values colon-separated ({"multiple":false} for one value). Shuttle: two lists (available, chosen) with move and order buttons; the chosen values colon-separated in their order. Select One / Select Many: a search field over the list of values (Select Many: chosen values as chips, colon-separated); {"null_label":"- None -"} or false for Select One. Date range: "from:to" (ISO dates; split_part(:P1_X, \':\', 1)). QR code: shows the value as a QR code ({"ecc":"M","size":200,"show_value":true}). Password: {"reveal":true} adds a show/hide button. Image (display image): the value is an https URL, a path or a data:image URL; or {"sql":"select photo, photo_mime from hr.emp where id = :P3_ID"} shows the first column of the first row (a BLOB); {"alt":"Photo of &P3_NAME.","width":160,"height":160}. Address: {"coordinates_item":"P3_LOCATION","country":"nl"} (a "Check address" button where the installation has a geocoder, GEOCODER_URL; the chosen address\'s position goes into the item as "lat,lng"). Any item: {"persist":"user"} keeps its value per user across sessions (a user preference named like the item). Number and display items: {"format_mask":"999G999G990D00"} shows the number in the language\'s notation (FML999G990D00 adds the currency) and reads it back on submit. · plugin: {"plugin":"name","attributes":{"NAME":"value"}} (an item plug-in: a text field its JavaScript enhances)' },
167
167
  ],
168
168
  },
169
169
  button: {
@@ -404,6 +404,11 @@ export async function designerRoutes(app: FastifyInstance) {
404
404
  ${select('build_option', 'Build option', p.build_option ?? '', buildOptionChoices(lk, p.build_option),
405
405
  'While the option is excluded (or "Not": included) the page does not exist in the running application.')}
406
406
  </div></fieldset>
407
+ <fieldset class="prop-group"><legend>Help</legend><div class="form-grid">
408
+ <div class="field" data-wide><label class="label" for="f_help">Help text</label>
409
+ <textarea id="f_help" name="help" rows="5" maxlength="20000">${p.help ?? ''}</textarea>
410
+ <small class="help">Markdown with &amp;ITEM. substitutions. A region of type Help Text on the page shows it (APEX: Page → Help).</small></div>
411
+ </div></fieldset>
407
412
  <div class="buttons"><button class="btn btn-hot">${tx('pd.save_page')}</button></div>
408
413
  </form>`, true)])}
409
414
  ${await usedInPanel(p.app_id, 'page', p)}
@@ -505,9 +510,10 @@ export async function designerRoutes(app: FastifyInstance) {
505
510
  const before = await owner.one('select app_id, page_no from meta.page where id = $1', [req.params.pid]);
506
511
  await owner.query(
507
512
  `update meta.page set page_no = $2, name = $3, title = $4, requires_auth = $5, mode = $6, parent_page = $7, authz = $8, protection = $9, build_option = $10,
508
- dialog_position = $11, dialog_size = $12, static_includes = $13, warn_unsaved = $14, page_template = $15, page_group = $16 where id = $1`,
513
+ dialog_position = $11, dialog_size = $12, static_includes = $13, warn_unsaved = $14, page_template = $15, page_group = $16, help = $17 where id = $1`,
509
514
  [req.params.pid, Number(b.page_no), b.name?.trim(), b.title?.trim() || null, b.requires_auth === 'true', b.mode, b.parent_page ? Number(b.parent_page) : null, b.authz || null, b.protection, b.build_option?.trim().toUpperCase() || null,
510
- ['left', 'right', 'top', 'bottom'].includes(b.dialog_position ?? '') ? b.dialog_position : 'center', ['small', 'large'].includes(b.dialog_size ?? '') ? b.dialog_size : 'medium', parseIncludes(b.static_includes), b.warn_unsaved === 'true', ['minimal', 'left_side_column', 'right_side_column'].includes(b.page_template ?? '') ? b.page_template : 'standard', b.page_group?.trim() || null],
515
+ ['left', 'right', 'top', 'bottom'].includes(b.dialog_position ?? '') ? b.dialog_position : 'center', ['small', 'large'].includes(b.dialog_size ?? '') ? b.dialog_size : 'medium', parseIncludes(b.static_includes), b.warn_unsaved === 'true', ['minimal', 'left_side_column', 'right_side_column'].includes(b.page_template ?? '') ? b.page_template : 'standard', b.page_group?.trim() || null,
516
+ typeof b.help === 'string' && b.help.trim() ? b.help.replace(/\r\n?/g, '\n').trim().slice(0, 20000) : null],
511
517
  );
512
518
  // the page's lock and comments follow a new page number
513
519
  if (before && before.page_no !== Number(b.page_no))
@@ -552,6 +552,9 @@ export const de: BuilderMessages = {
552
552
  'gallery.region.search': 'Suche',
553
553
  'gallery.region.login': 'Anmeldung',
554
554
  'gallery.region.plugin': 'Plug-in',
555
+ 'gallery.region.classic_report': 'Klassischer Bericht',
556
+ 'gallery.region.breadcrumb': 'Navigationspfad',
557
+ 'gallery.region.help_text': 'Hilfetext',
555
558
  'gallery.item.text': 'Textfeld',
556
559
  'gallery.item.textarea': 'Textbereich',
557
560
  'gallery.item.number': 'Zahlenfeld',
@@ -582,6 +585,9 @@ export const de: BuilderMessages = {
582
585
  'gallery.item.display': 'Nur anzeigen',
583
586
  'gallery.item.password': 'Kennwort',
584
587
  'gallery.item.plugin': 'Plug-in',
588
+ 'gallery.item.shuttle': 'Shuttle',
589
+ 'gallery.item.select_one': 'Einzelauswahl (Suche)',
590
+ 'gallery.item.select_many': 'Mehrfachauswahl (Suche)',
585
591
  'gallery.button.submit': 'Seite weiterleiten',
586
592
  'gallery.button.redirect': 'Zu Seite umleiten',
587
593
  'gallery.button.da': 'Dynamische Aktion',
@@ -555,6 +555,9 @@ export const en = {
555
555
  'gallery.region.search': 'Search',
556
556
  'gallery.region.login': 'Login',
557
557
  'gallery.region.plugin': 'Plug-in',
558
+ 'gallery.region.classic_report': 'Classic Report',
559
+ 'gallery.region.breadcrumb': 'Breadcrumb',
560
+ 'gallery.region.help_text': 'Help Text',
558
561
  'gallery.item.text': 'Text Field',
559
562
  'gallery.item.textarea': 'Text Area',
560
563
  'gallery.item.number': 'Number Field',
@@ -585,6 +588,9 @@ export const en = {
585
588
  'gallery.item.display': 'Display Only',
586
589
  'gallery.item.password': 'Password',
587
590
  'gallery.item.plugin': 'Plug-in',
591
+ 'gallery.item.shuttle': 'Shuttle',
592
+ 'gallery.item.select_one': 'Select One',
593
+ 'gallery.item.select_many': 'Select Many',
588
594
  'gallery.button.submit': 'Submit Page',
589
595
  'gallery.button.redirect': 'Redirect to Page',
590
596
  'gallery.button.da': 'Dynamic Action',