@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.
Files changed (67) hide show
  1. package/dist/components/DragOver.vue.d.ts.map +1 -1
  2. package/dist/components/dataTable/DataTable.vue.d.ts.map +1 -1
  3. package/dist/components/draggable/Draggable.vue.d.ts.map +1 -1
  4. package/dist/components/draggable/index.d.ts +1 -0
  5. package/dist/components/draggable/index.d.ts.map +1 -1
  6. package/dist/components/draggable/reorder.d.ts +5 -0
  7. package/dist/components/draggable/reorder.d.ts.map +1 -0
  8. package/dist/components/draggable/useDraggable.d.ts +5 -1
  9. package/dist/components/draggable/useDraggable.d.ts.map +1 -1
  10. package/dist/components/draggable/vDraggable.d.ts.map +1 -1
  11. package/dist/components/file-drop/useFileDropzone.d.ts +23 -0
  12. package/dist/components/file-drop/useFileDropzone.d.ts.map +1 -0
  13. package/dist/components/file-drop/useFileDropzone.test.d.ts +2 -0
  14. package/dist/components/file-drop/useFileDropzone.test.d.ts.map +1 -0
  15. package/dist/components/form/inputs/ArrayInput.vue.d.ts.map +1 -1
  16. package/dist/components/form/inputs/Upload/UploadInput.vue.d.ts.map +1 -1
  17. package/dist/components/index.d.ts +4 -1
  18. package/dist/components/index.d.ts.map +1 -1
  19. package/dist/components/layout/AppLayout.vue.d.ts.map +1 -1
  20. package/dist/components/layout/Panel.vue.d.ts.map +1 -1
  21. package/dist/components/layout/Resizable.vue.d.ts.map +1 -1
  22. package/dist/components/layout/appLayoutContext.d.ts +1 -1
  23. package/dist/components/layout/appLayoutContext.d.ts.map +1 -1
  24. package/dist/composables/index.d.ts +6 -0
  25. package/dist/composables/index.d.ts.map +1 -1
  26. package/dist/composables/resizeGeometry.d.ts +20 -0
  27. package/dist/composables/resizeGeometry.d.ts.map +1 -0
  28. package/dist/composables/useBoundedRectDrag.d.ts +26 -0
  29. package/dist/composables/useBoundedRectDrag.d.ts.map +1 -0
  30. package/dist/composables/usePointerSession.d.ts +17 -0
  31. package/dist/composables/usePointerSession.d.ts.map +1 -0
  32. package/dist/composables/useResizableLayout.d.ts +1 -2
  33. package/dist/composables/useResizableLayout.d.ts.map +1 -1
  34. package/dist/composables/useResizeObserver.d.ts +5 -4
  35. package/dist/composables/useResizeObserver.d.ts.map +1 -1
  36. package/dist/core.css +1 -1
  37. package/dist/directives/vResize.d.ts +2 -1
  38. package/dist/directives/vResize.d.ts.map +1 -1
  39. package/dist/index.cjs +42 -51
  40. package/dist/index.css +1 -1
  41. package/dist/index.mjs +10269 -9989
  42. package/dist/style.css +1 -1
  43. package/package.json +1 -1
  44. package/src/components/DragOver.vue +6 -41
  45. package/src/components/dataTable/DataTable.vue +11 -7
  46. package/src/components/draggable/Draggable.vue +5 -6
  47. package/src/components/draggable/index.ts +1 -0
  48. package/src/components/draggable/reorder.ts +17 -0
  49. package/src/components/draggable/useDraggable.ts +73 -71
  50. package/src/components/draggable/vDraggable.ts +18 -8
  51. package/src/components/file-drop/useFileDropzone.test.ts +108 -0
  52. package/src/components/file-drop/useFileDropzone.ts +83 -0
  53. package/src/components/form/inputs/ArrayInput.vue +3 -5
  54. package/src/components/form/inputs/Upload/UploadInput.vue +11 -21
  55. package/src/components/index.ts +4 -1
  56. package/src/components/layout/AppLayout.design.md +13 -15
  57. package/src/components/layout/AppLayout.vue +57 -38
  58. package/src/components/layout/Panel.vue +19 -1
  59. package/src/components/layout/Resizable.vue +2 -5
  60. package/src/components/layout/appLayoutContext.ts +2 -1
  61. package/src/composables/index.ts +6 -0
  62. package/src/composables/resizeGeometry.ts +31 -0
  63. package/src/composables/useBoundedRectDrag.ts +69 -0
  64. package/src/composables/usePointerSession.ts +70 -0
  65. package/src/composables/useResizableLayout.ts +66 -55
  66. package/src/composables/useResizeObserver.ts +32 -27
  67. 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
- const [movedItem] = items.value.splice(from, 1)
125
- items.value.splice(to, 0, movedItem)
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, ref, computed, useSlots } from 'vue'
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 = ref(false)
82
-
83
- function handleDrag(e: DragEvent, isDragging = false) {
84
- e.preventDefault()
85
- e.stopPropagation()
86
- if (!props.disabled) { isDragOver.value = isDragging }
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" @dragover="(e: DragEvent) => handleDrag(e, true)"
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 }" @click="browse()" @dragover="(e) => handleDrag(e, true)" @drop="handleDrop"
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">
@@ -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: **proposed** · Owner: layout · Scope: `@bagelink/vue`
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 (`ChatWindow`) positions itself
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. `ChatWindow`) *report into* it via `setRegion`; the layout does the math.
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; `ChatWindow` should call `setRegion` instead. |
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
- <!-- ChatWindow reports its own state into the end region; layout reserves room
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
- <ChatWindow
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="chatWindow?.open()" />
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. `ChatWindow` changes (minimal, careful)
187
+ ## 6. `legacy chat panel` changes (minimal, careful)
190
188
 
191
- `ChatWindow` gains one consolidated reporter that fires whenever `isOpen`, `activeMode`
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 `ChatWindow`'s own `fixed` positioning,
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 `ChatWindow`; simplify `personal_budget/App.vue`.
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
- let dragSide: ResizableSide | null = null
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 onResizeMove(e: PointerEvent) {
269
- if (!dragSide) { return }
270
- const r = regions.value[dragSide]
271
- const min = parsePx(r.minSize, 0)
272
- const max = parsePx(r.maxSize, window.innerWidth)
273
- // Physical sign: RTL flips the inline axis. A `start` rail grows as the pointer
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
- function onResizeUp() {
286
- dragSide = null
287
- isRegionDragging.value = false
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
- dragSide = side
295
- dragStartX = e.clientX
296
- dragStartW = parsePx(regions.value[side].size, 0)
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
- document.body.style.cursor = 'ew-resize'
299
- document.body.style.userSelect = 'none'
300
- window.addEventListener('pointermove', onResizeMove)
301
- window.addEventListener('pointerup', onResizeUp)
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, onMounted, onUnmounted, provide, ref } from 'vue'
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, observe, disconnect } = useResizableLayoutProvider({
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 (e.g. ChatWindow) reports its intent. */
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 = () => {}
@@ -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'