@omnitend/dashboard-for-laravel 0.22.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.
@@ -231,6 +231,29 @@ export interface FieldDefinition {
231
231
  * applied as `disabled`.
232
232
  */
233
233
  readonly?: MaybeFn<boolean>;
234
+ /**
235
+ * Display the value as static text instead of a control — no border, no
236
+ * input box (Bootstrap's `.form-control-plaintext`). For values a
237
+ * profile/settings page shows but never lets you edit, where a `readonly`
238
+ * input still reads as "you could edit this, but can't".
239
+ *
240
+ * Implies read-only, for every field type. Anything backed by a real input
241
+ * — the text family, textarea, currency, percentage, autocomplete — renders
242
+ * as static text; control types with no native readonly state
243
+ * (select/radio/checkbox/switch/file) are disabled instead, exactly as they
244
+ * are for `readonly`.
245
+ *
246
+ * Enforced over `inputProps`: unlike the field's other bindings, which
247
+ * `inputProps` may override, a plaintext field cannot be made editable
248
+ * again through it.
249
+ */
250
+ plaintext?: MaybeFn<boolean>;
251
+ /**
252
+ * Only for `type: "password"`. Show the reveal (eye) toggle that switches
253
+ * the input between masked and plain text. Defaults to `true` — set
254
+ * `false` to render a bare password input.
255
+ */
256
+ revealable?: boolean;
234
257
  /**
235
258
  * Conditionally show or hide this field. When omitted the field is
236
259
  * always visible. Boolean or a function of the form model; evaluated
@@ -1,8 +1,8 @@
1
1
  {
2
- "generated": "2026-07-11T04:13:01.837Z",
2
+ "generated": "2026-07-11T08:44:15.505Z",
3
3
  "package": {
4
4
  "name": "@omnitend/dashboard-for-laravel",
5
- "version": "0.21.0"
5
+ "version": "0.22.0"
6
6
  },
7
7
  "components": {
8
8
  "base": [
@@ -1468,6 +1468,22 @@
1468
1468
  {
1469
1469
  "name": "name",
1470
1470
  "title": "binding"
1471
+ },
1472
+ {
1473
+ "title": "binding",
1474
+ "type": {
1475
+ "name": "function"
1476
+ },
1477
+ "name": "toggleSidebar",
1478
+ "description": "Shows/hides the sidebar."
1479
+ },
1480
+ {
1481
+ "title": "binding",
1482
+ "type": {
1483
+ "name": "boolean"
1484
+ },
1485
+ "name": "sidebarHidden",
1486
+ "description": "Whether the sidebar is currently hidden."
1471
1487
  }
1472
1488
  ]
1473
1489
  },
@@ -1562,7 +1578,7 @@
1562
1578
  },
1563
1579
  {
1564
1580
  "name": "user-icon",
1565
- "description": "Custom user avatar/icon in the dropdown trigger.",
1581
+ "description": "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.",
1566
1582
  "bindings": [
1567
1583
  {
1568
1584
  "title": "binding",
@@ -1571,6 +1587,14 @@
1571
1587
  },
1572
1588
  "name": "initial",
1573
1589
  "description": "The first letter of the user's name."
1590
+ },
1591
+ {
1592
+ "title": "binding",
1593
+ "type": {
1594
+ "name": "object"
1595
+ },
1596
+ "name": "user",
1597
+ "description": "The signed-in user."
1574
1598
  }
1575
1599
  ]
1576
1600
  },
@@ -2857,6 +2881,39 @@
2857
2881
  "required": false,
2858
2882
  "description": "API endpoint for creating new items (e.g., \"/api/products\") — enables \"New\" button"
2859
2883
  },
2884
+ {
2885
+ "name": "showCreateButton",
2886
+ "type": "boolean",
2887
+ "required": false,
2888
+ "default": "true",
2889
+ "description": "Render the built-in \"New {item}\" button in the card header (default\n`true` whenever `createUrl` is set). Set `false` to drive the create\nmodal from your own trigger elsewhere — e.g. a button in the dashboard\nnavbar's actions slot calling the exposed `openCreate()`. With no\n`title` and no `header` slot, the header is then dropped entirely\nrather than left empty."
2890
+ },
2891
+ {
2892
+ "name": "footClone",
2893
+ "type": "boolean",
2894
+ "required": false,
2895
+ "default": "false",
2896
+ "description": "Render a `<tfoot>` mirroring the header row, so `#foot(<key>)` slots can\nhold per-column totals that line up under their data (#99). Without it\nthere is no footer to put them in. For a fully custom footer, use the\n`custom-foot` slot instead."
2897
+ },
2898
+ {
2899
+ "name": "showEmpty",
2900
+ "type": "boolean",
2901
+ "required": false,
2902
+ "default": "true",
2903
+ "description": "Show a message row when there are no rows (default `true`). An empty\ntable body is indistinguishable from a broken one, so the message is on\nby default. Override the wording with `emptyText`, or the whole row with\nthe `empty` slot."
2904
+ },
2905
+ {
2906
+ "name": "emptyText",
2907
+ "type": "string",
2908
+ "required": false,
2909
+ "description": "Message shown when there are no rows. Defaults to \"No {items} found\", or\n\"No {items} match your filters\" when a column filter is active."
2910
+ },
2911
+ {
2912
+ "name": "expandedItems",
2913
+ "type": "Array",
2914
+ "required": false,
2915
+ "description": "Rows currently expanded, as a `v-model`. Pair with the `row-expansion`\nslot to render detail content under a row (#112) instead of forcing every\nper-row detail into a modal."
2916
+ },
2860
2917
  {
2861
2918
  "name": "clientSide",
2862
2919
  "type": "boolean",
@@ -2925,6 +2982,11 @@
2925
2982
  "description": "`v-model:sortBy` update, emitted with the normalized single-column sort array when sorting changes.",
2926
2983
  "properties": []
2927
2984
  },
2985
+ {
2986
+ "name": "update:expandedItems",
2987
+ "description": "`v-model:expandedItems` update, emitted with the rows currently expanded.",
2988
+ "properties": []
2989
+ },
2928
2990
  {
2929
2991
  "name": "update:filters",
2930
2992
  "description": "`v-model:filters` update, emitted with the new filter map when a column filter changes.",
@@ -2949,7 +3011,7 @@
2949
3011
  },
2950
3012
  {
2951
3013
  "name": "name",
2952
- "description": "Custom rendering for a column's cell. Name it `cell(<fieldKey>)` to target a field; receives the underlying table cell scope.",
3014
+ "description": "Any slot the underlying table supports, forwarded with its scope: `cell(<fieldKey>)` for a cell, `foot(<fieldKey>)` for a footer cell (needs `footClone`), `empty` for the no-rows message, `row-expansion` for expandable row detail, plus `custom-foot`, `top-row`, `bottom-row`, `thead-sub` and the `table-*` slots.",
2953
3015
  "bindings": [
2954
3016
  {
2955
3017
  "name": "name",
@@ -3150,12 +3212,64 @@
3150
3212
  }
3151
3213
  ],
3152
3214
  "methods": []
3215
+ },
3216
+ {
3217
+ "name": "DXUserAvatar",
3218
+ "category": "extended",
3219
+ "filePath": "resources/js/components/extended/DXUserAvatar.vue",
3220
+ "description": "The circular user avatar shown in the dashboard navbar's user menu: a single initial on a solid disc, with an optional notification dot. It exists as its own component because `DXDashboardNavbar`'s `user-icon` slot is one consumers routinely *decorate* rather than replace — adding an unread badge, wrapping it in a link. The navbar's styles are scoped, so slot content (compiled in the consumer's scope) can't reuse them; without this component every override starts by re-implementing the avatar's CSS, which then drifts from the theme. Rendering it here means an override is one line and stays on theme.",
3221
+ "props": [
3222
+ {
3223
+ "name": "user",
3224
+ "type": "union",
3225
+ "required": false,
3226
+ "default": "null",
3227
+ "description": "The user whose initial is shown. The first letter of `name` is used."
3228
+ },
3229
+ {
3230
+ "name": "initial",
3231
+ "type": "string",
3232
+ "required": false,
3233
+ "default": "\"\"",
3234
+ "description": "Overrides the initial derived from `user.name` (e.g. two-letter initials)."
3235
+ },
3236
+ {
3237
+ "name": "badge",
3238
+ "type": "boolean",
3239
+ "required": false,
3240
+ "default": "false",
3241
+ "description": "Show a notification dot on the avatar (e.g. unread updates)."
3242
+ },
3243
+ {
3244
+ "name": "badgeVariant",
3245
+ "type": "ColorVariant",
3246
+ "required": false,
3247
+ "default": "\"danger\"",
3248
+ "description": "Colour of the notification dot."
3249
+ },
3250
+ {
3251
+ "name": "badgeLabel",
3252
+ "type": "string",
3253
+ "required": false,
3254
+ "default": "\"Unread notifications\"",
3255
+ "description": "Accessible text for the notification dot, announced to screen readers\n(the dot itself is a colour-only signal). Ignored unless `badge` is set."
3256
+ }
3257
+ ],
3258
+ "events": [],
3259
+ "slots": [
3260
+ {
3261
+ "name": "default",
3262
+ "description": "Replaces the initial inside the disc (e.g. a photo or an icon).",
3263
+ "bindings": []
3264
+ }
3265
+ ],
3266
+ "methods": []
3153
3267
  }
3154
3268
  ]
3155
3269
  },
3156
3270
  "stats": {
3157
- "totalComponents": 73,
3271
+ "totalComponents": 74,
3158
3272
  "baseComponents": 57,
3159
- "extendedComponents": 16
3273
+ "extendedComponents": 17
3160
3274
  }
3161
3275
  }
@@ -1,7 +1,7 @@
1
1
  # Documentation Map
2
2
 
3
3
  > Auto-generated hierarchical overview of all documentation
4
- > Last updated: 2026-07-11T04:13:01.900Z
4
+ > Last updated: 2026-07-11T08:44:15.559Z
5
5
 
6
6
  This file provides a complete map of all available documentation for AI agents and developers.
7
7
 
@@ -82,6 +82,7 @@ This file provides a complete map of all available documentation for AI agents a
82
82
  - [DXStatCard](/components/extended/DXStatCard): A KPI / stat tile: a big formatted value with a title, optional subtitle, an
83
83
  - [DXSwitch](/components/extended/DXSwitch): A "filled box" toggle switch built on `DFormCheckbox`: a bordered box sized like
84
84
  - [DXTable](/components/extended/DXTable): A comprehensive data table component with built-in pagination, sorting, filtering, and CRUD operatio
85
+ - [DXUserAvatar](/components/extended/DXUserAvatar): The circular user avatar shown in the dashboard navbar's user menu: a single
85
86
 
86
87
  ### Examples
87
88
 
@@ -100,4 +101,4 @@ This file provides a complete map of all available documentation for AI agents a
100
101
 
101
102
  ---
102
103
 
103
- **Total Pages**: 76
104
+ **Total Pages**: 77
@@ -2,7 +2,7 @@
2
2
 
3
3
  > Vue 3 dashboard components for Laravel with Bootstrap Vue Next
4
4
  >
5
- > A dual-package library (NPM + Composer) providing 68 Vue 3 components for building Laravel dashboards with Bootstrap Vue Next.
5
+ > A dual-package library (NPM + Composer) providing 69 Vue 3 components for building Laravel dashboards with Bootstrap Vue Next.
6
6
 
7
7
  ## Installation
8
8
 
@@ -81,7 +81,7 @@ Lightweight type-safe wrappers around Bootstrap Vue Next components providing AP
81
81
  - [DToaster](/components/base/dtoaster): Bootstrap Vue Next Toaster wrapper
82
82
  - [DTooltip](/components/base/dtooltip): Bootstrap Vue Next Tooltip wrapper
83
83
 
84
- ## Extended Components (11 components)
84
+ ## Extended Components (12 components)
85
85
 
86
86
  Custom dashboard components with advanced functionality beyond Bootstrap Vue Next.
87
87
 
@@ -96,6 +96,7 @@ Custom dashboard components with advanced functionality beyond Bootstrap Vue Nex
96
96
  - [DXStatCard](/components/extended/dxstatcard): Extended dashboard component
97
97
  - [DXSwitch](/components/extended/dxswitch): Extended dashboard component
98
98
  - [DXTable](/components/extended/dxtable): Data table with pagination, filtering, and sorting
99
+ - [DXUserAvatar](/components/extended/dxuseravatar): Extended dashboard component
99
100
 
100
101
  ## Composables
101
102
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@omnitend/dashboard-for-laravel",
3
- "version": "0.22.0",
3
+ "version": "0.23.0",
4
4
  "description": "Vue 3 dashboard components for Laravel with Bootstrap Vue Next",
5
5
  "type": "module",
6
6
  "main": "./dist/dashboard-for-laravel.umd.cjs",
@@ -25,8 +25,17 @@
25
25
  :key="strippedName"
26
26
  #[strippedName]="slotProps"
27
27
  >
28
- <!-- @slot Forwards any `sidebar-*` slot to DXDashboardSidebar with the `sidebar-` prefix stripped (e.g. `sidebar-brand` becomes the sidebar's `brand` slot). -->
29
- <slot :name="originalName" v-bind="slotProps" />
28
+ <!--
29
+ @slot Forwards any `sidebar-*` slot to DXDashboardSidebar with the `sidebar-` prefix stripped (e.g. `sidebar-brand` becomes the sidebar's `brand` slot).
30
+ @binding {function} toggleSidebar Shows/hides the sidebar — so slot content (e.g. a close affordance in the brand row) can drive it.
31
+ @binding {boolean} sidebarHidden Whether the sidebar is currently hidden.
32
+ -->
33
+ <slot
34
+ :name="originalName"
35
+ v-bind="slotProps"
36
+ :toggleSidebar="toggleSidebar"
37
+ :sidebarHidden="hidden"
38
+ />
30
39
  </template>
31
40
  </DXDashboardSidebar>
32
41
 
@@ -46,8 +55,17 @@
46
55
  :key="strippedName"
47
56
  #[strippedName]="slotProps"
48
57
  >
49
- <!-- @slot Forwards any `navbar-*` slot to DXDashboardNavbar with the `navbar-` prefix stripped (e.g. `navbar-actions` becomes the navbar's `actions` slot). -->
50
- <slot :name="originalName" v-bind="slotProps" />
58
+ <!--
59
+ @slot Forwards any `navbar-*` slot to DXDashboardNavbar with the `navbar-` prefix stripped (e.g. `navbar-actions` becomes the navbar's `actions` slot).
60
+ @binding {function} toggleSidebar Shows/hides the sidebar.
61
+ @binding {boolean} sidebarHidden Whether the sidebar is currently hidden.
62
+ -->
63
+ <slot
64
+ :name="originalName"
65
+ v-bind="slotProps"
66
+ :toggleSidebar="toggleSidebar"
67
+ :sidebarHidden="hidden"
68
+ />
51
69
  </template>
52
70
  </DXDashboardNavbar>
53
71
 
@@ -85,6 +103,12 @@ import type { Navigation, NavbarActionsOnMobile, NavbarSearchAlign } from '../..
85
103
 
86
104
  const slots = useSlots();
87
105
 
106
+ // Slot-binding precedence: the forwarded slots bind the child's own slot props
107
+ // FIRST and `toggleSidebar`/`sidebarHidden` after, so ours win a name clash.
108
+ // That's deliberate — the dashboard guarantees those two bindings on every
109
+ // forwarded slot, and a child that later grew a same-named prop must not be
110
+ // able to silently take the guarantee away. No child uses those names today.
111
+
88
112
  interface Props {
89
113
  /** Navigation structure for sidebar */
90
114
  navigation: Navigation;
@@ -252,6 +276,20 @@ const toggleSidebar = () => {
252
276
  console.error('Error saving sidebar state:', error);
253
277
  }
254
278
  };
