@mixd-id/web-scaffold 0.2.250801014 → 0.2.260801002

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 (113) hide show
  1. package/README.md +25 -1
  2. package/docs/components/AIDashboard.md +117 -32
  3. package/docs/components/Alert.md +3 -1
  4. package/docs/components/Card.md +5 -0
  5. package/docs/components/PivotList.md +207 -0
  6. package/docs/components/Textbox.md +20 -1
  7. package/docs/components/Tooltip.md +148 -0
  8. package/package.json +17 -61
  9. package/src/components/AIChat.vue +14 -5
  10. package/src/components/AIChatItem.vue +34 -3
  11. package/src/components/AIDashboard.vue +144 -98
  12. package/src/components/Alert.vue +58 -35
  13. package/src/components/Card.vue +55 -6
  14. package/src/components/Carousel.vue +1 -1
  15. package/src/{widgets → components}/CartSetting.vue +1 -0
  16. package/src/components/Chart.vue +2 -2
  17. package/src/components/Checkbox.vue +22 -24
  18. package/src/components/CodeEditor.vue +2 -0
  19. package/src/{widgets → components}/CollapsiblePanelSetting.vue +1 -0
  20. package/src/components/ColorPicker.vue +2 -2
  21. package/src/{widgets → components}/ComponentSetting2.vue +1 -1
  22. package/src/components/Confirm.vue +2 -0
  23. package/src/{widgets → components}/ContactForm.vue +1 -1
  24. package/src/components/CopyToClipboard.vue +0 -1
  25. package/src/components/Datepicker.vue +2 -0
  26. package/src/components/Datetimepicker.vue +1 -1
  27. package/src/components/Dropdown.vue +1 -1
  28. package/src/{widgets → components}/FeatureList.vue +1 -1
  29. package/src/components/Gmaps.vue +1 -1
  30. package/src/{widgets → components}/IconList.vue +1 -1
  31. package/src/components/Image.vue +1 -1
  32. package/src/components/ImagePreview.vue +32 -51
  33. package/src/components/List.vue +45 -8
  34. package/src/components/MarkdownEdit.vue +1 -1
  35. package/src/components/MarkdownPreview.vue +4 -1
  36. package/src/{widgets → components}/MenuItem1Setting.vue +1 -0
  37. package/src/components/Metric.vue +30 -7
  38. package/src/components/MultiDropdown.vue +3 -1
  39. package/src/{widgets → components}/MultiValueSetting.vue +2 -2
  40. package/src/{widgets → components}/MultiValueSetting2.vue +2 -2
  41. package/src/components/OTPField.vue +3 -1
  42. package/src/components/PivotList.vue +1239 -0
  43. package/src/{widgets → components}/PresetBar.vue +1 -1
  44. package/src/components/PresetSelectorFilterItem.vue +1 -1
  45. package/src/components/Radio.vue +1 -1
  46. package/src/{widgets → components}/Share.vue +1 -1
  47. package/src/components/SvgEditor.vue +1 -1
  48. package/src/components/Switch.vue +1 -1
  49. package/src/components/TextWithTag.vue +2 -2
  50. package/src/components/Textbox.vue +63 -5
  51. package/src/components/Toast.vue +2 -1
  52. package/src/components/Tooltip.vue +626 -0
  53. package/src/components/VirtualGrid.vue +2 -2
  54. package/src/components/VirtualTable.vue +4 -3
  55. package/src/{widgets → components}/WebDatasourceSelector.vue +2 -0
  56. package/src/{widgets → components}/WebLayoutSelector.vue +1 -1
  57. package/src/directives/tooltip.js +131 -0
  58. package/src/index.js +11 -235
  59. package/src/themes/default/index.js +16 -0
  60. package/src/utils/ai-dashboard.js +163 -38
  61. package/src/utils/helpers.mjs +52 -5
  62. package/src/utils/list.mjs +4 -1
  63. package/src/utils/pivot-formula.mjs +358 -0
  64. package/src/utils/pivot-list.mjs +557 -0
  65. package/src/utils/tooltip.mjs +243 -0
  66. package/src/utils/wss.js +26 -6
  67. package/src/utils/wss.mjs +29 -2
  68. /package/src/{widgets → components}/AhrefSetting.vue +0 -0
  69. /package/src/{widgets → components}/ArticleSetting.vue +0 -0
  70. /package/src/{widgets → components}/BackgroundColorSetting.vue +0 -0
  71. /package/src/{widgets → components}/BlockSetting.vue +0 -0
  72. /package/src/{widgets → components}/BorderColorSetting.vue +0 -0
  73. /package/src/{widgets → components}/ButtonSetting.vue +0 -0
  74. /package/src/{widgets → components}/CarouselSetting.vue +0 -0
  75. /package/src/{widgets → components}/ColumnSelector.vue +0 -0
  76. /package/src/{widgets → components}/ComponentSetting.vue +0 -0
  77. /package/src/{widgets → components}/ContactFormSetting.vue +0 -0
  78. /package/src/{widgets → components}/CountdownSetting.vue +0 -0
  79. /package/src/{widgets → components}/DataListSetting.vue +0 -0
  80. /package/src/{widgets → components}/EmbeddedVideo.vue +0 -0
  81. /package/src/{widgets → components}/EmbeddedVideoSetting.vue +0 -0
  82. /package/src/{widgets → components}/FAQ.vue +0 -0
  83. /package/src/{widgets → components}/FAQSetting.vue +0 -0
  84. /package/src/{widgets → components}/FeatureListSetting.vue +0 -0
  85. /package/src/{widgets → components}/FlexSetting.vue +0 -0
  86. /package/src/{widgets → components}/GridSetting.vue +0 -0
  87. /package/src/{widgets → components}/Header2.vue +0 -0
  88. /package/src/{widgets → components}/Header2Setting.vue +0 -0
  89. /package/src/{widgets → components}/IconListSetting.vue +0 -0
  90. /package/src/{widgets → components}/ImageSetting.vue +0 -0
  91. /package/src/{widgets → components}/LLMChat.vue +0 -0
  92. /package/src/{widgets → components}/LinkSetting.vue +0 -0
  93. /package/src/{widgets → components}/LinkSettingModal.vue +0 -0
  94. /package/src/{widgets → components}/ModalSetting.vue +0 -0
  95. /package/src/{widgets → components}/OGSettingModal.vue +0 -0
  96. /package/src/{widgets → components}/ParagraphSetting.vue +0 -0
  97. /package/src/{widgets → components}/PositionSetting.vue +0 -0
  98. /package/src/{widgets → components}/PresetBarPivot.vue +0 -0
  99. /package/src/{widgets → components}/PresetBarPivotColumnEdit.vue +0 -0
  100. /package/src/{widgets → components}/PresetBarPivotValueEdit.vue +0 -0
  101. /package/src/{widgets → components}/PresetSelector.vue +0 -0
  102. /package/src/{widgets → components}/Review.vue +0 -0
  103. /package/src/{widgets → components}/ReviewSetting.vue +0 -0
  104. /package/src/{widgets → components}/SearchModalSetting.vue +0 -0
  105. /package/src/{widgets → components}/ShareSetting.vue +0 -0
  106. /package/src/{widgets → components}/SvgSetting.vue +0 -0
  107. /package/src/{widgets → components}/TableSetting.vue +0 -0
  108. /package/src/{widgets → components}/TestimonialSetting.vue +0 -0
  109. /package/src/{widgets → components}/TextBlockSetting.vue +0 -0
  110. /package/src/{widgets → components}/ThumbnailSetting.vue +0 -0
  111. /package/src/{widgets → components}/WebComponentSelector.vue +0 -0
  112. /package/src/{widgets → components}/WebPageBuilder.vue +0 -0
  113. /package/src/{widgets → components}/YoutubeVideoSetting.vue +0 -0
