feat(fe): implement KsSplitter with resizer handle and localStorage ratio persistence for master-detail pages
This commit is contained in:
@@ -0,0 +1,197 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, watch } from 'vue'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
storageKey?: string
|
||||
initialRatio?: number // percentage for left panel, e.g. 65 (meaning 65% master, 35% detail)
|
||||
minLeftPx?: number
|
||||
minRightPx?: number
|
||||
}>(),
|
||||
{
|
||||
storageKey: 'ks_splitter_ratio_default',
|
||||
initialRatio: 65,
|
||||
minLeftPx: 280,
|
||||
minRightPx: 300,
|
||||
}
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:ratio': [value: number]
|
||||
}>()
|
||||
|
||||
const leftRatio = ref<number>(props.initialRatio)
|
||||
const isDragging = ref(false)
|
||||
const containerRef = ref<HTMLElement | null>(null)
|
||||
|
||||
// Restore saved ratio from localStorage
|
||||
onMounted(() => {
|
||||
if (props.storageKey) {
|
||||
const saved = localStorage.getItem(props.storageKey)
|
||||
if (saved) {
|
||||
const parsed = parseFloat(saved)
|
||||
if (!isNaN(parsed) && parsed >= 15 && parsed <= 85) {
|
||||
leftRatio.value = parsed
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Watch ratio changes to save to localStorage
|
||||
watch(leftRatio, (newRatio) => {
|
||||
if (props.storageKey) {
|
||||
localStorage.setItem(props.storageKey, newRatio.toFixed(2))
|
||||
}
|
||||
emit('update:ratio', newRatio)
|
||||
})
|
||||
|
||||
let startX = 0
|
||||
let startLeftWidth = 0
|
||||
let containerWidth = 0
|
||||
|
||||
const startDrag = (e: MouseEvent | TouchEvent) => {
|
||||
if (!containerRef.value) return
|
||||
isDragging.value = true
|
||||
|
||||
const clientX = 'touches' in e ? e.touches[0].clientX : e.clientX
|
||||
startX = clientX
|
||||
containerWidth = containerRef.value.getBoundingClientRect().width
|
||||
startLeftWidth = (containerWidth * leftRatio.value) / 100
|
||||
|
||||
document.addEventListener('mousemove', onDrag)
|
||||
document.addEventListener('mouseup', stopDrag)
|
||||
document.addEventListener('touchmove', onDrag)
|
||||
document.addEventListener('touchend', stopDrag)
|
||||
document.body.style.cursor = 'col-resize'
|
||||
document.body.style.userSelect = 'none'
|
||||
}
|
||||
|
||||
const onDrag = (e: MouseEvent | TouchEvent) => {
|
||||
if (!isDragging.value || containerWidth <= 0) return
|
||||
const clientX = 'touches' in e ? e.touches[0].clientX : e.clientX
|
||||
const deltaX = clientX - startX
|
||||
let newLeftPx = startLeftWidth + deltaX
|
||||
|
||||
// Enforce min widths
|
||||
if (newLeftPx < props.minLeftPx) {
|
||||
newLeftPx = props.minLeftPx
|
||||
}
|
||||
if (containerWidth - newLeftPx < props.minRightPx) {
|
||||
newLeftPx = containerWidth - props.minRightPx
|
||||
}
|
||||
|
||||
const newRatio = (newLeftPx / containerWidth) * 100
|
||||
leftRatio.value = Math.min(Math.max(newRatio, 15), 85)
|
||||
}
|
||||
|
||||
const stopDrag = () => {
|
||||
if (!isDragging.value) return
|
||||
isDragging.value = false
|
||||
document.removeEventListener('mousemove', onDrag)
|
||||
document.removeEventListener('mouseup', stopDrag)
|
||||
document.removeEventListener('touchmove', onDrag)
|
||||
document.removeEventListener('touchend', stopDrag)
|
||||
document.body.style.cursor = ''
|
||||
document.body.style.userSelect = ''
|
||||
}
|
||||
|
||||
const handleDoubleClick = () => {
|
||||
// Reset to initial ratio on double click
|
||||
leftRatio.value = props.initialRatio
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="containerRef" class="ks-splitter" :class="{ 'is-dragging': isDragging }">
|
||||
<!-- Left / Master Pane -->
|
||||
<div class="ks-splitter__pane ks-splitter__pane--left" :style="{ width: `calc(${leftRatio}% - 4px)` }">
|
||||
<slot name="left" />
|
||||
</div>
|
||||
|
||||
<!-- Resizer Handle Bar -->
|
||||
<div
|
||||
class="ks-splitter__resizer"
|
||||
role="separator"
|
||||
tabindex="0"
|
||||
aria-label="패널 크기 조절"
|
||||
title="드래그하여 크기 조절 (더블클릭 시 초기화)"
|
||||
@mousedown="startDrag"
|
||||
@touchstart="startDrag"
|
||||
@dblclick="handleDoubleClick"
|
||||
>
|
||||
<div class="ks-splitter__resizer-handle">
|
||||
<span></span>
|
||||
<span></span>
|
||||
<span></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right / Detail Pane -->
|
||||
<div class="ks-splitter__pane ks-splitter__pane--right" :style="{ width: `calc(${100 - leftRatio}% - 4px)` }">
|
||||
<slot name="right" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.ks-splitter {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.ks-splitter__pane {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.ks-splitter__resizer {
|
||||
width: 8px;
|
||||
height: 100%;
|
||||
background: var(--ks-color-canvas, #f8fafc);
|
||||
border-left: 1px solid var(--ks-color-border, #e2e8f0);
|
||||
border-right: 1px solid var(--ks-color-border, #e2e8f0);
|
||||
cursor: col-resize;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
z-index: 10;
|
||||
transition: background-color 0.15s ease, border-color 0.15s ease;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.ks-splitter__resizer:hover,
|
||||
.ks-splitter.is-dragging .ks-splitter__resizer {
|
||||
background: #e0f2fe;
|
||||
border-color: var(--ks-color-action, #2563eb);
|
||||
}
|
||||
|
||||
.ks-splitter__resizer-handle {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.ks-splitter__resizer-handle span {
|
||||
width: 2px;
|
||||
height: 2px;
|
||||
border-radius: 50%;
|
||||
background-color: var(--ks-color-neutral-400, #94a3b8);
|
||||
transition: background-color 0.15s ease;
|
||||
}
|
||||
|
||||
.ks-splitter__resizer:hover .ks-splitter__resizer-handle span,
|
||||
.ks-splitter.is-dragging .ks-splitter__resizer-handle span {
|
||||
background-color: var(--ks-color-action, #2563eb);
|
||||
}
|
||||
</style>
|
||||
@@ -23,3 +23,4 @@ export { default as KsFormGrid } from './KsFormGrid.vue'
|
||||
export { default as KsFormSection } from './KsFormSection.vue'
|
||||
export { default as KsFormSpan } from './KsFormSpan.vue'
|
||||
export { default as KsValidationSummary } from './KsValidationSummary.vue'
|
||||
export { default as KsSplitter } from './KsSplitter.vue'
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
<script setup lang="ts">import { computed } from 'vue'; import PageLayout from '../../layouts/PageLayout.vue'; import StandardScreenBoundary from './StandardScreenBoundary.vue'; import type { StandardScreenProps } from '../../contracts/screenContract'; const props=defineProps<StandardScreenProps>(); const hideDetail = computed(() => props.state === 'UNAUTHORIZED' || props.state === 'FORBIDDEN'); defineEmits<{retry:[]}>()</script>
|
||||
<template><PageLayout :title="props.title" :subtitle="props.subtitle" :status="props.state" :as-of="props.evidence?.asOf" :version="props.evidence?.version" aside-width="28rem"><template v-if="$slots.actions" #actions><slot name="actions"/></template><template v-if="$slots.filters" #filters><slot name="filters"/></template><StandardScreenBoundary :state="props.state" :warning="props.warning" @retry="$emit('retry')"><slot name="master"/></StandardScreenBoundary><template #aside><slot v-if="!hideDetail" name="detail"/></template><template v-if="$slots.footer" #footer><slot name="footer"/></template></PageLayout></template>
|
||||
<script setup lang="ts">import { computed } from 'vue'; import PageLayout from '../../layouts/PageLayout.vue'; import StandardScreenBoundary from './StandardScreenBoundary.vue'; import KsSplitter from '../../components/KsSplitter.vue'; import type { StandardScreenProps } from '../../contracts/screenContract'; const props=defineProps<StandardScreenProps & { storageKey?: string }>(); const hideDetail = computed(() => props.state === 'UNAUTHORIZED' || props.state === 'FORBIDDEN'); defineEmits<{retry:[]}>()</script>
|
||||
<template><PageLayout :title="props.title" :subtitle="props.subtitle" :status="props.state" :as-of="props.evidence?.asOf" :version="props.evidence?.version"><template v-if="$slots.actions" #actions><slot name="actions"/></template><template v-if="$slots.filters" #filters><slot name="filters"/></template><StandardScreenBoundary :state="props.state" :warning="props.warning" @retry="$emit('retry')"><KsSplitter :storage-key="props.storageKey ?? 'ks_splitter_master_detail'"><template #left><slot name="master"/></template><template #right><slot v-if="!hideDetail" name="detail"/></template></KsSplitter></StandardScreenBoundary><template v-if="$slots.footer" #footer><slot name="footer"/></template></PageLayout></template>
|
||||
|
||||
Reference in New Issue
Block a user