@electral-io/webspa 0.0.0-stage → 1.0.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/README.md +710 -2
  2. package/dist/GoogleSansFlex24pt-Black.55bc5c5a.woff +0 -0
  3. package/dist/GoogleSansFlex24pt-Black.b952c823.woff2 +0 -0
  4. package/dist/GoogleSansFlex24pt-Bold.874d19a6.woff2 +0 -0
  5. package/dist/GoogleSansFlex24pt-Bold.d908201f.woff +0 -0
  6. package/dist/GoogleSansFlex24pt-ExtraBold.ca04f51f.woff2 +0 -0
  7. package/dist/GoogleSansFlex24pt-ExtraBold.e526e9db.woff +0 -0
  8. package/dist/GoogleSansFlex24pt-ExtraLight.8108859d.woff +0 -0
  9. package/dist/GoogleSansFlex24pt-ExtraLight.830b9f19.woff2 +0 -0
  10. package/dist/GoogleSansFlex24pt-Light.bb140a25.woff +0 -0
  11. package/dist/GoogleSansFlex24pt-Light.fbe80f18.woff2 +0 -0
  12. package/dist/GoogleSansFlex24pt-Medium.ba242c40.woff2 +0 -0
  13. package/dist/GoogleSansFlex24pt-Medium.ddbc6d6f.woff +0 -0
  14. package/dist/GoogleSansFlex24pt-Regular.2338814d.woff2 +0 -0
  15. package/dist/GoogleSansFlex24pt-Regular.c26e9621.woff +0 -0
  16. package/dist/GoogleSansFlex24pt-SemiBold.535d1221.woff +0 -0
  17. package/dist/GoogleSansFlex24pt-SemiBold.f9f290b9.woff2 +0 -0
  18. package/dist/GoogleSansFlex24pt-Thin.1494da19.woff +0 -0
  19. package/dist/GoogleSansFlex24pt-Thin.4c26aecf.woff2 +0 -0
  20. package/dist/ibm-plex-mono-latin-400-normal.46c527cc.woff2 +0 -0
  21. package/dist/ibm-plex-mono-latin-500-normal.4eac63d2.woff2 +0 -0
  22. package/dist/ibm-plex-mono-latin-ext-400-normal.f7114466.woff2 +0 -0
  23. package/dist/ibm-plex-mono-latin-ext-500-normal.08d28480.woff2 +0 -0
  24. package/dist/index.css +10152 -0
  25. package/dist/index.css.map +1 -0
  26. package/dist/index.d.ts +4071 -0
  27. package/dist/index.d.ts.map +1 -0
  28. package/dist/index.js +26244 -0
  29. package/dist/index.js.map +1 -0
  30. package/dist/subset-Inter18pt-Black.564e4f6e.woff2 +0 -0
  31. package/dist/subset-Inter18pt-BlackItalic.69ce2c78.woff2 +0 -0
  32. package/dist/subset-Inter18pt-Bold.5d1d6992.woff2 +0 -0
  33. package/dist/subset-Inter18pt-BoldItalic.e6156b59.woff2 +0 -0
  34. package/dist/subset-Inter18pt-ExtraBold.2dfd989d.woff2 +0 -0
  35. package/dist/subset-Inter18pt-ExtraBoldItalic.68baab79.woff2 +0 -0
  36. package/dist/subset-Inter18pt-ExtraLight.91d746d9.woff2 +0 -0
  37. package/dist/subset-Inter18pt-ExtraLightItalic.f44c7f9a.woff2 +0 -0
  38. package/dist/subset-Inter18pt-Italic.7408decc.woff2 +0 -0
  39. package/dist/subset-Inter18pt-Light.f595f92d.woff2 +0 -0
  40. package/dist/subset-Inter18pt-LightItalic.f713d0f0.woff2 +0 -0
  41. package/dist/subset-Inter18pt-Medium.a680afd7.woff2 +0 -0
  42. package/dist/subset-Inter18pt-MediumItalic.f0c8bf8a.woff2 +0 -0
  43. package/dist/subset-Inter18pt-Regular.987ab761.woff2 +0 -0
  44. package/dist/subset-Inter18pt-SemiBold.e24d9ca5.woff2 +0 -0
  45. package/dist/subset-Inter18pt-SemiBoldItalic.fb6755ec.woff2 +0 -0
  46. package/dist/subset-Inter18pt-Thin.04cc6264.woff2 +0 -0
  47. package/dist/subset-Inter18pt-ThinItalic.5faec1bb.woff2 +0 -0
  48. package/dist/subset-Quicksand-Bold.78e1b5e6.woff2 +0 -0
  49. package/dist/subset-Quicksand-Light.dc2d2856.woff2 +0 -0
  50. package/dist/subset-Quicksand-Medium.6263fcab.woff2 +0 -0
  51. package/dist/subset-Quicksand-Regular.06f2ca43.woff2 +0 -0
  52. package/dist/subset-Quicksand-SemiBold.4c169654.woff2 +0 -0
  53. package/dist/web-spa-css-custom-data.json +1935 -0
  54. package/package.json +89 -4
