qdadm 0.26.1 → 0.26.3

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,6 +1,6 @@
1
1
  {
2
2
  "name": "qdadm",
3
- "version": "0.26.1",
3
+ "version": "0.26.3",
4
4
  "description": "Vue 3 framework for admin dashboards with PrimeVue",
5
5
  "author": "quazardous",
6
6
  "license": "MIT",
@@ -24,7 +24,8 @@
24
24
  "./components": "./src/components/index.js",
25
25
  "./module": "./src/module/index.js",
26
26
  "./utils": "./src/utils/index.js",
27
- "./styles": "./src/styles/index.css"
27
+ "./styles": "./src/styles/index.scss",
28
+ "./styles/breakpoints": "./src/styles/_breakpoints.scss"
28
29
  },
29
30
  "files": [
30
31
  "src",
@@ -35,11 +36,11 @@
35
36
  "@quazardous/quarkernel": "^2.1.0"
36
37
  },
37
38
  "peerDependencies": {
38
- "vue": "^3.3.0",
39
- "vue-router": "^4.0.0",
40
- "primevue": "^4.0.0",
41
39
  "pinia": "^2.0.0",
42
- "vanilla-jsoneditor": "^0.23.0"
40
+ "primevue": "^4.0.0",
41
+ "vanilla-jsoneditor": "^0.23.0",
42
+ "vue": "^3.3.0",
43
+ "vue-router": "^4.0.0"
43
44
  },
44
45
  "keywords": [
45
46
  "vue",
@@ -55,6 +56,7 @@
55
56
  "@vitejs/plugin-vue": "^5.2.1",
56
57
  "@vue/test-utils": "^2.4.6",
57
58
  "jsdom": "^25.0.1",
59
+ "sass": "^1.97.1",
58
60
  "vitest": "^2.1.8"
59
61
  }
60
62
  }
@@ -12,7 +12,7 @@
12
12
  * </AppLayout>
13
13
  */
14
14
 
15
- import { ref, watch, onMounted, computed, inject, provide, useSlots } from 'vue'
15
+ import { ref, watch, onMounted, onUnmounted, computed, inject, provide, useSlots } from 'vue'
16
16
  import { RouterLink, RouterView, useRouter, useRoute } from 'vue-router'
17
17
  import { useNavigation } from '../../composables/useNavigation'
18
18
  import { useApp } from '../../composables/useApp'
