@cube-nl/dmp-sdk 1.0.0-rc.3 → 1.1.0-rc.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +347 -25
- package/dist/app.d.ts +36 -2
- package/dist/app.js +192 -10
- package/dist/browser.js +6 -1
- package/dist/client.d.ts +2 -0
- package/dist/cube-sdk.js +179 -2
- package/dist/cube-ui.manifest.json +971 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +4 -0
- package/dist/preview.d.ts +18 -0
- package/dist/preview.js +199 -34
- package/dist/size.json +6 -0
- package/dist/ui/aggregate-block.d.ts +73 -0
- package/dist/ui/aggregate-block.js +138 -0
- package/dist/ui/attributes.d.ts +5 -0
- package/dist/ui/attributes.js +72 -0
- package/dist/ui/block.d.ts +63 -0
- package/dist/ui/block.js +229 -0
- package/dist/ui/blocks/app-shell.d.ts +4 -0
- package/dist/ui/blocks/app-shell.js +167 -0
- package/dist/ui/blocks/board.d.ts +8 -0
- package/dist/ui/blocks/board.js +292 -0
- package/dist/ui/blocks/calc.d.ts +9 -0
- package/dist/ui/blocks/calc.js +240 -0
- package/dist/ui/blocks/chart.d.ts +7 -0
- package/dist/ui/blocks/chart.js +166 -0
- package/dist/ui/blocks/detail.d.ts +8 -0
- package/dist/ui/blocks/detail.js +270 -0
- package/dist/ui/blocks/field.d.ts +8 -0
- package/dist/ui/blocks/field.js +16 -0
- package/dist/ui/blocks/form.d.ts +8 -0
- package/dist/ui/blocks/form.js +153 -0
- package/dist/ui/blocks/gap-list.d.ts +4 -0
- package/dist/ui/blocks/gap-list.js +36 -0
- package/dist/ui/blocks/kpi.d.ts +4 -0
- package/dist/ui/blocks/kpi.js +74 -0
- package/dist/ui/blocks/period-picker.d.ts +4 -0
- package/dist/ui/blocks/period-picker.js +155 -0
- package/dist/ui/blocks/state.d.ts +4 -0
- package/dist/ui/blocks/state.js +135 -0
- package/dist/ui/blocks/table.d.ts +8 -0
- package/dist/ui/blocks/table.js +607 -0
- package/dist/ui/blocks/top.d.ts +4 -0
- package/dist/ui/blocks/top.js +67 -0
- package/dist/ui/chart-svg.d.ts +28 -0
- package/dist/ui/chart-svg.js +111 -0
- package/dist/ui/csv.d.ts +9 -0
- package/dist/ui/csv.js +55 -0
- package/dist/ui/define.d.ts +24 -0
- package/dist/ui/define.js +107 -0
- package/dist/ui/dom.d.ts +12 -0
- package/dist/ui/dom.js +57 -0
- package/dist/ui/fields.d.ts +39 -0
- package/dist/ui/fields.js +73 -0
- package/dist/ui/format.d.ts +44 -0
- package/dist/ui/format.js +214 -0
- package/dist/ui/inputs.d.ts +35 -0
- package/dist/ui/inputs.js +161 -0
- package/dist/ui/manifest.d.ts +40 -0
- package/dist/ui/manifest.js +878 -0
- package/dist/ui/period.d.ts +18 -0
- package/dist/ui/period.js +52 -0
- package/dist/ui/query.d.ts +26 -0
- package/dist/ui/query.js +98 -0
- package/dist/ui/schema.d.ts +23 -0
- package/dist/ui/schema.js +21 -0
- package/dist/ui/styles.d.ts +8 -0
- package/dist/ui/styles.js +51 -0
- package/dist/ui/texts.d.ts +134 -0
- package/dist/ui/texts.js +122 -0
- package/dist/ui/theme.d.ts +58 -0
- package/dist/ui/theme.js +311 -0
- package/dist/ui/write.d.ts +20 -0
- package/dist/ui/write.js +54 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/wireframe.d.ts +37 -0
- package/dist/wireframe.js +315 -0
- package/fonts/OFL-albert-sans.txt +93 -0
- package/fonts/OFL-ibm-plex-sans.txt +93 -0
- package/fonts/OFL-inter.txt +93 -0
- package/fonts/OFL-nunito-sans.txt +93 -0
- package/fonts/OFL-source-sans-3.txt +93 -0
- package/fonts/albert-sans.woff2 +0 -0
- package/fonts/ibm-plex-sans.woff2 +0 -0
- package/fonts/inter.woff2 +0 -0
- package/fonts/nunito-sans.woff2 +0 -0
- package/fonts/source-sans-3.woff2 +0 -0
- package/package.json +8 -3
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The attribute names each block observes, apart from manifest.ts so the browser bundle does not carry the
|
|
3
|
+
* manifest's documentation texts. test/ui-manifest.test.js keeps the two in step.
|
|
4
|
+
*/
|
|
5
|
+
export const BLOCK_ATTRIBUTES = {
|
|
6
|
+
'cube-state': ['kind', 'message', 'retry'],
|
|
7
|
+
'cube-app-shell': ['name', 'logo', 'logo-alt'],
|
|
8
|
+
'cube-page': ['name', 'label'],
|
|
9
|
+
'cube-field': ['name', 'label', 'format', 'multiline', 'options', 'hidden'],
|
|
10
|
+
'cube-period-picker': ['default', 'label', 'today'],
|
|
11
|
+
'cube-kpi': [
|
|
12
|
+
'resource',
|
|
13
|
+
'measure',
|
|
14
|
+
'label',
|
|
15
|
+
'format',
|
|
16
|
+
'currency',
|
|
17
|
+
'filter',
|
|
18
|
+
'period',
|
|
19
|
+
'date-field',
|
|
20
|
+
'compare',
|
|
21
|
+
],
|
|
22
|
+
'cube-chart': [
|
|
23
|
+
'resource',
|
|
24
|
+
'measure',
|
|
25
|
+
'by',
|
|
26
|
+
'kind',
|
|
27
|
+
'label',
|
|
28
|
+
'format',
|
|
29
|
+
'currency',
|
|
30
|
+
'filter',
|
|
31
|
+
'period',
|
|
32
|
+
'date-field',
|
|
33
|
+
'lookup',
|
|
34
|
+
'height',
|
|
35
|
+
'limit',
|
|
36
|
+
],
|
|
37
|
+
'cube-top': [
|
|
38
|
+
'resource',
|
|
39
|
+
'measure',
|
|
40
|
+
'by',
|
|
41
|
+
'limit',
|
|
42
|
+
'order',
|
|
43
|
+
'label',
|
|
44
|
+
'format',
|
|
45
|
+
'currency',
|
|
46
|
+
'filter',
|
|
47
|
+
'period',
|
|
48
|
+
'date-field',
|
|
49
|
+
'lookup',
|
|
50
|
+
],
|
|
51
|
+
'cube-table': [
|
|
52
|
+
'resource',
|
|
53
|
+
'fields',
|
|
54
|
+
'search',
|
|
55
|
+
'filters',
|
|
56
|
+
'sort',
|
|
57
|
+
'page-size',
|
|
58
|
+
'export',
|
|
59
|
+
'detail',
|
|
60
|
+
'filter',
|
|
61
|
+
'period',
|
|
62
|
+
'date-field',
|
|
63
|
+
],
|
|
64
|
+
'cube-detail': ['resource', 'record-id', 'fields', 'editable'],
|
|
65
|
+
'cube-form': ['resource', 'fields', 'submit-label', 'success-text'],
|
|
66
|
+
'cube-calc': ['label'],
|
|
67
|
+
'cube-input': ['name', 'label', 'type', 'value', 'min', 'max', 'step', 'options'],
|
|
68
|
+
'cube-result': ['name', 'label', 'format', 'currency', 'emphasis'],
|
|
69
|
+
'cube-board': ['resource', 'status-field', 'title-field', 'fields', 'sort', 'filter', 'page-size'],
|
|
70
|
+
'cube-column': ['value', 'label'],
|
|
71
|
+
'cube-gap-list': ['scherm'],
|
|
72
|
+
};
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { type CubeAppSession } from '../app.js';
|
|
2
|
+
import type { BlockScope, DefineOptions } from './define.js';
|
|
3
|
+
export type StateKind = 'loading' | 'empty' | 'error' | 'forbidden' | 'signed-out';
|
|
4
|
+
export interface BlockState {
|
|
5
|
+
kind: StateKind;
|
|
6
|
+
detail: string | null;
|
|
7
|
+
/** False for an error that trying again cannot solve: the state offers no retry. */
|
|
8
|
+
retry?: boolean;
|
|
9
|
+
}
|
|
10
|
+
/** Thrown by a block's load to show exactly this state. */
|
|
11
|
+
export declare class BlockStateError extends Error {
|
|
12
|
+
readonly state: BlockState;
|
|
13
|
+
constructor(state: BlockState);
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* What a failure looks like to the person on the page. Only a TypeError carries its message: the blocks throw
|
|
17
|
+
* those for a wrong attribute, in Dutch. A server's text is never shown.
|
|
18
|
+
*/
|
|
19
|
+
export declare function stateFor(error: unknown): BlockState;
|
|
20
|
+
/**
|
|
21
|
+
* Resolves once the page's own scripts have run. A block must not take the page session before the app had its
|
|
22
|
+
* chance to call `CubeApp.connect()` with its own options, or the block would start a second, default one.
|
|
23
|
+
*/
|
|
24
|
+
export declare function pageReady(doc: Document): Promise<void>;
|
|
25
|
+
/** The class every block extends. Declared, not defined: the real one is made per window by `blockBase`. */
|
|
26
|
+
export declare abstract class CubeBlockShape extends HTMLElement {
|
|
27
|
+
static tag: string;
|
|
28
|
+
/** The block's own stylesheet text, adopted after BASE_CSS. */
|
|
29
|
+
static css: string;
|
|
30
|
+
/** False for blocks that render without data (cube-state): they never take the session to render. */
|
|
31
|
+
static needsSession: boolean;
|
|
32
|
+
static observedAttributes: string[];
|
|
33
|
+
/** The block's content root inside its shadow root. */
|
|
34
|
+
readonly body: HTMLElement;
|
|
35
|
+
/** Settles when the current (and any newer) load has finished. */
|
|
36
|
+
readonly updateComplete: Promise<void>;
|
|
37
|
+
/** Loads again: aborts a running load, shows the loading state, then calls `load`. */
|
|
38
|
+
refresh(): Promise<void>;
|
|
39
|
+
connectedCallback(): void;
|
|
40
|
+
disconnectedCallback(): void;
|
|
41
|
+
attributeChangedCallback(name: string, oldValue: string | null, newValue: string | null): void;
|
|
42
|
+
/** Renders the block into `body`. Throw to show a state; check `signal` before touching the DOM after an await. */
|
|
43
|
+
protected abstract load(signal: AbortSignal, session: CubeAppSession | null): Promise<void>;
|
|
44
|
+
/** The fields of `resource` the block shows after its load: one of them invented marks the whole block. */
|
|
45
|
+
protected shownFields(): string[];
|
|
46
|
+
/** Whether `field` of `resource` is invented while the resource itself exists: its column is marked. */
|
|
47
|
+
protected exampleField(session: CubeAppSession, resource: string, field: string): boolean;
|
|
48
|
+
/** `Voorbeeld — wordt niet opgeslagen` next to a write action of a wireframe; null in any other app. */
|
|
49
|
+
protected notSaved(session: CubeAppSession): HTMLElement | null;
|
|
50
|
+
protected session(): Promise<CubeAppSession>;
|
|
51
|
+
protected showState(state: StateKind | BlockState, detail?: string | null): void;
|
|
52
|
+
protected attr(name: string): string | null;
|
|
53
|
+
protected flag(name: string): boolean;
|
|
54
|
+
protected num(name: string, fallback: number): number;
|
|
55
|
+
protected json<T>(name: string): T | null;
|
|
56
|
+
protected emit(name: string, detail?: unknown): boolean;
|
|
57
|
+
}
|
|
58
|
+
export type BlockClass = typeof CubeBlockShape;
|
|
59
|
+
/** A block module's export: makes its element class on top of the window's base class. */
|
|
60
|
+
export type BlockFactory = (Base: BlockClass, scope: BlockScope) => BlockClass;
|
|
61
|
+
/** The header mark of an invented field: `Voorbeeld` on screen, the whole of exampleMissing read out. */
|
|
62
|
+
export declare function exampleBadge(doc: Document): HTMLElement;
|
|
63
|
+
export declare function blockBase(scope: BlockScope, options?: DefineOptions): BlockClass;
|
package/dist/ui/block.js
ADDED
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
import { CubeApp } from '../app.js';
|
|
2
|
+
import { isDmpError } from '../errors.js';
|
|
3
|
+
import { BLOCK_ATTRIBUTES } from './attributes.js';
|
|
4
|
+
import { clear, h } from './dom.js';
|
|
5
|
+
import { BASE_CSS, sheet } from './styles.js';
|
|
6
|
+
import { NL } from './texts.js';
|
|
7
|
+
/** Thrown by a block's load to show exactly this state. */
|
|
8
|
+
export class BlockStateError extends Error {
|
|
9
|
+
state;
|
|
10
|
+
constructor(state) {
|
|
11
|
+
super(state.detail ?? state.kind);
|
|
12
|
+
this.state = state;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* What a failure looks like to the person on the page. Only a TypeError carries its message: the blocks throw
|
|
17
|
+
* those for a wrong attribute, in Dutch. A server's text is never shown.
|
|
18
|
+
*/
|
|
19
|
+
export function stateFor(error) {
|
|
20
|
+
if (error instanceof BlockStateError)
|
|
21
|
+
return error.state;
|
|
22
|
+
if (isDmpError(error)) {
|
|
23
|
+
if (error.status === 401)
|
|
24
|
+
return { kind: 'signed-out', detail: null };
|
|
25
|
+
if (error.status === 403)
|
|
26
|
+
return { kind: 'forbidden', detail: null };
|
|
27
|
+
}
|
|
28
|
+
if (error instanceof TypeError)
|
|
29
|
+
return { kind: 'error', detail: error.message };
|
|
30
|
+
return { kind: 'error', detail: null };
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Resolves once the page's own scripts have run. A block must not take the page session before the app had its
|
|
34
|
+
* chance to call `CubeApp.connect()` with its own options, or the block would start a second, default one.
|
|
35
|
+
*/
|
|
36
|
+
export function pageReady(doc) {
|
|
37
|
+
return new Promise((resolve) => {
|
|
38
|
+
if (doc.readyState === 'loading') {
|
|
39
|
+
doc.addEventListener('DOMContentLoaded', () => resolve(), { once: true });
|
|
40
|
+
}
|
|
41
|
+
else {
|
|
42
|
+
(doc.defaultView?.setTimeout ?? setTimeout)(resolve, 0);
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
/** The header mark of an invented field: `Voorbeeld` on screen, the whole of exampleMissing read out. */
|
|
47
|
+
export function exampleBadge(doc) {
|
|
48
|
+
return h(doc, 'span', { class: 'badge example' }, NL.example, h(doc, 'span', { class: 'sr-only' }, NL.exampleMissing.slice(NL.example.length)));
|
|
49
|
+
}
|
|
50
|
+
function isAbort(error) {
|
|
51
|
+
return error !== null && typeof error === 'object' && error.name === 'AbortError';
|
|
52
|
+
}
|
|
53
|
+
export function blockBase(scope, options = {}) {
|
|
54
|
+
const getSession = options.session ?? (() => CubeApp.ready());
|
|
55
|
+
class CubeBlock extends scope.HTMLElement {
|
|
56
|
+
static tag = '';
|
|
57
|
+
static css = '';
|
|
58
|
+
static needsSession = true;
|
|
59
|
+
static get observedAttributes() {
|
|
60
|
+
return [...(BLOCK_ATTRIBUTES[this.tag] ?? [])];
|
|
61
|
+
}
|
|
62
|
+
body;
|
|
63
|
+
current = Promise.resolve();
|
|
64
|
+
scheduled = false;
|
|
65
|
+
controller = null;
|
|
66
|
+
/** Outside the body, so a block that redraws its body (a board move, an edit) keeps it. */
|
|
67
|
+
badge = null;
|
|
68
|
+
onSessionChange = () => this.schedule();
|
|
69
|
+
onDataChange = (event) => {
|
|
70
|
+
const resource = event.detail?.resource;
|
|
71
|
+
const own = this.getAttribute('resource')?.trim() ?? '';
|
|
72
|
+
if (typeof resource === 'string' && own !== '' && resource.trim() === own)
|
|
73
|
+
this.schedule();
|
|
74
|
+
};
|
|
75
|
+
constructor() {
|
|
76
|
+
super();
|
|
77
|
+
const ctor = this.constructor;
|
|
78
|
+
const root = this.attachShadow({ mode: 'open' });
|
|
79
|
+
root.adoptedStyleSheets = ctor.css === ''
|
|
80
|
+
? [sheet(scope, BASE_CSS)]
|
|
81
|
+
: [sheet(scope, BASE_CSS), sheet(scope, ctor.css)];
|
|
82
|
+
this.body = h(scope.document, 'div', { class: 'cube-body', part: 'body' });
|
|
83
|
+
root.appendChild(this.body);
|
|
84
|
+
// Bubbles up from the cube-state in this block's own shadow tree; an outer block must not reload too.
|
|
85
|
+
root.addEventListener('cube-retry', (event) => {
|
|
86
|
+
event.stopPropagation();
|
|
87
|
+
this.schedule();
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
get updateComplete() {
|
|
91
|
+
return (async () => {
|
|
92
|
+
let seen;
|
|
93
|
+
do {
|
|
94
|
+
seen = this.current;
|
|
95
|
+
await seen;
|
|
96
|
+
} while (seen !== this.current);
|
|
97
|
+
})();
|
|
98
|
+
}
|
|
99
|
+
connectedCallback() {
|
|
100
|
+
this.ownerDocument.addEventListener('cube-session-change', this.onSessionChange);
|
|
101
|
+
this.ownerDocument.addEventListener('cube-data-change', this.onDataChange);
|
|
102
|
+
this.schedule();
|
|
103
|
+
}
|
|
104
|
+
disconnectedCallback() {
|
|
105
|
+
this.ownerDocument.removeEventListener('cube-session-change', this.onSessionChange);
|
|
106
|
+
this.ownerDocument.removeEventListener('cube-data-change', this.onDataChange);
|
|
107
|
+
this.controller?.abort();
|
|
108
|
+
this.controller = null;
|
|
109
|
+
this.removeAttribute('aria-busy');
|
|
110
|
+
}
|
|
111
|
+
attributeChangedCallback(_name, oldValue, newValue) {
|
|
112
|
+
if (oldValue !== newValue)
|
|
113
|
+
this.schedule();
|
|
114
|
+
}
|
|
115
|
+
refresh() {
|
|
116
|
+
if (!this.isConnected)
|
|
117
|
+
return this.current;
|
|
118
|
+
this.controller?.abort();
|
|
119
|
+
const controller = new AbortController();
|
|
120
|
+
this.controller = controller;
|
|
121
|
+
const run = this.run(controller.signal);
|
|
122
|
+
this.current = run;
|
|
123
|
+
return run;
|
|
124
|
+
}
|
|
125
|
+
/** Several changes in one tick (attributes set one by one, say) make one load. */
|
|
126
|
+
schedule() {
|
|
127
|
+
if (this.scheduled || !this.isConnected)
|
|
128
|
+
return;
|
|
129
|
+
this.scheduled = true;
|
|
130
|
+
this.current = new Promise((resolve) => {
|
|
131
|
+
queueMicrotask(() => {
|
|
132
|
+
this.scheduled = false;
|
|
133
|
+
resolve(this.isConnected ? this.refresh() : undefined);
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
async run(signal) {
|
|
138
|
+
const ctor = this.constructor;
|
|
139
|
+
this.showState('loading');
|
|
140
|
+
try {
|
|
141
|
+
await pageReady(this.ownerDocument);
|
|
142
|
+
if (signal.aborted)
|
|
143
|
+
return;
|
|
144
|
+
const session = ctor.needsSession ? await this.session() : null;
|
|
145
|
+
if (signal.aborted)
|
|
146
|
+
return;
|
|
147
|
+
await this.load(signal, session);
|
|
148
|
+
if (!signal.aborted)
|
|
149
|
+
this.mark(session);
|
|
150
|
+
}
|
|
151
|
+
catch (error) {
|
|
152
|
+
if (signal.aborted || isAbort(error))
|
|
153
|
+
return;
|
|
154
|
+
this.badge?.remove();
|
|
155
|
+
this.showState(stateFor(error));
|
|
156
|
+
}
|
|
157
|
+
finally {
|
|
158
|
+
// An aborted run leaves the flag to the newer run (or to disconnect).
|
|
159
|
+
if (!signal.aborted)
|
|
160
|
+
this.removeAttribute('aria-busy');
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
session() {
|
|
164
|
+
return getSession();
|
|
165
|
+
}
|
|
166
|
+
shownFields() {
|
|
167
|
+
return [];
|
|
168
|
+
}
|
|
169
|
+
// Optional calls: a session made by hand (a test, an older embedder) may not know about wireframes.
|
|
170
|
+
exampleField(session, resource, field) {
|
|
171
|
+
return session.isExample?.(resource, field) === true && !session.isExample(resource);
|
|
172
|
+
}
|
|
173
|
+
notSaved(session) {
|
|
174
|
+
return session.simulatesWrites === true
|
|
175
|
+
? h(this.ownerDocument, 'p', { class: 'note not-saved' }, NL.exampleNotSaved)
|
|
176
|
+
: null;
|
|
177
|
+
}
|
|
178
|
+
mark(session) {
|
|
179
|
+
const resource = this.getAttribute('resource')?.trim() ?? '';
|
|
180
|
+
const marked = session?.isExample !== undefined && resource !== ''
|
|
181
|
+
&& (session.isExample(resource) || this.shownFields().some((f) => session.isExample(resource, f)));
|
|
182
|
+
if (!marked) {
|
|
183
|
+
this.badge?.remove();
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
this.badge ??= h(this.ownerDocument, 'p', { class: 'badge example', role: 'note' }, NL.exampleMissing);
|
|
187
|
+
this.shadowRoot?.insertBefore(this.badge, this.body);
|
|
188
|
+
}
|
|
189
|
+
showState(state, detail = null) {
|
|
190
|
+
const { kind, detail: message, retry } = typeof state === 'string' ? { kind: state, detail } : state;
|
|
191
|
+
// Busy until the run ends; a block that draws its own loading (the shell's frame) is never busy.
|
|
192
|
+
if (kind === 'loading')
|
|
193
|
+
this.setAttribute('aria-busy', 'true');
|
|
194
|
+
clear(this.body);
|
|
195
|
+
this.body.appendChild(h(this.ownerDocument, 'cube-state', { kind, message, retry: retry === false ? 'false' : null }));
|
|
196
|
+
}
|
|
197
|
+
attr(name) {
|
|
198
|
+
return this.getAttribute(name);
|
|
199
|
+
}
|
|
200
|
+
flag(name) {
|
|
201
|
+
return this.hasAttribute(name) && this.getAttribute(name) !== 'false';
|
|
202
|
+
}
|
|
203
|
+
num(name, fallback) {
|
|
204
|
+
const raw = this.getAttribute(name);
|
|
205
|
+
if (raw === null || raw.trim() === '')
|
|
206
|
+
return fallback;
|
|
207
|
+
const value = Number(raw);
|
|
208
|
+
if (!Number.isFinite(value))
|
|
209
|
+
throw new TypeError(NL.invalid.number(this.localName, name));
|
|
210
|
+
return value;
|
|
211
|
+
}
|
|
212
|
+
json(name) {
|
|
213
|
+
const raw = this.getAttribute(name);
|
|
214
|
+
if (raw === null || raw.trim() === '')
|
|
215
|
+
return null;
|
|
216
|
+
try {
|
|
217
|
+
return JSON.parse(raw);
|
|
218
|
+
}
|
|
219
|
+
catch {
|
|
220
|
+
throw new TypeError(NL.invalid.json(this.localName, name));
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
emit(name, detail = null) {
|
|
224
|
+
const View = this.ownerDocument.defaultView?.CustomEvent ?? CustomEvent;
|
|
225
|
+
return this.dispatchEvent(new View(name, { detail, bubbles: true, composed: true }));
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
return CubeBlock;
|
|
229
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { BlockClass } from '../block.js';
|
|
2
|
+
import type { BlockScope } from '../define.js';
|
|
3
|
+
/** `<cube-app-shell>`: header, page navigation and the content slot. The content shows whatever the session does. */
|
|
4
|
+
export declare function appShellBlock(Base: BlockClass, scope: BlockScope): BlockClass;
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { clear, h } from '../dom.js';
|
|
2
|
+
import { NL } from '../texts.js';
|
|
3
|
+
import { currentTheme, onTheme } from '../theme.js';
|
|
4
|
+
const CSS = `
|
|
5
|
+
:host{background:var(--cube-color-bg)}
|
|
6
|
+
header{display:flex;flex-wrap:wrap;align-items:center;gap:.75rem 1rem;padding:.75rem 1.5rem;
|
|
7
|
+
border-bottom:1px solid var(--cube-color-border);background:var(--cube-color-surface)}
|
|
8
|
+
.logo{display:block;height:2rem;width:auto;max-width:10rem;object-fit:contain}
|
|
9
|
+
h1{flex:1 1 auto;min-width:0;margin:0;font-size:1.25rem;line-height:1.3;overflow-wrap:anywhere}
|
|
10
|
+
.user{display:flex;flex-wrap:wrap;align-items:center;gap:.75rem;margin-left:auto}
|
|
11
|
+
nav{border-bottom:1px solid var(--cube-color-border);padding:0 1.5rem}
|
|
12
|
+
nav ul{display:flex;gap:.25rem;margin:0;padding:0;list-style:none;position:relative;overflow-x:auto}
|
|
13
|
+
nav a{display:block;padding:.75rem 1rem;white-space:nowrap;text-decoration:none;color:var(--cube-color-text-muted);
|
|
14
|
+
border-bottom:3px solid transparent}
|
|
15
|
+
nav a:hover{color:var(--cube-color-primary-text)}
|
|
16
|
+
nav a[aria-current=page]{font-weight:600;color:var(--cube-color-primary-text);
|
|
17
|
+
border-bottom-color:var(--cube-color-primary)}
|
|
18
|
+
main{padding:1.5rem}
|
|
19
|
+
::slotted(cube-page:not([hidden])){display:flex;flex-direction:column;gap:1.5rem}
|
|
20
|
+
@media (max-width:640px){header{padding:.75rem 1rem}nav{padding:0}main{padding:1rem}}
|
|
21
|
+
`;
|
|
22
|
+
const PREFIX = '#/';
|
|
23
|
+
/** `<cube-app-shell>`: header, page navigation and the content slot. The content shows whatever the session does. */
|
|
24
|
+
export function appShellBlock(Base, scope) {
|
|
25
|
+
class CubeAppShell extends Base {
|
|
26
|
+
static tag = 'cube-app-shell';
|
|
27
|
+
static css = CSS;
|
|
28
|
+
header;
|
|
29
|
+
heading;
|
|
30
|
+
user;
|
|
31
|
+
nav;
|
|
32
|
+
list;
|
|
33
|
+
main;
|
|
34
|
+
/** The page names and labels the navigation was built from, to rebuild it only when they change. */
|
|
35
|
+
built = '';
|
|
36
|
+
stopTheme = null;
|
|
37
|
+
onHash = () => this.updatePages();
|
|
38
|
+
constructor() {
|
|
39
|
+
super();
|
|
40
|
+
const doc = scope.document;
|
|
41
|
+
this.heading = h(doc, 'h1');
|
|
42
|
+
this.user = h(doc, 'div', { class: 'user' });
|
|
43
|
+
this.header = h(doc, 'header', {}, this.heading, this.user);
|
|
44
|
+
this.list = h(doc, 'ul');
|
|
45
|
+
this.nav = h(doc, 'nav', { 'aria-label': NL.pages }, this.list);
|
|
46
|
+
const slot = h(doc, 'slot', { onslotchange: () => this.updatePages() });
|
|
47
|
+
this.main = h(doc, 'main', {}, slot);
|
|
48
|
+
this.body.append(this.header, this.main);
|
|
49
|
+
this.renderHeader();
|
|
50
|
+
}
|
|
51
|
+
connectedCallback() {
|
|
52
|
+
super.connectedCallback();
|
|
53
|
+
this.ownerDocument.defaultView?.addEventListener('hashchange', this.onHash);
|
|
54
|
+
this.stopTheme?.();
|
|
55
|
+
this.stopTheme = onTheme(() => this.renderHeader());
|
|
56
|
+
this.renderHeader();
|
|
57
|
+
this.updatePages();
|
|
58
|
+
}
|
|
59
|
+
disconnectedCallback() {
|
|
60
|
+
super.disconnectedCallback();
|
|
61
|
+
this.ownerDocument.defaultView?.removeEventListener('hashchange', this.onHash);
|
|
62
|
+
this.stopTheme?.();
|
|
63
|
+
this.stopTheme = null;
|
|
64
|
+
}
|
|
65
|
+
async load(_signal, session) {
|
|
66
|
+
this.renderHeader();
|
|
67
|
+
this.updatePages();
|
|
68
|
+
if (session !== null)
|
|
69
|
+
this.renderUser(session);
|
|
70
|
+
}
|
|
71
|
+
// The frame stays; only the user area reflects a state. Signed out offers sign-in, anything else stays empty.
|
|
72
|
+
showState(state) {
|
|
73
|
+
const kind = typeof state === 'string' ? state : state.kind;
|
|
74
|
+
clear(this.user);
|
|
75
|
+
if (kind === 'signed-out')
|
|
76
|
+
this.user.appendChild(this.signInButton());
|
|
77
|
+
}
|
|
78
|
+
renderHeader() {
|
|
79
|
+
const name = this.attr('name') ?? '';
|
|
80
|
+
const src = this.attr('logo') ?? currentTheme().logo;
|
|
81
|
+
const current = this.header.querySelector('.logo');
|
|
82
|
+
this.heading.textContent = name;
|
|
83
|
+
if (src === null || src === '') {
|
|
84
|
+
current?.remove();
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
const logo = h(scope.document, 'img', { class: 'logo', src, alt: this.attr('logo-alt') ?? name });
|
|
88
|
+
if (current === null)
|
|
89
|
+
this.header.prepend(logo);
|
|
90
|
+
else
|
|
91
|
+
current.replaceWith(logo);
|
|
92
|
+
}
|
|
93
|
+
renderUser(session) {
|
|
94
|
+
const doc = scope.document;
|
|
95
|
+
const user = session.user();
|
|
96
|
+
clear(this.user);
|
|
97
|
+
if (user === null) {
|
|
98
|
+
this.user.appendChild(this.signInButton());
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
this.user.append(h(doc, 'span', {}, user.name !== null && user.name !== '' ? user.name : NL.signedIn), h(doc, 'button', { type: 'button', class: 'secondary', onclick: () => void this.change((s) => s.signOut()) }, NL.signOut));
|
|
102
|
+
}
|
|
103
|
+
signInButton() {
|
|
104
|
+
return h(scope.document, 'button', { type: 'button', onclick: () => void this.change((s) => s.signIn()) }, NL.signIn);
|
|
105
|
+
}
|
|
106
|
+
async change(action) {
|
|
107
|
+
try {
|
|
108
|
+
await action(await this.session());
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
// Nothing changed: show the user area as the session has it, so the button is there to try again.
|
|
112
|
+
void this.refresh();
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
const View = this.ownerDocument.defaultView?.CustomEvent ?? CustomEvent;
|
|
116
|
+
this.ownerDocument.dispatchEvent(new View('cube-session-change'));
|
|
117
|
+
}
|
|
118
|
+
pages() {
|
|
119
|
+
return Array.from(this.children).filter((child) => child.localName === 'cube-page');
|
|
120
|
+
}
|
|
121
|
+
/** Shows the page the address names (default the first) and marks it in the navigation. */
|
|
122
|
+
updatePages() {
|
|
123
|
+
const pages = this.pages();
|
|
124
|
+
const location = scope.location ?? this.ownerDocument.defaultView?.location;
|
|
125
|
+
const hash = location?.hash ?? '';
|
|
126
|
+
let wanted = '';
|
|
127
|
+
if (hash.startsWith(PREFIX)) {
|
|
128
|
+
try {
|
|
129
|
+
wanted = decodeURIComponent(hash.slice(PREFIX.length));
|
|
130
|
+
}
|
|
131
|
+
catch {
|
|
132
|
+
wanted = '';
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
const active = pages.find((page) => page.getAttribute('name') === wanted) ?? pages[0];
|
|
136
|
+
for (const page of pages)
|
|
137
|
+
page.hidden = page !== active;
|
|
138
|
+
// Not hidden but absent: a single page has no navigation at all, also not for a screen reader.
|
|
139
|
+
if (pages.length < 2) {
|
|
140
|
+
this.nav.remove();
|
|
141
|
+
clear(this.list);
|
|
142
|
+
this.built = '';
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
if (this.nav.parentNode !== this.body)
|
|
146
|
+
this.body.insertBefore(this.nav, this.main);
|
|
147
|
+
const signature = JSON.stringify(pages.map((page) => [page.getAttribute('name'), page.getAttribute('label')]));
|
|
148
|
+
if (signature !== this.built) {
|
|
149
|
+
clear(this.list);
|
|
150
|
+
for (const page of pages) {
|
|
151
|
+
const name = page.getAttribute('name') ?? '';
|
|
152
|
+
const href = PREFIX + encodeURIComponent(name);
|
|
153
|
+
const link = h(scope.document, 'a', { href }, page.getAttribute('label') ?? name);
|
|
154
|
+
this.list.appendChild(h(scope.document, 'li', {}, link));
|
|
155
|
+
}
|
|
156
|
+
this.built = signature;
|
|
157
|
+
}
|
|
158
|
+
Array.from(this.list.querySelectorAll('a')).forEach((link, index) => {
|
|
159
|
+
if (pages[index] === active)
|
|
160
|
+
link.setAttribute('aria-current', 'page');
|
|
161
|
+
else
|
|
162
|
+
link.removeAttribute('aria-current');
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
return CubeAppShell;
|
|
167
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { type BlockClass } from '../block.js';
|
|
2
|
+
import type { BlockScope } from '../define.js';
|
|
3
|
+
/**
|
|
4
|
+
* `<cube-board>`: the rows of a resource as cards in a column per value of a status field. A card moves by drag
|
|
5
|
+
* and drop or by a button per other column ("Verplaats naar …"): at once on screen, then saved; a refusal puts it
|
|
6
|
+
* back.
|
|
7
|
+
*/
|
|
8
|
+
export declare function boardBlock(Base: BlockClass, scope: BlockScope): BlockClass;
|