@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.
@@ -1,8 +1,9 @@
1
1
  <!--
2
2
  @component
3
- Top navigation bar for the dashboard shell: menu toggle, page title, centred
4
- search, page-level actions, and the user menu. Usually rendered by
5
- `DXDashboard`, which forwards these slots with a `navbar-` prefix.
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 justify-content-center"
43
+ class="dashboard-navbar__search d-flex"
44
+ :class="searchAlignClass"
43
45
  >
44
- <!-- @slot Search input or component. Centred in the bar on wider screens; drops to its own full-width row below the `md` breakpoint. -->
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
- <div class="dashboard-navbar__end d-flex align-items-center gap-3">
49
- <!-- Page-level primary actions, right-aligned next to the user menu -->
50
- <div
51
- v-if="$slots.actions"
52
- class="dashboard-navbar__actions d-flex align-items-center gap-2"
53
- >
54
- <!--
55
- @slot Page-level primary actions (e.g. a Create button), right-aligned next to the user menu.
56
- @binding {string} pageTitle The current page title, for context.
57
- -->
58
- <slot name="actions" :pageTitle="pageTitle" />
59
- </div>
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
- <div class="user-avatar">
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 / actions cluster to the right. */
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 sits on its own full-width row below the toggle /
150
- * title / user-menu row (order after both, flex-basis 100% forces the wrap).
151
- * From `md` up it moves inline between the title and the user menu and grows to
152
- * fill the middle, centring its content.
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
- width: auto;
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: 2;
239
+ order: 3;
169
240
  }
170
- }
171
241
 
172
- .user-avatar {
173
- width: 32px;
174
- height: 32px;
175
- border-radius: 50%;
176
- background-color: var(--bs-dark);
177
- color: var(--bs-white);
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="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";