@@ -42,6 +42,30 @@ const STORAGE_KEY = computed(() => `${app.shortName.toLowerCase()}_nav_collapsed
42
42
  // Collapsed sections state (section title -> boolean)
43
43
  const collapsedSections = ref({})
44
44
 
45
+ // Mobile sidebar state
46
+ const sidebarOpen = ref(false)
47
+ const MOBILE_BREAKPOINT = 768
48
+
49
+ function toggleSidebar() {
50
+ sidebarOpen.value = !sidebarOpen.value
51
+ }
52
+
53
+ function closeSidebar() {
54
+ sidebarOpen.value = false
55
+ }
56
+
57
+ // Check if we're on mobile
58
+ function isMobile() {
59
+ return window.innerWidth < MOBILE_BREAKPOINT
60
+ }
61
+
62
+ // Close sidebar on resize to desktop
63
+ function handleResize() {
64
+ if (!isMobile() && sidebarOpen.value) {
65
+ sidebarOpen.value = false
66
+ }
67
+ }
68
+
45
69
  /**
46
70
  * Load collapsed state from localStorage
47
71
  */
@@ -87,13 +111,21 @@ function isSectionExpanded(section) {
87
111
  return !collapsedSections.value[section.title]
88
112
  }
89
113
 
90
- // Load state on mount
114
+ // Load state on mount + setup resize listener
91
115
  onMounted(() => {
92
116
  loadCollapsedState()
117
+ window.addEventListener('resize', handleResize)
118
+ })
119
+
120
+ onUnmounted(() => {
121
+ window.removeEventListener('resize', handleResize)
93
122
  })
94
123
 
95
- // Auto-expand section when navigating to an item in it
124
+ // Auto-expand section when navigating to an item in it + close mobile sidebar
96
125
  watch(() => route.path, () => {
126
+ // Close mobile sidebar on navigation
127
+ closeSidebar()
128
+
97
129
  for (const section of navSections.value) {
98
130
  if (sectionHasActiveItem(section) && collapsedSections.value[section.title]) {
99
131
  // Auto-expand but don't save (user can collapse again if they want)
@@ -149,8 +181,15 @@ const showBreadcrumb = computed(() => {
149
181
 
150
182
  <template>
151
183
  <div class="app-layout">
184
+ <!-- Mobile overlay -->
185
+ <div
186
+ class="sidebar-overlay"
187
+ :class="{ 'sidebar-overlay--visible': sidebarOpen }"
188
+ @click="closeSidebar"
189
+ ></div>
190
+
152
191
  <!-- Sidebar -->
153
- <aside class="sidebar">
192
+ <aside class="sidebar" :class="{ 'sidebar--open': sidebarOpen }">
154
193
  <div class="sidebar-header">
155
194
  <div class="sidebar-header-top">
156
195
  <img v-if="app.logo" :src="app.logo" :alt="app.name" class="sidebar-logo" />
@@ -216,6 +255,19 @@ const showBreadcrumb = computed(() => {
216
255
 
217
256
  <!-- Main content -->
218
257
  <main class="main-content">
258
+ <!-- Mobile header bar -->
259
+ <div class="mobile-header">
260
+ <Button
261
+ icon="pi pi-bars"
262
+ severity="secondary"
263
+ text
264
+ class="hamburger-btn"
265
+ @click="toggleSidebar"
266
+ aria-label="Toggle menu"
267
+ />
268
+ <span class="mobile-header-title">{{ app.name }}</span>
269
+ </div>
270
+
219
271
  <!-- Breadcrumb + Navlinks bar -->
220
272
  <div v-if="showBreadcrumb" class="layout-nav-bar">
221
273
  <Breadcrumb :model="breadcrumbItems" class="layout-breadcrumb">
@@ -562,4 +614,98 @@ const showBreadcrumb = computed(() => {
562
614
  .layout-nav-bar :deep(.p-breadcrumb-separator) {
563
615
  color: var(--p-surface-400, #94a3b8);
564
616
  }
617
+
618
+ /* ============================================
619
+ Mobile / Responsive Styles
620
+ ============================================ */
621
+
622
+ .mobile-header {
623
+ display: none;
624
+ align-items: center;
625
+ gap: 0.75rem;
626
+ padding: 0.75rem 1rem;
627
+ background: var(--p-surface-0, white);
628
+ border-bottom: 1px solid var(--p-surface-200, #e2e8f0);
629
+ position: sticky;
630
+ top: 0;
631
+ z-index: 50;
632
+ }
633
+
634
+ .mobile-header-title {
635
+ font-weight: 600;
636
+ font-size: 1.125rem;
637
+ color: var(--p-surface-800, #1e293b);
638
+ }
639
+
640
+ .hamburger-btn {
641
+ display: none !important;
642
+ }
643
+
644
+ /* Sidebar overlay for mobile */
645
+ .sidebar-overlay {
646
+ display: none;
647
+ position: fixed;
648
+ inset: 0;
649
+ background: rgba(0, 0, 0, 0.5);
650
+ z-index: 99;
651
+ opacity: 0;
652
+ transition: opacity 0.25s ease;
653
+ pointer-events: none;
654
+ }
655
+
656
+ /* Tablet breakpoint (< 768px) */
657
+ @media (max-width: 767px) {
658
+ .mobile-header {
659
+ display: flex;
660
+ }
661
+
662
+ .hamburger-btn {
663
+ display: flex !important;
664
+ }
665
+
666
+ .sidebar {
667
+ transform: translateX(-100%);
668
+ transition: transform 0.25s ease;
669
+ box-shadow: none;
670
+ width: min(80vw, 280px);
671
+ }
672
+
673
+ .sidebar.sidebar--open {
674
+ transform: translateX(0);
675
+ box-shadow: 4px 0 20px rgba(0, 0, 0, 0.15);
676
+ }
677
+
678
+ .sidebar-overlay {
679
+ display: block;
680
+ }
681
+
682
+ .sidebar-overlay.sidebar-overlay--visible {
683
+ opacity: 1;
684
+ pointer-events: auto;
685
+ }
686
+
687
+ .main-content {
688
+ margin-left: 0;
689
+ }
690
+
691
+ .page-content {
692
+ padding: 1rem;
693
+ }
694
+
695
+ .layout-nav-bar {
696
+ padding: 1rem;
697
+ padding-bottom: 0;
698
+ }
699
+ }
700
+
701
+ /* Large tablet breakpoint (768px - 1023px) */
702
+ @media (min-width: 768px) and (max-width: 1023px) {
703
+ .sidebar {
704
+ width: 12rem;
705
+ }
706
+
707
+ .main-content {
708
+ margin-left: 12rem;
709
+ }
710
+ }
565
711
  </style>
@@ -1,4 +1,4 @@
1
- import { ref, computed, watch, onMounted, inject, provide, nextTick } from 'vue'
1
+ import { ref, computed, watch, onMounted, inject, provide } from 'vue'
2
2
  import { useRouter, useRoute } from 'vue-router'
3
3
  import { useToast } from 'primevue/usetoast'
4
4
  import { useConfirm } from 'primevue/useconfirm'
@@ -165,12 +165,19 @@ export function useListPageBuilder(config = {}) {
165
165
  // Get HookRegistry for list:alter hook (optional, may not exist in tests)
166
166
  const hooks = useHooks()
167
167
 
168
+ // ============ SESSION RESTORE ============
169
+ // Load saved filters + search from session storage (used by filterValues and searchQuery)
170
+ const savedSession = persistFilters ? getSessionFilters(filterSessionKey) : null
171
+ const savedSearch = savedSession?._search || ''
172
+ // Clone and remove _search from filters
173
+ const savedFilters = savedSession ? { ...savedSession } : null
174
+ if (savedFilters) delete savedFilters._search
175
+
168
176
  // ============ STATE ============
169
177
  const items = ref([])
170
178
  const loading = ref(false)
171
179
  const selected = ref([])
172
180
  const deleting = ref(false)
173
- let isRestoringFilters = false // Flag to skip watch during restore
174
181
 
175
182
  // Pagination (load from cookie if available)
176
183
  const page = ref(1)
@@ -182,8 +189,8 @@ export function useListPageBuilder(config = {}) {
182
189
  const sortField = ref(defaultSort)
183
190
  const sortOrder = ref(defaultSortOrder)
184
191
 
185
- // Search
186
- const searchQuery = ref('')
192
+ // Search (initialized from session storage)
193
+ const searchQuery = ref(savedSearch)
187
194
  const searchConfig = ref({
188
195
  placeholder: 'Search...',
189
196
  fields: [],
@@ -473,8 +480,7 @@ export function useListPageBuilder(config = {}) {
473
480
 
474
481
  // ============ FILTERS ============
475
482
  const filtersMap = ref(new Map())
476
- // Load saved filters from session storage
477
- const savedFilters = persistFilters ? getSessionFilters(filterSessionKey) : null
483
+ // filterValues initialized from savedFilters (loaded in SESSION RESTORE section)
478
484
  const filterValues = ref(savedFilters || {})
479
485
 
480
486
  function addFilter(name, filterConfig) {
@@ -897,55 +903,24 @@ export function useListPageBuilder(config = {}) {
897
903
  }
898
904
 
899
905
  /**
900
- * Restore filter values from URL query params (priority) or session storage
906
+ * Apply URL query params as overrides (session already loaded at init)
901
907
  */
902
908
  function restoreFilters() {
903
- isRestoringFilters = true // Prevent watch from triggering during restore
904
-
905
- // Priority 1: URL query params
906
- const urlFilters = {}
909
+ // URL params override session values
907
910
  for (const key of filtersMap.value.keys()) {
908
911
  if (route.query[key] !== undefined) {
909
- // Parse value (handle booleans, numbers, etc.)
910
912
  let value = route.query[key]
911
913
  if (value === 'true') value = true
912
914
  else if (value === 'false') value = false
913
915
  else if (value === 'null') value = null
914
916
  else if (!isNaN(Number(value)) && value !== '') value = Number(value)
915
- urlFilters[key] = value
917
+ filterValues.value[key] = value
916
918
  }
917
919
  }
918
- // Restore search from URL
920
+ // URL search overrides session search
919
921
  if (route.query.search) {
920
922
  searchQuery.value = route.query.search
921
923
  }
922
-
923
- // Priority 2: Session storage (only for filters/search not in URL)
924
- const sessionData = persistFilters ? getSessionFilters(filterSessionKey) : null
925
-
926
- // Extract search from session (stored as _search)
927
- if (sessionData?._search && !route.query.search) {
928
- searchQuery.value = sessionData._search
929
- }
930
-
931
- // Remove _search from session data before merging with filters
932
- const sessionFilters = sessionData ? { ...sessionData } : null
933
- if (sessionFilters) delete sessionFilters._search
934
-
935
- // Merge: URL takes priority over session
936
- const restoredFilters = { ...sessionFilters, ...urlFilters }
937
-
938
- // Apply restored values
939
- for (const [name, value] of Object.entries(restoredFilters)) {
940
- if (filtersMap.value.has(name)) {
941
- filterValues.value[name] = value
942
- }
943
- }
944
-
945
- // Reset flag after Vue processes updates
946
- nextTick(() => {
947
- isRestoringFilters = false
948
- })
949
924
  }
950
925
 
951
926
  // ============ ACTIONS ============
@@ -1242,8 +1217,6 @@ export function useListPageBuilder(config = {}) {
1242
1217
  // ============ WATCHERS ============
1243
1218
  let searchTimeout = null
1244
1219
  watch(searchQuery, () => {
1245
- // Skip watch during restore (loadItems will be called after restore)
1246
- if (isRestoringFilters) return
1247
1220
  clearTimeout(searchTimeout)
1248
1221
  searchTimeout = setTimeout(() => {
1249
1222
  // Use onFiltersChanged to also sync URL params
@@ -0,0 +1,65 @@
1
+ /**
2
+ * qdadm - Responsive Breakpoints
3
+ *
4
+ * Strategy: Desktop-first (PC > tablet > mobile)
5
+ *
6
+ * Usage in components:
7
+ * @use 'qdadm/styles/breakpoints' as *;
8
+ * .sidebar { @include tablet { width: 60px; } }
9
+ */
10
+
11
+ // Breakpoint values
12
+ $bp-desktop-lg: 1400px; // Large desktop
13
+ $bp-desktop: 1200px; // Standard desktop
14
+ $bp-tablet-lg: 1024px; // Large tablet / small laptop
15
+ $bp-tablet: 768px; // Tablet portrait
16
+ $bp-mobile-lg: 576px; // Large mobile
17
+ $bp-mobile: 480px; // Standard mobile
18
+
19
+ // Sidebar behavior threshold
20
+ $bp-sidebar-collapse: $bp-tablet-lg; // Sidebar collapses below this
21
+ $bp-sidebar-hidden: $bp-tablet; // Sidebar becomes drawer below this
22
+
23
+ // Mixins - Desktop-first (max-width)
24
+ @mixin desktop-lg {
25
+ @media (max-width: #{$bp-desktop-lg - 1px}) { @content; }
26
+ }
27
+
28
+ @mixin desktop {
29
+ @media (max-width: #{$bp-desktop - 1px}) { @content; }
30
+ }
31
+
32
+ @mixin tablet-lg {
33
+ @media (max-width: #{$bp-tablet-lg - 1px}) { @content; }
34
+ }
35
+
36
+ @mixin tablet {
37
+ @media (max-width: #{$bp-tablet - 1px}) { @content; }
38
+ }
39
+
40
+ @mixin mobile-lg {
41
+ @media (max-width: #{$bp-mobile-lg - 1px}) { @content; }
42
+ }
43
+
44
+ @mixin mobile {
45
+ @media (max-width: #{$bp-mobile - 1px}) { @content; }
46
+ }
47
+
48
+ // Utility: hide/show at breakpoints
49
+ @mixin hide-below($bp) {
50
+ @media (max-width: #{$bp - 1px}) { display: none !important; }
51
+ }
52
+
53
+ @mixin show-below($bp) {
54
+ display: none !important;
55
+ @media (max-width: #{$bp - 1px}) { display: block !important; }
56
+ }
57
+
58
+ // Sidebar-specific mixins
59
+ @mixin sidebar-collapsed {
60
+ @media (max-width: #{$bp-sidebar-collapse - 1px}) { @content; }
61
+ }
62
+
63
+ @mixin sidebar-hidden {
64
+ @media (max-width: #{$bp-sidebar-hidden - 1px}) { @content; }
65
+ }
@@ -0,0 +1,244 @@
1
+ /**
2
+ * qdadm - Responsive Layout Styles
3
+ *
4
+ * Global responsive rules for layout components.
5
+ * These complement the scoped styles in Vue components.
6
+ */
7
+
8
+ @use 'breakpoints' as *;
9
+
10
+ // ============================================
11
+ // CSS Variables for responsive behavior
12
+ // ============================================
13
+
14
+ :root {
15
+ // Sidebar
16
+ --qdadm-sidebar-width: 15rem;
17
+ --qdadm-sidebar-collapsed-width: 4rem;
18
+ --qdadm-sidebar-transition: 0.25s ease;
19
+
20
+ // Content padding
21
+ --qdadm-content-padding: 1.5rem;
22
+ --qdadm-content-padding-mobile: 1rem;
23
+ }
24
+
25
+ // ============================================
26
+ // App Layout Responsive
27
+ // ============================================
28
+
29
+ .app-layout {
30
+ // Tablet: reduce sidebar width
31
+ @include tablet-lg {
32
+ --qdadm-sidebar-width: 12rem;
33
+ }
34
+
35
+ // Mobile: sidebar becomes overlay
36
+ @include tablet {
37
+ --qdadm-sidebar-width: 80vw;
38
+ max-width: 280px;
39
+ }
40
+ }
41
+
42
+ // Sidebar responsive behavior
43
+ .sidebar {
44
+ transition: transform var(--qdadm-sidebar-transition),
45
+ width var(--qdadm-sidebar-transition);
46
+
47
+ @include tablet {
48
+ transform: translateX(-100%);
49
+ position: fixed;
50
+ z-index: 1000;
51
+ box-shadow: none;
52
+
53
+ &.sidebar--open {
54
+ transform: translateX(0);
55
+ box-shadow: 0 0 20px rgba(0, 0, 0, 0.3);
56
+ }
57
+ }
58
+ }
59
+
60
+ // Main content responsive
61
+ .main-content {
62
+ transition: margin-left var(--qdadm-sidebar-transition);
63
+
64
+ @include tablet {
65
+ margin-left: 0 !important;
66
+ }
67
+ }
68
+
69
+ // Mobile overlay when sidebar open
70
+ .sidebar-overlay {
71
+ display: none;
72
+ position: fixed;
73
+ inset: 0;
74
+ background: rgba(0, 0, 0, 0.5);
75
+ z-index: 99;
76
+ opacity: 0;
77
+ transition: opacity 0.25s;
78
+
79
+ @include tablet {
80
+ display: block;
81
+ pointer-events: none;
82
+
83
+ &.sidebar-overlay--visible {
84
+ opacity: 1;
85
+ pointer-events: auto;
86
+ }
87
+ }
88
+ }
89
+
90
+ // Hamburger menu button (hidden on desktop)
91
+ .hamburger-btn {
92
+ display: none !important;
93
+
94
+ @include tablet {
95
+ display: flex !important;
96
+ }
97
+ }
98
+
99
+ // ============================================
100
+ // Page Content Responsive
101
+ // ============================================
102
+
103
+ .page-content {
104
+ @include tablet {
105
+ padding: var(--qdadm-content-padding-mobile);
106
+ }
107
+ }
108
+
109
+ // Page header responsive
110
+ .page-header {
111
+ @include tablet {
112
+ flex-direction: column;
113
+ gap: 1rem;
114
+ align-items: flex-start !important;
115
+ }
116
+
117
+ .page-header-actions {
118
+ @include tablet {
119
+ width: 100%;
120
+ justify-content: flex-start;
121
+ }
122
+ }
123
+ }
124
+
125
+ // ============================================
126
+ // DataTable Responsive
127
+ // ============================================
128
+
129
+ .p-datatable {
130
+ @include tablet {
131
+ // Horizontal scroll for table
132
+ .p-datatable-wrapper {
133
+ overflow-x: auto;
134
+ -webkit-overflow-scrolling: touch;
135
+ }
136
+
137
+ // Minimum width to prevent squishing
138
+ .p-datatable-table {
139
+ min-width: 600px;
140
+ }
141
+ }
142
+
143
+ @include mobile {
144
+ .p-datatable-table {
145
+ min-width: 500px;
146
+ }
147
+ }
148
+ }
149
+
150
+ // ============================================
151
+ // Filter Bar Responsive
152
+ // ============================================
153
+
154
+ .filter-bar {
155
+ @include tablet {
156
+ flex-wrap: wrap;
157
+ gap: 0.75rem;
158
+
159
+ .filter-bar-search {
160
+ width: 100%;
161
+ order: -1;
162
+ }
163
+
164
+ .filter-bar-filters {
165
+ flex-wrap: wrap;
166
+ gap: 0.5rem;
167
+ }
168
+
169
+ .filter-bar-item {
170
+ min-width: calc(50% - 0.25rem);
171
+ flex: 1 1 auto;
172
+ }
173
+ }
174
+
175
+ @include mobile {
176
+ .filter-bar-item {
177
+ min-width: 100%;
178
+ }
179
+ }
180
+ }
181
+
182
+ // ============================================
183
+ // Form Layout Responsive
184
+ // ============================================
185
+
186
+ .form-grid {
187
+ @include tablet {
188
+ grid-template-columns: 1fr !important;
189
+ }
190
+ }
191
+
192
+ .form-actions {
193
+ @include mobile {
194
+ flex-direction: column;
195
+
196
+ .p-button {
197
+ width: 100%;
198
+ }
199
+ }
200
+ }
201
+
202
+ // ============================================
203
+ // Cards Grid Responsive
204
+ // ============================================
205
+
206
+ .cards-grid {
207
+ @include tablet {
208
+ grid-template-columns: repeat(2, 1fr);
209
+ }
210
+
211
+ @include mobile {
212
+ grid-template-columns: 1fr;
213
+ }
214
+ }
215
+
216
+ // ============================================
217
+ // Utility Classes
218
+ // ============================================
219
+
220
+ .hide-mobile {
221
+ @include tablet {
222
+ display: none !important;
223
+ }
224
+ }
225
+
226
+ .hide-desktop {
227
+ display: none !important;
228
+ @include tablet {
229
+ display: block !important;
230
+ }
231
+ }
232
+
233
+ .hide-mobile-inline {
234
+ @include tablet {
235
+ display: none !important;
236
+ }
237
+ }
238
+
239
+ .show-mobile-inline {
240
+ display: none !important;
241
+ @include tablet {
242
+ display: inline !important;
243
+ }
244
+ }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * qdadm - Styles entry point
3
+ *
4
+ * Import this file in your main.js:
5
+ * import 'qdadm/styles'
6
+ */
7
+
8
+ /* Main styles */
9
+ @use './main.css';
10
+
11
+ /* Component-specific styles */
12
+ @use './_alerts.css';
13
+ @use './_code.css';
14
+ @use './_dialogs.css';
15
+ @use './_markdown.css';
16
+ @use './_show-pages.css';
17
+
18
+ /* Responsive styles (must be last for specificity) */
19
+ @use './responsive';
@@ -1,16 +0,0 @@
1
- /**
2
- * qdadm - Styles entry point
3
- *
4
- * Import this file in your main.js:
5
- * import 'qdadm/styles'
6
- */
7
-
8
- /* Main styles */
9
- @import './main.css';
10
-
11
- /* Component-specific styles */
12
- @import './_alerts.css';
13
- @import './_code.css';
14
- @import './_dialogs.css';
15
- @import './_markdown.css';
16
- @import './_show-pages.css';