ketekny-ui-kit 1.0.62 → 1.0.64

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
@@ -154,7 +154,7 @@ Components:
154
154
 
155
155
  - `kMessage`, `kCode`, `kToolbar`, `kTable`, `kTabs`, `kChip`
156
156
  - `kSpinner`, `kDatatable`, `kIcon`, `kMenu`, `kSkeleton`
157
- - `kProgressBar`, `kTree`, `kButton`, `kSelect`, `kUploader`
157
+ - `kProgressBar`, `kPagination`, `kTree`, `kButton`, `kSelect`, `kUploader`
158
158
  - `kToggle`, `kInput`, `kDateSelector`, `kEditor`
159
159
  - `kSelectButton`, `kTags`, `kSearch`, `kArrayList`, `kList`, `kTextArea`
160
160
  - `kDialog`, `kDrawer`
package/index.js CHANGED
@@ -25,6 +25,7 @@ import kArrayList from './src/ui/kArrayList.vue'
25
25
  import kList from './src/ui/kList.vue'
26
26
  import kSkeleton from './src/ui/kSkeleton.vue'
27
27
  import kProgressBar from './src/ui/kProgressBar.vue'
28
+ import kPagination from './src/ui/kPagination.vue'
28
29
  import kTextArea from './src/ui/kTextArea.vue'
29
30
  import kTree from './src/ui/kTree.vue'
30
31
 
@@ -33,6 +34,7 @@ import kAppHeader from './src/layout/kAppHeader.vue'
33
34
  import kAppFooter from './src/layout/kAppFooter.vue'
34
35
  import kAppMain from './src/layout/kAppMain.vue'
35
36
  import kHero from './src/layout/kHero.vue'
37
+ import twoColLayout from './src/layout/twoColLayout.vue'
36
38
 
37
39
  // Toast/Confirm/Alert Plugins
38
40
  import toastPlugin from './src/plugins/toastPlugin.js'
@@ -47,7 +49,7 @@ import tailwindPreset from './tailwind-preset.js'
47
49
  // Named exports (tree-shaking friendly)
