pgo-uiux2 1.0.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.
Files changed (180) hide show
  1. package/.env +1 -0
  2. package/.env.production +1 -0
  3. package/.prettierrc +13 -0
  4. package/.vscode/extensions.json +3 -0
  5. package/BUTTON_GUIDE.md +257 -0
  6. package/README.md +49 -0
  7. package/THEME_REFERENCE.md +310 -0
  8. package/eslint.config.ts +27 -0
  9. package/index.html +13 -0
  10. package/package.json +85 -0
  11. package/public/favicon.ico +0 -0
  12. package/src/App.vue +368 -0
  13. package/src/assets/fonts/Faruma.ttf +0 -0
  14. package/src/components/examples/AppBarExample.vue +101 -0
  15. package/src/components/examples/AvatarExample.vue +47 -0
  16. package/src/components/examples/BannerExample.vue +287 -0
  17. package/src/components/examples/BaseInputExample.vue +25 -0
  18. package/src/components/examples/BreadcrumbExample.vue +53 -0
  19. package/src/components/examples/CardExample.vue +77 -0
  20. package/src/components/examples/ChipExample.vue +225 -0
  21. package/src/components/examples/DatePickerExample.vue +31 -0
  22. package/src/components/examples/DropdownExample.vue +84 -0
  23. package/src/components/examples/EditorExample.vue +200 -0
  24. package/src/components/examples/ExpansionPanelExample.vue +42 -0
  25. package/src/components/examples/FileUploadExample.vue +40 -0
  26. package/src/components/examples/FormExample.vue +121 -0
  27. package/src/components/examples/HugeTest.vue +8 -0
  28. package/src/components/examples/LayoutContainerExample.vue +80 -0
  29. package/src/components/examples/ModalExample.vue +82 -0
  30. package/src/components/examples/NavDrawerExample.vue +170 -0
  31. package/src/components/examples/NumberFieldExample.vue +145 -0
  32. package/src/components/examples/RadioButtonExample.vue +161 -0
  33. package/src/components/examples/SearchExample.vue +322 -0
  34. package/src/components/examples/SelectExample.vue +121 -0
  35. package/src/components/examples/StackedTableViewExample.vue +53 -0
  36. package/src/components/examples/TabExample.vue +336 -0
  37. package/src/components/examples/TableExample.vue +228 -0
  38. package/src/components/examples/TextFieldExample.vue +181 -0
  39. package/src/components/examples/TextareaExample.vue +173 -0
  40. package/src/components/examples/ThemeToggle.vue +50 -0
  41. package/src/components/examples/TimelineExample.vue +66 -0
  42. package/src/components/examples/TipTapEditorExample.vue +20 -0
  43. package/src/components/examples/TooltipExample.vue +53 -0
  44. package/src/components/examples/VueDatePickerShowcase.vue +214 -0
  45. package/src/components/examples/_DatePickerExample.vue +33 -0
  46. package/src/components/examples/__FormExample.vue +77 -0
  47. package/src/components/index.ts +25 -0
  48. package/src/components/pgo/AppBar.vue +347 -0
  49. package/src/components/pgo/Avatar.vue +139 -0
  50. package/src/components/pgo/Banner.vue +300 -0
  51. package/src/components/pgo/Breadcrumb.vue +101 -0
  52. package/src/components/pgo/Button.vue +171 -0
  53. package/src/components/pgo/Card.vue +178 -0
  54. package/src/components/pgo/ConfirmationModel.vue +32 -0
  55. package/src/components/pgo/DataTable.vue +845 -0
  56. package/src/components/pgo/DatePicker/CalendarPanel.vue +43 -0
  57. package/src/components/pgo/DatePicker/__DatePicker.vue +122 -0
  58. package/src/components/pgo/DatePicker/types.ts +11 -0
  59. package/src/components/pgo/DatePicker/useCalendar.ts +39 -0
  60. package/src/components/pgo/DatePicker/useDatePicker.ts +31 -0
  61. package/src/components/pgo/Deprecated/ToastContainer.vue +51 -0
  62. package/src/components/pgo/Deprecated/ToastItem.vue +55 -0
  63. package/src/components/pgo/Dropdown.vue +296 -0
  64. package/src/components/pgo/DropdownItem.vue +40 -0
  65. package/src/components/pgo/Editor.vue +511 -0
  66. package/src/components/pgo/ExpansionPanel.vue +185 -0
  67. package/src/components/pgo/Footer.vue +39 -0
  68. package/src/components/pgo/HeroIcon.vue +124 -0
  69. package/src/components/pgo/InputSearch.vue +194 -0
  70. package/src/components/pgo/LayoutContainer.vue +104 -0
  71. package/src/components/pgo/Main.vue +37 -0
  72. package/src/components/pgo/Modal.vue +273 -0
  73. package/src/components/pgo/NavDrawer.vue +127 -0
  74. package/src/components/pgo/NavDrawerItem.vue +161 -0
  75. package/src/components/pgo/NavigationDrawer.vue +849 -0
  76. package/src/components/pgo/OLDNavDrawer.vue +661 -0
  77. package/src/components/pgo/OldAppBar.vue +223 -0
  78. package/src/components/pgo/PApp.vue +102 -0
  79. package/src/components/pgo/Pagination.vue +242 -0
  80. package/src/components/pgo/Search copy.vue +310 -0
  81. package/src/components/pgo/Search.vue +411 -0
  82. package/src/components/pgo/StackedTableView.vue +167 -0
  83. package/src/components/pgo/Tab.vue +617 -0
  84. package/src/components/pgo/TestInput.vue +395 -0
  85. package/src/components/pgo/Timeline.vue +367 -0
  86. package/src/components/pgo/TimelineItem.vue +80 -0
  87. package/src/components/pgo/TipTapEditor.vue +315 -0
  88. package/src/components/pgo/Tooltip.NOTES.md +12 -0
  89. package/src/components/pgo/Tooltip.PROPS.md +21 -0
  90. package/src/components/pgo/Tooltip.vue +281 -0
  91. package/src/components/pgo/base/Base.vue +444 -0
  92. package/src/components/pgo/buttons/Chip.vue +324 -0
  93. package/src/components/pgo/buttons/ChipGroup.vue +224 -0
  94. package/src/components/pgo/buttons/Radio.vue +424 -0
  95. package/src/components/pgo/filters/FilterSection.vue +188 -0
  96. package/src/components/pgo/filters/Searchbar.vue +216 -0
  97. package/src/components/pgo/forms/DynamicForm.vue +45 -0
  98. package/src/components/pgo/forms/Form.vue +132 -0
  99. package/src/components/pgo/index.ts +15 -0
  100. package/src/components/pgo/inputs/Checkbox.vue +320 -0
  101. package/src/components/pgo/inputs/DatePicker.vue +395 -0
  102. package/src/components/pgo/inputs/FileUpload.vue +326 -0
  103. package/src/components/pgo/inputs/NumberField.vue +243 -0
  104. package/src/components/pgo/inputs/Radio.vue +162 -0
  105. package/src/components/pgo/inputs/RadioGroup.vue +188 -0
  106. package/src/components/pgo/inputs/Select.vue +535 -0
  107. package/src/components/pgo/inputs/TextField.vue +194 -0
  108. package/src/components/pgo/inputs/Textarea.vue +181 -0
  109. package/src/main.js +12 -0
  110. package/src/pgo-components/_index.js +31 -0
  111. package/src/pgo-components/assets/fonts/Faruma.ttf +0 -0
  112. package/src/pgo-components/assets/fonts/logo.png +0 -0
  113. package/src/pgo-components/composables/useTheme.js +10 -0
  114. package/src/pgo-components/directives/tooltip-directive.ts +393 -0
  115. package/src/pgo-components/index.js +96 -0
  116. package/src/pgo-components/lib/componentConfig.js +147 -0
  117. package/src/pgo-components/lib/core/composables/_useCalendar.ts +127 -0
  118. package/src/pgo-components/lib/core/composables/useDefaults.ts +15 -0
  119. package/src/pgo-components/lib/core/composables/useLanguageSelect.js +0 -0
  120. package/src/pgo-components/lib/core/composables/useRtl.ts +12 -0
  121. package/src/pgo-components/lib/core/defaults/createDefaults.ts +5 -0
  122. package/src/pgo-components/lib/core/defaults/defaults.ts +7 -0
  123. package/src/pgo-components/lib/core/rtl/rtl.ts +3 -0
  124. package/src/pgo-components/lib/core/rtl/setRtl.ts +19 -0
  125. package/src/pgo-components/lib/drawerState.ts +3 -0
  126. package/src/pgo-components/lib/i18n/defaultLables.js +71 -0
  127. package/src/pgo-components/lib/i18n/i18nPlugin.js +52 -0
  128. package/src/pgo-components/lib/i18n/useI18n.js +35 -0
  129. package/src/pgo-components/lib/index.ts +38 -0
  130. package/src/pgo-components/pages/Component.vue +7 -0
  131. package/src/pgo-components/pages/ComponentRenderer.vue +85 -0
  132. package/src/pgo-components/pages/Home.vue +130 -0
  133. package/src/pgo-components/pages/ListView.vue +370 -0
  134. package/src/pgo-components/pages/Page1.vue +296 -0
  135. package/src/pgo-components/pages/_Page1.vue +180 -0
  136. package/src/pgo-components/plugins/SnackBar.vue +251 -0
  137. package/src/pgo-components/plugins/SnackBarContainer.vue +53 -0
  138. package/src/pgo-components/plugins/SnackBarPlugin.ts +136 -0
  139. package/src/pgo-components/plugins/theme-plugin.js +114 -0
  140. package/src/pgo-components/plugins/types.ts +46 -0
  141. package/src/pgo-components/plugins/useSnackBar.js +11 -0
  142. package/src/pgo-components/plugins/useSnackBar.ts +21 -0
  143. package/src/pgo-components/plugins/validation-plugin.js +11 -0
  144. package/src/pgo-components/services/Entry.json +813 -0
  145. package/src/pgo-components/services/axios.js +54 -0
  146. package/src/pgo-components/services/data.json +90 -0
  147. package/src/pgo-components/services/person.json +260 -0
  148. package/src/pgo-components/services/toast.ts +44 -0
  149. package/src/pgo-components/styles/global.css +234 -0
  150. package/src/pgo-components/styles/reset.css +96 -0
  151. package/src/pgo-components/styles/tokens.css +18 -0
  152. package/src/pgo-components/styles/utilities/border-radius.css +57 -0
  153. package/src/pgo-components/styles/utilities/borders.css +85 -0
  154. package/src/pgo-components/styles/utilities/colors.css +38 -0
  155. package/src/pgo-components/styles/utilities/cursor.css +19 -0
  156. package/src/pgo-components/styles/utilities/display.css +78 -0
  157. package/src/pgo-components/styles/utilities/elevation.css +33 -0
  158. package/src/pgo-components/styles/utilities/flex.css +403 -0
  159. package/src/pgo-components/styles/utilities/float.css +41 -0
  160. package/src/pgo-components/styles/utilities/hover.css +9 -0
  161. package/src/pgo-components/styles/utilities/index.css +18 -0
  162. package/src/pgo-components/styles/utilities/opacity.css +27 -0
  163. package/src/pgo-components/styles/utilities/overflow.css +26 -0
  164. package/src/pgo-components/styles/utilities/palette.css +515 -0
  165. package/src/pgo-components/styles/utilities/position.css +14 -0
  166. package/src/pgo-components/styles/utilities/sizing.css +70 -0
  167. package/src/pgo-components/styles/utilities/spacing.css +578 -0
  168. package/src/pgo-components/styles/utilities/transitions.css +58 -0
  169. package/src/pgo-components/styles/utilities/typography.css +91 -0
  170. package/src/pgo-components/styles/utilities/z-index.css +11 -0
  171. package/src/pgo-components/tokens/index.js +337 -0
  172. package/src/router/index.js +88 -0
  173. package/src/shims-vue.d.ts +14 -0
  174. package/src/validations/validationRules.js +50 -0
  175. package/tailwind.config.js +73 -0
  176. package/test.php +5 -0
  177. package/tsconfig.json +25 -0
  178. package/ui +31 -0
  179. package/ui.pgo.mv.conf +18 -0
  180. package/vite.config.js +42 -0