package/README.md CHANGED
@@ -1,3 +1,27 @@
1
1
  # Web Scaffold for mixd.id
2
2
 
3
- This scaffold based on express vitejs vuejs
3
+ `@mixd-id/web-scaffold` the shared Vue 3 + Tailwind UI framework and utility library used by mixd CMS front ends (client: Vue 3 + Vite, server: Node.js ESM).
4
+
5
+ - **[Getting started](docs/GETTING-STARTED.md)** — installation, plugin setup, Tailwind theme, WebSocket client/server, utilities.
6
+ - **[Component docs](docs/components/)** — per-component usage and props.
7
+
8
+ ## Quick start
9
+
10
+ ```js
11
+ // main.js of a Vite + Vue 3 app
12
+ import webScaffold from '@mixd-id/web-scaffold'
13
+ app.use(webScaffold) // registers all components globally (lazy) + directives + $util helpers
14
+ ```
15
+
16
+ ```js
17
+ // tailwind.config.js
18
+ module.exports = {
19
+ content: [
20
+ './src/**/*.{vue,js}',
21
+ './node_modules/@mixd-id/web-scaffold/src/**/*.{vue,js}',
22
+ ],
23
+ plugins: [require('@mixd-id/web-scaffold/themes/default')],
24
+ }
25
+ ```
26
+
27
+ This package ships uncompiled sources — the consuming app's Vite build compiles it. Within the monorepo it is consumed via `"file:../web-scaffold"`, so changes here are picked up without a publish step.
@@ -191,11 +191,12 @@ data sources. All types also accept a shared **`filters`** array.
191
191
  // metric
192
192
  query = { datasource, aggregation: 'count'|'sum'|'avg'|'min'|'max', field, filters }
193
193
 
194
- // chart
195
- query = { datasource, groupBy, dateModifier: ''|'day'|'week'|'month'|'year', limit, filters }
194
+ // chart — `aggregation`/`field` size each bar (count of rows when unset)
195
+ query = { datasource, aggregation, field, groupBy,
196
+ dateModifier: ''|'day'|'week'|'month'|'year', limit, filters }
196
197
 
197
- // table
198
- query = { datasource, pageSize, order: [[field,'desc']], columns, filters }
198
+ // table — `order` is Sequelize's own shape, any number of levels
199
+ query = { datasource, pageSize, page, order: [[field,'asc'|'desc'], ...], columns, filters }
199
200
 
200
201
  // filters (all types) — folded into a Sequelize `where` on the server
201
202
  filters = [ { field: String, op: '='|'!='|'>'|'>='|'<'|'<='|'contains'|'in', value }, ... ]
@@ -268,10 +269,13 @@ and the offline chat demo while gaining the Data tab and query hydration.
268
269
  - **Add component** (toolbar `+`) opens a menu (Section / Metric / Chart /
269
270
  Table) that inserts into the selected group, else at the root.
270
271
  - **Node inspector** (bottom): edits `name`; for **groups** the `layout`
271
- (mode + columns); for **leaves** the data source, aggregation / group-by /
272
- page-size, eligible field, and the **Filters** editor (field / operator /
273
- value rows). A **Refresh** button (connected) re-hydrates on demand; query
274
- edits re-hydrate automatically (debounced ~450ms).
272
+ (mode + columns); for **leaves** the authored `data` (caption, and a metric's
273
+ value + delta) followed by the query builder data source, aggregation +
274
+ eligible field (metric **and** chart), group-by / bucket / limit (chart),
275
+ column checkboxes + multi-level order rows + page size (table), and the
276
+ **Filters** editor (field / operator / value rows). A **Refresh** button
277
+ (connected) re-hydrates on demand; query edits re-hydrate automatically
278
+ (debounced ~450ms).
275
279
 
276
280
  ### Data tab (data sources)
277
281
 
