@omnitend/dashboard-for-laravel 0.45.0 → 0.47.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 (51) hide show
  1. package/dist/assets/IBMPlexSans-Bold-Latin1-914f1400.woff2 +0 -0
  2. package/dist/assets/IBMPlexSans-Medium-Latin1-b5610af0.woff2 +0 -0
  3. package/dist/assets/IBMPlexSans-Regular-Latin1-b5ad7bd3.woff2 +0 -0
  4. package/dist/assets/IBMPlexSans-SemiBold-Latin1-fff0ab3a.woff2 +0 -0
  5. package/dist/components/base/DFormFile.vue.d.ts +17 -0
  6. package/dist/components/base/DFormValidFeedback.vue.d.ts +12 -0
  7. package/dist/components/extended/DXDashboard.vue.d.ts +95 -111
  8. package/dist/components/extended/DXDashboardNavbar.vue.d.ts +2 -36
  9. package/dist/components/extended/DXDashboardSidebar.vue.d.ts +60 -79
  10. package/dist/components/extended/DXField.vue.d.ts +16 -0
  11. package/dist/components/extended/DXTable.vue.d.ts +10 -1
  12. package/dist/components/extended/DXTableEditorModal.vue.d.ts +13 -3
  13. package/dist/dashboard-for-laravel.js +11767 -11479
  14. package/dist/dashboard-for-laravel.js.map +1 -1
  15. package/dist/dashboard-for-laravel.umd.cjs +7 -7
  16. package/dist/dashboard-for-laravel.umd.cjs.map +1 -1
  17. package/dist/index.d.ts +4 -2
  18. package/dist/style.css +2 -2
  19. package/dist/types/index.d.ts +60 -0
  20. package/dist/types/navigation.d.ts +153 -3
  21. package/dist/utils/formErrorTargets.d.ts +6 -2
  22. package/dist/utils/formSchema.d.ts +16 -0
  23. package/dist/utils/statCardFit.d.ts +19 -0
  24. package/docs/public/api-reference.json +171 -9
  25. package/docs/public/docs-map.md +4 -2
  26. package/docs/public/llms.txt +4 -2
  27. package/package.json +3 -2
  28. package/resources/css/theme.scss +339 -4
  29. package/resources/fonts/ibm-plex-sans/IBMPlexSans-Bold-Latin1.woff2 +0 -0
  30. package/resources/fonts/ibm-plex-sans/IBMPlexSans-Medium-Latin1.woff2 +0 -0
  31. package/resources/fonts/ibm-plex-sans/IBMPlexSans-Regular-Latin1.woff2 +0 -0
  32. package/resources/fonts/ibm-plex-sans/IBMPlexSans-SemiBold-Latin1.woff2 +0 -0
  33. package/resources/fonts/ibm-plex-sans/OFL.txt +93 -0
  34. package/resources/fonts/ibm-plex-sans/SOURCE.txt +18 -0
  35. package/resources/js/components/base/DFormFile.vue +103 -0
  36. package/resources/js/components/base/DFormValidFeedback.vue +16 -0
  37. package/resources/js/components/extended/DXDashboard.vue +41 -18
  38. package/resources/js/components/extended/DXDashboardNavbar.vue +9 -1
  39. package/resources/js/components/extended/DXDashboardSidebar.vue +25 -3
  40. package/resources/js/components/extended/DXField.vue +86 -23
  41. package/resources/js/components/extended/DXFormField.vue +3 -1
  42. package/resources/js/components/extended/DXStatCard.vue +154 -11
  43. package/resources/js/components/extended/DXTable.vue +62 -6
  44. package/resources/js/components/extended/DXTableEditorModal.vue +35 -2
  45. package/resources/js/composables/useResourceEditor.ts +64 -26
  46. package/resources/js/index.ts +11 -0
  47. package/resources/js/types/index.ts +64 -0
  48. package/resources/js/types/navigation.ts +175 -3
  49. package/resources/js/utils/formErrorTargets.ts +28 -2
  50. package/resources/js/utils/formSchema.ts +27 -0
  51. package/resources/js/utils/statCardFit.ts +210 -0