@@ -0,0 +1,849 @@
1
+ <template>
2
+ <div class="relative">
3
+ <!-- Scrim for temporary drawer -->
4
+ <div
5
+ v-if="isOpen && temporary"
6
+ class="fixed inset-0"
7
+ :class="scrimClass"
8
+ :style="[scrimStyle, scrimThemeStyles]"
9
+ @click="onScrimClick"
10
+ ></div>
11
+
12
+ <!-- Drawer container -->
13
+ <nav
14
+ ref="navRef"
15
+ :class="[
16
+ zClass,
17
+ 'transform transition-transform duration-200 ease-in-out',
18
+ containerBaseClasses,
19
+ locationClass,
20
+ railClass,
21
+ expandedClass,
22
+ color,
23
+ widthClass,
24
+ !isOpen && !permanent ? hiddenClass : 'translate-x-0',
25
+ props.location === 'right' ? 'rounded-l-lg' : 'rounded-r-lg'
26
+ ]"
27
+ :style="[zStyle, themeStyles, topPadding ? { paddingTop: topPadding + 'px' } : undefined]"
28
+ :aria-hidden="!isOpen && !permanent"
29
+ @click="onMouseEnter"
30
+
31
+ >
32
+ <div class="flex h-full flex-col" :style="{ minHeight: '100vh' }">
33
+ <div class="flex items-center justify-between vts-px-4 vts-py-3">
34
+ <slot name="prepend" v-if="showPrepend">
35
+ <!-- Default prepend content: app title -->
36
+ <span class="font-semibold">Menu</span>
37
+ </slot>
38
+ <div class="flex items-center vts-ga-2">
39
+ <slot name="append"></slot>
40
+ <button v-if="!permanent && !temporary" @click="toggleOpen" :class="['vts-pa-2', hoverButtonStyles]" aria-label="Toggle drawer">
41
+ <!-- FIX: for better code -->
42
+ <HeroIcon v-if="props.location === 'left' && !globalRtl && props.rail && collapsed" name="chevron-right" size="20" />
43
+ <HeroIcon v-if="props.location === 'left' && !globalRtl && props.rail && !collapsed" name="chevron-left" size="20" />
44
+
45
+ <HeroIcon v-if="props.location === 'right' && globalRtl && props.rail && collapsed" name="chevron-left" size="20" />
46
+ <HeroIcon v-if="props.location === 'right' && globalRtl && props.rail && !collapsed" name="chevron-right" size="20" />
47
+
48
+ <!-- <HeroIcon :name="locationIcon" size="20" /> -->
49
+ </button>
50
+ <button v-if="temporary" @click="close" :class="['ml-2 rounded p-2', hoverButtonStyles]" aria-label="Close drawer">
51
+ <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
52
+ <path d="M6 6a1 1 0 100-2 1 1 0 000 2zm0 6a1 1 0 110-2 1 1 0 010 2zm0 6a1 1 0 110-2 1 1 0 010 2z" />
53
+ </svg>
54
+ </button>
55
+ </div>
56
+ </div>
57
+
58
+ <div class="flex-1 overflow-auto">
59
+ <!-- If data-driven items provided, render grouped items and support filtering & ordering -->
60
+ <div v-if="props.items">
61
+ <div v-if="props.filterable && !(props.rail && collapsed)" class="vts-px-3 vts-py-2">
62
+ <input v-model="filterQuery" :placeholder="props.searchPlaceholder" class="w-full vts-px-4 vts-py-2" :style="searchInputStyles" />
63
+ </div>
64
+
65
+ <div v-for="group in groupedItems" :key="group.group" class="vts-py-2">
66
+ <div
67
+ v-if="group.group"
68
+ :class="['group-header vts-px-3 vts-pt-3 vts-pb-1 text-lg uppercase flex items-center justify-between', groupHeaderStyles]"
69
+ role="button"
70
+ :tabindex="props.collapsibleGroups ? 0 : -1"
71
+ @click.prevent="toggleGroup(group.group)"
72
+ @keydown.enter.prevent="toggleGroup(group.group)"
73
+ @keydown.space.prevent="toggleGroup(group.group)"
74
+ :aria-expanded="isGroupOpen(group.group)"
75
+ >
76
+ <span class="flex items-center vts-ga-2">
77
+ <!-- render group parent element (link or button) when enabled -->
78
+ <template v-if="props.renderGroupParent">
79
+ <component
80
+ :is="props.groupParents && props.groupParents[group.group] && props.groupParents[group.group].href ? 'a' : 'button'"
81
+ :href="(props.groupParents && props.groupParents[group.group] && props.groupParents[group.group].href) || undefined"
82
+ class="group-parent flex items-center vts-ga-3 vts-px-0 vts-py-0 w-full text-left"
83
+ @click.stop="e => onGroupParentClick(e, group.group)"
84
+ @keydown="e => onGroupParentKey(e, group.group)"
85
+ >
86
+ <span
87
+ v-if="props.groupParents && props.groupParents[group.group] && props.groupParents[group.group].icon"
88
+ class="vts-mr-2"
89
+ >
90
+ <HeroIcon :name="props.groupParents[group.group].icon" size="18" />
91
+ </span>
92
+ <span v-else-if="props.groupIcons && props.groupIcons[group.group]" class="vts-mr-2">
93
+ <HeroIcon :name="props.groupIcons[group.group]" size="18" />
94
+ </span>
95
+ <span v-if="!props.rail || !collapsed" :class="['text-lg uppercase', groupHeaderStyles]">
96
+ {{
97
+ (props.groupParents && props.groupParents[group.group] && props.groupParents[group.group].label) || group.group
98
+ }}
99
+ </span>
100
+ </component>
101
+ </template>
102
+ <template v-else>
103
+ <span v-if="props.groupIcons && props.groupIcons[group.group]" class="vts-mr-2">
104
+ <HeroIcon :name="props.groupIcons[group.group]" size="18" />
105
+ </span>
106
+ <span v-if="!props.rail || !collapsed" :class="['text-lg uppercase', groupHeaderStyles]">
107
+ {{ group.group }}
108
+ </span>
109
+ </template>
110
+ </span>
111
+ <button
112
+ v-if="props.collapsibleGroups && (!props.rail || !collapsed)"
113
+ @click.stop.prevent="toggleGroup(group.group)"
114
+ @keydown.enter.stop.prevent="toggleGroup(group.group)"
115
+ @keydown.space.stop.prevent="toggleGroup(group.group)"
116
+ :class="['vts-px-2 vts-py-1 rounded focus:outline-none', groupHeaderStyles]"
117
+ :style="{ opacity: 0.7 }"
118
+ :aria-expanded="isGroupOpen(group.group)"
119
+ >
120
+ <HeroIcon name="chevron-down" size="16" :class="{ 'rotate-180': isGroupOpen(group.group) }" />
121
+ </button>
122
+ </div>
123
+ <ul class="vts-py-1" v-show="isGroupOpen(group.group)">
124
+ <NavDrawerItem
125
+ v-for="item in group.items"
126
+ :key="item.id"
127
+ :id="item.id"
128
+ :href="item.href"
129
+ :draggable="props.draggable && !item.disabled"
130
+ :dragging="String(draggingId) === String(item.id)"
131
+ :is-drag-over="String(dragOverId) === String(item.id)"
132
+ :drag-pos="dragOverPos"
133
+ @dragstart="handleDragStart"
134
+ @dragover="handleDragOver"
135
+ @dragleave="
136
+ e => {
137
+ if (String(dragOverId) === String(item.id)) {
138
+ dragOverId = null
139
+ dragOverPos = null
140
+ }
141
+ }
142
+ "
143
+ @drop="handleDrop"
144
+ >
145
+ <HeroIcon v-if="item.icon" :name="item.icon" size="20" class="rail-icon mr-2" />
146
+ <span class="rail-label">{{ item.label }}</span>
147
+ </NavDrawerItem>
148
+ </ul>
149
+ </div>
150
+ </div>
151
+ <div v-else>
152
+ <slot />
153
+ </div>
154
+ </div>
155
+
156
+ <div class="vts-px-4 vts-py-3">
157
+ <slot name="footer"></slot>
158
+ </div>
159
+ </div>
160
+ </nav>
161
+ </div>
162
+ </template>
163
+
164
+ <script setup lang="ts">
165
+ import { ref, watch, computed, onMounted, onBeforeUnmount, onUpdated, inject, toRaw } from 'vue'
166
+ import { nextTick } from 'vue'
167
+ import type { PropType } from 'vue'
168
+ import HeroIcon from './HeroIcon.vue'
169
+ import NavDrawerItem from './NavDrawerItem.vue'
170
+ import { globalRtl } from '../../pgo-components/lib/core/rtl/rtl'
171
+
172
+ const themeStyles = computed(() => ({
173
+ backgroundColor: 'var(--vts-color-surface)',
174
+ color: 'var(--vts-color-text)',
175
+ borderRight: '1px solid var(--vts-color-border)',
176
+ boxShadow: 'var(--vts-elevation-2)'
177
+ }))
178
+
179
+ const scrimThemeStyles = computed(() => ({
180
+ backgroundColor: 'var(--vts-color-overlay)'
181
+ }))
182
+
183
+ const searchInputStyles = computed(() => ({
184
+ backgroundColor: 'var(--vts-color-surface)',
185
+ color: 'var(--vts-color-text)',
186
+ border: '1px solid var(--vts-color-border)',
187
+ borderRadius: 'var(--vts-radius-sm)'
188
+ }))
189
+
190
+ const hoverButtonStyles = 'hover:bg-[var(--vts-color-surface)]'
191
+ const groupHeaderStyles = 'text-[var(--vts-color-textSecondary)]'
192
+
193
+ const props = defineProps({
194
+ id: {
195
+ type: String,
196
+ default: () => `drawer-${Math.random().toString(36).slice(2, 9)}`
197
+ },
198
+ modelValue: { type: Boolean, default: false },
199
+ permanent: { type: Boolean, default: true },
200
+ temporary: { type: Boolean, default: false },
201
+ persistent: { type: Boolean, default: false },
202
+ rail: { type: Boolean, default: false },
203
+ hoverExpand: { type: Boolean, default: false },
204
+ isStuck: { type: Boolean, default: false },
205
+ belowAppbar: { type: Boolean, default: false },
206
+ location: { type: String as PropType<'left' | 'right'>, default: 'left' },
207
+ // color: { type: String, default: 'bg-white dark:bg-gray-900' },
208
+ color: { type: String },
209
+ width: { type: [String, Number], default: 'w-64' },
210
+ railWidth: { type: [String, Number], default: 'w-16' },
211
+ closeOnScrimClick: { type: Boolean, default: true },
212
+ expandOnHover: { type: Boolean, default: false },
213
+ overlayAppBar: { type: Boolean, default: true }, // if false, drawer will appear under app bar
214
+ zIndex: { type: [String, Number], default: undefined },
215
+ appBarOffset: { type: Boolean, default: false },
216
+ appBarHeight: { type: [String, Number], default: undefined },
217
+ // data-driven items
218
+ items: {
219
+ type: Array as PropType<Array<Record<string, any>>>,
220
+ default: undefined
221
+ },
222
+ filterable: { type: Boolean, default: false },
223
+ searchPlaceholder: { type: String, default: 'Search' },
224
+ draggable: { type: Boolean, default: false },
225
+ groupBy: {
226
+ type: [String, Function] as PropType<string | ((item: any) => string) | undefined>,
227
+ default: undefined
228
+ },
229
+ allowCrossGroupReorder: { type: Boolean, default: false },
230
+ collapsibleGroups: { type: Boolean, default: true },
231
+ groupDefaultOpen: { type: Boolean, default: true },
232
+ groupIcons: {
233
+ type: Object as PropType<Record<string, string> | undefined>,
234
+ default: undefined
235
+ },
236
+ renderGroupParent: { type: Boolean, default: false },
237
+ groupParents: {
238
+ type: Object as PropType<Record<string, { label?: string; href?: string; icon?: string }>>,
239
+ default: undefined
240
+ }
241
+ })
242
+
243
+ const locationIcon = computed(() => {
244
+ const isLeft = props.location === 'left'
245
+
246
+ // Case 1: Rail + closed → always show expand icon
247
+ if (props.rail && !props.modelValue && !hoverOpen.value) {
248
+ return isLeft ? 'chevron-right' : 'chevron-left'
249
+ }
250
+
251
+ // Case 2: Rail + open but NOT hover-expanded → still expand icon
252
+ if (props.rail && props.modelValue && !hoverOpen.value) {
253
+ return isLeft ? 'chevron-right' : 'chevron-left'
254
+ }
255
+
256
+ // Case 3: Expanded rail OR normal drawer → collapse icon
257
+ return isLeft ? 'chevron-left' : 'chevron-right'
258
+ })
259
+
260
+ const emit = defineEmits(['update:modelValue', 'open', 'close', 'update:items', 'reorder', 'group-toggle'])
261
+
262
+ const internalOpen = ref(Boolean(props.modelValue))
263
+ const navRef = ref<HTMLElement | null>(null)
264
+ // local copy of items so we can reorder and filter
265
+ const localItems = ref<Array<Record<string, any>>>(props.items ? [...props.items] : [])
266
+ const filterQuery = ref('')
267
+ // sync prop items with local items
268
+ watch(
269
+ () => props.items,
270
+ i => {
271
+ localItems.value = props.items ? [...props.items] : []
272
+ }
273
+ )
274
+
275
+ watch(
276
+ () => props.overlayAppBar,
277
+ v => {
278
+ computeTopPadding()
279
+ }
280
+ )
281
+
282
+ // derived filtered items
283
+ const filteredItems = computed(() => {
284
+ if (!props.items) return []
285
+ if (!filterQuery.value || filterQuery.value.trim().length === 0) return localItems.value
286
+ const q = filterQuery.value.toLowerCase()
287
+ return localItems.value.filter(it => {
288
+ const label = String(it.label || it.name || '')
289
+ return label.toLowerCase().includes(q) || (it.tags && String(it.tags).toLowerCase().includes(q))
290
+ })
291
+ })
292
+
293
+ // compute groups
294
+ const groupedItems = computed(() => {
295
+ if (!props.items) return []
296
+ if (!props.groupBy) return [{ group: null, items: filteredItems.value }]
297
+ const groups: Record<string, Array<any>> = {}
298
+ const getter = typeof props.groupBy === 'function' ? props.groupBy : (it: any) => it[props.groupBy as string]
299
+ filteredItems.value.forEach(it => {
300
+ const key = getter(it) || 'Ungrouped'
301
+ if (!groups[key]) groups[key] = []
302
+ groups[key].push(it)
303
+ })
304
+ return Object.keys(groups).map(g => ({ group: g, items: groups[g] }))
305
+ })
306
+
307
+ // Track open/closed state for groups
308
+ const groupOpenMap = ref<Record<string, boolean>>({})
309
+
310
+ // init or sync group states when groupedItems change
311
+ watch(groupedItems, groups => {
312
+ groups.forEach(g => {
313
+ const key = String(g.group || 'Ungrouped')
314
+ if (groupOpenMap.value[key] === undefined) {
315
+ groupOpenMap.value[key] = Boolean(props.groupDefaultOpen)
316
+ }
317
+ })
318
+ })
319
+
320
+ function isGroupOpen(key: string | null) {
321
+ if (!props.collapsibleGroups) return true
322
+ const k = String(key || 'Ungrouped')
323
+ return Boolean(groupOpenMap.value[k])
324
+ }
325
+
326
+ function toggleGroup(key: string | null) {
327
+ if (!props.collapsibleGroups) return
328
+ const k = String(key || 'Ungrouped')
329
+ groupOpenMap.value[k] = !Boolean(groupOpenMap.value[k])
330
+ emit('group-toggle', { group: key, open: groupOpenMap.value[k] })
331
+ }
332
+
333
+ function onGroupParentClick(e: Event, key: string | null) {
334
+ const parent = props.groupParents && key ? props.groupParents[key] : undefined
335
+ if (!parent || !parent.href) {
336
+ e.preventDefault()
337
+ toggleGroup(key)
338
+ }
339
+ }
340
+
341
+ function onGroupParentKey(e: KeyboardEvent, key: string | null) {
342
+ // Only toggle when Enter/Space and the group parent has no href (acting as a button)
343
+ if (!key) return
344
+ const parent = props.groupParents && key ? props.groupParents[key] : undefined
345
+ if (!(e.key === 'Enter' || e.key === ' ')) return
346
+ if (!parent || !parent.href) {
347
+ e.preventDefault()
348
+ e.stopPropagation()
349
+ toggleGroup(key)
350
+ }
351
+ }
352
+
353
+ // Normalize list items: wrap plain text nodes in `.rail-label` spans, mark icons with `.rail-icon` for consistent styling.
354
+ function normalizeNavItems() {
355
+ const nav = navRef.value
356
+ if (!nav) return
357
+ // If items are in a ul, normalize li elements. Otherwise, normalize anchors and buttons direct under nav
358
+ const items = nav.querySelectorAll('li')
359
+ items.forEach(li => {
360
+ // Add rail-icon class to first svg or component
361
+ const firstSvg = li.querySelector('svg') as SVGElement | null
362
+ if (firstSvg && !firstSvg.classList.contains('rail-icon')) {
363
+ firstSvg.classList.add('rail-icon')
364
+ }
365
+
366
+ // Wrap text nodes in .rail-label spans (if not already wrapped)
367
+ const childNodes = Array.from(li.childNodes)
368
+ childNodes.forEach(node => {
369
+ if (node.nodeType === Node.TEXT_NODE) {
370
+ const text = node.textContent?.trim()
371
+ if (text && text.length) {
372
+ // create span. Avoid double-wrapping by checking parent
373
+ const span = document.createElement('span')
374
+ span.className = 'rail-label'
375
+ span.textContent = text
376
+ li.replaceChild(span, node)
377
+ }
378
+ } else if (node.nodeType === Node.ELEMENT_NODE) {
379
+ const el = node as HTMLElement
380
+ // If element already is a label or icon, skip
381
+ if (el.classList.contains('rail-label') || el.classList.contains('rail-icon')) return
382
+ // If element contains text nodes, wrap them
383
+ const subChildren = Array.from(el.childNodes)
384
+ subChildren.forEach(sub => {
385
+ if (sub.nodeType === Node.TEXT_NODE) {
386
+ const txt = sub.textContent?.trim()
387
+ if (txt && txt.length) {
388
+ const span = document.createElement('span')
389
+ span.className = 'rail-label'
390
+ span.textContent = txt
391
+ el.replaceChild(span, sub)
392
+ }
393
+ }
394
+ })
395
+ }
396
+ })
397
+ })
398
+
399
+ // Normalize anchors directly under nav (wrap anchors/buttons directly under nav in an li)
400
+ const directAnchors = nav.querySelectorAll(':scope > a, :scope > button')
401
+ directAnchors.forEach(a => {
402
+ // ensure role and class
403
+ if (!a.classList.contains('nav-drawer-link')) a.classList.add('nav-drawer-link')
404
+ const existingParent = a.parentElement
405
+ if (existingParent && existingParent.tagName.toLowerCase() !== 'li') {
406
+ const li = document.createElement('li')
407
+ li.className = 'nav-drawer-item'
408
+ existingParent.replaceChild(li, a)
409
+ li.appendChild(a)
410
+ }
411
+ })
412
+ }
413
+
414
+ // Drag & drop helpers for data-driven items
415
+ let draggingId: string | number | null = null
416
+ let dragOverId: string | number | null = null
417
+ let dragOverPos: 'before' | 'after' | null = null
418
+
419
+ function findItemIndexById(id: string | number | null, arr = localItems.value) {
420
+ if (id === null || id === undefined) return -1
421
+ return arr.findIndex(it => String(it.id) === String(id))
422
+ }
423
+
424
+ function handleDragStart(ctx: { id: string | number }) {
425
+ draggingId = ctx.id
426
+ }
427
+
428
+ function handleDragOver(ctx: { id: string | number; event?: DragEvent }) {
429
+ dragOverId = ctx.id
430
+ // compute position relative to target element using event
431
+ if (ctx.event && ctx.event.clientY !== undefined) {
432
+ const el = document.querySelector(`[data-drawer-item-id='${ctx.id}']`) as HTMLElement | null
433
+ if (el) {
434
+ const rect = el.getBoundingClientRect()
435
+ const mid = rect.top + rect.height / 2
436
+ dragOverPos = ctx.event.clientY < mid ? 'before' : 'after'
437
+ // set CSS classes by forcing a reactivity value
438
+ }
439
+ } else {
440
+ dragOverPos = 'after'
441
+ }
442
+ }
443
+
444
+ function handleDrop(ctx: { id: string | number; sourceId?: string | number; event?: DragEvent }) {
445
+ // if no items are passed, nothing to do
446
+ if (!props.items || !props.draggable) return
447
+ const sourceId = ctx.sourceId !== undefined ? ctx.sourceId : draggingId
448
+ const targetId = ctx.id
449
+ if (!sourceId || !targetId) return
450
+ if (String(sourceId) === String(targetId)) return
451
+
452
+ const fromIndex = findItemIndexById(sourceId)
453
+ const toIndex = findItemIndexById(targetId)
454
+ if (fromIndex === -1 || toIndex === -1) return
455
+
456
+ // move
457
+ const copy = [...localItems.value]
458
+ const [moved] = copy.splice(fromIndex, 1)
459
+ copy.splice(toIndex, 0, moved)
460
+ localItems.value = copy
461
+ emit('update:items', copy)
462
+ emit('reorder', { items: copy })
463
+ // reset drag id & pos
464
+ draggingId = null
465
+ dragOverId = null
466
+ dragOverPos = null
467
+ }
468
+
469
+ function handleOutsideClick(e: MouseEvent) {
470
+ if (!navRef.value) return
471
+ if (!navRef.value.contains(e.target as Node)) {
472
+ onMouseLeave() // 👈 Trigger your function
473
+ }
474
+ }
475
+
476
+ watch(
477
+ () => props.modelValue,
478
+ v => {
479
+ internalOpen.value = Boolean(v)
480
+ updateLayout()
481
+ }
482
+ )
483
+
484
+ watch(
485
+ () => props.permanent,
486
+ v => {
487
+ updateLayout()
488
+ }
489
+ )
490
+
491
+ watch(internalOpen, v => {
492
+ emit('update:modelValue', v)
493
+ if (v) emit('open')
494
+ else emit('close')
495
+ })
496
+
497
+ // compute open: if permanent -> always open
498
+ const isOpen = computed(() => {
499
+ if (props.permanent) return true
500
+ return internalOpen.value
501
+ })
502
+
503
+ const toggleOpen = () => {
504
+ if (props.permanent) return
505
+ internalOpen.value = !internalOpen.value
506
+ }
507
+
508
+ const open = () => {
509
+ if (props.permanent) return
510
+ internalOpen.value = true
511
+ }
512
+
513
+ const close = () => {
514
+ if (props.permanent) return
515
+ internalOpen.value = false
516
+ }
517
+
518
+ const onScrimClick = () => {
519
+ if (props.closeOnScrimClick) close()
520
+ }
521
+
522
+ // keyboard ESC to close for temporary/persistent
523
+ const onKey = (e: KeyboardEvent) => {
524
+ if (e.key === 'Escape' && (props.temporary || props.persistent)) {
525
+ close()
526
+ }
527
+ }
528
+
529
+ onMounted(() => {
530
+ document.addEventListener('keydown', onKey)
531
+ document.addEventListener('click', handleOutsideClick)
532
+ // run normalization so rail mode works with existing markup
533
+ normalizeNavItems()
534
+ })
535
+
536
+ onUpdated(() => {
537
+ // re-run normalization when slot content or children update
538
+ normalizeNavItems()
539
+ })
540
+
541
+ onBeforeUnmount(() => {
542
+ document.removeEventListener('keydown', onKey)
543
+ document.removeEventListener('click', handleOutsideClick)
544
+ window.removeEventListener('resize', onWindowResize)
545
+ })
546
+
547
+ // Focus trap for temporary/persistent mode when open
548
+ let focusTrapHandler: ((e: KeyboardEvent) => void) | null = null
549
+ function getFocusable(el: HTMLElement) {
550
+ const selectors = 'a[href], button:not([disabled]), textarea, input, select, [tabindex]:not([tabindex="-1"])'
551
+ return Array.from(el.querySelectorAll(selectors)) as HTMLElement[]
552
+ }
553
+
554
+ watch(isOpen, open => {
555
+ if (open && (props.temporary || props.persistent)) {
556
+ const nav = navRef.value as HTMLElement | null
557
+ if (!nav) return
558
+ const focusables = getFocusable(nav)
559
+ if (focusables.length) {
560
+ focusables[0].focus()
561
+ } else {
562
+ nav.setAttribute('tabindex', '-1')
563
+ nav.focus()
564
+ }
565
+
566
+ focusTrapHandler = (e: KeyboardEvent) => {
567
+ if (e.key !== 'Tab') return
568
+ const focusEls = getFocusable(nav)
569
+ if (!focusEls.length) return
570
+ const first = focusEls[0]
571
+ const last = focusEls[focusEls.length - 1]
572
+ if (e.shiftKey && document.activeElement === first) {
573
+ e.preventDefault()
574
+ last.focus()
575
+ } else if (!e.shiftKey && document.activeElement === last) {
576
+ e.preventDefault()
577
+ first.focus()
578
+ }
579
+ }
580
+ document.addEventListener('keydown', focusTrapHandler)
581
+ } else {
582
+ if (focusTrapHandler) {
583
+ document.removeEventListener('keydown', focusTrapHandler)
584
+ focusTrapHandler = null
585
+ }
586
+ }
587
+ // ensure nav items normalized on open state change (e.g., rail toggled)
588
+ normalizeNavItems()
589
+ })
590
+
591
+ // hover expand behavior
592
+ const hoverOpen = ref(false)
593
+
594
+ const allowHoverExpand = computed(() => {
595
+ // In rail mode, expand on hover is always enabled (UX requirement)
596
+ if (props.rail) return true
597
+ return Boolean(props.hoverExpand || props.expandOnHover)
598
+ })
599
+
600
+ let hoverTimeout: number | undefined
601
+
602
+ const onMouseEnter = () => {
603
+ if (allowHoverExpand.value) {
604
+ hoverTimeout && window.clearTimeout(hoverTimeout)
605
+ hoverOpen.value = true
606
+ }
607
+ }
608
+
609
+ const onMouseLeave = () => {
610
+ if (allowHoverExpand.value) {
611
+ hoverTimeout = window.setTimeout(() => {
612
+ hoverOpen.value = false
613
+ }, 150)
614
+ }
615
+ }
616
+
617
+ const collapsed = computed(() => props.rail && !hoverOpen.value)
618
+
619
+ // classes
620
+ const locationClass = computed(() => (props.location === 'right' ? 'right-0' : 'left-0'))
621
+ const widthClass = computed(() => {
622
+ if (collapsed.value) return typeof props.railWidth === 'number' ? `w-[${props.railWidth}px]` : String(props.railWidth)
623
+ return typeof props.width === 'number' ? `w-[${props.width}px]` : String(props.width)
624
+ })
625
+
626
+ const containerBaseClasses = computed(() => {
627
+ const base = ['fixed', 'h-full', 'overflow-hidden', 'flex', 'flex-col']
628
+ if (props.isStuck) base.push('top-0')
629
+ if (props.location === 'right') base.push('right-0')
630
+ else base.push('left-0')
631
+ if (props.rail) base.push('transition-all')
632
+ watch(
633
+ () => props.rail,
634
+ v => {
635
+ normalizeNavItems()
636
+ }
637
+ )
638
+ return base.join(' ')
639
+ })
640
+
641
+ const railClass = computed(() => (props.rail ? 'drawer-rail' : ''))
642
+ const expandedClass = computed(() => (props.rail && !collapsed.value ? 'drawer-expanded' : ''))
643
+ const showPrepend = computed(() => !(props.rail && collapsed.value))
644
+
645
+ const hiddenClass = computed(() => {
646
+ if (props.location === 'right') return 'translate-x-full'
647
+ return '-translate-x-full'
648
+ })
649
+
650
+ // z-index control for drawer and scrim
651
+ const zClass = computed(() => {
652
+ if (props.zIndex !== undefined && typeof props.zIndex === 'string') {
653
+ // allow passing 'z-40' style classes
654
+ if ((props.zIndex as string).startsWith('z-')) return props.zIndex as string
655
+ }
656
+ return props.overlayAppBar ? 'z-50' : 'z-10'
657
+ })
658
+
659
+ const zStyle = computed(() => {
660
+ if (props.zIndex !== undefined && typeof props.zIndex === 'number') {
661
+ return { zIndex: props.zIndex }
662
+ }
663
+ return undefined
664
+ })
665
+
666
+ const scrimStyle = computed(() => {
667
+ if (props.zIndex !== undefined && typeof props.zIndex === 'number') {
668
+ return { zIndex: (props.zIndex as number) - 1 }
669
+ }
670
+ return undefined
671
+ })
672
+
673
+ // Calculate top padding so content appears below app bar if drawer sits behind it
674
+ const topPadding = ref(0)
675
+ const computeTopPadding = () => {
676
+ if (!props.appBarOffset && props.overlayAppBar) {
677
+ topPadding.value = 0
678
+ // console.log('No app bar offset, top padding set to 0')
679
+ return
680
+ }
681
+ if (props.appBarHeight !== undefined && props.appBarHeight !== null) {
682
+ if (typeof props.appBarHeight === 'number') topPadding.value = props.appBarHeight as number
683
+ else if (typeof props.appBarHeight === 'string') {
684
+ const px = Number(String(props.appBarHeight).replace(/px$/i, ''))
685
+ topPadding.value = Number.isFinite(px) ? px : 0
686
+ }
687
+ console.log('Using provided appBarHeight for top padding:', topPadding.value)
688
+ return
689
+ }
690
+ // detect appbar element
691
+ if (typeof document !== 'undefined') {
692
+ const el = document.querySelector('[data-component="appbar"]') as HTMLElement | null
693
+ if (!el) {
694
+ topPadding.value = 0
695
+ return
696
+ }
697
+ const rect = el.getBoundingClientRect()
698
+ topPadding.value = Math.round(rect.height)
699
+ console.log('Computed top padding for drawer:', topPadding.value)
700
+ }
701
+ }
702
+
703
+ // compute initial padding
704
+ computeTopPadding()
705
+ // recompute on resize
706
+ let resizeTimer: number | undefined
707
+ const onWindowResize = () => {
708
+ resizeTimer && window.clearTimeout(resizeTimer)
709
+ resizeTimer = window.setTimeout(() => {
710
+ computeTopPadding()
711
+ }, 100)
712
+ }
713
+ onMounted(() => {
714
+ window.addEventListener('resize', onWindowResize)
715
+ })
716
+ onBeforeUnmount(() => {
717
+ window.removeEventListener('resize', onWindowResize)
718
+ })
719
+
720
+ const scrimClass = computed(() => {
721
+ if (props.zIndex !== undefined && typeof props.zIndex === 'number') {
722
+ // scrim will be one less than drawer z-index — but since we're using inline style, we won't set class
723
+ return ''
724
+ }
725
+ return props.overlayAppBar ? 'z-40' : 'z-0'
726
+ })
727
+
728
+ // always allow overlay toggling for temporary models
729
+
730
+ const layout = inject('layout', null)
731
+ // const horizontalOffset = inject("horizontalOffset", ref(0));
732
+ const el = ref<HTMLElement | null>(null)
733
+ const w = ref(0)
734
+
735
+ function updateLayout() {
736
+ if (!el.value) return
737
+ w.value = el.value.getBoundingClientRect().width
738
+
739
+ if (layout && !props.rail) {
740
+ layout.register('drawer', props.id, {
741
+ mode: props.modelValue || props.permanent ? 'permanent' : 'rail',
742
+ right: props.location === 'right' ? true : false,
743
+ width: w.value
744
+ })
745
+ }
746
+ }
747
+
748
+ onMounted(() => {
749
+ el.value = navRef.value
750
+ updateLayout()
751
+
752
+ const ro = new ResizeObserver(updateLayout)
753
+ if (el.value) ro.observe(el.value)
754
+
755
+ onBeforeUnmount(() => {
756
+ ro.disconnect()
757
+ if (layout) {
758
+ layout.unregister('drawer', props.id)
759
+ }
760
+ })
761
+ })
762
+ </script>
763
+
764
+ <style scoped>
765
+ /* small helpers */
766
+ .w-rail {
767
+ width: var(--rail-width, 4rem);
768
+ }
769
+
770
+ /* rail mode: show icon only for items by hiding labels visually but keeping them accessible */
771
+ .drawer-rail:not(.drawer-expanded) :deep(.rail-label) {
772
+ /* sr-only (visually hidden but accessible) */
773
+ position: absolute !important;
774
+ width: 1px;
775
+ height: 1px;
776
+ padding: 0;
777
+ margin: -1px;
778
+ overflow: hidden;
779
+ clip: rect(0 0 0 0);
780
+ white-space: nowrap;
781
+ border: 0;
782
+ }
783
+
784
+ .drawer-rail:not(.drawer-expanded) :deep(.rail-icon) {
785
+ margin-right: 0 !important;
786
+ display: inline-flex;
787
+ justify-content: center;
788
+ align-items: center;
789
+ }
790
+
791
+ .drawer-rail:not(.drawer-expanded) :deep(ul li) {
792
+ display: flex;
793
+ align-items: center;
794
+ justify-content: center;
795
+ padding-left: 0.5rem;
796
+ padding-right: 0.5rem;
797
+ }
798
+
799
+ .drawer-rail:not(.drawer-expanded) :deep(ul li .rail-icon) {
800
+ width: 100%;
801
+ display: flex;
802
+ justify-content: center;
803
+ }
804
+
805
+ /* center group header icon when collapsed */
806
+ .drawer-rail:not(.drawer-expanded) :deep(.group-header) {
807
+ display: flex;
808
+ align-items: center;
809
+ justify-content: center;
810
+ padding-left: 0.25rem;
811
+ padding-right: 0.25rem;
812
+ }
813
+
814
+ .drawer-rail:not(.drawer-expanded) :deep(.group-header) .text-lg {
815
+ display: none;
816
+ }
817
+
818
+ .drawer-rail:not(.drawer-expanded) :deep(.group-header) .mr-2 {
819
+ margin-right: 0;
820
+ }
821
+
822
+ /* rail label animation & transitions */
823
+ .drawer-rail :deep(.rail-label) {
824
+ opacity: 1;
825
+ max-width: 1000px;
826
+ transform: translateX(0);
827
+ }
828
+
829
+ .drawer-rail:not(.drawer-expanded) :deep(.rail-label) {
830
+ opacity: 0 !important;
831
+ /* visually hidden when collapsed */
832
+ max-width: 0 !important;
833
+ transform: translateX(-6px);
834
+ }
835
+
836
+ /* smooth container width transition */
837
+ .drawer-rail {
838
+ transition: width 700ms ease;
839
+ }
840
+
841
+ .drawer-expanded {
842
+ transition: width 700ms ease;
843
+ }
844
+
845
+ /* rotate chevron with a smooth transition for toggle buttons */
846
+ button[aria-expanded] :deep(svg) {
847
+ transition: transform 700ms ease;
848
+ }
849
+ </style>