@revenexx/studio-shared 0.3.0 → 0.3.1

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 (29) hide show
  1. package/dist/module.d.mts +1 -0
  2. package/dist/module.json +1 -1
  3. package/dist/module.mjs +5 -0
  4. package/dist/runtime/components/DataTable/ColumnsMenu.d.vue.ts +2 -0
  5. package/dist/runtime/components/DataTable/ColumnsMenu.vue +90 -34
  6. package/dist/runtime/components/DataTable/ColumnsMenu.vue.d.ts +2 -0
  7. package/dist/runtime/components/DataTable/DataTable.d.vue.ts +23 -2
  8. package/dist/runtime/components/DataTable/DataTable.vue +199 -100
  9. package/dist/runtime/components/DataTable/DataTable.vue.d.ts +23 -2
  10. package/dist/runtime/components/DataTable/FilterMenu.d.vue.ts +5 -3
  11. package/dist/runtime/components/DataTable/FilterMenu.vue +32 -36
  12. package/dist/runtime/components/DataTable/FilterMenu.vue.d.ts +5 -3
  13. package/dist/runtime/components/DataTable/types.d.ts +15 -2
  14. package/dist/runtime/components/Shared/FloatingPanel.d.vue.ts +55 -0
  15. package/dist/runtime/components/Shared/FloatingPanel.vue +118 -0
  16. package/dist/runtime/components/Shared/FloatingPanel.vue.d.ts +55 -0
  17. package/dist/runtime/composables/useFloatingPanel.d.ts +69 -0
  18. package/dist/runtime/composables/useFloatingPanel.js +112 -0
  19. package/dist/runtime/composables/useTalkbackChannels.d.ts +21 -6
  20. package/dist/runtime/composables/useTalkbackChannels.js +16 -12
  21. package/dist/runtime/lib/columnOrder.d.ts +45 -0
  22. package/dist/runtime/lib/columnOrder.js +28 -0
  23. package/dist/runtime/lib/columnVisibility.d.ts +41 -0
  24. package/dist/runtime/lib/columnVisibility.js +12 -0
  25. package/dist/runtime/lib/dataTableFilters.d.ts +83 -0
  26. package/dist/runtime/lib/dataTableFilters.js +23 -0
  27. package/dist/runtime/lib/i18nBridge.d.ts +7 -2
  28. package/dist/types.d.mts +2 -0
  29. package/package.json +2 -2
@@ -20,6 +20,7 @@ declare const _default: <T>(__VLS_props: NonNullable<Awaited<typeof __VLS_setup>
20
20
  filter?: boolean | {
21
21
  width?: string;
22
22
  active?: boolean;
23
+ count?: number;
23
24
  };
24
25
  /** Built-in status/dimension filter pills (static mode). One dimension, or
25
26
  * several (each renders as its own sub-menu). */
@@ -32,6 +33,26 @@ declare const _default: <T>(__VLS_props: NonNullable<Awaited<typeof __VLS_setup>
32
33
  viewModes?: DataTableViewMode[] | null;
33
34
  /** Collapse handle for the surface's left rail (see `ListSurface`). */
34
35
  rail?: boolean | DataTableRail;
36
+ /**
37
+ * Scroll the ROWS inside the table instead of the page.
38
+ *
39
+ * The toolbar, the header and the footer stay put and only the data moves,
40
+ * so paging through a long list keeps the search box, the filters and the
41
+ * "showing 20 of 400" in view — you stay in the table's context instead of
42
+ * scrolling the whole surface away and losing the controls that scope it.
43
+ *
44
+ * OPT-IN. A short list scrolling inside a box shorter than the list is worse
45
+ * than a page that scrolls, and there is no way for this component to know
46
+ * which it is looking at.
47
+ *
48
+ * The header row moves INSIDE the scroll container and sticks, rather than
49
+ * sitting above it. That is not a detail: a scrollbar takes width from the
50
+ * scrolling box, so a header outside it stays wider than the rows and every
51
+ * column drifts by however wide the scrollbar is.
52
+ */
53
+ scrollBody?: boolean | {
54
+ maxHeight?: string;
55
+ };
35
56
  pagination?: boolean | {
36
57
  pageSize?: number;
37
58
  };
@@ -61,12 +82,12 @@ declare const _default: <T>(__VLS_props: NonNullable<Awaited<typeof __VLS_setup>
61
82
  }) => any) | undefined;
