pgkiln 0.42.0 → 0.44.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.
- package/db/migrations/099_layout_positions.sql +29 -0
- package/db/migrations/100_generated_labels.sql +381 -0
- package/db/migrations/101_theme_choice.sql +15 -0
- package/docs/apex-feature-parity.md +36 -9
- package/docs/guide/03-builder.md +82 -21
- package/docs/guide/04-pages-and-regions.md +55 -5
- package/docs/guide/05-items.md +1 -1
- package/docs/guide/09-reference.md +5 -1
- package/docs/guide/12-development.md +2 -1
- package/docs/guide/14-globalization.md +5 -1
- package/package.json +1 -1
- package/public/app.css +80 -0
- package/public/app.js +189 -1
- package/public/builder.css +112 -0
- package/public/builder.js +280 -3
- package/src/builder/appsheets.ts +53 -13
- package/src/builder/appwizard.ts +5 -3
- package/src/builder/arrange.ts +133 -17
- package/src/builder/components.ts +28 -22
- package/src/builder/designer.ts +67 -18
- package/src/builder/forms.ts +4 -1
- package/src/builder/object-browser.ts +342 -0
- package/src/builder/pwa.ts +3 -3
- package/src/builder/routes.ts +85 -26
- package/src/builder/shared.ts +103 -1
- package/src/builder/sql.ts +13 -65
- package/src/builder/ui.ts +21 -6
- package/src/i18n/ar.ts +18 -0
- package/src/i18n/cs.ts +18 -0
- package/src/i18n/da.ts +18 -0
- package/src/i18n/de.ts +18 -0
- package/src/i18n/el.ts +18 -0
- package/src/i18n/es.ts +18 -0
- package/src/i18n/fi.ts +18 -0
- package/src/i18n/fr.ts +18 -0
- package/src/i18n/he.ts +18 -0
- package/src/i18n/it.ts +18 -0
- package/src/i18n/ja.ts +18 -0
- package/src/i18n/ko.ts +18 -0
- package/src/i18n/nb.ts +18 -0
- package/src/i18n/pl.ts +18 -0
- package/src/i18n/pt.ts +18 -0
- package/src/i18n/ru.ts +18 -0
- package/src/i18n/sv.ts +18 -0
- package/src/i18n/tr.ts +18 -0
- package/src/i18n/uk.ts +18 -0
- package/src/i18n/zh.ts +18 -0
- package/src/i18n.ts +36 -0
- package/src/metadata.ts +5 -1
- package/src/runtime/account.ts +5 -5
- package/src/runtime/devbar.ts +16 -13
- package/src/runtime/engine.ts +6 -0
- package/src/runtime/facets.ts +34 -3
- package/src/runtime/format.ts +52 -0
- package/src/runtime/items.ts +27 -0
- package/src/runtime/locale.ts +7 -1
- package/src/runtime/regions.ts +36 -2
- package/src/runtime/render.ts +27 -6
- package/src/runtime/report.ts +141 -81
- package/src/runtime/routes.ts +39 -7
package/src/i18n/tr.ts
CHANGED
|
@@ -111,6 +111,21 @@ export const tr: Record<MessageKey, string> = {
|
|
|
111
111
|
'color.blue': "Mavi",
|
|
112
112
|
'color.gray': "Gri",
|
|
113
113
|
'report.saved_reports': "Kayıtlı raporlar",
|
|
114
|
+
'report.first': "İlk sayfa",
|
|
115
|
+
'report.last': "Son sayfa",
|
|
116
|
+
'report.hidden_column': "Gizli",
|
|
117
|
+
'report.show_column': "Sütunu yeniden göster",
|
|
118
|
+
'report.search_column': "Şurada ara",
|
|
119
|
+
'report.all_columns': "Tüm sütunlar",
|
|
120
|
+
'report.columns': "Sütunlar",
|
|
121
|
+
'report.hide_columns': "Gizlenecek sütunlar",
|
|
122
|
+
'report.data': "Veri",
|
|
123
|
+
'report.format': "Biçim",
|
|
124
|
+
'report.report': "Rapor",
|
|
125
|
+
'report.download_menu': "İndir",
|
|
126
|
+
'report.hide': "Sütunu gizle",
|
|
127
|
+
'report.filter_values': "Değerleri filtrele",
|
|
128
|
+
'report.no_values': "Değer yok",
|
|
114
129
|
'report.save_as': "Farklı kaydet…",
|
|
115
130
|
'report.name': "Ad",
|
|
116
131
|
'report.save': "Kaydet",
|
|
@@ -154,6 +169,7 @@ export const tr: Record<MessageKey, string> = {
|
|
|
154
169
|
'pwa.status.invalid': "reddedildi: sayfayı açıp düzeltin",
|
|
155
170
|
'pwa.status.error': "gönderilmedi",
|
|
156
171
|
'item.locate': "Konumumu kullan",
|
|
172
|
+
'item.choose_date': "Bir tarih seçin",
|
|
157
173
|
'item.show_map': "harita",
|
|
158
174
|
'item.scan': "Tara",
|
|
159
175
|
'item.locate_error': "Konum kullanılamıyor.",
|
|
@@ -374,6 +390,8 @@ export const tr: Record<MessageKey, string> = {
|
|
|
374
390
|
'facets.stars_up': "{n} yıldız ve üzeri",
|
|
375
391
|
'facets.star_up': "1 yıldız ve üzeri",
|
|
376
392
|
'facets.clear_short': "Temizle",
|
|
393
|
+
'facets.show_all': "Tümünü göster ({n})",
|
|
394
|
+
'facets.total': "Toplam satır sayısı {n}",
|
|
377
395
|
'smart.placeholder': "Ara veya süz…",
|
|
378
396
|
'smart.active': "Kullanılan süzgeçler",
|
|
379
397
|
'smart.remove': "{filter} süzgecini kaldır",
|
package/src/i18n/uk.ts
CHANGED
|
@@ -111,6 +111,21 @@ export const uk: Record<MessageKey, string> = {
|
|
|
111
111
|
'color.blue': "Синій",
|
|
112
112
|
'color.gray': "Сірий",
|
|
113
113
|
'report.saved_reports': "Збережені звіти",
|
|
114
|
+
'report.first': "Перша сторінка",
|
|
115
|
+
'report.last': "Остання сторінка",
|
|
116
|
+
'report.hidden_column': "Прихований",
|
|
117
|
+
'report.show_column': "Знову показати стовпець",
|
|
118
|
+
'report.search_column': "Шукати в",
|
|
119
|
+
'report.all_columns': "Усі стовпці",
|
|
120
|
+
'report.columns': "Стовпці",
|
|
121
|
+
'report.hide_columns': "Приховати стовпці",
|
|
122
|
+
'report.data': "Дані",
|
|
123
|
+
'report.format': "Формат",
|
|
124
|
+
'report.report': "Звіт",
|
|
125
|
+
'report.download_menu': "Завантажити",
|
|
126
|
+
'report.hide': "Приховати стовпець",
|
|
127
|
+
'report.filter_values': "Фільтр значень",
|
|
128
|
+
'report.no_values': "Немає значень",
|
|
114
129
|
'report.save_as': "Зберегти як…",
|
|
115
130
|
'report.name': "Назва",
|
|
116
131
|
'report.save': "Зберегти",
|
|
@@ -154,6 +169,7 @@ export const uk: Record<MessageKey, string> = {
|
|
|
154
169
|
'pwa.status.invalid': "відхилено: відкрийте сторінку й виправте",
|
|
155
170
|
'pwa.status.error': "не надіслано",
|
|
156
171
|
'item.locate': "Моє розташування",
|
|
172
|
+
'item.choose_date': "Вибрати дату",
|
|
157
173
|
'item.show_map': "карта",
|
|
158
174
|
'item.scan': "Сканувати",
|
|
159
175
|
'item.locate_error': "Розташування недоступне.",
|
|
@@ -374,6 +390,8 @@ export const uk: Record<MessageKey, string> = {
|
|
|
374
390
|
'facets.stars_up': "{n} зірки й вище",
|
|
375
391
|
'facets.star_up': "1 зірка й вище",
|
|
376
392
|
'facets.clear_short': "Очистити",
|
|
393
|
+
'facets.show_all': "Показати все ({n})",
|
|
394
|
+
'facets.total': "Усього рядків {n}",
|
|
377
395
|
'smart.placeholder': "Пошук або фільтр…",
|
|
378
396
|
'smart.active': "Чинні фільтри",
|
|
379
397
|
'smart.remove': "Видалити фільтр {filter}",
|
package/src/i18n/zh.ts
CHANGED
|
@@ -111,6 +111,21 @@ export const zh: Record<MessageKey, string> = {
|
|
|
111
111
|
'color.blue': "蓝色",
|
|
112
112
|
'color.gray': "灰色",
|
|
113
113
|
'report.saved_reports': "已保存的报表",
|
|
114
|
+
'report.first': "第一页",
|
|
115
|
+
'report.last': "最后一页",
|
|
116
|
+
'report.hidden_column': "已隐藏",
|
|
117
|
+
'report.show_column': "重新显示该列",
|
|
118
|
+
'report.search_column': "搜索范围",
|
|
119
|
+
'report.all_columns': "所有列",
|
|
120
|
+
'report.columns': "列",
|
|
121
|
+
'report.hide_columns': "要隐藏的列",
|
|
122
|
+
'report.data': "数据",
|
|
123
|
+
'report.format': "格式",
|
|
124
|
+
'report.report': "报表",
|
|
125
|
+
'report.download_menu': "下载",
|
|
126
|
+
'report.hide': "隐藏列",
|
|
127
|
+
'report.filter_values': "筛选值",
|
|
128
|
+
'report.no_values': "没有值",
|
|
114
129
|
'report.save_as': "另存为…",
|
|
115
130
|
'report.name': "名称",
|
|
116
131
|
'report.save': "保存",
|
|
@@ -154,6 +169,7 @@ export const zh: Record<MessageKey, string> = {
|
|
|
154
169
|
'pwa.status.invalid': "被拒绝:请打开页面并更正",
|
|
155
170
|
'pwa.status.error': "未发送",
|
|
156
171
|
'item.locate': "使用我的位置",
|
|
172
|
+
'item.choose_date': "选择日期",
|
|
157
173
|
'item.show_map': "地图",
|
|
158
174
|
'item.scan': "扫描",
|
|
159
175
|
'item.locate_error': "无法获取位置。",
|
|
@@ -374,6 +390,8 @@ export const zh: Record<MessageKey, string> = {
|
|
|
374
390
|
'facets.stars_up': "{n} 星及以上",
|
|
375
391
|
'facets.star_up': "1 星及以上",
|
|
376
392
|
'facets.clear_short': "清除",
|
|
393
|
+
'facets.show_all': "显示全部 ({n})",
|
|
394
|
+
'facets.total': "总行数 {n}",
|
|
377
395
|
'smart.placeholder': "搜索或筛选…",
|
|
378
396
|
'smart.active': "正在使用的筛选器",
|
|
379
397
|
'smart.remove': "删除筛选器 {filter}",
|
package/src/i18n.ts
CHANGED
|
@@ -138,6 +138,21 @@ const en = {
|
|
|
138
138
|
'color.blue': 'Blue',
|
|
139
139
|
'color.gray': 'Gray',
|
|
140
140
|
'report.saved_reports': 'Saved reports',
|
|
141
|
+
'report.first': "First page",
|
|
142
|
+
'report.last': "Last page",
|
|
143
|
+
'report.hidden_column': "Hidden",
|
|
144
|
+
'report.show_column': "Show the column again",
|
|
145
|
+
'report.search_column': "Search in",
|
|
146
|
+
'report.all_columns': "All columns",
|
|
147
|
+
'report.columns': "Columns",
|
|
148
|
+
'report.hide_columns': "Columns to hide",
|
|
149
|
+
'report.data': "Data",
|
|
150
|
+
'report.format': "Format",
|
|
151
|
+
'report.report': "Report",
|
|
152
|
+
'report.download_menu': "Download",
|
|
153
|
+
'report.hide': "Hide column",
|
|
154
|
+
'report.filter_values': "Filter the values",
|
|
155
|
+
'report.no_values': "No values",
|
|
141
156
|
'report.save_as': 'Save as…',
|
|
142
157
|
'report.name': 'Name',
|
|
143
158
|
'report.save': 'Save',
|
|
@@ -181,6 +196,7 @@ const en = {
|
|
|
181
196
|
'pwa.status.invalid': 'rejected: open the page and correct it',
|
|
182
197
|
'pwa.status.error': 'not sent',
|
|
183
198
|
'item.locate': 'Use my location',
|
|
199
|
+
'item.choose_date': "Choose a date",
|
|
184
200
|
'item.show_map': 'map',
|
|
185
201
|
'item.scan': 'Scan',
|
|
186
202
|
'item.locate_error': 'The location is not available.',
|
|
@@ -401,6 +417,8 @@ const en = {
|
|
|
401
417
|
'facets.stars_up': '{n} stars and up',
|
|
402
418
|
'facets.star_up': '1 star and up',
|
|
403
419
|
'facets.clear_short': 'Clear',
|
|
420
|
+
'facets.show_all': 'Show all ({n})',
|
|
421
|
+
'facets.total': 'Total row count {n}',
|
|
404
422
|
'smart.placeholder': 'Search or filter…',
|
|
405
423
|
'smart.active': 'Filters in use',
|
|
406
424
|
'smart.remove': 'Remove filter {filter}',
|
|
@@ -827,6 +845,21 @@ const nl: Record<MessageKey, string> = {
|
|
|
827
845
|
'color.blue': 'Blauw',
|
|
828
846
|
'color.gray': 'Grijs',
|
|
829
847
|
'report.saved_reports': 'Opgeslagen rapporten',
|
|
848
|
+
'report.first': "Eerste pagina",
|
|
849
|
+
'report.last': "Laatste pagina",
|
|
850
|
+
'report.hidden_column': "Verborgen",
|
|
851
|
+
'report.show_column': "Kolom weer tonen",
|
|
852
|
+
'report.search_column': "Zoeken in",
|
|
853
|
+
'report.all_columns': "Alle kolommen",
|
|
854
|
+
'report.columns': "Kolommen",
|
|
855
|
+
'report.hide_columns': "Kolommen verbergen",
|
|
856
|
+
'report.data': "Gegevens",
|
|
857
|
+
'report.format': "Opmaak",
|
|
858
|
+
'report.report': "Rapport",
|
|
859
|
+
'report.download_menu': "Downloaden",
|
|
860
|
+
'report.hide': "Kolom verbergen",
|
|
861
|
+
'report.filter_values': "Waarden filteren",
|
|
862
|
+
'report.no_values': "Geen waarden",
|
|
830
863
|
'report.save_as': 'Opslaan als…',
|
|
831
864
|
'report.name': 'Naam',
|
|
832
865
|
'report.save': 'Opslaan',
|
|
@@ -870,6 +903,7 @@ const nl: Record<MessageKey, string> = {
|
|
|
870
903
|
'pwa.status.invalid': 'geweigerd: open de pagina en verbeter het',
|
|
871
904
|
'pwa.status.error': 'niet verzonden',
|
|
872
905
|
'item.locate': 'Mijn locatie',
|
|
906
|
+
'item.choose_date': "Kies een datum",
|
|
873
907
|
'item.show_map': 'kaart',
|
|
874
908
|
'item.scan': 'Scannen',
|
|
875
909
|
'item.locate_error': 'De locatie is niet beschikbaar.',
|
|
@@ -1089,6 +1123,8 @@ const nl: Record<MessageKey, string> = {
|
|
|
1089
1123
|
'facets.stars_up': '{n} sterren of meer',
|
|
1090
1124
|
'facets.star_up': '1 ster of meer',
|
|
1091
1125
|
'facets.clear_short': 'Wissen',
|
|
1126
|
+
'facets.show_all': 'Alle tonen ({n})',
|
|
1127
|
+
'facets.total': 'Totaal aantal rijen {n}',
|
|
1092
1128
|
'smart.placeholder': 'Zoeken of filteren…',
|
|
1093
1129
|
'smart.active': 'Gebruikte filters',
|
|
1094
1130
|
'smart.remove': 'Filter {filter} verwijderen',
|
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. */
|
package/src/runtime/account.ts
CHANGED
|
@@ -7,7 +7,7 @@ import { forgetAllRemembered, rememberCookie, rememberedCount } from '../remembe
|
|
|
7
7
|
import { getSession, loginThrottled, logActivity, saveState, takeFlash } from '../session.ts';
|
|
8
8
|
import { loginWindowMinutes } from '../security.ts';
|
|
9
9
|
import type { PageContext } from './context.ts';
|
|
10
|
-
import { databaseTimeZone, isTheme, matchLanguage, sameOffset, THEME_COOKIE, timeZoneFor, timeZoneNames, validTimeZone } from './locale.ts';
|
|
10
|
+
import { databaseTimeZone, isTheme, matchLanguage, sameOffset, THEME_COOKIE, themeCookieOptions, timeZoneFor, timeZoneNames, validTimeZone } from './locale.ts';
|
|
11
11
|
import { chrome, clientTexts } from './render.ts';
|
|
12
12
|
import { appStyles, choosable, chosenStyleName, styleChoice } from './styles.ts';
|
|
13
13
|
import { loadApp } from '../metadata.ts';
|
|
@@ -28,8 +28,8 @@ async function saveStyle(appId: number, username: string, style: string) {
|
|
|
28
28
|
);
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
-
|
|
32
|
-
|
|
31
|
+
// (sprint 79) per application: a choice in one application doesn't change another's mode
|
|
32
|
+
const themeCookie = (reply: FastifyReply, theme: string, base: string) => reply.setCookie(THEME_COOKIE, theme, themeCookieOptions(base));
|
|
33
33
|
|
|
34
34
|
/** My account → Time zone: automatic (the browser's) or one of PostgreSQL's time zones. */
|
|
35
35
|
async function timeZoneField(ctx: PageContext) {
|
|
@@ -160,7 +160,7 @@ export async function accountRoutes(app: FastifyInstance) {
|
|
|
160
160
|
}
|
|
161
161
|
if (theme) {
|
|
162
162
|
ctx.session.state.__THEME = theme;
|
|
163
|
-
themeCookie(reply, theme);
|
|
163
|
+
themeCookie(reply, theme, ctx.base);
|
|
164
164
|
}
|
|
165
165
|
if (language) ctx.session.state.__LANG = language;
|
|
166
166
|
// a style variant: only one of the app's own styles ('' = the base colours)
|
|
@@ -263,7 +263,7 @@ export async function accountRoutes(app: FastifyInstance) {
|
|
|
263
263
|
const to = safeNext(a, b.next);
|
|
264
264
|
if (b.__csrf !== session.csrf_token || a.theme?.user_choice === false || !isTheme(b.theme)) return reply.redirect(to, 303);
|
|
265
265
|
session.state.__THEME = b.theme;
|
|
266
|
-
themeCookie(reply, b.theme);
|
|
266
|
+
themeCookie(reply, b.theme, `/a/${a.alias}`);
|
|
267
267
|
if (session.username) {
|
|
268
268
|
await runtime.query('update meta.account set theme_pref = $2 where lower(username) = lower($1)', [session.username, b.theme]);
|
|
269
269
|
}
|
package/src/runtime/devbar.ts
CHANGED
|
@@ -86,26 +86,28 @@ export function devbarHtml(ctx: PageContext): Raw | '' {
|
|
|
86
86
|
const level = sessionDebugLevel(ctx.session);
|
|
87
87
|
const appLevel = Number(ctx.app.debug_level ?? 0);
|
|
88
88
|
const page = ctx.page.page_no;
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
const
|
|
92
|
-
html`<li
|
|
89
|
+
// (sprint 79, UI-4; APEX: App 77536, Page 8, Debug ⏻) each tool with a short text; "Icons only" in Settings
|
|
90
|
+
const text = (s: string) => html`<span class="devbar-text" aria-hidden="true">${s}</span>`;
|
|
91
|
+
const tool = (href: string, label: string, name: string, short: string) =>
|
|
92
|
+
html`<li><a class="devbar-tool" href="${href}" title="${label}" aria-label="${label}">${icon(name)}${text(short)}</a></li>`;
|
|
93
|
+
const menu = (label: string, name: string, cls: string, body: Raw, hidden = false, short = label) =>
|
|
94
|
+
html`<li${hidden ? raw(' hidden') : ''}><details class="devbar-menu ${cls}"><summary class="devbar-tool" title="${label}" aria-label="${label}">${icon(name)}${text(short)}</summary>
|
|
93
95
|
<div class="devbar-pop" role="group" aria-label="${label}">${body}</div></details></li>`;
|
|
94
96
|
return html`<div class="devbar" data-dock="bottom">
|
|
95
97
|
<nav aria-label="Developer toolbar"><ul class="devbar-tools">
|
|
96
|
-
${tool(BUILDER, 'App Builder', 'home')}
|
|
97
|
-
${tool(`${BUILDER}/apps/${ctx.app.id}`, `Application ${ctx.app.id}`, 'grid')}
|
|
98
|
-
${tool(`${BUILDER}/pages/${ctx.page.id}`, `Edit page ${page}`, 'edit')}
|
|
99
|
-
${tool(`${ctx.base}/${page}/devbar/session`, 'Session', 'database')}
|
|
100
|
-
${menu(
|
|
98
|
+
${tool(BUILDER, 'App Builder', 'home', 'Home')}
|
|
99
|
+
${tool(`${BUILDER}/apps/${ctx.app.id}`, `Application ${ctx.app.id}`, 'grid', `App ${ctx.app.id}`)}
|
|
100
|
+
${tool(`${BUILDER}/pages/${ctx.page.id}`, `Edit page ${page}`, 'edit', `Page ${page}`)}
|
|
101
|
+
${tool(`${ctx.base}/${page}/devbar/session`, 'Session', 'database', 'Session')}
|
|
102
|
+
${menu(`Debug: ${level || appLevel ? 'on' : 'off'}`, 'bolt', `devbar-debug-menu${level || appLevel ? ' is-on' : ''}`, html`<form method="post" action="${ctx.base}/${page}/devbar/debug" class="devbar-debug">
|
|
101
103
|
<input type="hidden" name="__csrf" value="${ctx.session.csrf_token}">
|
|
102
104
|
<label for="devbar-debug">Debug messages</label>
|
|
103
105
|
<select id="devbar-debug" name="level">${DEBUG_LEVELS.map(([n, label]) =>
|
|
104
106
|
html`<option value="${n}"${n === level ? raw(' selected') : ''}>${n === 0 ? (appLevel ? `Application (${appLevel})` : 'Off') : label}</option>`)}</select>
|
|
105
107
|
<button>Set</button>
|
|
106
|
-
</form>${level || appLevel ? html`<a href="${BUILDER}/apps/${ctx.app.id}/debug">View debug</a>` : ''}`)}
|
|
107
|
-
<li><button type="button" class="devbar-tool devbar-quick" data-quick-edit="${BUILDER}/pages/${ctx.page.id}" aria-pressed="false" title="Quick edit" aria-label="Quick edit" hidden>${icon('scan')}</button></li>
|
|
108
|
-
${tool(`${BUILDER}/apps/${ctx.app.id}/theme`, 'Customize (Theme Roller)', 'sliders')}
|
|
108
|
+
</form>${level || appLevel ? html`<a href="${BUILDER}/apps/${ctx.app.id}/debug">View debug</a>` : ''}`, false, `Debug ${level || appLevel ? 'on' : 'off'}`)}
|
|
109
|
+
<li><button type="button" class="devbar-tool devbar-quick" data-quick-edit="${BUILDER}/pages/${ctx.page.id}" aria-pressed="false" title="Quick edit" aria-label="Quick edit" hidden>${icon('scan')}${text('Quick edit')}</button></li>
|
|
110
|
+
${tool(`${BUILDER}/apps/${ctx.app.id}/theme`, 'Customize (Theme Roller)', 'sliders', 'Customize')}
|
|
109
111
|
${menu('Info', 'info', 'devbar-info', html`<dl class="devbar-facts">
|
|
110
112
|
<dt>Application</dt><dd>${ctx.app.name} (${ctx.app.id})</dd>
|
|
111
113
|
<dt>Page</dt><dd>${page}: ${ctx.page.name}</dd>
|
|
@@ -115,7 +117,8 @@ export function devbarHtml(ctx: PageContext): Raw | '' {
|
|
|
115
117
|
${menu('Settings', 'settings', 'devbar-settings', html`<fieldset class="devbar-dock"><legend>Position</legend>
|
|
116
118
|
${(['bottom', 'top', 'left', 'right'] as const).map((d) => html`<label><input type="radio" name="devbar-dock" value="${d}"${d === 'bottom' ? raw(' checked') : ''}> ${d[0].toUpperCase() + d.slice(1)}</label>`)}
|
|
117
119
|
</fieldset>
|
|
118
|
-
<label class="devbar-autohide"><input type="checkbox" name="devbar-autohide" checked> Hide when not used</label
|
|
120
|
+
<label class="devbar-autohide"><input type="checkbox" name="devbar-autohide" checked> Hide when not used</label>
|
|
121
|
+
<label class="devbar-autohide"><input type="checkbox" name="devbar-compact"> Icons only</label>`, true)}
|
|
119
122
|
</ul></nav>
|
|
120
123
|
</div>`;
|
|
121
124
|
}
|
package/src/runtime/engine.ts
CHANGED
|
@@ -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;
|
package/src/runtime/facets.ts
CHANGED
|
@@ -99,6 +99,20 @@ export async function rangeCounts(ctx: PageContext, fs: FacetSource, f: FacetDef
|
|
|
99
99
|
return res.rows[0] as number[];
|
|
100
100
|
}
|
|
101
101
|
|
|
102
|
+
/** (sprint 78) Checkbox facet values shown before "Show all" (APEX shows seven). */
|
|
103
|
+
export const FACET_SHOWN = 7;
|
|
104
|
+
|
|
105
|
+
/** (sprint 78) The rows the report shows: the search and every facet's filter. */
|
|
106
|
+
export async function totalRows(ctx: PageContext, fs: FacetSource): Promise<number> {
|
|
107
|
+
const p = new SqlParams();
|
|
108
|
+
const where = othersWhere(ctx, fs, '', p);
|
|
109
|
+
const c = ctx.client!;
|
|
110
|
+
const res = await savepoint(c, () =>
|
|
111
|
+
c.query<{ n: number }>({ text: `select count(*)::int as n from (\n${fs.src}\n) "__q"${where.length ? ` where ${where.join(' and ')}` : ''}`, values: queryValues(p.values) }),
|
|
112
|
+
);
|
|
113
|
+
return res.rows[0].n;
|
|
114
|
+
}
|
|
115
|
+
|
|
102
116
|
export const facetLabel = (ctx: PageContext, f: FacetDef) => f.label ?? ctx.locale.tr(heading(f.column));
|
|
103
117
|
|
|
104
118
|
/** A star rating drawn with stars, read as "4 stars and up". */
|
|
@@ -156,15 +170,24 @@ export async function renderFacets(ctx: PageContext, r: Region): Promise<Raw> {
|
|
|
156
170
|
// keep selected values visible even when their count dropped to 0
|
|
157
171
|
for (const s of selected) if (s && !values.some((x) => x.v === s)) values.push({ v: s, n: 0 });
|
|
158
172
|
const excluded = f.exclude && ctx.params.get(k.exclude + f.column) === '1';
|
|
173
|
+
// (sprint 78) a boolean's values as Yes and No, not true and false
|
|
174
|
+
const bool = fs.cols.get(f.column) === 16;
|
|
175
|
+
const shown = (v: string) => (bool && (v === 'true' || v === 'false') ? t(v === 'true' ? 'item.yes' : 'item.no') : (toState(v) ?? ''));
|
|
159
176
|
const chart = facetChart(ctx, report, f, label, values.map((x) => ({
|
|
160
|
-
label:
|
|
177
|
+
label: shown(x.v), n: x.n,
|
|
161
178
|
apply: (p) => { p.delete(k.values + f.column); p.delete(k.exclude + f.column); p.append(k.values + f.column, x.v); },
|
|
162
179
|
})));
|
|
180
|
+
const value = (x: { v: string; n: number }) =>
|
|
181
|
+
html`<label class="check facet-value"><input type="checkbox" form="${formId}" name="${k.values}${f.column}" value="${x.v}"${selected.has(x.v) ? raw(' checked') : ''} data-facet>
|
|
182
|
+
<span class="facet-label">${shown(x.v)}</span><span class="facet-count">${x.n}</span></label>`;
|
|
163
183
|
groups.push(html`<fieldset class="facet${excluded ? ' facet-excluded' : ''}">
|
|
164
184
|
<legend>${label}${clearLink(f)}</legend>
|
|
165
185
|
${chart}
|
|
166
|
-
${values.
|
|
167
|
-
|
|
186
|
+
${values.slice(0, FACET_SHOWN).map(value)}
|
|
187
|
+
${values.length > FACET_SHOWN
|
|
188
|
+
? html`<details class="facet-more"${values.slice(FACET_SHOWN).some((x) => selected.has(x.v)) ? raw(' open') : ''}><summary>${t('facets.show_all', { n: String(values.length) })}</summary>
|
|
189
|
+
${values.slice(FACET_SHOWN).map(value)}</details>`
|
|
190
|
+
: ''}
|
|
168
191
|
${f.exclude
|
|
169
192
|
? html`<label class="check facet-exclude"><input type="checkbox" form="${formId}" name="${k.exclude}${f.column}" value="1"${excluded ? raw(' checked') : ''} data-facet> ${t('facets.exclude')}</label>`
|
|
170
193
|
: ''}
|
|
@@ -225,7 +248,15 @@ export async function renderFacets(ctx: PageContext, r: Region): Promise<Raw> {
|
|
|
225
248
|
html`<form id="${formId}" method="get" action="${ctx.base}/${ctx.page.page_no}">${keep.map(([name, v]) => html`<input type="hidden" name="${name}" value="${v}">`)}</form>`,
|
|
226
249
|
);
|
|
227
250
|
const any = defs.some((f) => active.has(f.column)) || (r.config.search === true && fs.search !== '');
|
|
251
|
+
// (sprint 78; APEX: the results' "Total Row Count") the rows the filters leave
|
|
252
|
+
let total: number | null = null;
|
|
253
|
+
try {
|
|
254
|
+
total = await totalRows(ctx, fs);
|
|
255
|
+
} catch {
|
|
256
|
+
total = null; // the facets' own errors are shown above
|
|
257
|
+
}
|
|
228
258
|
return html`<div class="facets">
|
|
259
|
+
${total !== null ? html`<p class="facet-total" role="status">${t('facets.total', { n: total.toLocaleString(ctx.locale.lang) })}</p>` : ''}
|
|
229
260
|
${groups}
|
|
230
261
|
<div class="buttons facet-actions">
|
|
231
262
|
<button class="btn btn-hot" form="${formId}">${t('report.apply')}</button>
|
package/src/runtime/format.ts
CHANGED
|
@@ -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) => {
|
package/src/runtime/items.ts
CHANGED
|
@@ -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
|
package/src/runtime/locale.ts
CHANGED
|
@@ -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;
|
|
@@ -42,7 +44,10 @@ export interface Locale {
|
|
|
42
44
|
timeZoneFrom: 'user' | 'browser' | 'app' | 'database';
|
|
43
45
|
}
|
|
44
46
|
|
|
45
|
-
|
|
47
|
+
/** (sprint 79) The light/dark choice of a user who is not signed in, per application (path /a/<alias>). */
|
|
48
|
+
export const THEME_COOKIE = 'pgkiln_mode';
|
|
49
|
+
/** The cookie's options: the application's path, a year. */
|
|
50
|
+
export const themeCookieOptions = (base: string) => ({ path: base, sameSite: 'lax' as const, secure: process.env.COOKIE_SECURE === 'true', maxAge: 365 * 86400 });
|
|
46
51
|
const THEMES: ThemeMode[] = ['auto', 'light', 'dark'];
|
|
47
52
|
export const isTheme = (v: unknown): v is ThemeMode => THEMES.includes(v as ThemeMode);
|
|
48
53
|
|
|
@@ -202,6 +207,7 @@ export async function resolveLocale(req: FastifyRequest, app: App, session?: Ses
|
|
|
202
207
|
style: baseStyleOf(app.theme) === 'standard' ? undefined : (baseStyleOf(app.theme) as 'iris' | 'lavender'),
|
|
203
208
|
themeChoice: app.theme?.user_choice !== false,
|
|
204
209
|
format,
|
|
210
|
+
dateMask: messages['FORMAT.DATE'] ?? app.date_format ?? null,
|
|
205
211
|
number,
|
|
206
212
|
numbers,
|
|
207
213
|
masked: (m) => maskedFormatter(format, lang, numbers, m),
|
package/src/runtime/regions.ts
CHANGED
|
@@ -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
|
+
}
|