@@ -307,8 +311,10 @@ The active preset's `structure` renders top-to-bottom (`flex flex-col gap-6`):
307
311
 
308
312
  Card (`bg-base-300 rounded-xl p-5`) with a title row (name + hover ✕), then:
309
313
 
310
- 1. **metric** caption (`data.label || node.name`), a large
311
- `toLocaleString()` number, optional delta line (`▲ n%` green / `▼ n%` red).
314
+ 0. The card **caption** is `data.label || node.name` for every type, and a
315
+ node whose hydration failed renders `data.error` in red in place of its body.
316
+ 1. **metric** — a large `toLocaleString()` number, optional delta line
317
+ (`▲ n%` green / `▼ n%` red).
312
318
  2. **chart** — a **vue-chartjs `Bar`** in a 240px-tall box. Bar/grid/tick colors
313
319
  are read from the active theme's CSS variables (`--primary`, `--border-50`,
314
320
  `--text-300`) with SSR-safe fallbacks; empty series shows a "No data." hint.
@@ -359,9 +365,10 @@ group). Then re-hydrates and persists. Missing uids are backfilled.
359
365
  structure to `<controller>.load-dashboard` and **merges** returned `data` into
360
366
  the live nodes **by uid**. Derived data is never authored by hand.
361
367
  - **Persist** — a deep `watch(config)` calls debounced (**700ms**)
362
- `<controller>.save` with a cleaned config: node `data` is stripped from any
363
- node that has a `query` (the server recomputes it), and `messages` are frozen.
364
- No-op unless `connected && autoSave`.
368
+ `<controller>.save` with a cleaned config: on a node that has a `query` only
369
+ the authored `data.label` / `data.delta` are kept (the server recomputes
370
+ `value` / `series` / `rows`), and `messages` are frozen. No-op unless
371
+ `connected && autoSave`.
365
372
 
366
373
  ### Offline persistence (host-owned)
367
374
 
@@ -389,7 +396,7 @@ watch(() => config.value, to => saveConfig('dashboard', to), { deep: true })
389
396
  | `samples` | `Object` | `{ structure: [], widgets: {} }` | Offline seed layout + generatable widgets. Nodes are uid-less templates. |
390
397
  | `socket` | `Object` | `null` | Socket transport. With `controller`, switches the component to **connected** mode. |
391
398
  | `controller` | `String` | `''` | Socket namespace prefixing every call (`<controller>.load-datasource`, `.load-dashboard`, `.save`, `.chat`, `.remove-preset`). |
392
- | `datasources` | `Object` | `{}` | Optional client-side data-source descriptor `{ [key]: { label, fields, aggregations } }`; used only if the server returns none. |
399
+ | `datasources` | `Object\|Array` | `{}` | Optional client-side data-source descriptor — either `{ [key]: { label, fields, aggregations } }` or the `[{ key, label, fields, aggregations }]` array `load-datasource` returns; used only if the server returns none. |
393
400
  | `autoSave` | `Boolean` | `true` | Persist edits via `<controller>.save`. Set `false` when the host page owns persistence. |
394
401
  | `serverChat` | `Boolean` | `true` | Route chat through `<controller>.chat`. Set `false` to keep the offline generator while connected. |
395
402
 
@@ -402,24 +409,36 @@ watch(() => config.value, to => saveConfig('dashboard', to), { deep: true })
402
409
  ### `ai-dashboard` socket controller
403
410
 
404
411
  `server/src/controllers/sockets/ai-dashboard.js` (registered as
405
- `routes['ai-dashboard']`) mixes in `AIDashboardMixin` and adds:
406
-
407
- - **`load-datasource`** → `loadDatasource()` returns `{ datasources: [...] }`,
408
- a **hardcoded** list (`order`, `message`, `inbox`, `customer`, `product`)
409
- with typed fields + supported aggregations.
410
- - **`get datasources()`** maps each `key` to a real Sequelize model, so
411
- `load-dashboard` / `analyze-node` can compute widget data.
412
- - **Filtering** `buildWhere(filters)` maps `{field, op, value}` rows to a
413
- Sequelize `where` (`= != > >= < <= contains in` → `Op.eq/ne/gt/gte/lt/lte/like/in`,
414
- blanks dropped, `in` comma-split). An `analyzeNode` override folds
415
- `query.filters` into `where` before delegating to the mixin analyzers.
412
+ `routes['ai-dashboard']`) mixes in `AIDashboardMixin` and adds only what is
413
+ project-specific:
414
+
415
+ - **`get datasources()`** maps each catalogue `key` to a real Sequelize model,
416
+ so `load-dashboard` / `analyze-node` can compute widget data.
417
+ - **`get datasourceList()`** the catalogue `load-datasource` answers with
418
+ (`order`, `message`, `inbox`, `customer`, `product` typed fields +
419
+ supported aggregations). Without it the mixin describes the models in
420
+ `datasources` instead.
421
+ - **`aiChat`** the real LLM call.
422
+
423
+ Filtering is **not** the controller's job any more — the mixin folds
424
+ `query.filters` into the `where` of every analyzer.
416
425
 
417
426
  ### `AIDashboardMixin` (`@mixd-id/web-scaffold/ai-dashboard`)
418
427
 
419
428
  Host-class contract (getters): `Models` `{ UserConfig, UserPreset }`,
420
429
  `Sequelize` (for `Op`), `configKey` (the `UserConfig.key`), `conn` and
421
- `datasources` (for hydration). `this.socket.token.userId` comes from the base
422
- socket `Controller`.
430
+ `datasources` (for hydration), optionally `datasourceList`.
431
+ `this.socket.token.userId` comes from the base socket `Controller`.
432
+
433
+ Every field name a query names — `field`, `groupBy`, `columns[].key`,
434
+ `order[][0]`, `filters[].field` — is checked against the model's attributes
435
+ before it reaches `fn`/`col`/`literal`. An unknown one throws, and
436
+ `load-dashboard` catches per node and returns `data.error` so one broken widget
437
+ cannot blank the dashboard.
438
+
439
+ **Filters → `where`** — `filtersToWhere(model, filters)` maps `{field, op, value}`
440
+ rows to Sequelize (`= != > >= < <= contains in` →
441
+ `Op.eq/ne/gt/gte/lt/lte/like/in`, blanks dropped, `in` comma-split).
423
442
 
