@bagelink/vue 1.15.433 → 1.15.440
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/components/DragOver.vue.d.ts.map +1 -1
- package/dist/components/dataTable/DataTable.vue.d.ts.map +1 -1
- package/dist/components/draggable/Draggable.vue.d.ts.map +1 -1
- package/dist/components/draggable/index.d.ts +1 -0
- package/dist/components/draggable/index.d.ts.map +1 -1
- package/dist/components/draggable/reorder.d.ts +5 -0
- package/dist/components/draggable/reorder.d.ts.map +1 -0
- package/dist/components/draggable/useDraggable.d.ts +5 -1
- package/dist/components/draggable/useDraggable.d.ts.map +1 -1
- package/dist/components/draggable/vDraggable.d.ts.map +1 -1
- package/dist/components/file-drop/useFileDropzone.d.ts +23 -0
- package/dist/components/file-drop/useFileDropzone.d.ts.map +1 -0
- package/dist/components/file-drop/useFileDropzone.test.d.ts +2 -0
- package/dist/components/file-drop/useFileDropzone.test.d.ts.map +1 -0
- package/dist/components/form/inputs/ArrayInput.vue.d.ts.map +1 -1
- package/dist/components/form/inputs/Upload/UploadInput.vue.d.ts.map +1 -1
- package/dist/components/index.d.ts +4 -1
- package/dist/components/index.d.ts.map +1 -1
- package/dist/components/layout/AppLayout.vue.d.ts.map +1 -1
- package/dist/components/layout/Panel.vue.d.ts.map +1 -1
- package/dist/components/layout/Resizable.vue.d.ts.map +1 -1
- package/dist/components/layout/appLayoutContext.d.ts +1 -1
- package/dist/components/layout/appLayoutContext.d.ts.map +1 -1
- package/dist/composables/index.d.ts +6 -0
- package/dist/composables/index.d.ts.map +1 -1
- package/dist/composables/resizeGeometry.d.ts +20 -0
- package/dist/composables/resizeGeometry.d.ts.map +1 -0
- package/dist/composables/useBoundedRectDrag.d.ts +26 -0
- package/dist/composables/useBoundedRectDrag.d.ts.map +1 -0
- package/dist/composables/usePointerSession.d.ts +17 -0
- package/dist/composables/usePointerSession.d.ts.map +1 -0
- package/dist/composables/useResizableLayout.d.ts +1 -2
- package/dist/composables/useResizableLayout.d.ts.map +1 -1
- package/dist/composables/useResizeObserver.d.ts +5 -4
- package/dist/composables/useResizeObserver.d.ts.map +1 -1
- package/dist/core.css +1 -1
- package/dist/directives/vResize.d.ts +2 -1
- package/dist/directives/vResize.d.ts.map +1 -1
- package/dist/index.cjs +42 -51
- package/dist/index.css +1 -1
- package/dist/index.mjs +10269 -9989
- package/dist/style.css +1 -1
- package/package.json +1 -1
- package/src/components/DragOver.vue +6 -41
- package/src/components/dataTable/DataTable.vue +11 -7
- package/src/components/draggable/Draggable.vue +5 -6
- package/src/components/draggable/index.ts +1 -0
- package/src/components/draggable/reorder.ts +17 -0
- package/src/components/draggable/useDraggable.ts +73 -71
- package/src/components/draggable/vDraggable.ts +18 -8
- package/src/components/file-drop/useFileDropzone.test.ts +108 -0
- package/src/components/file-drop/useFileDropzone.ts +83 -0
- package/src/components/form/inputs/ArrayInput.vue +3 -5
- package/src/components/form/inputs/Upload/UploadInput.vue +11 -21
- package/src/components/index.ts +4 -1
- package/src/components/layout/AppLayout.design.md +13 -15
- package/src/components/layout/AppLayout.vue +57 -38
- package/src/components/layout/Panel.vue +19 -1
- package/src/components/layout/Resizable.vue +2 -5
- package/src/components/layout/appLayoutContext.ts +2 -1
- package/src/composables/index.ts +6 -0
- package/src/composables/resizeGeometry.ts +31 -0
- package/src/composables/useBoundedRectDrag.ts +69 -0
- package/src/composables/usePointerSession.ts +70 -0
- package/src/composables/useResizableLayout.ts +66 -55
- package/src/composables/useResizeObserver.ts +32 -27
- package/src/directives/vResize.ts +86 -165
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
2
|
+
import { effectScope, ref } from 'vue'
|
|
3
|
+
import { useFileDropzone } from './useFileDropzone'
|
|
4
|
+
|
|
5
|
+
function dragEvent(types = ['Files'], files = {} as FileList) {
|
|
6
|
+
return {
|
|
7
|
+
dataTransfer: { types, files },
|
|
8
|
+
preventDefault: vi.fn(),
|
|
9
|
+
stopPropagation: vi.fn(),
|
|
10
|
+
} as unknown as DragEvent
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function setup(disabled = ref(false)) {
|
|
14
|
+
const scope = effectScope()
|
|
15
|
+
const onDrop = vi.fn()
|
|
16
|
+
const zone = scope.run(() => useFileDropzone({ disabled, onDrop }))!
|
|
17
|
+
return { scope, zone, onDrop, disabled }
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
describe('useFileDropzone', () => {
|
|
21
|
+
it('keeps nested enter/leave state balanced and clamps extra leaves', () => {
|
|
22
|
+
const { zone, scope } = setup()
|
|
23
|
+
zone.handleDragLeave(dragEvent())
|
|
24
|
+
zone.handleDragEnter(dragEvent())
|
|
25
|
+
zone.handleDragEnter(dragEvent())
|
|
26
|
+
zone.handleDragLeave(dragEvent())
|
|
27
|
+
expect(zone.isDragging.value).toBe(true)
|
|
28
|
+
zone.handleDragLeave(dragEvent())
|
|
29
|
+
expect(zone.isDragging.value).toBe(false)
|
|
30
|
+
scope.stop()
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('leaves text and internal drags untouched', () => {
|
|
34
|
+
const { zone, onDrop, scope } = setup()
|
|
35
|
+
for (const types of [[], ['text/plain'], ['text/uri-list']]) {
|
|
36
|
+
const event = dragEvent(types)
|
|
37
|
+
zone.handleDragEnter(event)
|
|
38
|
+
zone.handleDragOver(event)
|
|
39
|
+
zone.handleDragLeave(event)
|
|
40
|
+
zone.handleDrop(event)
|
|
41
|
+
expect(event.preventDefault).not.toHaveBeenCalled()
|
|
42
|
+
expect(event.stopPropagation).not.toHaveBeenCalled()
|
|
43
|
+
}
|
|
44
|
+
expect(zone.isDragging.value).toBe(false)
|
|
45
|
+
expect(onDrop).not.toHaveBeenCalled()
|
|
46
|
+
scope.stop()
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('passes the original unfiltered FileList and resets before callbacks', () => {
|
|
50
|
+
const scope = effectScope()
|
|
51
|
+
const files = { length: 2 } as FileList
|
|
52
|
+
const event = dragEvent(['Files', 'text/plain'], files)
|
|
53
|
+
const onDrop = vi.fn(() => expect(zone.isDragging.value).toBe(false))
|
|
54
|
+
const zone = scope.run(() => useFileDropzone({ onDrop }))!
|
|
55
|
+
zone.handleDragEnter(event)
|
|
56
|
+
zone.handleDrop(event)
|
|
57
|
+
expect(onDrop).toHaveBeenCalledWith(files, event)
|
|
58
|
+
expect(event.preventDefault).toHaveBeenCalled()
|
|
59
|
+
expect(event.stopPropagation).toHaveBeenCalled()
|
|
60
|
+
scope.stop()
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('reacts synchronously to disabled and can be reenabled', () => {
|
|
64
|
+
const { zone, onDrop, scope, disabled } = setup()
|
|
65
|
+
zone.handleDragEnter(dragEvent())
|
|
66
|
+
disabled.value = true
|
|
67
|
+
expect(zone.isDragging.value).toBe(false)
|
|
68
|
+
const event = dragEvent()
|
|
69
|
+
zone.handleDragEnter(event)
|
|
70
|
+
zone.handleDragOver(event)
|
|
71
|
+
zone.handleDrop(event)
|
|
72
|
+
expect(event.preventDefault).not.toHaveBeenCalled()
|
|
73
|
+
expect(onDrop).not.toHaveBeenCalled()
|
|
74
|
+
disabled.value = false
|
|
75
|
+
zone.handleDragEnter(event)
|
|
76
|
+
zone.handleDragLeave(event)
|
|
77
|
+
expect(zone.isDragging.value).toBe(false)
|
|
78
|
+
scope.stop()
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it('supports getters, dragover-only activation and dragend/disposal reset', () => {
|
|
82
|
+
const scope = effectScope()
|
|
83
|
+
const disabled = ref(false)
|
|
84
|
+
const zone = scope.run(() => useFileDropzone({ disabled: () => disabled.value }))!
|
|
85
|
+
zone.handlers.dragover(dragEvent())
|
|
86
|
+
expect(zone.isDragging.value).toBe(true)
|
|
87
|
+
zone.handlers.dragleave(dragEvent())
|
|
88
|
+
expect(zone.isDragging.value).toBe(false)
|
|
89
|
+
zone.handlers.dragenter(dragEvent())
|
|
90
|
+
zone.handlers.dragend()
|
|
91
|
+
expect(zone.isDragging.value).toBe(false)
|
|
92
|
+
zone.handlers.dragenter(dragEvent())
|
|
93
|
+
scope.stop()
|
|
94
|
+
expect(zone.isDragging.value).toBe(false)
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
it('resets on malformed drops and throwing consumers', () => {
|
|
98
|
+
const scope = effectScope()
|
|
99
|
+
const zone = scope.run(() => useFileDropzone({ onDrop: () => { throw new Error('upload failed') } }))!
|
|
100
|
+
zone.handleDragEnter(dragEvent())
|
|
101
|
+
zone.handleDrop({ dataTransfer: null } as DragEvent)
|
|
102
|
+
expect(zone.isDragging.value).toBe(false)
|
|
103
|
+
zone.handleDragEnter(dragEvent())
|
|
104
|
+
expect(() => zone.handleDrop(dragEvent())).toThrow('upload failed')
|
|
105
|
+
expect(zone.isDragging.value).toBe(false)
|
|
106
|
+
scope.stop()
|
|
107
|
+
})
|
|
108
|
+
})
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { getCurrentScope, onScopeDispose, readonly, ref, toValue, watch } from 'vue'
|
|
2
|
+
import type { MaybeRefOrGetter } from 'vue'
|
|
3
|
+
|
|
4
|
+
export interface UseFileDropzoneOptions {
|
|
5
|
+
disabled?: MaybeRefOrGetter<boolean | undefined>
|
|
6
|
+
/** Receives the original FileList; validation and upload policy belong to the consumer. */
|
|
7
|
+
onDrop?: (files: FileList, event: DragEvent) => void
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** Bind all returned handlers to the same drop target, or use v-on="handlers". */
|
|
11
|
+
export function useFileDropzone(options: UseFileDropzoneOptions = {}) {
|
|
12
|
+
const isDragging = ref(false)
|
|
13
|
+
let dragDepth = 0
|
|
14
|
+
|
|
15
|
+
function reset() {
|
|
16
|
+
dragDepth = 0
|
|
17
|
+
isDragging.value = false
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function acceptEvent(event: DragEvent) {
|
|
21
|
+
if (toValue(options.disabled)) {
|
|
22
|
+
reset()
|
|
23
|
+
return false
|
|
24
|
+
}
|
|
25
|
+
if (!Array.from(event.dataTransfer?.types ?? []).includes('Files')) { return false }
|
|
26
|
+
event.preventDefault()
|
|
27
|
+
event.stopPropagation()
|
|
28
|
+
return true
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function handleDragEnter(event: DragEvent) {
|
|
32
|
+
if (!acceptEvent(event)) { return }
|
|
33
|
+
dragDepth++
|
|
34
|
+
isDragging.value = true
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function handleDragOver(event: DragEvent) {
|
|
38
|
+
if (!acceptEvent(event)) { return }
|
|
39
|
+
// A target may be mounted underneath a drag already in progress.
|
|
40
|
+
dragDepth = Math.max(1, dragDepth)
|
|
41
|
+
isDragging.value = true
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function handleDragLeave(event: DragEvent) {
|
|
45
|
+
if (!acceptEvent(event)) { return }
|
|
46
|
+
dragDepth = Math.max(0, dragDepth - 1)
|
|
47
|
+
isDragging.value = dragDepth > 0
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function handleDrop(event: DragEvent) {
|
|
51
|
+
reset()
|
|
52
|
+
if (!acceptEvent(event)) { return }
|
|
53
|
+
const files = event.dataTransfer?.files
|
|
54
|
+
if (files) { options.onDrop?.(files, event) }
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const stopWatching = watch(() => toValue(options.disabled), disabled => {
|
|
58
|
+
if (disabled) { reset() }
|
|
59
|
+
}, { flush: 'sync' })
|
|
60
|
+
|
|
61
|
+
if (getCurrentScope()) {
|
|
62
|
+
onScopeDispose(() => {
|
|
63
|
+
stopWatching()
|
|
64
|
+
reset()
|
|
65
|
+
})
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return {
|
|
69
|
+
isDragging: readonly(isDragging),
|
|
70
|
+
reset,
|
|
71
|
+
handleDragEnter,
|
|
72
|
+
handleDragOver,
|
|
73
|
+
handleDragLeave,
|
|
74
|
+
handleDrop,
|
|
75
|
+
handlers: {
|
|
76
|
+
dragenter: handleDragEnter,
|
|
77
|
+
dragover: handleDragOver,
|
|
78
|
+
dragleave: handleDragLeave,
|
|
79
|
+
drop: handleDrop,
|
|
80
|
+
dragend: reset,
|
|
81
|
+
},
|
|
82
|
+
}
|
|
83
|
+
}
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import type { Ref, WritableComputedRef } from 'vue'
|
|
3
3
|
|
|
4
4
|
import type { DraggableEvent } from '../../draggable/useDraggable'
|
|
5
|
+
import { reorderItems } from '../../draggable/reorder'
|
|
5
6
|
|
|
6
7
|
import { Btn, resolveI18n, useI18n, pathKeyToURL, richTextToPlainText } from '@bagelink/vue'
|
|
7
8
|
import { computed, ref, watch, useSlots, type VNode } from 'vue'
|
|
@@ -121,11 +122,8 @@ function moveItem(from: number, to: number) {
|
|
|
121
122
|
if (to < 0 || to >= items.value.length || from === to) {
|
|
122
123
|
return
|
|
123
124
|
}
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
const [movedMinimizedState] = minimizedItems.value.splice(from, 1)
|
|
128
|
-
minimizedItems.value.splice(to, 0, movedMinimizedState)
|
|
125
|
+
items.value = reorderItems(items.value, from, to)
|
|
126
|
+
minimizedItems.value = reorderItems(minimizedItems.value, from, to)
|
|
129
127
|
|
|
130
128
|
updateModel()
|
|
131
129
|
}
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
import type { UploadInputProps } from '../../../../types/BagelForm'
|
|
3
3
|
import { Btn, IMAGE_FORMATS_REGEXP, Icon, Card, Image, pathKeyToURL, Loading, useI18n, resolveI18n } from '@bagelink/vue'
|
|
4
|
-
import { watch,
|
|
4
|
+
import { watch, computed, useSlots } from 'vue'
|
|
5
|
+
import { useFileDropzone } from '../../../file-drop/useFileDropzone'
|
|
5
6
|
import { useFileUpload } from './useFileUpload'
|
|
6
7
|
import { useBagelInputShell } from '../bagelInputShell'
|
|
7
8
|
|
|
@@ -78,28 +79,19 @@ const showDropZonePlaceholder = computed(
|
|
|
78
79
|
|
|
79
80
|
const isImage = (str: string) => IMAGE_FORMATS_REGEXP.test(str)
|
|
80
81
|
|
|
81
|
-
const isDragOver =
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
}
|
|
82
|
+
const { isDragging: isDragOver, handlers } = useFileDropzone({
|
|
83
|
+
disabled: () => props.disabled,
|
|
84
|
+
onDrop: files => {
|
|
85
|
+
emit('addFileStart')
|
|
86
|
+
addFile(files)
|
|
87
|
+
},
|
|
88
|
+
})
|
|
88
89
|
|
|
89
90
|
function updateModelValue() {
|
|
90
91
|
if (props.multiple) { emit('update:modelValue', pathKeys.value) }
|
|
91
92
|
else { emit('update:modelValue', pathKeys.value[0] || undefined) }
|
|
92
93
|
}
|
|
93
94
|
|
|
94
|
-
async function handleDrop(e: DragEvent) {
|
|
95
|
-
if (props.disabled) { return }
|
|
96
|
-
e.preventDefault()
|
|
97
|
-
e.stopPropagation()
|
|
98
|
-
emit('addFileStart')
|
|
99
|
-
addFile(e.dataTransfer?.files)
|
|
100
|
-
isDragOver.value = false
|
|
101
|
-
}
|
|
102
|
-
|
|
103
95
|
function fileName(pathKey: string) {
|
|
104
96
|
if (!pathKey || typeof pathKey !== 'string') { return 'file' }
|
|
105
97
|
return pathKey.split('/').pop() || 'file'
|
|
@@ -114,8 +106,7 @@ function fileName(pathKey: string) {
|
|
|
114
106
|
<label v-if="label">{{ label }}<span v-if="required"> *</span></label>
|
|
115
107
|
<input v-if="required && !pathKeys.length" :name="name || id" placeholder="required" type="text" required class="pixel">
|
|
116
108
|
<Card
|
|
117
|
-
v-if="theme === 'basic'" outline class="flex p-05 gap-1"
|
|
118
|
-
@drop="handleDrop"
|
|
109
|
+
v-if="theme === 'basic'" outline class="flex p-05 gap-1" v-on="handlers"
|
|
119
110
|
>
|
|
120
111
|
<Btn
|
|
121
112
|
v-if="!pathKeys.length && !fileQueue.length" class="px-1-5" icon="upload" outline
|
|
@@ -161,8 +152,7 @@ function fileName(pathKey: string) {
|
|
|
161
152
|
'fileDropZone': !pathKeys.length && !fileQueue.length,
|
|
162
153
|
'dragover': isDragOver,
|
|
163
154
|
'bgl_oval-upload': oval,
|
|
164
|
-
}" :style="{ width, height }"
|
|
165
|
-
@dragleave="(e) => handleDrag(e)"
|
|
155
|
+
}" :style="{ width, height }" v-on="handlers" @click="browse()"
|
|
166
156
|
>
|
|
167
157
|
<slot name="files" :files="pathKeys" :fileQueue>
|
|
168
158
|
<div v-if="multiple" class="bgl-multi-preview">
|
package/src/components/index.ts
CHANGED
|
@@ -17,8 +17,11 @@ export { default as DataPreview } from './DataPreview.vue'
|
|
|
17
17
|
export { default as DataTable } from './dataTable/DataTable.vue'
|
|
18
18
|
/** @deprecated Use DataTable instead. TableSchema is an alias that will be removed in a future version. */
|
|
19
19
|
export { default as TableSchema } from './dataTable/DataTable.vue'
|
|
20
|
-
export { Draggable, useDraggable, vDraggable } from './draggable'
|
|
20
|
+
export { Draggable, useDraggable, vDraggable, reorderItems, insertionIndexToFinalIndex } from './draggable'
|
|
21
|
+
export type { DraggableEvent, DraggableOptions } from './draggable'
|
|
21
22
|
export { default as DragOver } from './DragOver.vue'
|
|
23
|
+
export { useFileDropzone } from './file-drop/useFileDropzone'
|
|
24
|
+
export type { UseFileDropzoneOptions } from './file-drop/useFileDropzone'
|
|
22
25
|
export { default as Dropdown } from './Dropdown.vue'
|
|
23
26
|
export { default as EmptyState } from './EmptyState.vue'
|
|
24
27
|
export { default as FieldSetVue } from './FieldSetVue.vue'
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
# AppLayout — Region-based redesign (design doc)
|
|
2
2
|
|
|
3
|
-
> Status: **
|
|
3
|
+
> Status: **historical proposal** · Owner: layout · Scope: `@bagelink/vue`
|
|
4
|
+
> The legacy chat panel described below has been retired. Current chat integrations
|
|
5
|
+
> use `ChatDock`, `FloatingPanel`, or a full-page route; this proposal is not an API guide.
|
|
4
6
|
> Goal: turn `AppLayout` into a single-source-of-truth **region manager** so every
|
|
5
7
|
> edge (`start` / `end` / `top` / `bottom`) is a first-class, full-bleed slot that
|
|
6
8
|
> can either **push** the center content or **overlay** it — without any manual
|
|
@@ -13,7 +15,7 @@
|
|
|
13
15
|
### What hurts today
|
|
14
16
|
- **The `end` aside is bolted on.** `AppLayout` only renders an empty `<slot name="aside">`
|
|
15
17
|
and takes `asideWidth` + `asideOpen` as loose props, used *solely* to compute the
|
|
16
|
-
center's `margin-inline-end`. The actual panel (`
|
|
18
|
+
center's `margin-inline-end`. The actual panel (`legacy chat panel`) positions itself
|
|
17
19
|
`fixed`, owns its own width, open state, mode and resize.
|
|
18
20
|
- **Double book-keeping.** Because the panel owns its width/open state *and* the layout
|
|
19
21
|
needs the same numbers to reserve room, the consuming app (`personal_budget/App.vue`)
|
|
@@ -30,7 +32,7 @@
|
|
|
30
32
|
literally put `100vh` / `100vw` inside and it fills exactly that band).
|
|
31
33
|
- A region either **pushes** the center (reserves room) or **overlays** it (floats on top).
|
|
32
34
|
- **One source of truth** — `AppLayout` owns each region's `{ present, open, mode, size }`.
|
|
33
|
-
Children (e.g. `
|
|
35
|
+
Children (e.g. `legacy chat panel`) *report into* it via `setRegion`; the layout does the math.
|
|
34
36
|
- **The sidebar keeps working exactly as-is.** We generalize the pattern it already uses;
|
|
35
37
|
we do **not** rewrite `AppSidebar.vue` or its CSS.
|
|
36
38
|
|
|
@@ -113,7 +115,7 @@ Each slot receives:
|
|
|
113
115
|
| prop `sidebarWidth` | seeds `regions.start.size` | Single source of truth now; `AppSidebar` reads the same injected value. |
|
|
114
116
|
| prop `sidebarCardStyle` | seeds `regions.start.collapsedSize` (82px) | Removes the 66/82 mismatch. |
|
|
115
117
|
| prop `asideWidth` | seeds `regions.end.size` | Still accepted; `setRegion` is the preferred path. |
|
|
116
|
-
| prop `asideOpen` | seeds `regions.end.open` + `mode:'push'` | Still accepted; `
|
|
118
|
+
| prop `asideOpen` | seeds `regions.end.open` + `mode:'push'` | Still accepted; `legacy chat panel` should call `setRegion` instead. |
|
|
117
119
|
| `v-model:open` | maps to `regions.start.open` | Unchanged. |
|
|
118
120
|
|
|
119
121
|
### 3.3 Injected context (`appLayoutContext.ts`)
|
|
@@ -167,18 +169,14 @@ just `start` with `mode: 'overlay'` on mobile.
|
|
|
167
169
|
<AppSidebar :nav-links="navLinks" :footer-links="footerLinks" :card="true" … />
|
|
168
170
|
</template>
|
|
169
171
|
|
|
170
|
-
<!--
|
|
172
|
+
<!-- legacy chat panel reports its own state into the end region; layout reserves room
|
|
171
173
|
only while docked. floating/full → mode:'overlay' → no push. -->
|
|
172
174
|
<template #end>
|
|
173
|
-
|
|
174
|
-
ref="chatWindow"
|
|
175
|
-
:agent-id="BUDGET_AGENT_ID" :api="agents"
|
|
176
|
-
@update:open="chatOpen = $event"
|
|
177
|
-
/>
|
|
175
|
+
<!-- Use ChatDock for docked chat; FloatingPanel for an overlay. -->
|
|
178
176
|
</template>
|
|
179
177
|
|
|
180
178
|
<RouterWrapper />
|
|
181
|
-
<Btn v-if="!chatOpen" color="primary" round icon="chat" … @click="
|
|
179
|
+
<Btn v-if="!chatOpen" color="primary" round icon="chat" … @click="chatOpen = true" />
|
|
182
180
|
</AppLayout>
|
|
183
181
|
```
|
|
184
182
|
|
|
@@ -186,9 +184,9 @@ just `start` with `mode: 'overlay'` on mobile.
|
|
|
186
184
|
|
|
187
185
|
---
|
|
188
186
|
|
|
189
|
-
## 6. `
|
|
187
|
+
## 6. `legacy chat panel` changes (minimal, careful)
|
|
190
188
|
|
|
191
|
-
`
|
|
189
|
+
`legacy chat panel` gains one consolidated reporter that fires whenever `isOpen`, `activeMode`
|
|
192
190
|
or `dockWidth` change. It resolves mode → region intent:
|
|
193
191
|
|
|
194
192
|
```ts
|
|
@@ -198,7 +196,7 @@ or `dockWidth` change. It resolves mode → region intent:
|
|
|
198
196
|
```
|
|
199
197
|
|
|
200
198
|
It calls `setRegion(side, patch)` from the injected context (or emits `@region` for
|
|
201
|
-
apps not using injection). **No change** to `
|
|
199
|
+
apps not using injection). **No change** to `legacy chat panel`'s own `fixed` positioning,
|
|
202
200
|
transitions, or resize logic — in push mode it is still `fixed` on the edge; the center
|
|
203
201
|
now knows to leave room because it reads the reported `size`, not a hand-passed prop.
|
|
204
202
|
|
|
@@ -217,5 +215,5 @@ now knows to leave room because it reads the reported `size`, not a hand-passed
|
|
|
217
215
|
2. Rework `AppLayout.vue` to hold the region map + compute center insets from it;
|
|
218
216
|
render `#start/#end/#top/#bottom` with `#sidebar/#aside` aliasing.
|
|
219
217
|
3. Verify `AppSidebar` unchanged (desktop push, mobile overlay, card collapse widths).
|
|
220
|
-
4. Add the consolidated region reporter to `
|
|
218
|
+
4. Add the consolidated region reporter to `legacy chat panel`; simplify `personal_budget/App.vue`.
|
|
221
219
|
5. Regenerate `COMPONENTS.md` (`pnpm gen:cheatsheet`).
|
|
@@ -29,6 +29,7 @@
|
|
|
29
29
|
import { ref, provide, onMounted, onUnmounted, computed, watch, useSlots, Comment, Text } from 'vue'
|
|
30
30
|
import type { Slots } from 'vue'
|
|
31
31
|
import { useEscapeKey } from '../../composables/useEscapeKey'
|
|
32
|
+
import { usePointerSession } from '../../composables/usePointerSession'
|
|
32
33
|
import { MOBILE_BREAKPOINT } from '../../utils/constants'
|
|
33
34
|
import {
|
|
34
35
|
AppLayoutKey,
|
|
@@ -257,50 +258,61 @@ function handleVisible(side: ResizableSide): boolean {
|
|
|
257
258
|
return !!r.resizable && r.open && isFlow(side)
|
|
258
259
|
}
|
|
259
260
|
|
|
260
|
-
|
|
261
|
-
let dragStartX = 0
|
|
262
|
-
let dragStartW = 0
|
|
263
|
-
/** True while the user is drag-resizing a start/end rail. Suppresses the
|
|
264
|
-
* grid-template-columns transition so the track tracks the pointer 1:1
|
|
265
|
-
* instead of lagging behind a 300ms ease. */
|
|
261
|
+
const regionPointer = usePointerSession()
|
|
266
262
|
const isRegionDragging = ref(false)
|
|
267
263
|
|
|
268
|
-
function
|
|
269
|
-
|
|
270
|
-
const
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
// moves toward the end edge; an `end` rail grows toward the start edge.
|
|
275
|
-
const rtl = typeof document !== 'undefined' && document.dir === 'rtl'
|
|
276
|
-
const dx = e.clientX - dragStartX
|
|
277
|
-
// LTR: start grows with +dx, end grows with −dx. RTL inverts both.
|
|
278
|
-
const dirSign = rtl ? -1 : 1
|
|
279
|
-
const sideSign = dragSide === 'start' ? 1 : -1
|
|
280
|
-
const next = Math.min(max, Math.max(min, dragStartW + dx * dirSign * sideSign))
|
|
281
|
-
const size = `${next}px`
|
|
282
|
-
patch(dragSide, { size })
|
|
283
|
-
emit('resize', { side: dragSide, size })
|
|
264
|
+
function resizeRegion(side: ResizableSide, width: number) {
|
|
265
|
+
const r = regions.value[side]
|
|
266
|
+
const size = `${Math.min(parsePx(r.maxSize, window.innerWidth), Math.max(parsePx(r.minSize, 0), width))}px`
|
|
267
|
+
// Rails publish live sizes because their grid tracks and dock children share state.
|
|
268
|
+
patch(side, { size })
|
|
269
|
+
emit('resize', { side, size })
|
|
284
270
|
}
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
document.body.style.cursor = ''
|
|
289
|
-
document.body.style.userSelect = ''
|
|
290
|
-
window.removeEventListener('pointermove', onResizeMove)
|
|
291
|
-
window.removeEventListener('pointerup', onResizeUp)
|
|
271
|
+
|
|
272
|
+
function resizeSign(side: ResizableSide, handle: HTMLElement) {
|
|
273
|
+
return (getComputedStyle(handle).direction === 'rtl' ? -1 : 1) * (side === 'start' ? 1 : -1)
|
|
292
274
|
}
|
|
275
|
+
|
|
293
276
|
function onResizeDown(side: ResizableSide, e: PointerEvent) {
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
277
|
+
if (e.button !== 0 || e.isPrimary === false || !handleVisible(side)) return
|
|
278
|
+
regionPointer.cancel()
|
|
279
|
+
const handle = e.currentTarget as HTMLElement
|
|
280
|
+
const original = regions.value[side].size
|
|
281
|
+
const width = handle.parentElement?.getBoundingClientRect().width ?? parsePx(original, 0)
|
|
282
|
+
const sign = resizeSign(side, handle)
|
|
283
|
+
const update = (event: PointerEvent) => resizeRegion(side, width + (event.clientX - e.clientX) * sign)
|
|
297
284
|
isRegionDragging.value = true
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
285
|
+
regionPointer.start(e, {
|
|
286
|
+
cursor: 'ew-resize',
|
|
287
|
+
onMove: update,
|
|
288
|
+
onEnd(event) {
|
|
289
|
+
update(event)
|
|
290
|
+
isRegionDragging.value = false
|
|
291
|
+
},
|
|
292
|
+
onCancel() {
|
|
293
|
+
patch(side, { size: original })
|
|
294
|
+
emit('resize', { side, size: original })
|
|
295
|
+
isRegionDragging.value = false
|
|
296
|
+
},
|
|
297
|
+
})
|
|
302
298
|
}
|
|
303
299
|
|
|
300
|
+
function onResizeKeyDown(side: ResizableSide, event: KeyboardEvent) {
|
|
301
|
+
if (!['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) return
|
|
302
|
+
event.preventDefault()
|
|
303
|
+
regionPointer.cancel()
|
|
304
|
+
const handle = event.currentTarget as HTMLElement
|
|
305
|
+
const r = regions.value[side]
|
|
306
|
+
const width = handle.parentElement?.getBoundingClientRect().width ?? parsePx(r.size, 0)
|
|
307
|
+
const step = (event.shiftKey ? 50 : 10) * (event.key === 'ArrowLeft' ? -1 : 1)
|
|
308
|
+
resizeRegion(side, event.key === 'Home' ? parsePx(r.minSize, 0)
|
|
309
|
+
: event.key === 'End' ? parsePx(r.maxSize, window.innerWidth)
|
|
310
|
+
: width + step * resizeSign(side, handle))
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
watch(() => JSON.stringify(RESIZABLE_SIDES.map(side => [regions.value[side].open, regions.value[side].present, regions.value[side].mode, regions.value[side].resizable])), () => regionPointer.cancel())
|
|
314
|
+
watch(isMobile, () => regionPointer.cancel(), { flush: 'sync' })
|
|
315
|
+
|
|
304
316
|
// Published on the layout root so a fixed docked panel (ChatDock) can inset itself
|
|
305
317
|
// below the top band / above the bottom band:
|
|
306
318
|
// inset-block-start: var(--bgl-region-top); inset-block-end: var(--bgl-region-bottom);
|
|
@@ -341,8 +353,6 @@ onMounted(() => {
|
|
|
341
353
|
onUnmounted(() => {
|
|
342
354
|
window.removeEventListener('resize', onResize)
|
|
343
355
|
clearTimeout(resizeAnimTimer)
|
|
344
|
-
window.removeEventListener('pointermove', onResizeMove)
|
|
345
|
-
window.removeEventListener('pointerup', onResizeUp)
|
|
346
356
|
})
|
|
347
357
|
|
|
348
358
|
const slotBind = computed(() => ({ isOpen, isMobile, toggleMenu, closeOnMobile }))
|
|
@@ -372,6 +382,10 @@ const slotBind = computed(() => ({ isOpen, isMobile, toggleMenu, closeOnMobile }
|
|
|
372
382
|
<div
|
|
373
383
|
v-show="handleVisible('start')"
|
|
374
384
|
class="region-resize region-resize--start"
|
|
385
|
+
role="separator" tabindex="0" aria-orientation="vertical" aria-label="Resize start panel"
|
|
386
|
+
:aria-valuenow="parsePx(regions.start.size, 0)" :aria-valuemin="parsePx(regions.start.minSize, 0)"
|
|
387
|
+
:aria-valuemax="regions.start.maxSize ? parsePx(regions.start.maxSize, 0) : undefined"
|
|
388
|
+
@keydown="onResizeKeyDown('start', $event)"
|
|
375
389
|
@pointerdown.prevent="onResizeDown('start', $event)"
|
|
376
390
|
>
|
|
377
391
|
<div class="region-resize-bar" />
|
|
@@ -396,6 +410,10 @@ const slotBind = computed(() => ({ isOpen, isMobile, toggleMenu, closeOnMobile }
|
|
|
396
410
|
<div
|
|
397
411
|
v-show="handleVisible('end')"
|
|
398
412
|
class="region-resize region-resize--end"
|
|
413
|
+
role="separator" tabindex="0" aria-orientation="vertical" aria-label="Resize end panel"
|
|
414
|
+
:aria-valuenow="parsePx(regions.end.size, 0)" :aria-valuemin="parsePx(regions.end.minSize, 0)"
|
|
415
|
+
:aria-valuemax="regions.end.maxSize ? parsePx(regions.end.maxSize, 0) : undefined"
|
|
416
|
+
@keydown="onResizeKeyDown('end', $event)"
|
|
399
417
|
@pointerdown.prevent="onResizeDown('end', $event)"
|
|
400
418
|
>
|
|
401
419
|
<div class="region-resize-bar" />
|
|
@@ -486,6 +504,7 @@ transition: none;
|
|
|
486
504
|
opacity: 0;
|
|
487
505
|
transition: opacity 0.15s;
|
|
488
506
|
}
|
|
507
|
+
.region-resize:focus-visible .region-resize-bar,
|
|
489
508
|
.region-resize:hover .region-resize-bar,
|
|
490
509
|
.region-resize:active .region-resize-bar { opacity: 1; }
|
|
491
510
|
|
|
@@ -101,6 +101,11 @@ onMounted(() => {
|
|
|
101
101
|
ctx.syncPanelOrder()
|
|
102
102
|
})
|
|
103
103
|
|
|
104
|
+
watch(panelEl, (el) => {
|
|
105
|
+
if (panelState.value) panelState.value.el.value = el
|
|
106
|
+
if (el) ctx.syncPanelOrder()
|
|
107
|
+
})
|
|
108
|
+
|
|
104
109
|
onUnmounted(() => { ctx.unregisterPanel(panelId) })
|
|
105
110
|
|
|
106
111
|
// ── Computed state ─────────────────────────────────────────────────────────
|
|
@@ -118,6 +123,13 @@ const isLastPanel = computed(() => {
|
|
|
118
123
|
return idx >= 0 && idx === ps.length - 1
|
|
119
124
|
})
|
|
120
125
|
|
|
126
|
+
const handleAvailable = computed(() => {
|
|
127
|
+
const index = ctx.panels.value.findIndex(panel => panel.id === panelId)
|
|
128
|
+
const next = ctx.panels.value[index + 1]
|
|
129
|
+
return !!next && !next.collapsed.value && !(ctx.isMobile.value && next.config.hideOnMobile)
|
|
130
|
+
&& !(props.flex && next.config.flex)
|
|
131
|
+
})
|
|
132
|
+
|
|
121
133
|
const panelStyle = computed(() => {
|
|
122
134
|
if (isCollapsed.value) {
|
|
123
135
|
return isHorizontal.value
|
|
@@ -153,8 +165,13 @@ defineExpose({ toggleCollapse, size, isCollapsed })
|
|
|
153
165
|
</div>
|
|
154
166
|
|
|
155
167
|
<div
|
|
156
|
-
v-if="props.handle && !isLastPanel && !isCollapsed" class="panel__handle"
|
|
168
|
+
v-if="props.handle && !isLastPanel && !isCollapsed && handleAvailable" class="panel__handle"
|
|
157
169
|
:class="isHorizontal ? 'panel__handle--h' : 'panel__handle--v'"
|
|
170
|
+
role="separator" tabindex="0" :aria-label="`Resize ${panelId}`"
|
|
171
|
+
:aria-orientation="isHorizontal ? 'vertical' : 'horizontal'"
|
|
172
|
+
:aria-valuenow="size" :aria-valuemin="props.min"
|
|
173
|
+
:aria-valuemax="Number.isFinite(props.max) ? props.max : undefined"
|
|
174
|
+
@keydown="ctx.onHandleKeyDown(panelId, $event)"
|
|
158
175
|
@pointerdown="ctx.onHandlePointerDown(panelId, $event)"
|
|
159
176
|
>
|
|
160
177
|
<div class="panel__handle-bar" />
|
|
@@ -285,6 +302,7 @@ defineExpose({ toggleCollapse, size, isCollapsed })
|
|
|
285
302
|
background: var(--resizable-divider-active-color, var(--color-primary, #3b82f6));
|
|
286
303
|
}
|
|
287
304
|
|
|
305
|
+
.panel__handle:focus-visible .panel__handle-bar,
|
|
288
306
|
.panel__handle:hover .panel__handle-bar {
|
|
289
307
|
opacity: 1;
|
|
290
308
|
transform: scale(1);
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
<script lang="ts" setup>
|
|
2
|
-
import { computed,
|
|
2
|
+
import { computed, provide, ref } from 'vue'
|
|
3
3
|
import { RESIZABLE_KEY, useResizableLayoutProvider } from '../../composables/useResizableLayout'
|
|
4
4
|
import { MOBILE_BREAKPOINT } from '../../utils/constants'
|
|
5
5
|
|
|
@@ -29,7 +29,7 @@ const directionProp = computed(() => props.horizontal ? 'horizontal' as const :
|
|
|
29
29
|
const mobileDirection = computed(() => props.mobileVertical || !props.horizontal ? 'vertical' as const : 'horizontal' as const,
|
|
30
30
|
)
|
|
31
31
|
|
|
32
|
-
const { context
|
|
32
|
+
const { context } = useResizableLayoutProvider({
|
|
33
33
|
directionProp,
|
|
34
34
|
mobileDirection: mobileDirection.value,
|
|
35
35
|
breakpoint: props.breakpoint,
|
|
@@ -37,9 +37,6 @@ const { context, observe, disconnect } = useResizableLayoutProvider({
|
|
|
37
37
|
})
|
|
38
38
|
|
|
39
39
|
provide(RESIZABLE_KEY, context)
|
|
40
|
-
onMounted(observe)
|
|
41
|
-
onUnmounted(disconnect)
|
|
42
|
-
|
|
43
40
|
const isHorizontal = computed(() => context.direction.value === 'horizontal')
|
|
44
41
|
</script>
|
|
45
42
|
|
|
@@ -62,7 +62,7 @@ export interface AppLayoutContext {
|
|
|
62
62
|
// ── region manager ────────────────────────────────────────────────────
|
|
63
63
|
/** Live state for every edge. Center insets are derived from this. */
|
|
64
64
|
regions: Readonly<Ref<Record<RegionSide, RegionState>>>
|
|
65
|
-
/** Dedicated per-side patchers — a child
|
|
65
|
+
/** Dedicated per-side patchers — a docked child reports its intent. */
|
|
66
66
|
setStart: (patch: RegionPatch) => void
|
|
67
67
|
setEnd: (patch: RegionPatch) => void
|
|
68
68
|
setTop: (patch: RegionPatch) => void
|
|
@@ -105,6 +105,7 @@ export function useAppLayout(): AppLayoutContext {
|
|
|
105
105
|
if (ctx) { return ctx }
|
|
106
106
|
|
|
107
107
|
if (import.meta.env?.DEV) {
|
|
108
|
+
// eslint-disable-next-line no-console -- Surface incorrect layout usage during development.
|
|
108
109
|
console.warn('[bagelink] useAppLayout() called outside <AppLayout>. AppSidebar/AppContent are designed to be used inside it.')
|
|
109
110
|
}
|
|
110
111
|
const noop = () => {}
|
package/src/composables/index.ts
CHANGED
|
@@ -5,6 +5,8 @@ import { getFallbackSchema } from '@bagelink/vue'
|
|
|
5
5
|
import { computed, toValue } from 'vue'
|
|
6
6
|
|
|
7
7
|
export { useAddToCalendar } from './useAddToCalendar'
|
|
8
|
+
export { useBoundedRectDrag } from './useBoundedRectDrag'
|
|
9
|
+
export type { BoundedRectDragOptions, DragRect } from './useBoundedRectDrag'
|
|
8
10
|
export { useDevice } from './useDevice'
|
|
9
11
|
export { useEscapeKey } from './useEscapeKey'
|
|
10
12
|
export { useExcel } from './useExcel'
|
|
@@ -13,6 +15,10 @@ export { useGradientVariant } from './useGradientVariant'
|
|
|
13
15
|
export { useImageSrc } from './useImageSrc'
|
|
14
16
|
export { useLocalStore } from './useLocalStore'
|
|
15
17
|
export { usePolling } from './usePolling'
|
|
18
|
+
export { createPointerSession, usePointerSession } from './usePointerSession'
|
|
19
|
+
export type { PointerSessionOptions } from './usePointerSession'
|
|
20
|
+
export { resizeRect, snapGridDelta } from './resizeGeometry'
|
|
21
|
+
export type { ResizeRect } from './resizeGeometry'
|
|
16
22
|
export { usePrefersReducedMotion } from './usePrefersReducedMotion'
|
|
17
23
|
export { useQuery } from './useQuery'
|
|
18
24
|
export { useResizableLayoutProvider } from './useResizableLayout'
|