package/README.md CHANGED
@@ -1,3 +1,711 @@
1
- # Temporary Holding Version
1
+ # @electral-io/webspa
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ A no-HTML, TypeScript-first Single Page Application framework built on native Web Components, with an OpenLayers-based GIS layer in the same package. Build data-rich business UIs and map apps entirely in code: no templates, no JSX, no framework lock-in.
4
+
5
+ Coming from the previous two separate packages (core and GIS)? See [MIGRATION.md](MIGRATION.md).
6
+
7
+ > The exact API of the version you installed is in `node_modules/@electral-io/webspa/dist/index.d.ts` (core and GIS exports share the same entry). Examples below follow the current source (`src/`; GIS in `src/gis/`).
8
+
9
+ ## Table of Contents
10
+
11
+ - [Installation](#installation)
12
+ - [TypeScript & Parcel Configuration](#typescript--parcel-configuration)
13
+ - [Styles & Theme](#styles--theme)
14
+ - [CSS Variables](#css-variables)
15
+ - [CSS Helper Classes](#css-helper-classes)
16
+ - [IconClass](#iconclass)
17
+ - [Core Utilities](#core-utilities)
18
+ - [Components](#components)
19
+ - [Framework & Dependency Injection](#framework--dependency-injection)
20
+ - [Application Startup](#application-startup)
21
+ - [GIS (map apps)](#gis-map-apps)
22
+ - [Development](#development)
23
+ - [Release](#release)
24
+ - [Claude Code skill](#claude-code-skill)
25
+
26
+ ---
27
+
28
+ ## Installation
29
+
30
+ ```bash
31
+ npm install @electral-io/webspa
32
+ ```
33
+
34
+ One dependency covers both the core framework and the GIS layer. Parcel apps (geotiff comes in through `ol`) must also add `"alias": { "zstddec/stream": false }` to the **app's** package.json, otherwise the build fails with `Failed to resolve 'zstddec/stream'` (Parcel ignores `alias` in dependencies). Map apps additionally need `ol` (OpenLayers 10) in the app, see [GIS](#gis-map-apps).
35
+
36
+ ---
37
+
38
+ ## TypeScript & Parcel Configuration
39
+
40
+ Decorators drive dependency injection and component registration. `tsconfig.json` **must** include:
41
+
42
+ ```json
43
+ {
44
+ "compilerOptions": {
45
+ "target": "es2019",
46
+ "experimentalDecorators": true,
47
+ "emitDecoratorMetadata": true,
48
+ "useDefineForClassFields": false
49
+ }
50
+ }
51
+ ```
52
+
53
+ - `emitDecoratorMetadata` lets `@Inject()` resolve the field type at runtime through `reflect-metadata`.
54
+ - `useDefineForClassFields: false` keeps class fields from overwriting the `@Inject()` getters.
55
+
56
+ With Parcel, compile TypeScript with `tsc`, because the default SWC transform does not emit decorator metadata. Add the glob resolver so the entry file can import whole folders:
57
+
58
+ ```json
59
+ {
60
+ "extends": "@parcel/config-default",
61
+ "resolvers": ["@parcel/resolver-glob", "..."],
62
+ "transformers": { "*.{ts,tsx}": ["@parcel/transformer-typescript-tsc", "..."] }
63
+ }
64
+ ```
65
+
66
+ ---
67
+
68
+ ## Styles & Theme
69
+
70
+ Importing the package once in your entry point loads everything: Bootstrap Icons, the Tabulator base CSS, the theme (Google Sans Flex font, design tokens, element and form styles), the helper classes and the GIS / OpenLayers styles (`dist/index.css`).
71
+
72
+ ```ts
73
+ import { /* anything */ } from '@electral-io/webspa';
74
+ ```
75
+
76
+ ### Overriding the theme
77
+
78
+ Override the tokens in your own stylesheet:
79
+
80
+ ```css
81
+ :root {
82
+ --primary: var(--teal-600);
83
+ --primary-2: var(--cyan-600);
84
+ --secondary: var(--violet-600);
85
+ --font-family: "Roboto";
86
+ --spacing: 6px; /* every spacing / padding / radius token derives from this */
87
+ }
88
+ ```
89
+
90
+ Users can also pick colors at runtime from `ApplicationProperties.themes`.
91
+
92
+ ### Dark / light mode
93
+
94
+ ```ts
95
+ document.documentElement.setAttribute('data-theme', 'dark'); // or 'light'
96
+ ```
97
+
98
+ The theme switches its tokens under `:root[data-theme="dark"]`. Components styled only with tokens get dark mode for free, so avoid hard-coded colors.
99
+
100
+ ### CSS variables IntelliSense
101
+
102
+ Add this to your project's `.vscode/settings.json` to get autocompletion for every token:
103
+
104
+ ```json
105
+ {
106
+ "css.customData": ["./node_modules/@electral-io/webspa/dist/web-spa-css-custom-data.json"],
107
+ "less.customData": ["./node_modules/@electral-io/webspa/dist/web-spa-css-custom-data.json"],
108
+ "scss.customData": ["./node_modules/@electral-io/webspa/dist/web-spa-css-custom-data.json"]
109
+ }
110
+ ```
111
+
112
+ The file is generated from the theme sources by `npm run css-data` (it is written to `static/` and copied into `dist/` on build). Run it after changing `src/style/theme/*.less`.
113
+
114
+ ---
115
+
116
+ ## CSS Variables
117
+
118
+ The source of truth is `src/style/theme/main.less` (or `dist/web-spa-css-custom-data.json` in the package).
119
+
120
+ ### Brand
121
+
122
+ | Variable | Default | Use |
123
+ |---|---|---|
124
+ | `--primary` | `#2563EB` | Theme color (the theme menu overrides it): accents, active state |
125
+ | `--primary-2` | `#1D4ED8` | Second theme color (gradients) |
126
+ | `--action` / `--action-hover` / `--action-soft` | `= --primary` | Primary action color (buttons, links, active chip), its hover state and a light tint |
127
+ | `--brand` / `--brand-soft` | `#123C3A` / `#E7F0EF` | Fixed corporate color (side rail, group headers, active sub-tab); does not follow the theme menu |
128
+ | `--primary-inverse` | `--white` | Text on a primary or colored background |
129
+ | `--secondary` | `--fuchsia-600` | Secondary brand color |
130
+ | `--secondary-inverse` | `--white` | Text on a secondary background |
131
+ | `--mid` | `--gray-500` | Neutral mid tone |
132
+ | `--primary-hover` / `--primary-focus` | computed | Hover and focus tints of primary |
133
+ | `--secondary-hover` / `--secondary-focus` | computed | Same for secondary |
134
+ | `--accent-bg-img` / `--accent-bg-img-v` | gradient | Primary to primary-2 gradient (horizontal / vertical) |
135
+
136
+ ### Surfaces & text (light / dark)
137
+
138
+ | Variable | Use |
139
+ |---|---|
140
+ | `--base-up` / `--base-down` | Foreground / background direction (`#222`/`#FFF` light, `#fff`/`#111` dark) |
141
+ | `--bg-main` | Application background behind panels |
142
+ | `--bg-card` | Card / panel surface |
143
+ | `--background-color` | Element background (= `--base-down`) |
144
+ | `--navside-color` | Dark side navigation chrome |
145
+ | `--text` / `--text-color` / `--color` | Body text |
146
+ | `--text-2` | Secondary text |
147
+ | `--text-muted` | Hints, captions, placeholders |
148
+ | `--text-table` | Table cell text (= `--text-2`) |
149
+ | `--divider` | Thin separators (grid row dividers) |
150
+ | `--row-selected` | Selected table row |
151
+ | `--text-mark` | Search-match highlight background |
152
+ | `--hover-color` | Hovered row / item background |
153
+ | `--selected-color` | Selected row / item background |
154
+ | `--active` | Active outline color |
155
+ | `--h1-color` … `--h6-color` | Heading colors, progressively tinted with primary |
156
+
157
+ ### State colors
158
+
159
+ | Variable | Use |
160
+ |---|---|
161
+ | `--danger`, `--warning`, `--success`, `--info` | State colors |
162
+ | `--danger-2`, `--warning-2`, `--success-2`, `--info-2` | Softer tints |
163
+ | `--{state}-u1` … `-u3` | State color mixed toward `--base-up` (text direction, stronger contrast) |
164
+ | `--{state}-d1` … `-d3` | State color mixed toward `--base-down` (background direction, soft fills) |
165
+ | `--danger-soft`, `--warning-soft`, `--success-soft` | Light badge / pill backgrounds |
166
+ | `--ico-fg-{state}` / `--ico-bg-{state}` | Icon badge foreground / background |
167
+
168
+ Example: a soft warning box is `background: var(--warning-soft); color: var(--warning-u2); border-color: var(--warning)`.
169
+
170
+ ### Borders, shadows, motion
171
+
172
+ | Variable | Default | Use |
173
+ |---|---|---|
174
+ | `--border-color` | `#E2E8F0` light / `#1E293B` dark | Border color |
175
+ | `--border-width` | `1px` | Border width |
176
+ | `--border` | `var(--border-width) solid var(--border-color)` | Border shorthand |
177
+ | `--border-radius` | `var(--spacing)` | Inputs / buttons radius |
178
+ | `--radius-card` / `--radius-pill` | `12px` / `999px` | Card / panel and pill radius |
179
+ | `--control-h` / `--control-h-form` / `--control-h-sm` | `36px` / `40px` / `28px` | Toolbar, form input and small control heights |
180
+ | `--outline-width` | `2px` | Focus outline |
181
+ | `--focus-color` | `--primary-focus` | Focus ring |
182
+ | `--shadow-color` / `--shadow-color-lg` | | Shadow base colors |
183
+ | `--drop-shadow` / `-m` / `-r` / `-lg` | | Small, medium, top (reversed) and large shadows |
184
+ | `--transition` / `--transition-slow` | `0.25s` / `0.5s` | Standard transitions |
185
+ | `--modal-overlay-background-color` / `--modal-overlay-backdrop-filter` | | Modal backdrop |
186
+
187
+ ### Spacing & typography
188
+
189
+ | Variable | Default | Use |
190
+ |---|---|---|
191
+ | `--spacing` | `8px` | Base unit |
192
+ | `--spacing-half` / `--spacing-1-5x` / `--spacing-2x` / `--spacing-2-5x` / `--spacing-3x` | `4` / `12` / `16` / `20` / `24px` | Spacing scale |
193
+ | `--panel-spacing` | `12px` | Panel inner padding |
194
+ | `--pad` / `--pad-half` / `--pad-2x` | shorthands | Padding presets (vertical horizontal) |
195
+ | `--font-family` | `"Google Sans Flex 24pt"` | Base font |
196
+ | `--font-mono` | `"IBM Plex Mono"` | Ids, codes (bundled font) |
197
+ | `--font-size` | `13px` | Base font size |
198
+ | `--fs-xs` … `--fs-xl` | `0.75rem` … `1.25rem` | Font size scale |
199
+ | `--fs-grid-header` / `--fs-card-title` | `12px` / `16px` | Table header / card title |
200
+ | `--font-weight`, `--line-height` | | Base weight / line height |
201
+
202
+ ### Form elements
203
+
204
+ `--form-element-color`, `--form-element-background-color`, `--form-element-border-color`, `--form-element-focus-color`, `--form-element-active-background-color`, `--form-element-active-border-color`, `--form-element-disabled-background-color`, `--form-element-disabled-border-color`, `--form-element-disabled-opacity`, `--form-element-placeholder-color`, `--form-element-spacing-vertical`, `--form-element-spacing-horizontal`, `--form-label-font-weight`, `--form-element-invalid-border-color` / `-active-border-color` / `-focus-color`, `--form-element-valid-border-color` / `-active-border-color` / `-focus-color`.
205
+
206
+ ### Color palette
207
+
208
+ The full Tailwind palette (OKLCH) is available as `--{color}-{shade}`.
209
+
210
+ - **Colors**: `red`, `orange`, `amber`, `yellow`, `lime`, `green`, `emerald`, `teal`, `cyan`, `sky`, `blue`, `indigo`, `violet`, `purple`, `fuchsia`, `pink`, `rose`, `slate`, `gray`, `zinc`, `neutral`, `stone`.
211
+ - **Shades**: `50`, `100` … `900`, `950`.
212
+ - Also `--black` and `--white`.
213
+
214
+ Use palette colors to define your own tokens. Use the semantic tokens above in components, because palette colors do not switch with dark mode.
215
+
216
+ ---
217
+
218
+ ## CSS Helper Classes
219
+
220
+ | Class | Effect |
221
+ |---|---|
222
+ | `.wh-100`, `.w-100`, `.h-100` | 100% width and/or height |
223
+ | `.grid-100`, `.grid-100-of` | Full-size grid (`-of`: overflow hidden) |
224
+ | `.scrollable` | Full-size `overflow: auto` |
225
+ | `.flex-v`, `.flex-h` | Flex column / row |
226
+ | `.gap` | `gap: var(--spacing)` |
227
+ | `.centerContent` | Full-size, content centered |
228
+ | `.inline-titled` | Inline row, centered, half gap (label + control) |
229
+ | `.panel-container` | Full-size padded scrollable panel body |
230
+ | `.pad-h`, `.pad`, `.pad-2x` | Padding `--spacing-half` / `--spacing` / `--spacing-2x` |
231
+ | `.border`, `.borderrd` | Form border / form border + radius |
232
+ | `.hide` | `display: none !important` |
233
+ | `.clickable` | Pointer cursor + hover fade |
234
+ | `[disabled]`, `.disabled`, `[restricted]` | Dimmed, not-allowed cursor |
235
+ | `[active]` | `--active` outline |
236
+ | `.fg-primary`, `.fg-secondary`, `.fg-danger`, `.fg-warning`, `.fg-success`, `.fg-info` | Text color |
237
+ | `.bg-primary`, `.bg-secondary`, `.bg-danger`, `.bg-warning`, `.bg-success`, `.bg-info` | Background + inverse text |
238
+ | `.alert.alert-warning` | Warning box |
239
+ | `.f-large` | `font-size: large` |
240
+
241
+ ---
242
+
243
+ ## IconClass
244
+
245
+ Typed Bootstrap Icons class strings.
246
+
247
+ ```ts
248
+ import { IconClass } from '@electral-io/webspa';
249
+
250
+ IconClass.save; // 'bi bi-floppy2-fill'
251
+ IconClass.get('buildings'); // 'bi bi-buildings', any Bootstrap icon id (typed as BootstrapIconsId)
252
+ ```
253
+
254
+ Presets include `search`, `query`, `save`, `add`, `new`, `delete`, `edit`, `cancel`, `X`, `error`, `warning`, `info`, `success`, `chevron_*`, `caret_*`, `dots`, `dots_v`, `logout`, `settings`, `world`, `alert`, `hand`, `zoom_in`, `zoom_out`, `cursor`, `key`, `layers`, `person`, `people`, `pin*`, `image`, `book`, `download`, `bbox`, `circle`, `clock`, `clock_history`, `crosshair`, `email`, `eye`, `eye_slash`, `flag`, `journal`, `lightning`, `calendar`, `list`, `map`, `map_fill`, `paperclip`, `pinmap`, `puzzle`, `question`, `refresh`, `light`, `dark`, `palette`.
255
+
256
+ ---
257
+
258
+ ## Core Utilities
259
+
260
+ ### Result types
261
+
262
+ ```ts
263
+ type OpResult<T = void> = { success: boolean; message?: string; data?: T };
264
+ type AsyncOpResult<T = void> = Promise<OpResult<T>>;
265
+ interface ILookup { key?: string | number; title: string } // options for selects, lists, comboboxes
266
+ ```
267
+
268
+ ### Eventer
269
+
270
+ ```ts
271
+ import { Eventer } from '@electral-io/webspa';
272
+
273
+ const changed = new Eventer<string, void>();
274
+ const ac = new AbortController();
275
+ changed.register((data) => console.log(data), ac.signal); // the signal unregisters automatically
276
+ await changed.raise('hello');
277
+ ac.abort(); // or changed.unregister(listener)
278
+ ```
279
+
280
+ ### Observable properties
281
+
282
+ ```ts
283
+ class Model extends Observable { @Observe() status: string; }
284
+ model.onPropChanged.register(({ name, oldValue, newValue }) => { /* ... */ });
285
+ ```
286
+
287
+ `BaseElement` is observable too.
288
+
289
+ ### System & DateHelper
290
+
291
+ ```ts
292
+ System.isNullOrUndefined(v); System.isEmpty(v); System.isString(v); System.toNumber('5');
293
+ await System.wait(500); System.downloadBlob(blob, 'file.xlsx');
294
+ DateHelper.isoStrToDate(str); DateHelper.dateToIsoStr(date); DateHelper.formatIsoStr(str);
295
+ ```
296
+
297
+ ### List & IndexedList
298
+
299
+ ```ts
300
+ const list = new List<Item>(a, b); // observable array: add, remove, changeEvent...
301
+ const items = new IndexedList<Item>(a, b); // observable list with ids: source for ListComponent / TreeView
302
+ items.find(x => x.key == 'k');
303
+ ```
304
+
305
+ ---
306
+
307
+ ## Components
308
+
309
+ All components are Custom Elements. Create them with `new` and append them to the DOM.
310
+
311
+ ### Writing your own component
312
+
313
+ ```ts
314
+ @Component({ elementName: 'app-order-card' }) // unique, must contain a dash
315
+ export class OrderCard extends BaseElement {
316
+ constructor() { super(); this.appendChild(ELF.span('...')); }
317
+ protected onConnected(isFirst?: boolean) { if (!isFirst) return; /* load */ }
318
+ }
319
+ ```
320
+
321
+ `BaseElement` provides `getDisposedSignal()` (aborted on disconnect), `setBusy(true|false)`, `visible`, `bag`, `onPropChanged` and `canClose()`.
322
+
323
+ ### ELF (element factory)
324
+
325
+ ```ts
326
+ ELF.div('my-class', child1, 'text');
327
+ ELF.span('text', 'cls'); ELF.label('Name'); ELF.h(3, 'Title'); ELF.input('text', '', 'placeholder');
328
+ ELF.button('Save', 'Save changes', 'primary', IconClass.save, async (ev, btn) => { await save(); });
329
+ ELF.Buttons.delete(() => remove());
330
+ ELF.build({ tag: 'div', attrs: { className: 'card' }, children: [ /* ... */ ] });
331
+ ELF.showHideSpinnerAt(elm, true);
332
+ ```
333
+
334
+ ### Inputs
335
+
336
+ ```ts
337
+ const combo = new ComboBox([{ key: 1, title: 'A' }, { key: 2, title: 'B' }]); // or an async loader
338
+ const child = new ComboBox({ parents: [combo], itemLoader: async (parentValues) => loadChildren(parentValues[0]) });
339
+ combo.value = 1;
340
+ combo.onValueChanged.register(v => console.log(v));
341
+
342
+ new MultiComboBox(items); // value: LookupValue[]
343
+ new CheckList({ items }); // value: LookupValue[]
344
+ new ButtonRadio({ source: items }); // value: LookupValue
345
+ new TagInput('text'); // value: string[]
346
+ new DateRangeComponent({ inputType: 'date' }); // value: { startDate, endDate }
347
+ new AutoComplete({ loader: async (text) => lookups });
348
+ new TreeView(new IndexedList<ITreeItem>(...nodes), { selection: { mode: 'multi' } });
349
+ ```
350
+
351
+ ### Form
352
+
353
+ ```ts
354
+ const form = new Form<Customer>({
355
+ columns: 2,
356
+ inputs: [
357
+ { key: 'name', title: 'Name', input: 'text', rules: [new RequiredRule()] },
358
+ FormUtils.generateFormInputForCombobox('typeId', 'Type', typeLookups),
359
+ { key: 'active', title: 'Active', input: 'switch' },
360
+ ],
361
+ });
362
+ form.setData(customer);
363
+ if (form.check()) { const data = form.getData(); }
364
+ ```
365
+
366
+ ### DataTable
367
+
368
+ ```ts
369
+ const cc = new DataTableColCreator<Order>();
370
+ const table = new DataTable<Order>({
371
+ uniqueKey: 'orders',
372
+ title: 'Orders',
373
+ columns: {
374
+ idField: 'id',
375
+ modelColumns: {
376
+ id: cc.createBasicCol('id', 'ID'),
377
+ createdAt: cc.createDateTimeIsoStrCol('createdAt', 'Date'),
378
+ status: cc.createLookupCol('status', 'Status', statusLookups),
379
+ },
380
+ },
381
+ dataLoader: async () => api.listOrders(), // returns OpResult<Order[]>
382
+ pagination: { size: 20 },
383
+ actions: { export: { xls: true, pdf: true } },
384
+ segments: { items: [{ key: 'open', title: 'Open', filters: [{ field: 'status', type: '=', value: 1 }] }, { key: 'all', title: 'All' }] },
385
+ });
386
+ ```
387
+
388
+ Use `DtRenderers` for cells (`cc.createCol('status', 'Status', DtRenderers.status({ '1': { tone: 'info', text: 'Open' } }))`). Table actions with `bulk: true` appear only while rows are selected. Column layout and row density are stored per user.
389
+
390
+ ### Notifications & dialogs
391
+
392
+ ```ts
393
+ Notify.Success('Saved'); // also Error, Warning, Info: (title, message?)
394
+ const w = Notify.Wait('Working...'); w.hide();
395
+
396
+ await MessageBox.Info('Done.'); // also Error, Success, Warning, ShowOk
397
+ const yes = await MessageBox.Yesno('Delete this record?');
398
+ const name = await MessageBox.InputText('Enter a name');
399
+ const res = await MessageBox.ShowAsync({ title: 'Edit', content: form, buttons: 'OkCancel' }); // res?.ok
400
+ await DialogWindow.ShowAsync('Title', contentElement);
401
+ ```
402
+
403
+ ### Layout & containers
404
+
405
+ ```ts
406
+ new Flex({ direction: 'row', gap: 'var(--spacing)' }, a, b);
407
+ new Grid({ cols: 'auto 1fr' }, label, input);
408
+ new GroupBox('Details', child1, child2);
409
+ const tabs = new TabContainer();
410
+ tabs.addTab({ name: 't1', header: 'Overview', content: elm }, true);
411
+ tabs.activateTab('t1');
412
+ new DrawerContainer({ main: mainElm, left: { title: 'Filters', content: filtersElm, openMode: 'click' } });
413
+ new Accordion({ header: 'More', content: elm });
414
+ ```
415
+
416
+ ### Component summary
417
+
418
+ | Component | Description |
419
+ |---|---|
420
+ | `ComboBox`, `MultiComboBox`, `AutoComplete`, `CheckList`, `ButtonRadio`, `TagInput` | Inputs (`value`, `onValueChanged`, `readOnly`) |
421
+ | `DateRangeComponent`, `DropDownDateRange` | Date range pickers |
422
+ | `TreeView`, `DropdownTree` | Hierarchical selection |
423
+ | `Form` | Config-driven form with validation |
424
+ | `DataTable` | Tabulator table with search, filters, CRUD and Excel/PDF export |
425
+ | `ListComponent`, `GrouppedListComponent` | Lists over an `IndexedList` |
426
+ | `Flex`, `Grid`, `GroupBox`, `Card`, `Accordion`, `Empty` | Layout |
427
+ | `TabContainer`, `DrawerContainer` | Containers |
428
+ | `Toolbar`, `ButtonList`, `DropdownButton`, `Menu`, `OptionsMenu`, `ContextMenu`, `RadialMenu`, `RibbonMenu` | Buttons & menus |
429
+ | `Notify`, `MessageBox`, `DialogWindow` | Feedback (static APIs) |
430
+ | `MessageDisplay`, `Spinner`, `IndeterminateProgress`, `Skeleton` | Status display |
431
+ | `ConditionalElement` | Swaps content on an observable property |
432
+ | `SummaryCard`, `ProgramCard` | KPI card / progress card (in `.summary-card-row` / `.program-card-grid`) |
433
+ | `ListPanel`, `MasterDetailLayout` | Left list with segments + search; master / detail / aside layout |
434
+ | `FormPanel` | Side-panel form container: numbered sections, sticky footer, missing-field summary |
435
+ | `DtRenderers` | DataTable cell formatters: `pill`, `status`, `priority`, `tag`, `remainingDays`, `toggle`, `actions` |
436
+
437
+ ---
438
+
439
+ ## Framework & Dependency Injection
440
+
441
+ ### Services
442
+
443
+ ```ts
444
+ @Service({ type: ServiceTypes.Singleton }) // Singleton | Scoped (default) | Transient
445
+ export class ApiService { async getUsers(): AsyncOpResult<User[]> { /* ... */ } }
446
+
447
+ @Service()
448
+ export class OrderService {
449
+ @Inject() private api: ApiService; // lazy, resolved by type on first access
450
+ }
451
+ ```
452
+
453
+ | Lifetime | Behavior |
454
+ |---|---|
455
+ | `Singleton` | One instance for the app lifetime |
456
+ | `Scoped` | Recreated when the user scope changes (login / logout) |
457
+ | `Transient` | New instance on every resolution |
458
+
459
+ Built-in services you can inject: `Application`, `EventService`, `CommandService`, `AuthService`, `StoreService`, `NavigationService`, `DepResService`, `FormService`.
460
+
461
+ ### Panels
462
+
463
+ ```ts
464
+ @Inject() application: Application;
465
+
466
+ await this.application.openPanelAsnc({
467
+ name: 'orders', header: 'Orders', content: new OrderList(), closable: true, draggable: true,
468
+ }, PanelPos.Right); // Center | Left | Right | Bottom | Window
469
+
470
+ await this.application.closePanelAsync('orders');
471
+ ```
472
+
473
+ ### Pages
474
+
475
+ ```ts
476
+ @Component({ elementName: 'page-dashboard', classes: ['page'] })
477
+ @Page({ key: 'dashboard', title: 'Dashboard', iconClass: IconClass.layers, auth: 'dashboard-view' })
478
+ export class DashboardPage extends BasePage {
479
+ OnShow(): void { }
480
+ OnHide(): void { }
481
+ }
482
+ ```
483
+
484
+ ### Commands (ribbon)
485
+
486
+ ```ts
487
+ @Command({
488
+ type: CommandTypes.Command, // Command | Tool | Select | Custom
489
+ container: FrameworkCommandTargets.Ribbon, // 'ribbon' | 'user-menu' | custom container
490
+ group: 'mg-general-file', // a group key from ApplicationProperties.ribbon
491
+ key: 'cmd-save',
492
+ title: 'Save',
493
+ iconClass: IconClass.save,
494
+ order: 1,
495
+ auth: 'save_record', // optional permission key
496
+ })
497
+ export class SaveCommand extends BaseCommand {
498
+ start(childKey?: string, param?: any): void { /* ... */ }
499
+ }
500
+ ```
501
+
502
+ Tools (`CommandTypes.Tool`) stay active until another tool starts. They implement `cancel(): boolean`, or extend `BaseDisposableTool`. Start any command from code with `commandService.startCommand(key, childKey, param)`.
503
+
504
+ ### Events
505
+
506
+ ```ts
507
+ @Inject() eventService: EventService;
508
+ this.eventService.listen('orders-changed', (data) => { /* ... */ }, this.getDisposedSignal());
509
+ await this.eventService.raise('orders-changed', data);
510
+ ```
511
+
512
+ Framework events are in `SystemEvents` (`ApplicationStart`, `UserLogin`, `UserLogout`, `PageNavigated`, `ThemeDarkChanged`, ...).
513
+
514
+ ### Startup & periodic jobs
515
+
516
+ ```ts
517
+ @StartupJob('load-config', { type: StartupJobTypes.PreLoad, title: 'Config', required: true })
518
+ class LoadConfigJob implements IAppStartupJob {
519
+ name = 'load-config';
520
+ async run(): Promise<boolean> { /* ... */ return true; }
521
+ }
522
+
523
+ @PeriodicJob({ key: 'refresh', tick: 240 }) // 1 tick = 250 ms, so 240 = 1 minute
524
+ class RefreshJob implements IPeriodicJob { name = 'refresh'; async run() { return true; } }
525
+ ```
526
+
527
+ ### Localization
528
+
529
+ ```ts
530
+ export const AppDictionary = { orders: { tr: 'Siparişler', en: 'Orders' } };
531
+ registerDictionary(AppDictionary);
532
+ export const txtApp = (key: keyof typeof AppDictionary, ...p: string[]) => txtSpa(key as any, ...p);
533
+ ```
534
+
535
+ ---
536
+
537
+ ## Application Startup
538
+
539
+ ```ts
540
+ import './pages/**/*'; // modules must be imported so their decorators register
541
+ import './commands/**/*';
542
+
543
+ class MainStartup extends AppStartup {
544
+ onReady(): void { /* the shell is ready */ }
545
+ }
546
+
547
+ const props: ApplicationProperties = {
548
+ hostElement: document.body,
549
+ title: 'My App',
550
+ subtitle: 'v1.0',
551
+ logoUrl: '',
552
+ apiEndPoint: 'https://api.example.com/',
553
+ defaultPage: FrameworkPages.App,
554
+ adminPageDisabled: true,
555
+ languages: ['tr', 'en'],
556
+ auth: {
557
+ type: 'Local',
558
+ autoLogin: true,
559
+ loginInfoUrl: '',
560
+ createLoginComponent: () => new SimpleLoginComponent({
561
+ loginButtonText: 'Login', userPlaceholder: 'User', passPlaceholder: 'Password',
562
+ loginAction: async ({ ukey, pass }) => api.login(ukey, pass), // returns OpResult<ILoginInfo>
563
+ }),
564
+ },
565
+ ribbon: {
566
+ defaultTool: 'pan',
567
+ tabs: [{ key: 'tab-general', title: 'General', order: 1,
568
+ items: [{ key: 'mg-general-file', title: 'File', order: 1 }] }],
569
+ },
570
+ themes: [{ key: 'teal', title: 'Teal', color1: 'var(--teal-600)', color2: 'var(--cyan-600)' }],
571
+ };
572
+
573
+ new MainStartup().run(props);
574
+ ```
575
+
576
+ ---
577
+
578
+ ## GIS (map apps)
579
+
580
+ The GIS layer (`src/gis`) is exported from the same package root. It is built on OpenLayers 10 and adds a map panel (map + legend + layer tree), built-in ribbon map tools (pan, basemaps, extent history, full extent, fullscreen, go to, print, measure line/area), layer types, identify/popups, selection and search.
581
+
582
+ ```ts
583
+ import { GisAppService, GisEvents, LayerDefinitionTypes, BaseMapTypes, MapManager, LayerManager } from '@electral-io/webspa';
584
+ ```
585
+
586
+ Map apps need `ol` (10.x) in their own dependencies, plus `proj4`, `@terraformer/arcgis`, `geojson` and `coordinate-parser` at the versions in this package's `package.json`.
587
+
588
+ **Coordinates are lon/lat (EPSG:4326)** everywhere: the library calls `useGeographic()`. Centers are `[lon, lat]`, extents `[minLon, minLat, maxLon, maxLat]`.
589
+
590
+ ### Map app setup
591
+
592
+ `GisAppService` is abstract: subclass it as a service and call `startMapComponents` once the shell is ready.
593
+
594
+ ```ts
595
+ @Service({ type: ServiceTypes.Scoped })
596
+ export class MapAppService extends GisAppService { }
597
+
598
+ class MainStartup extends AppStartup {
599
+ @Inject() mapApp: MapAppService;
600
+ onReady(): void {
601
+ this.mapApp.startMapComponents({
602
+ baseMap: BaseMapTypes.GoogleSatellite, // GoogleRoad | GoogleSatellite | EsriGrayLight | EsriGrayDark | EsriTopo | Osm | Solid
603
+ defaultLocation: { center: [32.85, 39.93], zoom: 12 },
604
+ layerLoader: async () => ({ success: true, data: layerDefinitions }), // LayerDefinition[]
605
+ customLayerFactory: new AppLayerFactory(), // handles LayerDefinitionTypes.Custom
606
+ searchSources: [new MySearch()],
607
+ ExternalApiSettings: { proxyUrl: '/proxy', google: { apikey: '...', useProxy: true } },
608
+ });
609
+ }
610
+ }
611
+ ```
612
+
613
+ It opens a non-closable center panel `panel-map-main` (map + a left drawer with Legend and Layers tabs) and starts the ribbon's `defaultTool` when `GisEvents.MapReady` fires. The built-in tools register into the ribbon groups `mg-general-map`, `mg-general-measure` and `mg-general-query`: include these keys in `ApplicationProperties.ribbon` and set `defaultTool: 'pan'`. Permission keys used by built-ins: `Map-Print`, `Google-StreetView`, `Google-Geocode`.
614
+
615
+ ### Layers
616
+
617
+ Layers are described with `LayerDefinition` objects (`type`, `key`, `order`, `title`, `visible`, `minZoom?`, `maxZoom?`, `props`):
618
+
619
+ ```ts
620
+ const layerDefinitions: LayerDefinition[] = [
621
+ { type: LayerDefinitionTypes.EsriMapService, key: 'lyr-usa', order: 1, title: 'USA', visible: true,
622
+ props: { url: 'https://.../MapServer', tiled: false, useProxy: false } },
623
+ { type: LayerDefinitionTypes.WMS, key: 'lyr-poles', order: 2, title: 'Poles', visible: true, minZoom: 14,
624
+ props: { url: 'https://geo/geoserver/ws/wms', layers: 'ws:poles', imagetype: 'image/png', tiled: true,
625
+ useProxy: false, wfsEnabled: false, shapefield: 'geom' } },
626
+ { type: LayerDefinitionTypes.Custom, key: 'lyr-vehicles', order: 3, title: 'Vehicles', visible: true,
627
+ props: { factoryKey: 'vehicles', customProps: { refreshSec: 10 } } },
628
+ ];
629
+ ```
630
+
631
+ ArcGIS MapServer and WMS have built-in factories. Everything else (XYZ tiles, MVT, groups, client vector layers) goes through your single custom factory, an `ILayerFactory` whose `createAsync(ld)` returns the layer. Base classes for custom layers: `MapLayerBase`, `WmsLayer` / `WmsLayerBase`, `MvtLayerBase`, `TileXyzLayer`, `GroupLayerBase`. Add or remove layers at runtime with `LayerManager` (`addLayer`, `removeLayer`, `findLayer`).
632
+
633
+ ### Managers and events
634
+
635
+ Inject `MapManager` (`map`, `view`, `goto`, `gotoFullExtent`, `flashGeom`, `selectAreaFromMap`, ...) and `LayerManager`. Non-map code can navigate through events:
636
+
637
+ ```ts
638
+ eventService.raise(GisEvents.MapGotoExtentRequest, { extent: [minLon, minLat, maxLon, maxLat] });
639
+ eventService.raise(GisEvents.BasemapChange, BaseMapTypes.Osm);
640
+ ```
641
+
642
+ Other events: `MapReady`, `MapFirstRenderComplete`, `MapIdentifyRequest`, `MapSelectionChanged`, `MapIdleRefresh`, `MapTimeChanged`, `RemoveLayer`.
643
+
644
+ ### Map tools and selection
645
+
646
+ Map tools are ordinary commands with `CommandTypes.Tool` in the `GisCommandContainers.ribbon` container; right-click menu commands use `GisCommandContainers.mapCtx` with `canExecuteFor`. To select features, start the default `pan` tool with an `IMapSelectionContext`:
647
+
648
+ ```ts
649
+ const ctx: IMapSelectionContext = {
650
+ mode: 'polygon', // 'single' | 'freehand' | 'polygon'
651
+ makeSelected: true,
652
+ contextMenuContainer: GisCommandContainers.mapCtx,
653
+ layerFilter: (lyr) => getLayerProps(lyr)?.mapLayer?.id == 'lyr-poles',
654
+ completeWork: (items, geom) => { /* items: SelectedMapItem[] */ },
655
+ };
656
+ commandService.startCommand('pan', null, ctx);
657
+ ```
658
+
659
+ For an area only, use `mapManager.selectAreaFromMap('polygon', maxKm2, signal)`.
660
+
661
+ ### Search and proxy
662
+
663
+ Pass `ILocalSearchSource` / `IRemoteSearchSource` instances in `searchSources`; `GoogleGeocodingSearchSource` is built in (needs `google.apikey` and the `Google-Geocode` permission). External requests that need a proxy go through `GET {proxyUrl}?<encoded url>` (`ExternalApiSettings.proxyUrl`, default `{apiEndPoint}/proxy`). `.proxyrc.cjs` is the dev-server equivalent.
664
+
665
+ ---
666
+
667
+ ## Development
668
+
669
+ ```bash
670
+ npm install
671
+ npm run debug # core demo, http://localhost:5544
672
+ npm run debug-gis # map demo, http://localhost:5545
673
+ npm run build # dist/index.js, dist/index.d.ts, dist/index.css (core + GIS/OpenLayers styles)
674
+ npm run css-data # regenerate static/web-spa-css-custom-data.json after changing src/style/theme/*.less
675
+ npm run doc # typedoc (doc-md for Markdown output)
676
+ ```
677
+
678
+ Layout: `src/core`, `src/elements`, `src/framework`, `src/style` (core), `src/gis` (GIS), `src/spa.ts` (core barrel) and `src/index.ts` (public entry: `export * from './spa'` and `'./gis'`).
679
+
680
+ Google map / Street View in `debug-gis` needs an API key. Put it in a git-ignored `.env` file at the repo root (read by `.proxyrc.cjs`, which appends it server side):
681
+
682
+ ```
683
+ GOOGLE_MAPS_API_KEY=your-key
684
+ ```
685
+
686
+ ## Release
687
+
688
+ Publishing is done by GitHub Actions (`.github/workflows/publish.yml`) when a GitHub Release is published, using npm Trusted Publishing (OIDC), so no npm token is stored. To release:
689
+
690
+ 1. Bump `version` in `package.json`.
691
+ 2. Merge to the default branch.
692
+ 3. Create a GitHub Release (tag `vX.Y.Z`) in github.com/electral-io/webspa.
693
+
694
+ ---
695
+
696
+ ## Claude Code skill
697
+
698
+ This repo ships a Claude Code plugin with the `web-spa` skill (`claude-plugin/skills/web-spa/`). The skill teaches Claude these APIs (core and GIS) and the CSS token rules. Install it once:
699
+
700
+ ```
701
+ /plugin marketplace add electral-io/webspa
702
+ /plugin install web-spa@electral-io
703
+ ```
704
+
705
+ Get updates with `/plugin marketplace update electral-io`. Projects can preinstall it for everyone through `extraKnownMarketplaces` and `enabledPlugins` in their `.claude/settings.json`.
706
+
707
+ ---
708
+
709
+ ## License
710
+
711
+ ISC © Electral