@omnitend/dashboard-for-laravel 0.34.0 → 0.36.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 (34) hide show
  1. package/dist/charts.js +137 -97
  2. package/dist/charts.js.map +1 -1
  3. package/dist/charts.umd.cjs +1 -1
  4. package/dist/charts.umd.cjs.map +1 -1
  5. package/dist/components/charts/chartTheme.d.ts +29 -3
  6. package/dist/components/charts/useThemedChart.d.ts +7 -2
  7. package/dist/components/extended/DXForm.vue.d.ts +28 -6
  8. package/dist/components/extended/DXTable.vue.d.ts +38 -1
  9. package/dist/components/extended/DXTableEditorModal.vue.d.ts +7 -2
  10. package/dist/components/extended/DXTablePagination.vue.d.ts +22 -1
  11. package/dist/composables/useContainerWidth.d.ts +96 -0
  12. package/dist/dashboard-for-laravel.js +8288 -8105
  13. package/dist/dashboard-for-laravel.js.map +1 -1
  14. package/dist/dashboard-for-laravel.umd.cjs +7 -7
  15. package/dist/dashboard-for-laravel.umd.cjs.map +1 -1
  16. package/dist/index.d.ts +2 -0
  17. package/dist/style.css +1 -1
  18. package/docs/public/api-reference.json +48 -8
  19. package/docs/public/docs-map.md +1 -1
  20. package/package.json +1 -1
  21. package/resources/css/theme.scss +96 -4
  22. package/resources/js/components/charts/DXBarChart.vue +7 -2
  23. package/resources/js/components/charts/DXDoughnutChart.vue +33 -10
  24. package/resources/js/components/charts/DXLineChart.vue +7 -2
  25. package/resources/js/components/charts/chartTheme.ts +106 -11
  26. package/resources/js/components/charts/useThemedChart.ts +39 -21
  27. package/resources/js/components/extended/DXDashboardSidebar.vue +1 -1
  28. package/resources/js/components/extended/DXForm.vue +83 -9
  29. package/resources/js/components/extended/DXTable.vue +216 -12
  30. package/resources/js/components/extended/DXTableEditorModal.vue +7 -2
  31. package/resources/js/components/extended/DXTablePagination.vue +165 -8
  32. package/resources/js/components/extended/DXTableShell.vue +105 -1
  33. package/resources/js/composables/useContainerWidth.ts +303 -0
  34. package/resources/js/index.ts +7 -0
