@omnitend/dashboard-for-laravel 0.35.0 → 0.37.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.
@@ -1,8 +1,8 @@
1
1
  {
2
- "generated": "2026-07-21T12:16:47.073Z",
2
+ "generated": "2026-07-23T11:32:06.998Z",
3
3
  "package": {
4
4
  "name": "@omnitend/dashboard-for-laravel",
5
- "version": "0.35.0"
5
+ "version": "0.37.0"
6
6
  },
7
7
  "components": {
8
8
  "base": [
@@ -211,8 +211,8 @@
211
211
  "name": "variant",
212
212
  "type": "union",
213
213
  "required": false,
214
- "default": "\"primary\"",
215
- "description": "The visual style variant of the button"
214
+ "default": "\"secondary\"",
215
+ "description": "The visual style variant of the button.\n\nDefaults to `'secondary'` (soft, non-emphatic). Under the soft-first\nhouse rule, `primary` is THE single emphatic action per page/modal, so a\nvariant-less button must NOT be emphatic by default — declare\n`variant=\"primary\"` explicitly where you mean the one loud action."
216
216
  },
217
217
  {
218
218
  "name": "size",
@@ -3321,7 +3321,7 @@
3321
3321
  "type": "boolean",
3322
3322
  "required": false,
3323
3323
  "default": "true",
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, which requires the card\nto clip to its radius (`overflow: hidden`).\n\n**Consequence for slot content:** anything you render into a slot that\npositions itself OUTSIDE the card and does not teleport is clipped at the\ncard edge. Library components are unaffected — `DDropdown` teleports to\n`<body>` by default, and the column-filter menus position themselves\n`fixed` — so this only bites a hand-rolled `position: absolute` overlay\n(a custom popover in a `cell(<key>)` or `#header` slot). Teleport it, or\nuse `:card=\"false\"`. Narrowing the clip to the table region is tracked\nas #166."
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\"`."
3325
3325
  },
3326
3326
  {
3327
3327
  "name": "editFields",
@@ -3473,6 +3473,12 @@
3473
3473
  "required": false,
3474
3474
  "default": "false",
3475
3475
  "description": "Apply `table-layout: fixed` to the underlying table. Off by default, so\nexisting tables keep their content-sized columns. When on, column widths\nstop depending on cell content, so they no longer reshuffle when a filter\nnarrows the rows — the table stays put as you filter (#156). Combine with\nper-field `width`/`minWidth` to control the proportions; columns with no\ndeclared width share the remaining space equally."
3476
+ },
3477
+ {
3478
+ "name": "primaryKey",
3479
+ "type": "string",
3480
+ "required": false,
3481
+ "description": "Field on each row whose value uniquely identifies it, forwarded to the\ninner bvn table's `primaryKey` (all three data modes). Without it bvn keys\nclient-side rows by their INDEX, which mis-associates per-row STATEFUL cell\ncomponents when a row is inserted/removed mid-interaction — a debounced\nedit in one row can end up saved against the wrong record after a\nconcurrent delete of a row above. Set it (e.g. `primary-key=\"id\"`) so bvn\nkeys rows by that field's value and each row keeps its identity across\ndata mutations. Non-breaking: omit it and rows key by index exactly as\nbefore."
3476
3482
  }
3477
3483
  ],
3478
3484
  "events": [
@@ -3948,7 +3954,7 @@
3948
3954
  "name": "DXTablePagination",
3949
3955
  "category": "extended",
3950
3956
  "filePath": "resources/js/components/extended/DXTablePagination.vue",
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.",
3957
+ "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`.",
3952
3958
  "props": [
3953
3959
  {
3954
3960
  "name": "pagination",
@@ -4003,6 +4009,13 @@
4003
4009
  "type": "boolean",
4004
4010
  "required": true,
4005
4011
  "description": "Whether a column filter is active — gates the \"Filtered from N\" note."
4012
+ },
4013
+ {
4014
+ "name": "compactThreshold",
4015
+ "type": "number",
4016
+ "required": false,
4017
+ "default": "576",
4018
+ "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."
4006
4019
  }
4007
4020
  ],
4008
4021
  "events": [
@@ -4024,7 +4037,7 @@
4024
4037
  "name": "DXTableShell",
4025
4038
  "category": "extended",
4026
4039
  "filePath": "resources/js/components/extended/DXTableShell.vue",
4027
- "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 (overflow: hidden) ├─ .card-header (padded by Bootstrap's card-cap padding) ├─ .table-responsive ← FLUSH │ └─ table └─ .dx-table-pagination (pads itself — see DXTablePagination.vue) Bootstrap 5 has no `.card > .table` rounding rules (that was BS4), so the clipping is done with `overflow: hidden` on the card: the flush table's square top corners then follow the card's radius whether or not a header is present. bvn's popups position `fixed` by default, so they are not clipped by it.",
4040
+ "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).",
4028
4041
  "props": [
4029
4042
  {
4030
4043
  "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-21T12:16:47.128Z
4
+ > Last updated: 2026-07-23T11:32:07.049Z
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.35.0",
3
+ "version": "0.37.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",
@@ -98,6 +98,18 @@ $btn-padding-x-lg: 1.5rem; // 24px
98
98
 
99
99
  $input-padding-y: 0.375rem; // compact fields (Bootstrap's default; 0.625rem read oversized)
100
100
  $input-padding-x: 1rem;
101
+
102
+ // Match the sm/lg INPUT vertical padding to the sm/lg BUTTON padding above
103
+ // (PO18). Bootstrap sizes every child of an `.input-group-sm`/`-lg` — buttons
104
+ // INCLUDED — with the INPUT's sized padding, so leaving these at Bootstrap's
105
+ // defaults (0.25rem / 0.5rem) made a sized input-group sit ~8px / ~4px shorter
106
+ // than a `.btn-sm`/`.btn-lg` beside it. Aligning them makes sized controls share
107
+ // one height by construction. NB: the BASE `$input-padding-y` (0.375rem) stays
108
+ // deliberately more compact than `$btn-padding-y` (0.625rem) — see the comment
109
+ // above ("0.625rem read oversized"); only the SIZED variants are equalised,
110
+ // because those are the ones whose padding Bootstrap leaks onto grouped buttons.
111
+ $input-padding-y-sm: 0.5rem; // == $btn-padding-y-sm (was Bootstrap's 0.25rem)
112
+ $input-padding-y-lg: 0.75rem; // == $btn-padding-y-lg (was Bootstrap's 0.5rem)
101
113
  $input-border-radius: $border-radius;
102
114
  $input-border-color: #d1d5db; // Gray-300
103
115
  $input-focus-border-color: $primary;
@@ -652,8 +664,52 @@ $bootstrap-icons-font-src: url("bootstrap-icons/font/fonts/bootstrap-icons.woff2
652
664
  }
653
665
 
654
666
  // DXTable header titles: muted by default (#157) so the table's CONTENT is
655
- // the loud layer. Override to re-louden (e.g. `var(--bs-body-color)`).
656
- --dx-table-header-color: var(--bs-secondary-color);
667
+ // the loud layer. Lightened again for S5b — #157's `--bs-secondary-color`
668
+ // (rgba(33,37,41,.75), ~5.9:1 on white) still read near-black next to legacy
669
+ // omnitend's light-slate headers. `#7c8293` (legacy's `$text-secondary`) is
670
+ // the signed-off value (~3.6:1 on white — fine for the semibold/large header
671
+ // text it colours, below the 4.5:1 body-text bar). Still on the same token so
672
+ // a consumer can re-louden (e.g. `var(--bs-body-color)`).
673
+ --dx-table-header-color: #7c8293;
674
+ }
675
+
676
+ // ----------------------------------------------------------------------------
677
+ // Tables — header alignment + filter-row affordance
678
+ // ----------------------------------------------------------------------------
679
+
680
+ // B17: keep header labels vertically centred regardless of whether a column
681
+ // carries sort icons. Bootstrap sets `.table > thead { vertical-align: bottom }`,
682
+ // which floors a plain (non-sortable) column's label while a sortable
683
+ // neighbour's taller two-arrow sort stack holds ITS label higher — one header
684
+ // row, mixed baselines. Centring the whole thead makes vertical alignment
685
+ // independent of the sort indicator's presence. Scoped to `.table > thead` so a
686
+ // consumer who genuinely wants bottom-aligned headers can still opt back in on a
687
+ // specific table.
688
+ .table > thead {
689
+ vertical-align: middle;
690
+ }
691
+
692
+ // PL1: a magnifier glyph on DXTable's text/number filter inputs, so the filter
693
+ // row reads as "this searches" at a glance (legacy lt-table carried one).
694
+ //
695
+ // It MUST be a background-image, not a `::before`/`::after` — an `<input>` is a
696
+ // replaced element and cannot host pseudo-elements. The glyph is an inline
697
+ // bootstrap-icons "search" SVG data-URI, tinted `#7c8293` (the same muted slate
698
+ // as the header token above), sized and inset with `padding-left` so text
699
+ // clears it.
700
+ //
701
+ // Direct-child selector (`> th > input.form-control`) is deliberate: DXTable's
702
+ // text/number/date filters render a bare `.form-control` straight inside the
703
+ // `<th>`, whereas the `select` filter's DAutocomplete nests its input inside
704
+ // `.d-autocomplete .input-group .b-autocomplete-input-wrapper` — so it is not a
705
+ // direct child and correctly gets NO glyph (it has its own chevron affordance).
706
+ // `:not([type="date"])` further excludes the date filter (its own picker icon).
707
+ .filter-row > th > input.form-control:not([type="date"]) {
708
+ background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='%237c8293' viewBox='0 0 16 16'%3e%3cpath d='M11.742 10.344a6.5 6.5 0 1 0-1.397 1.398h-.001q.044.06.098.115l3.85 3.85a1 1 0 0 0 1.415-1.414l-3.85-3.85a1 1 0 0 0-.115-.1zM12 6.5a5.5 5.5 0 1 1-11 0 5.5 5.5 0 0 1 11 0'/%3e%3c/svg%3e");
709
+ background-repeat: no-repeat;
710
+ background-position: left 0.65rem center;
711
+ background-size: 0.85em;
712
+ padding-left: 1.9rem;
657
713
  }
658
714
 
659
715
  // primary is near-black navy, so Bootstrap's darken-on-hover is imperceptible —
@@ -679,6 +735,35 @@ $bootstrap-icons-font-src: url("bootstrap-icons/font/fonts/bootstrap-icons.woff2
679
735
  }
680
736
  }
681
737
 
738
+ // ----------------------------------------------------------------------------
739
+ // Dropdowns — chevron caret (B14 / PO5)
740
+ // ----------------------------------------------------------------------------
741
+ // Bootstrap's `.dropdown-toggle::after` is a CSS border-triangle that sits high
742
+ // against Poppins' line box and reads crude next to the rest of the theme.
743
+ // Replace it with the bootstrap-icons chevron glyph, optically centred with a
744
+ // little breathing room from the label. The font is already available: the
745
+ // theme `@import`s `bootstrap-icons/font/bootstrap-icons` (declaring the
746
+ // `@font-face`), and #77's post-build extraction ships that woff2 with the
747
+ // stylesheet — so this needs no new dependency and no data-URI. Values signed
748
+ // off after one padding iteration in the greendragon interim; the glyph metrics
749
+ // are tunable via the variables below.
750
+ $dx-dropdown-caret-size: 0.75em; // glyph size relative to the label
751
+ $dx-dropdown-caret-gap: 0.4em; // space between label and chevron
752
+ $dx-dropdown-caret-shift: 0.05em; // vertical nudge for optical centring
753
+ .dropdown-toggle::after {
754
+ border: none; // drop Bootstrap's border-triangle
755
+ font-family: "bootstrap-icons";
756
+ content: "\F282"; // bi-chevron-down
757
+ font-size: $dx-dropdown-caret-size;
758
+ line-height: 1;
759
+ vertical-align: $dx-dropdown-caret-shift;
760
+ margin-left: $dx-dropdown-caret-gap;
761
+ }
762
+ .dropup .dropdown-toggle::after {
763
+ border: none;
764
+ content: "\F286"; // bi-chevron-up
765
+ }
766
+
682
767
  // ----------------------------------------------------------------------------
683
768
  // Switches — green when ON, red when OFF, by default (#158)
684
769
  // ----------------------------------------------------------------------------
@@ -794,8 +879,10 @@ $dx-chart-palette: (#2563eb, #65a30d, #7c3aed, #0d9488, #ea580c, #0891b2, #d9770
794
879
  // same method as the light set (OKLab ΔE ×100, Viénot protan/deutan): min
795
880
  // adjacent CVD ΔE 15.1 (light set: 13.8), min normal ΔE 25.3, contrast
796
881
  // 5.67–10.23:1 on #212529. Swapping a step needs that validation re-run.
797
- // getPalette() reads the CSS variables live, so charts follow the theme with
798
- // no JS change; the TS fallbacks stay the light set (SSR/no-CSS default).
882
+ // getPalette() reads these CSS variables live — off the CHART'S OWN container,
883
+ // so nested colour-mode scopes resolve correctly, and re-read on every
884
+ // `data-bs-theme` change so mounted charts repaint (#161). The TS fallbacks
885
+ // stay the light set (SSR/no-CSS default).
799
886
  $dx-chart-palette-dark: (#60a5fa, #a3e635, #a78bfa, #2dd4bf, #fb923c, #22d3ee, #fbbf24, #f472b6);
800
887
 
801
888
  :root {
@@ -810,6 +897,18 @@ $dx-chart-palette-dark: (#60a5fa, #a3e635, #a78bfa, #2dd4bf, #fb923c, #22d3ee, #
810
897
  }
811
898
  }
812
899
 
900
+ // Bootstrap allows `data-bs-theme` on ANY container, so colour-mode scopes
901
+ // nest — and custom properties inherit. Without this re-declaration a LIGHT
902
+ // scope inside a dark root (a light card on a dark page) would inherit the dark
903
+ // palette, because only `:root` publishes the light values and the dark block
904
+ // above overrides it for the whole subtree (#161). Same list as `:root`, so the
905
+ // two can't drift.
906
+ [data-bs-theme="light"] {
907
+ @for $i from 1 through list.length($dx-chart-palette) {
908
+ --dx-chart-#{$i}: #{list.nth($dx-chart-palette, $i)};
909
+ }
910
+ }
911
+
813
912
  // The chart container used by DXBarChart/DXLineChart/DXDoughnutChart (shipped
814
913
  // from the `@omnitend/dashboard-for-laravel/charts` entry, #142). Global rather
815
914
  // than a component scoped style so the charts entry emits NO CSS — chart
@@ -837,6 +936,11 @@ $dx-chart-palette-dark: (#60a5fa, #a3e635, #a78bfa, #2dd4bf, #fb923c, #22d3ee, #
837
936
  .b-autocomplete-content {
838
937
  min-width: max-content; // grow to the longest option (floors at the trigger width)
839
938
  max-width: min(90vw, 32rem); // but don't run away
939
+ // S3b.1: bvn caps this popup at 300px, showing ~7 of a ~30-option list behind
940
+ // an inner scrollbar — it "doesn't drop all the way down the page like a
941
+ // native select". Let it fall most of the way down the viewport (but never
942
+ // taller than 42rem) so the list reads like a real dropdown.
943
+ max-height: min(70vh, 42rem);
840
944
  }
841
945
 
842
946
  // ----------------------------------------------------------------------------
@@ -37,8 +37,13 @@ type LinkVariant =
37
37
 
38
38
  interface Props {
39
39
  /**
40
- * The visual style variant of the button
41
- * @default 'primary'
40
+ * The visual style variant of the button.
41
+ *
42
+ * Defaults to `'secondary'` (soft, non-emphatic). Under the soft-first
43
+ * house rule, `primary` is THE single emphatic action per page/modal, so a
44
+ * variant-less button must NOT be emphatic by default — declare
45
+ * `variant="primary"` explicitly where you mean the one loud action.
46
+ * @default 'secondary'
42
47
  */
43
48
  variant?: ButtonVariant | LinkVariant | null;
44
49
 
@@ -105,7 +110,7 @@ interface Props {
105
110
  }
106
111
 
107
112
  const props = withDefaults(defineProps<Props>(), {
108
- variant: "primary",
113
+ variant: "secondary",
109
114
  disabled: false,
110
115
  loading: false,
111
116
  block: false,
@@ -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) => {