424
443
  Methods (socket path → method, kebab→camel):
425
444
 
@@ -430,13 +449,77 @@ Methods (socket path → method, kebab→camel):
430
449
  | `<controller>.save-preset` | `savePreset({ preset })` | Upsert one preset (appends `seq` for new rows) |
431
450
  | `<controller>.remove-preset` | `removePreset({ uid })` | Destroy one preset → `{ removed }` |
432
451
  | `<controller>.load-dashboard` | `loadDashboard({ preset })` | Hydrate: walk the structure, compute `data` for every node with a `query`, recurse into groups; returns the hydrated preset |
433
- | `<controller>.load-datasource` | `loadDatasource()` | *(controller, not mixin)* the available data sources |
434
- | `<controller>.analyze-node` | `analyzeNode({ node })` | Compute one node's `data`: metric → `{ value }`, chart → `{ series }`, table → `{ columns, rows, hasNext }` |
452
+ | `<controller>.load-datasource` | `loadDatasource()` | `{ datasources }` — `this.datasourceList`, or the models in `this.datasources` described |
453
+ | `<controller>.analyze-node` | `analyzeNode({ node })` | Compute one node's `data`: metric → `{ value }`, chart → `{ series }`, table → `{ columns, rows, hasNext }`. Each query's `filters` are folded into its `where` here — no override needed |
435
454
  | `<controller>.chat` | `chat({ messages, structure })` | **Overridable stub** — real controllers return `{ reply, structure?, nodes?, model? }` |
436
455
 
437
456
  `mixin(Class, overrides)` copies mixin methods onto the prototype **without
438
- clobbering** methods the class already defines (e.g. the controller's own
439
- `analyzeNode`); `overrides` always win.
457
+ clobbering** methods the class already defines (e.g. a controller's own
458
+ `loadDatasource` or `analyzeNode`); `overrides` always win.
459
+
460
+ `server/docs/dashboard.json` is the worked fixture for all of this — one preset
461
+ exercising every widget type, aggregation, bucket, order and filter operator the
462
+ analyzers understand.
463
+
464
+ ### `apiMixin` (`@mixd-id/web-scaffold/ai-dashboard-api`)
465
+
466
+ The REST counterpart. Same logic, same `UserConfig`/`UserPreset` rows — an API
467
+ client and the component read and write the same dashboards.
468
+
469
+ | Route | Verb → handler | Mixin method |
470
+ |-------|----------------|--------------|
471
+ | `/ai-dashboard` | GET → `index` | `load()` |
472
+ | `/ai-dashboard` | POST → `store` | `save({ config })` |
473
+ | `/ai-dashboard/preset` | POST → `store` | `savePreset({ preset })` |
474
+ | `/ai-dashboard/preset?uid=` | DELETE → `destroy` | `removePreset({ uid })` |
475
+ | `/ai-dashboard/datasource` | GET → `index` | `loadDatasource()` |
476
+ | `/ai-dashboard/dashboard` | POST → `store` | `loadDashboard({ preset })` |
477
+ | `/ai-dashboard/node` | POST → `store` | `analyzeNode({ node })` |
478
+ | `/ai-dashboard/chat` | POST → `store` | `aiChat({ messages, structure })` |
479
+
480
+ `apiMixin(host)` returns those grouped by route (`{ config, preset, datasource,
481
+ dashboard, node, chat }`). `host` is the socket controller's getter contract as
482
+ a plain object — `Models`, `Sequelize`, `conn`, `configKey`, `datasources`,
483
+ optional `datasourceList` / `model` — plus:
484
+
485
+ - **`ValidationError`** — when given, bodies are validated and a bad one is a
486
+ **400** (`uid`+`name` on a preset, `type` on a node, `messages` on chat).
487
+ - **`NotFoundError`** — when given, an unknown preset uid is a **404**.
488
+ - any override the socket controller would make, `aiChat` above all.
489
+
490
+ Notes that bite:
491
+
492
+ - Every body is taken both **wrapped** the way the socket path takes it
493
+ (`{ preset }`, `{ node }`, `{ config }`) and **bare**, so posting a preset as
494
+ the body does what it looks like it does.
495
+ - `POST /ai-dashboard/dashboard` also accepts just `{ uid }` and hydrates the
496
+ stored preset — an API client should not have to post back a structure the
497
+ server already has.
498
+ - **Identity is per request.** The http middleware calls a handler as
499
+ `fn.apply(route, [req, res])`, so `this` is the route object *shared by every
500
+ request*; the mixin builds a fresh context around `req.token` instead and
501
+ never stores a request on the route.
502
+ - **DELETE takes `?uid=`**, not a path segment: wildcard routes match `[a-z0-9]`
503
+ only, which cannot hold a `p-lz4k2h-1`.
504
+ - Controller files must stay **literal** — `npm run postman` reads `name` and
505
+ `examples` out of the source of `export default`, not out of anything a
506
+ function returned. So wire the handlers in, don't generate the object:
507
+
508
+ ```js
509
+ // src/controllers/api/ai-dashboard/index.js
510
+ import { apiMixin } from "@mixd-id/web-scaffold/ai-dashboard-api"
511
+ import host from "./_host.js"
512
+
513
+ const { config } = apiMixin(host)
514
+
515
+ export default {
516
+ index: { name: 'Load the dashboard', fn: config.index },
517
+ store: { name: 'Save the dashboard', fn: config.store, examples: [ ... ] },
518
+ }
519
+ ```
520
+
521
+ Then register the path in `src/controllers/api/index.js` and its auth in
522
+ `src/configs/api.js` (`'/ai-dashboard': { token: true }`).
440
523
 