@@ -1,8 +1,8 @@
1
1
  {
2
- "generated": "2026-07-21T07:46:13.035Z",
2
+ "generated": "2026-07-21T12:50:16.789Z",
3
3
  "package": {
4
4
  "name": "@omnitend/dashboard-for-laravel",
5
- "version": "0.34.0"
5
+ "version": "0.36.0"
6
6
  },
7
7
  "components": {
8
8
  "base": [
@@ -2427,7 +2427,14 @@
2427
2427
  "type": "union",
2428
2428
  "required": false,
2429
2429
  "default": "\"vertical\"",
2430
- "description": "Form-wide field layout: \"vertical\" (default, label above input) or\n\"horizontal\" (label left, input right). Overridable per-field via\n`field.layout`. A field with `span: true` always renders full-width,\nregardless of layout."
2430
+ "description": "Form-wide field layout:\n\n- `\"vertical\"` (default) — label above input, always.\n- `\"horizontal\"` — label left, input right, always.\n- `\"auto\"` — horizontal when the form's **own container** is at least\n `layoutThreshold` px wide, vertical below that. Container-driven, not\n viewport-driven: a page narrowed by the dashboard sidebar, or a form\n inside a modal, stacks even though the window is wide (which no\n Bootstrap media query can see).\n\nOverridable per-field via `field.layout`. A field with `span: true`\nalways renders full-width, regardless of layout."
2431
+ },
2432
+ {
2433
+ "name": "layoutThreshold",
2434
+ "type": "number",
2435
+ "required": false,
2436
+ "default": "640",
2437
+ "description": "Container width (px) at or above which `layout: \"auto\"` goes horizontal.\nIgnored for the explicit `\"vertical\"`/`\"horizontal\"` layouts.\n\nDefault 640, measured rather than guessed: with the default 3-column\nlabel the label's text area is `containerWidth / 4 - 18` px, so 640 gives\nit 142px — enough for a ~20-character label (\"Unit price (ex VAT)\"\nmeasures 128px at the theme's label font) to stay on one line, with the\ncontrol column still 474px. Below ~584px that label starts wrapping,\nwhich is the cramped-label symptom this exists to avoid. Raise it if your\nlabels run longer, or lower `labelCols` instead."
2431
2438
  },
2432
2439
  {
2433
2440
  "name": "labelCols",
@@ -3314,7 +3321,7 @@
3314
3321
  "type": "boolean",
3315
3322
  "required": false,
3316
3323
  "default": "true",
3317
- "description": "Wrap the table in a `DCard`. Defaults to `true`. Set `false` for a plain,\nborderless variant — the same header, filter row, table and pagination\nrendered directly on the page background (no card border/shadow), for\ndata-heavy admin index pages that read as busy inside a card."
3324
+ "description": "Wrap the table in a `DCard`. Defaults to `true`. Set `false` for a plain,\nborderless variant — the same header, filter row, table and pagination\nrendered directly on the page background (no card border/shadow), for\ndata-heavy admin index pages that read as busy inside a card.\n\nThe table renders FLUSH to the card border (rows and stripes reach the\nedge) while the header and pagination stay padded. Only the table region\nis rounded to follow the card's corners — the card itself does not clip\n(#166), so a `position: absolute` popover you render into `#header` or\nany other slot is free to overhang the card.\n\nThe one place content is still clipped is INSIDE the table: Bootstrap's\n`.table-responsive` is a horizontal scroll container, so an overlay in a\n`cell(<key>)` slot is bounded by it. Teleport it (bvn's own menus already\ndo — `DDropdown` teleports to `<body>`, the column-filter menus are\n`fixed`), or pass `:responsive=\"false\"`."
3318
3325
  },
3319
3326
  {
3320
3327
  "name": "editFields",
@@ -3346,7 +3353,7 @@
3346
3353
  "type": "union",
3347
3354
  "required": false,
3348
3355
  "default": "\"horizontal\"",
3349
- "description": "Field layout for the edit/create modal's form, forwarded to its `DXForm`\n(\"vertical\" = label above input, \"horizontal\" = label left). Defaults to\n**\"horizontal\"** to match the Omni Tend form convention (a raw modal form\npreviously fell back to DXForm's vertical default, which looked out of\nplace next to horizontal page forms). Pass `\"vertical\"` to opt out."
3356
+ "description": "Field layout for the edit/create modal's form, forwarded to its `DXForm`\n(\"vertical\" = label above input, \"horizontal\" = label left). Defaults to\n**\"horizontal\"** to match the Omni Tend form convention (a raw modal form\npreviously fell back to DXForm's vertical default, which looked out of\nplace next to horizontal page forms). Pass `\"vertical\"` to opt out.\n\n`\"auto\"` hands the decision to `DXForm`'s container-width observer, which\nmeasures the FORM rather than the window — so a modal narrowed by its own\nbreakpoint stacks to vertical even on a wide viewport, where a media query\nwould still report \"plenty of room\". See `useContainerWidth`."
3350
3357
  },
3351
3358
  {
3352
3359
  "name": "editLabelCols",
@@ -3578,6 +3585,32 @@
3578
3585
  }
3579
3586
  ]
3580
3587
  },
3588
+ {
3589
+ "name": "`head-end(${field.key})`",
3590
+ "description": "head-end(<fieldKey>) Additive content at the end of a column's own header — a period total above a numeric column, a small badge — keeping DXTable's sort indicator and field hint. Not a `head()` override.",
3591
+ "bindings": [
3592
+ {
3593
+ "name": "name",
3594
+ "title": "binding"
3595
+ },
3596
+ {
3597
+ "title": "binding",
3598
+ "type": {
3599
+ "name": "object"
3600
+ },
3601
+ "name": "field",
3602
+ "description": "The column's field definition."
3603
+ },
3604
+ {
3605
+ "title": "binding",
3606
+ "type": {
3607
+ "name": "string"
3608
+ },
3609
+ "name": "label",
3610
+ "description": "The column's resolved header label."
3611
+ }
3612
+ ]
3613
+ },
3581
3614
  {
3582
3615
  "name": "name",
3583
3616
  "description": "Any slot the underlying table supports, forwarded with its scope: `cell(<fieldKey>)` for a cell, `foot(<fieldKey>)` for a footer cell (needs `footClone`), `empty` for the no-rows message, `row-expansion` for expandable row detail, plus `custom-foot`, `top-row`, `bottom-row`, `thead-sub` and the `table-*` slots.",
@@ -3679,7 +3712,7 @@
3679
3712
  "name": "layout",
3680
3713
  "type": "union",
3681
3714
  "required": false,
3682
- "description": "Field layout forwarded to the modal's DXForm (defaults handled upstream)."
3715
+ "description": "Field layout forwarded to the modal's DXForm (defaults handled upstream).\nIncludes `\"auto\"`, which lets the form stack to vertical when the MODAL is\nnarrow even though the viewport is wide — the container-query case a\nviewport media query cannot see."
3683
3716
  },
