@happyvertical/smrt-svelte 0.51.38 → 0.52.0
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/AGENTS.md +3 -0
- package/README.md +167 -1
- package/agents/workspace.md +24 -0
- package/dist/browser-ai/adapters/stt/browser-speech.d.ts +11 -0
- package/dist/browser-ai/adapters/stt/browser-speech.d.ts.map +1 -1
- package/dist/browser-ai/adapters/stt/browser-speech.js +20 -4
- package/dist/browser-ai/adapters/stt/browser-speech.test.js +113 -0
- package/dist/browser-ai/adapters/stt/dictation-source.d.ts +3 -0
- package/dist/browser-ai/adapters/stt/dictation-source.d.ts.map +1 -0
- package/dist/browser-ai/adapters/stt/dictation-source.js +38 -0
- package/dist/browser-ai/adapters/stt/dictation-source.test.js +52 -0
- package/dist/browser-ai/adapters/stt/index.d.ts +2 -1
- package/dist/browser-ai/adapters/stt/index.d.ts.map +1 -1
- package/dist/browser-ai/adapters/stt/index.js +2 -1
- package/dist/components/forms/Form.svelte +118 -8
- package/dist/components/forms/Form.svelte.d.ts +18 -2
- package/dist/components/forms/Form.svelte.d.ts.map +1 -1
- package/dist/components/forms/FormScope.svelte +202 -0
- package/dist/components/forms/FormScope.svelte.d.ts +22 -0
- package/dist/components/forms/FormScope.svelte.d.ts.map +1 -0
- package/dist/components/forms/__tests__/Form.ui-controls.test.js +119 -0
- package/dist/components/forms/__tests__/Form.webmcp.test.js +5 -3
- package/dist/components/forms/__tests__/FormScope.test.js +39 -0
- package/dist/components/forms/__tests__/form-scope.fixture.svelte +30 -0
- package/dist/components/forms/__tests__/form-scope.fixture.svelte.d.ts +10 -0
- package/dist/components/forms/__tests__/form-scope.fixture.svelte.d.ts.map +1 -0
- package/dist/components/forms/__tests__/form-with-ui-controls.fixture.svelte +84 -0
- package/dist/components/forms/__tests__/form-with-ui-controls.fixture.svelte.d.ts +14 -0
- package/dist/components/forms/__tests__/form-with-ui-controls.fixture.svelte.d.ts.map +1 -0
- package/dist/components/forms/index.d.ts +1 -0
- package/dist/components/forms/index.d.ts.map +1 -1
- package/dist/components/forms/index.js +1 -0
- package/dist/components/workspace/README.md +18 -0
- package/dist/components/workspace/__tests__/AdminShell.responsive.test.js +586 -0
- package/dist/components/workspace/__tests__/AdminShell.test.js +85 -0
- package/dist/components/workspace/__tests__/TenantNav.test.js +49 -0
- package/dist/components/workspace/__tests__/admin-shell-header-overflow.test.js +25 -0
- package/dist/components/workspace/__tests__/admin-shell-mobile.test.js +217 -0
- package/dist/components/workspace/__tests__/admin-shell-page-contracts.test.js +30 -0
- package/dist/components/workspace/__tests__/admin-shell-responsive-state.test.js +184 -0
- package/dist/components/workspace/__tests__/admin-shell-theme-hooks.test.js +36 -0
- package/dist/components/workspace/__tests__/shell-chrome-components.test.js +108 -0
- package/dist/components/workspace/admin-shell/AdminShell.svelte +998 -40
- package/dist/components/workspace/admin-shell/AdminShell.svelte.d.ts +42 -1
- package/dist/components/workspace/admin-shell/AdminShell.svelte.d.ts.map +1 -1
- package/dist/components/workspace/admin-shell/PhoneBottomBar.svelte +170 -0
- package/dist/components/workspace/admin-shell/PhoneBottomBar.svelte.d.ts +11 -0
- package/dist/components/workspace/admin-shell/PhoneBottomBar.svelte.d.ts.map +1 -0
- package/dist/components/workspace/admin-shell/PhoneTopBar.svelte +93 -0
- package/dist/components/workspace/admin-shell/PhoneTopBar.svelte.d.ts +11 -0
- package/dist/components/workspace/admin-shell/PhoneTopBar.svelte.d.ts.map +1 -0
- package/dist/components/workspace/admin-shell/ShellNavToggle.svelte +88 -0
- package/dist/components/workspace/admin-shell/ShellNavToggle.svelte.d.ts +14 -0
- package/dist/components/workspace/admin-shell/ShellNavToggle.svelte.d.ts.map +1 -0
- package/dist/components/workspace/admin-shell/ShellTitle.svelte +42 -0
- package/dist/components/workspace/admin-shell/ShellTitle.svelte.d.ts +11 -0
- package/dist/components/workspace/admin-shell/ShellTitle.svelte.d.ts.map +1 -0
- package/dist/components/workspace/admin-shell/TenantNav.svelte +46 -1
- package/dist/components/workspace/admin-shell/TenantNav.svelte.d.ts.map +1 -1
- package/dist/components/workspace/admin-shell/mobile-shell-dom.d.ts +37 -0
- package/dist/components/workspace/admin-shell/mobile-shell-dom.d.ts.map +1 -0
- package/dist/components/workspace/admin-shell/mobile-shell-dom.js +150 -0
- package/dist/components/workspace/admin-shell/mobile-shell.d.ts +191 -0
- package/dist/components/workspace/admin-shell/mobile-shell.d.ts.map +1 -0
- package/dist/components/workspace/admin-shell/mobile-shell.js +165 -0
- package/dist/components/workspace/admin-shell/settings.d.ts +27 -2
- package/dist/components/workspace/admin-shell/settings.d.ts.map +1 -1
- package/dist/components/workspace/admin-shell/settings.js +84 -2
- package/dist/components/workspace/admin-shell/state.svelte.d.ts +55 -1
- package/dist/components/workspace/admin-shell/state.svelte.d.ts.map +1 -1
- package/dist/components/workspace/admin-shell/state.svelte.js +149 -6
- package/dist/components/workspace/admin-shell/types.d.ts +97 -0
- package/dist/components/workspace/admin-shell/types.d.ts.map +1 -1
- package/dist/components/workspace/admin-shell/types.js +13 -0
- package/dist/components/workspace/index.d.ts +11 -4
- package/dist/components/workspace/index.d.ts.map +1 -1
- package/dist/components/workspace/index.js +12 -3
- package/dist/components/workspace/tools-dock/ToolsDock.svelte +2 -0
- package/dist/i18n/strings.workspace.d.ts +10 -0
- package/dist/i18n/strings.workspace.d.ts.map +1 -1
- package/dist/i18n/strings.workspace.js +10 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/web/__tests__/assistant-dock.integration.svelte.test.js +2 -2
- package/dist/web/__tests__/list-sort-surface.fixture.svelte +41 -0
- package/dist/web/__tests__/list-sort-surface.fixture.svelte.d.ts +15 -0
- package/dist/web/__tests__/list-sort-surface.fixture.svelte.d.ts.map +1 -0
- package/dist/web/__tests__/list-sort-surface.svelte.test.js +59 -0
- package/dist/web/__tests__/surfaces.child.fixture.svelte +69 -0
- package/dist/web/__tests__/surfaces.child.fixture.svelte.d.ts +16 -0
- package/dist/web/__tests__/surfaces.child.fixture.svelte.d.ts.map +1 -0
- package/dist/web/__tests__/surfaces.fixture.svelte +26 -0
- package/dist/web/__tests__/surfaces.fixture.svelte.d.ts +18 -0
- package/dist/web/__tests__/surfaces.fixture.svelte.d.ts.map +1 -0
- package/dist/web/__tests__/surfaces.svelte.test.js +140 -0
- package/dist/web/__tests__/webmcp-ui.test.js +18 -0
- package/dist/web/index.d.ts +4 -1
- package/dist/web/index.d.ts.map +1 -1
- package/dist/web/index.js +4 -1
- package/dist/web/link-surface.svelte.d.ts +14 -0
- package/dist/web/link-surface.svelte.d.ts.map +1 -0
- package/dist/web/link-surface.svelte.js +49 -0
- package/dist/web/list-surface.svelte.d.ts +80 -0
- package/dist/web/list-surface.svelte.d.ts.map +1 -0
- package/dist/web/list-surface.svelte.js +309 -0
- package/dist/web/step-surface.svelte.d.ts +17 -0
- package/dist/web/step-surface.svelte.d.ts.map +1 -0
- package/dist/web/step-surface.svelte.js +56 -0
- package/dist/web/webmcp-ui-context.d.ts +7 -0
- package/dist/web/webmcp-ui-context.d.ts.map +1 -1
- package/dist/web/webmcp-ui-context.js +10 -0
- package/dist/web/webmcp-ui.d.ts.map +1 -1
- package/dist/web/webmcp-ui.js +16 -3
- package/package.json +16 -16
|
@@ -0,0 +1,586 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AdminShell's responsive chrome: header row, viewport defaults, phone top
|
|
3
|
+
* and bottom bars, form action bars, scrim, navigation, phone presentations
|
|
4
|
+
* of the right edge, and resizable side edges.
|
|
5
|
+
*/
|
|
6
|
+
import { fireEvent, render, screen } from '@testing-library/svelte';
|
|
7
|
+
import { createRawSnippet, flushSync, tick } from 'svelte';
|
|
8
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
9
|
+
import AdminShell from '../admin-shell/AdminShell.svelte';
|
|
10
|
+
import { createShellState } from '../admin-shell/state.svelte.js';
|
|
11
|
+
import { ADMIN_SHELL_REGION_IDS } from '../admin-shell/types.js';
|
|
12
|
+
function html(markup) {
|
|
13
|
+
return createRawSnippet(() => ({ render: () => markup }));
|
|
14
|
+
}
|
|
15
|
+
const listeners = new Set();
|
|
16
|
+
let width = 1280;
|
|
17
|
+
const originalMatchMedia = window.matchMedia;
|
|
18
|
+
function setWidth(next) {
|
|
19
|
+
width = next;
|
|
20
|
+
for (const listener of listeners)
|
|
21
|
+
listener();
|
|
22
|
+
}
|
|
23
|
+
beforeEach(() => {
|
|
24
|
+
width = 1280;
|
|
25
|
+
window.matchMedia = ((query) => {
|
|
26
|
+
const max = /max-width:\s*([\d.]+)rem/.exec(query);
|
|
27
|
+
const min = /min-width:\s*([\d.]+)rem/.exec(query);
|
|
28
|
+
return {
|
|
29
|
+
get matches() {
|
|
30
|
+
if (query.includes('prefers-reduced-motion'))
|
|
31
|
+
return false;
|
|
32
|
+
return ((!max || width <= Number(max[1]) * 16) &&
|
|
33
|
+
(!min || width >= Number(min[1]) * 16));
|
|
34
|
+
},
|
|
35
|
+
media: query,
|
|
36
|
+
addEventListener: (_, listener) => listeners.add(listener),
|
|
37
|
+
removeEventListener: (_, listener) => listeners.delete(listener),
|
|
38
|
+
};
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
afterEach(() => {
|
|
42
|
+
listeners.clear();
|
|
43
|
+
window.matchMedia = originalMatchMedia;
|
|
44
|
+
});
|
|
45
|
+
const config = {
|
|
46
|
+
top: false,
|
|
47
|
+
left: {
|
|
48
|
+
label: 'Navigation',
|
|
49
|
+
initial: 'expanded',
|
|
50
|
+
hotkey: null,
|
|
51
|
+
viewportDefaults: {
|
|
52
|
+
phone: 'collapsed',
|
|
53
|
+
tablet: 'collapsed',
|
|
54
|
+
desktop: 'expanded',
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
right: { label: 'Assistant', initial: 'collapsed', hotkey: null },
|
|
58
|
+
bottom: false,
|
|
59
|
+
};
|
|
60
|
+
async function settle() {
|
|
61
|
+
flushSync();
|
|
62
|
+
await tick();
|
|
63
|
+
flushSync();
|
|
64
|
+
}
|
|
65
|
+
function shellRoot() {
|
|
66
|
+
return document.querySelector('.smrt-admin-shell');
|
|
67
|
+
}
|
|
68
|
+
describe('AdminShell header and viewport', () => {
|
|
69
|
+
it('renders a header row above the edges on desktop, with public region ids', async () => {
|
|
70
|
+
const shell = createShellState({ config });
|
|
71
|
+
render(AdminShell, {
|
|
72
|
+
props: {
|
|
73
|
+
state: shell,
|
|
74
|
+
header: createRawSnippet((ctx) => ({
|
|
75
|
+
render: () => `<span>Header ${ctx().viewport}</span>`,
|
|
76
|
+
})),
|
|
77
|
+
phoneTopBar: html('<span>phone top</span>'),
|
|
78
|
+
phoneBottomBar: html('<nav>phone bar</nav>'),
|
|
79
|
+
children: html('<p>page</p>'),
|
|
80
|
+
},
|
|
81
|
+
});
|
|
82
|
+
await settle();
|
|
83
|
+
const header = document.getElementById(ADMIN_SHELL_REGION_IDS.header);
|
|
84
|
+
expect(header?.textContent).toContain('Header desktop');
|
|
85
|
+
expect(document.getElementById(ADMIN_SHELL_REGION_IDS.main)?.tagName).toBe('MAIN');
|
|
86
|
+
expect(document.getElementById(ADMIN_SHELL_REGION_IDS.left)).not.toBeNull();
|
|
87
|
+
expect(shellRoot().dataset.viewport).toBe('desktop');
|
|
88
|
+
expect(shellRoot().getAttribute('style')).toContain('--smrt-admin-shell-header-track: var(--smrt-admin-shell-header-size)');
|
|
89
|
+
expect(screen.queryByTestId('admin-shell-phone-top')).toBeNull();
|
|
90
|
+
expect(screen.queryByTestId('admin-shell-phone-bar')).toBeNull();
|
|
91
|
+
expect(shell.panels.left).toBe('expanded');
|
|
92
|
+
});
|
|
93
|
+
it('follows viewport classes: tablet collapses the nav, desktop reopens it', async () => {
|
|
94
|
+
const shell = createShellState({ config });
|
|
95
|
+
render(AdminShell, {
|
|
96
|
+
props: { state: shell, children: html('<p>page</p>') },
|
|
97
|
+
});
|
|
98
|
+
await settle();
|
|
99
|
+
setWidth(900);
|
|
100
|
+
flushSync();
|
|
101
|
+
expect(shell.viewport).toBe('tablet');
|
|
102
|
+
expect(shell.panels.left).toBe('collapsed');
|
|
103
|
+
setWidth(1400);
|
|
104
|
+
flushSync();
|
|
105
|
+
expect(shell.panels.left).toBe('expanded');
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
describe('AdminShell on a phone', () => {
|
|
109
|
+
beforeEach(() => setWidth(390));
|
|
110
|
+
function renderPhone(extra = {}, page = '<p>page</p>') {
|
|
111
|
+
const shell = createShellState({ config });
|
|
112
|
+
const result = render(AdminShell, {
|
|
113
|
+
props: {
|
|
114
|
+
state: shell,
|
|
115
|
+
header: html('<span>desktop header</span>'),
|
|
116
|
+
phoneTopBar: createRawSnippet((ctx) => ({
|
|
117
|
+
render: () => `<span>top ${ctx().hidden ? 'hidden' : 'shown'}</span>`,
|
|
118
|
+
})),
|
|
119
|
+
phoneBottomBar: html('<nav aria-label="Main">bar</nav>'),
|
|
120
|
+
phone: { scrim: true, swipeToClose: true },
|
|
121
|
+
path: '/a',
|
|
122
|
+
children: html(page),
|
|
123
|
+
...extra,
|
|
124
|
+
},
|
|
125
|
+
});
|
|
126
|
+
return { shell, ...result };
|
|
127
|
+
}
|
|
128
|
+
it('swaps the header for the phone top bar and shows the bottom bar', async () => {
|
|
129
|
+
const { shell } = renderPhone();
|
|
130
|
+
await settle();
|
|
131
|
+
expect(shell.viewport).toBe('phone');
|
|
132
|
+
expect(shell.panels.left).toBe('collapsed');
|
|
133
|
+
expect(screen.queryByTestId('admin-shell-header')).toBeNull();
|
|
134
|
+
expect(screen.getByTestId('admin-shell-phone-top').textContent).toContain('top shown');
|
|
135
|
+
expect(screen.getByTestId('admin-shell-phone-bar')).toBeInTheDocument();
|
|
136
|
+
expect(shellRoot().dataset.bottomBar).toBe('nav');
|
|
137
|
+
expect(shellRoot().hasAttribute('data-phone-top')).toBe(true);
|
|
138
|
+
});
|
|
139
|
+
it('lets a form action bar replace the bottom bar', async () => {
|
|
140
|
+
renderPhone({}, '<form><div data-form-action-bar><button>Save</button></div></form>');
|
|
141
|
+
await settle();
|
|
142
|
+
expect(screen.queryByTestId('admin-shell-phone-bar')).toBeNull();
|
|
143
|
+
expect(shellRoot().dataset.bottomBar).toBe('form');
|
|
144
|
+
});
|
|
145
|
+
it('dims the page behind an open drawer; the scrim closes it', async () => {
|
|
146
|
+
const { shell } = renderPhone();
|
|
147
|
+
await settle();
|
|
148
|
+
expect(screen.queryByTestId('admin-shell-scrim')).toBeNull();
|
|
149
|
+
shell.expandPanel('left');
|
|
150
|
+
flushSync();
|
|
151
|
+
const scrim = screen.getByTestId('admin-shell-scrim');
|
|
152
|
+
expect(scrim).toHaveAttribute('aria-label', 'Close Navigation');
|
|
153
|
+
await fireEvent.click(scrim);
|
|
154
|
+
expect(shell.panels.left).toBe('collapsed');
|
|
155
|
+
});
|
|
156
|
+
it('keeps a closed phone drawer out of the tab order and away from screen readers', async () => {
|
|
157
|
+
const { shell } = renderPhone();
|
|
158
|
+
await settle();
|
|
159
|
+
const left = document.getElementById('smrt-admin-shell-left-panel');
|
|
160
|
+
expect(left.inert).toBe(true);
|
|
161
|
+
shell.expandPanel('left');
|
|
162
|
+
flushSync();
|
|
163
|
+
expect(left.inert).toBe(false);
|
|
164
|
+
setWidth(1280);
|
|
165
|
+
await settle();
|
|
166
|
+
expect(left.inert).toBe(false);
|
|
167
|
+
});
|
|
168
|
+
it('closes an open drawer when the path changes', async () => {
|
|
169
|
+
const { shell, rerender } = renderPhone();
|
|
170
|
+
await settle();
|
|
171
|
+
shell.expandPanel('left');
|
|
172
|
+
flushSync();
|
|
173
|
+
await rerender({ path: '/b' });
|
|
174
|
+
await settle();
|
|
175
|
+
expect(shell.panels.left).toBe('collapsed');
|
|
176
|
+
});
|
|
177
|
+
it('does not render a phone-hidden right edge and renders a sheet with a grabber', async () => {
|
|
178
|
+
const hiddenShell = createShellState({
|
|
179
|
+
config: {
|
|
180
|
+
...config,
|
|
181
|
+
right: { label: 'Assistant', phone: 'hidden', initial: 'expanded' },
|
|
182
|
+
},
|
|
183
|
+
});
|
|
184
|
+
const first = render(AdminShell, {
|
|
185
|
+
props: { state: hiddenShell, children: html('<p>page</p>') },
|
|
186
|
+
});
|
|
187
|
+
await settle();
|
|
188
|
+
expect(document.getElementById(ADMIN_SHELL_REGION_IDS.right)).toBeNull();
|
|
189
|
+
first.unmount();
|
|
190
|
+
const sheetShell = createShellState({
|
|
191
|
+
config: {
|
|
192
|
+
...config,
|
|
193
|
+
right: { label: 'Assistant', phone: 'sheet', initial: 'expanded' },
|
|
194
|
+
},
|
|
195
|
+
});
|
|
196
|
+
render(AdminShell, {
|
|
197
|
+
props: {
|
|
198
|
+
state: sheetShell,
|
|
199
|
+
focusPanel: html('<p>chat</p>'),
|
|
200
|
+
phone: { scrim: true },
|
|
201
|
+
children: html('<p>page</p>'),
|
|
202
|
+
},
|
|
203
|
+
});
|
|
204
|
+
await settle();
|
|
205
|
+
const aside = document.getElementById(ADMIN_SHELL_REGION_IDS.right);
|
|
206
|
+
expect(aside.dataset.phone).toBe('sheet');
|
|
207
|
+
expect(aside.querySelector('.smrt-admin-shell__sheet-grabber')).not.toBeNull();
|
|
208
|
+
expect(screen.getByTestId('admin-shell-scrim')).toHaveAttribute('aria-label', 'Close Assistant');
|
|
209
|
+
});
|
|
210
|
+
it('renders host overlays with the viewport and bottom bar mode', async () => {
|
|
211
|
+
renderPhone({
|
|
212
|
+
overlays: createRawSnippet((ctx) => ({
|
|
213
|
+
render: () => `<div data-testid="overlay">${ctx().viewport}/${ctx().bottomBar}</div>`,
|
|
214
|
+
})),
|
|
215
|
+
});
|
|
216
|
+
await settle();
|
|
217
|
+
expect(screen.getByTestId('overlay').textContent).toBe('phone/nav');
|
|
218
|
+
});
|
|
219
|
+
});
|
|
220
|
+
describe('AdminShell resizable edges', () => {
|
|
221
|
+
function storeAdapter() {
|
|
222
|
+
const writes = [];
|
|
223
|
+
return {
|
|
224
|
+
writes,
|
|
225
|
+
read: () => null,
|
|
226
|
+
write: (delta) => {
|
|
227
|
+
writes.push(JSON.parse(JSON.stringify(delta)));
|
|
228
|
+
},
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
function renderResizable(overrides = {}) {
|
|
232
|
+
const adapter = storeAdapter();
|
|
233
|
+
const shell = createShellState({
|
|
234
|
+
config: {
|
|
235
|
+
...config,
|
|
236
|
+
right: {
|
|
237
|
+
label: 'Assistant',
|
|
238
|
+
initial: 'expanded',
|
|
239
|
+
expandedSize: '28rem',
|
|
240
|
+
resizable: { min: 320, max: 640, step: 10 },
|
|
241
|
+
hotkey: null,
|
|
242
|
+
},
|
|
243
|
+
...overrides,
|
|
244
|
+
},
|
|
245
|
+
settingsAdapter: adapter,
|
|
246
|
+
});
|
|
247
|
+
const view = render(AdminShell, {
|
|
248
|
+
props: {
|
|
249
|
+
state: shell,
|
|
250
|
+
focusPanel: html('<p>chat</p>'),
|
|
251
|
+
children: html('<p>page</p>'),
|
|
252
|
+
},
|
|
253
|
+
});
|
|
254
|
+
flushSync();
|
|
255
|
+
return { shell, adapter, view };
|
|
256
|
+
}
|
|
257
|
+
it('does not render a separator unless the edge is resizable (backward compatible)', () => {
|
|
258
|
+
const shell = createShellState({
|
|
259
|
+
config: { right: { initial: 'expanded' } },
|
|
260
|
+
});
|
|
261
|
+
render(AdminShell, {
|
|
262
|
+
props: { state: shell, children: html('<p>page</p>') },
|
|
263
|
+
});
|
|
264
|
+
expect(document.querySelector('[role="separator"]')).toBeNull();
|
|
265
|
+
});
|
|
266
|
+
it('exposes an accessible, keyboard-resizable separator', async () => {
|
|
267
|
+
const { shell, adapter } = renderResizable();
|
|
268
|
+
const separator = screen.getByRole('separator', {
|
|
269
|
+
name: 'Resize Assistant',
|
|
270
|
+
});
|
|
271
|
+
expect(separator).toHaveAttribute('aria-orientation', 'vertical');
|
|
272
|
+
expect(separator).toHaveAttribute('aria-controls', ADMIN_SHELL_REGION_IDS.right);
|
|
273
|
+
expect(separator).toHaveAttribute('aria-valuemin', '320');
|
|
274
|
+
expect(separator).toHaveAttribute('aria-valuemax', '640');
|
|
275
|
+
expect(separator).toHaveAttribute('aria-valuenow', '448');
|
|
276
|
+
expect(separator).toHaveAttribute('tabindex', '0');
|
|
277
|
+
await fireEvent.keyDown(separator, { key: 'ArrowLeft' });
|
|
278
|
+
expect(shell.panelSize('right')).toBe(458);
|
|
279
|
+
expect(separator).toHaveAttribute('aria-valuenow', '458');
|
|
280
|
+
await fireEvent.keyDown(separator, { key: 'ArrowRight', shiftKey: true });
|
|
281
|
+
expect(shell.panelSize('right')).toBe(418);
|
|
282
|
+
await fireEvent.keyDown(separator, { key: 'End' });
|
|
283
|
+
expect(shell.panelSize('right')).toBe(640);
|
|
284
|
+
await fireEvent.keyDown(separator, { key: 'Home' });
|
|
285
|
+
expect(shell.panelSize('right')).toBe(320);
|
|
286
|
+
expect(adapter.writes.at(-1)?.sizes).toEqual({ right: 320 });
|
|
287
|
+
expect(shellRoot().getAttribute('style')).toContain('--smrt-admin-shell-right-track: 320px');
|
|
288
|
+
await fireEvent.keyDown(separator, { key: 'Enter' });
|
|
289
|
+
expect(shell.panelSize('right')).toBeNull();
|
|
290
|
+
expect(separator).toHaveAttribute('aria-valuenow', '448');
|
|
291
|
+
});
|
|
292
|
+
it('resizes by dragging, persisting once at the end, and resets on double-click', async () => {
|
|
293
|
+
const { shell, adapter } = renderResizable();
|
|
294
|
+
const separator = screen.getByTestId('admin-shell-resizer-right');
|
|
295
|
+
const before = adapter.writes.length;
|
|
296
|
+
await fireEvent.pointerDown(separator, { button: 0, clientX: 800 });
|
|
297
|
+
expect(shellRoot().dataset.resizing).toBe('right');
|
|
298
|
+
await fireEvent.pointerMove(separator, { clientX: 760 });
|
|
299
|
+
expect(shell.panelSize('right')).toBe(488);
|
|
300
|
+
await fireEvent.pointerMove(separator, { clientX: 700 });
|
|
301
|
+
expect(shell.panelSize('right')).toBe(548);
|
|
302
|
+
expect(adapter.writes.length).toBe(before);
|
|
303
|
+
await fireEvent.pointerUp(separator, { clientX: 700 });
|
|
304
|
+
expect(shellRoot().dataset.resizing).toBeUndefined();
|
|
305
|
+
expect(adapter.writes.length).toBe(before + 1);
|
|
306
|
+
expect(adapter.writes.at(-1)?.sizes).toEqual({ right: 548 });
|
|
307
|
+
await fireEvent.dblClick(separator);
|
|
308
|
+
expect(shell.panelSize('right')).toBeNull();
|
|
309
|
+
});
|
|
310
|
+
it('ends the drag when pointer capture is lost, persisting once', async () => {
|
|
311
|
+
const { shell, adapter } = renderResizable();
|
|
312
|
+
const separator = screen.getByTestId('admin-shell-resizer-right');
|
|
313
|
+
const before = adapter.writes.length;
|
|
314
|
+
await fireEvent.pointerDown(separator, { button: 0, clientX: 800 });
|
|
315
|
+
await fireEvent.pointerMove(separator, { clientX: 760 });
|
|
316
|
+
expect(shellRoot().dataset.resizing).toBe('right');
|
|
317
|
+
// The browser took the capture away (a system gesture, a window switch)
|
|
318
|
+
// without a pointerup.
|
|
319
|
+
await fireEvent(separator, new Event('lostpointercapture'));
|
|
320
|
+
expect(shellRoot().dataset.resizing).toBeUndefined();
|
|
321
|
+
expect(adapter.writes.length).toBe(before + 1);
|
|
322
|
+
await fireEvent.pointerMove(separator, { clientX: 600 });
|
|
323
|
+
expect(shell.panelSize('right')).toBe(488);
|
|
324
|
+
await fireEvent.pointerUp(separator, { clientX: 600 });
|
|
325
|
+
expect(adapter.writes.length).toBe(before + 1);
|
|
326
|
+
});
|
|
327
|
+
it('drops an in-flight drag when the shell unmounts', async () => {
|
|
328
|
+
const { shell, view } = renderResizable();
|
|
329
|
+
const separator = screen.getByTestId('admin-shell-resizer-right');
|
|
330
|
+
await fireEvent.pointerDown(separator, { button: 0, clientX: 800 });
|
|
331
|
+
await fireEvent.pointerMove(separator, { clientX: 760 });
|
|
332
|
+
expect(shell.panelSize('right')).toBe(488);
|
|
333
|
+
view.unmount();
|
|
334
|
+
// The detached handle no longer drives the (still live) shell state.
|
|
335
|
+
await fireEvent.pointerMove(separator, { clientX: 600 });
|
|
336
|
+
expect(shell.panelSize('right')).toBe(488);
|
|
337
|
+
});
|
|
338
|
+
it('grows a left edge to the right', async () => {
|
|
339
|
+
const { shell } = renderResizable({
|
|
340
|
+
left: {
|
|
341
|
+
label: 'Navigation',
|
|
342
|
+
initial: 'expanded',
|
|
343
|
+
expandedSize: '16rem',
|
|
344
|
+
resizable: true,
|
|
345
|
+
},
|
|
346
|
+
});
|
|
347
|
+
const separator = screen.getByRole('separator', {
|
|
348
|
+
name: 'Resize Navigation',
|
|
349
|
+
});
|
|
350
|
+
await fireEvent.keyDown(separator, { key: 'ArrowRight' });
|
|
351
|
+
expect(shell.panelSize('left')).toBe(272);
|
|
352
|
+
});
|
|
353
|
+
it('hides the separator while collapsed and on phones', async () => {
|
|
354
|
+
const { shell } = renderResizable();
|
|
355
|
+
shell.collapsePanel('right');
|
|
356
|
+
flushSync();
|
|
357
|
+
expect(screen.queryByRole('separator')).toBeNull();
|
|
358
|
+
shell.expandPanel('right');
|
|
359
|
+
flushSync();
|
|
360
|
+
expect(screen.getByRole('separator')).toBeInTheDocument();
|
|
361
|
+
setWidth(390);
|
|
362
|
+
flushSync();
|
|
363
|
+
expect(screen.queryByRole('separator')).toBeNull();
|
|
364
|
+
});
|
|
365
|
+
it('keeps the size out of storage when the edge opts out', async () => {
|
|
366
|
+
const { shell, adapter } = renderResizable({
|
|
367
|
+
right: {
|
|
368
|
+
label: 'Assistant',
|
|
369
|
+
initial: 'expanded',
|
|
370
|
+
resizable: true,
|
|
371
|
+
persist: { size: false },
|
|
372
|
+
},
|
|
373
|
+
});
|
|
374
|
+
await fireEvent.keyDown(screen.getByRole('separator'), { key: 'End' });
|
|
375
|
+
expect(shell.panelSize('right')).toBe(720);
|
|
376
|
+
expect(adapter.writes.at(-1)?.sizes).toBeUndefined();
|
|
377
|
+
});
|
|
378
|
+
});
|
|
379
|
+
describe('AdminShell overlay edges (overlayMedia)', () => {
|
|
380
|
+
function overlayShell() {
|
|
381
|
+
return createShellState({
|
|
382
|
+
config: {
|
|
383
|
+
...config,
|
|
384
|
+
right: {
|
|
385
|
+
label: 'Assistant',
|
|
386
|
+
initial: 'collapsed',
|
|
387
|
+
hotkey: null,
|
|
388
|
+
collapsedSize: '0rem',
|
|
389
|
+
expandedSize: '28rem',
|
|
390
|
+
resizable: { min: 320, max: 640 },
|
|
391
|
+
overlayMedia: '(max-width: 99.9375rem)',
|
|
392
|
+
},
|
|
393
|
+
},
|
|
394
|
+
});
|
|
395
|
+
}
|
|
396
|
+
function rightEdge() {
|
|
397
|
+
return document.getElementById(ADMIN_SHELL_REGION_IDS.right);
|
|
398
|
+
}
|
|
399
|
+
it('slides over the page below the query and docks above it', async () => {
|
|
400
|
+
setWidth(1280);
|
|
401
|
+
const shell = overlayShell();
|
|
402
|
+
render(AdminShell, {
|
|
403
|
+
props: { state: shell, children: html('<button>page action</button>') },
|
|
404
|
+
});
|
|
405
|
+
await settle();
|
|
406
|
+
shell.expandPanel('right');
|
|
407
|
+
await settle();
|
|
408
|
+
expect(shell.presentationFor('right')).toBe('overlay');
|
|
409
|
+
expect(rightEdge().dataset.presentation).toBe('overlay');
|
|
410
|
+
// The page keeps its width: the right track stays collapsed.
|
|
411
|
+
expect(shellRoot().getAttribute('style')).toContain('--smrt-admin-shell-right-track: 0rem');
|
|
412
|
+
expect(screen.getByTestId('admin-shell-overlay-scrim')).toBeTruthy();
|
|
413
|
+
expect(document.getElementById('smrt-admin-shell-main')?.inert).toBe(true);
|
|
414
|
+
expect(screen.queryByTestId('admin-shell-resizer-right')).toBeNull();
|
|
415
|
+
setWidth(1600);
|
|
416
|
+
await settle();
|
|
417
|
+
expect(shell.presentationFor('right')).toBe('push');
|
|
418
|
+
expect(rightEdge().dataset.presentation).toBe('push');
|
|
419
|
+
expect(shellRoot().getAttribute('style')).toContain('--smrt-admin-shell-right-track: 28rem');
|
|
420
|
+
expect(screen.queryByTestId('admin-shell-overlay-scrim')).toBeNull();
|
|
421
|
+
expect(document.getElementById('smrt-admin-shell-main')?.inert).toBe(false);
|
|
422
|
+
expect(screen.getByTestId('admin-shell-resizer-right')).toBeTruthy();
|
|
423
|
+
});
|
|
424
|
+
it('closes on a scrim click or Escape, moving focus in and back', async () => {
|
|
425
|
+
setWidth(1100);
|
|
426
|
+
const shell = overlayShell();
|
|
427
|
+
render(AdminShell, {
|
|
428
|
+
props: { state: shell, children: html('<p>page</p>') },
|
|
429
|
+
});
|
|
430
|
+
await settle();
|
|
431
|
+
const opener = document.createElement('button');
|
|
432
|
+
document.body.append(opener);
|
|
433
|
+
opener.focus();
|
|
434
|
+
shell.expandPanel('right');
|
|
435
|
+
await settle();
|
|
436
|
+
expect(document.activeElement).toBe(rightEdge());
|
|
437
|
+
await fireEvent.keyDown(window, { key: 'Escape' });
|
|
438
|
+
await settle();
|
|
439
|
+
expect(shell.panels.right).toBe('collapsed');
|
|
440
|
+
expect(document.activeElement).toBe(opener);
|
|
441
|
+
opener.remove();
|
|
442
|
+
shell.expandPanel('right');
|
|
443
|
+
await settle();
|
|
444
|
+
await fireEvent.click(screen.getByTestId('admin-shell-overlay-scrim'));
|
|
445
|
+
await settle();
|
|
446
|
+
expect(shell.panels.right).toBe('collapsed');
|
|
447
|
+
shell.expandPanel('right');
|
|
448
|
+
await settle();
|
|
449
|
+
await fireEvent.keyDown(window, { key: 'Escape' });
|
|
450
|
+
await settle();
|
|
451
|
+
expect(shell.panels.right).toBe('collapsed');
|
|
452
|
+
});
|
|
453
|
+
it('does not open an overlay on load, but still restores a docked edge', async () => {
|
|
454
|
+
const stored = { panels: { right: 'expanded' } };
|
|
455
|
+
const adapter = { read: () => stored, write: vi.fn() };
|
|
456
|
+
const make = () => createShellState({
|
|
457
|
+
config: {
|
|
458
|
+
...config,
|
|
459
|
+
right: {
|
|
460
|
+
label: 'Assistant',
|
|
461
|
+
initial: 'collapsed',
|
|
462
|
+
hotkey: null,
|
|
463
|
+
overlayMedia: '(max-width: 99.9375rem)',
|
|
464
|
+
},
|
|
465
|
+
},
|
|
466
|
+
settingsAdapter: adapter,
|
|
467
|
+
});
|
|
468
|
+
setWidth(1280);
|
|
469
|
+
const narrow = make();
|
|
470
|
+
const first = render(AdminShell, {
|
|
471
|
+
props: { state: narrow, children: html('<p>page</p>') },
|
|
472
|
+
});
|
|
473
|
+
await settle();
|
|
474
|
+
await settle();
|
|
475
|
+
expect(narrow.panels.right).toBe('collapsed');
|
|
476
|
+
expect(adapter.write).not.toHaveBeenCalled();
|
|
477
|
+
first.unmount();
|
|
478
|
+
setWidth(1700);
|
|
479
|
+
const wide = make();
|
|
480
|
+
render(AdminShell, {
|
|
481
|
+
props: { state: wide, children: html('<p>page</p>') },
|
|
482
|
+
});
|
|
483
|
+
await settle();
|
|
484
|
+
await settle();
|
|
485
|
+
expect(wide.panels.right).toBe('expanded');
|
|
486
|
+
});
|
|
487
|
+
it('slides a closed overlay back out before hiding it', async () => {
|
|
488
|
+
vi.useFakeTimers();
|
|
489
|
+
try {
|
|
490
|
+
setWidth(1100);
|
|
491
|
+
const shell = overlayShell();
|
|
492
|
+
render(AdminShell, {
|
|
493
|
+
props: { state: shell, children: html('<p>page</p>') },
|
|
494
|
+
});
|
|
495
|
+
await settle();
|
|
496
|
+
shell.expandPanel('right');
|
|
497
|
+
await settle();
|
|
498
|
+
shell.collapsePanel('right');
|
|
499
|
+
await settle();
|
|
500
|
+
// Closed at once for the page (not inert), still shown while it slides.
|
|
501
|
+
expect(document.getElementById('smrt-admin-shell-main')?.inert).toBe(false);
|
|
502
|
+
expect(rightEdge().dataset.state).toBe('expanded');
|
|
503
|
+
expect(rightEdge().hasAttribute('data-closing')).toBe(true);
|
|
504
|
+
expect(rightEdge().inert).toBe(true);
|
|
505
|
+
const scrim = screen.getByTestId('admin-shell-overlay-scrim');
|
|
506
|
+
expect(scrim.hasAttribute('data-closing')).toBe(true);
|
|
507
|
+
await fireEvent.click(scrim);
|
|
508
|
+
expect(shell.panels.right).toBe('collapsed');
|
|
509
|
+
vi.advanceTimersByTime(700);
|
|
510
|
+
await settle();
|
|
511
|
+
expect(rightEdge().dataset.state).toBe('collapsed');
|
|
512
|
+
expect(rightEdge().hasAttribute('data-closing')).toBe(false);
|
|
513
|
+
expect(screen.queryByTestId('admin-shell-overlay-scrim')).toBeNull();
|
|
514
|
+
}
|
|
515
|
+
finally {
|
|
516
|
+
vi.useRealTimers();
|
|
517
|
+
}
|
|
518
|
+
});
|
|
519
|
+
it('skips the slide-out when reduced motion is preferred', async () => {
|
|
520
|
+
setWidth(1100);
|
|
521
|
+
const base = window.matchMedia;
|
|
522
|
+
window.matchMedia = ((query) => query.includes('prefers-reduced-motion')
|
|
523
|
+
? {
|
|
524
|
+
matches: true,
|
|
525
|
+
media: query,
|
|
526
|
+
addEventListener: () => { },
|
|
527
|
+
removeEventListener: () => { },
|
|
528
|
+
}
|
|
529
|
+
: base(query));
|
|
530
|
+
const shell = overlayShell();
|
|
531
|
+
render(AdminShell, {
|
|
532
|
+
props: { state: shell, children: html('<p>page</p>') },
|
|
533
|
+
});
|
|
534
|
+
await settle();
|
|
535
|
+
shell.expandPanel('right');
|
|
536
|
+
await settle();
|
|
537
|
+
shell.collapsePanel('right');
|
|
538
|
+
await settle();
|
|
539
|
+
expect(rightEdge().dataset.state).toBe('collapsed');
|
|
540
|
+
expect(screen.queryByTestId('admin-shell-overlay-scrim')).toBeNull();
|
|
541
|
+
});
|
|
542
|
+
it('keeps the phone presentation on phones', async () => {
|
|
543
|
+
setWidth(390);
|
|
544
|
+
const shell = overlayShell();
|
|
545
|
+
render(AdminShell, {
|
|
546
|
+
props: { state: shell, children: html('<p>page</p>') },
|
|
547
|
+
});
|
|
548
|
+
await settle();
|
|
549
|
+
shell.expandPanel('right');
|
|
550
|
+
await settle();
|
|
551
|
+
expect(shell.presentationFor('right')).toBe('push');
|
|
552
|
+
expect(screen.queryByTestId('admin-shell-overlay-scrim')).toBeNull();
|
|
553
|
+
});
|
|
554
|
+
});
|
|
555
|
+
describe('AdminShell backward compatibility', () => {
|
|
556
|
+
it('adds no header, phone chrome, overlays, or scrim without the new props', async () => {
|
|
557
|
+
setWidth(390);
|
|
558
|
+
const shell = createShellState({
|
|
559
|
+
config: { left: { initial: 'expanded' } },
|
|
560
|
+
});
|
|
561
|
+
render(AdminShell, {
|
|
562
|
+
props: { state: shell, children: html('<p>page</p>') },
|
|
563
|
+
});
|
|
564
|
+
await settle();
|
|
565
|
+
expect(screen.queryByTestId('admin-shell-header')).toBeNull();
|
|
566
|
+
expect(screen.queryByTestId('admin-shell-phone-top')).toBeNull();
|
|
567
|
+
expect(screen.queryByTestId('admin-shell-phone-bar')).toBeNull();
|
|
568
|
+
expect(screen.queryByTestId('admin-shell-scrim')).toBeNull();
|
|
569
|
+
expect(document.querySelector('.smrt-admin-shell__overlays')).toBeNull();
|
|
570
|
+
// Without viewport defaults the left edge keeps its configured state.
|
|
571
|
+
expect(shell.panels.left).toBe('expanded');
|
|
572
|
+
expect(shellRoot().getAttribute('style')).toContain('--smrt-admin-shell-header-track: 0rem');
|
|
573
|
+
});
|
|
574
|
+
it('still renders a system bar in the bottom edge', () => {
|
|
575
|
+
const shell = createShellState();
|
|
576
|
+
render(AdminShell, {
|
|
577
|
+
props: {
|
|
578
|
+
state: shell,
|
|
579
|
+
systemBar: html('<span>system chips</span>'),
|
|
580
|
+
children: html('<p>page</p>'),
|
|
581
|
+
},
|
|
582
|
+
});
|
|
583
|
+
expect(document.getElementById(ADMIN_SHELL_REGION_IDS.bottom)?.textContent).toContain('system chips');
|
|
584
|
+
});
|
|
585
|
+
});
|
|
586
|
+
vi.setConfig({ testTimeout: 30000 });
|
|
@@ -141,6 +141,91 @@ describe('AdminShell', () => {
|
|
|
141
141
|
unmount(component);
|
|
142
142
|
}
|
|
143
143
|
});
|
|
144
|
+
it('keeps a keepMounted focus panel (and its draft) mounted while collapsed', async () => {
|
|
145
|
+
const state = createShellState({
|
|
146
|
+
config: { right: { initial: 'expanded', keepMounted: true } },
|
|
147
|
+
});
|
|
148
|
+
const component = mount(AdminShell, {
|
|
149
|
+
target: container,
|
|
150
|
+
props: {
|
|
151
|
+
state,
|
|
152
|
+
children: textSnippet('main work'),
|
|
153
|
+
focusPanel: createRawSnippet(() => ({
|
|
154
|
+
render: () => '<textarea aria-label="Message"></textarea>',
|
|
155
|
+
})),
|
|
156
|
+
},
|
|
157
|
+
});
|
|
158
|
+
try {
|
|
159
|
+
const draft = container.querySelector('textarea');
|
|
160
|
+
if (!draft)
|
|
161
|
+
throw new Error('focus panel did not render');
|
|
162
|
+
draft.value = 'half-typed question';
|
|
163
|
+
state.collapsePanel('right');
|
|
164
|
+
await tick();
|
|
165
|
+
const panel = container.querySelector('.smrt-admin-shell__panel--right');
|
|
166
|
+
expect(panel?.hidden).toBe(true);
|
|
167
|
+
expect(container.querySelector('textarea')).toBe(draft);
|
|
168
|
+
state.expandPanel('right');
|
|
169
|
+
await tick();
|
|
170
|
+
expect(panel?.hidden).toBe(false);
|
|
171
|
+
expect(container.querySelector('textarea')).toBe(draft);
|
|
172
|
+
expect(draft.value).toBe('half-typed question');
|
|
173
|
+
}
|
|
174
|
+
finally {
|
|
175
|
+
unmount(component);
|
|
176
|
+
}
|
|
177
|
+
});
|
|
178
|
+
it('unmounts a collapsed panel without keepMounted', async () => {
|
|
179
|
+
const state = createShellState({
|
|
180
|
+
config: { right: { initial: 'expanded' } },
|
|
181
|
+
});
|
|
182
|
+
const component = mount(AdminShell, {
|
|
183
|
+
target: container,
|
|
184
|
+
props: {
|
|
185
|
+
state,
|
|
186
|
+
children: textSnippet('main work'),
|
|
187
|
+
focusPanel: textSnippet('focus panel'),
|
|
188
|
+
},
|
|
189
|
+
});
|
|
190
|
+
try {
|
|
191
|
+
expect(container.querySelector('.smrt-admin-shell__panel--right')).not.toBeNull();
|
|
192
|
+
state.collapsePanel('right');
|
|
193
|
+
await tick();
|
|
194
|
+
expect(container.querySelector('.smrt-admin-shell__panel--right')).toBeNull();
|
|
195
|
+
}
|
|
196
|
+
finally {
|
|
197
|
+
unmount(component);
|
|
198
|
+
}
|
|
199
|
+
});
|
|
200
|
+
it('keeps a keepMounted tenant panel beside the collapsed rail', async () => {
|
|
201
|
+
const state = createShellState({
|
|
202
|
+
config: { left: { initial: 'collapsed', keepMounted: true } },
|
|
203
|
+
});
|
|
204
|
+
const component = mount(AdminShell, {
|
|
205
|
+
target: container,
|
|
206
|
+
props: {
|
|
207
|
+
state,
|
|
208
|
+
children: textSnippet('main work'),
|
|
209
|
+
tenantRail: textSnippet('tenant rail'),
|
|
210
|
+
tenantPanel: textSnippet('tenant navigation'),
|
|
211
|
+
},
|
|
212
|
+
});
|
|
213
|
+
try {
|
|
214
|
+
const stack = container.querySelector('.smrt-admin-shell__tenant-stack');
|
|
215
|
+
expect(stack?.hidden).toBe(true);
|
|
216
|
+
expect(stack?.textContent).toContain('tenant navigation');
|
|
217
|
+
const rail = container.querySelector('.smrt-admin-shell__rail');
|
|
218
|
+
expect(rail?.textContent).toContain('tenant rail');
|
|
219
|
+
state.expandPanel('left');
|
|
220
|
+
await tick();
|
|
221
|
+
expect(container.querySelector('.smrt-admin-shell__tenant-stack')).toBe(stack);
|
|
222
|
+
expect(stack?.hidden).toBe(false);
|
|
223
|
+
expect(rail?.textContent).not.toContain('tenant rail');
|
|
224
|
+
}
|
|
225
|
+
finally {
|
|
226
|
+
unmount(component);
|
|
227
|
+
}
|
|
228
|
+
});
|
|
144
229
|
it('pins a tenant footer below the scrolling tenant panel', () => {
|
|
145
230
|
const state = createShellState({
|
|
146
231
|
config: { left: { initial: 'expanded' } },
|