main
vue 463 lines 10 KB
Raw
1 <template>
2 <n-split
3 direction="horizontal"
4 :default-size="sanitizedDefaultSplit"
5 :min="0"
6 :max="1"
7 :resize-trigger-size="0"
8 :disabled="!enableResize || splitDisabled"
9 class="wrapper flex grow"
10 :class="[{ 'sidebar-open': sidebarOpen }, `sidebar-position-${sidebarPosition}`]"
11 :pane1-style
12 >
13 <template #[tplNameSide]>
14 <div v-if="sidebarAvailable" ref="sidebar" class="sidebar flex flex-col">
15 <div v-if="$slots['sidebar-header']" class="sidebar-header flex items-center justify-between">
16 <slot name="sidebar-header" />
17 <n-button text class="close-btn" @click="sidebarOpen = false">
18 <Icon :size="24" :name="CloseIcon" />
19 </n-button>
20 </div>
21 <div v-if="$slots['sidebar-content']" class="sidebar-main grow">
22 <n-scrollbar class="max-h-full">
23 <div class="sidebar-main-content" :style="sidebarContentStyle" :class="sidebarContentClass">
24 <slot name="sidebar-content" />
25 </div>
26 </n-scrollbar>
27 </div>
28 <div v-if="$slots['sidebar-footer']" class="sidebar-footer flex items-center">
29 <slot name="sidebar-footer" />
30 </div>
31 </div>
32 </template>
33
34 <template #resize-trigger>
35 <div class="split-trigger">
36 <div class="split-trigger-icon">
37 <Icon :name="SplitIcon" :size="12" />
38 </div>
39 </div>
40 </template>
41
42 <template #[tplNameMain]>
43 <div class="main flex grow flex-col">
44 <div v-if="$slots['main-toolbar']" class="main-toolbar flex items-center">
45 <div v-if="sidebarAvailable && !hideMenuBtn" class="menu-btn flex justify-center opacity-50">
46 <n-button text @click="sidebarOpen = true">
47 <Icon :size="24" :name="MenuIcon" />
48 </n-button>
49 </div>
50
51 <div class="grow">
52 <slot name="main-toolbar" />
53 </div>
54 </div>
55 <div class="main-view grow" :class="{ 'no-container-query': disableContainerQuery }">
56 <n-scrollbar v-if="useMainScroll" ref="mainScrollbar" class="max-h-full">
57 <div class="main-content" :style="mainContentStyle" :class="mainContentClass">
58 <slot name="main-content" />
59 </div>
60 </n-scrollbar>
61 <div v-else class="main-content" :style="mainContentStyle" :class="mainContentClass">
62 <slot name="main-content" />
63 </div>
64 </div>
65 <div v-if="$slots['main-footer']" class="main-footer flex items-center">
66 <div class="wrap">
67 <slot name="main-footer" />
68 </div>
69 </div>
70
71 <div v-if="sidebarOpen" class="main-overlay" />
72 </div>
73 </template>
74 </n-split>
75 </template>
76
77 <script setup lang="ts">
78 import type { SetupContext } from "vue"
79 import { onClickOutside, useWindowSize } from "@vueuse/core"
80 import { NButton, NScrollbar, NSplit } from "naive-ui"
81 import { computed, ref, useSlots, watch } from "vue"
82 import Icon from "@/components/common/Icon.vue"
83
84 type SidebarPosition = "left" | "right"
85
86 const {
87 hideMenuBtn,
88 mainContentStyle,
89 mainContentClass,
90 sidebarContentStyle,
91 sidebarContentClass,
92 enableResize,
93 disableContainerQuery,
94 sidebarPosition = "left",
95 useMainScroll = true,
96 defaultSplit = 0.3,
97 maxSidebarWidth = 450,
98 minSidebarWidth = 250,
99 padding = "30px",
100 paddingMobile = "20px",
101 toolbarHeight = "70px",
102 toolbarHeightMobile = "62px"
103 } = defineProps<{
104 sidebarPosition?: SidebarPosition
105 hideMenuBtn?: boolean
106 useMainScroll?: boolean
107 mainContentStyle?: string
108 mainContentClass?: string
109 sidebarContentStyle?: string
110 sidebarContentClass?: string
111 enableResize?: boolean
112 disableContainerQuery?: boolean
113 defaultSplit?: number
114 maxSidebarWidth?: number
115 minSidebarWidth?: number
116 padding?: string
117 paddingMobile?: string
118 toolbarHeight?: string
119 toolbarHeightMobile?: string
120 }>()
121
122 const emit = defineEmits<{
123 (e: "sidebar", value: boolean): void
124 }>()
125
126 const MenuIcon = "ph:list-light"
127 const CloseIcon = "carbon:close"
128 const SplitIcon = "carbon:draggable"
129
130 const sanitizedDefaultSplit = ref(defaultSplit)
131 const splitDisabled = ref(false)
132
133 const slots: SetupContext["slots"] = useSlots()
134 const sidebarOpen = ref(false)
135 const sidebar = ref(null)
136 const mainScrollbar = ref<typeof NScrollbar | null>(null)
137 const { width } = useWindowSize()
138 const sidebarAvailable = computed<boolean>(
139 () => !!slots["sidebar-header"] || !!slots["sidebar-content"] || !!slots["sidebar-footer"]
140 )
141 const isSidebarLeft = computed<boolean>(() => sidebarPosition === "left")
142 const tplNameMain = computed<1 | 2>(() => (isSidebarLeft.value ? 2 : 1))
143 const tplNameSide = computed<1 | 2>(() => (isSidebarLeft.value ? 1 : 2))
144 const pane1Style = computed(() => ({
145 maxWidth: isSidebarLeft.value ? `${maxSidebarWidth}px` : `calc(100% - ${minSidebarWidth}px)`,
146 minWidth: isSidebarLeft.value ? `${minSidebarWidth}px` : `calc(100% - ${maxSidebarWidth}px)`
147 }))
148
149 function closeSidebar() {
150 sidebarOpen.value = false
151 }
152
153 function openSidebar() {
154 sidebarOpen.value = true
155 }
156
157 onClickOutside(sidebar, () => closeSidebar())
158
159 watch(
160 sidebarOpen,
161 val => {
162 emit("sidebar", val)
163 },
164 { immediate: true }
165 )
166
167 watch(
168 width,
169 val => {
170 sanitizedDefaultSplit.value = val <= 700 ? 0 : isSidebarLeft.value ? defaultSplit : 1 - defaultSplit
171 splitDisabled.value = val <= 700
172 },
173 { immediate: true }
174 )
175
176 defineExpose({
177 closeSidebar,
178 openSidebar,
179 mainScrollbar: () => mainScrollbar.value
180 })
181 </script>
182
183 <style lang="scss" scoped>
184 .wrapper {
185 --mb-toolbar-height: v-bind(toolbarHeight);
186 --padding-x: v-bind(padding);
187 position: relative;
188 height: 100%;
189 overflow: hidden;
190 border-radius: var(--border-radius);
191 border: 1px solid var(--border-color);
192 background-color: var(--bg-default-color);
193 direction: ltr;
194
195 .split-trigger {
196 height: 100%;
197 width: 3px;
198 display: flex;
199 align-items: center;
200 position: relative;
201 z-index: 1;
202 left: -2px;
203 transition: background-color 0.3s var(--bezier-ease);
204
205 .split-trigger-icon {
206 background-color: var(--border-color);
207 border-radius: var(--border-radius-small);
208 height: 18px;
209 display: flex;
210 justify-content: center;
211 align-items: center;
212 position: relative;
213 margin-left: -5px;
214 z-index: 1;
215 transition: background-color 0.3s var(--bezier-ease);
216 }
217
218 &:hover {
219 background-color: rgba(var(--primary-color-rgb) / 0.1);
220
221 .split-trigger-icon {
222 background-color: rgba(var(--primary-color-rgb) / 0.1);
223 }
224 }
225 }
226
227 .sidebar {
228 background-color: var(--bg-secondary-color);
229 height: 100%;
230 overflow: hidden;
231 border-right: 1px solid var(--border-color);
232
233 .sidebar-header {
234 border-block-end: 1px solid var(--border-color);
235 min-height: var(--mb-toolbar-height);
236 height: var(--mb-toolbar-height);
237 padding: 0 var(--padding-x);
238
239 .close-btn {
240 display: none;
241 }
242 }
243
244 .sidebar-main {
245 overflow: hidden;
246
247 .sidebar-main-content {
248 padding: var(--padding-x);
249 }
250 }
251
252 .sidebar-footer {
253 border-block-start: 1px solid var(--border-color);
254 min-height: var(--mb-toolbar-height);
255 padding: 0 var(--padding-x);
256 }
257 }
258
259 .main {
260 background-color: var(--bg-default-color);
261 position: relative;
262 height: 100%;
263
264 .main-overlay {
265 position: absolute;
266 width: 100%;
267 height: 100%;
268 z-index: 2;
269 top: 0;
270 left: 0;
271 }
272
273 .main-toolbar {
274 border-block-end: 1px solid var(--border-color);
275 min-height: var(--mb-toolbar-height);
276 height: var(--mb-toolbar-height);
277 padding: 0 var(--padding-x);
278 gap: 18px;
279 line-height: 1.3;
280 container-type: inline-size;
281
282 .menu-btn {
283 display: none;
284 }
285 }
286
287 .main-view {
288 overflow: hidden;
289 &:not(.no-container-query) {
290 container-type: inline-size;
291 }
292
293 .main-content {
294 padding: var(--padding-x);
295 }
296 }
297
298 .main-footer {
299 container-type: inline-size;
300 border-block-start: 1px solid var(--border-color);
301 padding: 0 var(--padding-x);
302
303 .wrap {
304 min-height: calc(var(--mb-toolbar-height) - 1px);
305 width: 100%;
306 display: flex;
307 align-items: center;
308 }
309 }
310 }
311
312 &.sidebar-position-right {
313 .sidebar {
314 border-right: none;
315 border-left: 1px solid var(--border-color);
316 }
317 }
318
319 @media (max-width: 700px) {
320 --mb-toolbar-height: v-bind(toolbarHeightMobile);
321 --padding-x: v-bind(paddingMobile);
322
323 height: 100%;
324 overflow: hidden;
325 border-radius: 0;
326 border: none;
327
328 &::before {
329 content: "";
330 width: 100vw;
331 display: block;
332 background-color: var(--bg-body-color);
333 position: absolute;
334 top: 0;
335 left: 0;
336 bottom: 0;
337 transform: translateX(-100%);
338 opacity: 0;
339 transition:
340 opacity 0.25s ease-in-out,
341 transform 0s linear 0.3s;
342 z-index: 1;
343 }
344
345 .sidebar {
346 position: absolute;
347 top: 0;
348 left: 0;
349 bottom: 0;
350 transform: translateX(-100%);
351 transition: transform 0.25s ease-in-out;
352 z-index: 3;
353 min-width: 300px;
354 max-width: min(450px, 80vw);
355
356 &::before {
357 content: "";
358 width: 100%;
359 height: 100%;
360 display: block;
361 background-color: var(--bg-default-color);
362 z-index: -1;
363 position: absolute;
364 }
365
366 .sidebar-header {
367 border-block-start: 1px solid var(--border-color);
368 }
369
370 .sidebar-header,
371 .sidebar-footer {
372 padding: 0 var(--padding-x);
373
374 .close-btn {
375 display: flex;
376 }
377 }
378
379 .sidebar-main {
380 .sidebar-main-content {
381 padding: var(--padding-x);
382 }
383 }
384 }
385 .main {
386 .main-toolbar {
387 border-block-start: 1px solid var(--border-color);
388 padding: 0 var(--padding-x);
389 gap: 14px;
390
391 .menu-btn {
392 display: flex;
393 }
394 }
395
396 .main-view {
397 .main-content {
398 padding: var(--padding-x);
399 }
400 }
401 .main-footer {
402 padding: 0 var(--padding-x);
403 }
404 }
405
406 &.sidebar-position-left {
407 :deep() {
408 .n-split-pane-1 {
409 min-width: 0 !important;
410 max-width: 0 !important;
411 }
412 }
413 }
414
415 &.sidebar-position-right {
416 :deep() {
417 .n-split-pane-1 {
418 min-width: 100% !important;
419 max-width: 100% !important;
420 }
421 }
422
423 &::before,
424 .sidebar {
425 left: initial;
426 right: 0;
427 transform: translateX(100%);
428 }
429
430 .main {
431 .main-toolbar {
432 flex-direction: row-reverse;
433 justify-content: space-between;
434 }
435 }
436 }
437
438 &.sidebar-open {
439 &::before {
440 transform: translateX(0);
441 opacity: 0.4;
442 transition:
443 opacity 0.25s ease-in-out,
444 transform 0s linear 0s;
445 }
446
447 .sidebar {
448 transform: translateX(0);
449 box-shadow: 0px 0px 80px 0px rgba(0, 0, 0, 0.1);
450 }
451 }
452 }
453 }
454
455 .direction-rtl {
456 .wrapper {
457 .sidebar,
458 .main {
459 direction: rtl;
460 }
461 }
462 }
463 </style>