@omnitend/dashboard-for-laravel 0.44.0 → 0.46.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.
- package/dist/assets/IBMPlexSans-Bold-Latin1-914f1400.woff2 +0 -0
- package/dist/assets/IBMPlexSans-Medium-Latin1-b5610af0.woff2 +0 -0
- package/dist/assets/IBMPlexSans-Regular-Latin1-b5ad7bd3.woff2 +0 -0
- package/dist/assets/IBMPlexSans-SemiBold-Latin1-fff0ab3a.woff2 +0 -0
- package/dist/components/base/DFormFile.vue.d.ts +17 -0
- package/dist/components/base/DFormValidFeedback.vue.d.ts +12 -0
- package/dist/components/extended/DXDashboard.vue.d.ts +95 -111
- package/dist/components/extended/DXDashboardNavbar.vue.d.ts +2 -36
- package/dist/components/extended/DXDashboardSidebar.vue.d.ts +60 -79
- package/dist/components/extended/DXLoading.vue.d.ts +3 -0
- package/dist/components/extended/DXTable.vue.d.ts +10 -1
- package/dist/dashboard-for-laravel.js +9783 -9564
- package/dist/dashboard-for-laravel.js.map +1 -1
- package/dist/dashboard-for-laravel.umd.cjs +7 -7
- package/dist/dashboard-for-laravel.umd.cjs.map +1 -1
- package/dist/index.d.ts +3 -1
- package/dist/style.css +2 -2
- package/dist/types/navigation.d.ts +153 -3
- package/dist/utils/statCardFit.d.ts +19 -0
- package/docs/public/api-reference.json +50 -8
- package/docs/public/docs-map.md +4 -2
- package/docs/public/llms.txt +4 -2
- package/package.json +3 -2
- package/resources/css/theme.scss +384 -4
- package/resources/fonts/ibm-plex-sans/IBMPlexSans-Bold-Latin1.woff2 +0 -0
- package/resources/fonts/ibm-plex-sans/IBMPlexSans-Medium-Latin1.woff2 +0 -0
- package/resources/fonts/ibm-plex-sans/IBMPlexSans-Regular-Latin1.woff2 +0 -0
- package/resources/fonts/ibm-plex-sans/IBMPlexSans-SemiBold-Latin1.woff2 +0 -0
- package/resources/fonts/ibm-plex-sans/OFL.txt +93 -0
- package/resources/fonts/ibm-plex-sans/SOURCE.txt +18 -0
- package/resources/js/components/base/DFormFile.vue +103 -0
- package/resources/js/components/base/DFormValidFeedback.vue +16 -0
- package/resources/js/components/extended/DXDashboard.vue +41 -18
- package/resources/js/components/extended/DXDashboardNavbar.vue +9 -1
- package/resources/js/components/extended/DXDashboardSidebar.vue +25 -3
- package/resources/js/components/extended/DXField.vue +36 -8
- package/resources/js/components/extended/DXLoading.vue +18 -11
- package/resources/js/components/extended/DXStatCard.vue +154 -11
- package/resources/js/components/extended/DXTable.vue +60 -10
- package/resources/js/index.ts +9 -0
- package/resources/js/types/navigation.ts +175 -3
- package/resources/js/utils/statCardFit.ts +210 -0
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { BFormValidFeedback } from "bootstrap-vue-next";
|
|
3
|
+
|
|
4
|
+
defineOptions({
|
|
5
|
+
inheritAttrs: false,
|
|
6
|
+
});
|
|
7
|
+
</script>
|
|
8
|
+
|
|
9
|
+
<template>
|
|
10
|
+
<BFormValidFeedback v-bind="$attrs">
|
|
11
|
+
<!-- Dynamically pass through all named slots -->
|
|
12
|
+
<template v-for="(_, name) in $slots" :key="name" #[name]="slotProps">
|
|
13
|
+
<slot :name="name" v-bind="slotProps" />
|
|
14
|
+
</template>
|
|
15
|
+
</BFormValidFeedback>
|
|
16
|
+
</template>
|
|
@@ -113,17 +113,29 @@
|
|
|
113
113
|
</div>
|
|
114
114
|
</template>
|
|
115
115
|
|
|
116
|
-
<script setup lang="ts">
|
|
117
|
-
import { ref, computed,
|
|
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 {
|
|
125
|
-
|
|
126
|
-
|
|
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:
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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 {
|
|
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 {
|
|
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:
|
|
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>
|
|
@@ -405,7 +405,14 @@ import DXNumericField from "./DXNumericField.vue";
|
|
|
405
405
|
import DXChoiceField from "./DXChoiceField.vue";
|
|
406
406
|
import { useAsyncOptions } from "../../composables/useAsyncOptions";
|
|
407
407
|
import type { UseFormReturn } from "../../composables/useForm";
|
|
408
|
-
import type {
|
|
408
|
+
import type {
|
|
409
|
+
FieldDefinition,
|
|
410
|
+
FieldOption,
|
|
411
|
+
FieldType,
|
|
412
|
+
LabelCols,
|
|
413
|
+
MaybeFn,
|
|
414
|
+
} from "../../types";
|
|
415
|
+
import { humaniseErrorKey } from "../../utils/formErrorTargets";
|
|
409
416
|
import { getByPath, setByPath } from "../../utils/objectPath";
|
|
410
417
|
|
|
411
418
|
// Async to break the DXField <-> DXRepeater circular import.
|
|
@@ -619,6 +626,21 @@ function resolveMaybe<TValue>(
|
|
|
619
626
|
return value;
|
|
620
627
|
}
|
|
621
628
|
|
|
629
|
+
/** Resolve display TEXT (label, hint, info, switch text) the way
|
|
630
|
+
* resolveMaybe does, but a function that throws (a repeater row missing a
|
|
631
|
+
* property it reads) gives `undefined` instead of breaking the render (#204).
|
|
632
|
+
* Behaviour options (disabled, readonly, plaintext) stay unguarded: no
|
|
633
|
+
* fallback for those is safe to guess. */
|
|
634
|
+
function resolveText(
|
|
635
|
+
value: MaybeFn<string> | undefined,
|
|
636
|
+
): string | undefined {
|
|
637
|
+
try {
|
|
638
|
+
return resolveMaybe(value);
|
|
639
|
+
} catch {
|
|
640
|
+
return undefined;
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
|
|
622
644
|
const fieldValue = computed({
|
|
623
645
|
get: () =>
|
|
624
646
|
usePathSemantics.value
|
|
@@ -691,13 +713,19 @@ function setValue(value: any): void {
|
|
|
691
713
|
|
|
692
714
|
const fieldState = computed(() => props.form.getState(errorKey.value));
|
|
693
715
|
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
716
|
+
// A label function that throws falls back to the humanised key, as the
|
|
717
|
+
// repeater's headers and the error summary do; a missing label still shows
|
|
718
|
+
// the raw key, as it always has.
|
|
719
|
+
const resolvedLabel = computed(() => {
|
|
720
|
+
if (typeof props.field.label !== "function") {
|
|
721
|
+
return props.field.label ?? props.field.key;
|
|
722
|
+
}
|
|
723
|
+
return resolveText(props.field.label) ?? humaniseErrorKey(props.field.key);
|
|
724
|
+
});
|
|
697
725
|
|
|
698
|
-
const resolvedHint = computed(() =>
|
|
726
|
+
const resolvedHint = computed(() => resolveText(props.field.hint));
|
|
699
727
|
|
|
700
|
-
const resolvedInfo = computed(() =>
|
|
728
|
+
const resolvedInfo = computed(() => resolveText(props.field.info));
|
|
701
729
|
|
|
702
730
|
// ————————————————— switch-list (#160)
|
|
703
731
|
|
|
@@ -763,8 +791,8 @@ const switchModel = computed({
|
|
|
763
791
|
*/
|
|
764
792
|
const switchText = computed(() => {
|
|
765
793
|
const contextual = switchIsOn.value
|
|
766
|
-
?
|
|
767
|
-
:
|
|
794
|
+
? resolveText(props.field.textWhenTrue)
|
|
795
|
+
: resolveText(props.field.textWhenFalse);
|
|
768
796
|
return contextual ?? resolvedLabel.value;
|
|
769
797
|
});
|
|
770
798
|
|
|
@@ -1,12 +1,16 @@
|
|
|
1
1
|
<template>
|
|
2
|
+
<!-- The status box is there from the start, holding the loader's space
|
|
3
|
+
with its contents hidden (theme.scss); the delay only reveals them.
|
|
4
|
+
Content added to a live region already in the page is announced,
|
|
5
|
+
where a region inserted with its text can be missed. -->
|
|
2
6
|
<div
|
|
3
|
-
|
|
4
|
-
class="dx-loading
|
|
7
|
+
class="dx-loading py-5"
|
|
8
|
+
:class="{ 'dx-loading--shown': isVisible }"
|
|
5
9
|
role="status"
|
|
6
10
|
aria-live="polite"
|
|
7
11
|
>
|
|
8
|
-
<DSpinner
|
|
9
|
-
<
|
|
12
|
+
<DSpinner small aria-hidden="true" />
|
|
13
|
+
<p class="dx-loading__text">{{ text }}</p>
|
|
10
14
|
</div>
|
|
11
15
|
</template>
|
|
12
16
|
|
|
@@ -14,14 +18,20 @@
|
|
|
14
18
|
import { onBeforeUnmount, onMounted, ref } from "vue";
|
|
15
19
|
import DSpinner from "../base/DSpinner.vue";
|
|
16
20
|
|
|
21
|
+
// Styles (one line, grey, a slow sweep across the text, a fade-in, contents
|
|
22
|
+
// hidden until `.dx-loading--shown`) are global in theme.scss under
|
|
23
|
+
// `.dx-loading`, with `--dx-loading-*` tokens.
|
|
17
24
|
const props = withDefaults(
|
|
18
25
|
defineProps<{
|
|
19
26
|
text?: string;
|
|
27
|
+
/** Milliseconds to wait before showing anything. Most loads finish
|
|
28
|
+
* sooner, and one that has already taken this long is likely to take
|
|
29
|
+
* longer, so a quick load never flashes a loader. */
|
|
20
30
|
delay?: number;
|
|
21
31
|
}>(),
|
|
22
32
|
{
|
|
23
33
|
text: "Loading…",
|
|
24
|
-
delay:
|
|
34
|
+
delay: 600,
|
|
25
35
|
},
|
|
26
36
|
);
|
|
27
37
|
|
|
@@ -29,14 +39,11 @@ const isVisible = ref(false);
|
|
|
29
39
|
let showTimer: ReturnType<typeof setTimeout> | undefined;
|
|
30
40
|
|
|
31
41
|
onMounted(() => {
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
return;
|
|
35
|
-
}
|
|
36
|
-
|
|
42
|
+
// Always a timer, even for no delay: the empty status box must reach the
|
|
43
|
+
// page a task before its contents, or the announcement can be missed.
|
|
37
44
|
showTimer = setTimeout(() => {
|
|
38
45
|
isVisible.value = true;
|
|
39
|
-
}, props.delay);
|
|
46
|
+
}, Math.max(props.delay, 0));
|
|
40
47
|
});
|
|
41
48
|
|
|
42
49
|
onBeforeUnmount(() => {
|
|
@@ -17,15 +17,28 @@
|
|
|
17
17
|
</div>
|
|
18
18
|
</div>
|
|
19
19
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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;
|
|
@@ -35,10 +35,7 @@
|
|
|
35
35
|
{{ error || apiError }}
|
|
36
36
|
</div>
|
|
37
37
|
|
|
38
|
-
<
|
|
39
|
-
<DSpinner variant="primary" />
|
|
40
|
-
<p class="mt-2">{{ loadingText }}</p>
|
|
41
|
-
</div>
|
|
38
|
+
<DXLoading v-if="effectiveBusy && !isProviderMode" :text="loadingText" />
|
|
42
39
|
|
|
43
40
|
<!-- One table for all three data modes. The modes differ only
|
|
44
41
|
in a few bound props, supplied by `tableModeBindings`, so
|
|
@@ -50,7 +47,7 @@
|
|
|
50
47
|
v-else-if="isProviderMode || isClientSideMode || isInertiaMode"
|
|
51
48
|
ref="tableRef"
|
|
52
49
|
:key="activeMode + ':' + tableSlotSignature($slots)"
|
|
53
|
-
:fields="
|
|
50
|
+
:fields="renderedFields"
|
|
54
51
|
:sort-by="effectiveSortBy"
|
|
55
52
|
:multisort="false"
|
|
56
53
|
:no-sortable-icon="true"
|
|
@@ -248,8 +245,10 @@
|
|
|
248
245
|
PaginationData; `handleActivePageChange` routes the page
|
|
249
246
|
event to the right per-mode handler. Previously three
|
|
250
247
|
byte-identical copies, one per mode. -->
|
|
248
|
+
<!-- Not while the loader shows: the count would read "0 items"
|
|
249
|
+
before the rows have arrived. -->
|
|
251
250
|
<DXTablePagination
|
|
252
|
-
v-if="activePagination"
|
|
251
|
+
v-if="activePagination && !(effectiveBusy && !isProviderMode)"
|
|
253
252
|
:pagination="activePagination"
|
|
254
253
|
:per-page="effectivePerPage"
|
|
255
254
|
:per-page-options="perPageOptions"
|
|
@@ -326,7 +325,7 @@ import DContainer from "../base/DContainer.vue";
|
|
|
326
325
|
import DRow from "../base/DRow.vue";
|
|
327
326
|
import DCol from "../base/DCol.vue";
|
|
328
327
|
import DXTableShell from "./DXTableShell.vue";
|
|
329
|
-
import
|
|
328
|
+
import DXLoading from "./DXLoading.vue";
|
|
330
329
|
import DTable from "../base/DTable.vue";
|
|
331
330
|
import DFormInput from "../base/DFormInput.vue";
|
|
332
331
|
import DFormSelect from "../base/DFormSelect.vue";
|
|
@@ -343,7 +342,8 @@ import DXTableEditorModal from "./DXTableEditorModal.vue";
|
|
|
343
342
|
export type FilterType = 'text' | 'select' | 'select-native' | 'number' | 'date' | false;
|
|
344
343
|
|
|
345
344
|
export interface FilterOption {
|
|
346
|
-
|
|
345
|
+
/** Numbers and booleans are accepted and compared as their string form. */
|
|
346
|
+
value: string | number | boolean | null;
|
|
347
347
|
text: string;
|
|
348
348
|
}
|
|
349
349
|
|
|
@@ -452,6 +452,15 @@ export interface TableField {
|
|
|
452
452
|
* A number is pixels; a string is used verbatim.
|
|
453
453
|
*/
|
|
454
454
|
minWidth?: string | number;
|
|
455
|
+
|
|
456
|
+
/**
|
|
457
|
+
* Render this column's figures tabular (every digit the same width), so
|
|
458
|
+
* amounts line up down the column: adds the global `.dx-tabular` class to
|
|
459
|
+
* the column's body cells and its header cell. Composes with the field's
|
|
460
|
+
* own `tdClass`/`thClass` (string, array, object or `tdClass` function)
|
|
461
|
+
* rather than replacing them. Default `false`.
|
|
462
|
+
*/
|
|
463
|
+
tabular?: boolean;
|
|
455
464
|
[key: string]: any;
|
|
456
465
|
}
|
|
457
466
|
|
|
@@ -1382,6 +1391,35 @@ const columnStyleFor = (field: TableField): Record<string, string> | undefined =
|
|
|
1382
1391
|
// content-sized default byte-for-byte.
|
|
1383
1392
|
const fieldsHaveWidths = computed(() => props.fields.some(hasDeclaredWidth));
|
|
1384
1393
|
|
|
1394
|
+
/*
|
|
1395
|
+
* Tabular columns (#215). A `tabular: true` field gets `.dx-tabular` added
|
|
1396
|
+
* to its `tdClass` and `thClass`, appended to the consumer's own value
|
|
1397
|
+
* (wrapped in an array, or called through when `tdClass` is a function) so
|
|
1398
|
+
* their classes survive. Fields without the flag pass through as the same
|
|
1399
|
+
* objects, so a table using no tabular column renders exactly as before.
|
|
1400
|
+
*/
|
|
1401
|
+
const TABULAR_CLASS = 'dx-tabular';
|
|
1402
|
+
|
|
1403
|
+
const withTabularClass = (consumerClass: unknown): unknown[] =>
|
|
1404
|
+
consumerClass === undefined || consumerClass === null
|
|
1405
|
+
? [TABULAR_CLASS]
|
|
1406
|
+
: [consumerClass, TABULAR_CLASS];
|
|
1407
|
+
|
|
1408
|
+
const renderedFields = computed<TableField[]>(() =>
|
|
1409
|
+
props.fields.map((field) => {
|
|
1410
|
+
if (field.tabular !== true) return field;
|
|
1411
|
+
const consumerTdClass = field.tdClass;
|
|
1412
|
+
return {
|
|
1413
|
+
...field,
|
|
1414
|
+
tdClass:
|
|
1415
|
+
typeof consumerTdClass === 'function'
|
|
1416
|
+
? (...args: unknown[]) => withTabularClass(consumerTdClass(...args))
|
|
1417
|
+
: withTabularClass(consumerTdClass),
|
|
1418
|
+
thClass: withTabularClass(field.thClass),
|
|
1419
|
+
};
|
|
1420
|
+
}),
|
|
1421
|
+
);
|
|
1422
|
+
|
|
1385
1423
|
// Computed: Get effective filter options for a field
|
|
1386
1424
|
// The key a column's filter is SENT under, which is not always the column's own
|
|
1387
1425
|
// key: a display column can filter on a different server param via `filterKey`.
|
|
@@ -1582,9 +1620,21 @@ const getFieldFilterOptions = (field: TableField): FilterOption[] => {
|
|
|
1582
1620
|
options.push({ value: filterNullValueFor(field), text: field.filterNullText });
|
|
1583
1621
|
}
|
|
1584
1622
|
|
|
1585
|
-
// If field has static filterOptions, use those
|
|
1623
|
+
// If field has static filterOptions, use those. Their values are
|
|
1624
|
+
// stringified: options built from records carry numbers (ids) and booleans
|
|
1625
|
+
// (flags), but the filter map, the URL and the row match all speak strings,
|
|
1626
|
+
// and DAutocomplete matches its model to an option by strict equality — so a
|
|
1627
|
+
// raw `1` never re-selected the stored `'1'`, and picking it put a number in
|
|
1628
|
+
// the map that the active-filter check could not `.trim()`.
|
|
1586
1629
|
if (field.filterOptions && field.filterOptions.length > 0) {
|
|
1587
|
-
return [
|
|
1630
|
+
return [
|
|
1631
|
+
...options,
|
|
1632
|
+
...field.filterOptions.map((option) =>
|
|
1633
|
+
option.value === null || option.value === undefined
|
|
1634
|
+
? option
|
|
1635
|
+
: { ...option, value: String(option.value) },
|
|
1636
|
+
),
|
|
1637
|
+
];
|
|
1588
1638
|
}
|
|
1589
1639
|
|
|
1590
1640
|
// Otherwise, check for server-provided values (keyed by the filter key)
|