@omnitend/dashboard-for-laravel 0.33.1 → 0.35.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/charts.js +82 -90
- package/dist/charts.js.map +1 -1
- package/dist/charts.umd.cjs +1 -1
- package/dist/charts.umd.cjs.map +1 -1
- package/dist/components/base/DButton.vue.d.ts +1 -1
- package/dist/components/charts/DXBarChart.vue.d.ts +3 -18
- package/dist/components/charts/DXLineChart.vue.d.ts +3 -18
- package/dist/components/charts/useThemedChart.d.ts +35 -0
- package/dist/components/extended/DXChoiceField.vue.d.ts +26 -0
- package/dist/components/extended/DXDashboardSidebar.vue.d.ts +1 -1
- package/dist/components/extended/DXField.vue.d.ts +10 -113
- package/dist/components/extended/DXFieldShell.vue.d.ts +73 -0
- package/dist/components/extended/DXForm.vue.d.ts +28 -6
- package/dist/components/extended/DXNumericField.vue.d.ts +23 -0
- package/dist/components/extended/DXRepeater.vue.d.ts +4 -4
- package/dist/components/extended/DXRepeaterCards.vue.d.ts +45 -0
- package/dist/components/extended/DXTable.vue.d.ts +107 -2
- package/dist/components/extended/DXTableEditorModal.vue.d.ts +22 -2
- package/dist/components/extended/DXUserAvatar.vue.d.ts +1 -1
- package/dist/composables/useAsyncOptions.d.ts +20 -0
- package/dist/composables/useContainerWidth.d.ts +96 -0
- package/dist/composables/useResourceEditor.d.ts +2 -1
- package/dist/dashboard-for-laravel.js +10513 -10443
- 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 +5 -2
- package/dist/style.css +1 -1
- package/dist/types/index.d.ts +146 -0
- package/dist/utils/formSchema.d.ts +59 -0
- package/docs/public/api-reference.json +494 -33
- package/docs/public/docs-map.md +1 -1
- package/docs/public/llms.txt +5 -2
- package/package.json +1 -1
- package/resources/css/theme.scss +80 -2
- package/resources/js/components/charts/DXBarChart.vue +4 -43
- package/resources/js/components/charts/DXLineChart.vue +4 -43
- package/resources/js/components/charts/useThemedChart.ts +69 -0
- package/resources/js/components/extended/DXChoiceField.vue +114 -0
- package/resources/js/components/extended/DXDashboardSidebar.vue +1 -1
- package/resources/js/components/extended/DXField.vue +328 -615
- package/resources/js/components/extended/DXFieldShell.vue +124 -0
- package/resources/js/components/extended/DXForm.vue +92 -14
- package/resources/js/components/extended/DXNumericField.vue +144 -0
- package/resources/js/components/extended/DXRepeater.vue +53 -175
- package/resources/js/components/extended/DXRepeaterCards.vue +133 -0
- package/resources/js/components/extended/DXTable.vue +418 -43
- package/resources/js/components/extended/DXTableEditorModal.vue +39 -2
- package/resources/js/components/extended/DXTablePagination.vue +19 -3
- package/resources/js/components/extended/DXTableShell.vue +70 -1
- package/resources/js/composables/defineForm.ts +8 -44
- package/resources/js/composables/useAsyncOptions.ts +61 -0
- package/resources/js/composables/useContainerWidth.ts +303 -0
- package/resources/js/composables/useResourceEditor.ts +24 -38
- package/resources/js/index.ts +40 -0
- package/resources/js/types/index.ts +231 -0
- package/resources/js/utils/formSchema.ts +126 -0
|
@@ -47,6 +47,9 @@
|
|
|
47
47
|
:form="form"
|
|
48
48
|
:fields="fields"
|
|
49
49
|
:tabs="tabs"
|
|
50
|
+
:layout="layout"
|
|
51
|
+
:label-cols="labelCols"
|
|
52
|
+
:card="card"
|
|
50
53
|
:card-tabs="false"
|
|
51
54
|
:context="item ?? undefined"
|
|
52
55
|
:show-submit="false"
|
|
@@ -147,7 +150,7 @@
|
|
|
147
150
|
:disabled="form?.processing || loading"
|
|
148
151
|
@click="emit('delete')"
|
|
149
152
|
>
|
|
150
|
-
|
|
153
|
+
{{ deleteLabel }}
|
|
151
154
|
</DButton>
|
|
152
155
|
</div>
|
|
153
156
|
<div class="d-flex gap-2">
|
|
@@ -161,7 +164,7 @@
|
|
|
161
164
|
:disabled="form?.processing || loading"
|
|
162
165
|
@click="emit('save')"
|
|
163
166
|
>
|
|
164
|
-
{{ isCreateMode ?
|
|
167
|
+
{{ isCreateMode ? createLabel : saveLabel }}
|
|
165
168
|
</DButton>
|
|
166
169
|
</div>
|
|
167
170
|
</div>
|
|
@@ -176,6 +179,7 @@ import DButton from "../base/DButton.vue";
|
|
|
176
179
|
import DSpinner from "../base/DSpinner.vue";
|
|
177
180
|
import DXForm from "./DXForm.vue";
|
|
178
181
|
import type { EditTab } from "./DXTable.vue";
|
|
182
|
+
import type { LabelCols } from "../../types";
|
|
179
183
|
|
|
180
184
|
interface Props {
|
|
181
185
|
/** Whether the modal is open (v-model:show). */
|
|
@@ -204,10 +208,43 @@ interface Props {
|
|
|
204
208
|
pendingAction: 'save' | 'delete' | null;
|
|
205
209
|
/** Delete endpoint — gates the Delete button. */
|
|
206
210
|
deleteUrl?: string;
|
|
211
|
+
/**
|
|
212
|
+
* Field layout forwarded to the modal's DXForm (defaults handled upstream).
|
|
213
|
+
* Includes `"auto"`, which lets the form stack to vertical when the MODAL is
|
|
214
|
+
* narrow even though the viewport is wide — the container-query case a
|
|
215
|
+
* viewport media query cannot see.
|
|
216
|
+
*/
|
|
217
|
+
layout?: 'vertical' | 'horizontal' | 'auto';
|
|
218
|
+
/** Label column width forwarded to DXForm (horizontal layout). */
|
|
219
|
+
labelCols?: LabelCols;
|
|
220
|
+
/** Wrap the modal form in a card (opt-in — off by default). */
|
|
221
|
+
card?: boolean;
|
|
222
|
+
/** Singular item noun for the default button labels (e.g. "customer"). */
|
|
223
|
+
itemName?: string;
|
|
224
|
+
/** Override the Save button label (edit mode). */
|
|
225
|
+
saveText?: string;
|
|
226
|
+
/** Override the Create button label (create mode). */
|
|
227
|
+
createText?: string;
|
|
228
|
+
/** Override the Delete button label. */
|
|
229
|
+
deleteText?: string;
|
|
207
230
|
}
|
|
208
231
|
|
|
209
232
|
const props = defineProps<Props>();
|
|
210
233
|
|
|
234
|
+
// Title-case the singular item noun for button labels ("customer" → "Customer",
|
|
235
|
+
// "sales order" → "Sales Order") — capitalize the first letter of each word.
|
|
236
|
+
const capitalizedItem = computed(() => {
|
|
237
|
+
const noun = props.itemName?.trim() || 'item';
|
|
238
|
+
return noun.replace(/\b\w/g, (char) => char.toUpperCase());
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
// Buttons default to item-named copy ("Save Customer" / "Create Customer" /
|
|
242
|
+
// "Delete Customer") — clearer than a generic "Save Changes" — overridable per
|
|
243
|
+
// action. Falls back to "Save Item" etc. when no `itemName` is set.
|
|
244
|
+
const saveLabel = computed(() => props.saveText ?? `Save ${capitalizedItem.value}`);
|
|
245
|
+
const createLabel = computed(() => props.createText ?? `Create ${capitalizedItem.value}`);
|
|
246
|
+
const deleteLabel = computed(() => props.deleteText ?? `Delete ${capitalizedItem.value}`);
|
|
247
|
+
|
|
211
248
|
const emit = defineEmits<{
|
|
212
249
|
/** v-model:show — the modal open state changed. */
|
|
213
250
|
'update:show': [value: boolean];
|
|
@@ -135,7 +135,7 @@ const emit = defineEmits<{
|
|
|
135
135
|
// except a single-page gap which shows the page itself (an ellipsis hiding one
|
|
136
136
|
// page is silly). Client-computed from current + last page so it works in every
|
|
137
137
|
// DXTable mode (provider / client-side / inertia) — not just where a server
|
|
138
|
-
// `links` array exists. Reproduces
|
|
138
|
+
// `links` array exists. Reproduces the house style: `1 2 … 8 9 10 [11] 12 13 14 … 44 45`.
|
|
139
139
|
const BOUNDARY = 2;
|
|
140
140
|
const AROUND = 3;
|
|
141
141
|
|
|
@@ -192,9 +192,25 @@ const goToPage = (page: number) => {
|
|
|
192
192
|
</script>
|
|
193
193
|
|
|
194
194
|
<style scoped>
|
|
195
|
+
/*
|
|
196
|
+
In `DXTable`'s card mode the card is rendered `no-body` so the table can sit
|
|
197
|
+
flush against the card border, which makes this footer a direct child of
|
|
198
|
+
`.card` — it no longer inherits any `.card-body` padding. Re-instate the inset
|
|
199
|
+
the card body used to give it, so the pager lines up with the padded card
|
|
200
|
+
header above it and keeps a bottom margin inside the (clipped) card.
|
|
201
|
+
|
|
202
|
+
Top padding is deliberately 0: the `mt-3` on the root already separates the
|
|
203
|
+
footer from the table above. `.dx-table-card` is DXTableShell's element and
|
|
204
|
+
needs no scope id — a scoped selector only stamps its LAST compound selector,
|
|
205
|
+
which here is this component's own root.
|
|
206
|
+
*/
|
|
207
|
+
.dx-table-card > .dx-table-pagination {
|
|
208
|
+
padding: 0 var(--bs-card-spacer-x) var(--bs-card-spacer-y);
|
|
209
|
+
}
|
|
210
|
+
|
|
195
211
|
/* Windowed pager (#155). The buttons are DButtons; give the numbered ones a
|
|
196
|
-
consistent min-width so they read as an even row of keys (
|
|
197
|
-
|
|
212
|
+
consistent min-width so they read as an even row of keys (the house pager
|
|
213
|
+
style), and let the whole row wrap on narrow widths rather than
|
|
198
214
|
overflow. `:deep(.btn)` is anchored on the plain `.dx-pager` host, so the
|
|
199
215
|
scope-id has a deterministic element to attach to. */
|
|
200
216
|
.dx-pager :deep(.btn) {
|
|
@@ -9,9 +9,27 @@
|
|
|
9
9
|
- `#header` — the title / action row. In card mode it fills `DCard`'s header;
|
|
10
10
|
in plain mode it renders above the table with a subtle bottom rule.
|
|
11
11
|
- default — the table body (spinner/error/table/pagination).
|
|
12
|
+
|
|
13
|
+
Card mode renders `DCard` in **`no-body`** mode so the table sits FLUSH against
|
|
14
|
+
the card border. With a `.card-body` in between, a striped table's rows were
|
|
15
|
+
inset by `--bs-card-spacer-x` (24px) and left a white gutter between the
|
|
16
|
+
stripes and the border. Without it, DXTable's children — error alert, loading
|
|
17
|
+
block, table, pagination — become direct children of `.card`, which is already
|
|
18
|
+
a `flex-direction: column` container, so they still stack in order:
|
|
19
|
+
|
|
20
|
+
.card (overflow: hidden)
|
|
21
|
+
├─ .card-header (padded by Bootstrap's card-cap padding)
|
|
22
|
+
├─ .table-responsive ← FLUSH
|
|
23
|
+
│ └─ table
|
|
24
|
+
└─ .dx-table-pagination (pads itself — see DXTablePagination.vue)
|
|
25
|
+
|
|
26
|
+
Bootstrap 5 has no `.card > .table` rounding rules (that was BS4), so the
|
|
27
|
+
clipping is done with `overflow: hidden` on the card: the flush table's square
|
|
28
|
+
top corners then follow the card's radius whether or not a header is present.
|
|
29
|
+
bvn's popups position `fixed` by default, so they are not clipped by it.
|
|
12
30
|
-->
|
|
13
31
|
<template>
|
|
14
|
-
<DCard v-if="card">
|
|
32
|
+
<DCard v-if="card" no-body class="dx-table-card">
|
|
15
33
|
<template v-if="$slots.header" #header>
|
|
16
34
|
<slot name="header" />
|
|
17
35
|
</template>
|
|
@@ -52,3 +70,54 @@ withDefaults(defineProps<Props>(), {
|
|
|
52
70
|
border-bottom: 1px solid var(--bs-border-color);
|
|
53
71
|
}
|
|
54
72
|
</style>
|
|
73
|
+
|
|
74
|
+
<style>
|
|
75
|
+
/*
|
|
76
|
+
GLOBAL, not scoped, on purpose: these rules target slot content supplied by
|
|
77
|
+
`DXTable`, which is compiled in *DXTable's* scope. A scoped selector here would
|
|
78
|
+
get this component's `data-v-*` appended to its last compound selector and so
|
|
79
|
+
would match nothing — the same silent-inertness trap documented in CLAUDE.md.
|
|
80
|
+
Every selector is namespaced under `.dx-table-card`, a class only this
|
|
81
|
+
component applies, so nothing leaks into a consumer's own cards.
|
|
82
|
+
*/
|
|
83
|
+
|
|
84
|
+
/*
|
|
85
|
+
Clip the card to its radius so the flush table's square corners follow the
|
|
86
|
+
border. Kept in this global block with the rest of the card-mode layout: a
|
|
87
|
+
scoped rule WOULD reach DCard's root (the renderer walks the component-root
|
|
88
|
+
chain), but that forwarding is the exact thing CLAUDE.md records as unreliable
|
|
89
|
+
across build pipelines, and a silently-unclipped card is invisible in review.
|
|
90
|
+
The lower specificity also leaves consumers free to override it.
|
|
91
|
+
|
|
92
|
+
KNOWN LIMITATION (#166): this clips the WHOLE card, so a consumer's own
|
|
93
|
+
non-teleported `position: absolute` overlay in a slot is cut off at the card
|
|
94
|
+
edge. dfl's own components are fine — DDropdown teleports to <body>, the
|
|
95
|
+
filter menus are `fixed` — so the residual case is a hand-rolled popover.
|
|
96
|
+
The fix is to move the radius clipping onto the flush table region, which
|
|
97
|
+
`.table-responsive`'s `overflow-x: auto` complicates; see the issue.
|
|
98
|
+
*/
|
|
99
|
+
.dx-table-card {
|
|
100
|
+
overflow: hidden;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/*
|
|
104
|
+
The error alert is a full-width child of the card now that there is no
|
|
105
|
+
`.card-body`. Give it back the inset the card body used to provide, so it
|
|
106
|
+
doesn't bleed into the card's rounded corners.
|
|
107
|
+
*/
|
|
108
|
+
.dx-table-card > .alert {
|
|
109
|
+
margin: var(--bs-card-spacer-y) var(--bs-card-spacer-x);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/*
|
|
113
|
+
Nothing may leave a trailing margin at the bottom of the card. bvn already
|
|
114
|
+
zeroes `.table-responsive > .table`, but the responsive wrapper itself carries
|
|
115
|
+
`margin-bottom: 1rem` (and an un-responsive `.table` carries Bootstrap's own),
|
|
116
|
+
which used to be absorbed by the card body and now shows as a white band
|
|
117
|
+
inside the clipped card whenever the table is the last child (no pager). The
|
|
118
|
+
gap ABOVE the pager is unaffected — that margin only drops on the last child.
|
|
119
|
+
*/
|
|
120
|
+
.dx-table-card > :last-child {
|
|
121
|
+
margin-bottom: 0;
|
|
122
|
+
}
|
|
123
|
+
</style>
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { useForm, type UseFormReturn } from "./useForm";
|
|
2
|
-
import type { FieldDefinition
|
|
2
|
+
import type { FieldDefinition } from "../types";
|
|
3
|
+
import { resolveFieldDefault } from "../utils/formSchema";
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* Extended field definition that includes default value
|
|
@@ -30,35 +31,6 @@ export interface DefineFormReturn<TData extends Record<string, any>> {
|
|
|
30
31
|
fields: FieldDefinition[];
|
|
31
32
|
}
|
|
32
33
|
|
|
33
|
-
/**
|
|
34
|
-
* Helper to get default value for a field type
|
|
35
|
-
*/
|
|
36
|
-
function getDefaultValueForType(type: FieldType): any {
|
|
37
|
-
switch (type) {
|
|
38
|
-
case "checkbox":
|
|
39
|
-
case "switch":
|
|
40
|
-
return false;
|
|
41
|
-
case "number":
|
|
42
|
-
case "currency":
|
|
43
|
-
case "percentage":
|
|
44
|
-
return 0;
|
|
45
|
-
case "repeater":
|
|
46
|
-
// Array-valued selections: checkbox-group previously fell through to
|
|
47
|
-
// the "" default, which is the wrong shape for an array model.
|
|
48
|
-
case "checkbox-group":
|
|
49
|
-
case "switch-list":
|
|
50
|
-
return [];
|
|
51
|
-
case "image":
|
|
52
|
-
case "file":
|
|
53
|
-
return null;
|
|
54
|
-
case "select":
|
|
55
|
-
case "radio":
|
|
56
|
-
return "";
|
|
57
|
-
default:
|
|
58
|
-
return "";
|
|
59
|
-
}
|
|
60
|
-
}
|
|
61
|
-
|
|
62
34
|
/**
|
|
63
35
|
* Define a form with type-safe field definitions.
|
|
64
36
|
* Reduces duplication by inferring initial values from field definitions.
|
|
@@ -75,22 +47,14 @@ export function defineForm<const TFields extends readonly FormFieldDefinition[]>
|
|
|
75
47
|
): DefineFormReturn<InferFormData<TFields>> {
|
|
76
48
|
type FormData = InferFormData<TFields>;
|
|
77
49
|
|
|
78
|
-
// Extract initial data from field definitions.
|
|
79
|
-
//
|
|
80
|
-
//
|
|
81
|
-
//
|
|
82
|
-
//
|
|
83
|
-
// and rendered blank. This is the same bug #122 fixed in DXTable. Decide on
|
|
84
|
-
// definedness, not nullishness, so null survives; an absent (or explicitly
|
|
85
|
-
// `undefined`) default still falls back to the type default rather than
|
|
86
|
-
// seeding `undefined`, which would drop the key from the JSON payload. This
|
|
87
|
-
// matches DXRepeater's `!== undefined` rule, so all seeding sites agree.
|
|
50
|
+
// Extract initial data from field definitions. Seeding (definedness over
|
|
51
|
+
// nullishness so a `null` default survives — #122/#125 — plus a type-aware
|
|
52
|
+
// fallback and deep-cloning) is the shared `resolveFieldDefault` rule, so
|
|
53
|
+
// every seeding site (defineForm / useResourceEditor / DXRepeater) agrees
|
|
54
|
+
// by construction (#134).
|
|
88
55
|
const initialData = fieldDefinitions.reduce(
|
|
89
56
|
(acc, field) => {
|
|
90
|
-
acc[field.key] =
|
|
91
|
-
field.default !== undefined
|
|
92
|
-
? field.default
|
|
93
|
-
: getDefaultValueForType(field.type);
|
|
57
|
+
acc[field.key] = resolveFieldDefault(field);
|
|
94
58
|
return acc;
|
|
95
59
|
},
|
|
96
60
|
{} as Record<string, any>,
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { onMounted, ref, watch, type ComputedRef, type Ref } from "vue";
|
|
2
|
+
import type { FieldDefinition, FieldOption } from "../types";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Async option loading for a `DXField`: runs `field.optionsLoader` on mount and
|
|
6
|
+
* (when `field.reloadOptionsOnChange` is set) whenever the model changes,
|
|
7
|
+
* exposing the loaded options. A monotonic request token guards against
|
|
8
|
+
* out-of-order responses clobbering newer ones; loader failures are swallowed
|
|
9
|
+
* so the last good options (or the static `field.options` fallback) stay put.
|
|
10
|
+
*
|
|
11
|
+
* Extracted verbatim from DXField's inline logic (#135) — behaviour unchanged.
|
|
12
|
+
* `getField` is an accessor (props aren't destructurable without losing
|
|
13
|
+
* reactivity); `effectiveModel` is the model passed to the loader.
|
|
14
|
+
*/
|
|
15
|
+
export interface UseAsyncOptionsReturn {
|
|
16
|
+
/** Options returned by the most recent successful load, or null if none yet. */
|
|
17
|
+
loadedOptions: Ref<FieldOption[] | null>;
|
|
18
|
+
/** Trigger a load (no-op when the field has no `optionsLoader`). */
|
|
19
|
+
loadOptions: () => Promise<void>;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function useAsyncOptions(
|
|
23
|
+
getField: () => FieldDefinition,
|
|
24
|
+
effectiveModel: ComputedRef<any> | Ref<any>,
|
|
25
|
+
): UseAsyncOptionsReturn {
|
|
26
|
+
const loadedOptions = ref<FieldOption[] | null>(null);
|
|
27
|
+
|
|
28
|
+
// Monotonic token so out-of-order async responses can't clobber newer ones.
|
|
29
|
+
let optionsRequestToken = 0;
|
|
30
|
+
|
|
31
|
+
async function loadOptions(): Promise<void> {
|
|
32
|
+
const field = getField();
|
|
33
|
+
if (!field.optionsLoader) return;
|
|
34
|
+
const token = ++optionsRequestToken;
|
|
35
|
+
try {
|
|
36
|
+
const options = await field.optionsLoader(effectiveModel.value);
|
|
37
|
+
// Ignore a stale response superseded by a newer load.
|
|
38
|
+
if (token === optionsRequestToken) loadedOptions.value = options;
|
|
39
|
+
} catch (error) {
|
|
40
|
+
// Swallow loader failures: keep the last successfully loaded options
|
|
41
|
+
// (or the static `field.options` fallback when none have loaded yet).
|
|
42
|
+
if (token === optionsRequestToken) {
|
|
43
|
+
// eslint-disable-next-line no-console
|
|
44
|
+
console.error(
|
|
45
|
+
`optionsLoader failed for field "${field.key}"`,
|
|
46
|
+
error,
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
onMounted(() => {
|
|
53
|
+
void loadOptions();
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
if (getField().reloadOptionsOnChange) {
|
|
57
|
+
watch(effectiveModel, () => void loadOptions(), { deep: true });
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return { loadedOptions, loadOptions };
|
|
61
|
+
}
|
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useContainerWidth — container-query-style responsiveness for Vue components.
|
|
3
|
+
*
|
|
4
|
+
* Bootstrap's breakpoints are **media** queries: they only ever see the WINDOW.
|
|
5
|
+
* A dashboard page is narrowed by the persistent sidebar (and a form inside a
|
|
6
|
+
* modal is narrower still), so a component can have very little room while the
|
|
7
|
+
* viewport is wide — and no media query ever fires. This composable closes that
|
|
8
|
+
* gap by observing an element's **own rendered width** with a `ResizeObserver`
|
|
9
|
+
* and exposing it reactively, so the template can pick a layout from the space
|
|
10
|
+
* it actually has.
|
|
11
|
+
*
|
|
12
|
+
* ```ts
|
|
13
|
+
* const { containerRef, width, isBelow, isNarrowerThan } = useContainerWidth({
|
|
14
|
+
* threshold: 640,
|
|
15
|
+
* });
|
|
16
|
+
* // <div ref="containerRef"> … </div>
|
|
17
|
+
* ```
|
|
18
|
+
*
|
|
19
|
+
* One observer can drive several independent decisions — `isBelow` uses the
|
|
20
|
+
* configured `threshold`, and `isNarrowerThan(n)` answers for any other width:
|
|
21
|
+
*
|
|
22
|
+
* ```ts
|
|
23
|
+
* const stacked = computed(() => isNarrowerThan(576));
|
|
24
|
+
* const hideDetailColumn = computed(() => isNarrowerThan(900));
|
|
25
|
+
* ```
|
|
26
|
+
*/
|
|
27
|
+
import {
|
|
28
|
+
getCurrentScope,
|
|
29
|
+
onScopeDispose,
|
|
30
|
+
ref,
|
|
31
|
+
watch,
|
|
32
|
+
type Ref,
|
|
33
|
+
} from "vue";
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* What to observe: a template ref, or a getter (so the element can be resolved
|
|
37
|
+
* lazily / conditionally — e.g. `() => enabled ? el.value : null` to observe
|
|
38
|
+
* nothing at all).
|
|
39
|
+
*/
|
|
40
|
+
export type ContainerWidthTarget =
|
|
41
|
+
| Ref<HTMLElement | null | undefined>
|
|
42
|
+
| (() => HTMLElement | null | undefined);
|
|
43
|
+
|
|
44
|
+
export interface UseContainerWidthOptions {
|
|
45
|
+
/**
|
|
46
|
+
* Width (px) that `isBelow` compares against. Omit if you only need
|
|
47
|
+
* `width` / `isNarrowerThan`. Pass a getter (`() => props.threshold`) when
|
|
48
|
+
* it can change at runtime — `isBelow` re-evaluates when it does.
|
|
49
|
+
*/
|
|
50
|
+
threshold?: number | (() => number | undefined);
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Hysteresis band (px) applied to `isBelow` only, to stop a layout flipping
|
|
54
|
+
* back and forth at the boundary. Once `isBelow` is true it stays true
|
|
55
|
+
* until the width reaches `threshold + hysteresis`.
|
|
56
|
+
*
|
|
57
|
+
* Defaults to `0` so `isBelow` means exactly `width < threshold` unless you
|
|
58
|
+
* ask for otherwise. Opt in whenever the layout you drive from it can
|
|
59
|
+
* change the observed width — the classic loop is: narrow layout is taller
|
|
60
|
+
* → an ancestor with `overflow:auto` gains a vertical scrollbar → the
|
|
61
|
+
* container loses ~15-17px → it crosses back over the threshold → repeat.
|
|
62
|
+
* A band wider than a scrollbar (24px is comfortable) breaks that cycle.
|
|
63
|
+
* (`isNarrowerThan` is deliberately raw — it holds no per-width state.)
|
|
64
|
+
*/
|
|
65
|
+
hysteresis?: number;
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Width assumed before the first measurement, and permanently where there
|
|
69
|
+
* is no `ResizeObserver` (SSR, or a very old browser).
|
|
70
|
+
*
|
|
71
|
+
* Defaults to `0` — i.e. "assume the narrowest until proven otherwise", so
|
|
72
|
+
* an unmeasured render picks the STACKED layout. That is the mobile-first
|
|
73
|
+
* choice and the safe one: a stacked layout is legible at every width,
|
|
74
|
+
* whereas a wide layout guessed wrongly truncates content. It also means
|
|
75
|
+
* the server-rendered markup matches the first client render.
|
|
76
|
+
*/
|
|
77
|
+
initialWidth?: number;
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Which box to report. `content-box` (default) is the space actually
|
|
81
|
+
* available to children — what a layout decision usually wants.
|
|
82
|
+
* `border-box` matches `getBoundingClientRect().width`.
|
|
83
|
+
*/
|
|
84
|
+
box?: "content-box" | "border-box";
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Extra debounce (ms) before publishing a new width. Defaults to `0`:
|
|
88
|
+
* `ResizeObserver` already batches to one delivery per frame, and the
|
|
89
|
+
* callback reads the size the observer *hands* it (no forced layout), so
|
|
90
|
+
* there is nothing to thrash. Set it only for genuinely expensive
|
|
91
|
+
* downstream work — a timer adds a frame of visible lag.
|
|
92
|
+
*/
|
|
93
|
+
debounce?: number;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export interface UseContainerWidthReturn {
|
|
97
|
+
/** Bind to the element you want measured (`<div ref="containerRef">`). */
|
|
98
|
+
containerRef: Ref<HTMLElement | null>;
|
|
99
|
+
/** The observed width in px. `initialWidth` until first measured. */
|
|
100
|
+
width: Readonly<Ref<number>>;
|
|
101
|
+
/** False until a real measurement has landed (always false without a `ResizeObserver`). */
|
|
102
|
+
hasMeasured: Readonly<Ref<boolean>>;
|
|
103
|
+
/** `width < threshold`, with `hysteresis` applied. False when no `threshold` was given. */
|
|
104
|
+
isBelow: Readonly<Ref<boolean>>;
|
|
105
|
+
/** Raw `width < px`, for driving other decisions off the same observer. */
|
|
106
|
+
isNarrowerThan: (px: number) => boolean;
|
|
107
|
+
/** Disconnect early. Called automatically when the owning scope is disposed. */
|
|
108
|
+
stop: () => void;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function useContainerWidth(
|
|
112
|
+
options?: UseContainerWidthOptions,
|
|
113
|
+
): UseContainerWidthReturn;
|
|
114
|
+
export function useContainerWidth(
|
|
115
|
+
target: ContainerWidthTarget,
|
|
116
|
+
options?: UseContainerWidthOptions,
|
|
117
|
+
): UseContainerWidthReturn;
|
|
118
|
+
export function useContainerWidth(
|
|
119
|
+
targetOrOptions?: ContainerWidthTarget | UseContainerWidthOptions,
|
|
120
|
+
maybeOptions?: UseContainerWidthOptions,
|
|
121
|
+
): UseContainerWidthReturn {
|
|
122
|
+
// Disambiguate the two overloads: a ref (has `.value`) or a function is a
|
|
123
|
+
// target; anything else is the options object.
|
|
124
|
+
const targetGiven =
|
|
125
|
+
typeof targetOrOptions === "function" ||
|
|
126
|
+
(targetOrOptions !== null &&
|
|
127
|
+
targetOrOptions !== undefined &&
|
|
128
|
+
typeof targetOrOptions === "object" &&
|
|
129
|
+
"value" in targetOrOptions);
|
|
130
|
+
|
|
131
|
+
const target = targetGiven
|
|
132
|
+
? (targetOrOptions as ContainerWidthTarget)
|
|
133
|
+
: undefined;
|
|
134
|
+
const options =
|
|
135
|
+
(targetGiven
|
|
136
|
+
? maybeOptions
|
|
137
|
+
: (targetOrOptions as UseContainerWidthOptions | undefined)) ?? {};
|
|
138
|
+
|
|
139
|
+
const {
|
|
140
|
+
threshold,
|
|
141
|
+
hysteresis = 0,
|
|
142
|
+
initialWidth = 0,
|
|
143
|
+
box = "content-box",
|
|
144
|
+
debounce = 0,
|
|
145
|
+
} = options;
|
|
146
|
+
|
|
147
|
+
/** Returned for the common `<div ref="containerRef">` case. */
|
|
148
|
+
const containerRef = ref<HTMLElement | null>(null);
|
|
149
|
+
|
|
150
|
+
const resolveTarget = (): HTMLElement | null => {
|
|
151
|
+
if (target === undefined) return containerRef.value;
|
|
152
|
+
if (typeof target === "function") return target() ?? null;
|
|
153
|
+
return target.value ?? null;
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
const startWidth = Math.round(initialWidth);
|
|
157
|
+
const width = ref<number>(startWidth);
|
|
158
|
+
const hasMeasured = ref<boolean>(false);
|
|
159
|
+
|
|
160
|
+
let observer: ResizeObserver | null = null;
|
|
161
|
+
let observedElement: HTMLElement | null = null;
|
|
162
|
+
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
|
163
|
+
|
|
164
|
+
function publish(nextWidth: number): void {
|
|
165
|
+
// Sub-pixel churn (a 0.3px reflow) must not wake every dependent
|
|
166
|
+
// computed, so publish at whole-pixel resolution and skip no-ops.
|
|
167
|
+
const rounded = Math.round(nextWidth);
|
|
168
|
+
if (!hasMeasured.value) hasMeasured.value = true;
|
|
169
|
+
if (rounded !== width.value) width.value = rounded;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function schedule(nextWidth: number): void {
|
|
173
|
+
if (debounce <= 0) {
|
|
174
|
+
publish(nextWidth);
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
if (debounceTimer !== null) clearTimeout(debounceTimer);
|
|
178
|
+
debounceTimer = setTimeout(() => {
|
|
179
|
+
debounceTimer = null;
|
|
180
|
+
publish(nextWidth);
|
|
181
|
+
}, debounce);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function measure(entry: ResizeObserverEntry): number {
|
|
185
|
+
if (box === "border-box") {
|
|
186
|
+
// borderBoxSize is an array in the spec (fragmented inline boxes);
|
|
187
|
+
// some engines shipped a bare object first, hence the shape check.
|
|
188
|
+
const sizes = entry.borderBoxSize as unknown;
|
|
189
|
+
if (Array.isArray(sizes) && sizes.length > 0) {
|
|
190
|
+
return sizes[0].inlineSize;
|
|
191
|
+
}
|
|
192
|
+
if (
|
|
193
|
+
sizes !== null &&
|
|
194
|
+
sizes !== undefined &&
|
|
195
|
+
typeof sizes === "object" &&
|
|
196
|
+
"inlineSize" in (sizes as ResizeObserverSize)
|
|
197
|
+
) {
|
|
198
|
+
return (sizes as ResizeObserverSize).inlineSize;
|
|
199
|
+
}
|
|
200
|
+
// No borderBoxSize: fall back to the element's own box.
|
|
201
|
+
return (entry.target as HTMLElement).getBoundingClientRect().width;
|
|
202
|
+
}
|
|
203
|
+
return entry.contentRect.width;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// `stop()` is final: it also tears down the watcher that re-observes when
|
|
207
|
+
// the target changes, so a later ref change cannot silently restart a
|
|
208
|
+
// stopped observer.
|
|
209
|
+
let stopTargetWatch: (() => void) | null = null;
|
|
210
|
+
let stopped = false;
|
|
211
|
+
|
|
212
|
+
function stop(): void {
|
|
213
|
+
stopped = true;
|
|
214
|
+
if (stopTargetWatch !== null) {
|
|
215
|
+
stopTargetWatch();
|
|
216
|
+
stopTargetWatch = null;
|
|
217
|
+
}
|
|
218
|
+
if (debounceTimer !== null) {
|
|
219
|
+
clearTimeout(debounceTimer);
|
|
220
|
+
debounceTimer = null;
|
|
221
|
+
}
|
|
222
|
+
if (observer !== null) {
|
|
223
|
+
observer.disconnect();
|
|
224
|
+
observer = null;
|
|
225
|
+
}
|
|
226
|
+
observedElement = null;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* SSR guard: `ResizeObserver` (and `window`) do not exist while the docs
|
|
231
|
+
* site server-renders these components. We stay at `initialWidth`, so the
|
|
232
|
+
* markup is the stacked/narrow one — see `initialWidth` above.
|
|
233
|
+
*/
|
|
234
|
+
const canObserve =
|
|
235
|
+
typeof window !== "undefined" && typeof ResizeObserver !== "undefined";
|
|
236
|
+
|
|
237
|
+
function observe(element: HTMLElement | null): void {
|
|
238
|
+
if (stopped) return;
|
|
239
|
+
if (element === observedElement) return;
|
|
240
|
+
if (!canObserve) return;
|
|
241
|
+
|
|
242
|
+
if (observer === null) {
|
|
243
|
+
observer = new ResizeObserver((entries) => {
|
|
244
|
+
// Only the last entry matters — a single element is observed and
|
|
245
|
+
// the observer coalesces to one delivery per frame.
|
|
246
|
+
const entry = entries[entries.length - 1];
|
|
247
|
+
if (entry === undefined) return;
|
|
248
|
+
schedule(measure(entry));
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
if (observedElement !== null) observer.unobserve(observedElement);
|
|
253
|
+
observedElement = element;
|
|
254
|
+
if (element !== null) observer.observe(element, { box });
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// `post` flush so the element exists when a v-if/v-for swaps the target.
|
|
258
|
+
stopTargetWatch = watch(resolveTarget, observe, {
|
|
259
|
+
immediate: true,
|
|
260
|
+
flush: "post",
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
if (getCurrentScope() !== undefined) onScopeDispose(stop);
|
|
264
|
+
|
|
265
|
+
const resolveThreshold = (): number | undefined =>
|
|
266
|
+
typeof threshold === "function" ? threshold() : threshold;
|
|
267
|
+
|
|
268
|
+
// `isBelow` is a latched ref rather than a computed BECAUSE of hysteresis:
|
|
269
|
+
// the next answer depends on the current one, which a pure getter cannot
|
|
270
|
+
// express without mutating during evaluation. With `hysteresis: 0` it is
|
|
271
|
+
// exactly `width < threshold`. The watcher is `sync` so `isBelow` is never
|
|
272
|
+
// observed one tick behind `width`.
|
|
273
|
+
const initialThreshold = resolveThreshold();
|
|
274
|
+
const isBelow = ref<boolean>(
|
|
275
|
+
initialThreshold === undefined ? false : startWidth < initialThreshold,
|
|
276
|
+
);
|
|
277
|
+
watch(
|
|
278
|
+
[width, () => resolveThreshold()],
|
|
279
|
+
([currentWidth, currentThreshold]) => {
|
|
280
|
+
if (currentThreshold === undefined) {
|
|
281
|
+
isBelow.value = false;
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
isBelow.value = isBelow.value
|
|
285
|
+
? currentWidth < currentThreshold + hysteresis
|
|
286
|
+
: currentWidth < currentThreshold;
|
|
287
|
+
},
|
|
288
|
+
{ flush: "sync" },
|
|
289
|
+
);
|
|
290
|
+
|
|
291
|
+
function isNarrowerThan(px: number): boolean {
|
|
292
|
+
return width.value < px;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
return {
|
|
296
|
+
containerRef,
|
|
297
|
+
width,
|
|
298
|
+
hasMeasured,
|
|
299
|
+
isBelow,
|
|
300
|
+
isNarrowerThan,
|
|
301
|
+
stop,
|
|
302
|
+
};
|
|
303
|
+
}
|