sh3-core 0.17.2 → 0.19.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.
- package/dist/Sh3.svelte +59 -4
- package/dist/actions/CommandPalette.svelte +1 -2
- package/dist/actions/listeners.js +12 -1
- package/dist/api.d.ts +4 -0
- package/dist/app/store/storeShard.svelte.js +1 -21
- package/dist/app/store/version.d.ts +11 -0
- package/dist/app/store/version.js +39 -0
- package/dist/app/store/version.test.d.ts +1 -0
- package/dist/app/store/version.test.js +44 -0
- package/dist/apps/lifecycle.d.ts +6 -0
- package/dist/apps/lifecycle.js +5 -2
- package/dist/apps/lifecycle.test.js +30 -0
- package/dist/apps/types.d.ts +12 -0
- package/dist/assets/iconIds.generated.d.ts +1 -1
- package/dist/assets/iconIds.generated.js +5 -0
- package/dist/assets/icons.svg +31 -0
- package/dist/auth/auth.svelte.js +18 -8
- package/dist/auth/types.d.ts +6 -0
- package/dist/chrome/CompactChrome.svelte +54 -20
- package/dist/chrome/CompactChrome.svelte.test.js +112 -5
- package/dist/createShell.d.ts +9 -0
- package/dist/createShell.js +20 -7
- package/dist/createShell.remoteAuth.test.d.ts +1 -0
- package/dist/createShell.remoteAuth.test.js +71 -0
- package/dist/documents/http-backend.js +12 -11
- package/dist/env/client.js +11 -5
- package/dist/files/types.d.ts +106 -0
- package/dist/files/types.js +1 -0
- package/dist/gestures/gestureRegistry.d.ts +6 -0
- package/dist/gestures/gestureRegistry.js +190 -0
- package/dist/gestures/gestureRegistry.test.d.ts +1 -0
- package/dist/gestures/gestureRegistry.test.js +120 -0
- package/dist/gestures/index.d.ts +6 -0
- package/dist/gestures/index.js +12 -0
- package/dist/gestures/pointerClaim.d.ts +7 -0
- package/dist/gestures/pointerClaim.js +36 -0
- package/dist/gestures/pointerClaim.test.d.ts +1 -0
- package/dist/gestures/pointerClaim.test.js +64 -0
- package/dist/gestures/types.d.ts +83 -0
- package/dist/gestures/types.js +1 -0
- package/dist/host-entry.d.ts +1 -0
- package/dist/host-entry.js +1 -0
- package/dist/layout/LayoutRenderer.browser.test.js +15 -3
- package/dist/layout/LayoutRenderer.svelte +16 -3
- package/dist/layout/LayoutRenderer.svelte.d.ts +2 -0
- package/dist/layout/__screenshots__/LayoutRenderer.browser.test.ts/LayoutRenderer-browser---E-3-splitter-drag-updates-split-sizes-when-the-splitter-handle-is-dragged-1.png +0 -0
- package/dist/layout/__screenshots__/LayoutRenderer.browser.test.ts/LayoutRenderer-browser---E-5-splitter-collapse-toggle-toggles-collapsed-i--on-double-click-1.png +0 -0
- package/dist/layout/__screenshots__/LayoutRenderer.browser.test.ts/LayoutRenderer-browser---E-6-fixed-slots-hides-the-collapse-widget-on-a-fixed-pane-but-keeps-it-on-panes-with-a-non-fixed-neighbor-1.png +0 -0
- package/dist/layout/compact/CarouselTabs.svelte +362 -0
- package/dist/layout/compact/CarouselTabs.svelte.d.ts +10 -0
- package/dist/layout/compact/CarouselTabs.svelte.test.d.ts +1 -0
- package/dist/layout/compact/CarouselTabs.svelte.test.js +300 -0
- package/dist/layout/compact/CompactRenderer.svelte +1 -1
- package/dist/layout/compact/CompactRenderer.svelte.test.js +49 -0
- package/dist/layout/compact/derive.js +2 -0
- package/dist/layout/compact/derive.test.js +37 -0
- package/dist/layout/compact/enrichCarousels.d.ts +8 -0
- package/dist/layout/compact/enrichCarousels.js +44 -0
- package/dist/layout/compact/enrichCarousels.test.d.ts +1 -0
- package/dist/layout/compact/enrichCarousels.test.js +88 -0
- package/dist/layout/compact/types.d.ts +3 -0
- package/dist/layout/drag.svelte.js +13 -0
- package/dist/layout/store.schemaVersion.test.js +2 -2
- package/dist/layout/types.d.ts +9 -1
- package/dist/layout/types.js +1 -1
- package/dist/layout/types.test.d.ts +1 -0
- package/dist/layout/types.test.js +26 -0
- package/dist/overlays/ModalFrame.svelte +3 -1
- package/dist/overlays/ModalFrame.svelte.d.ts +1 -0
- package/dist/overlays/floatDismiss.js +5 -0
- package/dist/overlays/focusTrap.d.ts +11 -1
- package/dist/overlays/focusTrap.js +11 -9
- package/dist/overlays/modal.js +1 -0
- package/dist/overlays/popup.js +4 -0
- package/dist/overlays/types.d.ts +9 -0
- package/dist/primitives/Button.svelte +18 -0
- package/dist/primitives/Button.svelte.d.ts +6 -0
- package/dist/primitives/ResizableSplitter.svelte +71 -11
- package/dist/primitives/ResizableSplitter.svelte.d.ts +8 -0
- package/dist/primitives/ResizableSplitter.svelte.test.d.ts +1 -0
- package/dist/primitives/ResizableSplitter.svelte.test.js +74 -0
- package/dist/server-shard/types.d.ts +2 -1
- package/dist/shards/activate.svelte.js +16 -0
- package/dist/shards/ctx-fetch.test.d.ts +1 -0
- package/dist/shards/ctx-fetch.test.js +136 -0
- package/dist/shards/types.d.ts +29 -0
- package/dist/transport/apiFetch.d.ts +1 -0
- package/dist/transport/apiFetch.js +65 -0
- package/dist/transport/apiFetch.test.d.ts +1 -0
- package/dist/transport/apiFetch.test.js +37 -0
- package/dist/transport/authToken.d.ts +2 -0
- package/dist/transport/authToken.js +53 -0
- package/dist/transport/authToken.test.d.ts +1 -0
- package/dist/transport/authToken.test.js +33 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { LAYOUT_SCHEMA_VERSION } from './types';
|
|
3
|
+
describe('TabsNode.wrap', () => {
|
|
4
|
+
it('accepts the optional wrap field', () => {
|
|
5
|
+
const tabs = {
|
|
6
|
+
type: 'tabs',
|
|
7
|
+
tabs: [{ slotId: 's1', viewId: null, label: 'A' }],
|
|
8
|
+
activeTab: 0,
|
|
9
|
+
wrap: true,
|
|
10
|
+
};
|
|
11
|
+
expect(tabs.wrap).toBe(true);
|
|
12
|
+
});
|
|
13
|
+
it('defaults to undefined (consumers treat as false)', () => {
|
|
14
|
+
const tabs = {
|
|
15
|
+
type: 'tabs',
|
|
16
|
+
tabs: [{ slotId: 's1', viewId: null, label: 'A' }],
|
|
17
|
+
activeTab: 0,
|
|
18
|
+
};
|
|
19
|
+
expect(tabs.wrap).toBeUndefined();
|
|
20
|
+
});
|
|
21
|
+
});
|
|
22
|
+
describe('LAYOUT_SCHEMA_VERSION', () => {
|
|
23
|
+
it('is 6 (bumped for TabsNode.wrap)', () => {
|
|
24
|
+
expect(LAYOUT_SCHEMA_VERSION).toBe(6);
|
|
25
|
+
});
|
|
26
|
+
});
|
|
@@ -35,19 +35,21 @@
|
|
|
35
35
|
close,
|
|
36
36
|
boxStyle,
|
|
37
37
|
onBackdropClick,
|
|
38
|
+
initialFocus = true,
|
|
38
39
|
}: {
|
|
39
40
|
Content: Component<Record<string, unknown>>;
|
|
40
41
|
contentProps: Record<string, unknown>;
|
|
41
42
|
close: () => void;
|
|
42
43
|
boxStyle?: string;
|
|
43
44
|
onBackdropClick?: () => void;
|
|
45
|
+
initialFocus?: boolean;
|
|
44
46
|
} = $props();
|
|
45
47
|
|
|
46
48
|
let box: HTMLDivElement;
|
|
47
49
|
|
|
48
50
|
$effect(() => {
|
|
49
51
|
if (!box) return;
|
|
50
|
-
return createFocusTrap(box);
|
|
52
|
+
return createFocusTrap(box, { initialFocus });
|
|
51
53
|
});
|
|
52
54
|
|
|
53
55
|
function handleFrameClick(ev: MouseEvent): void {
|
|
@@ -12,11 +12,16 @@
|
|
|
12
12
|
* own action handlers fire on `click`/`pointerup` and are unaffected.
|
|
13
13
|
*/
|
|
14
14
|
import { floatManager } from './float';
|
|
15
|
+
import { getOwner } from '../gestures';
|
|
15
16
|
const registry = new Map();
|
|
16
17
|
let listenerAttached = false;
|
|
17
18
|
function onDocumentPointerDown(event) {
|
|
18
19
|
if (registry.size === 0)
|
|
19
20
|
return;
|
|
21
|
+
// Skip dismiss if a gesture has claimed this pointer — a swipe or drag
|
|
22
|
+
// is in progress and this pointerdown is not a real outside-click.
|
|
23
|
+
if (getOwner(event.pointerId))
|
|
24
|
+
return;
|
|
20
25
|
const target = event.target;
|
|
21
26
|
if (!target)
|
|
22
27
|
return;
|
|
@@ -1 +1,11 @@
|
|
|
1
|
-
export
|
|
1
|
+
export interface FocusTrapOptions {
|
|
2
|
+
/**
|
|
3
|
+
* When false, skip moving focus into the container on install. Tab
|
|
4
|
+
* cycling and previous-focus restoration still work — this only
|
|
5
|
+
* suppresses the initial auto-focus. Used by the command palette on
|
|
6
|
+
* touch-only devices to keep the on-screen keyboard from popping
|
|
7
|
+
* up unprompted.
|
|
8
|
+
*/
|
|
9
|
+
initialFocus?: boolean;
|
|
10
|
+
}
|
|
11
|
+
export declare function createFocusTrap(container: HTMLElement, options?: FocusTrapOptions): () => void;
|
|
@@ -20,7 +20,7 @@ const FOCUSABLE_SELECTOR = [
|
|
|
20
20
|
'textarea:not([disabled])',
|
|
21
21
|
'[tabindex]:not([tabindex="-1"])',
|
|
22
22
|
].join(',');
|
|
23
|
-
export function createFocusTrap(container) {
|
|
23
|
+
export function createFocusTrap(container, options = {}) {
|
|
24
24
|
const previouslyFocused = document.activeElement;
|
|
25
25
|
function getFocusables() {
|
|
26
26
|
return Array.from(container.querySelectorAll(FOCUSABLE_SELECTOR));
|
|
@@ -47,14 +47,16 @@ export function createFocusTrap(container) {
|
|
|
47
47
|
}
|
|
48
48
|
}
|
|
49
49
|
container.addEventListener('keydown', onKeydown);
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
50
|
+
if (options.initialFocus !== false) {
|
|
51
|
+
// Defer initial focus to the next microtask so the container contents
|
|
52
|
+
// (which may still be rendering if createFocusTrap was called mid-mount)
|
|
53
|
+
// have a chance to appear in the DOM.
|
|
54
|
+
queueMicrotask(() => {
|
|
55
|
+
var _a;
|
|
56
|
+
const focusables = getFocusables();
|
|
57
|
+
((_a = focusables[0]) !== null && _a !== void 0 ? _a : container).focus();
|
|
58
|
+
});
|
|
59
|
+
}
|
|
58
60
|
return () => {
|
|
59
61
|
container.removeEventListener('keydown', onKeydown);
|
|
60
62
|
if (previouslyFocused && document.contains(previouslyFocused)) {
|
package/dist/overlays/modal.js
CHANGED
|
@@ -132,6 +132,7 @@ function openModal(Content, props, options) {
|
|
|
132
132
|
close: handle.close,
|
|
133
133
|
boxStyle: options === null || options === void 0 ? void 0 : options.boxStyle,
|
|
134
134
|
onBackdropClick: (options === null || options === void 0 ? void 0 : options.dismissOnBackdrop) ? handle.close : undefined,
|
|
135
|
+
initialFocus: options === null || options === void 0 ? void 0 : options.initialFocus,
|
|
135
136
|
},
|
|
136
137
|
});
|
|
137
138
|
entry.host = host;
|
package/dist/overlays/popup.js
CHANGED
|
@@ -26,6 +26,7 @@
|
|
|
26
26
|
* - close() is idempotent.
|
|
27
27
|
*/
|
|
28
28
|
import { mount, unmount } from 'svelte';
|
|
29
|
+
import { getOwner } from '../gestures';
|
|
29
30
|
import PopupFrame from './PopupFrame.svelte';
|
|
30
31
|
import { getLayerRoot } from './roots';
|
|
31
32
|
import { registerDismissable } from '../navigation/back-stack';
|
|
@@ -44,6 +45,9 @@ let current = null;
|
|
|
44
45
|
function onDocumentPointerDown(e) {
|
|
45
46
|
if (!current)
|
|
46
47
|
return;
|
|
48
|
+
// Skip dismiss if a gesture owns this pointer.
|
|
49
|
+
if (getOwner(e.pointerId))
|
|
50
|
+
return;
|
|
47
51
|
const target = e.target;
|
|
48
52
|
if (target && current.host.contains(target))
|
|
49
53
|
return;
|
package/dist/overlays/types.d.ts
CHANGED
|
@@ -44,4 +44,13 @@ export interface ModalOptions {
|
|
|
44
44
|
* expected dismissal gesture.
|
|
45
45
|
*/
|
|
46
46
|
dismissOnBackdrop?: boolean;
|
|
47
|
+
/**
|
|
48
|
+
* When false, suppress the focus trap's initial auto-focus on the
|
|
49
|
+
* modal's first focusable descendant. Tab cycling within the trap
|
|
50
|
+
* still works, and previous-focus is still restored on close.
|
|
51
|
+
* Default true. Opt-out is for cases where focusing an input pops
|
|
52
|
+
* an on-screen keyboard the user didn't ask for (e.g. command
|
|
53
|
+
* palette on touch-only devices).
|
|
54
|
+
*/
|
|
55
|
+
initialFocus?: boolean;
|
|
47
56
|
}
|
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
sprite,
|
|
22
22
|
disabled = false,
|
|
23
23
|
loading,
|
|
24
|
+
pressed,
|
|
24
25
|
type = 'button',
|
|
25
26
|
title,
|
|
26
27
|
ariaLabel,
|
|
@@ -35,6 +36,12 @@
|
|
|
35
36
|
disabled?: boolean;
|
|
36
37
|
/** Controlled pending state. When true, spinner + disabled + aria-busy. */
|
|
37
38
|
loading?: boolean;
|
|
39
|
+
/**
|
|
40
|
+
* Toggle state for toolbar/action buttons. When provided, sets
|
|
41
|
+
* aria-pressed and applies a pressed visual treatment. Orthogonal
|
|
42
|
+
* to `variant` — works with any variant.
|
|
43
|
+
*/
|
|
44
|
+
pressed?: boolean;
|
|
38
45
|
type?: 'button' | 'submit' | 'reset';
|
|
39
46
|
title?: string;
|
|
40
47
|
ariaLabel?: string;
|
|
@@ -77,8 +84,10 @@
|
|
|
77
84
|
{type}
|
|
78
85
|
class="sh3-btn sh3-btn--{variant}"
|
|
79
86
|
class:sh3-btn--icon-only={iconOnly}
|
|
87
|
+
class:sh3-btn--pressed={pressed}
|
|
80
88
|
disabled={disabled || pending}
|
|
81
89
|
aria-busy={pending || undefined}
|
|
90
|
+
aria-pressed={pressed ?? undefined}
|
|
82
91
|
{title}
|
|
83
92
|
aria-label={ariaLabel ?? (iconOnly ? title : undefined)}
|
|
84
93
|
onclick={handleClick}
|
|
@@ -134,6 +143,15 @@
|
|
|
134
143
|
cursor: not-allowed;
|
|
135
144
|
}
|
|
136
145
|
|
|
146
|
+
/* pressed=true: inset shadow + slight dimming to signal toggle-on state */
|
|
147
|
+
.sh3-btn--pressed {
|
|
148
|
+
box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.35);
|
|
149
|
+
filter: brightness(0.88);
|
|
150
|
+
}
|
|
151
|
+
.sh3-btn--pressed:hover:not(:disabled) {
|
|
152
|
+
filter: brightness(0.96);
|
|
153
|
+
}
|
|
154
|
+
|
|
137
155
|
.sh3-btn--alert {
|
|
138
156
|
background: var(--sh3-error);
|
|
139
157
|
color: var(--sh3-fg-on-error);
|
|
@@ -9,6 +9,12 @@ type $$ComponentProps = {
|
|
|
9
9
|
disabled?: boolean;
|
|
10
10
|
/** Controlled pending state. When true, spinner + disabled + aria-busy. */
|
|
11
11
|
loading?: boolean;
|
|
12
|
+
/**
|
|
13
|
+
* Toggle state for toolbar/action buttons. When provided, sets
|
|
14
|
+
* aria-pressed and applies a pressed visual treatment. Orthogonal
|
|
15
|
+
* to `variant` — works with any variant.
|
|
16
|
+
*/
|
|
17
|
+
pressed?: boolean;
|
|
12
18
|
type?: 'button' | 'submit' | 'reset';
|
|
13
19
|
title?: string;
|
|
14
20
|
ariaLabel?: string;
|
|
@@ -19,6 +19,8 @@
|
|
|
19
19
|
|
|
20
20
|
import type { Snippet } from 'svelte';
|
|
21
21
|
import type { SizeMode, SplitDirection } from '../layout/types';
|
|
22
|
+
import { claim, revoke } from '../gestures/pointerClaim';
|
|
23
|
+
import { ancestorCount } from '../gestures';
|
|
22
24
|
|
|
23
25
|
const MIN_PX = 40;
|
|
24
26
|
const COLLAPSED_PX = 28;
|
|
@@ -33,6 +35,7 @@
|
|
|
33
35
|
pane,
|
|
34
36
|
onResize,
|
|
35
37
|
onCollapseToggle,
|
|
38
|
+
compact = false,
|
|
36
39
|
}: {
|
|
37
40
|
direction: SplitDirection;
|
|
38
41
|
/**
|
|
@@ -69,6 +72,14 @@
|
|
|
69
72
|
onResize?: (index: number, value: number) => void;
|
|
70
73
|
/** Called when a collapsed pane's header is clicked to toggle. */
|
|
71
74
|
onCollapseToggle?: (index: number, collapsed: boolean) => void;
|
|
75
|
+
/**
|
|
76
|
+
* Compact-mode rendering: drops the per-pane collapse arrows (the
|
|
77
|
+
* 16px side rail eats too much room on a phone) and freezes the
|
|
78
|
+
* resize handles into thin visual separators with no drag gesture.
|
|
79
|
+
* Existing collapsed panes stay visually collapsed but lose the
|
|
80
|
+
* expand affordance until the viewport flips back.
|
|
81
|
+
*/
|
|
82
|
+
compact?: boolean;
|
|
72
83
|
} = $props();
|
|
73
84
|
|
|
74
85
|
let container: HTMLDivElement;
|
|
@@ -103,12 +114,19 @@
|
|
|
103
114
|
};
|
|
104
115
|
|
|
105
116
|
let drag: DragState | null = $state(null);
|
|
117
|
+
let activeDragPointerId: number | null = null;
|
|
106
118
|
|
|
107
119
|
function beginDrag(e: PointerEvent, handleIndex: number) {
|
|
108
120
|
// Disable resize handles adjacent to collapsed or fixed panes.
|
|
121
|
+
if (compact) return;
|
|
109
122
|
if (isCollapsed(handleIndex) || isCollapsed(handleIndex + 1)) return;
|
|
110
123
|
if (isHandleFrozen(handleIndex)) return;
|
|
111
124
|
|
|
125
|
+
const depth = container ? ancestorCount(container) : 0;
|
|
126
|
+
const claimGranted = claim(e.pointerId, { ownerId: 'sh3:splitter', axis: 'xy', priority: 'normal', depth });
|
|
127
|
+
if (!claimGranted) return;
|
|
128
|
+
activeDragPointerId = e.pointerId;
|
|
129
|
+
|
|
112
130
|
e.preventDefault();
|
|
113
131
|
(e.target as HTMLElement).setPointerCapture(e.pointerId);
|
|
114
132
|
|
|
@@ -188,6 +206,10 @@
|
|
|
188
206
|
function endDrag(e: PointerEvent) {
|
|
189
207
|
if (!drag) return;
|
|
190
208
|
(e.target as HTMLElement).releasePointerCapture(e.pointerId);
|
|
209
|
+
if (activeDragPointerId !== null) {
|
|
210
|
+
revoke(activeDragPointerId, 'sh3:splitter');
|
|
211
|
+
activeDragPointerId = null;
|
|
212
|
+
}
|
|
191
213
|
drag = null;
|
|
192
214
|
}
|
|
193
215
|
</script>
|
|
@@ -196,6 +218,7 @@
|
|
|
196
218
|
class="splitter"
|
|
197
219
|
class:horizontal={direction === 'horizontal'}
|
|
198
220
|
class:vertical={direction === 'vertical'}
|
|
221
|
+
class:compact
|
|
199
222
|
bind:this={container}
|
|
200
223
|
>
|
|
201
224
|
{#each Array(count) as _, i (i)}
|
|
@@ -205,18 +228,33 @@
|
|
|
205
228
|
style="flex: {flexFor(i)};"
|
|
206
229
|
>
|
|
207
230
|
{#if isCollapsed(i)}
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
231
|
+
{#if compact}
|
|
232
|
+
<!--
|
|
233
|
+
Compact mode strands collapsed panes — the toggle isn't shown
|
|
234
|
+
so they can't be expanded until the viewport flips back to
|
|
235
|
+
desktop. Render an inert placeholder rather than a disabled
|
|
236
|
+
button so taps don't register at all.
|
|
237
|
+
-->
|
|
238
|
+
<div
|
|
239
|
+
class="collapse-header inert"
|
|
240
|
+
class:horizontal={direction === 'horizontal'}
|
|
241
|
+
class:vertical={direction === 'vertical'}
|
|
242
|
+
aria-hidden="true"
|
|
243
|
+
></div>
|
|
244
|
+
{:else}
|
|
245
|
+
<button
|
|
246
|
+
type="button"
|
|
247
|
+
class="collapse-header"
|
|
248
|
+
class:horizontal={direction === 'horizontal'}
|
|
249
|
+
class:vertical={direction === 'vertical'}
|
|
250
|
+
onclick={() => onCollapseToggle?.(i, false)}
|
|
251
|
+
aria-label="Expand pane"
|
|
252
|
+
>
|
|
253
|
+
<span class="collapse-icon">{direction === 'horizontal' ? '▸' : '▾'}</span>
|
|
254
|
+
</button>
|
|
255
|
+
{/if}
|
|
218
256
|
{:else}
|
|
219
|
-
{#if onCollapseToggle && canCollapse(i)}
|
|
257
|
+
{#if !compact && onCollapseToggle && canCollapse(i)}
|
|
220
258
|
<button
|
|
221
259
|
type="button"
|
|
222
260
|
class="collapse-header expanded"
|
|
@@ -367,4 +405,26 @@
|
|
|
367
405
|
}
|
|
368
406
|
.horizontal > .splitter-handle.frozen { width: 1px; }
|
|
369
407
|
.vertical > .splitter-handle.frozen { height: 1px; }
|
|
408
|
+
|
|
409
|
+
/*
|
|
410
|
+
* Compact mode: handles are pure visual separators — 1px, no cursor
|
|
411
|
+
* change, no pointer events (so taps fall through to the panes
|
|
412
|
+
* beneath). Mirrors how the .frozen variant degrades a fixed-pane
|
|
413
|
+
* boundary, but applies to every handle in the splitter.
|
|
414
|
+
*/
|
|
415
|
+
.splitter.compact > .splitter-handle {
|
|
416
|
+
pointer-events: none;
|
|
417
|
+
cursor: default;
|
|
418
|
+
opacity: 0.6;
|
|
419
|
+
}
|
|
420
|
+
.splitter.compact.horizontal > .splitter-handle { width: 1px; }
|
|
421
|
+
.splitter.compact.vertical > .splitter-handle { height: 1px; }
|
|
422
|
+
.splitter.compact > .splitter-handle:hover {
|
|
423
|
+
background: var(--sh3-border);
|
|
424
|
+
}
|
|
425
|
+
.collapse-header.inert {
|
|
426
|
+
pointer-events: none;
|
|
427
|
+
background: var(--sh3-bg-elevated);
|
|
428
|
+
flex: 0 0 auto;
|
|
429
|
+
}
|
|
370
430
|
</style>
|
|
@@ -36,6 +36,14 @@ type $$ComponentProps = {
|
|
|
36
36
|
onResize?: (index: number, value: number) => void;
|
|
37
37
|
/** Called when a collapsed pane's header is clicked to toggle. */
|
|
38
38
|
onCollapseToggle?: (index: number, collapsed: boolean) => void;
|
|
39
|
+
/**
|
|
40
|
+
* Compact-mode rendering: drops the per-pane collapse arrows (the
|
|
41
|
+
* 16px side rail eats too much room on a phone) and freezes the
|
|
42
|
+
* resize handles into thin visual separators with no drag gesture.
|
|
43
|
+
* Existing collapsed panes stay visually collapsed but lose the
|
|
44
|
+
* expand affordance until the viewport flips back.
|
|
45
|
+
*/
|
|
46
|
+
compact?: boolean;
|
|
39
47
|
};
|
|
40
48
|
declare const ResizableSplitter: import("svelte").Component<$$ComponentProps, {}, "">;
|
|
41
49
|
type ResizableSplitter = ReturnType<typeof ResizableSplitter>;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Compact-mode behavior pin for ResizableSplitter — when `compact=true`,
|
|
3
|
+
* the per-pane collapse arrow is not rendered, the root carries the
|
|
4
|
+
* `.compact` class (which CSS uses to thin handles + freeze pointer
|
|
5
|
+
* events), and pointer-down on a handle does not start a drag.
|
|
6
|
+
*
|
|
7
|
+
* Resize / drag / collapse interactions in desktop mode are covered by
|
|
8
|
+
* LayoutRenderer.browser.test.ts; this file pins the new gate only.
|
|
9
|
+
*/
|
|
10
|
+
import { describe, it, expect, afterEach } from 'vitest';
|
|
11
|
+
import { claim, __resetForTest as resetClaims } from '../gestures/pointerClaim';
|
|
12
|
+
import { mount, unmount, flushSync } from 'svelte';
|
|
13
|
+
import ResizableSplitter from './ResizableSplitter.svelte';
|
|
14
|
+
const SplitterAny = ResizableSplitter;
|
|
15
|
+
let mounted = null;
|
|
16
|
+
let host = null;
|
|
17
|
+
function renderHost(props) {
|
|
18
|
+
host = document.createElement('div');
|
|
19
|
+
host.style.cssText = 'position: relative; width: 600px; height: 400px;';
|
|
20
|
+
document.body.appendChild(host);
|
|
21
|
+
mounted = mount(SplitterAny, { target: host, props });
|
|
22
|
+
flushSync();
|
|
23
|
+
return host;
|
|
24
|
+
}
|
|
25
|
+
afterEach(() => {
|
|
26
|
+
if (mounted) {
|
|
27
|
+
unmount(mounted);
|
|
28
|
+
mounted = null;
|
|
29
|
+
}
|
|
30
|
+
if (host) {
|
|
31
|
+
host.remove();
|
|
32
|
+
host = null;
|
|
33
|
+
}
|
|
34
|
+
resetClaims();
|
|
35
|
+
});
|
|
36
|
+
describe('ResizableSplitter compact-mode (dom)', () => {
|
|
37
|
+
const baseProps = (extra = {}) => (Object.assign({ direction: 'horizontal', sizes: [0.5, 0.5], count: 2, pane: () => null, onResize: () => { }, onCollapseToggle: () => { } }, extra));
|
|
38
|
+
it('desktop mode: collapse-toggle buttons render for each pane', () => {
|
|
39
|
+
const el = renderHost(baseProps({ compact: false }));
|
|
40
|
+
expect(el.querySelectorAll('[data-testid^="collapse-toggle-"]').length).toBe(2);
|
|
41
|
+
expect(el.querySelector('.splitter').classList.contains('compact')).toBe(false);
|
|
42
|
+
});
|
|
43
|
+
it('compact mode: no collapse-toggle buttons render', () => {
|
|
44
|
+
const el = renderHost(baseProps({ compact: true }));
|
|
45
|
+
expect(el.querySelectorAll('[data-testid^="collapse-toggle-"]').length).toBe(0);
|
|
46
|
+
});
|
|
47
|
+
it('compact mode: root carries .compact class', () => {
|
|
48
|
+
const el = renderHost(baseProps({ compact: true }));
|
|
49
|
+
expect(el.querySelector('.splitter').classList.contains('compact')).toBe(true);
|
|
50
|
+
});
|
|
51
|
+
it('compact mode: pointerdown on a handle does not begin a drag (no .dragging class)', () => {
|
|
52
|
+
const el = renderHost(baseProps({ compact: true }));
|
|
53
|
+
const handle = el.querySelector('.splitter-handle');
|
|
54
|
+
expect(handle).not.toBeNull();
|
|
55
|
+
// Simulate pointerdown — beginDrag should bail before adding .dragging.
|
|
56
|
+
const evt = new PointerEvent('pointerdown', { bubbles: true, pointerId: 1 });
|
|
57
|
+
handle.dispatchEvent(evt);
|
|
58
|
+
flushSync();
|
|
59
|
+
expect(handle.classList.contains('dragging')).toBe(false);
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
describe('ResizableSplitter PointerClaim integration', () => {
|
|
63
|
+
const baseProps = (extra = {}) => (Object.assign({ direction: 'horizontal', sizes: [0.5, 0.5], count: 2, pane: () => null, onResize: () => { }, onCollapseToggle: () => { } }, extra));
|
|
64
|
+
it('does not begin drag when pointer is already claimed', () => {
|
|
65
|
+
const el = renderHost(baseProps({ compact: false }));
|
|
66
|
+
const handle = el.querySelector('.splitter-handle');
|
|
67
|
+
expect(handle).not.toBeNull();
|
|
68
|
+
claim(1, { ownerId: 'app:pan', axis: 'xy', priority: 'normal', depth: 99 });
|
|
69
|
+
const evt = new PointerEvent('pointerdown', { bubbles: true, pointerId: 1, isPrimary: true });
|
|
70
|
+
handle.dispatchEvent(evt);
|
|
71
|
+
flushSync();
|
|
72
|
+
expect(handle.classList.contains('dragging')).toBe(false);
|
|
73
|
+
});
|
|
74
|
+
});
|
|
@@ -33,7 +33,8 @@ export interface TenantDocumentAPI {
|
|
|
33
33
|
shardId: string;
|
|
34
34
|
path: string;
|
|
35
35
|
content: string | Uint8Array;
|
|
36
|
-
|
|
36
|
+
/** The exact version the store will write. Not auto-incremented. */
|
|
37
|
+
assignedVersion: number;
|
|
37
38
|
expectedLocalVersion: number;
|
|
38
39
|
origin: string;
|
|
39
40
|
deleted?: boolean;
|
|
@@ -21,6 +21,8 @@ import { registerView, unregisterView, registerVerb as fwRegisterVerb, unregiste
|
|
|
21
21
|
import { makeSh3Api } from '../sh3Api/headless';
|
|
22
22
|
import { createDocumentHandle, getTenantId, getDocumentBackend } from '../documents';
|
|
23
23
|
import { fetchEnvState, putEnvState } from '../env/client';
|
|
24
|
+
import { getEnvServerUrl } from '../env/index';
|
|
25
|
+
import { apiFetch } from '../transport/apiFetch';
|
|
24
26
|
import { isAdmin as checkIsAdmin } from '../auth/index';
|
|
25
27
|
import { createZoneManager } from '../state/manage';
|
|
26
28
|
import { PERMISSION_STATE_MANAGE } from '../state/types';
|
|
@@ -157,6 +159,20 @@ export async function activateShard(id, opts) {
|
|
|
157
159
|
entry.cleanupFns.push(() => handle.dispose());
|
|
158
160
|
return handle;
|
|
159
161
|
},
|
|
162
|
+
fetch(path, init) {
|
|
163
|
+
return apiFetch(this.resolveUrl(path), init);
|
|
164
|
+
},
|
|
165
|
+
get serverUrl() {
|
|
166
|
+
return getEnvServerUrl();
|
|
167
|
+
},
|
|
168
|
+
resolveUrl(path) {
|
|
169
|
+
const isAbsolute = path.startsWith('http://') || path.startsWith('https://');
|
|
170
|
+
if (isAbsolute)
|
|
171
|
+
return path;
|
|
172
|
+
const base = getEnvServerUrl();
|
|
173
|
+
const sep = path.startsWith('/') ? '' : '/';
|
|
174
|
+
return `${base}${sep}${path}`;
|
|
175
|
+
},
|
|
160
176
|
env(defaults) {
|
|
161
177
|
if (envState.proxy) {
|
|
162
178
|
console.warn(`[sh3] Shard "${id}" called ctx.env() more than once; extra calls are ignored.`);
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { MemoryDocumentBackend } from '../documents/backends';
|
|
3
|
+
import { __setDocumentBackend, __setTenantId } from '../documents/config';
|
|
4
|
+
import { __setEnvServerUrl } from '../env/index';
|
|
5
|
+
import { registerShard, activateShard, __resetShardRegistryForTest, } from './activate.svelte';
|
|
6
|
+
import { __resetViewRegistryForTest } from './registry';
|
|
7
|
+
describe('ctx.fetch', () => {
|
|
8
|
+
let originalFetch;
|
|
9
|
+
beforeEach(() => {
|
|
10
|
+
originalFetch = globalThis.fetch;
|
|
11
|
+
__resetShardRegistryForTest();
|
|
12
|
+
__resetViewRegistryForTest();
|
|
13
|
+
__setDocumentBackend(new MemoryDocumentBackend());
|
|
14
|
+
__setTenantId('tenant-test');
|
|
15
|
+
__setEnvServerUrl('https://example.com');
|
|
16
|
+
});
|
|
17
|
+
afterEach(() => {
|
|
18
|
+
globalThis.fetch = originalFetch;
|
|
19
|
+
__setEnvServerUrl('');
|
|
20
|
+
});
|
|
21
|
+
it('resolves relative paths against the configured serverUrl', async () => {
|
|
22
|
+
const calls = [];
|
|
23
|
+
globalThis.fetch = vi.fn(async (input) => {
|
|
24
|
+
calls.push(String(input));
|
|
25
|
+
return new Response('ok');
|
|
26
|
+
});
|
|
27
|
+
let captured = null;
|
|
28
|
+
registerShard({
|
|
29
|
+
manifest: { id: 'test', label: 'test', version: '0.0.0', views: [] },
|
|
30
|
+
activate(ctx) { captured = ctx; },
|
|
31
|
+
});
|
|
32
|
+
await activateShard('test');
|
|
33
|
+
await captured.fetch('/api/foo');
|
|
34
|
+
expect(calls[0]).toBe('https://example.com/api/foo');
|
|
35
|
+
});
|
|
36
|
+
it('passes absolute URLs through unchanged', async () => {
|
|
37
|
+
const calls = [];
|
|
38
|
+
globalThis.fetch = vi.fn(async (input) => {
|
|
39
|
+
calls.push(String(input));
|
|
40
|
+
return new Response('ok');
|
|
41
|
+
});
|
|
42
|
+
let captured = null;
|
|
43
|
+
registerShard({
|
|
44
|
+
manifest: { id: 'test', label: 'test', version: '0.0.0', views: [] },
|
|
45
|
+
activate(ctx) { captured = ctx; },
|
|
46
|
+
});
|
|
47
|
+
await activateShard('test');
|
|
48
|
+
await captured.fetch('https://other.example.com/api/bar');
|
|
49
|
+
expect(calls[0]).toBe('https://other.example.com/api/bar');
|
|
50
|
+
});
|
|
51
|
+
it('prepends a slash to bare relative paths', async () => {
|
|
52
|
+
const calls = [];
|
|
53
|
+
globalThis.fetch = vi.fn(async (input) => {
|
|
54
|
+
calls.push(String(input));
|
|
55
|
+
return new Response('ok');
|
|
56
|
+
});
|
|
57
|
+
let captured = null;
|
|
58
|
+
registerShard({
|
|
59
|
+
manifest: { id: 'test', label: 'test', version: '0.0.0', views: [] },
|
|
60
|
+
activate(ctx) { captured = ctx; },
|
|
61
|
+
});
|
|
62
|
+
await activateShard('test');
|
|
63
|
+
await captured.fetch('api/baz');
|
|
64
|
+
expect(calls[0]).toBe('https://example.com/api/baz');
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
describe('ctx.serverUrl', () => {
|
|
68
|
+
beforeEach(() => {
|
|
69
|
+
__resetShardRegistryForTest();
|
|
70
|
+
__resetViewRegistryForTest();
|
|
71
|
+
__setDocumentBackend(new MemoryDocumentBackend());
|
|
72
|
+
__setTenantId('tenant-test');
|
|
73
|
+
__setEnvServerUrl('https://example.com');
|
|
74
|
+
});
|
|
75
|
+
afterEach(() => {
|
|
76
|
+
__setEnvServerUrl('');
|
|
77
|
+
});
|
|
78
|
+
it('returns the configured server base URL', async () => {
|
|
79
|
+
let captured = null;
|
|
80
|
+
registerShard({
|
|
81
|
+
manifest: { id: 'test', label: 'test', version: '0.0.0', views: [] },
|
|
82
|
+
activate(ctx) { captured = ctx; },
|
|
83
|
+
});
|
|
84
|
+
await activateShard('test');
|
|
85
|
+
expect(captured.serverUrl).toBe('https://example.com');
|
|
86
|
+
});
|
|
87
|
+
it('returns empty string when no server URL is configured', async () => {
|
|
88
|
+
__setEnvServerUrl('');
|
|
89
|
+
let captured = null;
|
|
90
|
+
registerShard({
|
|
91
|
+
manifest: { id: 'test', label: 'test', version: '0.0.0', views: [] },
|
|
92
|
+
activate(ctx) { captured = ctx; },
|
|
93
|
+
});
|
|
94
|
+
await activateShard('test');
|
|
95
|
+
expect(captured.serverUrl).toBe('');
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
describe('ctx.resolveUrl', () => {
|
|
99
|
+
beforeEach(() => {
|
|
100
|
+
__resetShardRegistryForTest();
|
|
101
|
+
__resetViewRegistryForTest();
|
|
102
|
+
__setDocumentBackend(new MemoryDocumentBackend());
|
|
103
|
+
__setTenantId('tenant-test');
|
|
104
|
+
__setEnvServerUrl('https://example.com');
|
|
105
|
+
});
|
|
106
|
+
afterEach(() => {
|
|
107
|
+
__setEnvServerUrl('');
|
|
108
|
+
});
|
|
109
|
+
it('resolves a relative path to an absolute URL', async () => {
|
|
110
|
+
let captured = null;
|
|
111
|
+
registerShard({
|
|
112
|
+
manifest: { id: 'test', label: 'test', version: '0.0.0', views: [] },
|
|
113
|
+
activate(ctx) { captured = ctx; },
|
|
114
|
+
});
|
|
115
|
+
await activateShard('test');
|
|
116
|
+
expect(captured.resolveUrl('/api/foo')).toBe('https://example.com/api/foo');
|
|
117
|
+
});
|
|
118
|
+
it('passes absolute URLs through unchanged', async () => {
|
|
119
|
+
let captured = null;
|
|
120
|
+
registerShard({
|
|
121
|
+
manifest: { id: 'test', label: 'test', version: '0.0.0', views: [] },
|
|
122
|
+
activate(ctx) { captured = ctx; },
|
|
123
|
+
});
|
|
124
|
+
await activateShard('test');
|
|
125
|
+
expect(captured.resolveUrl('https://other.example.com/ws')).toBe('https://other.example.com/ws');
|
|
126
|
+
});
|
|
127
|
+
it('prepends a slash to bare relative paths', async () => {
|
|
128
|
+
let captured = null;
|
|
129
|
+
registerShard({
|
|
130
|
+
manifest: { id: 'test', label: 'test', version: '0.0.0', views: [] },
|
|
131
|
+
activate(ctx) { captured = ctx; },
|
|
132
|
+
});
|
|
133
|
+
await activateShard('test');
|
|
134
|
+
expect(captured.resolveUrl('api/ws')).toBe('https://example.com/api/ws');
|
|
135
|
+
});
|
|
136
|
+
});
|