@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.
- package/README.md +25 -1
- package/docs/components/AIDashboard.md +117 -32
- package/docs/components/Alert.md +3 -1
- package/docs/components/Card.md +5 -0
- package/docs/components/PivotList.md +207 -0
- package/docs/components/Textbox.md +20 -1
- package/docs/components/Tooltip.md +148 -0
- package/package.json +17 -61
- package/src/components/AIChat.vue +14 -5
- package/src/components/AIChatItem.vue +34 -3
- package/src/components/AIDashboard.vue +144 -98
- package/src/components/Alert.vue +58 -35
- package/src/components/Card.vue +55 -6
- package/src/components/Carousel.vue +1 -1
- package/src/{widgets → components}/CartSetting.vue +1 -0
- package/src/components/Chart.vue +2 -2
- package/src/components/Checkbox.vue +22 -24
- package/src/components/CodeEditor.vue +2 -0
- package/src/{widgets → components}/CollapsiblePanelSetting.vue +1 -0
- package/src/components/ColorPicker.vue +2 -2
- package/src/{widgets → components}/ComponentSetting2.vue +1 -1
- package/src/components/Confirm.vue +2 -0
- package/src/{widgets → components}/ContactForm.vue +1 -1
- package/src/components/CopyToClipboard.vue +0 -1
- package/src/components/Datepicker.vue +2 -0
- package/src/components/Datetimepicker.vue +1 -1
- package/src/components/Dropdown.vue +1 -1
- package/src/{widgets → components}/FeatureList.vue +1 -1
- package/src/components/Gmaps.vue +1 -1
- package/src/{widgets → components}/IconList.vue +1 -1
- package/src/components/Image.vue +1 -1
- package/src/components/ImagePreview.vue +32 -51
- package/src/components/List.vue +45 -8
- package/src/components/MarkdownEdit.vue +1 -1
- package/src/components/MarkdownPreview.vue +4 -1
- package/src/{widgets → components}/MenuItem1Setting.vue +1 -0
- package/src/components/Metric.vue +30 -7
- package/src/components/MultiDropdown.vue +3 -1
- package/src/{widgets → components}/MultiValueSetting.vue +2 -2
- package/src/{widgets → components}/MultiValueSetting2.vue +2 -2
- package/src/components/OTPField.vue +3 -1
- package/src/components/PivotList.vue +1239 -0
- package/src/{widgets → components}/PresetBar.vue +1 -1
- package/src/components/PresetSelectorFilterItem.vue +1 -1
- package/src/components/Radio.vue +1 -1
- package/src/{widgets → components}/Share.vue +1 -1
- package/src/components/SvgEditor.vue +1 -1
- package/src/components/Switch.vue +1 -1
- package/src/components/TextWithTag.vue +2 -2
- package/src/components/Textbox.vue +63 -5
- package/src/components/Toast.vue +2 -1
- package/src/components/Tooltip.vue +626 -0
- package/src/components/VirtualGrid.vue +2 -2
- package/src/components/VirtualTable.vue +4 -3
- package/src/{widgets → components}/WebDatasourceSelector.vue +2 -0
- package/src/{widgets → components}/WebLayoutSelector.vue +1 -1
- package/src/directives/tooltip.js +131 -0
- package/src/index.js +11 -235
- package/src/themes/default/index.js +16 -0
- package/src/utils/ai-dashboard.js +163 -38
- package/src/utils/helpers.mjs +52 -5
- package/src/utils/list.mjs +4 -1
- package/src/utils/pivot-formula.mjs +358 -0
- package/src/utils/pivot-list.mjs +557 -0
- package/src/utils/tooltip.mjs +243 -0
- package/src/utils/wss.js +26 -6
- package/src/utils/wss.mjs +29 -2
- /package/src/{widgets → components}/AhrefSetting.vue +0 -0
- /package/src/{widgets → components}/ArticleSetting.vue +0 -0
- /package/src/{widgets → components}/BackgroundColorSetting.vue +0 -0
- /package/src/{widgets → components}/BlockSetting.vue +0 -0
- /package/src/{widgets → components}/BorderColorSetting.vue +0 -0
- /package/src/{widgets → components}/ButtonSetting.vue +0 -0
- /package/src/{widgets → components}/CarouselSetting.vue +0 -0
- /package/src/{widgets → components}/ColumnSelector.vue +0 -0
- /package/src/{widgets → components}/ComponentSetting.vue +0 -0
- /package/src/{widgets → components}/ContactFormSetting.vue +0 -0
- /package/src/{widgets → components}/CountdownSetting.vue +0 -0
- /package/src/{widgets → components}/DataListSetting.vue +0 -0
- /package/src/{widgets → components}/EmbeddedVideo.vue +0 -0
- /package/src/{widgets → components}/EmbeddedVideoSetting.vue +0 -0
- /package/src/{widgets → components}/FAQ.vue +0 -0
- /package/src/{widgets → components}/FAQSetting.vue +0 -0
- /package/src/{widgets → components}/FeatureListSetting.vue +0 -0
- /package/src/{widgets → components}/FlexSetting.vue +0 -0
- /package/src/{widgets → components}/GridSetting.vue +0 -0
- /package/src/{widgets → components}/Header2.vue +0 -0
- /package/src/{widgets → components}/Header2Setting.vue +0 -0
- /package/src/{widgets → components}/IconListSetting.vue +0 -0
- /package/src/{widgets → components}/ImageSetting.vue +0 -0
- /package/src/{widgets → components}/LLMChat.vue +0 -0
- /package/src/{widgets → components}/LinkSetting.vue +0 -0
- /package/src/{widgets → components}/LinkSettingModal.vue +0 -0
- /package/src/{widgets → components}/ModalSetting.vue +0 -0
- /package/src/{widgets → components}/OGSettingModal.vue +0 -0
- /package/src/{widgets → components}/ParagraphSetting.vue +0 -0
- /package/src/{widgets → components}/PositionSetting.vue +0 -0
- /package/src/{widgets → components}/PresetBarPivot.vue +0 -0
- /package/src/{widgets → components}/PresetBarPivotColumnEdit.vue +0 -0
- /package/src/{widgets → components}/PresetBarPivotValueEdit.vue +0 -0
- /package/src/{widgets → components}/PresetSelector.vue +0 -0
- /package/src/{widgets → components}/Review.vue +0 -0
- /package/src/{widgets → components}/ReviewSetting.vue +0 -0
- /package/src/{widgets → components}/SearchModalSetting.vue +0 -0
- /package/src/{widgets → components}/ShareSetting.vue +0 -0
- /package/src/{widgets → components}/SvgSetting.vue +0 -0
- /package/src/{widgets → components}/TableSetting.vue +0 -0
- /package/src/{widgets → components}/TestimonialSetting.vue +0 -0
- /package/src/{widgets → components}/TextBlockSetting.vue +0 -0
- /package/src/{widgets → components}/ThumbnailSetting.vue +0 -0
- /package/src/{widgets → components}/WebComponentSelector.vue +0 -0
- /package/src/{widgets → components}/WebPageBuilder.vue +0 -0
- /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
|
-
|
|
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,
|
|
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
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
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
|
-
|
|
311
|
-
|
|
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
|
|
363
|
-
|
|
364
|
-
No-op unless
|
|
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 } }
|
|
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
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
- **`get
|
|
411
|
-
`
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
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)
|
|
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()` |
|
|
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.
|
|
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
|
-
| `
|
|
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`). |
|
package/docs/components/Alert.md
CHANGED
|
@@ -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
|
|------|-------------|
|
package/docs/components/Card.md
CHANGED
|
@@ -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
|
+
```
|