@7365admin1/layer-common 3.2.2-staging.180 → 3.2.2-staging.182

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.
@@ -631,19 +631,10 @@ button.app-btn--err {
631
631
  color: var(--text2);
632
632
  }
633
633
 
634
- /* A `<select>` keeps the native menu; only the chrome is ours. */
635
- select.app-field__input {
636
- font-weight: 600;
637
- font-size: var(--fs-nav);
638
- appearance: none;
639
- cursor: pointer;
640
- }
641
-
642
634
  /*
643
- * The MULTI select. Native has no dropdown for `multiple` - only an
644
- * always-open list box - so multi wears the same field chrome as a button and
645
- * opens a popover. The chosen values are `StatusChip pill tone-neutral`, the
646
- * soft pill the design already uses and the one whose contrast is measured.
635
+ * AppSelect wears the same field chrome as a button and opens a themed
636
+ * popover. Multi values are `StatusChip pill tone-neutral`, the soft pill the
637
+ * design already uses and the one whose contrast is measured.
647
638
  */
648
639
  .app-select {
649
640
  cursor: pointer;
@@ -655,6 +646,23 @@ select.app-field__input {
655
646
  opacity: 0.55;
656
647
  }
657
648
 
649
+ .app-select--single {
650
+ min-width: 180px;
651
+ font-family: inherit;
652
+ text-align: left;
653
+ }
654
+
655
+ .app-select__single-value {
656
+ flex: 1;
657
+ min-width: 0;
658
+ overflow: hidden;
659
+ font-size: var(--fs-nav);
660
+ font-weight: 600;
661
+ color: var(--text);
662
+ text-overflow: ellipsis;
663
+ white-space: nowrap;
664
+ }
665
+
658
666
  /*
659
667
  * The SAME step for the typed field, which never had one. `AppField` and
660
668
  * `AppDateField` both take a `disabled` prop and pass it to the native
@@ -666,8 +674,8 @@ select.app-field__input {
666
674
  * filter, on BOTH themes. The field was genuinely dead and read as live.
667
675
  *
668
676
  * Keyed off the input's own `:disabled` rather than a new modifier class, so
669
- * it covers `AppField`, `AppDateField` and the native path of `AppSelect`
670
- * without three templates having to remember to add it. 0.55 is
677
+ * it covers `AppField` and `AppDateField` without two templates having to
678
+ * remember to add it. 0.55 is
671
679
  * `.app-select--disabled`'s number, not a second one.
672
680
  */
673
681
  .app-field:has(.app-field__input:disabled) {
@@ -746,6 +754,16 @@ select.app-field__input {
746
754
  background: var(--hover);
747
755
  }
748
756
 
757
+ .app-select__option--selected {
758
+ background: var(--accent-soft);
759
+ color: var(--accent-text);
760
+ font-weight: 600;
761
+ }
762
+
763
+ .app-select__option--selected:hover {
764
+ background: var(--accent-soft);
765
+ }
766
+
749
767
  .app-select__box {
750
768
  color: var(--muted);
751
769
  flex: 0 0 auto;
@@ -756,6 +774,7 @@ select.app-field__input {
756
774
  }
757
775
 
758
776
  .app-select__option-text {
777
+ flex: 1;
759
778
  min-width: 0;
760
779
  overflow: hidden;
761
780
  text-overflow: ellipsis;
@@ -657,6 +657,18 @@
657
657
  white-space: nowrap;
658
658
  }
659
659
 
660
+ /*
661
+ * `--thead` is intentionally translucent. A fixed header, however, sits above
662
+ * scrolling rows, so using the token by itself lets the first row show through
663
+ * the header. Layer the same tint over the card surface to keep the rendered
664
+ * colour unchanged while making the sticky header fully opaque.
665
+ */
666
+ .table-card .v-table--fixed-header thead th {
667
+ background:
668
+ linear-gradient(var(--thead), var(--thead)),
669
+ var(--card) !important;
670
+ }
671
+
660
672
  /**
661
673
  * ROW HEIGHT IS THE LINE BOX, NOT THE PADDING.
662
674
  *
@@ -2,10 +2,9 @@
2
2
  THE SELECT.
3
3
 
4
4
  The handoff draws it as the text field with the filter's name in muted 12px
5
- on the left, the chosen value next to it and a chevron on the right. A native
6
- `<select>` under our chrome: the option list, the keyboard and the mobile
7
- wheel are the browser's, so there is no menu to build and nothing to get
8
- wrong on a phone.
5
+ on the left, the chosen value next to it and a chevron on the right. Both
6
+ single and multiple values use the same themed popover, so the option list
7
+ does not fall back to the browser/OS styling.
9
8
 
10
9
  `multiple` is the ONE case where native is the wrong answer. A native
11
10
  `<select multiple>` is not a dropdown at all - it is an always-open scrolling
@@ -13,32 +12,62 @@
13
12
  and change how the staff work. So multi opens a POPOVER in the same field
14
13
  chrome, with the chosen values as the design's soft removable pills.
15
14
 
16
- The single-value path below is byte-for-byte the one it has always been: a
17
- consumer that passes neither `multiple` nor `return-object` still emits
18
- exactly the `<select>` element's own string.
15
+ A single-value consumer that does not use `return-object` still emits the
16
+ same string that the old native `<select>` emitted.
19
17
  -->
20
18
  <template>
21
- <label
19
+ <v-menu
22
20
  v-if="!multiple"
23
- v-bind="$attrs"
24
- class="app-field"
25
- :class="{ 'app-field--compact': compact }"
21
+ :close-on-content-click="true"
22
+ :disabled="disabled"
23
+ location="bottom start"
24
+ offset="4"
26
25
  >
27
- <span v-if="label" class="app-field__label">{{ label }}</span>
28
- <select
29
- class="app-field__input"
30
- :value="singleValue"
31
- :disabled="disabled"
32
- @change="onNativeChange"
33
- >
34
- <!-- `clearable` on a native select is the empty option, not an x. -->
35
- <option v-if="clearable" value="">{{ placeholder }}</option>
36
- <option v-for="opt in normalized" :key="String(opt.value)" :value="opt.value">
37
- {{ opt.title }}
38
- </option>
39
- </select>
40
- <v-icon class="app-field__icon" icon="mdi-chevron-down" size="17" />
41
- </label>
26
+ <template #activator="{ props: activator }">
27
+ <div
28
+ v-bind="{ ...activator, ...$attrs }"
29
+ class="app-field app-select app-select--single"
30
+ :class="{ 'app-field--compact': compact, 'app-select--disabled': disabled }"
31
+ role="button"
32
+ :aria-disabled="disabled"
33
+ :tabindex="disabled ? -1 : 0"
34
+ >
35
+ <span v-if="label" class="app-field__label">{{ label }}</span>
36
+ <span
37
+ class="app-select__single-value"
38
+ :class="{ 'app-select__placeholder': !hasSingleValue }"
39
+ >
40
+ {{ singleDisplay }}
41
+ </span>
42
+ <button
43
+ v-if="clearable && hasSingleValue"
44
+ type="button"
45
+ class="app-select__clear"
46
+ aria-label="Clear"
47
+ @click.stop="clearSingle"
48
+ >
49
+ <v-icon icon="mdi-close-circle" size="15" />
50
+ </button>
51
+ <v-icon class="app-field__icon" icon="mdi-chevron-down" size="17" />
52
+ </div>
53
+ </template>
54
+
55
+ <div class="app-select__menu" role="listbox">
56
+ <button
57
+ v-for="opt in normalized"
58
+ :key="String(opt.value)"
59
+ type="button"
60
+ class="app-select__option"
61
+ :class="{ 'app-select__option--selected': isSingleOn(opt) }"
62
+ role="option"
63
+ :aria-selected="isSingleOn(opt)"
64
+ @click="selectSingle(opt)"
65
+ >
66
+ <span class="app-select__option-text">{{ opt.title }}</span>
67
+ <v-icon v-if="isSingleOn(opt)" icon="mdi-check" size="17" />
68
+ </button>
69
+ </div>
70
+ </v-menu>
42
71
 
43
72
  <v-menu
44
73
  v-else
@@ -114,9 +143,8 @@
114
143
  </template>
115
144
 
116
145
  <script setup lang="ts">
117
- /* `v-menu` has no single root node, so a class on `<AppSelect multiple>` has
118
- to be put on the activator by hand. The single branch lands where it always
119
- did - on the `<label>`. */
146
+ /* `v-menu` has no single root node, so attributes and classes have to be put
147
+ on each activator by hand. */
120
148
  defineOptions({ inheritAttrs: false });
121
149
 
122
150
  /*
@@ -157,15 +185,31 @@ const keyOf = (v: any) =>
157
185
 
158
186
  /* ---- single ---------------------------------------------------------- */
159
187
 
160
- const singleValue = computed(() =>
161
- props.returnObject ? keyOf(model.value) : model.value
188
+ const hasSingleValue = computed(
189
+ () => model.value !== "" && model.value !== null && model.value !== undefined
190
+ );
191
+
192
+ const selectedSingleOption = computed(() =>
193
+ normalized.value.find((opt) => String(opt.value) === keyOf(model.value))
194
+ );
195
+
196
+ const singleDisplay = computed(() =>
197
+ hasSingleValue.value
198
+ ? selectedSingleOption.value?.title ?? keyOf(model.value)
199
+ : props.placeholder
162
200
  );
163
201
 
164
- function onNativeChange(event: Event) {
165
- const value = (event.target as HTMLSelectElement).value;
166
- model.value = props.returnObject
167
- ? normalized.value.find((o) => String(o.value) === value)?.raw ?? value
168
- : value;
202
+ const isSingleOn = (opt: any) =>
203
+ hasSingleValue.value && String(opt.value) === keyOf(model.value);
204
+
205
+ function selectSingle(opt: any) {
206
+ if (props.disabled) return;
207
+ model.value = props.returnObject ? opt.raw : String(opt.value);
208
+ }
209
+
210
+ function clearSingle() {
211
+ if (props.disabled) return;
212
+ model.value = "";
169
213
  }
170
214
 
171
215
  /* ---- multiple -------------------------------------------------------- */
@@ -334,4 +334,18 @@ onUnmounted(() => {
334
334
  white-space: normal;
335
335
  line-height: 1.2;
336
336
  }
337
+
338
+ /**
339
+ * The attach icon (mdi-paperclip) was drawing at Vuetify's default
340
+ * `.v-input__prepend > .v-icon { opacity: var(--v-medium-emphasis-opacity) }`
341
+ * (0.64) - the same field-icon dimming ~200 other call sites rely on, so it is
342
+ * overridden here only, not in the layer's token. Full opacity, `--text2` -
343
+ * the same colour `.screen-icon-btn` already uses for an icon at rest -
344
+ * matches a sibling icon drawn outside a field (e.g. a composer's chat icon,
345
+ * which is never subject to this Vuetify rule in the first place).
346
+ */
347
+ .filter-field :deep(.v-input__prepend > .v-icon) {
348
+ opacity: 1;
349
+ color: var(--text2);
350
+ }
337
351
  </style>
@@ -65,7 +65,18 @@
65
65
  @update:model-value="loadDashboard"
66
66
  />
67
67
  <AppField v-model="search" search placeholder="Search" />
68
- <AppSelect v-model="status" :items="statusOptions" />
68
+ <AppSelect
69
+ v-model="status"
70
+ :items="statusOptions"
71
+ placeholder="Status"
72
+ clearable
73
+ />
74
+ <AppSelect
75
+ v-model="accessMethod"
76
+ :items="accessMethodFilterOptions"
77
+ placeholder="Access Method"
78
+ clearable
79
+ />
69
80
  </div>
70
81
  </template>
71
82
 
@@ -253,6 +264,7 @@
253
264
  :items="statusOptions"
254
265
  aria-label="Status"
255
266
  placeholder="Select"
267
+ clearable
256
268
  />
257
269
  </v-card-text>
258
270
 
@@ -323,7 +335,8 @@ const userImages = ref<Record<string, string>>({});
323
335
  const selectedReaderId = ref("");
324
336
  const activeTab = ref<AccessTab>("resident");
325
337
  const search = ref("");
326
- const status = ref("Status");
338
+ const status = ref("");
339
+ const accessMethod = ref("");
327
340
  const page = ref(1);
328
341
  const limit = 10;
329
342
  const loading = ref(false);
@@ -338,11 +351,12 @@ const exportForm = reactive({
338
351
  users: [] as string[],
339
352
  accessMethod: "All",
340
353
  readerLocation: "All",
341
- status: "Status",
354
+ status: "",
342
355
  });
343
356
 
344
- const statusOptions = ["Status", "Authorized", "Not Authorized", "Unknown"];
345
- const accessMethodOptions = ["All", "Facial", "QR Code", "ID and Password", "PIN", "Card"];
357
+ const statusOptions = ["Authorized", "Not Authorized", "Unknown"];
358
+ const accessMethodFilterOptions = ["Facial", "QR Code", "ID and Password", "PIN", "Card"];
359
+ const accessMethodOptions = ["All", ...accessMethodFilterOptions];
346
360
 
347
361
  const readerOptions = computed(() =>
348
362
  readers.value.map((reader) => ({
@@ -442,9 +456,10 @@ const filteredRows = computed(() => {
442
456
  row.status,
443
457
  ].some((value) => String(value ?? "").toLowerCase().includes(query));
444
458
 
445
- const matchesStatus = status.value === "Status" || row.status === status.value;
459
+ const matchesStatus = !status.value || row.status === status.value;
460
+ const matchesMethod = !accessMethod.value || row.method === accessMethod.value;
446
461
 
447
- return matchesSearch && matchesStatus;
462
+ return matchesSearch && matchesStatus && matchesMethod;
448
463
  });
449
464
  });
450
465
 
@@ -497,7 +512,7 @@ const exportRowsData = computed(() =>
497
512
  const matchesUsers = !selectedUsers.length || selectedUsers.includes(row.key) || selectedUsers.includes(row.identityKey);
498
513
  const matchesMethod = exportForm.accessMethod === "All" || row.method === exportForm.accessMethod;
499
514
  const matchesLocation = exportForm.readerLocation === "All" || row.readerLocation === exportForm.readerLocation;
500
- const matchesStatus = exportForm.status === "Status" || row.status === exportForm.status;
515
+ const matchesStatus = !exportForm.status || row.status === exportForm.status;
501
516
  const matchesDate = isWithinExportRange(row.lastAccessDate);
502
517
 
503
518
  return matchesUsers && matchesMethod && matchesLocation && matchesStatus && matchesDate;
@@ -510,7 +525,7 @@ watch(
510
525
  { immediate: true },
511
526
  );
512
527
 
513
- watch([search, status, activeTab], () => {
528
+ watch([search, status, accessMethod, activeTab], () => {
514
529
  page.value = 1;
515
530
  });
516
531
 
@@ -45,7 +45,11 @@ const props = withDefaults(
45
45
  const { getSiteById } = useSiteSettings();
46
46
  const { getHidServiceEnabled } = useOptionalServices();
47
47
 
48
- const { data: siteData } = await useLazyAsyncData(
48
+ // Do not await this request during route setup. Every HID page uses this gate;
49
+ // awaiting it makes Nuxt Suspense keep rendering the previous page until the
50
+ // site request finishes, so the URL changes while the visible module appears
51
+ // frozen. The lazy request updates `siteData` reactively after navigation.
52
+ const { data: siteData } = useLazyAsyncData(
49
53
  "hid-enabled-gate-site-" + props.site,
50
54
  () => getSiteById(props.site),
51
55
  {