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