ketekny-ui-kit 1.0.114 → 1.0.116

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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "ketekny-ui-kit",
3
3
  "type": "module",
4
- "version": "1.0.114",
4
+ "version": "1.0.116",
5
5
  "description": "A Vue 3 UI component library with Tailwind CSS styling",
6
6
  "main": "index.js",
7
7
  "files": [
@@ -1,5 +1,11 @@
1
1
  <template>
2
2
  <main class="flex min-h-0 w-full max-w-none flex-1 flex-col overflow-hidden bg-slate-100">
3
+ <div
4
+ v-if="mobileSidebarOpen && !isDesktopViewport"
5
+ class="fixed inset-0 z-20 bg-slate-950/40 backdrop-blur-[1px] lg:hidden"
6
+ @click="toggleMobileSidebar"
7
+ />
8
+
3
9
  <section
4
10
  v-if="hasTopSection"
5
11
  class="shrink-0 bg-white px-4 pb-4 pb-0 pt-8 sm:px-6 lg:px-8"
@@ -44,8 +50,27 @@
44
50
  </div>
45
51
 
46
52
  <div class="flex min-h-0 flex-1 flex-col overflow-hidden bg-slate-100 py-4">
53
+ <div
54
+ class="lg:hidden"
55
+ :class="mobileSidebarOpen ? 'mx-auto flex w-full max-w-none px-4 pb-3 sm:px-6' : 'fixed left-0 top-1/2 z-20 -translate-y-1/2'"
56
+ >
57
+ <kButton
58
+ :label="mobileSidebarOpen ? 'Hide sidebar' : 'Show sidebar'"
59
+ :icon="mobileSidebarOpen ? 'PanelLeftClose' : 'PanelLeftOpen'"
60
+ variant="soft"
61
+ :icon-only="!mobileSidebarOpen"
62
+ :aria-label="mobileSidebarOpen ? null : 'Show sidebar'"
63
+ :class="mobileSidebarOpen ? '' : 'rounded-full shadow-lg shadow-slate-900/15'"
64
+ @click="toggleMobileSidebar"
65
+ />
66
+ </div>
47
67
  <div class="mx-auto flex min-h-0 w-full max-w-none flex-1 gap-2">
48
- <aside class="ml-8 min-h-0 shrink-0 overflow-y-auto pr-4" :style="sidebarStyle">
68
+ <aside
69
+ v-if="showSidebar"
70
+ class="min-h-0 shrink-0 overflow-y-auto bg-slate-100 px-4 sm:px-6 lg:ml-8 lg:pr-4 lg:pl-0"
71
+ :class="mobileSidebarClass"
72
+ :style="sidebarStyle"
73
+ >
49
74
  <slot name="sidebar" />
50
75
  </aside>
51
76
  <section class="min-h-0 min-w-0 flex-1 overflow-y-auto px-2">
@@ -60,6 +85,9 @@
60
85
  import packageJson from '../../package.json'
61
86
  import kButton from '../ui/kButton.vue'
62
87
 
88
+ const LG_VIEWPORT_QUERY = '(min-width: 1024px)'
89
+ const MAIN_MENU_TOGGLE_EVENT = 'ketekny:two-col-layout-mobile-menu'
90
+
63
91
  export default {
64
92
  name: 'PageTwoColShell',
65
93
  components: { kButton },
@@ -85,6 +113,13 @@ export default {
85
113
  default: '300px',
86
114
  },
87
115
  },
116
+ data() {
117
+ return {
118
+ isDesktopViewport: true,
119
+ mobileSidebarOpen: false,
120
+ viewportQuery: null,
121
+ }
122
+ },
88
123
  computed: {
89
124
  hasTopSection() {
90
125
  return Boolean(
@@ -105,8 +140,46 @@ export default {
105
140
  maxWidth: this.sidebarWidth,
106
141
  }
107
142
  },
143
+ mobileSidebarClass() {
144
+ if (this.isDesktopViewport) return ''
145
+ return 'fixed left-0 top-0 z-20 h-full max-w-[85vw] border-r border-slate-200 bg-slate-100 py-4 shadow-xl shadow-slate-900/20'
146
+ },
147
+ showSidebar() {
148
+ return this.isDesktopViewport || this.mobileSidebarOpen
149
+ },
150
+ },
151
+ mounted() {
152
+ if (typeof window === 'undefined') return
153
+ this.viewportQuery = window.matchMedia(LG_VIEWPORT_QUERY)
154
+ this.isDesktopViewport = this.viewportQuery.matches
155
+ this.syncSidebarForViewport()
156
+ this.viewportQuery.addEventListener('change', this.handleViewportChange)
157
+ window.addEventListener(MAIN_MENU_TOGGLE_EVENT, this.handleMainMenuToggle)
158
+ },
159
+ beforeUnmount() {
160
+ this.viewportQuery?.removeEventListener?.('change', this.handleViewportChange)
161
+ window.removeEventListener(MAIN_MENU_TOGGLE_EVENT, this.handleMainMenuToggle)
108
162
  },
109
163
  methods: {
164
+ handleMainMenuToggle(event) {
165
+ if (this.isDesktopViewport) return
166
+ if (event?.detail?.open) {
167
+ this.mobileSidebarOpen = false
168
+ }
169
+ },
170
+ handleViewportChange(event) {
171
+ this.isDesktopViewport = event.matches
172
+ this.syncSidebarForViewport()
173
+ },
174
+ syncSidebarForViewport() {
175
+ if (!this.isDesktopViewport) {
176
+ this.mobileSidebarOpen = false
177
+ }
178
+ },
179
+ toggleMobileSidebar() {
180
+ if (this.isDesktopViewport) return
181
+ this.mobileSidebarOpen = !this.mobileSidebarOpen
182
+ },
110
183
  openExternalUrl() {
111
184
  const target = String(this.externalUrl || '').trim()
112
185
  if (!target) return
@@ -18,6 +18,7 @@ import demoAvatarUrl from '../assets/placeholder-user.jpg'
18
18
  const FAVORITES_STORAGE_KEY = 'ketekny-ui-sidebar-favorites'
19
19
  const MENU_TAB_STORAGE_KEY = 'ketekny-ui-sidebar-menu-tab'
20
20
  const LOGO_BASE_URL = 'https://s3.ketekny.gr/public/web-apps/logos'
21
+ const LG_VIEWPORT_QUERY = '(min-width: 1024px)'
21
22
 
22
23
  export default {
23
24
  name: 'MultilevelSidebar',
@@ -86,6 +87,8 @@ export default {
86
87
  activeMenuTab: 'all',
87
88
  query: '',
88
89
  collapsed: false,
90
+ isDesktopViewport: true,
91
+ viewportQuery: null,
89
92
  openIds: new Set(['projects']),
90
93
  favoriteIds: new Set(),
91
94
  footerLinks: [
@@ -232,8 +235,30 @@ export default {
232
235
  activeMenuTab(value) {
233
236
  this.persistActiveMenuTab(value)
234
237
  },
238
+ mobileOpen(isOpen) {
239
+ if (isOpen) this.syncCollapsedForViewport()
240
+ },
241
+ },
242
+ mounted() {
243
+ if (typeof window === 'undefined') return
244
+ this.viewportQuery = window.matchMedia(LG_VIEWPORT_QUERY)
245
+ this.isDesktopViewport = this.viewportQuery.matches
246
+ this.syncCollapsedForViewport()
247
+ this.viewportQuery.addEventListener('change', this.handleViewportChange)
248
+ },
249
+ beforeUnmount() {
250
+ this.viewportQuery?.removeEventListener?.('change', this.handleViewportChange)
235
251
  },
236
252
  methods: {
253
+ handleViewportChange(event) {
254
+ this.isDesktopViewport = event.matches
255
+ this.syncCollapsedForViewport()
256
+ },
257
+ syncCollapsedForViewport() {
258
+ if (!this.isDesktopViewport) {
259
+ this.collapsed = false
260
+ }
261
+ },
237
262
  findMenuItemById(items, id) {
238
263
  for (const item of items) {
239
264
  if (item.id === id) return item
@@ -307,6 +332,10 @@ export default {
307
332
  }
308
333
  },
309
334
  toggleCollapsed() {
335
+ if (!this.isDesktopViewport) {
336
+ this.collapsed = false
337
+ return
338
+ }
310
339
  this.collapsed = !this.collapsed
311
340
  },
312
341
  toggle(id) {
@@ -39,9 +39,9 @@
39
39
  </main>
40
40
 
41
41
  <button
42
- v-if="!mobileMenuOpen"
42
+ v-if="!mobileMenuOpen && showMobileMenuButton"
43
43
  type="button"
44
- class="fixed bottom-5 left-5 z-50 inline-flex size-14 items-center justify-center rounded-full transition-colors lg:hidden"
44
+ class="fixed bottom-0 left-0 z-50 inline-flex size-14 items-center justify-center rounded-tr-2xl transition-colors lg:hidden"
45
45
  :class="theme === 'light'
46
46
  ? 'border border-slate-200 bg-white text-slate-700 shadow-lg shadow-slate-900/10 hover:bg-slate-100'
47
47
  : 'bg-sidebar text-white shadow-lg shadow-black/25 hover:bg-sidebar-accent'"
@@ -56,6 +56,9 @@
56
56
  import { Menu } from "@lucide/vue";
57
57
  import MultilevelSidebar from "./MultilevelSidebar.vue";
58
58
 
59
+ const LG_VIEWPORT_QUERY = "(min-width: 1024px)";
60
+ const MAIN_MENU_TOGGLE_EVENT = "ketekny:two-col-layout-mobile-menu";
61
+
59
62
  export default {
60
63
  name: "TwoColLayout",
61
64
  components: {
@@ -105,14 +108,66 @@ export default {
105
108
  data() {
106
109
  return {
107
110
  mobileMenuOpen: false,
111
+ desktopViewportQuery: null,
112
+ showMobileMenuButton: true,
113
+ mobileScrollHideTimer: null,
108
114
  };
109
115
  },
116
+ mounted() {
117
+ if (typeof window === "undefined") return;
118
+ this.desktopViewportQuery = window.matchMedia(LG_VIEWPORT_QUERY);
119
+ this.syncMenuForViewport(this.desktopViewportQuery.matches);
120
+ this.desktopViewportQuery.addEventListener("change", this.handleViewportChange);
121
+ window.addEventListener("scroll", this.handleWindowScroll, { passive: true });
122
+ },
123
+ beforeUnmount() {
124
+ this.desktopViewportQuery?.removeEventListener?.("change", this.handleViewportChange);
125
+ window.removeEventListener("scroll", this.handleWindowScroll);
126
+ if (this.mobileScrollHideTimer) {
127
+ window.clearTimeout(this.mobileScrollHideTimer);
128
+ this.mobileScrollHideTimer = null;
129
+ }
130
+ },
110
131
  methods: {
132
+ handleWindowScroll() {
133
+ if (typeof window === "undefined") return;
134
+ if (this.mobileMenuOpen || this.desktopViewportQuery?.matches) return;
135
+ this.showMobileMenuButton = false;
136
+ if (this.mobileScrollHideTimer) {
137
+ window.clearTimeout(this.mobileScrollHideTimer);
138
+ }
139
+ this.mobileScrollHideTimer = window.setTimeout(() => {
140
+ this.showMobileMenuButton = true;
141
+ this.mobileScrollHideTimer = null;
142
+ }, 180);
143
+ },
144
+ emitMobileMenuState() {
145
+ if (typeof window === "undefined") return;
146
+ window.dispatchEvent(
147
+ new CustomEvent(MAIN_MENU_TOGGLE_EVENT, {
148
+ detail: { open: this.mobileMenuOpen },
149
+ })
150
+ );
151
+ },
152
+ handleViewportChange(event) {
153
+ this.syncMenuForViewport(event.matches);
154
+ },
155
+ syncMenuForViewport(isDesktopViewport) {
156
+ if (!isDesktopViewport) {
157
+ this.mobileMenuOpen = false;
158
+ this.emitMobileMenuState();
159
+ this.showMobileMenuButton = true;
160
+ }
161
+ },
111
162
  toggleMenu() {
112
163
  this.mobileMenuOpen = !this.mobileMenuOpen;
164
+ this.showMobileMenuButton = !this.mobileMenuOpen;
165
+ this.emitMobileMenuState();
113
166
  },
114
167
  closeMenu() {
115
168
  this.mobileMenuOpen = false;
169
+ this.showMobileMenuButton = true;
170
+ this.emitMobileMenuState();
116
171
  },
117
172
  handleSelect(id) {
118
173
  this.closeMenu();
package/src/ui/kMenu.vue CHANGED
@@ -53,6 +53,13 @@
53
53
  @click="handleLinkClick(link, $event)">
54
54
  <kIcon v-if="link.icon" :name="link.icon" :size="16" class="shrink-0 self-center" :class="iconClass(link)" />
55
55
  <span class="leading-none">{{ link.label }}</span>
56
+ <kIcon
57
+ v-if="link.checked"
58
+ name="Check"
59
+ :size="16"
60
+ class="ml-auto shrink-0"
61
+ :class="checkedIconClass(link)"
62
+ />
56
63
  </component>
57
64
  </li>
58
65
  </ul>
@@ -135,6 +142,10 @@ export default {
135
142
  if (link?.tone === "danger") return "text-orange-500 dark:text-orange-300";
136
143
  return "text-primary/80";
137
144
  },
145
+ checkedIconClass(link) {
146
+ if (link?.tone === "danger") return "text-orange-500 dark:text-orange-300";
147
+ return "text-primary dark:text-primary";
148
+ },
138
149
  handleLinkClick(link, event) {
139
150
  if (link?.disabled) {
140
151
  event.preventDefault();
@@ -37,24 +37,48 @@
37
37
  </button>
38
38
 
39
39
  <div v-if="actions.length && !isCompact" class="flex flex-wrap items-center justify-end gap-2 ml-auto shrink-0">
40
- <kButton
41
- v-for="(action, index) in actions"
42
- :key="`${action.label || 'action'}-${index}`"
43
- :label="action.label"
44
- :icon="action.icon"
45
- :loading="!!action.loading"
46
- :disabled="disabled || !!action.disabled"
47
- :aria-label="action.ariaLabel || action.label"
48
- :tooltip="action.tooltip"
49
- :size="resolveActionButtonSize(action)"
50
- :small="action.small === true"
51
- :icon-only="!!action.iconOnly"
52
- :variant="action.variant || 'outlined'"
53
- :secondary="!!action.secondary"
54
- :success="!!action.success"
55
- :warning="!!action.warning"
56
- :danger="!!action.danger"
57
- @click="handleActionClick(action)" />
40
+ <template v-for="(action, index) in actions" :key="`${action.label || 'action'}-${index}`">
41
+ <kMenu
42
+ v-if="isActionGroup(action)"
43
+ :links="resolveActionGroupLinks(action)"
44
+ :aria-label="action.ariaLabel || action.label || 'Action group'"
45
+ >
46
+ <template #trigger>
47
+ <button
48
+ type="button"
49
+ :disabled="disabled || !!action.disabled"
50
+ :aria-label="action.ariaLabel || action.label"
51
+ :class="resolveActionGroupTriggerClass(action)"
52
+ >
53
+ <kIcon
54
+ v-if="action.icon"
55
+ :name="action.icon"
56
+ :size="resolveActionGroupIconSize(action)"
57
+ class="shrink-0"
58
+ />
59
+ <span v-if="!action.iconOnly" class="truncate">{{ action.label }}</span>
60
+ <kIcon v-if="!action.iconOnly" name="ChevronDown" :size="16" class="shrink-0 opacity-80" />
61
+ </button>
62
+ </template>
63
+ </kMenu>
64
+ <kButton
65
+ v-else
66
+ :label="action.label"
67
+ :icon="action.icon"
68
+ :loading="!!action.loading"
69
+ :disabled="disabled || !!action.disabled"
70
+ :aria-label="action.ariaLabel || action.label"
71
+ :tooltip="action.tooltip"
72
+ :size="resolveActionButtonSize(action)"
73
+ :small="action.small === true"
74
+ :icon-only="!!action.iconOnly"
75
+ :variant="action.variant || 'outlined'"
76
+ :secondary="!!action.secondary"
77
+ :success="!!action.success"
78
+ :warning="!!action.warning"
79
+ :danger="!!action.danger"
80
+ @click="handleActionClick(action)" />
81
+ </template>
58
82
  </div>
59
83
  </div>
60
84
 
@@ -79,20 +103,42 @@
79
103
  </div>
80
104
 
81
105
  <div class="flex flex-col overflow-y-auto max-h-[60vh]">
82
- <button
83
- v-for="(action, index) in actions"
84
- :key="`mobile-${action.label || 'action'}-${index}`"
85
- type="button"
86
- :disabled="disabled || !!action.disabled || !!action.loading"
87
- :class="[
88
- 'flex items-center w-full gap-3 px-2 py-3 text-left rounded-lg min-h-11',
89
- disabled || action.disabled || action.loading ? 'opacity-50 cursor-not-allowed' : 'hover:bg-primary/10 active:bg-primary/15',
90
- ]"
91
- @click="handleMobileAction(action)">
92
- <kIcon v-if="action.icon" :name="action.icon" :size="18" class="shrink-0 text-slate-600 dark:text-slate-400" />
93
- <span class="text-sm font-medium text-slate-900 dark:text-slate-200">{{ action.label }}</span>
94
- <span v-if="action.loading" class="w-4 h-4 ml-auto border-2 rounded-full border-slate-300 border-t-transparent animate-spin dark:border-slate-600"></span>
95
- </button>
106
+ <template v-for="(action, index) in actions" :key="`mobile-${action.label || 'action'}-${index}`">
107
+ <kMenu
108
+ v-if="isActionGroup(action)"
109
+ class="w-full"
110
+ :links="resolveActionGroupLinks(action)"
111
+ :aria-label="action.ariaLabel || action.label || 'Action group'"
112
+ >
113
+ <template #trigger>
114
+ <button
115
+ type="button"
116
+ :disabled="disabled || !!action.disabled"
117
+ :class="[
118
+ 'flex items-center w-full gap-3 px-2 py-3 text-left rounded-lg min-h-11',
119
+ disabled || action.disabled ? 'opacity-50 cursor-not-allowed' : 'hover:bg-primary/10 active:bg-primary/15',
120
+ ]"
121
+ >
122
+ <kIcon v-if="action.icon" :name="action.icon" :size="18" class="shrink-0 text-slate-600 dark:text-slate-400" />
123
+ <span class="text-sm font-medium text-slate-900 dark:text-slate-200">{{ action.label }}</span>
124
+ <kIcon name="ChevronDown" :size="16" class="ml-auto shrink-0 text-slate-500 dark:text-slate-400" />
125
+ </button>
126
+ </template>
127
+ </kMenu>
128
+ <button
129
+ v-else
130
+ type="button"
131
+ :disabled="disabled || !!action.disabled || !!action.loading"
132
+ :class="[
133
+ 'flex items-center w-full gap-3 px-2 py-3 text-left rounded-lg min-h-11',
134
+ disabled || action.disabled || action.loading ? 'opacity-50 cursor-not-allowed' : 'hover:bg-primary/10 active:bg-primary/15',
135
+ ]"
136
+ @click="handleMobileAction(action)">
137
+ <kIcon v-if="action.icon" :name="action.icon" :size="18" class="shrink-0 text-slate-600 dark:text-slate-400" />
138
+ <span class="text-sm font-medium text-slate-900 dark:text-slate-200">{{ action.label }}</span>
139
+ <span v-if="action.loading" class="w-4 h-4 ml-auto border-2 rounded-full border-slate-300 border-t-transparent animate-spin dark:border-slate-600"></span>
140
+ </button>
141
+ </template>
96
142
  </div>
97
143
  </section>
98
144
  </div>
@@ -103,6 +149,13 @@
103
149
  import { ref, watch, nextTick, onMounted, onBeforeUnmount } from "vue";
104
150
  import kButton from "./kButton.vue";
105
151
  import kIcon from "./kIcon.vue";
152
+ import kMenu from "./kMenu.vue";
153
+ import {
154
+ K_BUTTON_BASE,
155
+ K_BUTTON_FOCUS_RING,
156
+ K_BUTTON_SIZE_STYLES,
157
+ K_BUTTON_VARIANT_STYLES,
158
+ } from "./themes/kButton.theme";
106
159
 
107
160
  const props = defineProps({
108
161
  title: {
@@ -140,6 +193,18 @@ const isCompact = ref(false);
140
193
  let resizeObserver = null;
141
194
 
142
195
  const VALID_BUTTON_SIZES = ["small", "normal", "large"];
196
+ const ACTION_GROUP_KEYS = ["items", "children", "links"];
197
+
198
+ const isActionGroup = (action) =>
199
+ ACTION_GROUP_KEYS.some((key) => Array.isArray(action?.[key]) && action[key].length > 0);
200
+
201
+ const resolveActionGroupLinks = (action) => {
202
+ const items = ACTION_GROUP_KEYS.map((key) => action?.[key]).find((value) => Array.isArray(value)) || [];
203
+ return items.map((item) => ({
204
+ ...item,
205
+ action: item?.action || item?.onClick,
206
+ }));
207
+ };
143
208
 
144
209
  const handleActionClick = (action) => {
145
210
  if (typeof action?.onClick === "function") action.onClick();
@@ -157,6 +222,52 @@ const resolveActionButtonSize = (action) => {
157
222
  return "normal";
158
223
  };
159
224
 
225
+ const resolveActionGroupIntent = (action) => {
226
+ if (action?.danger) return "danger";
227
+ if (action?.success) return "success";
228
+ if (action?.warning) return "warning";
229
+ if (action?.secondary) return "secondary";
230
+ return "primary";
231
+ };
232
+
233
+ const resolveActionGroupVariant = (action) => {
234
+ if (action?.outlined) return "outlined";
235
+ if (action?.secondary && !action?.variant) return "soft";
236
+ return action?.variant || "outlined";
237
+ };
238
+
239
+ const resolveActionGroupSizeKey = (action) => {
240
+ const size = resolveActionButtonSize(action);
241
+ return K_BUTTON_SIZE_STYLES[size] ? size : "normal";
242
+ };
243
+
244
+ const resolveActionGroupTriggerClass = (action) => {
245
+ const intent = resolveActionGroupIntent(action);
246
+ const variant = resolveActionGroupVariant(action);
247
+ const sizeSet = K_BUTTON_SIZE_STYLES[resolveActionGroupSizeKey(action)];
248
+ const intentSet = K_BUTTON_VARIANT_STYLES[intent] || K_BUTTON_VARIANT_STYLES.primary;
249
+ const styleSet = intentSet[variant] || intentSet.outlined;
250
+ const variantClass = (action?.disabled || props.disabled) ? styleSet.disabled : styleSet.enabled;
251
+ const sizeClass = action?.iconOnly ? sizeSet.iconOnly : sizeSet.regular;
252
+
253
+ return [
254
+ K_BUTTON_BASE,
255
+ K_BUTTON_FOCUS_RING,
256
+ variantClass,
257
+ sizeClass,
258
+ "gap-2",
259
+ action?.fullWidth ? "w-full justify-center" : action?.iconOnly ? "" : "w-max shrink-0",
260
+ ];
261
+ };
262
+
263
+ const resolveActionGroupIconSize = (action) => {
264
+ const sizeSet = K_BUTTON_SIZE_STYLES[resolveActionGroupSizeKey(action)];
265
+ const iconClass = sizeSet?.icon || "h-4 w-4";
266
+ const match = /(?:^|\s)(?:size-|h-)(\d+(?:\.\d+)?)/.exec(iconClass);
267
+ if (!match) return 16;
268
+ return Number(match[1]) * 4;
269
+ };
270
+
160
271
  const updateCompactMode = () => {
161
272
  if (!toolbarRef.value) return;
162
273
  isCompact.value = toolbarRef.value.clientWidth < 768;