@omnitend/dashboard-for-laravel 0.21.0 → 0.23.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/components/extended/DXDashboard.vue.d.ts +26 -5
- package/dist/components/extended/DXDashboardNavbar.vue.d.ts +23 -2
- package/dist/components/extended/DXField.vue.d.ts +4 -4
- package/dist/components/extended/DXTable.vue.d.ts +37 -2
- package/dist/components/extended/DXUserAvatar.vue.d.ts +40 -0
- package/dist/dashboard-for-laravel.js +8029 -7902
- package/dist/dashboard-for-laravel.js.map +1 -1
- package/dist/dashboard-for-laravel.umd.cjs +8 -8
- package/dist/dashboard-for-laravel.umd.cjs.map +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/style.css +1 -1
- package/dist/types/index.d.ts +23 -0
- package/dist/types/navigation.d.ts +13 -0
- package/docs/public/api-reference.json +164 -16
- package/docs/public/docs-map.md +3 -2
- package/docs/public/llms.txt +3 -2
- package/package.json +4 -1
- package/resources/css/theme.scss +17 -2
- package/resources/js/components/extended/DXDashboard.vue +61 -11
- package/resources/js/components/extended/DXDashboardNavbar.vue +108 -43
- package/resources/js/components/extended/DXField.vue +122 -26
- package/resources/js/components/extended/DXTable.vue +304 -133
- package/resources/js/components/extended/DXUserAvatar.vue +104 -0
- package/resources/js/index.ts +3 -0
- package/resources/js/types/index.ts +25 -0
- package/resources/js/types/navigation.ts +15 -0
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
<!--
|
|
2
2
|
@component
|
|
3
|
-
Top navigation bar for the dashboard shell: menu toggle, page title,
|
|
4
|
-
|
|
5
|
-
`DXDashboard`, which forwards these slots
|
|
3
|
+
Top navigation bar for the dashboard shell: menu toggle, page title, search
|
|
4
|
+
(aligned per `searchAlign`, left by default), page-level actions, and the
|
|
5
|
+
user menu. Usually rendered by `DXDashboard`, which forwards these slots
|
|
6
|
+
with a `navbar-` prefix.
|
|
6
7
|
-->
|
|
7
8
|
<template>
|
|
8
9
|
<header class="dashboard-navbar border-bottom">
|
|
@@ -39,25 +40,35 @@
|
|
|
39
40
|
|
|
40
41
|
<div
|
|
41
42
|
v-if="$slots.search"
|
|
42
|
-
class="dashboard-navbar__search d-flex
|
|
43
|
+
class="dashboard-navbar__search d-flex"
|
|
44
|
+
:class="searchAlignClass"
|
|
43
45
|
>
|
|
44
|
-
<!-- @slot Search input or component.
|
|
46
|
+
<!-- @slot Search input or component. Aligned per `searchAlign` within its region (inline in the bar from `md` up; its own full-width row below). -->
|
|
45
47
|
<slot name="search" />
|
|
46
48
|
</div>
|
|
47
49
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
50
|
+
<!-- Page-level primary actions: right-aligned next to the user menu
|
|
51
|
+
from `md` up; their own wrappable full-width row below (#93),
|
|
52
|
+
or hidden below `md` when `actionsOnMobile` is "hide". -->
|
|
53
|
+
<div
|
|
54
|
+
v-if="$slots.actions"
|
|
55
|
+
class="dashboard-navbar__actions align-items-center gap-2"
|
|
56
|
+
:class="actionsDisplayClass"
|
|
57
|
+
>
|
|
58
|
+
<!--
|
|
59
|
+
@slot Page-level primary actions (e.g. a Create button). Right-aligned next to the user menu from `md` up; below, wraps to its own full-width row or is hidden entirely per `actionsOnMobile`.
|
|
60
|
+
@binding {string} pageTitle The current page title, for context.
|
|
61
|
+
-->
|
|
62
|
+
<slot name="actions" :pageTitle="pageTitle" />
|
|
63
|
+
</div>
|
|
60
64
|
|
|
65
|
+
<!-- Only render the user-menu cluster when it has content: an empty
|
|
66
|
+
flex item would still cost a bar gap, pushing actions off the
|
|
67
|
+
right edge in guest (user-less) layouts. -->
|
|
68
|
+
<div
|
|
69
|
+
v-if="$slots['user-menu'] || user"
|
|
70
|
+
class="dashboard-navbar__end d-flex align-items-center gap-3"
|
|
71
|
+
>
|
|
61
72
|
<!--
|
|
62
73
|
@slot Replaces the entire user menu (the default avatar dropdown).
|
|
63
74
|
@binding {object} user The signed-in user.
|
|
@@ -68,17 +79,17 @@
|
|
|
68
79
|
variant="link"
|
|
69
80
|
class="text-dark"
|
|
70
81
|
menu-class="dropdown-menu-end"
|
|
82
|
+
toggle-class="dashboard-navbar__user-menu-toggle"
|
|
71
83
|
no-caret
|
|
72
84
|
>
|
|
73
85
|
<template #button-content>
|
|
74
86
|
<!--
|
|
75
|
-
@slot Custom user avatar/icon in the dropdown trigger.
|
|
87
|
+
@slot Custom user avatar/icon in the dropdown trigger. To decorate the default avatar (e.g. add a notification dot) rather than replace it, render `DXUserAvatar` — the same component used here, so the styling comes with it.
|
|
76
88
|
@binding {string} initial The first letter of the user's name.
|
|
89
|
+
@binding {object} user The signed-in user.
|
|
77
90
|
-->
|
|
78
|
-
<slot name="user-icon" :initial="getUserInitial(user)">
|
|
79
|
-
<
|
|
80
|
-
{{ getUserInitial(user) }}
|
|
81
|
-
</div>
|
|
91
|
+
<slot name="user-icon" :initial="getUserInitial(user)" :user="user">
|
|
92
|
+
<DXUserAvatar :user="user" />
|
|
82
93
|
</slot>
|
|
83
94
|
</template>
|
|
84
95
|
|
|
@@ -96,11 +107,14 @@
|
|
|
96
107
|
</template>
|
|
97
108
|
|
|
98
109
|
<script setup lang="ts">
|
|
110
|
+
import { computed } from "vue";
|
|
99
111
|
import DContainer from "../base/DContainer.vue";
|
|
100
112
|
import DButton from "../base/DButton.vue";
|
|
101
113
|
import DDropdown from "../base/DDropdown.vue";
|
|
114
|
+
import DXUserAvatar from "./DXUserAvatar.vue";
|
|
115
|
+
import type { NavbarActionsOnMobile, NavbarSearchAlign } from "../../types/navigation";
|
|
102
116
|
|
|
103
|
-
withDefaults(
|
|
117
|
+
const props = withDefaults(
|
|
104
118
|
defineProps<{
|
|
105
119
|
/** The signed-in user shown in the avatar dropdown. `null` hides the menu. */
|
|
106
120
|
user?: {
|
|
@@ -110,13 +124,53 @@ withDefaults(
|
|
|
110
124
|
} | null;
|
|
111
125
|
/** Page title shown at the left of the navbar (hidden below the `md` breakpoint). */
|
|
112
126
|
pageTitle?: string;
|
|
127
|
+
/**
|
|
128
|
+
* Horizontal alignment of the search slot content within its region
|
|
129
|
+
* (`"start"` = flush left, `"center"` = centred). Applies at every size:
|
|
130
|
+
* the region sits inline after the title from `md` up, and is its own
|
|
131
|
+
* full-width row below.
|
|
132
|
+
*/
|
|
133
|
+
searchAlign?: NavbarSearchAlign;
|
|
134
|
+
/**
|
|
135
|
+
* What the actions slot does below the `md` breakpoint: `"wrap"` moves it
|
|
136
|
+
* to its own full-width row (the bar grows), `"hide"` removes it entirely
|
|
137
|
+
* (for apps that relocate page actions into the page on phones).
|
|
138
|
+
*/
|
|
139
|
+
actionsOnMobile?: NavbarActionsOnMobile;
|
|
113
140
|
}>(),
|
|
114
141
|
{
|
|
115
142
|
user: null,
|
|
116
143
|
pageTitle: "",
|
|
144
|
+
searchAlign: "start",
|
|
145
|
+
actionsOnMobile: "wrap",
|
|
117
146
|
},
|
|
118
147
|
);
|
|
119
148
|
|
|
149
|
+
// Explicit map (not string interpolation) so an out-of-union value at runtime
|
|
150
|
+
// falls back to a valid class instead of a silent Bootstrap no-op, and the
|
|
151
|
+
// full class names stay greppable.
|
|
152
|
+
const SEARCH_ALIGN_CLASSES: Record<NavbarSearchAlign, string> = {
|
|
153
|
+
start: "justify-content-start",
|
|
154
|
+
center: "justify-content-center",
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
const searchAlignClass = computed(
|
|
158
|
+
() => SEARCH_ALIGN_CLASSES[props.searchAlign] ?? SEARCH_ALIGN_CLASSES.start,
|
|
159
|
+
);
|
|
160
|
+
|
|
161
|
+
// "hide" swaps d-flex for d-none/d-md-flex on the WRAPPER — hiding only the
|
|
162
|
+
// slot content would leave a zero-height full-width row still costing a flex
|
|
163
|
+
// row-gap on phones. Explicit map + fallback, same rationale as
|
|
164
|
+
// SEARCH_ALIGN_CLASSES above.
|
|
165
|
+
const ACTIONS_DISPLAY_CLASSES: Record<NavbarActionsOnMobile, string> = {
|
|
166
|
+
wrap: "d-flex",
|
|
167
|
+
hide: "d-none d-md-flex",
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
const actionsDisplayClass = computed(
|
|
171
|
+
() => ACTIONS_DISPLAY_CLASSES[props.actionsOnMobile] ?? ACTIONS_DISPLAY_CLASSES.wrap,
|
|
172
|
+
);
|
|
173
|
+
|
|
120
174
|
defineEmits<{
|
|
121
175
|
/** Emitted when the hamburger menu button is clicked. */
|
|
122
176
|
toggleSidebar: [];
|
|
@@ -140,45 +194,56 @@ const getUserInitial = (user: { name: string } | null) => {
|
|
|
140
194
|
padding: 0.5rem 0;
|
|
141
195
|
}
|
|
142
196
|
|
|
143
|
-
/* Push the user-menu
|
|
197
|
+
/* Push the user-menu cluster to the right. (The actions region carries its
|
|
198
|
+
own auto margin from `md` up — see the sibling rule below.) */
|
|
144
199
|
.dashboard-navbar__end {
|
|
145
200
|
margin-left: auto;
|
|
146
201
|
}
|
|
147
202
|
|
|
148
203
|
/*
|
|
149
|
-
* Mobile-first: the search
|
|
150
|
-
* title / user-menu row (
|
|
151
|
-
*
|
|
152
|
-
*
|
|
204
|
+
* Mobile-first: the actions and the search each sit on their own full-width
|
|
205
|
+
* row below the toggle / title / user-menu row (flex-basis 100% forces the
|
|
206
|
+
* wrap; the bar and the sticky header grow to contain them — #93). The
|
|
207
|
+
* actions row itself wraps so a wide button group stacks instead of
|
|
208
|
+
* overflowing the viewport.
|
|
209
|
+
* From `md` up everything moves inline: search between the title and the
|
|
210
|
+
* actions (grows to fill the middle; `searchAlign` controls where its content
|
|
211
|
+
* sits), actions right-aligned next to the user menu. The actions' order 2
|
|
212
|
+
* holds at every size — only search and end change order across the
|
|
213
|
+
* breakpoint.
|
|
153
214
|
*/
|
|
215
|
+
.dashboard-navbar__actions {
|
|
216
|
+
order: 2;
|
|
217
|
+
flex: 0 0 100%;
|
|
218
|
+
flex-wrap: wrap;
|
|
219
|
+
}
|
|
220
|
+
|
|
154
221
|
.dashboard-navbar__search {
|
|
155
222
|
order: 3;
|
|
156
223
|
flex: 0 0 100%;
|
|
157
|
-
width: 100%;
|
|
158
224
|
}
|
|
159
225
|
|
|
160
226
|
@media (min-width: 768px) {
|
|
161
227
|
.dashboard-navbar__search {
|
|
162
228
|
order: 1;
|
|
163
229
|
flex: 1 1 auto;
|
|
164
|
-
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
.dashboard-navbar__actions {
|
|
233
|
+
flex: 0 1 auto;
|
|
234
|
+
flex-wrap: nowrap;
|
|
235
|
+
margin-left: auto;
|
|
165
236
|
}
|
|
166
237
|
|
|
167
238
|
.dashboard-navbar__end {
|
|
168
|
-
order:
|
|
239
|
+
order: 3;
|
|
169
240
|
}
|
|
170
|
-
}
|
|
171
241
|
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
display: flex;
|
|
179
|
-
align-items: center;
|
|
180
|
-
justify-content: center;
|
|
181
|
-
font-weight: 600;
|
|
182
|
-
font-size: 14px;
|
|
242
|
+
/* When the actions region is present it carries the push-right auto margin;
|
|
243
|
+
zero the user-menu cluster's so the two sit adjacent instead of having
|
|
244
|
+
the free space split between them. */
|
|
245
|
+
.dashboard-navbar__actions ~ .dashboard-navbar__end {
|
|
246
|
+
margin-left: 0;
|
|
247
|
+
}
|
|
183
248
|
}
|
|
184
249
|
</style>
|
|
@@ -61,8 +61,8 @@
|
|
|
61
61
|
<DFormCheckbox
|
|
62
62
|
v-else
|
|
63
63
|
v-model="fieldValue"
|
|
64
|
-
:disabled="isDisabled || isReadonly"
|
|
65
|
-
v-bind="
|
|
64
|
+
:disabled="isDisabled || isReadonly || isPlaintext"
|
|
65
|
+
v-bind="controlPropsWithGuards"
|
|
66
66
|
>
|
|
67
67
|
<DXFieldLabel :label="resolvedLabel" class="visually-hidden" />
|
|
68
68
|
</DFormCheckbox>
|
|
@@ -97,8 +97,8 @@
|
|
|
97
97
|
<DFormCheckbox
|
|
98
98
|
v-else
|
|
99
99
|
v-model="fieldValue"
|
|
100
|
-
:disabled="isDisabled || isReadonly"
|
|
101
|
-
v-bind="
|
|
100
|
+
:disabled="isDisabled || isReadonly || isPlaintext"
|
|
101
|
+
v-bind="controlPropsWithGuards"
|
|
102
102
|
>
|
|
103
103
|
<DXFieldLabel :label="resolvedLabel" :info="resolvedInfo" />
|
|
104
104
|
</DFormCheckbox>
|
|
@@ -159,8 +159,8 @@
|
|
|
159
159
|
<DXSwitch
|
|
160
160
|
v-else
|
|
161
161
|
v-model="switchModel"
|
|
162
|
-
:disabled="isDisabled || isReadonly"
|
|
163
|
-
v-bind="
|
|
162
|
+
:disabled="isDisabled || isReadonly || isPlaintext"
|
|
163
|
+
v-bind="controlPropsWithGuards"
|
|
164
164
|
>
|
|
165
165
|
<DXFieldLabel
|
|
166
166
|
:label="switchText"
|
|
@@ -201,8 +201,8 @@
|
|
|
201
201
|
<DXSwitch
|
|
202
202
|
v-else
|
|
203
203
|
v-model="switchModel"
|
|
204
|
-
:disabled="isDisabled || isReadonly"
|
|
205
|
-
v-bind="
|
|
204
|
+
:disabled="isDisabled || isReadonly || isPlaintext"
|
|
205
|
+
v-bind="controlPropsWithGuards"
|
|
206
206
|
>
|
|
207
207
|
<DXFieldLabel :label="switchText" :info="resolvedInfo" />
|
|
208
208
|
</DXSwitch>
|
|
@@ -306,8 +306,8 @@
|
|
|
306
306
|
v-model="fieldValue"
|
|
307
307
|
:field="field"
|
|
308
308
|
:model="model"
|
|
309
|
-
:disabled="isDisabled || isReadonly"
|
|
310
|
-
v-bind="
|
|
309
|
+
:disabled="isDisabled || isReadonly || isPlaintext"
|
|
310
|
+
v-bind="controlPropsWithGuards"
|
|
311
311
|
/>
|
|
312
312
|
|
|
313
313
|
<!-- Textarea. Error clearing happens in the v-model setter. -->
|
|
@@ -319,8 +319,9 @@
|
|
|
319
319
|
:rows="field.rows || 3"
|
|
320
320
|
:state="fieldState"
|
|
321
321
|
:disabled="isDisabled"
|
|
322
|
-
:readonly="isReadonly"
|
|
323
|
-
|
|
322
|
+
:readonly="isReadonly || isPlaintext"
|
|
323
|
+
:plaintext="isPlaintext"
|
|
324
|
+
v-bind="inputPropsWithGuards"
|
|
324
325
|
/>
|
|
325
326
|
|
|
326
327
|
<!-- Select (sync or async options) -->
|
|
@@ -330,8 +331,8 @@
|
|
|
330
331
|
:required="field.required"
|
|
331
332
|
:options="resolvedOptions"
|
|
332
333
|
:state="fieldState"
|
|
333
|
-
:disabled="isDisabled || isReadonly"
|
|
334
|
-
v-bind="
|
|
334
|
+
:disabled="isDisabled || isReadonly || isPlaintext"
|
|
335
|
+
v-bind="controlPropsWithGuards"
|
|
335
336
|
/>
|
|
336
337
|
|
|
337
338
|
<!-- Radio group -->
|
|
@@ -341,8 +342,8 @@
|
|
|
341
342
|
:options="resolvedOptions"
|
|
342
343
|
:required="field.required"
|
|
343
344
|
:state="fieldState"
|
|
344
|
-
:disabled="isDisabled || isReadonly"
|
|
345
|
-
v-bind="
|
|
345
|
+
:disabled="isDisabled || isReadonly || isPlaintext"
|
|
346
|
+
v-bind="controlPropsWithGuards"
|
|
346
347
|
/>
|
|
347
348
|
|
|
348
349
|
<!-- File / image upload -->
|
|
@@ -352,8 +353,8 @@
|
|
|
352
353
|
:accept="field.accept || (field.type === 'image' ? 'image/*' : undefined)"
|
|
353
354
|
:required="field.required"
|
|
354
355
|
:state="fieldState"
|
|
355
|
-
:disabled="isDisabled || isReadonly"
|
|
356
|
-
v-bind="
|
|
356
|
+
:disabled="isDisabled || isReadonly || isPlaintext"
|
|
357
|
+
v-bind="controlPropsWithGuards"
|
|
357
358
|
@change="handleFileChange"
|
|
358
359
|
/>
|
|
359
360
|
<img
|
|
@@ -384,8 +385,9 @@
|
|
|
384
385
|
:max="field.max"
|
|
385
386
|
:state="fieldState"
|
|
386
387
|
:disabled="isDisabled"
|
|
387
|
-
:readonly="isReadonly"
|
|
388
|
-
|
|
388
|
+
:readonly="isReadonly || isPlaintext"
|
|
389
|
+
:plaintext="isPlaintext"
|
|
390
|
+
v-bind="inputPropsWithGuards"
|
|
389
391
|
@input="handleCurrencyInput"
|
|
390
392
|
@focus="handleCurrencyFocus"
|
|
391
393
|
@blur="handleCurrencyBlur"
|
|
@@ -401,8 +403,9 @@
|
|
|
401
403
|
:max="field.max"
|
|
402
404
|
:state="fieldState"
|
|
403
405
|
:disabled="isDisabled"
|
|
404
|
-
:readonly="isReadonly"
|
|
405
|
-
|
|
406
|
+
:readonly="isReadonly || isPlaintext"
|
|
407
|
+
:plaintext="isPlaintext"
|
|
408
|
+
v-bind="inputPropsWithGuards"
|
|
406
409
|
/>
|
|
407
410
|
|
|
408
411
|
<template v-if="field.type === 'percentage'" #append>
|
|
@@ -422,9 +425,10 @@
|
|
|
422
425
|
:placeholder="field.placeholder"
|
|
423
426
|
:state="fieldState"
|
|
424
427
|
:disabled="isDisabled"
|
|
425
|
-
:readonly="isReadonly"
|
|
428
|
+
:readonly="isReadonly || isPlaintext"
|
|
429
|
+
:plaintext="isPlaintext"
|
|
426
430
|
autocomplete="off"
|
|
427
|
-
v-bind="
|
|
431
|
+
v-bind="inputPropsWithGuards"
|
|
428
432
|
/>
|
|
429
433
|
<datalist :id="datalistId">
|
|
430
434
|
<option
|
|
@@ -437,6 +441,31 @@
|
|
|
437
441
|
</datalist>
|
|
438
442
|
</template>
|
|
439
443
|
|
|
444
|
+
<!-- Password with a reveal toggle (the default for `type: "password"`).
|
|
445
|
+
Typing a long password blind is the one place the eye really earns
|
|
446
|
+
its keep, so it's opt-out (`revealable: false`) rather than opt-in. -->
|
|
447
|
+
<DInputGroup v-else-if="isRevealablePassword">
|
|
448
|
+
<DFormInput
|
|
449
|
+
v-model="textFieldModel"
|
|
450
|
+
:required="field.required"
|
|
451
|
+
:placeholder="field.placeholder"
|
|
452
|
+
:state="fieldState"
|
|
453
|
+
:disabled="isDisabled"
|
|
454
|
+
:readonly="isReadonly"
|
|
455
|
+
v-bind="passwordInputProps"
|
|
456
|
+
/>
|
|
457
|
+
<template #append>
|
|
458
|
+
<DButton
|
|
459
|
+
variant="outline-secondary"
|
|
460
|
+
:icon="passwordRevealed ? 'eye-slash' : 'eye'"
|
|
461
|
+
:aria-label="passwordRevealed ? 'Hide password' : 'Show password'"
|
|
462
|
+
:aria-pressed="passwordRevealed"
|
|
463
|
+
:disabled="isDisabled"
|
|
464
|
+
@click="passwordRevealed = !passwordRevealed"
|
|
465
|
+
/>
|
|
466
|
+
</template>
|
|
467
|
+
</DInputGroup>
|
|
468
|
+
|
|
440
469
|
<!-- Text-based inputs (text/email/password/number/url/tel/date/time/datetime) -->
|
|
441
470
|
<DFormInput
|
|
442
471
|
v-else
|
|
@@ -449,8 +478,9 @@
|
|
|
449
478
|
:max="field.max"
|
|
450
479
|
:state="fieldState"
|
|
451
480
|
:disabled="isDisabled"
|
|
452
|
-
:readonly="isReadonly"
|
|
453
|
-
|
|
481
|
+
:readonly="isReadonly || isPlaintext"
|
|
482
|
+
:plaintext="isPlaintext"
|
|
483
|
+
v-bind="inputPropsWithGuards"
|
|
454
484
|
/>
|
|
455
485
|
|
|
456
486
|
<!-- Validation error -->
|
|
@@ -505,6 +535,7 @@ import DXSwitch from "./DXSwitch.vue";
|
|
|
505
535
|
import DFormInvalidFeedback from "../base/DFormInvalidFeedback.vue";
|
|
506
536
|
import DFormText from "../base/DFormText.vue";
|
|
507
537
|
import DInputGroup from "../base/DInputGroup.vue";
|
|
538
|
+
import DButton from "../base/DButton.vue";
|
|
508
539
|
import DXFieldLabel from "./DXFieldLabel.vue";
|
|
509
540
|
import type { UseFormReturn } from "../../composables/useForm";
|
|
510
541
|
import type { FieldDefinition, FieldOption, FieldType, LabelCols } from "../../types";
|
|
@@ -880,6 +911,71 @@ const isDisabled = computed(() => {
|
|
|
880
911
|
|
|
881
912
|
const isReadonly = computed(() => resolveMaybe(props.field.readonly) ?? false);
|
|
882
913
|
|
|
914
|
+
// Display-only: the value as static text rather than a control. bvn's
|
|
915
|
+
// `plaintext` only swaps the class, so pair it with `readonly` at every call
|
|
916
|
+
// site — otherwise the "static text" is still editable.
|
|
917
|
+
const isPlaintext = computed(() => resolveMaybe(props.field.plaintext) ?? false);
|
|
918
|
+
|
|
919
|
+
const passwordRevealed = ref(false);
|
|
920
|
+
|
|
921
|
+
const isRevealablePassword = computed(
|
|
922
|
+
() =>
|
|
923
|
+
props.field.type === "password" &&
|
|
924
|
+
(props.field.revealable ?? true) &&
|
|
925
|
+
!isPlaintext.value,
|
|
926
|
+
);
|
|
927
|
+
|
|
928
|
+
// DXTable reuses ONE DXField instance per field key across modal opens (the
|
|
929
|
+
// v-for is keyed on `field.key`, which is stable across rows) and swaps in a
|
|
930
|
+
// fresh form per record. Without this, revealing row A's password leaves row
|
|
931
|
+
// B's — and the create form's — showing in clear text.
|
|
932
|
+
watch(
|
|
933
|
+
() => [props.form, props.field.key],
|
|
934
|
+
() => {
|
|
935
|
+
passwordRevealed.value = false;
|
|
936
|
+
},
|
|
937
|
+
);
|
|
938
|
+
|
|
939
|
+
/*
|
|
940
|
+
* `inputProps` is an escape hatch that deliberately wins over the field's own
|
|
941
|
+
* bindings — it is spread last. But it must not win over the guarantees the
|
|
942
|
+
* field makes, or those guarantees are only documentation: a `plaintext` field
|
|
943
|
+
* that `inputProps: { readonly: false }` re-opens for editing isn't read-only,
|
|
944
|
+
* and a password whose `type` `inputProps` sets to "text" is unmasked under a
|
|
945
|
+
* toggle still reading "Show password". So the guards are re-applied ON TOP of
|
|
946
|
+
* inputProps. Everything else in inputProps still passes through, and a field
|
|
947
|
+
* that isn't plaintext/readonly is untouched — `inputProps: { readonly: true }`
|
|
948
|
+
* still works as before.
|
|
949
|
+
*/
|
|
950
|
+
|
|
951
|
+
// For controls with a native readonly state (the text family + textarea).
|
|
952
|
+
const inputPropsWithGuards = computed(() => {
|
|
953
|
+
const merged: Record<string, any> = { ...props.field.inputProps };
|
|
954
|
+
if (isPlaintext.value) {
|
|
955
|
+
merged.plaintext = true;
|
|
956
|
+
merged.readonly = true;
|
|
957
|
+
} else if (isReadonly.value) {
|
|
958
|
+
merged.readonly = true;
|
|
959
|
+
}
|
|
960
|
+
return merged;
|
|
961
|
+
});
|
|
962
|
+
|
|
963
|
+
// For controls with NO native readonly state (select/radio/checkbox/switch/
|
|
964
|
+
// file/component), where read-only is expressed as `disabled`.
|
|
965
|
+
const controlPropsWithGuards = computed(() => {
|
|
966
|
+
const merged: Record<string, any> = { ...props.field.inputProps };
|
|
967
|
+
if (isDisabled.value || isReadonly.value || isPlaintext.value) {
|
|
968
|
+
merged.disabled = true;
|
|
969
|
+
}
|
|
970
|
+
return merged;
|
|
971
|
+
});
|
|
972
|
+
|
|
973
|
+
// The reveal toggle owns the password input's `type`.
|
|
974
|
+
const passwordInputProps = computed(() => ({
|
|
975
|
+
...inputPropsWithGuards.value,
|
|
976
|
+
type: passwordRevealed.value ? "text" : "password",
|
|
977
|
+
}));
|
|
978
|
+
|
|
883
979
|
const inputType = computed<string>(() => {
|
|
884
980
|
const type: FieldType = props.field.type;
|
|
885
981
|
if (type === "datetime") return "datetime-local";
|