ketekny-ui-kit 1.0.90 → 1.0.92

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/README.md CHANGED
@@ -46,6 +46,7 @@ import {
46
46
  kDateSelector,
47
47
  kToggle,
48
48
  kCheckbox,
49
+ twoColLayout,
49
50
  toastPlugin,
50
51
  confirmPlugin,
51
52
  alertPlugin,
@@ -61,6 +62,7 @@ app.component("kSelect", kSelect);
61
62
  app.component("kDateSelector", kDateSelector);
62
63
  app.component("kToggle", kToggle);
63
64
  app.component("kCheckbox", kCheckbox);
65
+ app.component("twoColLayout", twoColLayout);
64
66
 
65
67
  app.use(toastPlugin);
66
68
  app.use(confirmPlugin);
@@ -92,6 +94,44 @@ function save() {
92
94
  </script>
93
95
  ```
94
96
 
97
+ Two-column layout usage:
98
+
99
+ ```vue
100
+ <template>
101
+ <twoColLayout
102
+ title="App title"
103
+ :main-menu="menu"
104
+ :account="account"
105
+ @select-menu="handleSelect"
106
+ >
107
+ <kSingleColPage app-title="Demo" page-title="Dashboard">
108
+ <div class="p-6">Page content</div>
109
+ </kSingleColPage>
110
+ </twoColLayout>
111
+ </template>
112
+
113
+ <script setup>
114
+ import { ref } from "vue";
115
+ import { kSingleColPage } from "ketekny-ui-kit";
116
+
117
+ const account = ref({
118
+ loggedIn: true,
119
+ firstName: "Demo",
120
+ lastName: "User",
121
+ email: "demo@ketekny.gr",
122
+ });
123
+
124
+ const menu = [
125
+ { id: "dashboard", label: "Dashboard", icon: "LayoutDashboard" },
126
+ { id: "reports", label: "Reports", icon: "BarChart3" },
127
+ ];
128
+
129
+ function handleSelect(id) {
130
+ console.log("Selected menu item:", id);
131
+ }
132
+ </script>
133
+ ```
134
+
95
135
  ## Tailwind Setup
96
136
 
97
137
  Use the provided preset to include semantic tokens and safelist patterns used by dynamic component classes.
@@ -165,6 +205,7 @@ Components:
165
205
  - `kSelectButton`, `kTags`, `kSearch`, `kArrayList`, `kList`, `kTextArea`
166
206
  - `kDialog`, `kDrawer`
167
207
  - `kAppHeader`, `kAppFooter`, `kAppMain`, `kHero`
208
+ - `kSingleColPage`, `kTwoColPage`, `twoColLayout`, `legacyTwoColLayout`
168
209
 
169
210
  Other exports:
170
211
 
@@ -202,4 +243,3 @@ ISC
202
243
  - Live demo/docs: https://ui.ketekny.gr
203
244
  - npm: https://www.npmjs.com/package/ketekny-ui-kit
204
245
 
205
-
package/index.js CHANGED
@@ -38,7 +38,8 @@ import kAppMain from './src/layout/kAppMain.vue'
38
38
  import kHero from './src/layout/kHero.vue'
39
39
  import kSingleColPage from './src/layout/kSingleColPage.vue'
40
40
  import kTwoColPage from './src/layout/kTwoColPage.vue'
41
- import twoColLayout from './src/layout/twoColLayout.vue'
41
+ import twoColLayout from './src/twoColLayout/components/TwoColLayout.vue'
42
+ import legacyTwoColLayout from './src/layout/twoColLayout.vue'
42
43
 
43
44
  // Toast/Confirm/Alert Plugins
44
45
  import toastPlugin from './src/plugins/toastPlugin.js'
@@ -62,7 +63,7 @@ export {
62
63
  kDialog, kDrawer,
63
64
 
64
65
  // Layout Components
65
- kAppHeader, kAppFooter, kAppMain, kHero, kSingleColPage, kTwoColPage, twoColLayout,
66
+ kAppHeader, kAppFooter, kAppMain, kHero, kSingleColPage, kTwoColPage, twoColLayout, legacyTwoColLayout,
66
67
 
67
68
  // Plugins
68
69
  toastPlugin, confirmPlugin, alertPlugin, tooltipPlugin, inputDialogPlugin,
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "ketekny-ui-kit",
3
3
  "type": "module",
4
- "version": "1.0.90",
4
+ "version": "1.0.92",
5
5
  "description": "A Vue 3 UI component library with Tailwind CSS styling",
6
6
  "main": "index.js",
7
7
  "files": [
@@ -1,48 +1,17 @@
1
- @import url("https://fonts.googleapis.com/css2?family=Ubuntu:wght@300;400;500;700&display=swap");
1
+ @import url('https://fonts.googleapis.com/css2?family=Ubuntu:wght@400;500;700&display=swap');
2
2
 
3
3
  @tailwind base;
4
4
  @tailwind components;
5
5
  @tailwind utilities;
6
6
 
7
- body {
8
- font-family: "Ubuntu", Helvetica, Arial, sans-serif;
9
- }
10
-
11
-
12
- h1, h2, h3, h4{
13
- color: theme('colors.primary');
14
- font-weight: bold;
15
- line-height: 1.2;
16
- }
17
- h1{
18
- font-size: clamp(1.625rem, 4vw + 0.75rem, 2.5rem);
19
- }
20
-
21
- h2{
22
- font-size: clamp(1.375rem, 3vw + 0.7rem, 2rem);
23
- }
24
- h3{
25
- font-size: clamp(1.125rem, 2vw + 0.6rem, 1.5rem);
7
+ html,
8
+ body,
9
+ #app {
10
+ height: 100%;
26
11
  }
27
- h4{
28
- font-size: clamp(1rem, 1.5vw + 0.55rem, 1.25rem);
29
- }
30
-
31
- .group{
32
- border: 1px dotted #ccc;
33
- padding: 10px;
34
- border-radius: 5px;
35
- }
36
-
37
- .inputLabel{
38
- font-weight: bold;
39
- color: theme('colors.primary');
40
- }
41
-
42
- .menuList{
43
- @apply p-2;
44
- li{
45
- @apply p-2 rounded cursor-pointer ;
46
12
 
47
- }
13
+ body {
14
+ margin: 0;
15
+ font-family: 'Ubuntu', ui-sans-serif, system-ui, sans-serif;
16
+ -webkit-font-smoothing: antialiased;
48
17
  }
@@ -0,0 +1,422 @@
1
+ <script>
2
+ import {
3
+ Search,
4
+ Bell,
5
+ Building2,
6
+ CircleUser,
7
+ CircleHelp,
8
+ X,
9
+ LogOut,
10
+ PanelLeftClose,
11
+ PanelLeftOpen,
12
+ UserRound,
13
+ } from '@lucide/vue'
14
+ import SidebarMenuItem from './SidebarMenuItem.vue'
15
+
16
+ export default {
17
+ name: 'MultilevelSidebar',
18
+ components: {
19
+ Search,
20
+ Bell,
21
+ Building2,
22
+ CircleUser,
23
+ CircleHelp,
24
+ X,
25
+ LogOut,
26
+ PanelLeftClose,
27
+ PanelLeftOpen,
28
+ UserRound,
29
+ SidebarMenuItem,
30
+ },
31
+ props: {
32
+ title: {
33
+ type: String,
34
+ default: "",
35
+ },
36
+ width: {
37
+ type: String,
38
+ default: "360px",
39
+ },
40
+ collapsedWidth: {
41
+ type: String,
42
+ default: "76px",
43
+ },
44
+ mainMenu: {
45
+ type: Array,
46
+ default: () => [],
47
+ },
48
+ account: {
49
+ type: Object,
50
+ default: null,
51
+ },
52
+ contactEmail: {
53
+ type: String,
54
+ default: "grammateia@ketekny.gr",
55
+ },
56
+ helpUrl: {
57
+ type: String,
58
+ default: "https://help.ketekny.gr",
59
+ },
60
+ mobileOpen: {
61
+ type: Boolean,
62
+ default: false,
63
+ },
64
+ },
65
+ emits: ['select', 'logo-click', 'signin', 'signup', 'signout', 'edit-profile', 'item-click'],
66
+ data() {
67
+ return {
68
+ activeId: 'dashboard',
69
+ query: '',
70
+ collapsed: false,
71
+ openIds: new Set(['projects']),
72
+ footerLinks: [
73
+ { id: 'my-profile', label: 'Το προφίλ μου', icon: 'UserRound' },
74
+ { id: 'help', label: 'Βοήθεια', icon: 'CircleHelp' },
75
+ { id: 'sign-out', label: 'Αποσύνδεση', icon: 'LogOut' },
76
+ ],
77
+ }
78
+ },
79
+ computed: {
80
+ sidebarWidthStyle() {
81
+ return {
82
+ width: this.collapsed ? this.collapsedWidth : this.width,
83
+ }
84
+ },
85
+ collapsedTitle() {
86
+ if (!this.title) return ''
87
+ const words = this.title.trim().split(/\s+/).filter(Boolean)
88
+ return words.slice(0, 3).map(word => word.charAt(0)).join('').toUpperCase()
89
+ },
90
+ accountDisplayName() {
91
+ if (!this.account) return 'Ava Mitchell'
92
+ return [this.account.firstName, this.account.lastName].filter(Boolean).join(' ') || 'Account'
93
+ },
94
+ accountEmail() {
95
+ return this.account?.email || 'ava@nimbus.io'
96
+ },
97
+ filteredMenu() {
98
+ const q = this.query.trim().toLowerCase()
99
+ if (!q) return this.mainMenu
100
+ return this.filterTree(this.mainMenu, q)
101
+ },
102
+ effectiveOpenIds() {
103
+ const q = this.query.trim().toLowerCase()
104
+ if (!q) return this.openIds
105
+ const all = new Set()
106
+ const collect = (nodes) => {
107
+ for (const node of nodes) {
108
+ if (node.children) {
109
+ all.add(node.id)
110
+ collect(node.children)
111
+ }
112
+ }
113
+ }
114
+ collect(this.filteredMenu)
115
+ return all
116
+ },
117
+ },
118
+ methods: {
119
+ toggleCollapsed() {
120
+ this.collapsed = !this.collapsed
121
+ },
122
+ toggle(id) {
123
+ if (this.openIds.has(id)) this.openIds.delete(id)
124
+ else this.openIds.add(id)
125
+ },
126
+ select(itemOrId) {
127
+ const id = typeof itemOrId === 'string' ? itemOrId : itemOrId?.id
128
+ if (!id) return
129
+ this.activeId = id
130
+ this.$emit('select', id)
131
+ },
132
+ navigateItem(item) {
133
+ const to = item?.link?.to ?? item?.to
134
+ const href = item?.link?.href ?? item?.href
135
+
136
+ if (to && this.$router) {
137
+ this.$router.push(to).catch(() => {})
138
+ return true
139
+ }
140
+
141
+ if (href) {
142
+ window.open(href, '_blank', 'noopener,noreferrer')
143
+ return true
144
+ }
145
+
146
+ return false
147
+ },
148
+ filterTree(nodes, q) {
149
+ const result = []
150
+ for (const node of nodes) {
151
+ const matches = node.label.toLowerCase().includes(q)
152
+ const matchedChildren = node.children ? this.filterTree(node.children, q) : []
153
+ if (matches) result.push(node)
154
+ else if (matchedChildren.length) result.push({ ...node, children: matchedChildren })
155
+ }
156
+ return result
157
+ },
158
+ handleFooterSelect(id) {
159
+ if (id === 'help') {
160
+ window.open(this.helpUrl || 'https://help.ketekny.gr', '_blank', 'noopener,noreferrer')
161
+ return
162
+ }
163
+ if (id === 'my-profile') {
164
+ this.$emit('edit-profile')
165
+ return
166
+ }
167
+ if (id === 'sign-out') {
168
+ this.$emit('signout')
169
+ return
170
+ }
171
+ this.select(id)
172
+ },
173
+ handleLogoClick() {
174
+ this.$emit('logo-click')
175
+ },
176
+ handleMenuSelect(item) {
177
+ const isExternalLink = Boolean(item?.link?.href ?? item?.href)
178
+ if (!isExternalLink) {
179
+ this.select(item)
180
+ }
181
+ this.$emit('item-click', item)
182
+ this.navigateItem(item)
183
+ },
184
+ clearQuery() {
185
+ this.query = ''
186
+ },
187
+ },
188
+ }
189
+ </script>
190
+
191
+ <template>
192
+ <aside
193
+ class="fixed inset-y-0 left-0 z-40 flex h-full shrink-0 flex-col border-r border-sidebar-border bg-sidebar text-sidebar-foreground transition-[width,transform] duration-200 ease-out lg:static lg:translate-x-0"
194
+ :style="sidebarWidthStyle"
195
+ :class="[
196
+ mobileOpen ? 'translate-x-0' : '-translate-x-full lg:translate-x-0',
197
+ ]"
198
+ >
199
+ <div class="flex items-center px-4 py-4" :class="collapsed ? 'justify-center' : 'gap-2.5'">
200
+ <img
201
+ :src="
202
+ collapsed
203
+ ? 'https://s3.ketekny.gr/public/web-apps/logos/logo-icon-dark.png'
204
+ : 'https://support.ketekny.gr/logo-dark.png'
205
+ "
206
+ alt="Ketekny logo"
207
+ class="object-contain"
208
+ :class="collapsed ? 'size-9 shrink-0 rounded-lg' : 'h-10 min-w-0 flex-1'"
209
+ @click="handleLogoClick"
210
+ />
211
+ <button
212
+ v-if="!collapsed"
213
+ type="button"
214
+ class="ml-auto hidden rounded-md p-1.5 text-sidebar-muted hover:bg-sidebar-accent hover:text-sidebar-foreground lg:inline-flex"
215
+ aria-label="Collapse sidebar"
216
+ @click="toggleCollapsed"
217
+ >
218
+ <PanelLeftClose class="size-4" :stroke-width="2" />
219
+ </button>
220
+ </div>
221
+
222
+ <div v-if="title && !collapsed" class="px-4 pb-2">
223
+ <p class="text-xl font-bold text-center text-sidebar-foreground">
224
+ {{ title }}
225
+ </p>
226
+ </div>
227
+
228
+ <div v-else-if="title && collapsed" class="px-3 pb-2">
229
+ <div
230
+ v-tooltip.right="title"
231
+ class="flex h-10 w-10 items-center justify-center rounded-md border border-sidebar-border/30 bg-sidebar-accent/30 text-[10px] font-bold tracking-[0.18em] text-sidebar-muted"
232
+ >
233
+ {{ collapsedTitle }}
234
+ </div>
235
+ </div>
236
+
237
+ <div v-if="collapsed" class="justify-center hidden px-3 pb-1 lg:flex">
238
+ <button
239
+ type="button"
240
+ v-tooltip.right="'Ανάπτυξη πλαϊνού μενού'"
241
+ class="flex h-10 w-10 items-center justify-center rounded-md text-sidebar-muted hover:bg-sidebar-accent hover:text-sidebar-foreground"
242
+ aria-label="Ανάπτυξη πλαϊνού μενού"
243
+ @click="toggleCollapsed"
244
+ >
245
+ <PanelLeftOpen class="size-4" :stroke-width="2" />
246
+ </button>
247
+ </div>
248
+
249
+ <div v-if="!collapsed" class="px-3 pb-2">
250
+ <div class="relative">
251
+ <Search
252
+ class="pointer-events-none absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-sidebar-muted"
253
+ :stroke-width="2"
254
+ />
255
+ <input
256
+ v-model="query"
257
+ type="text"
258
+ placeholder="Αναζήτηση μενού..."
259
+ aria-label="Αναζήτηση μενού"
260
+ class="w-full py-2 pl-8 pr-9 text-sm border rounded-md border-sidebar-border/35 bg-sidebar-accent/40 text-sidebar-foreground placeholder:text-sidebar-muted focus:border-white/15 focus:outline-none focus:ring-1 focus:ring-white/10"
261
+ />
262
+ <button
263
+ v-if="query"
264
+ type="button"
265
+ class="absolute right-2 top-1/2 inline-flex size-6 -translate-y-1/2 items-center justify-center rounded-md text-sidebar-muted transition-colors hover:bg-sidebar-accent hover:text-sidebar-foreground"
266
+ aria-label="Καθαρισμός αναζήτησης"
267
+ @click="clearQuery"
268
+ >
269
+ <X class="size-3.5" :stroke-width="2" />
270
+ </button>
271
+ </div>
272
+ </div>
273
+
274
+ <nav
275
+ class="flex-1 px-3 py-2"
276
+ :class="collapsed ? 'overflow-visible' : 'overflow-y-auto'"
277
+ aria-label="Main navigation"
278
+ >
279
+ <ul class="flex flex-col gap-0.5">
280
+ <SidebarMenuItem
281
+ v-for="item in filteredMenu"
282
+ :key="item.id"
283
+ :item="item"
284
+ :level="0"
285
+ :active-id="activeId"
286
+ :open-ids="effectiveOpenIds"
287
+ :collapsed="collapsed"
288
+ @select="handleMenuSelect"
289
+ @toggle="toggle"
290
+ />
291
+ </ul>
292
+ <p
293
+ v-if="!collapsed && filteredMenu.length === 0"
294
+ class="px-3 py-6 text-sm text-center text-sidebar-muted"
295
+ >
296
+ Δεν βρέθηκαν αποτελέσματα.
297
+ </p>
298
+ </nav>
299
+
300
+ <div class="p-3 mt-auto border-t border-sidebar-border">
301
+ <div
302
+ class="flex items-center py-2 rounded-md hover:bg-sidebar-accent"
303
+ :class="collapsed ? 'justify-center px-0' : 'gap-2.5 px-2'"
304
+ >
305
+ <CircleUser class="size-8 shrink-0 text-sidebar-muted" :stroke-width="1.75" />
306
+ <div v-if="!collapsed" class="flex-1 min-w-0">
307
+ <p class="text-sm font-medium truncate text-sidebar-foreground">{{ accountDisplayName }}</p>
308
+ <p class="text-xs truncate text-sidebar-muted">{{ accountEmail }}</p>
309
+ </div>
310
+ <button
311
+ v-if="!collapsed"
312
+ type="button"
313
+ class="rounded-md p-1.5 text-sidebar-muted hover:bg-sidebar-accent hover:text-sidebar-foreground"
314
+ aria-label="Notifications"
315
+ >
316
+ <Bell class="size-4" :stroke-width="2" />
317
+ </button>
318
+ </div>
319
+
320
+ <div
321
+ class="pt-2 mt-2 border-t border-sidebar-border/70"
322
+ :class="collapsed ? 'flex flex-col items-center gap-1' : 'space-y-1'"
323
+ >
324
+ <button
325
+ v-for="link in footerLinks"
326
+ :key="link.id"
327
+ v-if="collapsed"
328
+ type="button"
329
+ v-tooltip.right="link.label"
330
+ class="flex items-center w-full text-sm transition-colors duration-150 rounded-md"
331
+ :class="[
332
+ 'justify-center px-0 py-2',
333
+ activeId === link.id
334
+ ? 'bg-brand text-brand-foreground font-medium'
335
+ : link.id === 'sign-out'
336
+ ? 'text-orange-300 hover:bg-orange-500/10 hover:text-orange-200'
337
+ : 'text-sidebar-foreground/80 hover:bg-sidebar-accent hover:text-sidebar-foreground',
338
+ ]"
339
+ :aria-label="collapsed ? link.label : undefined"
340
+ @click="handleFooterSelect(link.id)"
341
+ >
342
+ <component
343
+ :is="link.icon"
344
+ class="size-4 shrink-0"
345
+ :class="
346
+ activeId === link.id
347
+ ? 'text-brand-foreground'
348
+ : link.id === 'sign-out'
349
+ ? 'text-orange-400'
350
+ : 'text-sidebar-muted'
351
+ "
352
+ :stroke-width="2"
353
+ />
354
+ </button>
355
+ <button
356
+ v-for="link in footerLinks"
357
+ :key="`${link.id}-expanded`"
358
+ v-else
359
+ type="button"
360
+ class="flex items-center w-full text-sm transition-colors duration-150 rounded-md"
361
+ :class="[
362
+ 'gap-2.5 px-2 py-2 text-left',
363
+ activeId === link.id
364
+ ? 'bg-brand text-brand-foreground font-medium'
365
+ : link.id === 'sign-out'
366
+ ? 'text-orange-300 hover:bg-orange-500/10 hover:text-orange-200'
367
+ : 'text-sidebar-foreground/80 hover:bg-sidebar-accent hover:text-sidebar-foreground',
368
+ ]"
369
+ @click="handleFooterSelect(link.id)"
370
+ >
371
+ <component
372
+ :is="link.icon"
373
+ class="size-4 shrink-0"
374
+ :class="
375
+ activeId === link.id
376
+ ? 'text-brand-foreground'
377
+ : link.id === 'sign-out'
378
+ ? 'text-orange-400'
379
+ : 'text-sidebar-muted'
380
+ "
381
+ :stroke-width="2"
382
+ />
383
+ <span class="truncate">{{ link.label }}</span>
384
+ </button>
385
+ </div>
386
+
387
+ <div
388
+ v-if="!collapsed"
389
+ class="mt-3 border-t border-sidebar-border/70 px-2 pt-2 text-[11px] leading-snug text-sidebar-foreground/90"
390
+ >
391
+ <div class="flex items-center gap-1.5 text-sidebar-foreground">
392
+ <Building2 class="size-3 shrink-0 text-sidebar-muted" :stroke-width="2" />
393
+ <p class="font-semibold uppercase tracking-[0.28em]">KETEKNY AE</p>
394
+ </div>
395
+ <div class="mt-1.5 pl-[18px]">
396
+ <p class="truncate ">Βερανζέρου 13, 10677, Αθήνα
397
+ <span>&nbsp;</span>
398
+ <a href="tel:+302103648337" class="transition-colors hover:text-sidebar-foreground">
399
+ 210 3648 337
400
+ </a></p>
401
+
402
+ <div class="flex flex-wrap items-center gap-x-3">
403
+ <a
404
+ :href="`mailto:${contactEmail}`"
405
+ class="transition-colors hover:text-sidebar-foreground"
406
+ >
407
+ {{ contactEmail }}
408
+ </a>
409
+ <a
410
+ :href="helpUrl || 'https://help.ketekny.gr'"
411
+ target="_blank"
412
+ rel="noreferrer"
413
+ class="truncate transition-colors hover:text-sidebar-foreground"
414
+ >
415
+ {{ helpUrl || 'https://help.ketekny.gr' }}
416
+ </a>
417
+ </div>
418
+ </div>
419
+ </div>
420
+ </div>
421
+ </aside>
422
+ </template>
@@ -0,0 +1,217 @@
1
+ <script>
2
+ import { ChevronRight, ExternalLink } from '@lucide/vue'
3
+ import * as icons from '@lucide/vue'
4
+
5
+ export default {
6
+ name: 'SidebarMenuItem',
7
+ components: {
8
+ ChevronRight,
9
+ ExternalLink,
10
+ },
11
+ props: {
12
+ item: { type: Object, required: true },
13
+ level: { type: Number, default: 0 },
14
+ activeId: { type: String, default: '' },
15
+ openIds: { type: Object, required: true },
16
+ collapsed: { type: Boolean, default: false },
17
+ },
18
+ emits: ['select', 'toggle'],
19
+ computed: {
20
+ hasChildren() {
21
+ return Array.isArray(this.item.children) && this.item.children.length > 0
22
+ },
23
+ isOpen() {
24
+ return this.openIds.has(this.item.id)
25
+ },
26
+ IconComponent() {
27
+ return this.item.icon ? icons[this.item.icon] : null
28
+ },
29
+ isActiveTrail() {
30
+ return this.hasChildren && this.containsActive(this.item)
31
+ },
32
+ isActiveLeaf() {
33
+ return !this.hasChildren && this.item.id === this.activeId
34
+ },
35
+ isActive() {
36
+ return this.isActiveLeaf || this.isActiveTrail
37
+ },
38
+ isCollapsedTop() {
39
+ return this.collapsed && this.level === 0
40
+ },
41
+ isExternalLink() {
42
+ return !this.hasChildren && Boolean(this.item?.link?.href ?? this.item?.href)
43
+ },
44
+ indentStyle() {
45
+ return {
46
+ paddingLeft: this.level === 0 ? '0.75rem' : `${this.level * 0.875 + 0.75}rem`,
47
+ }
48
+ },
49
+ },
50
+ methods: {
51
+ containsActive(node) {
52
+ if (node.id === this.activeId) return true
53
+ if (!node.children) return false
54
+ return node.children.some((child) => this.containsActive(child))
55
+ },
56
+ handleClick() {
57
+ if (this.hasChildren && !this.isCollapsedTop) {
58
+ this.$emit('toggle', this.item.id)
59
+ } else if (!this.hasChildren) {
60
+ this.$emit('select', this.item)
61
+ }
62
+ },
63
+ onEnter(el) {
64
+ el.style.height = '0'
65
+ el.offsetHeight
66
+ el.style.height = `${el.scrollHeight}px`
67
+ },
68
+ onAfterEnter(el) {
69
+ el.style.height = 'auto'
70
+ },
71
+ onLeave(el) {
72
+ el.style.height = `${el.scrollHeight}px`
73
+ el.offsetHeight
74
+ el.style.height = '0'
75
+ },
76
+ },
77
+ }
78
+ </script>
79
+
80
+ <template>
81
+ <li :class="isCollapsedTop ? 'group/fly relative' : ''">
82
+ <button
83
+ type="button"
84
+ :style="isCollapsedTop ? undefined : indentStyle"
85
+ class="group relative flex w-full items-center rounded-md text-left text-sm transition-colors duration-150"
86
+ :class="[
87
+ isCollapsedTop ? 'h-10 justify-center px-0' : 'gap-2.5 py-2 pr-2',
88
+ isActiveLeaf || (isCollapsedTop && isActive)
89
+ ? 'bg-brand text-brand-foreground font-medium'
90
+ : isActiveTrail
91
+ ? 'text-sidebar-foreground'
92
+ : 'text-sidebar-foreground/80 hover:bg-sidebar-accent hover:text-sidebar-foreground',
93
+ ]"
94
+ :aria-expanded="hasChildren && !isCollapsedTop ? isOpen : undefined"
95
+ :aria-label="isCollapsedTop ? item.label : undefined"
96
+ @click="handleClick"
97
+ >
98
+ <span
99
+ v-if="isActiveLeaf && !isCollapsedTop"
100
+ class="absolute left-0 top-1/2 h-5 w-1 -translate-y-1/2 rounded-r bg-brand-foreground/80"
101
+ aria-hidden="true"
102
+ />
103
+
104
+ <component
105
+ :is="IconComponent"
106
+ v-if="IconComponent"
107
+ class="size-4 shrink-0"
108
+ :class="[
109
+ isActiveLeaf || (isCollapsedTop && isActive)
110
+ ? 'text-brand-foreground'
111
+ : 'text-sidebar-muted group-hover:text-sidebar-foreground',
112
+ ]"
113
+ :stroke-width="2"
114
+ />
115
+
116
+ <template v-if="!isCollapsedTop">
117
+ <span class="flex-1 truncate">{{ item.label }}</span>
118
+
119
+ <span
120
+ v-if="item.badge != null"
121
+ class="ml-auto rounded-full px-1.5 py-0.5 text-xs font-medium tabular-nums"
122
+ :class="isActiveLeaf ? 'bg-brand-foreground/20 text-brand-foreground' : 'bg-sidebar-accent text-sidebar-muted'"
123
+ >
124
+ {{ item.badge }}
125
+ </span>
126
+
127
+ <ExternalLink
128
+ v-if="isExternalLink"
129
+ class="size-3.5 shrink-0"
130
+ :class="isActiveLeaf ? 'text-brand-foreground' : 'text-sidebar-muted'"
131
+ :stroke-width="2"
132
+ />
133
+
134
+ <ChevronRight
135
+ v-if="hasChildren"
136
+ class="size-4 shrink-0 text-sidebar-muted transition-transform duration-200"
137
+ :class="isOpen ? 'rotate-90' : ''"
138
+ :stroke-width="2"
139
+ />
140
+ </template>
141
+
142
+ <span
143
+ v-if="isCollapsedTop && hasChildren"
144
+ class="absolute right-1.5 top-1.5 size-1.5 rounded-full"
145
+ :class="isActive ? 'bg-brand-foreground/80' : 'bg-sidebar-muted/60'"
146
+ aria-hidden="true"
147
+ />
148
+ </button>
149
+
150
+ <Transition
151
+ v-if="!isCollapsedTop"
152
+ @enter="onEnter"
153
+ @after-enter="onAfterEnter"
154
+ @leave="onLeave"
155
+ >
156
+ <ul
157
+ v-if="hasChildren && isOpen"
158
+ class="overflow-hidden border-l border-sidebar-border/25 ml-4 pl-2 transition-[height] duration-200 ease-out"
159
+ >
160
+ <SidebarMenuItem
161
+ v-for="child in item.children"
162
+ :key="child.id"
163
+ :item="child"
164
+ :level="level + 1"
165
+ :active-id="activeId"
166
+ :open-ids="openIds"
167
+ @select="$emit('select', $event)"
168
+ @toggle="$emit('toggle', $event)"
169
+ />
170
+ </ul>
171
+ </Transition>
172
+
173
+ <div
174
+ v-if="isCollapsedTop"
175
+ class="invisible absolute left-full top-0 z-50 pl-2 opacity-0 transition-opacity duration-150 group-hover/fly:visible group-hover/fly:opacity-100"
176
+ >
177
+ <div
178
+ class="min-w-56 rounded-lg border border-sidebar-border bg-sidebar p-2 shadow-xl shadow-black/40"
179
+ role="menu"
180
+ :aria-label="item.label"
181
+ >
182
+ <button
183
+ type="button"
184
+ class="mb-1 flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm font-semibold"
185
+ :class="isActive ? 'text-brand' : 'text-sidebar-foreground hover:bg-sidebar-accent'"
186
+ @click="!hasChildren && $emit('select', item)"
187
+ >
188
+ <component
189
+ :is="IconComponent"
190
+ v-if="IconComponent"
191
+ class="size-4 shrink-0"
192
+ :stroke-width="2"
193
+ />
194
+ <span class="truncate">{{ item.label }}</span>
195
+ <ExternalLink
196
+ v-if="isExternalLink"
197
+ class="ml-auto size-3.5 shrink-0 text-sidebar-muted"
198
+ :stroke-width="2"
199
+ />
200
+ </button>
201
+
202
+ <ul v-if="hasChildren" class="flex flex-col gap-0.5">
203
+ <SidebarMenuItem
204
+ v-for="child in item.children"
205
+ :key="child.id"
206
+ :item="child"
207
+ :level="0"
208
+ :active-id="activeId"
209
+ :open-ids="openIds"
210
+ @select="$emit('select', $event)"
211
+ @toggle="$emit('toggle', $event)"
212
+ />
213
+ </ul>
214
+ </div>
215
+ </div>
216
+ </li>
217
+ </template>
@@ -0,0 +1,125 @@
1
+ <template>
2
+ <div class="flex h-screen bg-canvas text-canvas-foreground">
3
+ <div v-if="mobileMenuOpen" class="fixed inset-0 z-30 bg-slate-950/50 backdrop-blur-[1px] lg:hidden" @click="closeMenu" />
4
+
5
+ <MultilevelSidebar
6
+ :title="title"
7
+ :width="width"
8
+ :collapsed-width="collapsedWidth"
9
+ :main-menu="mainMenu"
10
+ :mobile-open="mobileMenuOpen"
11
+ :account="account"
12
+ :contact-email="contactEmail"
13
+ :help-url="helpUrl"
14
+ @logo-click="handleLogoClick"
15
+ @signin="handleSignin"
16
+ @signup="handleSignup"
17
+ @signout="handleSignout"
18
+ @edit-profile="handleEditProfile"
19
+ @item-click="handleItemClick"
20
+ @select="handleSelect"
21
+ />
22
+
23
+ <main class="flex flex-col flex-1 overflow-y-auto">
24
+ <slot />
25
+ </main>
26
+
27
+ <button
28
+ v-if="showBurger && !mobileMenuOpen"
29
+ type="button"
30
+ class="fixed z-50 inline-flex items-center justify-center text-white transition-colors rounded-full shadow-lg bottom-5 left-5 size-14 bg-sidebar shadow-black/25 hover:bg-sidebar-accent lg:hidden"
31
+ aria-label="Open menu"
32
+ @click="toggleMenu">
33
+ <Menu class="size-6" :stroke-width="2.25" />
34
+ </button>
35
+ </div>
36
+ </template>
37
+
38
+ <script>
39
+ import { Menu } from "@lucide/vue";
40
+ import MultilevelSidebar from "./MultilevelSidebar.vue";
41
+
42
+ export default {
43
+ name: "TwoColLayout",
44
+ components: {
45
+ Menu,
46
+ MultilevelSidebar,
47
+ },
48
+ props: {
49
+ title: {
50
+ type: String,
51
+ default: "",
52
+ },
53
+ width: {
54
+ type: String,
55
+ default: "360px",
56
+ },
57
+ collapsedWidth: {
58
+ type: String,
59
+ default: "76px",
60
+ },
61
+ mainMenu: {
62
+ type: Array,
63
+ default: () => [],
64
+ },
65
+ account: {
66
+ type: Object,
67
+ default: null,
68
+ },
69
+ contactEmail: {
70
+ type: String,
71
+ default: "grammateia@ketekny.gr",
72
+ },
73
+ helpUrl: {
74
+ type: String,
75
+ default: "https://help.ketekny.gr",
76
+ },
77
+ showBurger: {
78
+ type: Boolean,
79
+ default: true,
80
+ },
81
+ },
82
+ emits: ["select-menu", "logo-click", "signin", "signup", "signout", "edit-profile", "item-click"],
83
+ data() {
84
+ return {
85
+ mobileMenuOpen: false,
86
+ };
87
+ },
88
+ methods: {
89
+ toggleMenu() {
90
+ this.mobileMenuOpen = !this.mobileMenuOpen;
91
+ },
92
+ closeMenu() {
93
+ this.mobileMenuOpen = false;
94
+ },
95
+ handleSelect(id) {
96
+ this.closeMenu();
97
+ this.$emit("select-menu", id);
98
+ },
99
+ handleLogoClick() {
100
+ this.closeMenu();
101
+ this.$emit("logo-click");
102
+ },
103
+ handleSignin() {
104
+ this.closeMenu();
105
+ this.$emit("signin");
106
+ },
107
+ handleSignup() {
108
+ this.closeMenu();
109
+ this.$emit("signup");
110
+ },
111
+ handleSignout() {
112
+ this.closeMenu();
113
+ this.$emit("signout");
114
+ },
115
+ handleEditProfile() {
116
+ this.closeMenu();
117
+ this.$emit("edit-profile");
118
+ },
119
+ handleItemClick(item) {
120
+ this.closeMenu();
121
+ this.$emit("item-click", item);
122
+ },
123
+ },
124
+ };
125
+ </script>
@@ -6,6 +6,10 @@ export default {
6
6
  "./src/**/*.{vue,js,ts,jsx,tsx}",
7
7
  "./preview/**/*.{vue,js,ts}",
8
8
  ],
9
+ fontFamily: {
10
+ sans: ['Ubuntu', 'ui-sans-serif', 'system-ui', 'sans-serif'],
11
+ mono: ['Geist Mono', 'ui-monospace', 'monospace'],
12
+ },
9
13
  safelist: [
10
14
  // Runtime-generated semantic classes used by alert/message/toast components.
11
15
  { pattern: /(bg|text|border)-semantic-(success|warning|error|info)-(bg|border|text|disabled|button)/ },
@@ -18,7 +22,28 @@ export default {
18
22
  ],
19
23
  theme: {
20
24
  extend: {
25
+
21
26
  colors: {
27
+ sidebar: {
28
+ DEFAULT: 'hsl(222 18% 11%)',
29
+ foreground: 'hsl(220 14% 80%)',
30
+ muted: 'hsl(220 10% 55%)',
31
+ accent: 'hsl(222 16% 17%)',
32
+ border: 'hsl(220 14% 100% / 0.06)',
33
+ },
34
+ brand: {
35
+ DEFAULT: 'hsl(2 78% 55%)',
36
+ foreground: 'hsl(0 0% 100%)',
37
+ },
38
+ canvas: {
39
+ DEFAULT: 'hsl(220 20% 97%)',
40
+ foreground: 'hsl(222 30% 12%)',
41
+ muted: 'hsl(220 10% 45%)',
42
+ card: 'hsl(0 0% 100%)',
43
+ border: 'hsl(220 14% 90%)',
44
+ },
45
+
46
+
22
47
  default: "#1B2A33",
23
48
  header: "#256D96",
24
49
  background: "#FEFCF6",