@7365admin1/layer-common 3.2.2-staging.80 → 3.2.2-staging.82
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/assets/css/shell.css +258 -0
- package/assets/css/tokens.css +220 -0
- package/components/Layout/Header.vue +274 -36
- package/components/Layout/NavigationDrawer.vue +298 -30
- package/composables/useThemePreference.ts +63 -0
- package/nuxt.config.ts +31 -0
- package/package.json +3 -2
- package/plugins/vuetify.ts +30 -1
- package/utils/nav-sections.test.ts +131 -0
- package/utils/nav-sections.ts +128 -0
- package/utils/status.test.ts +41 -0
- package/utils/status.ts +54 -0
- package/utils/theme.test.ts +325 -7
- package/utils/theme.ts +268 -137
|
@@ -28,45 +28,90 @@
|
|
|
28
28
|
primary-tinted fill (see `NavigationItem.vue`); 0.12 left the fill at
|
|
29
29
|
1.15:1, 0.18 is 1.44:1 light, 1.33:1 dark.
|
|
30
30
|
-->
|
|
31
|
+
<!--
|
|
32
|
+
RESPONSIVE: THE RAIL BECOMES AN OVERLAY BELOW 1024px.
|
|
33
|
+
|
|
34
|
+
`permanent` was hardcoded, which is why the prototype's own behaviour - and
|
|
35
|
+
ours - was a 252px rail still sitting there on a 360px phone with the
|
|
36
|
+
content squeezed into what was left. Dropping `permanent` and naming the
|
|
37
|
+
breakpoint hands the whole behaviour to Vuetify: at >= 1024 the drawer is
|
|
38
|
+
part of the layout exactly as it is today, and below it the same drawer
|
|
39
|
+
becomes temporary, floats OVER the content with a scrim, and closes on the
|
|
40
|
+
scrim, on Escape and on a navigation.
|
|
41
|
+
|
|
42
|
+
1024 rather than Vuetify's default 1280: at 1024 the rail still leaves a
|
|
43
|
+
772px content column, which is a usable table. Below that it does not.
|
|
44
|
+
|
|
45
|
+
Nothing about the menu changes - same items, same order, same permissions,
|
|
46
|
+
same routes. Only where the panel is drawn.
|
|
47
|
+
-->
|
|
48
|
+
<!--
|
|
49
|
+
PHASE 2 - THE GROUPED SIDEBAR.
|
|
50
|
+
|
|
51
|
+
The rail is 252px as drawn, the brand row is the design's 34px tile plus
|
|
52
|
+
the product name, and the menu is printed in labelled sections.
|
|
53
|
+
|
|
54
|
+
The MENU ITSELF IS UNTOUCHED. Each application still builds its own array,
|
|
55
|
+
gated item by item on its own permissions, and it is rendered in exactly
|
|
56
|
+
that order; `utils/nav-sections.ts` only decides which label to print above
|
|
57
|
+
which item, and an item whose title it does not know keeps its place under
|
|
58
|
+
the heading it follows. Nothing here can add, hide, move or rename a menu
|
|
59
|
+
entry, and no route, permission or click handler is involved.
|
|
60
|
+
-->
|
|
31
61
|
<v-navigation-drawer
|
|
32
62
|
v-model="drawer"
|
|
33
|
-
|
|
63
|
+
:mobile-breakpoint="1024"
|
|
64
|
+
:width="252"
|
|
34
65
|
class="bg-brand-surface brand-drawer"
|
|
35
66
|
>
|
|
36
|
-
<
|
|
37
|
-
<
|
|
38
|
-
<v-
|
|
39
|
-
|
|
40
|
-
</v-list-item-title>
|
|
41
|
-
</v-list-item>
|
|
67
|
+
<div class="brand-drawer__brand">
|
|
68
|
+
<div class="brand-drawer__logo">
|
|
69
|
+
<v-icon :icon="brandIcon" size="19" />
|
|
70
|
+
</div>
|
|
42
71
|
|
|
43
|
-
<
|
|
72
|
+
<div class="brand-drawer__names">
|
|
73
|
+
<div class="brand-drawer__app">{{ props.title || APP_NAME }}</div>
|
|
74
|
+
<div class="brand-drawer__suite">iService 365</div>
|
|
75
|
+
</div>
|
|
76
|
+
</div>
|
|
77
|
+
|
|
78
|
+
<v-list>
|
|
79
|
+
<!--
|
|
80
|
+
The site selector, in the design's bordered card. The slot's CONTENTS
|
|
81
|
+
are whatever the application puts there, still inside the same
|
|
82
|
+
`v-list` it has always been inside - this only draws the box around it.
|
|
83
|
+
-->
|
|
84
|
+
<div v-if="$slots.action" class="brand-drawer__context">
|
|
85
|
+
<slot name="action" />
|
|
86
|
+
</div>
|
|
44
87
|
|
|
45
88
|
<!-- NAVIGATION ITEMS -->
|
|
46
|
-
<
|
|
47
|
-
v-for="(item, index) in
|
|
89
|
+
<template
|
|
90
|
+
v-for="({ item, section }, index) in sectionedItems"
|
|
48
91
|
:key="`${item.title}-${index}`"
|
|
49
|
-
location="right"
|
|
50
|
-
:disabled="!item.disabled"
|
|
51
92
|
>
|
|
52
|
-
<
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
93
|
+
<div v-if="section" class="brand-drawer__section">{{ section }}</div>
|
|
94
|
+
|
|
95
|
+
<v-tooltip location="right" :disabled="!item.disabled">
|
|
96
|
+
<template #activator="{ props: tooltipProps }">
|
|
97
|
+
<div v-bind="tooltipProps">
|
|
98
|
+
<NavigationItem
|
|
99
|
+
:title="item.title"
|
|
100
|
+
:icon="item.icon"
|
|
101
|
+
:route="item.route"
|
|
102
|
+
:children="item.children"
|
|
103
|
+
:link="item.link"
|
|
104
|
+
:class="{
|
|
105
|
+
'opacity-50 pointer-events-none': item.disabled,
|
|
106
|
+
}"
|
|
107
|
+
@click="() => handleClick(item)"
|
|
108
|
+
/>
|
|
109
|
+
</div>
|
|
110
|
+
</template>
|
|
111
|
+
|
|
112
|
+
<span>Please complete onboarding first</span>
|
|
113
|
+
</v-tooltip>
|
|
114
|
+
</template>
|
|
70
115
|
|
|
71
116
|
<!-- SERVICES SLOT -->
|
|
72
117
|
<slot name="services" />
|
|
@@ -75,6 +120,10 @@
|
|
|
75
120
|
</template>
|
|
76
121
|
|
|
77
122
|
<script setup lang="ts">
|
|
123
|
+
import { useDisplay } from "vuetify";
|
|
124
|
+
|
|
125
|
+
import { withSections } from "../../utils/nav-sections";
|
|
126
|
+
|
|
78
127
|
const props = defineProps({
|
|
79
128
|
navigationItems: { type: Array, required: true },
|
|
80
129
|
title: { type: String, default: "" },
|
|
@@ -85,6 +134,32 @@ const emit = defineEmits(["navigate"]);
|
|
|
85
134
|
const { drawer } = useLocal();
|
|
86
135
|
const { APP_NAME } = useRuntimeConfig().public;
|
|
87
136
|
const route = useRoute();
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* `drawer` is shared application state and it defaults to OPEN, which is right
|
|
140
|
+
* for a rail and wrong for an overlay - a phone would load with the menu
|
|
141
|
+
* covering the page behind a scrim. So the default follows the viewport, and a
|
|
142
|
+
* user who resizes past the breakpoint gets the state that suits where they
|
|
143
|
+
* landed.
|
|
144
|
+
*
|
|
145
|
+
* Only the DEFAULT is managed here. Once the hamburger has been used, the
|
|
146
|
+
* user's choice stands until the layout itself changes underneath them.
|
|
147
|
+
*/
|
|
148
|
+
const { mobile } = useDisplay({ mobileBreakpoint: 1024 });
|
|
149
|
+
|
|
150
|
+
watch(mobile, (isOverlay) => (drawer.value = !isOverlay), { immediate: true });
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* An overlay drawer that survives a navigation leaves the scrim over the page
|
|
154
|
+
* the user just asked for. A rail must NOT close - that would be a step added
|
|
155
|
+
* to every desktop navigation.
|
|
156
|
+
*/
|
|
157
|
+
watch(
|
|
158
|
+
() => route.fullPath,
|
|
159
|
+
() => {
|
|
160
|
+
if (mobile.value) drawer.value = false;
|
|
161
|
+
}
|
|
162
|
+
);
|
|
88
163
|
const orgId = computed(() => String(route.params.org ?? ""));
|
|
89
164
|
const siteId = computed(() => String(route.params.site ?? ""));
|
|
90
165
|
const { useHidFaceReaderMenu } = useHidNavigation();
|
|
@@ -118,6 +193,39 @@ const navigationItems = computed(() => {
|
|
|
118
193
|
return items;
|
|
119
194
|
});
|
|
120
195
|
|
|
196
|
+
const sectionedItems = computed(() => withSections(navigationItems.value));
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* The design's brand tile carries the product's own mark. One icon for eleven
|
|
200
|
+
* applications would be one application's icon on ten of them, so the tile
|
|
201
|
+
* follows `APP_NAME` - the same value the row already prints - and falls back
|
|
202
|
+
* to the suite's shield for anything not listed.
|
|
203
|
+
*/
|
|
204
|
+
const BRAND_ICONS: Record<string, string> = {
|
|
205
|
+
security: "mdi-shield-check",
|
|
206
|
+
"property-management": "mdi-office-building",
|
|
207
|
+
hygiene: "mdi-broom",
|
|
208
|
+
landscape: "mdi-flower",
|
|
209
|
+
"pest-control": "mdi-bug",
|
|
210
|
+
"pool-mgmt": "mdi-pool",
|
|
211
|
+
"mechanical-electrical": "mdi-flash",
|
|
212
|
+
admin: "mdi-shield-crown",
|
|
213
|
+
org: "mdi-domain",
|
|
214
|
+
account: "mdi-account-circle",
|
|
215
|
+
main: "mdi-view-grid",
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
const brandIcon = computed(() => {
|
|
219
|
+
// `APP_NAME` is an environment value and is spelled for people ("Property
|
|
220
|
+
// Management"), so it is normalised rather than matched literally.
|
|
221
|
+
const key = String(APP_NAME ?? "")
|
|
222
|
+
.toLowerCase()
|
|
223
|
+
.trim()
|
|
224
|
+
.replace(/[\s_]+/g, "-");
|
|
225
|
+
|
|
226
|
+
return BRAND_ICONS[key] ?? "mdi-shield-check";
|
|
227
|
+
});
|
|
228
|
+
|
|
121
229
|
const handleClick = (item: any) => {
|
|
122
230
|
if (item.disabled) return;
|
|
123
231
|
emit("navigate", item);
|
|
@@ -130,5 +238,165 @@ const handleClick = (item: any) => {
|
|
|
130
238
|
--v-border-opacity: 0.45;
|
|
131
239
|
--v-hover-opacity: 0.08;
|
|
132
240
|
--v-activated-opacity: 0.18;
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Phase 1 loaded Manrope and named it `--font-sans` but deliberately set it
|
|
244
|
+
* on nothing: switching eleven applications to a new typeface before a
|
|
245
|
+
* single surface had been rebuilt would have left the product looking
|
|
246
|
+
* half-migrated. The shell IS the surface Phase 2 rebuilds, so it is the
|
|
247
|
+
* first thing to wear it.
|
|
248
|
+
*/
|
|
249
|
+
font-family: var(--font-sans);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/* ---------------------------------------------------------------- */
|
|
253
|
+
/* The brand row: 34px accent tile + product name + suite name. */
|
|
254
|
+
/* ---------------------------------------------------------------- */
|
|
255
|
+
|
|
256
|
+
.brand-drawer__brand {
|
|
257
|
+
display: flex;
|
|
258
|
+
align-items: center;
|
|
259
|
+
gap: 10px;
|
|
260
|
+
padding: 14px 16px 10px;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
.brand-drawer__logo {
|
|
264
|
+
flex: 0 0 34px;
|
|
265
|
+
width: 34px;
|
|
266
|
+
height: 34px;
|
|
267
|
+
display: flex;
|
|
268
|
+
align-items: center;
|
|
269
|
+
justify-content: center;
|
|
270
|
+
border-radius: var(--r-inner);
|
|
271
|
+
/* A white glyph, so the fill has to be the one accent that carries white:
|
|
272
|
+
`--accent` is 3.23:1 under it, `--accent-strong` is 4.57:1. */
|
|
273
|
+
background: var(--accent-strong);
|
|
274
|
+
color: var(--on-accent-strong);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
.brand-drawer__names {
|
|
278
|
+
min-width: 0;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
.brand-drawer__app {
|
|
282
|
+
font-size: var(--fs-card-title);
|
|
283
|
+
font-weight: var(--fw-card-title);
|
|
284
|
+
line-height: 1.2;
|
|
285
|
+
color: var(--text);
|
|
286
|
+
overflow: hidden;
|
|
287
|
+
white-space: nowrap;
|
|
288
|
+
text-overflow: ellipsis;
|
|
289
|
+
text-transform: capitalize;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
.brand-drawer__suite {
|
|
293
|
+
font-size: 11.5px;
|
|
294
|
+
font-weight: 600;
|
|
295
|
+
line-height: 1.3;
|
|
296
|
+
color: var(--muted);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* The organisation / site selector, drawn as the design's bordered card.
|
|
301
|
+
*
|
|
302
|
+
* Two applications have no selector to draw: `web-app-admin` passes no slot at
|
|
303
|
+
* all (handled by the `v-if`), and `web-app-org`'s super-admin layout passes an
|
|
304
|
+
* EMPTY one - `<template #action> </template>` - which the `v-if` cannot see.
|
|
305
|
+
* An empty bordered box on that rail would be a box around nothing, so a card
|
|
306
|
+
* with no element inside it is not drawn.
|
|
307
|
+
*/
|
|
308
|
+
.brand-drawer__context:not(:has(*)) {
|
|
309
|
+
display: none;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
.brand-drawer__context {
|
|
313
|
+
margin: 2px 4px 4px;
|
|
314
|
+
border: 1px solid var(--border);
|
|
315
|
+
border-radius: var(--r-inner-lg);
|
|
316
|
+
background: var(--card);
|
|
317
|
+
overflow: hidden;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/* ---------------------------------------------------------------- */
|
|
321
|
+
/* Section labels and the menu items under them. */
|
|
322
|
+
/* ---------------------------------------------------------------- */
|
|
323
|
+
|
|
324
|
+
.brand-drawer__section {
|
|
325
|
+
padding: 14px 12px 6px;
|
|
326
|
+
font-size: var(--fs-section-label);
|
|
327
|
+
font-weight: var(--fw-section-label);
|
|
328
|
+
letter-spacing: var(--ls-section-label);
|
|
329
|
+
line-height: 1.2;
|
|
330
|
+
text-transform: uppercase;
|
|
331
|
+
color: var(--muted);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/* The first label sits directly under the site selector, so it does not need
|
|
335
|
+
the separating space the ones between two menu blocks do. */
|
|
336
|
+
.brand-drawer__section:first-child {
|
|
337
|
+
padding-top: 4px;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* `:deep` because the items are `NavigationItem`s - a child component - and
|
|
342
|
+
* the design's item shape (10px radius, 8px 12px, 13px label) belongs to the
|
|
343
|
+
* rail rather than to that component, which is also rendered elsewhere.
|
|
344
|
+
*
|
|
345
|
+
* ONLY shape and type. The active state's colour still comes from
|
|
346
|
+
* `color="primary"` on the item itself, which is `--accent-text`.
|
|
347
|
+
*/
|
|
348
|
+
.brand-drawer :deep(.v-list) {
|
|
349
|
+
padding: 0 8px 12px;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* The two `!important`s are not carelessness: Vuetify puts `rounded-0` and
|
|
354
|
+
* `text-subtitle-2` ON the item, and both of those are utility classes that
|
|
355
|
+
* are themselves `!important`. Nothing weaker can reach the design's 10px
|
|
356
|
+
* radius or its 13px label. Both are confined to `.brand-drawer`.
|
|
357
|
+
*/
|
|
358
|
+
.brand-drawer :deep(.v-list-item) {
|
|
359
|
+
border-radius: var(--r-inner) !important;
|
|
360
|
+
min-height: 38px;
|
|
361
|
+
padding-top: 8px;
|
|
362
|
+
padding-bottom: 8px;
|
|
363
|
+
padding-inline: 12px;
|
|
364
|
+
font-size: var(--fs-nav) !important;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
.brand-drawer :deep(.v-list-item__prepend > .v-icon) {
|
|
368
|
+
font-size: 19px;
|
|
369
|
+
opacity: 1;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/* Vuetify's own 16px gap between the icon and the label, brought to the 10px
|
|
373
|
+
the design draws. The spacer element is what holds that distance - a margin
|
|
374
|
+
on the icon leaves it where it was. */
|
|
375
|
+
.brand-drawer :deep(.v-list-item__spacer) {
|
|
376
|
+
width: 10px;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
.brand-drawer :deep(.v-list-item--active) {
|
|
380
|
+
font-weight: var(--fw-section-label);
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* The design's active item is a 12% accent wash, which is exactly what
|
|
385
|
+
* `--accent-soft` is. Vuetify paints the same idea with an overlay whose
|
|
386
|
+
* opacity is theme-wide, so the fill is stated here instead of leaving the
|
|
387
|
+
* rail's active row to a number tuned for tables and cards.
|
|
388
|
+
*/
|
|
389
|
+
.brand-drawer :deep(.v-list-item--active > .v-list-item__overlay) {
|
|
390
|
+
opacity: 0;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
.brand-drawer :deep(.v-list-item--active) {
|
|
394
|
+
background: var(--accent-soft);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/* A child row indents by the icon slot it does not have, so the labels of a
|
|
398
|
+
group line up with the labels of its siblings rather than with their icons. */
|
|
399
|
+
.brand-drawer :deep(.v-list-group__items .v-list-item) {
|
|
400
|
+
padding-inline-start: 41px !important;
|
|
133
401
|
}
|
|
134
402
|
</style>
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE USER'S LIGHT/DARK CHOICE, REMEMBERED.
|
|
3
|
+
*
|
|
4
|
+
* Until now `Layout/Header.vue` flipped `theme.global.name` and wrote nothing,
|
|
5
|
+
* so the choice was gone on the next reload.
|
|
6
|
+
*
|
|
7
|
+
* A COOKIE, NOT localStorage. The handoff says "persists in localStorage", and
|
|
8
|
+
* for a single-page prototype that is right - but this product is ELEVEN
|
|
9
|
+
* applications on eleven hostnames. localStorage is per-origin, so a user who
|
|
10
|
+
* picked dark mode in Security would land on a white screen in Property
|
|
11
|
+
* Management, and again in Account, and would have to set it eleven times.
|
|
12
|
+
*
|
|
13
|
+
* A cookie written with the layer's own `cookieConfig` carries the parent
|
|
14
|
+
* domain, which is exactly how `sid`, `user` and `landing-page` already travel
|
|
15
|
+
* between these applications. So this is the estate's existing preference
|
|
16
|
+
* mechanism rather than a new one, and it is also the only one that makes the
|
|
17
|
+
* setting mean what a user expects it to mean.
|
|
18
|
+
*
|
|
19
|
+
* NO FLASH OF THE WRONG THEME. Nothing here corrects the theme after mount -
|
|
20
|
+
* `plugins/vuetify.ts` reads the same cookie BEFORE `createVuetify` and boots
|
|
21
|
+
* on it, so the first paint is already right. The apps are `ssr: false`, so
|
|
22
|
+
* there is no server render to disagree with.
|
|
23
|
+
*/
|
|
24
|
+
import { useTheme } from "vuetify";
|
|
25
|
+
import type { CookieOptions } from "#app";
|
|
26
|
+
|
|
27
|
+
export type TThemeName = "light" | "dark";
|
|
28
|
+
|
|
29
|
+
/** The one place the cookie is named. `plugins/vuetify.ts` reads the same one. */
|
|
30
|
+
export const THEME_COOKIE = "theme";
|
|
31
|
+
|
|
32
|
+
export default function useThemePreference() {
|
|
33
|
+
// `runtimeConfig.public.cookieConfig` is typed `unknown`, so it is narrowed
|
|
34
|
+
// here rather than at every call site. Same object `sid`, `user` and
|
|
35
|
+
// `landing-page` are written with - it carries the shared parent domain.
|
|
36
|
+
const cookieConfig = useRuntimeConfig().public.cookieConfig as CookieOptions<
|
|
37
|
+
TThemeName | null
|
|
38
|
+
> & { readonly?: false };
|
|
39
|
+
|
|
40
|
+
const vuetifyTheme = useTheme();
|
|
41
|
+
|
|
42
|
+
const stored = useCookie<TThemeName | null>(THEME_COOKIE, cookieConfig);
|
|
43
|
+
|
|
44
|
+
const current = computed<TThemeName>(() =>
|
|
45
|
+
vuetifyTheme.global.current.value.dark ? "dark" : "light"
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Sets the theme AND remembers it. The write is the whole point - a caller
|
|
50
|
+
* that only wants a temporary theme for one subtree should use
|
|
51
|
+
* `<v-theme-provider>` instead, which is what `plain-dark` does.
|
|
52
|
+
*/
|
|
53
|
+
function setTheme(name: TThemeName) {
|
|
54
|
+
vuetifyTheme.global.name.value = name;
|
|
55
|
+
stored.value = name;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function toggleTheme() {
|
|
59
|
+
setTheme(current.value === "dark" ? "light" : "dark");
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return { current, setTheme, toggleTheme, stored };
|
|
63
|
+
}
|
package/nuxt.config.ts
CHANGED
|
@@ -7,6 +7,37 @@ export default defineNuxtConfig({
|
|
|
7
7
|
transpile: ["vuetify"],
|
|
8
8
|
},
|
|
9
9
|
|
|
10
|
+
/**
|
|
11
|
+
* THE REDESIGN'S TYPEFACE - LOADED, DELIBERATELY NOT APPLIED YET.
|
|
12
|
+
*
|
|
13
|
+
* `--font-sans` in `assets/css/tokens.css` names Manrope, and a token that
|
|
14
|
+
* names a font nobody loaded is a trap: the next phase writes
|
|
15
|
+
* `font-family: var(--font-sans)`, silently falls back to Arial, and looks
|
|
16
|
+
* fine to whoever wrote it. So the stylesheet is requested here.
|
|
17
|
+
*
|
|
18
|
+
* Nothing sets `font-family` on the page in Phase 1. Switching eleven
|
|
19
|
+
* applications to a new typeface before one screen has been rebuilt would
|
|
20
|
+
* leave the whole product looking half-migrated; each phase adopts the token
|
|
21
|
+
* as it rebuilds its own surfaces. Browsers do not download a webfont no
|
|
22
|
+
* element uses, so until then this costs one small CSS request.
|
|
23
|
+
*/
|
|
24
|
+
app: {
|
|
25
|
+
head: {
|
|
26
|
+
link: [
|
|
27
|
+
{ rel: "preconnect", href: "https://fonts.googleapis.com" },
|
|
28
|
+
{
|
|
29
|
+
rel: "preconnect",
|
|
30
|
+
href: "https://fonts.gstatic.com",
|
|
31
|
+
crossorigin: "",
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
rel: "stylesheet",
|
|
35
|
+
href: "https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200&display=swap",
|
|
36
|
+
},
|
|
37
|
+
],
|
|
38
|
+
},
|
|
39
|
+
},
|
|
40
|
+
|
|
10
41
|
runtimeConfig: {
|
|
11
42
|
public: {
|
|
12
43
|
cookieConfig: {
|
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "@7365admin1/layer-common",
|
|
3
3
|
"license": "MIT",
|
|
4
4
|
"type": "module",
|
|
5
|
-
"version": "3.2.2-staging.
|
|
5
|
+
"version": "3.2.2-staging.82",
|
|
6
6
|
"author": "7365admin1",
|
|
7
7
|
"main": "./nuxt.config.ts",
|
|
8
8
|
"publishConfig": {
|
|
@@ -14,7 +14,8 @@
|
|
|
14
14
|
"build": "nuxt build .playground",
|
|
15
15
|
"generate": "nuxt generate .playground",
|
|
16
16
|
"preview": "nuxt preview .playground",
|
|
17
|
-
"test": "esbuild composables/useVisitorSocket.ts --format=esm --outfile=test/.build/useVisitorSocket.mjs --log-level=error && node --test \"test/*.test.mjs\"",
|
|
17
|
+
"test": "esbuild composables/useVisitorSocket.ts --format=esm --outfile=test/.build/useVisitorSocket.mjs --log-level=error && node --test \"test/*.test.mjs\" && yarn test:units",
|
|
18
|
+
"test:units": "node --experimental-strip-types --test \"utils/*.test.ts\"",
|
|
18
19
|
"release": "yarn run build && changeset publish"
|
|
19
20
|
},
|
|
20
21
|
"devDependencies": {
|
package/plugins/vuetify.ts
CHANGED
|
@@ -43,8 +43,37 @@ import {
|
|
|
43
43
|
import "vuetify-pro-tiptap/style.css";
|
|
44
44
|
|
|
45
45
|
import { LIGHT_THEME, DARK_THEME } from "../utils/theme";
|
|
46
|
+
import { THEME_COOKIE } from "../composables/useThemePreference";
|
|
47
|
+
import type { CookieOptions } from "#app";
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The design tokens. Loaded HERE rather than in `nuxt.config.ts` so they land
|
|
51
|
+
* after `vuetify/styles` in the bundle - `tokens.css` aliases `--v-theme-*`,
|
|
52
|
+
* which Vuetify has to have emitted first.
|
|
53
|
+
*/
|
|
54
|
+
import "../assets/css/tokens.css";
|
|
55
|
+
import "../assets/css/shell.css";
|
|
46
56
|
|
|
47
57
|
export default defineNuxtPlugin((app) => {
|
|
58
|
+
/**
|
|
59
|
+
* BOOT ON THE REMEMBERED THEME, DON'T CORRECT IT AFTERWARDS.
|
|
60
|
+
*
|
|
61
|
+
* Reading the cookie here, before `createVuetify`, is what makes the choice
|
|
62
|
+
* survive a reload with no flash: Vuetify's first paint is already the right
|
|
63
|
+
* theme, so there is no moment where the wrong one is on screen. Setting it
|
|
64
|
+
* from a component's `onMounted` would repaint the whole application one
|
|
65
|
+
* frame in, which is the flash this is written to avoid.
|
|
66
|
+
*
|
|
67
|
+
* `useThemePreference()` writes this cookie; nothing else does.
|
|
68
|
+
*/
|
|
69
|
+
const stored = useCookie<string | null>(
|
|
70
|
+
THEME_COOKIE,
|
|
71
|
+
// typed `unknown` in runtimeConfig; same object `sid` and `user` use
|
|
72
|
+
useRuntimeConfig().public.cookieConfig as CookieOptions<string | null> & {
|
|
73
|
+
readonly?: false;
|
|
74
|
+
}
|
|
75
|
+
).value;
|
|
76
|
+
|
|
48
77
|
const vuetify = createVuetify({
|
|
49
78
|
defaults: {
|
|
50
79
|
VTextField: {
|
|
@@ -97,7 +126,7 @@ export default defineNuxtPlugin((app) => {
|
|
|
97
126
|
* `utils/theme.ts`, which `utils/theme.test.ts` measures on every run.
|
|
98
127
|
*/
|
|
99
128
|
theme: {
|
|
100
|
-
defaultTheme: "light",
|
|
129
|
+
defaultTheme: stored === "dark" ? "dark" : "light",
|
|
101
130
|
themes: {
|
|
102
131
|
light: LIGHT_THEME,
|
|
103
132
|
dark: DARK_THEME,
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { test } from "node:test";
|
|
3
|
+
|
|
4
|
+
import { NAV_SECTIONS, withSections } from "./nav-sections.ts";
|
|
5
|
+
|
|
6
|
+
/** The Security menu, item for item and in `web-app-security`'s own order. */
|
|
7
|
+
const SECURITY = [
|
|
8
|
+
"Dashboard",
|
|
9
|
+
"Feedbacks",
|
|
10
|
+
"Work Orders",
|
|
11
|
+
"Vehicle Mgmt",
|
|
12
|
+
"Building Mgmt",
|
|
13
|
+
"Visitor Mgmt",
|
|
14
|
+
"Pass & Key Mgmt",
|
|
15
|
+
"HID Face Reader",
|
|
16
|
+
"Daily Occurrence Books",
|
|
17
|
+
"Manpower",
|
|
18
|
+
"Virtual Patrol",
|
|
19
|
+
"Robot Mgmt",
|
|
20
|
+
"Incident Reports",
|
|
21
|
+
"Bulletin Board",
|
|
22
|
+
"Attendance",
|
|
23
|
+
"My Attendance",
|
|
24
|
+
"Invitations",
|
|
25
|
+
"Members",
|
|
26
|
+
"Roles & Permissions",
|
|
27
|
+
"Settings",
|
|
28
|
+
].map((title) => ({ title }));
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* THE RULE THAT MATTERS. A label is decoration; the menu is the product. If
|
|
32
|
+
* this ever fails, someone has taught the sidebar to reorder itself.
|
|
33
|
+
*/
|
|
34
|
+
test("the menu comes back in the order it went in, item for item", () => {
|
|
35
|
+
const out = withSections(SECURITY);
|
|
36
|
+
|
|
37
|
+
assert.equal(out.length, SECURITY.length);
|
|
38
|
+
out.forEach((entry, i) => {
|
|
39
|
+
assert.equal(entry.item, SECURITY[i], `position ${i}`);
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("Security is labelled the way the design draws it", () => {
|
|
44
|
+
const labelled = withSections(SECURITY)
|
|
45
|
+
.filter((e) => e.section)
|
|
46
|
+
.map((e) => `${e.section}: ${e.item.title}`);
|
|
47
|
+
|
|
48
|
+
assert.deepEqual(labelled, [
|
|
49
|
+
"OVERVIEW: Dashboard",
|
|
50
|
+
"SERVICE DESK: Feedbacks",
|
|
51
|
+
"PROPERTY: Vehicle Mgmt",
|
|
52
|
+
"SECURITY OPERATIONS: HID Face Reader",
|
|
53
|
+
"COMMUNITY: Bulletin Board",
|
|
54
|
+
"WORKFORCE: Attendance",
|
|
55
|
+
"ADMINISTRATION: Invitations",
|
|
56
|
+
]);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* A permission-gated menu is a SUBSET, and every application's is different.
|
|
61
|
+
* Dropping the items a user cannot see must not leave a heading behind or move
|
|
62
|
+
* one onto the wrong item.
|
|
63
|
+
*/
|
|
64
|
+
test("a user who can only see three modules gets three correct headings", () => {
|
|
65
|
+
const out = withSections(
|
|
66
|
+
[{ title: "Dashboard" }, { title: "Work Orders" }, { title: "Settings" }]
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
assert.deepEqual(
|
|
70
|
+
out.map((e) => e.section),
|
|
71
|
+
["OVERVIEW", "SERVICE DESK", "ADMINISTRATION"]
|
|
72
|
+
);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* The eleven applications add menu items without touching this layer. An
|
|
77
|
+
* unknown title must appear where its author put it, under the heading it
|
|
78
|
+
* follows - never suppressed, and never given a heading of its own.
|
|
79
|
+
*/
|
|
80
|
+
test("an unmapped module inherits the section above it", () => {
|
|
81
|
+
const out = withSections([
|
|
82
|
+
{ title: "Dashboard" },
|
|
83
|
+
{ title: "Something New" },
|
|
84
|
+
{ title: "Members" },
|
|
85
|
+
]);
|
|
86
|
+
|
|
87
|
+
assert.deepEqual(
|
|
88
|
+
out.map((e) => e.section),
|
|
89
|
+
["OVERVIEW", "", "ADMINISTRATION"]
|
|
90
|
+
);
|
|
91
|
+
assert.equal(out[1].item.title, "Something New");
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("an unmapped FIRST item prints no heading rather than a blank one", () => {
|
|
95
|
+
const out = withSections([{ title: "Something New" }, { title: "Members" }]);
|
|
96
|
+
|
|
97
|
+
assert.deepEqual(
|
|
98
|
+
out.map((e) => e.section),
|
|
99
|
+
["", "ADMINISTRATION"]
|
|
100
|
+
);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* The applications do not agree on order: Landscape lists Feedbacks after its
|
|
105
|
+
* equipment modules. The heading is printed again rather than left off, so no
|
|
106
|
+
* item ever sits under a heading that is not its own.
|
|
107
|
+
*/
|
|
108
|
+
test("a section that comes back later is labelled again, not orphaned", () => {
|
|
109
|
+
const out = withSections([
|
|
110
|
+
{ title: "Feedbacks" },
|
|
111
|
+
{ title: "Equipment Items" },
|
|
112
|
+
{ title: "Work Orders" },
|
|
113
|
+
]);
|
|
114
|
+
|
|
115
|
+
assert.deepEqual(
|
|
116
|
+
out.map((e) => e.section),
|
|
117
|
+
["SERVICE DESK", "EQUIPMENT", "SERVICE DESK"]
|
|
118
|
+
);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test("a missing or empty title does not throw", () => {
|
|
122
|
+
const out = withSections([{}, { title: "" }] as Array<{ title?: string }>);
|
|
123
|
+
assert.equal(out.length, 2);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
/** Every section name is the uppercase form the type scale is drawn for. */
|
|
127
|
+
test("section names are uppercase", () => {
|
|
128
|
+
for (const section of Object.values(NAV_SECTIONS)) {
|
|
129
|
+
assert.equal(section, section.toUpperCase(), section);
|
|
130
|
+
}
|
|
131
|
+
});
|