feat(fe): implement KsSplitter with resizer handle and localStorage ratio persistence for master-detail pages

This commit is contained in:
2026-08-15 22:24:23 +09:00
parent 7893b2020e
commit fe96bae516
4 changed files with 200 additions and 2 deletions
@@ -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'