@codecavepro/brand 3.0.1 → 3.1.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.
@@ -237,7 +237,7 @@ withDefaults(defineProps<{
237
237
  position: absolute;
238
238
  top: 100%;
239
239
  left: 0;
240
- margin-top: 13px;
240
+ margin-top: 14px;
241
241
  width: max-content;
242
242
  opacity: 0;
243
243
  visibility: hidden;
@@ -245,6 +245,8 @@ withDefaults(defineProps<{
245
245
  padding: 1.25rem;
246
246
  z-index: 100;
247
247
  background: var(--color-surface-primary);
248
+ border: 1px solid var(--color-surface-quaternary);
249
+ border-top: 0px;
248
250
  -webkit-backdrop-filter: blur(24px);
249
251
  backdrop-filter: blur(24px);
250
252
  transition: opacity 0.2s ease, visibility 0.2s;
@@ -1,5 +1,6 @@
1
1
  <script setup lang="ts">
2
- import { computed } from 'vue';
2
+ import { computed, ref, onMounted, nextTick } from 'vue';
3
+ import Shevron from '../assets/icons/shevron.vue';
3
4
  import { type MenuItem } from '../lib/menus/menuTypes.ts';
4
5
 
5
6
  interface Props {
@@ -10,48 +11,131 @@ interface Props {
10
11
 
11
12
  const props = defineProps<Props>();
12
13
 
14
+ const openItem = ref<string | null>(null);
15
+
16
+ const itemRefs = new Map<string, HTMLElement>();
17
+
18
+ const submenuStyle = ref({
19
+ left: '0px',
20
+ width: '200px'
21
+ });
22
+
13
23
  const items = computed<MenuItem[]>(() => {
14
24
  return props.section ? props.subMenu[props.section] ?? [] : [];
15
25
  });
16
26
 
17
- if (
18
- props.current &&
19
- props.section &&
20
- !(props.subMenu[props.section] ?? []).some(i => i.name === props.current)
21
- ) {
22
- throw new Error(
23
- `SubNav: no item named "${props.current}" in SUB["${props.section}"]`
27
+ const activeSubmenu = computed(() => {
28
+ const item = items.value.find(i => i.name === openItem.value);
29
+ return item?.subMenu ?? [];
30
+ });
31
+
32
+ function setItemRef(name: string, el: Element | null) {
33
+ if (el instanceof HTMLElement) {
34
+ itemRefs.set(name, el);
35
+ } else {
36
+ itemRefs.delete(name);
37
+ }
38
+ }
39
+
40
+ function updateSubmenuPosition(itemName: string) {
41
+ if (window.innerWidth < 768) {
42
+ submenuStyle.value = {
43
+ left: '0px',
44
+ width: '100vw'
45
+ };
46
+ return;
47
+ }
48
+
49
+ const el = itemRefs.get(itemName);
50
+ const nav = document.querySelector('.ds-subnav');
51
+
52
+ if (!el || !nav) {
53
+ return;
54
+ }
55
+
56
+ const elRect = el.getBoundingClientRect();
57
+ const navRect = nav.getBoundingClientRect();
58
+
59
+ submenuStyle.value = {
60
+ left: `${elRect.left + elRect.width / 2 - navRect.left}px`,
61
+ width: `${elRect.width}px`
62
+ };
63
+ }
64
+
65
+ function openSubmenu(item: MenuItem) {
66
+ if (!item.subMenu?.length) {
67
+ return;
68
+ }
69
+
70
+ if (openItem.value === item.name) {
71
+ openItem.value = null;
72
+ return;
73
+ }
74
+
75
+ updateSubmenuPosition(item.name);
76
+ openItem.value = item.name;
77
+ }
78
+ function closeSubmenu(event?: Event) {
79
+ event?.stopPropagation();
80
+ openItem.value = null;
81
+ }
82
+ async function initSubmenu() {
83
+ await nextTick();
84
+
85
+ const activeItem = items.value.find(
86
+ item => item.name === props.current && item.subMenu?.length
24
87
  );
88
+
89
+ if (!activeItem) {
90
+ openItem.value = null;
91
+ return;
92
+ }
93
+
94
+ updateSubmenuPosition(activeItem.name);
95
+ openItem.value = activeItem.name;
25
96
  }
97
+
98
+ onMounted(async () => {
99
+ await initSubmenu();
100
+ });
101
+
26
102
  </script>
27
103
 
28
104
  <template>
29
- <nav
30
- v-if="items.length"
31
- class="ds-subnav"
32
- aria-label="Section"
33
- >
34
- <div class="ds-subnav-inner">
105
+ <nav v-if="items.length" class="ds-subnav" aria-label="Section">
106
+ <div class="ds-subnav-inner" :class="{ 'is-submenu-open': openItem }">
35
107
  <ul>
36
- <li
37
- v-for="item in items"
38
- :key="item.name"
39
- >
40
- <a
41
- class="ds-subnav-link"
42
- :href="item.href"
43
- :aria-current="item.name === current ? 'page' : undefined"
44
- >
45
- {{ item.label }}
46
- </a>
108
+ <li v-for="item in items" :key="item.name" :ref="el => setItemRef(item.name, el)">
109
+ <div class="ds-subnav-item" @click="item.subMenu?.length ? openSubmenu(item) : closeSubmenu()">
110
+ <component :is="item.href ? 'a' : 'span'" class="ds-subnav-link" :href="item.href" :aria-current="item.name === current ? 'page' : undefined">
111
+ {{ item.label }}
112
+ </component>
113
+
114
+ <span v-if="item.subMenu?.length" class="ds-subnav-toggle" aria-hidden="true">
115
+ <Shevron class="ds-subnav-chevron" :class="{ 'is-open': openItem === item.name }" />
116
+ </span>
117
+ </div>
47
118
  </li>
48
119
  </ul>
49
120
  </div>
121
+
122
+ <Transition name="submenu">
123
+ <div class="ds-submenu-overlay" :class="{ 'is-open': activeSubmenu.length }" :style="submenuStyle">
124
+ <ul class="ds-submenu-list">
125
+ <li v-for="(subItem, index) in activeSubmenu" :key="subItem.name" class="ds-submenu-item" :style="{ '--delay': `${index * 80}ms` }">
126
+ <a :href="subItem.href" @click="closeSubmenu" class="ds-submenu-link">
127
+ {{ subItem.label }}
128
+ </a>
129
+ </li>
130
+ </ul>
131
+ </div>
132
+ </Transition>
50
133
  </nav>
51
134
  </template>
52
135
 
53
136
  <style>
54
137
  .ds-subnav {
138
+ position: relative;
55
139
  border-bottom: 1px solid var(--color-surface-quaternary);
56
140
  background: var(--color-surface-primary-transparent);
57
141
  -webkit-backdrop-filter: blur(24px);
@@ -67,6 +151,10 @@ if (
67
151
  overflow-y: hidden;
68
152
  }
69
153
 
154
+ .ds-subnav-inner.is-submenu-open {
155
+ overflow: hidden;
156
+ }
157
+
70
158
  @media (min-width: 768px) {
71
159
  .ds-subnav-inner {
72
160
  padding-left: var(--gutter-md);
@@ -81,7 +169,6 @@ if (
81
169
  }
82
170
  }
83
171
 
84
- /* center links when there is enough space */
85
172
  .ds-subnav ul {
86
173
  display: flex;
87
174
  align-items: center;
@@ -89,13 +176,11 @@ if (
89
176
 
90
177
  margin: 0;
91
178
  padding: 0;
179
+
92
180
  list-style: none;
93
181
 
94
- /* allow overflow while staying centered */
95
182
  width: max-content;
96
183
  min-width: 100%;
97
-
98
- /* prevents wrapping */
99
184
  flex-wrap: nowrap;
100
185
  }
101
186
 
@@ -104,6 +189,12 @@ if (
104
189
  flex-shrink: 0;
105
190
  }
106
191
 
192
+ .ds-subnav-item {
193
+ display: flex;
194
+ align-items: center;
195
+ cursor: pointer;
196
+ }
197
+
107
198
  .ds-subnav-link {
108
199
  box-sizing: border-box;
109
200
  display: inline-flex;
@@ -125,7 +216,139 @@ if (
125
216
 
126
217
  .ds-subnav-link:hover,
127
218
  .ds-subnav-link:active,
128
- .ds-subnav-link[aria-current="page"] {
219
+ .ds-subnav-link[aria-current='page'] {
220
+ color: var(--color-hovered);
221
+ }
222
+
223
+ .ds-subnav-toggle {
224
+ display: inline-flex;
225
+ align-items: center;
226
+ justify-content: center;
227
+
228
+ margin-left: -0.75rem;
229
+ padding: 0;
230
+
231
+ border: 0;
232
+ background: transparent;
233
+ pointer-events: none;
234
+ }
235
+
236
+ .ds-subnav-chevron {
237
+ width: 1rem;
238
+ height: 1rem;
239
+
240
+ color: var(--color-heading);
241
+ pointer-events: none;
242
+
243
+ transition:
244
+ transform 0.25s ease,
245
+ color 0.25s ease;
246
+
247
+ }
248
+
249
+ .ds-subnav-item:hover .ds-subnav-chevron {
129
250
  color: var(--color-hovered);
130
251
  }
252
+
253
+ .ds-subnav-chevron.is-open {
254
+ transform: rotate(180deg);
255
+ }
256
+
257
+ .ds-submenu-overlay {
258
+ position: absolute;
259
+ top: calc(100% + 1px);
260
+ transform: translateX(-50%);
261
+ z-index: 100;
262
+
263
+ min-width: max-content;
264
+
265
+ visibility: hidden;
266
+ opacity: 0;
267
+ pointer-events: none;
268
+
269
+ background: var(--color-surface-primary);
270
+
271
+ border: 1px solid var(--color-surface-quaternary);
272
+ border-top: 0;
273
+ border-radius: 0 0 1rem 1rem;
274
+ }
275
+
276
+ .ds-submenu-overlay.is-open {
277
+ visibility: visible;
278
+ opacity: 1;
279
+ pointer-events: auto;
280
+ }
281
+
282
+ .ds-submenu-list {
283
+ display: flex;
284
+ flex-direction: column;
285
+
286
+ margin: 0;
287
+ padding: 0.5rem 0;
288
+
289
+ min-width: 200px;
290
+ max-width: 280px;
291
+
292
+ list-style: none;
293
+ }
294
+
295
+ .ds-submenu-item {
296
+ margin: 0;
297
+ padding: 0;
298
+ }
299
+
300
+ .ds-submenu-link {
301
+ box-sizing: border-box;
302
+ --delay: 0ms;
303
+ display: block;
304
+
305
+ padding: 0.5rem 1.5rem;
306
+
307
+ color: var(--color-body-primary);
308
+
309
+ font-family: var(--font-sans);
310
+ font-size: var(--text-body);
311
+ font-weight: var(--font-weight-bold);
312
+ line-height: var(--leading-body);
313
+
314
+ text-align: center;
315
+ text-decoration: none;
316
+
317
+ opacity: 0;
318
+ transform: translateY(8px);
319
+
320
+ animation: submenuTextIn 300ms ease forwards;
321
+ animation-delay: var(--delay);
322
+
323
+ transition: color var(--transition-colors);
324
+ }
325
+
326
+ .ds-submenu-link:hover {
327
+ color: var(--color-hovered);
328
+ }
329
+
330
+ @keyframes submenuTextIn {
331
+ from {
332
+ opacity: 0;
333
+ transform: translateY(8px);
334
+ }
335
+
336
+ to {
337
+ opacity: 1;
338
+ transform: translateY(0);
339
+ }
340
+ }
341
+
342
+ @media (max-width: 767px) {
343
+ .ds-submenu-overlay {
344
+ left: 0 !important;
345
+ width: 100vw !important;
346
+ transform: none;
347
+ }
348
+
349
+ .ds-submenu-list {
350
+ width: 100%;
351
+ max-width: none;
352
+ }
353
+ }
131
354
  </style>
@@ -3,5 +3,6 @@ export interface MenuItem {
3
3
  name: string;
4
4
  label: string;
5
5
  /** Relative to the docs root; the renderer prefixes `up`. */
6
- href: string;
6
+ href?: string;
7
+ subMenu?: MenuItem[]
7
8
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@codecavepro/brand",
3
- "version": "3.0.1",
3
+ "version": "3.1.0",
4
4
  "description": "CODECAVE design system — colour, typography and layout tokens as CSS custom properties and as a typed module.",
5
5
  "license": "Unlicense",
6
6
  "type": "module",