diff --git a/src/components/IssueDetails.vue b/src/components/IssueDetails.vue index 5ae5c66..e9dba7d 100644 --- a/src/components/IssueDetails.vue +++ b/src/components/IssueDetails.vue @@ -1,5 +1,5 @@ - + {{ ckey }} @@ -128,6 +133,7 @@ defineExpose({ + @@ -155,8 +161,8 @@ defineExpose({ flex: 1; min-height: 0; display: grid; - grid-template-columns: 1fr 1fr; - gap: 1rem; + grid-template-columns: v-bind(gridTemplate); + column-gap: 0.5rem; } .details > * { diff --git a/src/components/SplitHandle.vue b/src/components/SplitHandle.vue new file mode 100644 index 0000000..574c0e3 --- /dev/null +++ b/src/components/SplitHandle.vue @@ -0,0 +1,50 @@ + + + + + + + diff --git a/src/composables/useSplitPane.ts b/src/composables/useSplitPane.ts new file mode 100644 index 0000000..9f33500 --- /dev/null +++ b/src/composables/useSplitPane.ts @@ -0,0 +1,88 @@ +import { computed, onScopeDispose, ref, type Ref } from 'vue' +import { useLocalStorage } from '@vueuse/core' + +interface Options { + /** localStorage key for the ratio; the ratio is not persisted without it */ + storageKey?: string + /** Share of the container taken by the first pane, 0..1 */ + initial?: number + min?: number + max?: number + /** Ratio change per arrow key press */ + step?: number +} + +/** + * Two panes in a CSS grid split by a draggable handle. + * Bind `gridTemplate` to `grid-template-columns` of the container and `handleProps` to the handle between the panes. + */ +export function useSplitPane(container: Ref, options: Options = {}) { + const { storageKey, initial = 0.5, min = 0.2, max = 0.8, step = 0.02 } = options + + const clamp = (value: number) => Math.min(max, Math.max(min, value)) + + const ratio = storageKey ? useLocalStorage(`split-pane:${storageKey}`, initial) : ref(initial) + const dragging = ref(false) + + // fr units share the space left after the auto-sized handle, so the ratio is exact + const gridTemplate = computed(() => { + const r = clamp(ratio.value) + return `minmax(0, ${r}fr) auto minmax(0, ${1 - r}fr)` + }) + + function onPointerMove(e: PointerEvent) { + const el = container.value + if (!el) return + // Measure against the content box, so the container padding does not shift the handle off the pointer + const rect = el.getBoundingClientRect() + const style = getComputedStyle(el) + const left = rect.left + parseFloat(style.paddingLeft) + const width = rect.width - parseFloat(style.paddingLeft) - parseFloat(style.paddingRight) + if (width <= 0) return + ratio.value = clamp((e.clientX - left) / width) + } + + function stopDrag() { + dragging.value = false + document.body.classList.remove('split-pane--dragging') + window.removeEventListener('pointermove', onPointerMove) + window.removeEventListener('pointerup', stopDrag) + window.removeEventListener('pointercancel', stopDrag) + } + + function onPointerdown(e: PointerEvent) { + if (e.button !== 0) return + e.preventDefault() + dragging.value = true + // Disables text selection and keeps the resize cursor while the pointer is outside the handle + document.body.classList.add('split-pane--dragging') + window.addEventListener('pointermove', onPointerMove) + window.addEventListener('pointerup', stopDrag) + window.addEventListener('pointercancel', stopDrag) + } + + function onKeydown(e: KeyboardEvent) { + if (e.key === 'ArrowLeft') ratio.value = clamp(ratio.value - step) + else if (e.key === 'ArrowRight') ratio.value = clamp(ratio.value + step) + else return + e.preventDefault() + } + + function reset() { + ratio.value = initial + } + + onScopeDispose(stopDrag) + + const handleProps = computed(() => ({ + dragging: dragging.value, + 'aria-valuenow': Math.round(clamp(ratio.value) * 100), + 'aria-valuemin': Math.round(min * 100), + 'aria-valuemax': Math.round(max * 100), + onPointerdown, + onKeydown, + onDblclick: reset, + })) + + return { ratio, gridTemplate, dragging, handleProps, reset } +} diff --git a/src/views/Issues/IssuesListView.vue b/src/views/Issues/IssuesListView.vue index 12536f2..e5cc08b 100644 --- a/src/views/Issues/IssuesListView.vue +++ b/src/views/Issues/IssuesListView.vue @@ -1,5 +1,5 @@