@@ -113,17 +113,29 @@
113
113
  </div>
114
114
  </template>
115
115
 
116
- <script setup lang="ts">
117
- import { ref, computed, useSlots, useId, watch, onMounted, onBeforeUnmount, onActivated, onDeactivated } from 'vue';
116
+ <script setup lang="ts" generic="TNavigation extends Navigation = Navigation">
117
+ import { ref, computed, useId, useTemplateRef, watch, onMounted, onBeforeUnmount, onActivated, onDeactivated } from 'vue';
118
118
  import DXDashboardSidebar from './DXDashboardSidebar.vue';
119
119
  import DXDashboardNavbar from './DXDashboardNavbar.vue';
120
120
  import DContainer from '../base/DContainer.vue';
121
121
  import DRow from '../base/DRow.vue';
122
122
  import DCol from '../base/DCol.vue';
123
123
  import { acquirePageScrollLock, releasePageScrollLock } from '../../utils/pageScrollLock';
124
- import type { Navigation, NavbarActionsOnMobile, NavbarSearchAlign, SidebarVariant } from '../../types/navigation';
125
-
126
- const slots = useSlots();
124
+ import type {
125
+ DashboardSlots,
126
+ Navigation,
127
+ NavigationItemOf,
128
+ NavbarActionsOnMobile,
129
+ NavbarSearchAlign,
130
+ SidebarVariant,
131
+ } from '../../types/navigation';
132
+
133
+ // Typed slots, derived from the children's slot types (see `DashboardSlots`).
134
+ // `TNavigation` is inferred from the `navigation` prop, so
135
+ // `#sidebar-link="{ item }"` has the app's own item keys (see
136
+ // `NavigationItemOf`). Types only: the forwarding below stays dynamic, so it
137
+ // never lists names.
138
+ const slots = defineSlots<DashboardSlots<NavigationItemOf<TNavigation>>>();
127
139
 
128
140
  // Slot-binding precedence: the forwarded slots bind the child's own slot props
129
141
  // FIRST and `toggleSidebar`/`sidebarHidden` after, so ours win a name clash.
@@ -131,9 +143,12 @@ const slots = useSlots();
131
143
  // forwarded slot, and a child that later grew a same-named prop must not be
132
144
  // able to silently take the guarantee away. No child uses those names today.
133
145
 