48
50
  export {
49
51
  // UI Components
50
- kMessage, kCode, kToolbar, kTable, kTabs, kChip, kSpinner, kDatatable, kIcon, kMenu, kSkeleton, kProgressBar, kTree,
52
+ kMessage, kCode, kToolbar, kTable, kTabs, kChip, kSpinner, kDatatable, kIcon, kMenu, kSkeleton, kProgressBar, kPagination, kTree,
51
53
 
52
54
  // Form Components
53
55
  kButton, kSelect, kUploader, kToggle, kInput, kDateSelector, kEditor, kSelectButton, kTags, kSearch, kArrayList, kList, kTextArea,
@@ -56,7 +58,7 @@ export {
56
58
  kDialog, kDrawer,
57
59
 
58
60
  // Layout Components
59
- kAppHeader, kAppFooter, kAppMain, kHero,
61
+ kAppHeader, kAppFooter, kAppMain, kHero, twoColLayout,
60
62
 
61
63
  // Plugins
62
64
  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.62",
4
+ "version": "1.0.64",
5
5
  "description": "A Vue 3 UI component library with Tailwind CSS styling",
6
6
  "main": "index.js",
7
7
  "files": [
@@ -0,0 +1,388 @@
1
+ <template>
2
+ <div
3
+ class="relative flex min-h-screen flex-col bg-[#edf4fb] lg:h-screen lg:flex-row lg:overflow-hidden"
4
+ >
5
+ <button
6
+ v-show="isSidebarCollapsed"
7
+ type="button"
8
+ :aria-label="expandLabel"
9
+ v-tooltip.right="expandLabel"
10
+ class="fixed left-0 top-4 z-[70] hidden h-10 w-10 items-center justify-center rounded-r-full border border-white/15 bg-[#121b2e] text-white shadow-2xl shadow-black/50 transition-all duration-300 hover:bg-[#1a2640] lg:flex"
11
+ @click="toggleSidebar"
12
+ >
13
+ <slot name="collapsed-toggle-icon">
14
+ <kIcon :name="expandIcon" size="16" class="text-white" />
15
+ </slot>
16
+ </button>
17
+
18
+ <aside
19
+ class="flex h-screen w-full flex-col overflow-y-auto overflow-x-hidden bg-[radial-gradient(circle_at_0_0,#ffffff0f,#0000_34%),linear-gradient(#101a2d_0%,#0a1120_52%,#070d19_100%)] text-white shadow-2xl transition-all duration-300 ease-out lg:w-[360px] lg:shrink-0 lg:border-r lg:border-white/10 lg:will-change-transform"
20
+ :class="
21
+ isSidebarCollapsed
22
+ ? 'lg:relative lg:z-30 lg:w-[76px]'
23
+ : 'lg:relative lg:z-30 lg:translate-x-0'
24
+ "
25
+ >
26
+ <div class="relative border-b border-white/10">
27
+ <button
28
+ v-if="logoSrc || title"
29
+ type="button"
30
+ class="block w-full text-left"
31
+ v-tooltip.right.html="isSidebarCollapsed ? collapsedContactTooltip : logoAlt"
32
+ @click="$emit('logo-click')"
33
+ >
34
+ <img
35
+ v-if="logoSrc"
36
+ :alt="logoAlt"
37
+ :src="isSidebarCollapsed ? collapsedLogoSrc : logoSrc"
38
+ class="cursor-pointer px-4 py-4 transition-all duration-300"
39
+ />
40
+ <div v-if="title && !isSidebarCollapsed" class="py-3 mt-3 text-lg text-center bg-white/10">
41
+ {{ title }}
42
+ </div>
43
+ </button>
44
+
45
+ <slot name="sidebar-header" />
46
+
47
+ <button
48
+ v-show="!isSidebarCollapsed"
49
+ type="button"
50
+ :aria-label="collapseLabel"
51
+ v-tooltip.left="collapseLabel"
52
+ class="absolute right-2 top-2 z-[70] hidden h-10 w-10 items-center justify-center rounded-full border border-white/15 bg-[#121b2e] text-white shadow-2xl shadow-black/50 transition hover:bg-[#1a2640] lg:flex"
53
+ @click.stop="toggleSidebar"
54
+ >
55
+ <slot name="toggle-icon">
56
+ <kIcon
57
+ :name="collapseIcon"
58
+ size="16"
59
+ class="text-white"
60
+ />
61
+ </slot>
62
+ </button>
63
+ </div>
64
+
65
+ <div class="flex-1 pt-4">
66
+ <div v-if="isSidebarCollapsed" class="mx-1 mt-3 flex flex-col items-center gap-2">
67
+ <SidebarIconLink
68
+ v-for="item in normalizedMainMenu"
69
+ :key="item.key"
70
+ :icon="item.icon"
71
+ :active="item.active"
72
+ :to="item.to"
73
+ :href="item.href"
74
+ :title="item.title"
75
+ :is-external="item.isExternal"
76
+ :target="item.target"
77
+ :rel="item.rel"
78
+ :on-click="item.onClick"
79
+ v-tooltip.right="item.label"
80
+ />
81
+ </div>
82
+ <div v-else-if="mainMenu.length" class="mx-2 mt-3 space-y-3">
83
+ <component
84
+ v-for="item in normalizedMainMenu"
85
+ :key="item.key"
86
+ :is="item.isLink ? RouterLink : item.isExternal ? 'a' : 'button'"
87
+ v-bind="item.bindings"
88
+ class="flex w-full items-start gap-3 rounded-2xl border px-4 py-4 text-left transition-all duration-200"
89
+ :class="item.classes"
90
+ >
91
+ <span
92
+ class="mt-0.5 flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-white/10 text-cyan-100 transition-colors duration-200"
93
+ :class="item.active ? 'bg-slate-950 text-white' : ''"
94
+ >
95
+ <kIcon :name="item.icon" />
96
+ </span>
97
+ <span class="min-w-0">
98
+ <span class="block font-semibold leading-tight">
99
+ {{ item.label }}
100
+ </span>
101
+ <span
102
+ v-if="item.description"
103
+ class="mt-1 block text-sm"
104
+ :class="item.active ? 'text-slate-700' : 'text-slate-300'"
105
+ >
106
+ {{ item.description }}
107
+ </span>
108
+ </span>
109
+ </component>
110
+ </div>
111
+ </div>
112
+
113
+ <div v-if="account " class="shrink-0 border-t border-white/10 bg-slate-900/80 px-1 py-4 text-white/80">
114
+ <div v-if="isSidebarCollapsed" class="flex flex-col gap-3">
115
+ <div v-if="account.loggedIn" class="flex flex-col items-center gap-2">
116
+ <SidebarIconLink icon="UserRoundCog" :on-click="() => $emit('edit-profile')" :title="'Επεξεργασία προφίλ'" v-tooltip.right="'Επεξεργασία προφίλ'" />
117
+ <SidebarIconLink icon="LogOut" warning :on-click="() => $emit('signout')" :title="'Αποσύνδεση'" v-tooltip.right="'Αποσύνδεση'" />
118
+ </div>
119
+ <div v-else class="flex flex-col items-center gap-2">
120
+ <SidebarIconLink icon="LogIn" :on-click="() => $emit('signin')" :title="'Sign in'" v-tooltip.right="'Sign in'" />
121
+ <SidebarIconLink icon="UserPlus" :on-click="() => $emit('signup')" :title="'Sign up'" v-tooltip.right="'Sign up'" />
122
+ </div>
123
+ </div>
124
+ <div v-else class="flex flex-col gap-3 px-3">
125
+ <div class="min-w-0">
126
+ <p v-if="account.loggedIn" class="mt-2 truncate text-lg font-semibold text-white">
127
+ {{ accountDisplayName }}
128
+ </p>
129
+ <p v-if="account.loggedIn && account.email" class="truncate text-sm text-slate-300">
130
+ {{ account.email }}
131
+ </p>
132
+ </div>
133
+ <div class="flex flex-wrap gap-2">
134
+ <kButton
135
+ v-if="!account.loggedIn"
136
+ secondary
137
+ icon="LogIn"
138
+ label="Sign in"
139
+ @click="$emit('signin')"
140
+ />
141
+ <kButton
142
+ v-if="!account.loggedIn"
143
+ secondary
144
+ icon="UserPlus"
145
+ label="Sign up"
146
+ @click="$emit('signup')"
147
+ />
148
+ <kButton
149
+ v-if="account.loggedIn"
150
+ secondary
151
+ icon="UserRoundCog"
152
+ label="Επεξεργασία προφίλ"
153
+ @click="$emit('edit-profile')"
154
+ />
155
+ <kButton
156
+ v-if="account.loggedIn"
157
+ icon="LogOut"
158
+ warning
159
+ label="Αποσύνδεση"
160
+ @click="$emit('signout')"
161
+ />
162
+ </div>
163
+ </div>
164
+ </div>
165
+
166
+ <div
167
+ v-if="showContactInfo && !isSidebarCollapsed"
168
+ class="shrink-0 border-t border-white/10 p-4 text-white/50"
169
+ >
170
+ <div class="flex flex-col gap-2">
171
+ <p v-if="!isSidebarCollapsed" class="text-sm font-semibold uppercase tracking-[0.25em] text-white/60">
172
+ KETEKNY AE
173
+ </p>
174
+ <div class="flex flex-wrap items-center gap-x-3 text-sm leading-relaxed" :class="isSidebarCollapsed ? 'justify-center' : ''">
175
+ <span v-if="isSidebarCollapsed" class="sr-only">KETEKNY AE contact info</span>
176
+ <span class="whitespace-nowrap">Βερανζέρου 13, 10677, Αθήνα</span>
177
+ <span class="whitespace-nowrap">210 3648 337</span>
178
+ <span class="whitespace-nowrap">
179
+ <a href="mailto:grammateia@ketekny.gr" class="whitespace-nowrap hover:underline">
180
+ grammateia@ketekny.gr
181
+ </a>
182
+ </span>
183
+ <a
184
+ href="https://ketekny.gr"
185
+ target="_blank"
186
+ rel="noreferrer"
187
+ class="whitespace-nowrap hover:underline"
188
+ >
189
+ https://ketekny.gr
190
+ </a>
191
+ </div>
192
+ </div>
193
+ </div>
194
+
195
+ </aside>
196
+
197
+ <main class="min-h-screen min-w-0 flex-1 overflow-y-auto overflow-x-hidden lg:h-full lg:min-h-0">
198
+ <div class="w-full">
199
+ <slot />
200
+ </div>
201
+ </main>
202
+ </div>
203
+ </template>
204
+
205
+ <script>
206
+ import kButton from '../ui/kButton.vue'
207
+ import kIcon from '../ui/kIcon.vue'
208
+ import { RouterLink } from 'vue-router'
209
+
210
+ const SidebarIconLink = {
211
+ props: {
212
+ icon: { type: String, required: true },
213
+ active: { type: Boolean, default: false },
214
+ to: { type: [String, Object], default: null },
215
+ href: { type: String, default: null },
216
+ title: { type: String, default: '' },
217
+ isExternal: { type: Boolean, default: false },
218
+ target: { type: String, default: '_blank' },
219
+ rel: { type: String, default: 'noreferrer' },
220
+ onClick: { type: Function, default: null },
221
+ warning: { type: Boolean, default: false },
222
+ tooltip: { type: String, default: '' },
223
+ },
224
+ methods: {
225
+ handleClick(event) {
226
+ if (this.onClick) this.onClick(event)
227
+ },
228
+ },
229
+ template: `
230
+ <component
231
+ :is="to ? RouterLink : isExternal ? 'a' : 'button'"
232
+ :to="to"
233
+ :href="href"
234
+ :target="isExternal ? target : null"
235
+ :rel="isExternal ? rel : null"
236
+ :type="to || isExternal ? null : 'button'"
237
+ :title="title"
238
+ class="group relative flex h-14 w-14 items-center justify-center rounded-xl border border-white/15 transition"
239
+ :class="active ? 'bg-slate-950 text-white' : warning ? 'bg-[#d97706] text-white hover:bg-[#b45309]' : 'bg-white/5 text-white hover:border-cyan-400/40 hover:bg-white/10'"
240
+ @click="handleClick"
241
+ >
242
+ <kIcon :name="icon" size="20" class="text-current" />
243
+ </component>
244
+ `,
245
+ components: { RouterLink, kIcon },
246
+ }
247
+
248
+ const DEFAULT_STORAGE_KEY = 'two-col-layout-sidebar-collapsed'
249
+
250
+ export default {
251
+ components: {
252
+ kButton,
253
+ kIcon,
254
+ RouterLink,
255
+ SidebarIconLink,
256
+ },
257
+ emits: ['logo-click', 'signin', 'signup', 'signout', 'edit-profile'],
258
+ props: {
259
+ title: { type: String, default: '' },
260
+ collapsedLogoSrc: {
261
+ type: String,
262
+ default: 'https://s3.ketekny.gr/public/web-apps/logos/logo-icon-dark.png',
263
+ },
264
+ logoSrc: { type: String, default: '' },
265
+ logoAlt: { type: String, default: 'Logo' },
266
+ collapseLabel: { type: String, default: 'Collapse sidebar' },
267
+ expandLabel: { type: String, default: 'Expand sidebar' },
268
+ collapseIcon: { type: String, default: 'ArrowLeftToLine' },
269
+ expandIcon: { type: String, default: 'ArrowRightToLine' },
270
+ storageKey: { type: String, default: DEFAULT_STORAGE_KEY },
271
+ defaultCollapsed: { type: Boolean, default: false },
272
+ mainMenu: { type: Array, default: () => [] },
273
+ showContactInfo: { type: Boolean, default: false },
274
+ account: { type: Object, default: null },
275
+ },
276
+ data() {
277
+ return {
278
+ isSidebarCollapsed: false,
279
+ mdCollapseQuery: null,
280
+ }
281
+ },
282
+ created() {
283
+ this.restoreSidebarState()
284
+ },
285
+ mounted() {
286
+ this.mdCollapseQuery = window.matchMedia('(min-width: 768px) and (max-width: 1023px)')
287
+ this.syncSidebarForViewport()
288
+ this.mdCollapseQuery.addEventListener('change', this.syncSidebarForViewport)
289
+ },
290
+ beforeUnmount() {
291
+ this.mdCollapseQuery?.removeEventListener('change', this.syncSidebarForViewport)
292
+ },
293
+ methods: {
294
+ restoreSidebarState() {
295
+ if (typeof window === 'undefined') {
296
+ this.isSidebarCollapsed = this.defaultCollapsed
297
+ return
298
+ }
299
+
300
+ const stored = window.localStorage.getItem(this.storageKey)
301
+ if (stored == null) {
302
+ this.isSidebarCollapsed = this.defaultCollapsed
303
+ return
304
+ }
305
+
306
+ this.isSidebarCollapsed = stored === 'true'
307
+ },
308
+ syncSidebarForViewport() {
309
+ if (typeof window === 'undefined') return
310
+
311
+ const isMd = window.matchMedia('(min-width: 768px) and (max-width: 1023px)').matches
312
+ if (isMd) {
313
+ this.isSidebarCollapsed = true
314
+ return
315
+ }
316
+
317
+ this.restoreSidebarState()
318
+ },
319
+ persistSidebarState() {
320
+ if (typeof window === 'undefined') return
321
+
322
+ window.localStorage.setItem(this.storageKey, this.isSidebarCollapsed ? 'true' : 'false')
323
+ },
324
+ toggleSidebar() {
325
+ this.isSidebarCollapsed = !this.isSidebarCollapsed
326
+ this.persistSidebarState()
327
+ },
328
+ },
329
+ computed: {
330
+ normalizedMainMenu() {
331
+ return this.mainMenu.map((item, index) => {
332
+ const isExternal = Boolean(item?.href)
333
+ const isLink = Boolean(item?.to)
334
+ const active = Boolean(item?.active)
335
+
336
+ return {
337
+ key: item?.key || item?.label || index,
338
+ label: item?.label || '',
339
+ description: item?.description || '',
340
+ icon: item?.icon || 'Dot',
341
+ active,
342
+ isExternal,
343
+ isLink,
344
+ bindings: isLink
345
+ ? {
346
+ to: item.to,
347
+ title: item.title || item.label,
348
+ 'aria-current': active ? 'page' : null,
349
+ }
350
+ : isExternal
351
+ ? {
352
+ href: item.href,
353
+ target: item.target || '_blank',
354
+ rel: item.rel || 'noreferrer',
355
+ title: item.title || item.label,
356
+ }
357
+ : {
358
+ type: 'button',
359
+ title: item.title || item.label,
360
+ },
361
+ classes: active
362
+ ? 'border-white/70 bg-white/90 text-slate-950 shadow-lg shadow-black/20'
363
+ : 'border-white/10 bg-white/5 text-white hover:border-cyan-400/40 hover:bg-white/10',
364
+ }
365
+ })
366
+ },
367
+ accountDisplayName() {
368
+ if (!this.account) return ''
369
+ return [this.account.firstName, this.account.lastName].filter(Boolean).join(' ').trim() || 'Account'
370
+ },
371
+ collapsedContactTooltip() {
372
+ return `
373
+ <div style="min-width: 220px;">
374
+ <div style="font-weight: 700; text-transform: uppercase; letter-spacing: 0.25em; font-size: 11px; color: rgba(255,255,255,0.65); margin-bottom: 8px;">
375
+ KETEKNY AE
376
+ </div>
377
+ <div style="display: flex; flex-direction: column; gap: 4px; font-size: 13px; line-height: 1.4; color: rgba(255,255,255,0.9);">
378
+ <div>Βερανζέρου 13, 10677, Αθήνα</div>
379
+ <div>210 3648 337</div>
380
+ <div>grammateia@ketekny.gr</div>
381
+ <div>https://ketekny.gr</div>
382
+ </div>
383
+ </div>
384
+ `
385
+ },
386
+ },
387
+ }
388
+ </script>
@@ -0,0 +1,118 @@
1
+ <template>
2
+ <div class="flex flex-wrap items-center gap-3">
3
+ <div class="text-sm font-medium text-slate-500">
4
+ {{ paginationLabel }}
5
+ </div>
6
+
7
+ <kButton
8
+ label="First"
9
+ icon="ChevronsLeft"
10
+ variant="soft"
11
+ :disabled="loading || currentPage <= 1"
12
+ @click="emit('first')"
13
+ />
14
+
15
+ <kButton
16
+ label="Previous"
17
+ icon="ChevronLeft"
18
+ variant="soft"
19
+ :disabled="loading || currentPage <= 1"
20
+ @click="emit('previous')"
21
+ />
22
+
23
+ <div class="flex items-center gap-1">
24
+ <template v-for="item in paginationItems" :key="item.key">
25
+ <span
26
+ v-if="item.type === 'ellipsis'"
27
+ class="px-2 text-sm font-medium text-slate-400"
28
+ >
29
+ ...
30
+ </span>
31
+
32
+ <kButton
33
+ v-else
34
+ :label="String(item.page)"
35
+ variant="soft"
36
+ :class="item.active ? 'ring-2 ring-cyan-200' : ''"
37
+ :disabled="loading || item.active"
38
+ @click="emit('page', item.page)"
39
+ />
40
+ </template>
41
+ </div>
42
+
43
+ <kButton
44
+ label="Next"
45
+ icon="ChevronRight"
46
+ variant="soft"
47
+ :disabled="loading || currentPage >= safeTotalPages"
48
+ @click="emit('next')"
49
+ />
50
+
51
+ <kButton
52
+ label="Last"
53
+ icon="ChevronsRight"
54
+ variant="soft"
55
+ :disabled="loading || currentPage >= safeTotalPages"
56
+ @click="emit('last')"
57
+ />
58
+ </div>
59
+ </template>
60
+
61
+ <script setup>
62
+ import { computed } from 'vue'
63
+ import kButton from './kButton.vue'
64
+
65
+ const props = defineProps({
66
+ paginationLabel: {
67
+ type: String,
68
+ default: '',
69
+ },
70
+ page: {
71
+ type: Number,
72
+ required: true,
73
+ },
74
+ totalPages: {
75
+ type: Number,
76
+ required: true,
77
+ },
78
+ loading: {
79
+ type: Boolean,
80
+ default: false,
81
+ },
82
+ })
83
+
84
+ const emit = defineEmits(['first', 'previous', 'page', 'next', 'last'])
85
+
86
+ const safeTotalPages = computed(() => Math.max(1, Math.trunc(props.totalPages || 0)))
87
+ const currentPage = computed(() => Math.min(Math.max(Math.trunc(props.page || 1), 1), safeTotalPages.value))
88
+
89
+ const paginationItems = computed(() => {
90
+ const pages = new Set([1, safeTotalPages.value, currentPage.value - 1, currentPage.value, currentPage.value + 1])
91
+ const clampedPages = [...pages]
92
+ .filter((pageNumber) => Number.isFinite(pageNumber) && pageNumber >= 1 && pageNumber <= safeTotalPages.value)
93
+ .sort((a, b) => a - b)
94
+
95
+ const items = []
96
+ let lastPage = 0
97
+
98
+ for (const pageNumber of clampedPages) {
99
+ if (lastPage && pageNumber - lastPage > 1) {
100
+ items.push({
101
+ key: `ellipsis-${lastPage}-${pageNumber}`,
102
+ type: 'ellipsis',
103
+ })
104
+ }
105
+
106
+ items.push({
107
+ key: `page-${pageNumber}`,
108
+ type: 'page',
109
+ page: pageNumber,
110
+ active: pageNumber === currentPage.value,
111
+ })
112
+
113
+ lastPage = pageNumber
114
+ }
115
+
116
+ return items
117
+ })
118
+ </script>