3684
3717
  {
3685
3718
  "name": "labelCols",
@@ -3915,7 +3948,7 @@
3915
3948
  "name": "DXTablePagination",
3916
3949
  "category": "extended",
3917
3950
  "filePath": "resources/js/components/extended/DXTablePagination.vue",
3918
- "description": "Internal pagination + per-page footer for `DXTable`. Renders the pager, the per-page selector, and the \"X to Y out of Z items\" info line (with the \"Filtered from N\" note when a column filter is active). Extracted because `DXTable` used to carry three byte-identical copies of this footer — one per data mode (client-side / inertia / provider) — that differed only in which `PaginationData` object and page-change handler they used, so every footer fix had to be written three times (#123). `DXTable` now derives the active pagination + handler and renders this once. It owns its own `:deep(.pagination …)` styling (rather than leaving it in `DXTable`), because a scoped `:deep()` in `DXTable` would not reach the pager once it lives in a child component — the scope-id doesn't cross the boundary. Anchoring the rules on this component's own `.dx-table-pagination` root (a plain element it owns) gives the scope-id a deterministic host.",
3951
+ "description": "Internal pagination + per-page footer for `DXTable`. Renders the pager, the per-page selector, and the \"X to Y out of Z items\" info line (with the \"Filtered from N\" note when a column filter is active). Extracted because `DXTable` used to carry three byte-identical copies of this footer — one per data mode (client-side / inertia / provider) — that differed only in which `PaginationData` object and page-change handler they used, so every footer fix had to be written three times (#123). `DXTable` now derives the active pagination + handler and renders this once. It owns its own `:deep(.pagination …)` styling (rather than leaving it in `DXTable`), because a scoped `:deep()` in `DXTable` would not reach the pager once it lives in a child component — the scope-id doesn't cross the boundary. Anchoring the rules on this component's own `.dx-table-pagination` root (a plain element it owns) gives the scope-id a deterministic host. In a row too narrow for the full windowed pager, the number strip collapses to a \"current / last\" status with Previous/Next either side (#162). The decision is CONTAINER-driven, not viewport-driven — see `compactThreshold`.",
3919
3952
  "props": [
3920
3953
  {
3921
3954
  "name": "pagination",
@@ -3970,6 +4003,13 @@
3970
4003
  "type": "boolean",
3971
4004
  "required": true,
3972
4005
  "description": "Whether a column filter is active — gates the \"Filtered from N\" note."
4006
+ },
4007
+ {
4008
+ "name": "compactThreshold",
4009
+ "type": "number",
4010
+ "required": false,
4011
+ "default": "576",
4012
+ "description": "Upper bound (px) on the pager ROW width at which the number window may\ncollapse to the compact \"current / last\" status (#162). At or above it\nthe full windowed pager always renders, whatever the page count.\n\nDefault **576** — Bootstrap's `sm` breakpoint, i.e. the width the rest of\nthe design system already treats as phone-sized. Its job is to GUARANTEE\nthat a desktop-width table is never quietly downgraded: a 45-page window\nthat doesn't quite fit at 800px should wrap (today's behaviour), not lose\nits numbers.\n\nBelow it, compact engages only when the full pager genuinely would not\nfit on one line (see `fullPagerWidth`) — so a three-page pager still\nshows `1 2 3` on a phone, where \"2 / 3\" would be strictly worse.\n\nMeasured on the ROW, not the viewport, so a table squeezed by a sidebar\nor inside a modal collapses even though the window is wide."
3973
4013
  }
3974
4014
  ],
3975
4015
  "events": [
@@ -3991,7 +4031,7 @@
3991
4031
  "name": "DXTableShell",
3992
4032
  "category": "extended",
3993
4033
  "filePath": "resources/js/components/extended/DXTableShell.vue",
3994
- "description": "Internal chrome wrapper for `DXTable`. Renders its content either inside a `DCard` (the default) or as a plain, borderless block on the page background (`:card=\"false\"`). Extracted so `DXTable`'s ~460-line body and all its slots aren't duplicated across a card/plain `v-if`. Slots: - `#header` — the title / action row. In card mode it fills `DCard`'s header; in plain mode it renders above the table with a subtle bottom rule. - default — the table body (spinner/error/table/pagination).",
4034
+ "description": "Internal chrome wrapper for `DXTable`. Renders its content either inside a `DCard` (the default) or as a plain, borderless block on the page background (`:card=\"false\"`). Extracted so `DXTable`'s ~460-line body and all its slots aren't duplicated across a card/plain `v-if`. Slots: - `#header` — the title / action row. In card mode it fills `DCard`'s header; in plain mode it renders above the table with a subtle bottom rule. - default — the table body (spinner/error/table/pagination). Card mode renders `DCard` in **`no-body`** mode so the table sits FLUSH against the card border. With a `.card-body` in between, a striped table's rows were inset by `--bs-card-spacer-x` (24px) and left a white gutter between the stripes and the border. Without it, DXTable's children — error alert, loading block, table, pagination — become direct children of `.card`, which is already a `flex-direction: column` container, so they still stack in order: .card ├─ .card-header (padded by Bootstrap's card-cap padding) ├─ .table-responsive ← FLUSH, rounded when it is at a card corner │ └─ table └─ .dx-table-pagination (pads itself — see DXTablePagination.vue) Bootstrap 5 has no `.card > .table` rounding rules (that was BS4), so the flush table's square corners have to be rounded here. The rounding is applied to the TABLE REGION, never to the card: clipping the whole card would cut off any non-teleported positioned content a consumer puts in a slot (#166).",
3995
4035
  "props": [
3996
4036
  {
3997
4037
  "name": "card",
@@ -1,7 +1,7 @@
1
1
  # Documentation Map
2
2
 
3
3
  > Auto-generated hierarchical overview of all documentation
4
- > Last updated: 2026-07-21T07:46:13.078Z
4
+ > Last updated: 2026-07-21T12:50:16.844Z
5
5
 
6
6
  This file provides a complete map of all available documentation for AI agents and developers.
7
7
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@omnitend/dashboard-for-laravel",
3
- "version": "0.34.0",
3
+ "version": "0.36.0",
4
4
  "description": "Vue 3 dashboard components for Laravel with Bootstrap Vue Next",
5
5
  "type": "module",
6
6
  "main": "./dist/dashboard-for-laravel.umd.cjs",
@@ -313,6 +313,32 @@ $bootstrap-icons-font-src: url("bootstrap-icons/font/fonts/bootstrap-icons.woff2
313
313
  flex-wrap: nowrap;
314
314
  }
315
315
 
316
+ // ----------------------------------------------------------------------------
317
+ // Nav tabs
318
+ // ----------------------------------------------------------------------------
319
+ // Chrome recedes, content leads — the same ethos as the muted table headers
320
+ // (#157). Bootstrap gives an INACTIVE tab `--bs-nav-link-color:
321
+ // var(--bs-link-color)`, i.e. the saturated link-blue, so a row of tabs above a
322
+ // form reads as a row of links competing with the fields below. Mute the
323
+ // inactive tabs and let the ACTIVE one (Bootstrap's
324
+ // `--bs-nav-tabs-link-active-color`, the near-black emphasis colour) carry the
325
+ // selection on its own.
326
+ //
327
+ // Driven through Bootstrap's own `--bs-nav-*` variables rather than a raw
328
+ // `color:` — so a consumer can re-louden one tab set by resetting the same
329
+ // variable, `[data-bs-theme="dark"]` remaps the tokens for free, and Bootstrap
330
+ // keeps ownership of the `.active`/`.disabled` resolution order.
331
+ //
332
+ // `--bs-secondary-color` is `rgba(var(--bs-body-color-rgb), .75)` → ~#585b5e on
333
+ // white: 6.83:1, comfortably AA (AAA for body text); 6.53:1 on the theme's
334
+ // `$light` #f8fafc panel. Hover goes to the full body colour (#212529, 16.1:1)
335
+ // so hovering reads as "heading toward selected" rather than "turning into a
336
+ // link".
337
+ .nav-tabs {
338
+ --bs-nav-link-color: var(--bs-secondary-color);
339
+ --bs-nav-link-hover-color: var(--bs-body-color);
340
+ }
341
+
316
342
  // ----------------------------------------------------------------------------
317
343
  // Dashboard Custom Styles
318
344
  // ----------------------------------------------------------------------------
@@ -433,6 +459,40 @@ $bootstrap-icons-font-src: url("bootstrap-icons/font/fonts/bootstrap-icons.woff2
433
459
  color: $secondary; // muted message text
434
460
  }
435
461
 
462
+ // HEADER-LESS toast (`body` but no `title` — the shape every toast created
463
+ // through `useToast().create({ body })` takes). BVN renders it as
464
+ // .toast > .d-flex > (.toast-body + .btn-close)
465
+ // and BVN's own `.toast .btn-close-custom { margin: <pad> <pad> auto }` pins
466
+ // the close X to the TOP of that row, while the `.toast-body` above still
467
+ // carries the reduced `padding-top` that only makes sense when it sits under
468
+ // a `.toast-header`. Result: the message rides high, the X sits lower, and a
469
+ // one-line toast reads as misaligned.
470
+ //
471
+ // With no header there is nothing above the body, so restore symmetric
472
+ // padding (Bootstrap's `.toast-body` is `padding: var(--bs-toast-padding-x)`
473
+ // all round) and centre the row. The `margin-bottom: auto` has to go too —
474
+ // an auto margin outranks `align-items` in flex alignment, so centring alone
475
+ // would leave the X pinned; `margin-block: 0` hands alignment back to the
476
+ // container while keeping BVN's horizontal margin.
477
+ //
478
+ // `:has()` is the structural test — it is the only way to key off "this toast
479
+ // has no header" from CSS, and it is supported in every evergreen browser
480
+ // (Chrome/Edge 105+, Safari 15.4+, Firefox 121+). A browser without it simply
481
+ // keeps today's slightly-off alignment; nothing breaks.
482
+ &:not(:has(.toast-header)) {
483
+ > .d-flex {
484
+ align-items: center;
485
+ }
486
+
487
+ .toast-body {
488
+ padding-top: var(--bs-toast-padding-x);
489
+ }
490
+
491
+ .btn-close-custom {
492
+ margin-block: 0;
493
+ }
494
+ }
495
+
436
496
  // Hide the auto-dismiss countdown bar — the ticking animation reads as
437
497
  // stressful/urgent, which is wrong for a passive notification. Auto-dismiss
438
498
  // still works; only its visual indicator is removed.
@@ -530,7 +590,18 @@ $bootstrap-icons-font-src: url("bootstrap-icons/font/fonts/bootstrap-icons.woff2
530
590
  // (which sets its colours `!important` by design). `.toast` is excluded: it
531
591
  // has its own soft handling (a fainter 50%-white mix via `--bs-toast-bg` for
532
592
  // its large surface) and themed toasts don't carry `.text-bg-*` anyway.
533
- .text-bg-#{$name}:not(.toast) {
593
+ //
594
+ // The exclusion is wrapped in `:where()` DELIBERATELY: `:where()` always
595
+ // contributes ZERO specificity, so this stays a single-class 0,1,0 selector
596
+ // while matching exactly what a bare `:not(.toast)` would. Written bare it is
597
+ // 0,2,0 — and because the declarations are `!important`, a consumer painting
598
+ // a fully custom badge (`.my-status-badge { background: …; }`) could not win
599
+ // with a single-class `!important` rule and had to escalate to a
600
+ // repeated-class hack. One class in, one class out: a consumer's own
601
+ // `!important` single-class rule now overrides us on source order, as it
602
+ // should. (Everything Bootstrap defines for `.text-bg-*` is also 0,1,0
603
+ // `!important` and is imported ABOVE this block, so we still win it.)
604
+ .text-bg-#{$name}:where(:not(.toast)) {
534
605
  background-color: #{$soft-bg} !important;
535
606
  color: #{$soft-text} !important;
536
607
  }
@@ -551,7 +622,14 @@ $bootstrap-icons-font-src: url("bootstrap-icons/font/fonts/bootstrap-icons.woff2
551
622
  // (danger/info's solid IS their emphasis). The variant-less default bar
552
623
  // stays `$progress-bar-bg` (brand navy). `!important` beats the `.bg-*`
553
624
  // utility, which sets its colour `!important` by design.
554
- .progress-bar.bg-#{$name} {
625
+ //
626
+ // `.progress-bar` is only here to SCOPE the rule (large fills, not every
627
+ // `.bg-*` element), so it goes in `:where()` — zero specificity, same
628
+ // matching, and the selector stays 0,1,0 like Bootstrap's own `.bg-*`. Same
629
+ // reasoning as the `.text-bg-*` rule above: at 0,2,0 `!important` a consumer
630
+ // recolouring one bar had to out-specify us instead of just writing
631
+ // `.my-bar { background-color: … !important }`.
632
+ :where(.progress-bar).bg-#{$name} {
555
633
  background-color: #{$solid-bg} !important;
556
634
  color: #{$solid-text};
557
635
  }
@@ -716,8 +794,10 @@ $dx-chart-palette: (#2563eb, #65a30d, #7c3aed, #0d9488, #ea580c, #0891b2, #d9770
716
794
  // same method as the light set (OKLab ΔE ×100, Viénot protan/deutan): min
717
795
  // adjacent CVD ΔE 15.1 (light set: 13.8), min normal ΔE 25.3, contrast
718
796
  // 5.67–10.23:1 on #212529. Swapping a step needs that validation re-run.
719
- // getPalette() reads the CSS variables live, so charts follow the theme with
720
- // no JS change; the TS fallbacks stay the light set (SSR/no-CSS default).
797
+ // getPalette() reads these CSS variables live — off the CHART'S OWN container,
798
+ // so nested colour-mode scopes resolve correctly, and re-read on every
799
+ // `data-bs-theme` change so mounted charts repaint (#161). The TS fallbacks
800
+ // stay the light set (SSR/no-CSS default).
721
801
  $dx-chart-palette-dark: (#60a5fa, #a3e635, #a78bfa, #2dd4bf, #fb923c, #22d3ee, #fbbf24, #f472b6);
722
802
 
723
803
  :root {
@@ -732,6 +812,18 @@ $dx-chart-palette-dark: (#60a5fa, #a3e635, #a78bfa, #2dd4bf, #fb923c, #22d3ee, #
732
812
  }
733
813
  }
734
814
 
815
+ // Bootstrap allows `data-bs-theme` on ANY container, so colour-mode scopes
816
+ // nest — and custom properties inherit. Without this re-declaration a LIGHT
817
+ // scope inside a dark root (a light card on a dark page) would inherit the dark
818
+ // palette, because only `:root` publishes the light values and the dark block
819
+ // above overrides it for the whole subtree (#161). Same list as `:root`, so the
820
+ // two can't drift.
821
+ [data-bs-theme="light"] {
822
+ @for $i from 1 through list.length($dx-chart-palette) {
823
+ --dx-chart-#{$i}: #{list.nth($dx-chart-palette, $i)};
824
+ }
825
+ }
826
+
735
827
  // The chart container used by DXBarChart/DXLineChart/DXDoughnutChart (shipped
736
828
  // from the `@omnitend/dashboard-for-laravel/charts` entry, #142). Global rather
737
829
  // than a component scoped style so the charts entry emits NO CSS — chart
@@ -8,12 +8,13 @@
8
8
  Requires the optional peer deps `chart.js` and `vue-chartjs`.
9
9
  -->
10
10
  <template>
11
- <div class="dx-chart">
11
+ <div ref="host" class="dx-chart">
12
12
  <Bar :data="chartData" :options="mergedOptions" />
13
13
  </div>
14
14
  </template>
15
15
 
16
16
  <script setup lang="ts">
17
+ import { ref } from "vue";
17
18
  import { Bar } from "vue-chartjs";
18
19
  import { registerCharts } from "./chartTheme";
19
20
  import { useThemedChart, type ThemedChartProps } from "./useThemedChart";
@@ -27,5 +28,9 @@ const props = withDefaults(defineProps<ThemedChartProps<"bar">>(), {
27
28
  showLegend: undefined,
28
29
  });
29
30
 
30
- const { chartData, mergedOptions } = useThemedChart("bar", props);
31
+ // The container is the `data-bs-theme` scope the palette resolves against, so a
32
+ // chart inside a dark card on a light page themes from the card (#161).
33
+ const host = ref<HTMLElement | null>(null);
34
+
35
+ const { chartData, mergedOptions } = useThemedChart("bar", props, host);
31
36
  </script>
@@ -8,19 +8,20 @@
8
8
  Requires the optional peer deps `chart.js` and `vue-chartjs`.
9
9
  -->
10
10
  <template>
11
- <div class="dx-chart">
11
+ <div ref="host" class="dx-chart">
12
12
  <Doughnut :data="chartData" :options="mergedOptions" />
13
13
  </div>
14
14
  </template>
15
15
 
16
16
  <script setup lang="ts">
17
- import { computed } from "vue";
17
+ import { computed, ref } from "vue";
18
18
  import { Doughnut } from "vue-chartjs";
19
19
  import {
20
20
  registerCharts,
21
21
  baseOptions,
22
22
  applyPalette,
23
23
  mergeOptions,
24
+ useColorModeVersion,
24
25
  type ChartValueFormat,
25
26
  } from "./chartTheme";
26
27
 
@@ -52,25 +53,47 @@ const props = withDefaults(defineProps<Props>(), {
52
53
  showLegend: undefined,
53
54
  });
54
55
 
56
+ // The container is the `data-bs-theme` scope the palette resolves against, so a
57
+ // chart inside a dark card on a light page themes from the card. Reading the ref
58
+ // inside the computeds also re-themes on mount, when the first (pre-ref) render
59
+ // had to fall back to the document root (#161). DXDoughnutChart doesn't share
60
+ // `useThemedChart` (its data shape differs), so the wiring is repeated here.
61
+ const host = ref<HTMLElement | null>(null);
62
+
63
+ // Bumped by a MutationObserver on every `data-bs-theme` change; read below so
64
+ // the CSS-variable palette — invisible to Vue's reactivity — is re-resolved
65
+ // when the colour mode flips under a mounted chart.
66
+ const colorModeVersion = useColorModeVersion();
67
+
55
68
  const resolvedDatasets = computed(() =>
56
69
  props.datasets ?? [{ data: props.data ?? [] }],
57
70
  );
58
71
 
59
- const chartData = computed(() => ({
60
- labels: props.labels,
61
- datasets: applyPalette(resolvedDatasets.value, "doughnut", props.labels.length),
62
- }));
72
+ const chartData = computed(() => {
73
+ void colorModeVersion.value;
74
+ return {
75
+ labels: props.labels,
76
+ datasets: applyPalette(
77
+ resolvedDatasets.value,
78
+ "doughnut",
79
+ props.labels.length,
80
+ host.value,
81
+ ),
82
+ };
83
+ });
63
84
 
64
- const mergedOptions = computed(() =>
65
- mergeOptions(
85
+ const mergedOptions = computed(() => {
86
+ void colorModeVersion.value;
87
+ return mergeOptions(
66
88
  baseOptions({
67
89
  valueFormat: props.valueFormat,
68
90
  currencySymbol: props.currencySymbol,
69
91
  locale: props.locale,
70
92
  showLegend: props.showLegend ?? true,
71
93
  hasValueAxis: false,
94
+ scope: host.value,
72
95
  }),
73
96
  props.options ?? {},
74
- ),
75
- );
97
+ );
98
+ });
76
99
  </script>
@@ -8,12 +8,13 @@
8
8
  Requires the optional peer deps `chart.js` and `vue-chartjs`.
9
9
  -->
10
10
  <template>
11
- <div class="dx-chart">
11
+ <div ref="host" class="dx-chart">
12
12
  <Line :data="chartData" :options="mergedOptions" />
13
13
  </div>
14
14
  </template>
15
15
 
16
16
  <script setup lang="ts">
17
+ import { ref } from "vue";
17
18
  import { Line } from "vue-chartjs";
18
19
  import { registerCharts } from "./chartTheme";
19
20
  import { useThemedChart, type ThemedChartProps } from "./useThemedChart";
@@ -27,5 +28,9 @@ const props = withDefaults(defineProps<ThemedChartProps<"line">>(), {
27
28
  showLegend: undefined,
28
29
  });
29
30
 
30
- const { chartData, mergedOptions } = useThemedChart("line", props);
31
+ // The container is the `data-bs-theme` scope the palette resolves against, so a
32
+ // chart inside a dark card on a light page themes from the card (#161).
33
+ const host = ref<HTMLElement | null>(null);
34
+
35
+ const { chartData, mergedOptions } = useThemedChart("line", props, host);
31
36
  </script>
@@ -5,6 +5,7 @@
5
5
  * registration. chart.js / vue-chartjs are OPTIONAL peer deps.
6
6
  */
7
7
  import { Chart, registerables } from "chart.js";
8
+ import { getCurrentScope, onScopeDispose, ref, type Ref } from "vue";
8
9
 
9
10
  export type ChartValueFormat = "number" | "currency" | "percent";
10
11
 
@@ -59,12 +60,37 @@ export const PALETTE_VARS: Array<[string, string]> = [
59
60
  ["--dx-chart-8", "#db2777"], // pink
60
61
  ];
61
62
 
62
- /** Resolve the themed categorical palette from the document root. */
63
- export function getPalette(): string[] {
63
+ /**
64
+ * The element whose computed style the theme variables are read from.
65
+ *
66
+ * Bootstrap 5.3 allows `data-bs-theme` on ANY container, not just `<html>` — a
67
+ * dark card on a light page is a supported pattern. Reading the variables off
68
+ * `document.documentElement` therefore gives a *nested* scope the wrong palette
69
+ * (#161), so every resolver takes the chart's own container and only falls back
70
+ * to the root when it has none (SSR, a caller outside a component).
71
+ */
72
+ function themeScope(scope?: Element | null): Element | null {
64
73
  if (typeof document === "undefined" || typeof getComputedStyle === "undefined") {
74
+ return null;
75
+ }
76
+ if (scope !== undefined && scope !== null) return scope;
77
+ return document.documentElement;
78
+ }
79
+
80
+ /**
81
+ * Resolve the themed categorical palette.
82
+ *
83
+ * @param scope The chart's container element; its computed style decides which
84
+ * `data-bs-theme` scope applies. Omit (or pass null) to read the document
85
+ * root — the pre-#161 behaviour, kept so the exported signature stays
86
+ * backward compatible.
87
+ */
88
+ export function getPalette(scope?: Element | null): string[] {
89
+ const host = themeScope(scope);
90
+ if (host === null) {
65
91
  return PALETTE_VARS.map(([, fallback]) => fallback);
66
92
  }
67
- const styles = getComputedStyle(document.documentElement);
93
+ const styles = getComputedStyle(host);
68
94
  return PALETTE_VARS.map(([name, fallback]) => {
69
95
  const value = styles.getPropertyValue(name).trim();
70
96
  return value || fallback;
@@ -72,16 +98,73 @@ export function getPalette(): string[] {
72
98
  }
73
99
 
74
100
  /** Read a single theme colour (e.g. the body text colour) with a fallback. */
75
- function themeColor(name: string, fallback: string): string {
76
- if (typeof document === "undefined" || typeof getComputedStyle === "undefined") {
101
+ function themeColor(name: string, fallback: string, scope?: Element | null): string {
102
+ const host = themeScope(scope);
103
+ if (host === null) {
77
104
  return fallback;
78
105
  }
79
- const value = getComputedStyle(document.documentElement)
80
- .getPropertyValue(name)
81
- .trim();
106
+ const value = getComputedStyle(host).getPropertyValue(name).trim();
82
107
  return value || fallback;
83
108
  }
84
109
 
110
+ // ---------------------------------------------------------------------------
111
+ // Colour-mode reactivity (#161)
112
+ //
113
+ // The palette lives in CSS variables, which Vue cannot track: a computed that
114
+ // calls getPalette() depends only on the props it also reads, so flipping
115
+ // `data-bs-theme` repaints the page but leaves every MOUNTED chart on the old
116
+ // palette until a prop changes or it remounts. One shared MutationObserver
117
+ // bumps a version ref that the chart computeds read, which re-resolves the
118
+ // variables. `subtree: true` because the attribute can sit on any container,
119
+ // and `attributeFilter` keeps the observer from firing on unrelated mutations.
120
+ // ---------------------------------------------------------------------------
121
+
122
+ const colorModeVersion = ref(0);
123
+ let colorModeObserver: MutationObserver | null = null;
124
+ let colorModeWatchers = 0;
125
+
126
+ /**
127
+ * A version ref that increments whenever a `data-bs-theme` attribute changes
128
+ * anywhere in the document. Read it inside a computed that resolves theme
129
+ * variables to make that computed re-run on a colour-mode switch.
130
+ *
131
+ * The observer is started on first use and disconnected when the last consumer's
132
+ * effect scope is disposed, so nothing leaks after the charts unmount. Called
133
+ * outside an effect scope (or without a DOM) it is a no-op returning a static
134
+ * ref — there would be no teardown hook to disconnect the observer.
135
+ */
136
+ export function useColorModeVersion(): Ref<number> {
137
+ if (
138
+ typeof document === "undefined" ||
139
+ typeof MutationObserver === "undefined" ||
140
+ getCurrentScope() === undefined
141
+ ) {
142
+ return colorModeVersion;
143
+ }
144
+
145
+ colorModeWatchers += 1;
146
+ if (colorModeObserver === null) {
147
+ colorModeObserver = new MutationObserver(() => {
148
+ colorModeVersion.value += 1;
149
+ });
150
+ colorModeObserver.observe(document.documentElement, {
151
+ attributes: true,
152
+ attributeFilter: ["data-bs-theme"],
153
+ subtree: true,
154
+ });
155
+ }
156
+
157
+ onScopeDispose(() => {
158
+ colorModeWatchers -= 1;
159
+ if (colorModeWatchers === 0 && colorModeObserver !== null) {
160
+ colorModeObserver.disconnect();
161
+ colorModeObserver = null;
162
+ }
163
+ });
164
+
165
+ return colorModeVersion;
166
+ }
167
+
85
168
  /** Add an alpha channel to a hex or rgb() colour (best-effort). */
86
169
  export function withAlpha(color: string, alpha: number): string {
87
170
  const hex = color.replace(/^#/, "");
@@ -122,6 +205,9 @@ export interface BaseOptionArgs {
122
205
  showLegend?: boolean;
123
206
  /** Whether the chart has value axes (bar/line) vs none (doughnut). */
124
207
  hasValueAxis?: boolean;
208
+ /** The chart's container element — decides which `data-bs-theme` scope the
209
+ * grid/tick colours resolve against (#161). Defaults to the document root. */
210
+ scope?: Element | null;
125
211
  }
126
212
 
127
213
  /** Dashboard default Chart.js options merged under any consumer `options`. */
@@ -132,10 +218,14 @@ export function baseOptions(args: BaseOptionArgs): Record<string, any> {
132
218
  locale,
133
219
  showLegend = false,
134
220
  hasValueAxis = true,
221
+ scope,
135
222
  } = args;
136
223
 
137
- const gridColor = withAlpha(themeColor("--bs-border-color", "#dee2e6"), 0.6);
138
- const tickColor = themeColor("--bs-secondary-color", "#6c757d");
224
+ const gridColor = withAlpha(
225
+ themeColor("--bs-border-color", "#dee2e6", scope),
226
+ 0.6,
227
+ );
228
+ const tickColor = themeColor("--bs-secondary-color", "#6c757d", scope);
139
229
 
140
230
  const options: Record<string, any> = {
141
231
  responsive: true,
@@ -185,13 +275,18 @@ export function baseOptions(args: BaseOptionArgs): Record<string, any> {
185
275
  * Apply the themed palette to any dataset that doesn't set its own colours.
186
276
  * Bar: one colour per dataset (or per bar for a single dataset). Line: a themed
187
277
  * stroke + translucent fill. Doughnut/pie: one colour per slice.
278
+ *
279
+ * `scope` is the chart's container element; the palette is resolved from its
280
+ * computed style so a nested `data-bs-theme` container themes correctly (#161).
281
+ * Omit it to resolve against the document root.
188
282
  */
189
283
  export function applyPalette(
190
284
  datasets: any[],
191
285
  kind: "bar" | "line" | "doughnut",
192
286
  labelsCount: number,
287
+ scope?: Element | null,
193
288
  ): any[] {
194
- const palette = getPalette();
289
+ const palette = getPalette(scope);
195
290
  const single = datasets.length === 1;
196
291
 
197
292
  return datasets.map((dataset, index) => {