441
524
  ---
442
525
 
@@ -469,4 +552,6 @@ clobbering** methods the class already defines (e.g. the controller's own
469
552
  | `src/components/AIChatTextbox.vue` | Reused composer input (see `AIChat.md`). |
470
553
  | `src/pages/Dashboard.vue` | Page host; owns `samples`, connects to `ai-dashboard` for data only (auto-save + server-chat off). |
471
554
  | `web-scaffold/src/utils/ai-dashboard.js` | Server-side `AIDashboardMixin` + `mixin()` for socket controllers. |
472
- | `server/src/controllers/sockets/ai-dashboard.js` | Concrete `ai-dashboard` controller: hardcoded `load-datasource` + filter→`where`. |
555
+ | `web-scaffold/src/utils/ai-dashboard-api.js` | `apiMixin()` the same logic behind REST handlers for `src/controllers/api`. |
556
+ | `server/src/controllers/sockets/ai-dashboard.js` | Concrete `ai-dashboard` controller: the datasource catalogue, its models, and the real `ai-chat`. |
557
+ | `server/docs/dashboard.json` | Worked fixture of the persisted shape + catalogue (see `server/docs/dashboard.md`). |
@@ -12,9 +12,11 @@ None (controlled via methods)
12
12
  ## Methods
13
13
  | Method | Parameters | Description |
14
14
  |--------|------------|-------------|
15
- | open | `{ title?, message, buttonText?, onConfirm? }` | Opens the alert dialog |
15
+ | open | `{ title?, message, buttonText?, onConfirm?, icon? }` | Opens the alert dialog. Pass `icon: false` to drop the red warning mark when the dialog is not reporting an error |
16
16
  | close | - | Closes the alert dialog |
17
17
 
18
+ The dialog is capped at 80% of the viewport height: the title, message and details scroll inside it while the confirm button stays pinned at the bottom. Pressing Escape confirms and closes it, the same as the button.
19
+
18
20
  ## Slots
19
21
  | Slot | Description |
20
22
  |------|-------------|
@@ -13,6 +13,11 @@ A card component that can be used as a static container or floating modal.
13
13
  | hash | String | `undefined` | URL hash to control card visibility |
14
14
  | query | String | `undefined` | URL query param to control card visibility |
15
15
 
16
+ ## Events
17
+ | Event | Payload | Description |
18
+ |-------|---------|-------------|
19
+ | show | `{ context, caller }` | Emitted when a floating card opens — by `open()`, or by its `hash`/`query` appearing in the route |
20
+
16
21
  ## Methods
17
22
  | Method | Parameters | Description |
18
23
  |--------|------------|-------------|
@@ -0,0 +1,207 @@
1
+ # PivotList
2
+
3
+ A pivot-first data list. Same structure and preset system as `List`, but the
4
+ preset is **always** in pivot mode and the result renders in a **single
5
+ `VirtualTable`**. Built for aggregating a Sequelize model — including columns
6
+ that live across **relationships** (`customer.name`, `order.customer.city`) —
7
+ with client-side **virtual columns** edited in a spreadsheet-style formula bar.
8
+
9
+ ## TL;DR — how to use
10
+
11
+ **1. Client** (any CMS front end):
12
+
13
+ ```vue
14
+ <PivotList title="Sales"
15
+ controller="order"
16
+ preset-key="order-pivot"
17
+ subscribe-key="order"
18
+ :default-config="defaultConfig"
19
+ @pivot-item-click="drillDown" />
20
+ ```
21
+
22
+ ```js
23
+ import PivotList from '@mixd-id/web-scaffold/components/PivotList.vue'
24
+
25
+ const defaultConfig = () => ({
26
+ name: 'Orders',
27
+ columns: [
28
+ { key: 'status', label: 'Status', filterable: true, search: true },
29
+ { key: 'amount', label: 'Amount', type: 'currency', filterable: true },
30
+ { key: 'orderedAt', label: 'Ordered', type: 'date', filterable: true },
31
+ // relationship column — dotted key resolves through model.associations
32
+ { key: 'customer.city', label: 'City', filterable: true },
33
+ ],
34
+ presets: [{
35
+ name: 'By city / month',
36
+ pivot: {
37
+ rows: [
38
+ { key: 'customer.city' },
39
+ { key: 'orderedAt', aggregate: 'month' },
40
+ ],
41
+ values: [
42
+ { key: 'amount', aggregate: 'sum' },
43
+ { key: 'id', aggregate: 'count' },
44
+ ],
45
+ sorts: [ { key: '_amount-sum', type: 'desc' } ],
46
+ useTotal: true,
47
+ },
48
+ }],
49
+ })
50
+
51
+ const drillDown = ({ item, column, filters }) => {
52
+ // filters are ready-made List filters equivalent to the clicked cell
53
+ // — e.g. navigate to the orders List with them applied
54
+ }
55
+ ```
56
+
57
+ **2. Server** — apply the mixin once on the model base class
58
+ (`server/src/classes/model.js`):
59
+
60
+ ```js
61
+ import { PivotListMixin } from '@mixd-id/web-scaffold/pivot-list'
62
+
63
+ class CustomModel extends PivotListMixin(Model) { ... }
64
+ ```
65
+
66
+ then add the socket handler the component calls (`${controller}.pivot-load`):
67
+
68
+ ```js
69
+ // server/src/controllers/sockets/order.js
70
+ async 'pivot-load'(preset){
71
+ return Order.findAllFromPivotPreset(preset, {
72
+ config: { columns: [ ...same column defs... ] },
73
+ Sequelize
74
+ })
75
+ }
76
+ ```
77
+
78
+ (Or skip the mixin and call the standalone helper:
79
+ `findAllFromPivotPreset(Order, preset, { config })`.)
80
+
81
+ **3. Presets** persist exactly like `List`: pass `preset-key` and the config
82
+ (presets, pivot setup, virtual columns, sidebar state) auto-saves to
83
+ `${controller}.preset` (falling back to `user.preset`) a moment after any
84
+ change, and reloads on mount. No extra wiring if your controller already
85
+ supports `List` presets.
86
+
87
+ That's it — the sidebar (`Alt+\``) gives users groups, aggregates, crosstab
88
+ spreads, ordering, top-N limits, filters and sharing; the formula bar adds
89
+ calculated columns without touching the server.
90
+
91
+ ## How it differs from List
92
+
93
+ | | List | PivotList |
94
+ |---|---|---|
95
+ | Pivot | optional per preset | always on (`pivot.enabled` is forced) |
96
+ | Result view | table or grid, pivot drill-down opens a second table | one `VirtualTable`, always |
97
+ | Pagination | infinite scroll (`afterItem`) | none — one grouped result set, cap with `pivot.limit` |
98
+ | Drill-down | opens the built-in ext panel | emits `pivot-item-click` with ready-made filters; the host decides |
99
+ | Relationships | filter-only (`filtersToSequelizeInclude`) | groups, aggregates, spreads, filters and sorts on dotted keys |
100
+ | Virtual columns | — | client-side formula columns (formula bar) |
101
+ | Server endpoint | `${controller}.load` | `${controller}.pivot-load` |
102
+
103
+ ## Data-type behavior
104
+
105
+ Column types come from your config or, when omitted, from the model
106
+ (`columnTypes` in the response — dotted relationship keys included). The type
107
+ changes what the pivot does with a column:
108
+
109
+ | Type | As a group (row) | As a value (aggregate) | Default aggregate |
110
+ |---|---|---|---|
111
+ | `number` / `currency` | groups raw values | `sum`, `avg`, `min`, `max`, `stddev`, `count`, `countDistinct`, `first`, `last` | `sum` |
112
+ | `date` | buckets: `hour`, `date`, `week`, `month`, `quarter`, `year` | `min` (earliest), `max` (latest), `count`, `countDistinct` | `max` |
113
+ | `boolean` | groups true/false | `countTrue`, `countFalse`, `count` | `countTrue` |
114
+ | `string` / `enum` | groups raw values | `count`, `countDistinct`, `groupConcat` (distinct values in one cell), `min`, `max` | `count` |
115
+
116
+ An aggregate that doesn't fit the column's type falls back to the type's
117
+ default (e.g. `sum` on a string becomes `count`). Value-returning aggregates
118
+ (`min`/`max`/`first`/`last`) keep the source column's type and format in the
119
+ result — a date's `max` renders as a date, an enum's `first` resolves its
120
+ enum label.
121
+
122
+ Drill-down filters are also type-aware: boolean cells compare as booleans and
123
+ date buckets become `fn: 'format_month'`-style filters, on both own and
124
+ relationship columns.
125
+
126
+ ## Relationships (Sequelize)
127
+
128
+ Any dotted key resolves through `model.associations`, joining as needed:
129
+
130
+ ```js
131
+ rows: [ { key: 'customer.city' } ] // GROUP BY `customer`.`city`
132
+ values: [ { key: 'customer.name', aggregate: 'countDistinct' } ]
133
+ filters:[ { key: 'customer.city', value: [ { operator: 'contains', value: 'Jak' } ] } ]
134
+ ```
135
+
136
+ Nested paths work too (`order.customer.company.name` →
137
+ `` `order->customer->company`.`name` ``). Joins are `LEFT OUTER` with
138
+ `attributes: []` — they exist only for grouping/aggregating.
139
+
140
+ Caveats:
141
+ - **hasMany joins multiply rows.** Aggregating a parent measure while joining
142
+ a hasMany relation inflates `sum`/`count` — prefer pivoting from the child
143
+ model, or use `countDistinct`.
144
+ - `first`/`last` are only supported on own columns (they resolve via the root
145
+ table's id); on dotted keys they fall back to `count`.
146
+
147
+ ## Virtual columns (formula bar)
148
+
149
+ The `fx` bar above the table adds columns **calculated client-side** per pivot
150
+ row — including the total row — like a spreadsheet formula:
151
+
152
+ ```
153
+ =([_amount-sum] - [_cost-sum]) / [_amount-sum] * 100
154
+ =IF([_id-count] > 100, "High", "Low")
155
+ =ROUND([_amount-sum] / [_qty-sum], 2)
156
+ ```
157
+
158
+ - Reference pivot result columns as `[key]` — focus the input and click a
159
+ chip to insert one. Aggregate aliases follow the `List` contract:
160
+ `_<key>-<aggregate>`, spreads `_<key>-<aggregate>-<group>`, plus
161
+ `_rowTotal`.
162
+ - Operators: `+ - * / % ^`, `&` (concat), comparisons `= <> > >= < <=`.
163
+ - Functions: `IF`, `AND`, `OR`, `NOT`, `ABS`, `ROUND`, `FLOOR`, `CEIL`,
164
+ `MIN`, `MAX`, `SUM`, `AVG`, `COUNT`, `CONCAT`, `LEN`, `UPPER`, `LOWER`.
165
+ - Blank cells count as `0`; per-cell failures show Excel-style codes
166
+ (`#DIV/0!`, `#VALUE!`, `#NAME?`) without breaking the column.
167
+ - Stored on the preset (`pivot.virtualColumns`), so they persist and share
168
+ with the preset; recomputed locally after every load — the server never
169
+ sees them.
170
+
171
+ ## Props
172
+
173
+ | Property | Type | Default | Description |
174
+ |----------|------|---------|-------------|
175
+ | config | Object | `undefined` | Preset config (columns/presets); omit to use `defaultConfig` + `presetKey` |
176
+ | defaultConfig | Function | `undefined` | Returns the default config when no saved preset exists |
177
+ | presetKey | String | `undefined` | Persistence key — enables preset load/save |
178
+ | controller | String | `undefined` | Socket controller prefix (`<controller>.pivot-load`, `<controller>.preset`) |
179
+ | src | String | `` `${controller}.pivot-load` `` | Override the load endpoint |
180
+ | subscribeKey | String | `undefined` | Realtime channel; signals trigger a debounced pivot reload |
181
+ | title | String | `'Untitled'` | Small title above the preset name |
182
+ | updateInterval | Number | `1200` | Debounce (ms) for realtime reloads |
183
+ | presetMode | String | `'sidebar'` | `'sidebar'`, `'popup'` or `'external'` (emits `open-preset`) |
184
+ | searchable | Boolean/String | `true` | Show the search box (columns with `search: true` are matched) |
185
+ | toolbar | Boolean/String | `true` | Show the right toolbar + formula bar |
186
+ | headerClass / containerClass / itemClass | String | — | Styling pass-throughs (same as `List`) |
187
+ | itemsFn | Function | `undefined` | Transform the loaded rows before render |
188
+
189
+ ## Events
190
+
191
+ | Event | Payload | Description |
192
+ |-------|---------|-------------|
193
+ | pivot-item-click | `{ item, column, filters }` | Aggregate cell clicked — `filters` reproduce the cell's row slice |
194
+ | item-click | `(item, column)` | Group-column or virtual-column cell clicked |
195
+ | after-load | — | Pivot data loaded |
196
+ | signal | `(event, items)` | Realtime signal received (reload already scheduled) |
197
+ | open-preset | — | `presetMode="external"` asked to open the preset editor |
198
+
199
+ ## Slots
200
+
201
+ `head`, `left`, `left-toolbar`, `toolbar`, `right-toolbar`, `mobile-toolbar`,
202
+ and per-column `col-<key>` header/content slots — same contract as `List`.
203
+
204
+ ## Keyboard
205
+
206
+ - `Alt+\`` — toggle the preset sidebar
207
+ - `Alt+1..9` — switch preset
@@ -6,7 +6,7 @@ A versatile text input component with support for various types, formatting, and
6
6
  |----------|------|---------|-------------|
7
7
  | modelValue | Any | `undefined` | The input value. Supports `v-model` |
8
8
  | value | Any | `undefined` | Alternative value prop |
9
- | type | String | `undefined` | Input type: `'text'`, `'tel'`, `'password'`, `'number'` |
9
+ | type | String | `undefined` | Input type: `'text'`, `'tel'`, `'password'`, `'number'`. `tel` accepts digits and one decimal separator — see below |
10
10
  | placeholder | String | `undefined` | Placeholder text |
11
11
  | readonly | Any | `undefined` | Makes the input read-only |
12
12
  | disabled | Any | `undefined` | Disables the input |
@@ -63,6 +63,25 @@ A versatile text input component with support for various types, formatting, and
63
63
  <Textbox v-model="phone" type="tel" placeholder="+62..." />
64
64
  ```
65
65
 
66
+ ### `type="tel"` and decimals
67
+
68
+ `tel` takes digits and **one** decimal separator. Either key produces it — a dot and a comma
69
+ mean the same thing here, because which one a keyboard offers depends on the layout, and
70
+ being told the key you have is the wrong one is not a useful thing for a number field to say.
71
+
72
+ What is shown follows the locale (`Intl.NumberFormat` — a dot in `en`, a comma in `id`, `de`,
73
+ `fr`), and **what is emitted is always written with a dot**, so `Number()` and `v-model.number`
74
+ read it the same wherever it was typed:
75
+
76
+ | typed | shown (en) | shown (id) | emitted |
77
+ |---|---|---|---|
78
+ | `0.7` | `0.7` | `0,7` | `0.7` |
79
+ | `0,7` | `0.7` | `0,7` | `0.7` |
80
+ | `1,2,3` | `1.23` | `1,23` | `1.23` |
81
+
82
+ Only the first separator is one; anything after it belongs to the fraction. `maxlength` counts
83
+ the separator, so a field expecting `0.75` needs at least 4.
84
+
66
85
  ### With clear button
67
86
  ```vue
68
87
  <Textbox v-model="search" clearable @clear="search = ''" />
@@ -0,0 +1,148 @@
1
+ # Tooltip
2
+ Floating label shown on hover, keyboard focus, or long-press. A single tooltip layer is mounted lazily on first use and reused for every tooltip in the app, so only one tooltip element ever exists in the DOM.
3
+
4
+ Three ways to use it:
5
+
6
+ - `v-tooltip` directive — the common case, works on any element.
7
+ - `<Tooltip>` component — wraps a trigger, useful when you want props instead of a binding object.
8
+ - `showTooltip()` / `hideTooltip()` — programmatic, for custom triggers.
9
+
10
+ ## Props
11
+ | Property | Type | Default | Description |
12
+ |----------|------|---------|-------------|
13
+ | text | String | `undefined` | Tooltip body. Supports `\n` for multiple lines |
14
+ | title | String | `undefined` | Optional bold first line, for a label + description tooltip |
15
+ | shortcut | String | `undefined` | Keyboard hint rendered as a chip, e.g. `⌘S` |
16
+ | placement | String | `'top'` | `'top'`, `'bottom'`, `'left'`, `'right'`. Flips automatically when it doesn't fit |
17
+ | variant | String | `'base'` | `'base'`, `'primary'`, `'secondary'`, `'accent'`, `'danger'`, `'success'` |
18
+ | delay | Number | `null` | Hover intent in ms before showing (default `400`). `0` shows immediately |
19
+ | offset | Number | `8` | Gap in px between the anchor and the tooltip |
20
+ | maxWidth | Number/String | `260` | Max width of the tooltip surface |
21
+ | arrow | Boolean | `true` | Show the arrow pointing at the anchor |
22
+ | disabled | Boolean | `false` | Never show this tooltip |
23
+ | open | Boolean | `null` | Controlled mode — when set, hover/focus is ignored |
24
+ | tag | String | `'span'` | Element used for the trigger wrapper |
25
+ | singleton | Boolean | `false` | Renders the shared layer instead of a trigger. Set by the controller — you never need this |
26
+
27
+ ## Events
28
+ | Event | Payload | Description |
29
+ |-------|---------|-------------|
30
+ | show | - | Emitted when the tooltip actually becomes visible for this trigger (after the hover-intent delay) |
31
+ | hide | - | Emitted when it stops being visible — including self-hides such as `Escape` or scrolling the anchor away |
32
+ | update:open | Boolean | Emitted alongside `show`/`hide`, enables `v-model:open` |
33
+
34
+ Events come from the wrapper component only; the `v-tooltip` directive doesn't emit.
35
+
36
+ ## Methods
37
+ | Method | Parameters | Description |
38
+ |--------|------------|-------------|
39
+ | show | - | Shows the tooltip |
40
+ | hide | `immediate` | Hides it, skipping the hide delay when `immediate` is true |
41
+
42
+ ## Slots
43
+ | Slot | Description |
44
+ |------|-------------|
45
+ | default | The trigger element the tooltip is anchored to |
46
+
47
+ ## Directive
48
+
49
+ ```vue
50
+ <button v-tooltip="'Save changes'">Save</button>
51
+ ```
52
+
53
+ Placement via argument or modifier, variant via modifier, `.now` to skip the delay:
54
+
55
+ ```vue
56
+ <button v-tooltip:bottom="'Filters'">…</button>
57
+ <button v-tooltip.right.primary="'Filters'">…</button>
58
+ <button v-tooltip.now="'Copied'">…</button>
59
+ ```
60
+
61
+ Object form, for anything richer:
62
+
63
+ ```vue
64
+ <button v-tooltip="{ title: 'Save', text: 'Writes to the server', shortcut: '⌘S', placement: 'bottom' }">
65
+ Save
66
+ </button>
67
+ ```
68
+
69
+ The binding is read at show time, so a value that starts empty and fills in later works — as does `v-tooltip` on a **disabled** button, which the directive hit-tests separately because disabled controls never fire mouse events.
70
+
71
+ ## Samples
72
+
73
+ ### Basic
74
+ ```vue
75
+ <Tooltip text="Delete this row">
76
+ <button>Delete</button>
77
+ </Tooltip>
78
+ ```
79
+
80
+ ### Title, description and shortcut
81
+ ```vue
82
+ <Tooltip title="Publish" text="Makes the page visible to everyone" shortcut="⌘↵" placement="bottom">
83
+ <Button text="Publish" />
84
+ </Tooltip>
85
+ ```
86
+
87
+ ### Variants
88
+ ```vue
89
+ <Tooltip text="Looks good" variant="success"><span>✓</span></Tooltip>
90
+ <Tooltip text="Quota exceeded" variant="danger"><span>!</span></Tooltip>
91
+ ```
92
+
93
+ ### Controlled
94
+ ```vue
95
+ <Tooltip text="Copied to clipboard" :open="justCopied" placement="right">
96
+ <button @click="copy">Copy</button>
97
+ </Tooltip>
98
+ ```
99
+
100
+ ### Reacting to show/hide
101
+ ```vue
102
+ <Tooltip text="Writes to the server" @show="track('tooltip-seen')" @hide="onHidden">
103
+ <button>Save</button>
104
+ </Tooltip>
105
+ ```
106
+
107
+ `v-model:open` keeps a controlled tooltip in sync when it closes itself (Escape, scrolling away):
108
+
109
+ ```vue
110
+ <Tooltip text="Copied to clipboard" v-model:open="justCopied">
111
+ <button @click="copy">Copy</button>
112
+ </Tooltip>
113
+ ```
114
+
115
+ ### Programmatic
116
+ ```js
117
+ import { showTooltip, hideTooltip } from '@mixd-id/web-scaffold/tooltip'
118
+
119
+ showTooltip(el, { text: 'Drop files here', placement: 'bottom', delay: 0 })
120
+ hideTooltip(el)
121
+ ```
122
+
123
+ ## Behaviour
124
+ - Shows after `400ms` of hover intent; while a tooltip is already open (or one closed within `320ms`) the next one appears instantly, so scanning a toolbar doesn't stutter.
125
+ - Hides on mouse leave, click, blur, `Escape`, and when the anchor scrolls out of view. It repositions itself while the page scrolls or resizes.
126
+ - Keyboard focus shows it only for `:focus-visible`, so clicking a button doesn't leave a tooltip behind.
127
+ - Touch devices: long-press (`450ms`) shows it, and it auto-hides after `2.5s`.
128
+ - Text is rendered as text, never HTML.
129
+
130
+ ## Styling
131
+ The layer is global CSS (class `mx-tooltip`) and paints itself entirely from the app's theme tokens — no colours of its own — so it follows whatever theme is active, light or dark:
132
+
133
+ | Part | Token |
134
+ |------|-------|
135
+ | surface, arrow | `bg-base-500`, `border-border-100` |
136
+ | text | `text-text` |
137
+ | description under a title | `text-text-400` |
138
+ | shortcut chip | `bg-base-300`, `text-text-400` |
139
+ | `primary` / `secondary` / `accent` | that tone's `-500` surface, `-400` border, `-50` / `-100` text |
140
+
141
+ Only layout knobs are exposed as custom properties:
142
+
143
+ ```css
144
+ .mx-tooltip{
145
+ --mx-tooltip-max-width: 320px;
146
+ --mx-tooltip-z: 12000;
147
+ }
148
+ ```