62
83
  } & {
63
84
  'filter-menu'?: (props: {}) => any;
85
+ } & {
86
+ scope?: (props: {}) => any;
64
87
  } & {
65
88
  'toolbar-left'?: (props: {}) => any;
66
89
  } & {
67
90
  'toolbar-right'?: (props: {}) => any;
68
- } & {
69
- scope?: (props: {}) => any;
70
91
  } & {
71
92
  actions?: (props: {}) => any;
72
93
  } & {
@@ -1,12 +1,14 @@
1
1
  type __VLS_Props = {
2
2
  /** Popover width class (a wide panel of inputs needs more than the default). */
3
3
  width?: string;
4
- /** Tint the trigger while at least one filter is set. */
4
+ /** The trigger goes primary while at least one filter is set. */
5
5
  active?: boolean;
6
+ /** How many are set, for the badge. */
7
+ count?: number;
6
8
  };
7
- declare var __VLS_34: {};
9
+ declare var __VLS_22: {};
8
10
  type __VLS_Slots = {} & {
9
- default?: (props: typeof __VLS_34) => any;
11
+ default?: (props: typeof __VLS_22) => any;
10
12
  };
11
13
  declare const __VLS_component: import("vue").DefineComponent<__VLS_Props, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<__VLS_Props> & Readonly<{}>, {
12
14
  active: boolean;
@@ -1,46 +1,42 @@
1
1
  <script setup>
2
- import { AltArrowDown, Filter } from "#solar-icons";
3
- import {
4
- Button,
5
- DropdownMenu,
6
- DropdownMenuContent,
7
- DropdownMenuItem,
8
- DropdownMenuLabel,
9
- DropdownMenuSeparator,
10
- DropdownMenuTrigger
11
- } from "@revenexx/studio";
2
+ import { Filter } from "#solar-icons";
3
+ import { Popover, PopoverContent, PopoverTrigger, ToolbarFilterTrigger } from "@revenexx/studio";
12
4
  defineProps({
13
- width: { type: String, required: false, default: "w-56" },
14
- active: { type: Boolean, required: false, default: false }
5
+ width: { type: String, required: false, default: "w-72" },
6
+ active: { type: Boolean, required: false, default: false },
7
+ count: { type: Number, required: false }
15
8
  });
16
9
  const t = useT();
17
10
  </script>
18
11
 
19
12
  <template>
20
- <DropdownMenu>
21
- <DropdownMenuTrigger as-child>
22
- <Button
23
- variant="outline"
24
- size="sm"
25
- class="h-7 gap-1.5 text-sm"
26
- :class="active ? 'border-primary/30 text-primary' : ''"
27
- :aria-label="t('shared.datatable.filtermenu.aria')"
13
+ <Popover>
14
+ <PopoverTrigger as-child>
15
+ <ToolbarFilterTrigger
16
+ icon-only
17
+ :active="active"
18
+ :count="count"
19
+ :label="t('shared.datatable.filtermenu.aria')"
28
20
  >
29
- <Filter :size="14" />
30
- {{ t("shared.datatable.filtermenu.action") }}
31
- <AltArrowDown :size="12" class="opacity-60" />
32
- </Button>
33
- </DropdownMenuTrigger>
34
- <DropdownMenuContent align="start" :class="width">
35
- <DropdownMenuLabel class="text-sm text-muted-foreground">
21
+ <template #icon>
22
+ <Filter :size="14" weight="BoldDuotone" />
23
+ </template>
24
+ </ToolbarFilterTrigger>
25
+ </PopoverTrigger>
26
+
27
+ <PopoverContent align="start" :class="['p-0', width]">
28
+ <p class="px-4 pt-3 pb-1 text-sm text-muted-foreground">
36
29
  {{ t("shared.datatable.filtermenu.heading") }}
37
- </DropdownMenuLabel>
38
- <DropdownMenuSeparator />
39
- <slot>
40
- <DropdownMenuItem disabled>
41
- {{ t("shared.datatable.filtermenu.empty") }}
42
- </DropdownMenuItem>
43
- </slot>
44
- </DropdownMenuContent>
45
- </DropdownMenu>
30
+ </p>
31
+ <!-- The surface's controls, stacked. Full width, because a filter panel
32
+ holding a half-width select looks like something failed to load. -->
33
+ <div class="space-y-2.5 p-4 pt-2 [&_[data-slot=select-trigger]]:w-full">
34
+ <slot>
35
+ <p class="py-4 text-center text-sm text-muted-foreground">
36
+ {{ t("shared.datatable.filtermenu.empty") }}
37
+ </p>
38
+ </slot>
39
+ </div>
40
+ </PopoverContent>
41
+ </Popover>
46
42
  </template>
@@ -1,12 +1,14 @@
1
1
  type __VLS_Props = {
2
2
  /** Popover width class (a wide panel of inputs needs more than the default). */
3
3
  width?: string;
4
- /** Tint the trigger while at least one filter is set. */
4
+ /** The trigger goes primary while at least one filter is set. */
5
5
  active?: boolean;
6
+ /** How many are set, for the badge. */
7
+ count?: number;
6
8
  };
7
- declare var __VLS_34: {};
9
+ declare var __VLS_22: {};
8
10
  type __VLS_Slots = {} & {
9
- default?: (props: typeof __VLS_34) => any;
11
+ default?: (props: typeof __VLS_22) => any;
10
12
  };
11
13
  declare const __VLS_component: import("vue").DefineComponent<__VLS_Props, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<__VLS_Props> & Readonly<{}>, {
12
14
  active: boolean;
@@ -46,8 +46,21 @@ export interface DataTableFilter<T = unknown> {
46
46
  key?: string;
47
47
  /** Sub-menu title, e.g. "Status" (default `Status`). */
48
48
  label?: string;
49
- /** The row's value for this dimension (e.g. `r => r.status`). */
50
- getter: (row: T) => string | null | undefined;
49
+ /**
50
+ * The row's value(s) for this dimension.
51
+ *
52
+ * A string for anything a row has exactly one of — `r => r.status`. An ARRAY
53
+ * where a row can have several, and that is not a convenience: modelled as
54
+ * one value, `r => r.locales.join(', ')` makes the SET the value, so the
55
+ * options become combinations (four languages produce up to fifteen, six up
56
+ * to sixty-three) and "which of these have Polish?" cannot be asked at all —
57
+ * the answer is spread across three options and none of the counts is it.
58
+ *
59
+ * With an array the row lands in every bucket it belongs to. The counts then
60
+ * overlap rather than sum to the row total, which is correct and looks like a
61
+ * bug; see `runtime/lib/dataTableFilters.ts`.
62
+ */
63
+ getter: (row: T) => string | string[] | null | undefined;
51
64
  /** The selectable values + their labels. */
52
65
  options: {
53
66
  value: string;
@@ -0,0 +1,55 @@
1
+ type Mode = 'docked-left' | 'docked-right' | 'floating';
2
+ type __VLS_Props = {
3
+ /** What `useFloatingPanel` returned, passed through whole. */
4
+ panel: {
5
+ mode: {
6
+ value: Mode;
7
+ };
8
+ collapsed: {
9
+ value: boolean;
10
+ };
11
+ floatingStyle: {
12
+ value: Record<string, string>;
13
+ };
14
+ dragging: {
15
+ value: boolean;
16
+ };
17
+ startDrag: (ev: PointerEvent, el: HTMLElement | null) => void;
18
+ dock: (side: 'left' | 'right') => void;
19
+ floatHere: (el: HTMLElement | null) => void;
20
+ setCollapsed: (v: boolean) => void;
21
+ };
22
+ title: string;
23
+ /** Room the docked panel leaves at the bottom, for a floating control bar. */
24
+ bottomClass?: string;
25
+ /** Tailwind width, matching the `width` given to the composable. */
26
+ widthClass?: string;
27
+ /** Labels, so the package carries no copy of its own. */
28
+ labels: {
29
+ dockLeft: string;
30
+ dockRight: string;
31
+ floatHere: string;
32
+ collapse: string;
33
+ expand: string;
34
+ placement: string;
35
+ };
36
+ };
37
+ declare var __VLS_5: {}, __VLS_67: {}, __VLS_69: {};
38
+ type __VLS_Slots = {} & {
39
+ 'pill-icon'?: (props: typeof __VLS_5) => any;
40
+ } & {
41
+ default?: (props: typeof __VLS_67) => any;
42
+ } & {
43
+ footer?: (props: typeof __VLS_69) => any;
44
+ };
45
+ declare const __VLS_component: import("vue").DefineComponent<__VLS_Props, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<__VLS_Props> & Readonly<{}>, {
46
+ bottomClass: string;
47
+ widthClass: string;
48
+ }, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
49
+ declare const _default: __VLS_WithSlots<typeof __VLS_component, __VLS_Slots>;
50
+ export default _default;
51
+ type __VLS_WithSlots<T, S> = T & {
52
+ new (): {
53
+ $slots: S;
54
+ };
55
+ };
@@ -0,0 +1,118 @@
1
+ <script setup>
2
+ import { computed } from "vue";
3
+ import { HamburgerMenu, MenuDots, Minimize } from "#solar-icons";
4
+ import {
5
+ DropdownMenu,
6
+ DropdownMenuContent,
7
+ DropdownMenuItem,
8
+ DropdownMenuSeparator,
9
+ DropdownMenuTrigger
10
+ } from "@revenexx/studio";
11
+ const props = defineProps({
12
+ panel: { type: Object, required: true },
13
+ title: { type: String, required: true },
14
+ bottomClass: { type: String, required: false, default: "bottom-16" },
15
+ widthClass: { type: String, required: false, default: "w-80" },
16
+ labels: { type: Object, required: true }
17
+ });
18
+ const shell = ref(null);
19
+ const shellClass = computed(() => {
20
+ if (props.panel.mode.value === "docked-left") return `left-3 top-3 ${props.bottomClass}`;
21
+ if (props.panel.mode.value === "docked-right") return `right-3 top-3 ${props.bottomClass}`;
22
+ return "max-h-[70vh]";
23
+ });
24
+ const pillClass = computed(() => {
25
+ if (props.panel.mode.value === "docked-left") return "left-3 top-3";
26
+ if (props.panel.mode.value === "docked-right") return "right-3 top-3";
27
+ return "";
28
+ });
29
+ </script>
30
+
31
+ <template>
32
+ <Transition
33
+ enter-active-class="transition duration-150 ease-out"
34
+ enter-from-class="opacity-0 scale-95"
35
+ enter-to-class="opacity-100 scale-100"
36
+ leave-active-class="transition duration-100 ease-in"
37
+ leave-from-class="opacity-100 scale-100"
38
+ leave-to-class="opacity-0 scale-95"
39
+ >
40
+ <div
41
+ v-if="panel.collapsed.value"
42
+ key="pill"
43
+ class="absolute z-10"
44
+ :class="pillClass"
45
+ :style="panel.mode.value === 'floating' ? panel.floatingStyle.value : void 0"
46
+ >
47
+ <!-- Two-layer frame: outer glass ring, inner white pane — so the pill is
48
+ recognisably the same object as the panel it came from. -->
49
+ <div class="rounded-lg border border-gray-400/10 outline-1 outline-white/30 backdrop-blur-sm">
50
+ <button
51
+ type="button"
52
+ class="flex h-9 cursor-pointer items-center gap-1.5 rounded-lg bg-white px-3 text-sm font-medium transition hover:bg-muted/75"
53
+ :title="labels.expand"
54
+ @click="panel.setCollapsed(false)"
55
+ >
56
+ <slot name="pill-icon" />
57
+ {{ title }}
58
+ </button>
59
+ </div>
60
+ </div>
61
+
62
+ <div
63
+ v-else
64
+ key="panel"
65
+ ref="shell"
66
+ class="absolute z-10 flex flex-col rounded-lg border border-gray-400/10 outline-1 outline-white/30 backdrop-blur-sm"
67
+ :class="[shellClass, widthClass]"
68
+ :style="panel.mode.value === 'floating' ? panel.floatingStyle.value : void 0"
69
+ >
70
+ <div class="relative flex min-h-0 flex-1 flex-col overflow-hidden rounded-lg border border-white bg-white">
71
+ <div
72
+ class="flex shrink-0 touch-none select-none items-center gap-1 border-b border-black/[0.06] bg-white px-2 py-1.5"
73
+ :class="panel.dragging.value ? 'cursor-grabbing' : 'cursor-grab'"
74
+ @pointerdown="panel.startDrag($event, shell)"
75
+ >
76
+ <HamburgerMenu :size="14" class="shrink-0 text-muted-foreground/60" />
77
+ <span class="min-w-0 flex-1 truncate text-sm font-medium">{{ title }}</span>
78
+
79
+ <DropdownMenu>
80
+ <DropdownMenuTrigger as-child>
81
+ <button
82
+ type="button"
83
+ class="flex size-6 shrink-0 items-center justify-center rounded-md text-muted-foreground transition hover:bg-muted/50 hover:text-foreground"
84
+ :title="labels.placement"
85
+ >
86
+ <MenuDots :size="14" />
87
+ </button>
88
+ </DropdownMenuTrigger>
89
+ <DropdownMenuContent align="end" class="w-36 rounded-lg border border-black/[0.06] bg-white p-1 shadow-lg">
90
+ <DropdownMenuItem class="rounded-md text-sm" @select="panel.dock('left')">{{ labels.dockLeft }}</DropdownMenuItem>
91
+ <DropdownMenuItem class="rounded-md text-sm" @select="panel.dock('right')">{{ labels.dockRight }}</DropdownMenuItem>
92
+ <DropdownMenuItem class="rounded-md text-sm" @select="panel.floatHere(shell)">{{ labels.floatHere }}</DropdownMenuItem>
93
+ <DropdownMenuSeparator class="my-1 h-px bg-black/[0.06]" />
94
+ <DropdownMenuItem class="rounded-md text-sm" @select="panel.setCollapsed(true)">{{ labels.collapse }}</DropdownMenuItem>
95
+ </DropdownMenuContent>
96
+ </DropdownMenu>
97
+
98
+ <button
99
+ type="button"
100
+ class="flex size-6 shrink-0 items-center justify-center rounded-md text-muted-foreground transition hover:bg-muted/50 hover:text-foreground"
101
+ :title="labels.collapse"
102
+ @click="panel.setCollapsed(true)"
103
+ >
104
+ <Minimize :size="14" />
105
+ </button>
106
+ </div>
107
+
108
+ <div class="flex min-h-0 flex-1 flex-col overflow-hidden">
109
+ <slot />
110
+ </div>
111
+
112
+ <div v-if="$slots.footer" class="shrink-0 border-t border-black/[0.06] px-3 py-2 text-xs text-muted-foreground">
113
+ <slot name="footer" />
114
+ </div>
115
+ </div>
116
+ </div>
117
+ </Transition>
118
+ </template>
@@ -0,0 +1,55 @@
1
+ type Mode = 'docked-left' | 'docked-right' | 'floating';
2
+ type __VLS_Props = {
3
+ /** What `useFloatingPanel` returned, passed through whole. */
4
+ panel: {
5
+ mode: {
6
+ value: Mode;
7
+ };
8
+ collapsed: {
9
+ value: boolean;
10
+ };
11
+ floatingStyle: {
12
+ value: Record<string, string>;
13
+ };
14
+ dragging: {
15
+ value: boolean;
16
+ };
17
+ startDrag: (ev: PointerEvent, el: HTMLElement | null) => void;
18
+ dock: (side: 'left' | 'right') => void;
19
+ floatHere: (el: HTMLElement | null) => void;
20
+ setCollapsed: (v: boolean) => void;
21
+ };
22
+ title: string;
23
+ /** Room the docked panel leaves at the bottom, for a floating control bar. */
24
+ bottomClass?: string;
25
+ /** Tailwind width, matching the `width` given to the composable. */
26
+ widthClass?: string;
27
+ /** Labels, so the package carries no copy of its own. */
28
+ labels: {
29
+ dockLeft: string;
30
+ dockRight: string;
31
+ floatHere: string;
32
+ collapse: string;
33
+ expand: string;
34
+ placement: string;
35
+ };
36
+ };
37
+ declare var __VLS_5: {}, __VLS_67: {}, __VLS_69: {};
38
+ type __VLS_Slots = {} & {
39
+ 'pill-icon'?: (props: typeof __VLS_5) => any;
40
+ } & {
41
+ default?: (props: typeof __VLS_67) => any;
42
+ } & {
43
+ footer?: (props: typeof __VLS_69) => any;
44
+ };
45
+ declare const __VLS_component: import("vue").DefineComponent<__VLS_Props, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<__VLS_Props> & Readonly<{}>, {
46
+ bottomClass: string;
47
+ widthClass: string;
48
+ }, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
49
+ declare const _default: __VLS_WithSlots<typeof __VLS_component, __VLS_Slots>;
50
+ export default _default;
51
+ type __VLS_WithSlots<T, S> = T & {
52
+ new (): {
53
+ $slots: S;
54
+ };
55
+ };
@@ -0,0 +1,69 @@
1
+ import { type Ref } from 'vue';
2
+ export type FloatingPanelDock = 'left' | 'right';
3
+ export type FloatingPanelMode = 'docked-left' | 'docked-right' | 'floating';
4
+ export interface FloatingPanelOptions {
5
+ /**
6
+ * Where this panel's placement is remembered. One key per panel — two panels
7
+ * sharing a key would drag each other around.
8
+ */
9
+ storageKey: string;
10
+ /** Which side it starts on, before anybody has moved it. */
11
+ initialMode?: FloatingPanelMode;
12
+ /** The panel's width, in px. Used to clamp it inside the canvas. */
13
+ width?: number;
14
+ /** Room to keep clear at the bottom, for a floating control bar. */
15
+ bottomClearance?: number;
16
+ }
17
+ /**
18
+ * A panel that can be docked to either side of a canvas, floated anywhere on
19
+ * it, or collapsed into a pill — and dragged between those states by its own
20
+ * header.
21
+ *
22
+ * Lifted from the integration studio's workflow editor, where this shape was
23
+ * worked out and where it has been in daily use: `usePaletteLayout`, with the
24
+ * storage key and the dimensions made arguments so a second editor can have
25
+ * its own panels rather than fighting over one remembered position.
26
+ *
27
+ * ## Why pointer events rather than HTML5 drag
28
+ *
29
+ * The panel's CONTENTS are draggable — the workflow editor drags nodes out of
30
+ * its palette onto the canvas, and the mail builder drags blocks. HTML5 drag
31
+ * on the shell would swallow those gestures. The header moves the panel with
32
+ * plain pointer events, so the two never compete: grab the bar, or grab a row,
33
+ * and each does the one thing.
34
+ *
35
+ * ## The magnet
36
+ *
37
+ * Coming within `DOCK_ZONE` of the left or right edge while dragging arms
38
+ * `dockPreview`, which the host renders as a ghost of the dock slot. Releasing
39
+ * inside the zone snaps into that dock; anywhere else leaves the panel where it
40
+ * was dropped. Docking is a state, not a position, so a docked panel keeps its
41
+ * side when the window resizes.
42
+ *
43
+ * Placement is persisted (guarded — private mode and quota must not throw) and
44
+ * clamped to the canvas on restore, because the window may have shrunk since.
45
+ */
46
+ export declare function useFloatingPanel(canvasRef: Ref<HTMLElement | null>, options: FloatingPanelOptions): {
47
+ mode: Ref<FloatingPanelMode, FloatingPanelMode>;
48
+ collapsed: Ref<boolean, boolean>;
49
+ floating: Ref<{
50
+ x: number;
51
+ y: number;
52
+ }, {
53
+ x: number;
54
+ y: number;
55
+ } | {
56
+ x: number;
57
+ y: number;
58
+ }>;
59
+ floatingStyle: import("vue").ComputedRef<{
60
+ left: string;
61
+ top: string;
62
+ }>;
63
+ dragging: Ref<boolean, boolean>;
64
+ dockPreview: Ref<FloatingPanelDock | null, FloatingPanelDock | null>;
65
+ startDrag: (ev: PointerEvent, shellEl: HTMLElement | null) => void;
66
+ dock: (side: FloatingPanelDock) => void;
67
+ floatHere: (shellEl: HTMLElement | null) => void;
68
+ setCollapsed: (v: boolean) => void;
69
+ };
@@ -0,0 +1,112 @@
1
+ import { computed, onBeforeUnmount, onMounted, ref } from "vue";
2
+ const MARGIN = 12;
3
+ const DOCK_ZONE = 48;
4
+ function clampNum(v, lo, hi) {
5
+ return Math.min(hi, Math.max(lo, v));
6
+ }
7
+ export function useFloatingPanel(canvasRef, options) {
8
+ const width = options.width ?? 320;
9
+ const bottomClearance = options.bottomClearance ?? 64;
10
+ const mode = ref(options.initialMode ?? "docked-left");
11
+ const collapsed = ref(false);
12
+ const floating = ref({ x: MARGIN, y: MARGIN });
13
+ const dragging = ref(false);
14
+ const dockPreview = ref(null);
15
+ function persist() {
16
+ try {
17
+ const payload = { mode: mode.value, floating: floating.value, collapsed: collapsed.value };
18
+ localStorage.setItem(options.storageKey, JSON.stringify(payload));
19
+ } catch {
20
+ }
21
+ }
22
+ function restore() {
23
+ try {
24
+ const raw = localStorage.getItem(options.storageKey);
25
+ if (!raw) return;
26
+ const saved = JSON.parse(raw);
27
+ if (saved.mode === "docked-left" || saved.mode === "docked-right" || saved.mode === "floating") {
28
+ mode.value = saved.mode;
29
+ }
30
+ if (typeof saved.floating?.x === "number" && typeof saved.floating?.y === "number") {
31
+ floating.value = { x: saved.floating.x, y: saved.floating.y };
32
+ }
33
+ collapsed.value = saved.collapsed === true;
34
+ clampFloating();
35
+ } catch {
36
+ }
37
+ }
38
+ function clampFloating() {
39
+ const rect = canvasRef.value?.getBoundingClientRect();
40
+ if (!rect || rect.width === 0) return;
41
+ floating.value = {
42
+ x: clampNum(floating.value.x, MARGIN, Math.max(MARGIN, rect.width - width - MARGIN)),
43
+ y: clampNum(floating.value.y, MARGIN, Math.max(MARGIN, rect.height - bottomClearance - 160))
44
+ };
45
+ }
46
+ onMounted(restore);
47
+ let grabDX = 0;
48
+ let grabDY = 0;
49
+ let panelHeight = 0;
50
+ function startDrag(ev, shellEl) {
51
+ if (ev.button !== 0 || dragging.value) return;
52
+ if (ev.target?.closest("button")) return;
53
+ const canvas = canvasRef.value;
54
+ if (!canvas || !shellEl) return;
55
+ const cRect = canvas.getBoundingClientRect();
56
+ const pRect = shellEl.getBoundingClientRect();
57
+ floating.value = { x: pRect.left - cRect.left, y: pRect.top - cRect.top };
58
+ mode.value = "floating";
59
+ grabDX = ev.clientX - pRect.left;
60
+ grabDY = ev.clientY - pRect.top;
61
+ panelHeight = Math.min(pRect.height, window.innerHeight * 0.7);
62
+ dragging.value = true;
63
+ ev.preventDefault();
64
+ window.addEventListener("pointermove", onDragMove);
65
+ window.addEventListener("pointerup", onDragEnd);
66
+ window.addEventListener("pointercancel", onDragEnd);
67
+ }
68
+ function onDragMove(ev) {
69
+ const rect = canvasRef.value?.getBoundingClientRect();
70
+ if (!rect) return;
71
+ const x = clampNum(ev.clientX - rect.left - grabDX, MARGIN, Math.max(MARGIN, rect.width - width - MARGIN));
72
+ const y = clampNum(ev.clientY - rect.top - grabDY, MARGIN, Math.max(MARGIN, rect.height - bottomClearance - panelHeight));
73
+ floating.value = { x, y };
74
+ dockPreview.value = x <= DOCK_ZONE ? "left" : x + width >= rect.width - DOCK_ZONE ? "right" : null;
75
+ }
76
+ function stopListeners() {
77
+ window.removeEventListener("pointermove", onDragMove);
78
+ window.removeEventListener("pointerup", onDragEnd);
79
+ window.removeEventListener("pointercancel", onDragEnd);
80
+ }
81
+ function onDragEnd() {
82
+ if (dockPreview.value) mode.value = dockPreview.value === "left" ? "docked-left" : "docked-right";
83
+ dockPreview.value = null;
84
+ dragging.value = false;
85
+ stopListeners();
86
+ persist();
87
+ }
88
+ onBeforeUnmount(stopListeners);
89
+ function dock(side) {
90
+ mode.value = side === "left" ? "docked-left" : "docked-right";
91
+ persist();
92
+ }
93
+ function floatHere(shellEl) {
94
+ if (mode.value !== "floating") {
95
+ const cRect = canvasRef.value?.getBoundingClientRect();
96
+ const pRect = shellEl?.getBoundingClientRect();
97
+ if (cRect && pRect) floating.value = { x: pRect.left - cRect.left, y: pRect.top - cRect.top };
98
+ mode.value = "floating";
99
+ clampFloating();
100
+ }
101
+ persist();
102
+ }
103
+ function setCollapsed(v) {
104
+ collapsed.value = v;
105
+ persist();
106
+ }
107
+ const floatingStyle = computed(() => ({
108
+ left: `${floating.value.x}px`,
109
+ top: `${floating.value.y}px`
110
+ }));
111
+ return { mode, collapsed, floating, floatingStyle, dragging, dockPreview, startDrag, dock, floatHere, setCollapsed };
112
+ }
@@ -21,12 +21,27 @@ import type { MaybeRefOrGetter } from '@revenexx/talkback-js/vue';
21
21
  * with the caller's scope — which is also what gives the delegate's own
22
22
  * `onScopeDispose(release)` a scope to bind to.
23
23
  *
24
- * 3. **`channel` is actually reactive.** Up to and including
25
- * `@revenexx/talkback-js@0.1.3`, `ChannelSubscription.channel` is
26
- * `{ value: null }` cast to a `Ref` — `ref` is never imported, so `isRef()`
27
- * is false and a template binding never updates. Fixed upstream in
28
- * talkback-js#11 but unreleased; this wrapper can be simplified to a
29
- * pass-through once that ships.
24
+ * 3. **An attach that cannot name a channel yet is retried, as long as what is
25
+ * missing is reactive.** `useChannel` attaches EAGERLY and only then installs
26
+ * the watch that would re-attach, and the eager call has no `try` around
27
+ * `resolveName()` while the watch's source does. So a name the grammar rejects
28
+ * at that moment — overwhelmingly a tenant that has not been loaded yet, since
29
+ * it is positionally first in every channel name — throws straight out of the
30
+ * composable, before anything exists that could try again. Swallowing that (as
31
+ * this wrapper once did) turns "not yet" into "never": the subscription is lost
32
+ * for the life of the tab while the socket connects perfectly well, which is
33
+ * the shape realtime silently missing takes. The attach therefore runs inside
34
+ * an effect, so the reads that got as far as throwing are tracked and the
35
+ * attempt is repeated when one of them changes.
36
+ *
37
+ * THE QUALIFIER IS LOAD-BEARING, and `useTalkbackUser` is where it bites:
38
+ * `userChannel(tenant, '')` throws too, but the subject is a plain `let userId`
39
+ * in `createPlatformTalkback` — cached from the last mint so it can answer
40
+ * synchronously — so the tenant is the only tracked read and a subject that
41
+ * fills in later re-runs nothing. That is not new (the throw was swallowed
42
+ * before as well, and there was no channel either way), and a null token is the
43
+ * ordinary state for up to fifteen seconds per `usePlatformTalkback`. Making
44
+ * `userId` reactive is what would close it.
30
45
  *
31
46
  * With realtime off (`talkbackHost` empty) every one of these is a no-op that
32
47
  * reports a null channel, so a call site needs no `if (enabled)`.