279
+
280
+ defineExpose({
281
+ /**
282
+ * Show/hide the sidebar (same as clicking the navbar's hamburger). The
283
+ * dashboard owns the visibility state, so this is the supported way to drive
284
+ * it from outside — page content, a custom brand row, a keyboard shortcut.
285
+ * Also available as a slot binding on every forwarded `sidebar-*`/`navbar-*`
286
+ * slot, which is usually more convenient.
287
+ */
288
+ toggleSidebar,
289
+
290
+ /** Whether the sidebar is currently hidden. */
291
+ sidebarHidden: computed(() => hidden.value),
292
+ });
255
293
  </script>
256
294
 
257
295
  <style scoped>
@@ -84,13 +84,12 @@
84
84
  >
85
85
  <template #button-content>
86
86
  <!--
87
- @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.
88
88
  @binding {string} initial The first letter of the user's name.
89
+ @binding {object} user The signed-in user.
89
90
  -->
90
- <slot name="user-icon" :initial="getUserInitial(user)">
91
- <div class="user-avatar">
92
- {{ getUserInitial(user) }}
93
- </div>
91
+ <slot name="user-icon" :initial="getUserInitial(user)" :user="user">
92
+ <DXUserAvatar :user="user" />
94
93
  </slot>
95
94
  </template>