134
- interface Props {
146
+ // Declared inline, not as a local `interface Props`: a generic component's
147
+ // emitted declaration names its props type, and a local interface there fails
148
+ // the declaration build (TS4082).
149
+ const props = withDefaults(defineProps<{
135
150
  /** Navigation structure for sidebar */
136
- navigation: Navigation;
151
+ navigation: TNavigation;
137
152
 
138
153
  /** Current URL path for active state */
139
154
  currentUrl: string;
@@ -209,9 +224,7 @@ interface Props {
209
224
  * If not provided, uses global HTML class approach (for SSR compatibility)
210
225
  */
211
226
  dashboardId?: string;
212
- }
213
-
214
- const props = withDefaults(defineProps<Props>(), {
227
+ }>(), {
215
228
  title: 'Dashboard',
216
229
  pageTitle: '',
217
230
  user: null,
@@ -228,13 +241,15 @@ const props = withDefaults(defineProps<Props>(), {
228
241
 
229
242
  const collapsed = ref(false);
230
243
 
231
- // Compute sidebar slots (strip 'sidebar-' prefix)
244
+ // Compute sidebar slots (strip 'sidebar-' prefix). The values are typed as
245
+ // declared slot names so the template's `<slot :name>` type-checks; at runtime
246
+ // any `sidebar-*`/`navbar-*` slot the consumer passes is forwarded.
232
247
  const sidebarSlots = computed(() => {
233
- const result: Record<string, string> = {};
248
+ const result: Record<string, keyof DashboardSlots> = {};
234
249
  Object.keys(slots).forEach(name => {
235
250
  if (name.startsWith('sidebar-')) {
236
251
  const strippedName = name.substring(8); // Remove 'sidebar-' prefix
237
- result[strippedName] = name;
252
+ result[strippedName] = name as keyof DashboardSlots;
238
253
  }
239
254
  });
240
255
  return result;
@@ -242,11 +257,11 @@ const sidebarSlots = computed(() => {
242
257
 
243
258
  // Compute navbar slots (strip 'navbar-' prefix)
244
259
  const navbarSlots = computed(() => {
245
- const result: Record<string, string> = {};
260
+ const result: Record<string, keyof DashboardSlots> = {};
246
261
  Object.keys(slots).forEach(name => {
247
262
  if (name.startsWith('navbar-')) {
248
263
  const strippedName = name.substring(7); // Remove 'navbar-' prefix
249
- result[strippedName] = name;
264
+ result[strippedName] = name as keyof DashboardSlots;
250
265
  }
251
266
  });
252
267
  return result;
@@ -352,7 +367,9 @@ const phoneMenuOpen = computed(
352
367
 
353
368
  const sidebarId = `${useId()}-sidebar`;
354
369
  const layoutRef = ref<HTMLElement | null>(null);
355
- const sidebarComponent = ref<InstanceType<typeof DXDashboardSidebar> | null>(null);
370
+ // A ref to the generic sidebar carries only what it exposes; its root element
371
+ // is the exposed `el`.
372
+ const sidebarInstance = useTemplateRef('sidebarComponent');
356
373
 
357
374
  const setHidden = (isHidden: boolean): void => {
358
375
  hidden.value = isHidden;
@@ -388,8 +405,7 @@ const onSidebarNavigate = (): void => {
388
405
  closePhoneMenu();
389
406
  };
390
407
 
391
- const sidebarElement = (): HTMLElement | null =>
392
- (sidebarComponent.value?.$el as HTMLElement | undefined) ?? null;
408
+ const sidebarElement = (): HTMLElement | null => sidebarInstance.value?.el ?? null;
393
409
 
394
410
  const onKeydown = (event: KeyboardEvent): void => {
395
411
  if (event.key !== 'Escape') return;
@@ -489,6 +505,13 @@ defineExpose({
489
505
 
490
506
  /** Whether the sidebar is currently hidden. */
491
507
  sidebarHidden: computed(() => hidden.value),
508
+
509
+ /**
510
+ * The dashboard's root element (the `.dashboard-layout` `<div>`; `null`
511
+ * before mount). A template ref to this generic component carries only its
512
+ * exposed members, so read the DOM node here rather than through `$el`.
513
+ */
514
+ el: layoutRef,
492
515
  });
493
516
  </script>
494
517
 
@@ -122,7 +122,15 @@ import DContainer from "../base/DContainer.vue";
122
122
  import DButton from "../base/DButton.vue";
123
123
  import DDropdown from "../base/DDropdown.vue";
124
124
  import DXUserAvatar from "./DXUserAvatar.vue";
125
- import type { NavbarActionsOnMobile, NavbarSearchAlign } from "../../types/navigation";
125
+ import type {
126
+ DashboardNavbarSlots,
127
+ NavbarActionsOnMobile,
128
+ NavbarSearchAlign,
129
+ } from "../../types/navigation";
130
+
131
+ // Typed slots: consumers get autocomplete and slot-prop types, and DXDashboard
132
+ // derives its `navbar-*` slot types from this interface.
133
+ defineSlots<DashboardNavbarSlots>();
126
134
 
127
135
  const props = withDefaults(
128
136
  defineProps<{
@@ -165,13 +165,26 @@
165
165
  </aside>
166
166
  </template>
167
167
 
168
- <script setup lang="ts">
168
+ <script setup lang="ts" generic="TNavigation extends Navigation = Navigation">
169
169
  import { computed, ref, onMounted, watch, nextTick, useId } from 'vue';
170
- import type { Navigation, NavigationGroup, SidebarVariant } from '../../types/navigation';
170
+ import type {
171
+ DashboardSidebarSlots,
172
+ Navigation,
173
+ NavigationGroup,
174
+ NavigationItemOf,
175
+ SidebarVariant,
176
+ } from '../../types/navigation';
177
+
178
+ // Typed slots: consumers get autocomplete and slot-prop types, and DXDashboard
179
+ // derives its `sidebar-*` slot types from this interface. `TNavigation` is
180
+ // inferred from the `navigation` prop, so an app whose items carry extra keys
181
+ // gets them on the `link` slot's `item` (see `NavigationItemOf`). Types only:
182
+ // nothing at runtime depends on it.
183
+ defineSlots<DashboardSidebarSlots<NavigationItemOf<TNavigation>>>();
171
184
 
172
185
  const props = withDefaults(defineProps<{
173
186
  /** Grouped navigation to render: an array of groups, each with a label and items. */
174
- navigation: Navigation;
187
+ navigation: TNavigation;
175
188
  /** The current route URL, used to highlight the matching nav item and open its group. */
176
189
  currentUrl: string;
177
190
  /** Collapse the rail to an icons-only strip, hiding labels and the brand title. */
@@ -469,6 +482,15 @@ watch(() => props.currentUrl, () => {
469
482
  watch(activeGroupIndex, () => {
470
483
  openActiveGroup();
471
484
  });
485
+
486
+ defineExpose({
487
+ /**
488
+ * The sidebar's root `<aside>` element (`null` before mount). A template ref
489
+ * to this generic component carries only its exposed members, so read the
490
+ * DOM node here rather than through `$el`.
491
+ */
492
+ el: sidebarRef,
493
+ });
472
494
  </script>
473
495
 
474
496
  <style scoped>
@@ -1,6 +1,6 @@
1
1
  <!--
2
2
  @component
3
- Renders a single `FieldDefinition` of any type (text, select, autocomplete, checkbox, switch, repeater, currency, percentage, file/image, component, etc.) with its label, validation error, hint and help. Used by `DXForm` and `DXTable`'s edit modal; exposes `value`, `span`, `info`, `info-popover`, `hint` and `repeater-row` slots for per-field customisation.
3
+ Renders a single `FieldDefinition` of any type (text, select, autocomplete, checkbox, switch, repeater, currency, percentage, file/image, component, etc.) with its label, validation error, hint and help. Used by `DXForm` and `DXTable`'s edit modal; exposes `value`, `span`, `info`, `info-popover`, `hint`, `repeater-row` and `password-actions` slots for per-field customisation.
4
4
 
5
5
  Structure (#135): a thin DISPATCHER. The shared chrome (label + validation
6
6
  feedback + hint + help + info) lives in `DXFieldShell`; the numeric controls
@@ -336,28 +336,61 @@
336
336
  <!-- Password with a reveal toggle (the default for
337
337
  `type: "password"`). Typing a long password blind is the one
338
338
  place the eye really earns its keep, so it's opt-out
339
- (`revealable: false`) rather than opt-in. -->
340
- <DInputGroup v-else-if="isRevealablePassword">
341
- <DFormInput
342
- v-model="textFieldModel"
343
- :required="field.required"
344
- :placeholder="field.placeholder"
345
- :state="fieldState"
346
- :disabled="isDisabled"
347
- :readonly="isReadonly"
348
- v-bind="passwordInputProps"
349
- />
350
- <template #append>
351
- <DButton
352
- variant="outline-secondary"
353
- :icon="passwordRevealed ? 'eye-slash' : 'eye'"
354
- :aria-label="passwordRevealed ? 'Hide password' : 'Show password'"
355
- :aria-pressed="passwordRevealed"
339
+ (`revealable: false`) rather than opt-in. The eye is drawn
340
+ as part of the field (`.dx-password-reveal`: the input's
341
+ border, radius and background). A `password-actions` slot
342
+ lands AFTER the input group, in `.dx-password-field__actions`,
343
+ which wraps below the group when the row cannot give the
344
+ input `--dx-password-input-min-width` (theme.scss); it also
345
+ brings the group to a `revealable: false` field. -->
346
+ <div v-else-if="isPasswordGroup()" class="dx-password-field">
347
+ <DInputGroup class="dx-password-field__group">
348
+ <DFormInput
349
+ v-model="textFieldModel"
350
+ :required="field.required"
351
+ :placeholder="field.placeholder"
352
+ :state="fieldState"
356
353
  :disabled="isDisabled"
357
- @click="passwordRevealed = !passwordRevealed"
354
+ :readonly="isReadonly"
355
+ v-bind="passwordInputProps"
358
356
  />
359
- </template>
360
- </DInputGroup>
357
+ <template #append>
358
+ <DButton
359
+ v-if="isRevealablePassword"
360
+ class="dx-password-reveal"
361
+ variant="outline-secondary"
362
+ icon-only
363
+ :icon="passwordRevealed ? 'eye-slash' : 'eye'"
364
+ :aria-label="passwordRevealed ? 'Hide password' : 'Show password'"
365
+ :aria-pressed="passwordRevealed"
366
+ :disabled="isDisabled"
367
+ @click="passwordRevealed = !passwordRevealed"
368
+ />
369
+ </template>
370
+ </DInputGroup>
371
+ <div v-if="slots['password-actions'] !== undefined" class="dx-password-field__actions">
372
+ <!--
373
+ @slot Extra controls beside a password input, after its input group (the input and the reveal (eye) toggle), e.g. a "Generate password" button. They sit on the input's row while it keeps `--dx-password-input-min-width`, and wrap below it when the row is too narrow. Use an ordinary button (a `secondary` `DButton` matches the form's other buttons). Not rendered on a `plaintext` field.
374
+ @binding {FieldDefinition} field The field definition being rendered.
375
+ @binding {any} model The model passed to field predicates (defaults to the live form data).
376
+ @binding {any} value The current field value.
377
+ @binding {(value: any) => void} update Setter that writes the value back and clears the field's validation error.
378
+ @binding {boolean} revealed Whether the password is currently shown as plain text.
379
+ @binding {(revealed: boolean) => void} setRevealed Show (`true`) or mask (`false`) the password, exactly as the eye does. A no-op on a `revealable: false` field.
380
+ @binding {boolean} disabled Whether the field is disabled or read-only, so actions that change the value can disable themselves.
381
+ -->
382
+ <slot
383
+ name="password-actions"
384
+ :field="field"
385
+ :model="model"
386
+ :value="fieldValue"
387
+ :update="setValue"
388
+ :revealed="passwordRevealed"
389
+ :set-revealed="setPasswordRevealed"
390
+ :disabled="isDisabled || isReadonly"
391
+ />
392
+ </div>
393
+ </div>
361
394
 
362
395
  <!-- Text-based inputs (text/email/password/url/tel/date/time/datetime) -->
363
396
  <DFormInput
@@ -819,6 +852,32 @@ const isRevealablePassword = computed(
819
852
  !isPlaintext.value,
820
853
  );
821
854
 
855
+ /*
856
+ * The password input group: the eye (unless `revealable: false`) plus any
857
+ * `password-actions` slot content. A `revealable: false` field with no slot
858
+ * stays a bare input, as before; with the slot it gets the group so the
859
+ * actions have somewhere to sit, but still no eye and no way to unmask.
860
+ * A function, not a computed: `useSlots()` is not reactive, so a computed
861
+ * would keep the first render's answer if the slot came or went later.
862
+ */
863
+ function isPasswordGroup(): boolean {
864
+ return (
865
+ isRevealablePassword.value ||
866
+ (props.field.type === "password" &&
867
+ !isPlaintext.value &&
868
+ slots["password-actions"] !== undefined)
869
+ );
870
+ }
871
+
872
+ /**
873
+ * The `password-actions` slot's handle on the reveal state: the same state
874
+ * the eye toggles, so the two never disagree. Refused on a `revealable: false`
875
+ * field, which promises never to unmask.
876
+ */
877
+ function setPasswordRevealed(revealed: boolean): void {
878
+ passwordRevealed.value = isRevealablePassword.value && revealed === true;
879
+ }
880
+
822
881
  // DXTable reuses ONE DXField instance per field key across modal opens (the
823
882
  // v-for is keyed on `field.key`, which is stable across rows) and swaps in a
824
883
  // fresh form per record. Without this, revealing row A's password leaves row
@@ -864,10 +923,14 @@ const controlPropsWithGuards = computed(() => {
864
923
  return merged;
865
924
  });
866
925
 
867
- // The reveal toggle owns the password input's `type`.
926
+ // The reveal toggle owns the password input's `type`. Only a revealable field
927
+ // can be shown as text (`setPasswordRevealed` also refuses the others).
868
928
  const passwordInputProps = computed(() => ({
869
929
  ...inputPropsWithGuards.value,
870
- type: passwordRevealed.value ? "text" : "password",
930
+ type:
931
+ passwordRevealed.value && isRevealablePassword.value
932
+ ? "text"
933
+ : "password",
871
934
  }));
872
935
 
873
936
  const inputType = computed<string>(() => {
@@ -5,7 +5,8 @@
5
5
  Owns the single per-field render block that DXForm's tabbed and flat layouts
6
6
  both need: the `field(<key>)` full-replacement slot, the
7
7
  `field-before(<key>)`/`field-after(<key>)` wrapper slots, and the `<DXField>`
8
- element with its `value`/`span`/`info`/`hint`/`repeater-row` slot forwarding.
8
+ element with its `value`/`span`/`info`/`hint`/`repeater-row`/
9
+ `switch-list-item`/`password-actions` slot forwarding.
9
10
 
10
11
  Extracted so those per-field props/slots are wired in ONE place — before this,
11
12
  DXForm carried two near-identical copies (tabbed + flat) and a new per-field
@@ -99,6 +100,7 @@ function fieldSlotMap(): Record<string, string> {
99
100
  ["hint", `hint(${key})`],
100
101
  ["repeater-row", `repeater-row(${key})`],
101
102
  ["switch-list-item", `switch-list-item(${key})`],
103
+ ["password-actions", `password-actions(${key})`],
102
104
  ];
103
105
  for (const [target, source] of candidates) {
104
106
  if (slots[source]) map[target] = source;
@@ -17,15 +17,28 @@
17
17
  </div>
18
18
  </div>
19
19
 
20
- <div class="dx-stat-card__value">
21
- <!--
22
- @slot Override the rendered value.
23
- @binding {string} formatted The value formatted per `format`.
24
- @binding {string | number} value The raw value prop.
25
- -->
26
- <slot name="value" :formatted="formattedValue" :value="value">
27
- {{ formattedValue }}
28
- </slot>
20
+ <!-- The value's box (#213): a numeric value is fitted to this box's
21
+ width (utils/statCardFit.ts), so two cards to a phone row shrink a
22
+ long number instead of wrapping it, while a short one stays full
23
+ size. It is also a size container, for the CSS baseline used
24
+ before the fit runs. It wraps the value alone, not the card, so
25
+ inline-size containment never removes the title's or footer's
26
+ contribution to the width of a content-sized card. -->
27
+ <div ref="valueBox" class="dx-stat-card__value-box">
28
+ <div
29
+ ref="valueElement"
30
+ class="dx-stat-card__value"
31
+ :class="{ 'dx-stat-card__value--numeric': isNumericValue }"
32
+ >
33
+ <!--
34
+ @slot Override the rendered value.
35
+ @binding {string} formatted The value formatted per `format`.
36
+ @binding {string | number} value The raw value prop.
37
+ -->
38
+ <slot name="value" :formatted="formattedValue" :value="value">
39
+ {{ formattedValue }}
40
+ </slot>
41
+ </div>
29
42
  </div>
30
43
 
31
44
  <div v-if="hasDelta || deltaLabel || $slots.trend" class="dx-stat-card__foot">
@@ -49,9 +62,10 @@
49
62
  </template>
50
63
 
51
64
  <script setup lang="ts">
52
- import { computed } from "vue";
65
+ import { computed, onActivated, onBeforeUnmount, onMounted, ref } from "vue";
53
66
  import DCard from "../base/DCard.vue";
54
67
  import DBadge from "../base/DBadge.vue";
68
+ import { queueFit, registerTarget, unregisterTarget, type FitTarget } from "../../utils/statCardFit";
55
69
 
56
70
  type StatFormat = "number" | "currency" | "percent";
57
71
  type DeltaDirection = "up" | "down" | "neutral";
@@ -114,6 +128,81 @@ const formattedValue = computed<string>(() => {
114
128
  return formatNumber(raw, props.format);
115
129
  });
116
130
 
131
+ // A number, or a string with no letters ("£1,234.56", "42%"): it must never
132
+ // break between digits. Any other string (a URL, a status word) keeps the
133
+ // card's last-resort break so it cannot run out of the card.
134
+ const isNumericValue = computed<boolean>(
135
+ () => typeof props.value === "number" || !/\p{L}/u.test(props.value),
136
+ );
137
+
138
+ const valueBox = ref<HTMLElement | null>(null);
139
+ const valueElement = ref<HTMLElement | null>(null);
140
+ let fitTarget: FitTarget | null = null;
141
+ let resizeObserver: ResizeObserver | null = null;
142
+ let mutationObserver: MutationObserver | null = null;
143
+
144
+ // Browser-only, and onMounted never runs during server rendering, so the
145
+ // server's markup carries the CSS baseline size.
146
+ onMounted(() => {
147
+ if (!valueBox.value || !valueElement.value) return;
148
+ const target: FitTarget = {
149
+ box: valueBox.value,
150
+ value: valueElement.value,
151
+ isNumeric: () => isNumericValue.value,
152
+ fittedBoxWidth: -1,
153
+ };
154
+ fitTarget = target;
155
+ registerTarget(target);
156
+ // A microtask: after every card in this render has mounted (one batch),
157
+ // and before the browser paints.
158
+ queueFit(target, "microtask");
159
+
160
+ if (typeof ResizeObserver !== "undefined") {
161
+ resizeObserver = new ResizeObserver((entries) => {
162
+ // Text is never fitted; a change to or from text is a mutation.
163
+ if (!target.isNumeric()) return;
164
+ const width = entries[entries.length - 1]!.contentRect.width;
165
+ // A fit changes the box's height; only a new width needs a new fit.
166
+ if (Math.abs(width - target.fittedBoxWidth) < 0.01) return;
167
+ queueFit(target, "frame");
168
+ });
169
+ resizeObserver.observe(target.box);
170
+ }
171
+
172
+ // Whatever renders in the value changing (the formatted value, the
173
+ // numeric class, `value` slot content at any depth) refits. The fit's own
174
+ // writes (`style` on the value element) are ignored.
175
+ if (typeof MutationObserver !== "undefined") {
176
+ mutationObserver = new MutationObserver((records) => {
177
+ const changed = records.some(
178
+ (record) => !(record.target === target.value && record.attributeName === "style"),
179
+ );
180
+ if (changed) queueFit(target, "microtask");
181
+ });
182
+ mutationObserver.observe(target.value, {
183
+ childList: true,
184
+ subtree: true,
185
+ characterData: true,
186
+ attributes: true,
187
+ });
188
+ }
189
+ });
190
+
191
+ // Back from a deactivated <KeepAlive>: the value or the layout may have
192
+ // changed while it was detached, when no fit could measure it.
193
+ onActivated(() => {
194
+ if (fitTarget) queueFit(fitTarget, "microtask");
195
+ });
196
+
197
+ onBeforeUnmount(() => {
198
+ resizeObserver?.disconnect();
199
+ mutationObserver?.disconnect();
200
+ resizeObserver = null;
201
+ mutationObserver = null;
202
+ if (fitTarget) unregisterTarget(fitTarget);
203
+ fitTarget = null;
204
+ });
205
+
117
206
  const hasDelta = computed(
118
207
  () => typeof props.delta === "number" && Number.isFinite(props.delta),
119
208
  );
@@ -176,13 +265,67 @@ const formattedDelta = computed<string>(() => {
176
265
  flex-shrink: 0;
177
266
  }
178
267
 
268
+ /*
269
+ * Inline-size containment means the box's content no longer gives it a
270
+ * width, so a card sized to its content (a non-growing flex item, an
271
+ * inline-block, a `col-auto`) would take only its title's width and crush
272
+ * the value. The intrinsic fallback, 8rem (128px), holds a 7-figure amount
273
+ * at the 1.125rem floor: "£1,234,567.89" is 6.64em, 119.6px at 18px.
274
+ */
275
+ .dx-stat-card__value-box {
276
+ container-type: inline-size;
277
+ contain-intrinsic-inline-size: 8rem;
278
+ }
279
+
280
+ /*
281
+ * The size is the fit's (`--dx-stat-card-fit-size`, set by the script on a
282
+ * numeric value: 1.75rem unless that overflows the box, then just small
283
+ * enough, down to 1.125rem). Before the fit has run (server markup, first
284
+ * paint of hydrated markup, no script) a numeric value uses the baseline
285
+ * below and any other value 1.75rem; text wraps, so it is never shrunk.
286
+ * Tabular figures (#215) keep each digit on the same advance: the digits
287
+ * come from the theme's figures face (`--dx-font-family-figures`, as
288
+ * `.dx-tabular`), the rest of the value from the body face.
289
+ */
179
290
  .dx-stat-card__value {
180
- font-size: 1.75rem;
291
+ font-size: var(--dx-stat-card-fit-size, 1.75rem);
292
+ font-family: var(--dx-font-family-figures), var(--bs-body-font-family);
293
+ font-variant-numeric: tabular-nums;
181
294
  font-weight: 700;
182
295
  line-height: 1.1;
183
296
  color: var(--bs-emphasis-color, var(--bs-body-color));
184
297
  }
185
298
 
299
+ /*
300
+ * A numeric value (a number, or a string with no letters) never breaks:
301
+ * `normal` undoes the card's `word-wrap: break-word`, which split a number
302
+ * mid-figure when it overflowed. Any other string keeps that last-resort
303
+ * break, so a long URL wraps inside the card instead of running out of it.
304
+ */
305
+ .dx-stat-card__value--numeric {
306
+ overflow-wrap: normal;
307
+ }
308
+
309
+ /*
310
+ * The numeric baseline before the fit runs, which never overflows for up
311
+ * to 7 figures: 1.75rem from a 175px box up, then a line down to the
312
+ * 1.125rem floor at 8rem (128px). (A plain `16cqi` also reaches 1.75rem at
313
+ * 175px but cannot hold a 6.64em value under 175px.) The slope is 10px over
314
+ * 47px, written 0.2128 so it reaches 28px at 175px, never just under. It
315
+ * sits in `@supports` because the `var()` makes the declaration valid at
316
+ * parse time: without container units the cqi fallback would be invalid at
317
+ * computed time and the value would fall to the inherited body size, where
318
+ * this way such a browser keeps the flat 1.75rem above.
319
+ */
320
+ @supports (width: 1cqi) {
321
+ .dx-stat-card__value--numeric {
322
+ font-size: var(
323
+ --dx-stat-card-fit-size,
324
+ clamp(1.125rem, 1.125rem + (100cqi - 8rem) * 0.2128, 1.75rem)
325
+ );
326
+ }
327
+ }
328
+
186
329
  .dx-stat-card__foot {
187
330
  display: flex;
188
331
  align-items: center;