96
95
 
@@ -112,6 +111,7 @@ import { computed } from "vue";
112
111
  import DContainer from "../base/DContainer.vue";
113
112
  import DButton from "../base/DButton.vue";
114
113
  import DDropdown from "../base/DDropdown.vue";
114
+ import DXUserAvatar from "./DXUserAvatar.vue";
115
115
  import type { NavbarActionsOnMobile, NavbarSearchAlign } from "../../types/navigation";
116
116
 
117
117
  const props = withDefaults(
@@ -246,17 +246,4 @@ const getUserInitial = (user: { name: string } | null) => {
246
246
  margin-left: 0;
247
247
  }
248
248
  }
249
-
250
- .user-avatar {
251
- width: 32px;
252
- height: 32px;
253
- border-radius: 50%;
254
- background-color: var(--bs-dark);
255
- color: var(--bs-white);
256
- display: flex;
257
- align-items: center;
258
- justify-content: center;
259
- font-weight: 600;
260
- font-size: 14px;
261
- }
262
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="field.inputProps"
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="field.inputProps"
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="field.inputProps"
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="field.inputProps"
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="field.inputProps"
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
- v-bind="field.inputProps"
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="field.inputProps"
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="field.inputProps"
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="field.inputProps"
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
- v-bind="field.inputProps"
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
- v-bind="field.inputProps"
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="field.inputProps"
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
- v-bind="field.inputProps"
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";