@happyvertical/smrt-svelte 0.52.0 → 0.53.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/README.md +47 -0
- package/agents/components.md +8 -1
- package/dist/Provider.svelte +19 -15
- package/dist/Provider.svelte.d.ts +2 -2
- package/dist/Provider.svelte.d.ts.map +1 -1
- package/dist/__tests__/provider-mode-harness.svelte +14 -0
- package/dist/__tests__/provider-mode-harness.svelte.d.ts +11 -0
- package/dist/__tests__/provider-mode-harness.svelte.d.ts.map +1 -0
- package/dist/__tests__/provider-mode-probe.svelte +13 -0
- package/dist/__tests__/provider-mode-probe.svelte.d.ts +8 -0
- package/dist/__tests__/provider-mode-probe.svelte.d.ts.map +1 -0
- package/dist/components/forms/FileUpload.svelte +120 -16
- package/dist/components/forms/FileUpload.svelte.d.ts +7 -1
- package/dist/components/forms/FileUpload.svelte.d.ts.map +1 -1
- package/dist/components/forms/__tests__/FileUpload.native-form.test.js +249 -0
- package/dist/components/forms/__tests__/FileUploadReset.fixture.svelte +24 -0
- package/dist/components/forms/__tests__/FileUploadReset.fixture.svelte.d.ts +10 -0
- package/dist/components/forms/__tests__/FileUploadReset.fixture.svelte.d.ts.map +1 -0
- package/dist/components/workspace/__tests__/AdminShell.responsive.test.js +12 -1
- package/dist/components/workspace/__tests__/AdminShell.test.js +172 -1
- package/dist/components/workspace/__tests__/TenantNav.test.js +66 -0
- package/dist/components/workspace/__tests__/WorkspaceAccountMenu.test.js +6 -0
- package/dist/components/workspace/__tests__/admin-shell-state.test.js +25 -0
- package/dist/components/workspace/admin-shell/AdminShell.svelte +108 -26
- package/dist/components/workspace/admin-shell/AdminShell.svelte.d.ts +2 -0
- package/dist/components/workspace/admin-shell/AdminShell.svelte.d.ts.map +1 -1
- package/dist/components/workspace/admin-shell/TenantNav.svelte +70 -12
- package/dist/components/workspace/admin-shell/TenantNav.svelte.d.ts +8 -2
- package/dist/components/workspace/admin-shell/TenantNav.svelte.d.ts.map +1 -1
- package/dist/components/workspace/admin-shell/WorkspaceAccountMenu.svelte +16 -2
- package/dist/components/workspace/admin-shell/WorkspaceAccountMenu.svelte.d.ts +2 -0
- package/dist/components/workspace/admin-shell/WorkspaceAccountMenu.svelte.d.ts.map +1 -1
- package/dist/components/workspace/admin-shell/state.svelte.d.ts +2 -0
- package/dist/components/workspace/admin-shell/state.svelte.d.ts.map +1 -1
- package/dist/components/workspace/admin-shell/state.svelte.js +7 -0
- package/dist/components/workspace/admin-shell/types.d.ts +18 -1
- package/dist/components/workspace/admin-shell/types.d.ts.map +1 -1
- package/dist/components/workspace/index.d.ts +1 -1
- package/dist/components/workspace/index.d.ts.map +1 -1
- package/dist/i18n/strings.forms.d.ts +3 -0
- package/dist/i18n/strings.forms.d.ts.map +1 -1
- package/dist/i18n/strings.forms.js +3 -0
- package/dist/i18n/strings.workspace.d.ts +4 -0
- package/dist/i18n/strings.workspace.d.ts.map +1 -1
- package/dist/i18n/strings.workspace.js +4 -0
- package/dist/provider-mode.test.js +86 -0
- package/dist/state/app-state.svelte.d.ts.map +1 -1
- package/dist/state/app-state.svelte.js +8 -8
- package/package.json +16 -16
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FileUpload native-form wiring (#3260).
|
|
3
|
+
*
|
|
4
|
+
* jsdom has no `DataTransfer` constructor, so these tests cover attribute
|
|
5
|
+
* forwarding, the visually-hidden (not `hidden`) input, the required-error
|
|
6
|
+
* announcement, and the no-DataTransfer fallback. The DataTransfer path and the
|
|
7
|
+
* `new FormData(form)` post are proven in a real browser by
|
|
8
|
+
* `e2e/file-upload.spec.ts`; the form-reset cases below also run the mirror
|
|
9
|
+
* against a jsdom `DataTransfer` stand-in.
|
|
10
|
+
*/
|
|
11
|
+
import { expectNoA11yViolations } from '@happyvertical/smrt-ui/test-support/a11y';
|
|
12
|
+
import { fireEvent, render, screen } from '@testing-library/svelte';
|
|
13
|
+
import userEvent from '@testing-library/user-event';
|
|
14
|
+
import { tick } from 'svelte';
|
|
15
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
16
|
+
import FileUpload from '../FileUpload.svelte';
|
|
17
|
+
import FileUploadResetFixture from './FileUploadReset.fixture.svelte';
|
|
18
|
+
function fileInput(container) {
|
|
19
|
+
const input = container.querySelector('input[type="file"]');
|
|
20
|
+
if (!input)
|
|
21
|
+
throw new Error('file input not found');
|
|
22
|
+
return input;
|
|
23
|
+
}
|
|
24
|
+
const txt = (name, size = 4) => new File(['a'.repeat(size)], name, { type: 'text/plain' });
|
|
25
|
+
describe('FileUpload — native form wiring', () => {
|
|
26
|
+
it('forwards name, capture and required to the file input', () => {
|
|
27
|
+
const { container } = render(FileUpload, {
|
|
28
|
+
props: { name: 'photo', capture: 'environment', required: true },
|
|
29
|
+
});
|
|
30
|
+
const input = fileInput(container);
|
|
31
|
+
expect(input).toHaveAttribute('name', 'photo');
|
|
32
|
+
expect(input).toHaveAttribute('capture', 'environment');
|
|
33
|
+
expect(input).toBeRequired();
|
|
34
|
+
});
|
|
35
|
+
it('omits name, capture and required by default', () => {
|
|
36
|
+
const { container } = render(FileUpload);
|
|
37
|
+
const input = fileInput(container);
|
|
38
|
+
expect(input).not.toHaveAttribute('name');
|
|
39
|
+
expect(input).not.toHaveAttribute('capture');
|
|
40
|
+
expect(input).not.toBeRequired();
|
|
41
|
+
});
|
|
42
|
+
it('keeps the input focusable for native validation but out of the a11y tree and tab order', () => {
|
|
43
|
+
const { container } = render(FileUpload, { props: { required: true } });
|
|
44
|
+
const input = fileInput(container);
|
|
45
|
+
// `hidden` would make the control unfocusable, so the browser could not
|
|
46
|
+
// show its required bubble and would silently block the submit.
|
|
47
|
+
expect(input).not.toHaveAttribute('hidden');
|
|
48
|
+
expect(input).toHaveClass('file-upload__input');
|
|
49
|
+
expect(input).toHaveAttribute('aria-hidden', 'true');
|
|
50
|
+
expect(input).toHaveAttribute('tabindex', '-1');
|
|
51
|
+
// Not nested inside the role="button" drop zone (nested-interactive).
|
|
52
|
+
expect(screen.getByRole('button').contains(input)).toBe(false);
|
|
53
|
+
});
|
|
54
|
+
it('announces a failed required check through the error live region', async () => {
|
|
55
|
+
const { container } = render(FileUpload, { props: { required: true } });
|
|
56
|
+
const input = fileInput(container);
|
|
57
|
+
expect(input.checkValidity()).toBe(false);
|
|
58
|
+
await fireEvent(input, new Event('invalid'));
|
|
59
|
+
expect(screen.getByRole('alert').textContent?.trim()).not.toBe('');
|
|
60
|
+
});
|
|
61
|
+
it('is axe-clean with name, capture and required', async () => {
|
|
62
|
+
const { container } = render(FileUpload, {
|
|
63
|
+
props: {
|
|
64
|
+
label: 'Mill cert photo',
|
|
65
|
+
name: 'photo',
|
|
66
|
+
capture: 'environment',
|
|
67
|
+
required: true,
|
|
68
|
+
},
|
|
69
|
+
});
|
|
70
|
+
await expectNoA11yViolations(container);
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
describe('FileUpload — fallback without the DataTransfer constructor', () => {
|
|
74
|
+
it('runs in an environment without DataTransfer', () => {
|
|
75
|
+
expect(typeof globalThis.DataTransfer).toBe('undefined');
|
|
76
|
+
});
|
|
77
|
+
it('keeps the native selection when it matches the accepted list', async () => {
|
|
78
|
+
const { container } = render(FileUpload, { props: { name: 'doc' } });
|
|
79
|
+
const input = fileInput(container);
|
|
80
|
+
await userEvent.upload(input, txt('a.txt'));
|
|
81
|
+
expect(screen.getByText('a.txt')).toBeInTheDocument();
|
|
82
|
+
expect(input.files).toHaveLength(1);
|
|
83
|
+
expect(input.files?.[0].name).toBe('a.txt');
|
|
84
|
+
});
|
|
85
|
+
it('clears the input when a file is removed, so it never posts', async () => {
|
|
86
|
+
const { container } = render(FileUpload, { props: { name: 'doc' } });
|
|
87
|
+
const input = fileInput(container);
|
|
88
|
+
await userEvent.upload(input, txt('a.txt'));
|
|
89
|
+
await userEvent.click(screen.getByRole('button', { name: /remove a\.txt/i }));
|
|
90
|
+
expect(input.files).toHaveLength(0);
|
|
91
|
+
});
|
|
92
|
+
it('clears the input when the pick is rejected, so it never posts', async () => {
|
|
93
|
+
const { container } = render(FileUpload, {
|
|
94
|
+
props: { name: 'doc', maxSize: 1 },
|
|
95
|
+
});
|
|
96
|
+
const input = fileInput(container);
|
|
97
|
+
await userEvent.upload(input, txt('big.txt'));
|
|
98
|
+
expect(screen.queryByText('big.txt')).not.toBeInTheDocument();
|
|
99
|
+
expect(input.files).toHaveLength(0);
|
|
100
|
+
});
|
|
101
|
+
it('clears the input when an accumulated list no longer matches it', async () => {
|
|
102
|
+
const { container } = render(FileUpload, {
|
|
103
|
+
props: { name: 'doc', multiple: true },
|
|
104
|
+
});
|
|
105
|
+
const input = fileInput(container);
|
|
106
|
+
await userEvent.upload(input, txt('a.txt'));
|
|
107
|
+
await userEvent.upload(input, txt('b.txt'));
|
|
108
|
+
expect(screen.getByText('a.txt')).toBeInTheDocument();
|
|
109
|
+
expect(screen.getByText('b.txt')).toBeInTheDocument();
|
|
110
|
+
expect(input.files).toHaveLength(0);
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
/**
|
|
114
|
+
* A real jsdom `FileList` holding `files`: jsdom's `input.files` setter only
|
|
115
|
+
* accepts genuine instances and has no constructor, so the list comes from a
|
|
116
|
+
* detached input and is filled through its implementation object (mirrors
|
|
117
|
+
* smrt-ui's capture test environment).
|
|
118
|
+
*/
|
|
119
|
+
function createFileList(files) {
|
|
120
|
+
const implOf = (wrapper, guard) => {
|
|
121
|
+
const holder = wrapper;
|
|
122
|
+
const key = Object.getOwnPropertySymbols(wrapper).find((s) => guard(holder[s]));
|
|
123
|
+
if (!key)
|
|
124
|
+
throw new Error('jsdom wrapper has no implementation symbol');
|
|
125
|
+
return holder[key];
|
|
126
|
+
};
|
|
127
|
+
const list = document.createElement('input');
|
|
128
|
+
list.type = 'file';
|
|
129
|
+
const fileList = list.files;
|
|
130
|
+
const impl = implOf(fileList, Array.isArray);
|
|
131
|
+
for (const file of files) {
|
|
132
|
+
impl.push(implOf(file, (v) => typeof v === 'object' && v !== null));
|
|
133
|
+
}
|
|
134
|
+
return fileList;
|
|
135
|
+
}
|
|
136
|
+
/** Browser-shaped `DataTransfer` for jsdom: `items.add()` plus a real `files`. */
|
|
137
|
+
class TestDataTransfer {
|
|
138
|
+
#files = [];
|
|
139
|
+
items = {
|
|
140
|
+
add: (file) => {
|
|
141
|
+
this.#files.push(file);
|
|
142
|
+
return null;
|
|
143
|
+
},
|
|
144
|
+
};
|
|
145
|
+
get files() {
|
|
146
|
+
return createFileList(this.#files);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* A reset of the owning form (#3260): SvelteKit `enhance`'s `update()` after a
|
|
151
|
+
* success, `createFormRetry()`'s conditional reset, a reset button and
|
|
152
|
+
* `form.reset()` all empty a native file input, so the list must empty with
|
|
153
|
+
* it. jsdom's reset does not empty file inputs (browsers do), so these
|
|
154
|
+
* assertions prove the component empties its own input.
|
|
155
|
+
*/
|
|
156
|
+
describe.each([
|
|
157
|
+
['the DataTransfer mirror', true],
|
|
158
|
+
['the no-DataTransfer fallback', false],
|
|
159
|
+
])('FileUpload — form reset with %s', (_label, withDataTransfer) => {
|
|
160
|
+
beforeEach(() => {
|
|
161
|
+
if (withDataTransfer)
|
|
162
|
+
vi.stubGlobal('DataTransfer', TestDataTransfer);
|
|
163
|
+
});
|
|
164
|
+
afterEach(() => {
|
|
165
|
+
vi.unstubAllGlobals();
|
|
166
|
+
});
|
|
167
|
+
function mount(props = {}) {
|
|
168
|
+
const onchange = vi.fn();
|
|
169
|
+
const result = render(FileUploadResetFixture, {
|
|
170
|
+
props: { onchange, ...props },
|
|
171
|
+
});
|
|
172
|
+
const form = result.container.querySelector('form');
|
|
173
|
+
const bound = () => screen.getByTestId('bound').getAttribute('data-files');
|
|
174
|
+
return {
|
|
175
|
+
...result,
|
|
176
|
+
form,
|
|
177
|
+
onchange,
|
|
178
|
+
bound,
|
|
179
|
+
input: fileInput(result.container),
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
async function pick(input, files) {
|
|
183
|
+
input.files = createFileList(files);
|
|
184
|
+
await fireEvent.change(input);
|
|
185
|
+
}
|
|
186
|
+
/** Names of the non-empty `doc` entries a native submit would post. */
|
|
187
|
+
const posted = (form) => new FormData(form).getAll('doc')
|
|
188
|
+
.filter((file) => file.name !== '')
|
|
189
|
+
.map((file) => file.name);
|
|
190
|
+
it('form.reset() empties the list, its error and what posts', async () => {
|
|
191
|
+
const { form, input, onchange, bound } = mount({ maxSize: 10 });
|
|
192
|
+
await pick(input, [txt('a.txt'), txt('big.txt', 50)]);
|
|
193
|
+
expect(screen.getByText('a.txt')).toBeInTheDocument();
|
|
194
|
+
expect(bound()).toBe('a.txt');
|
|
195
|
+
expect(screen.getByRole('alert')).toBeInTheDocument();
|
|
196
|
+
if (withDataTransfer)
|
|
197
|
+
expect(posted(form)).toEqual(['a.txt']);
|
|
198
|
+
form.reset();
|
|
199
|
+
await tick();
|
|
200
|
+
expect(screen.queryByText('a.txt')).not.toBeInTheDocument();
|
|
201
|
+
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
|
|
202
|
+
expect(onchange).toHaveBeenLastCalledWith([]);
|
|
203
|
+
expect(bound()).toBe('');
|
|
204
|
+
expect(input.files).toHaveLength(0);
|
|
205
|
+
expect(posted(form)).toEqual([]);
|
|
206
|
+
// The next pick posts alone; the reset list is never re-mirrored.
|
|
207
|
+
await pick(input, [txt('b.txt')]);
|
|
208
|
+
expect(screen.getByText('b.txt')).toBeInTheDocument();
|
|
209
|
+
expect(screen.queryByText('a.txt')).not.toBeInTheDocument();
|
|
210
|
+
expect(posted(form)).toEqual(['b.txt']);
|
|
211
|
+
});
|
|
212
|
+
it('a reset button empties the list and required blocks again', async () => {
|
|
213
|
+
const { form, input } = mount({ required: true });
|
|
214
|
+
await pick(input, [txt('a.txt')]);
|
|
215
|
+
expect(input.checkValidity()).toBe(true);
|
|
216
|
+
await fireEvent.click(screen.getByRole('button', { name: 'Reset form' }));
|
|
217
|
+
expect(screen.queryByText('a.txt')).not.toBeInTheDocument();
|
|
218
|
+
expect(posted(form)).toEqual([]);
|
|
219
|
+
expect(input.validity.valueMissing).toBe(true);
|
|
220
|
+
});
|
|
221
|
+
it('empties the list even while disabled, as it empties a disabled input', async () => {
|
|
222
|
+
const { form, input, rerender, bound } = mount();
|
|
223
|
+
await pick(input, [txt('a.txt')]);
|
|
224
|
+
await rerender({ disabled: true });
|
|
225
|
+
expect(screen.getByText('a.txt')).toBeInTheDocument();
|
|
226
|
+
form.reset();
|
|
227
|
+
await tick();
|
|
228
|
+
expect(screen.queryByText('a.txt')).not.toBeInTheDocument();
|
|
229
|
+
expect(bound()).toBe('');
|
|
230
|
+
expect(input.files).toHaveLength(0);
|
|
231
|
+
});
|
|
232
|
+
it('does not report an empty list that was already empty', async () => {
|
|
233
|
+
const { form, onchange } = mount();
|
|
234
|
+
form.reset();
|
|
235
|
+
await tick();
|
|
236
|
+
expect(onchange).not.toHaveBeenCalled();
|
|
237
|
+
});
|
|
238
|
+
it('ignores the reset of another form', async () => {
|
|
239
|
+
const { input, onchange } = mount();
|
|
240
|
+
await pick(input, [txt('a.txt')]);
|
|
241
|
+
onchange.mockClear();
|
|
242
|
+
const other = document.createElement('form');
|
|
243
|
+
document.body.append(other);
|
|
244
|
+
other.reset();
|
|
245
|
+
await tick();
|
|
246
|
+
expect(screen.getByText('a.txt')).toBeInTheDocument();
|
|
247
|
+
expect(onchange).not.toHaveBeenCalled();
|
|
248
|
+
});
|
|
249
|
+
});
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import FileUpload from '../FileUpload.svelte';
|
|
3
|
+
|
|
4
|
+
let {
|
|
5
|
+
disabled = false,
|
|
6
|
+
required = false,
|
|
7
|
+
maxSize,
|
|
8
|
+
onchange,
|
|
9
|
+
}: {
|
|
10
|
+
disabled?: boolean;
|
|
11
|
+
required?: boolean;
|
|
12
|
+
maxSize?: number;
|
|
13
|
+
onchange?: (files: File[]) => void;
|
|
14
|
+
} = $props();
|
|
15
|
+
|
|
16
|
+
let files = $state<File[]>([]);
|
|
17
|
+
</script>
|
|
18
|
+
|
|
19
|
+
<!-- A plain form with a bound list and a native reset button (#3260). -->
|
|
20
|
+
<form>
|
|
21
|
+
<FileUpload name="doc" multiple {disabled} {required} {maxSize} {onchange} bind:files />
|
|
22
|
+
<output data-testid="bound" data-files={files.map((file) => file.name).join(',')}></output>
|
|
23
|
+
<button type="reset">Reset form</button>
|
|
24
|
+
</form>
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
type $$ComponentProps = {
|
|
2
|
+
disabled?: boolean;
|
|
3
|
+
required?: boolean;
|
|
4
|
+
maxSize?: number;
|
|
5
|
+
onchange?: (files: File[]) => void;
|
|
6
|
+
};
|
|
7
|
+
declare const FileUploadReset: import("svelte").Component<$$ComponentProps, {}, "">;
|
|
8
|
+
type FileUploadReset = ReturnType<typeof FileUploadReset>;
|
|
9
|
+
export default FileUploadReset;
|
|
10
|
+
//# sourceMappingURL=FileUploadReset.fixture.svelte.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"FileUploadReset.fixture.svelte.d.ts","sourceRoot":"","sources":["../../../../src/components/forms/__tests__/FileUploadReset.fixture.svelte.ts"],"names":[],"mappings":"AAKC,KAAK,gBAAgB,GAAI;IACxB,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,QAAQ,CAAC,EAAE,CAAC,KAAK,EAAE,IAAI,EAAE,KAAK,IAAI,CAAC;CACpC,CAAC;AAuBF,QAAA,MAAM,eAAe,sDAAwC,CAAC;AAC9D,KAAK,eAAe,GAAG,UAAU,CAAC,OAAO,eAAe,CAAC,CAAC;AAC1D,eAAe,eAAe,CAAC"}
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* and bottom bars, form action bars, scrim, navigation, phone presentations
|
|
4
4
|
* of the right edge, and resizable side edges.
|
|
5
5
|
*/
|
|
6
|
-
import { fireEvent, render, screen } from '@testing-library/svelte';
|
|
6
|
+
import { fireEvent, render, screen, waitFor } from '@testing-library/svelte';
|
|
7
7
|
import { createRawSnippet, flushSync, tick } from 'svelte';
|
|
8
8
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
9
9
|
import AdminShell from '../admin-shell/AdminShell.svelte';
|
|
@@ -165,6 +165,17 @@ describe('AdminShell on a phone', () => {
|
|
|
165
165
|
await settle();
|
|
166
166
|
expect(left.inert).toBe(false);
|
|
167
167
|
});
|
|
168
|
+
it('moves focus into the phone drawer without animation enumeration', async () => {
|
|
169
|
+
const { shell } = renderPhone({
|
|
170
|
+
tenantPanel: html('<a href="/phone-target">Phone navigation target</a>'),
|
|
171
|
+
});
|
|
172
|
+
await settle();
|
|
173
|
+
const left = document.getElementById('smrt-admin-shell-left-panel');
|
|
174
|
+
Object.defineProperty(left, 'getAnimations', { value: undefined });
|
|
175
|
+
shell.expandPanel('left');
|
|
176
|
+
await settle();
|
|
177
|
+
await waitFor(() => expect(left.contains(document.activeElement)).toBe(true));
|
|
178
|
+
});
|
|
168
179
|
it('closes an open drawer when the path changes', async () => {
|
|
169
180
|
const { shell, rerender } = renderPhone();
|
|
170
181
|
await settle();
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createRawSnippet, flushSync, mount, tick, unmount } from 'svelte';
|
|
2
|
-
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
2
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
3
3
|
import AdminShell from '../admin-shell/AdminShell.svelte';
|
|
4
4
|
import adminShellSource from '../admin-shell/AdminShell.svelte?raw';
|
|
5
5
|
import { createShellState } from '../admin-shell/state.svelte.js';
|
|
@@ -15,13 +15,184 @@ function activeToolSnippet() {
|
|
|
15
15
|
}
|
|
16
16
|
let container;
|
|
17
17
|
beforeEach(() => {
|
|
18
|
+
vi.stubGlobal('matchMedia', () => ({
|
|
19
|
+
matches: false,
|
|
20
|
+
addEventListener() { },
|
|
21
|
+
removeEventListener() { },
|
|
22
|
+
}));
|
|
18
23
|
container = document.createElement('div');
|
|
19
24
|
document.body.appendChild(container);
|
|
20
25
|
});
|
|
21
26
|
afterEach(() => {
|
|
27
|
+
vi.unstubAllGlobals();
|
|
22
28
|
container.remove();
|
|
23
29
|
});
|
|
24
30
|
describe('AdminShell', () => {
|
|
31
|
+
it('keeps a single tenant footer reachable when the left edge collapses or hides', async () => {
|
|
32
|
+
const state = createShellState({
|
|
33
|
+
config: { left: { initial: 'collapsed' } },
|
|
34
|
+
});
|
|
35
|
+
const component = mount(AdminShell, {
|
|
36
|
+
target: container,
|
|
37
|
+
props: {
|
|
38
|
+
state,
|
|
39
|
+
tenantFooter: textSnippet('Dana account'),
|
|
40
|
+
children: textSnippet('main'),
|
|
41
|
+
},
|
|
42
|
+
});
|
|
43
|
+
try {
|
|
44
|
+
await tick();
|
|
45
|
+
expect(container.querySelector('header')?.textContent).toContain('Dana account');
|
|
46
|
+
state.setPanelState('left', 'expanded');
|
|
47
|
+
flushSync();
|
|
48
|
+
expect(container.querySelector('header')?.textContent).not.toContain('Dana account');
|
|
49
|
+
expect(container.querySelector('.smrt-admin-shell__tenant-footer')
|
|
50
|
+
?.textContent).toContain('Dana account');
|
|
51
|
+
state.setPanelState('left', 'hidden');
|
|
52
|
+
flushSync();
|
|
53
|
+
expect(container.querySelector('header')?.textContent).toContain('Dana account');
|
|
54
|
+
expect(container.textContent?.match(/Dana account/g)).toHaveLength(1);
|
|
55
|
+
}
|
|
56
|
+
finally {
|
|
57
|
+
await unmount(component);
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
it('keeps an explicit account slot in a custom app bar regardless of left state', async () => {
|
|
61
|
+
const state = createShellState({
|
|
62
|
+
config: { left: { initial: 'expanded' } },
|
|
63
|
+
});
|
|
64
|
+
const component = mount(AdminShell, {
|
|
65
|
+
target: container,
|
|
66
|
+
props: {
|
|
67
|
+
state,
|
|
68
|
+
account: textSnippet('Dana account'),
|
|
69
|
+
appBar: textSnippet('Custom title'),
|
|
70
|
+
children: textSnippet('main'),
|
|
71
|
+
},
|
|
72
|
+
});
|
|
73
|
+
try {
|
|
74
|
+
await tick();
|
|
75
|
+
expect(container.querySelector('header')?.textContent).toContain('Dana account');
|
|
76
|
+
}
|
|
77
|
+
finally {
|
|
78
|
+
await unmount(component);
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
it('keeps the left collapse control with supplied navigation and permits opt-out', async () => {
|
|
82
|
+
for (const showTenantToggle of [true, false]) {
|
|
83
|
+
const state = createShellState({
|
|
84
|
+
config: { left: { initial: 'expanded' } },
|
|
85
|
+
});
|
|
86
|
+
const component = mount(AdminShell, {
|
|
87
|
+
target: container,
|
|
88
|
+
props: {
|
|
89
|
+
state,
|
|
90
|
+
showTenantToggle,
|
|
91
|
+
tenantPanel: textSnippet('supplied nav'),
|
|
92
|
+
children: textSnippet('main'),
|
|
93
|
+
},
|
|
94
|
+
});
|
|
95
|
+
try {
|
|
96
|
+
await tick();
|
|
97
|
+
const toggle = container.querySelector('.smrt-admin-shell__edge--left .smrt-admin-shell__edge-toggle');
|
|
98
|
+
expect(!!toggle).toBe(showTenantToggle);
|
|
99
|
+
if (toggle) {
|
|
100
|
+
toggle.click();
|
|
101
|
+
flushSync();
|
|
102
|
+
expect(state.panels.left).toBe('collapsed');
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
finally {
|
|
106
|
+
await unmount(component);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
});
|
|
110
|
+
it('preserves the system toggle when a custom system bar is supplied', async () => {
|
|
111
|
+
const component = mount(AdminShell, {
|
|
112
|
+
target: container,
|
|
113
|
+
props: {
|
|
114
|
+
systemBar: textSnippet('custom status'),
|
|
115
|
+
children: textSnippet('main'),
|
|
116
|
+
},
|
|
117
|
+
});
|
|
118
|
+
try {
|
|
119
|
+
await tick();
|
|
120
|
+
expect(container.querySelector('footer .smrt-admin-shell__edge-toggle')).not.toBeNull();
|
|
121
|
+
}
|
|
122
|
+
finally {
|
|
123
|
+
unmount(component);
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
it('links full and compact branding home with an accessible title', async () => {
|
|
127
|
+
const state = createShellState({
|
|
128
|
+
config: { left: { initial: 'collapsed' } },
|
|
129
|
+
});
|
|
130
|
+
const component = mount(AdminShell, {
|
|
131
|
+
target: container,
|
|
132
|
+
props: {
|
|
133
|
+
state,
|
|
134
|
+
title: 'Shop',
|
|
135
|
+
homeHref: '/home',
|
|
136
|
+
children: textSnippet('main'),
|
|
137
|
+
},
|
|
138
|
+
});
|
|
139
|
+
try {
|
|
140
|
+
await tick();
|
|
141
|
+
const links = container.querySelectorAll('.smrt-admin-shell__brand-link');
|
|
142
|
+
expect(links).toHaveLength(2);
|
|
143
|
+
expect([...links].map((link) => link.getAttribute('href'))).toEqual([
|
|
144
|
+
'/home',
|
|
145
|
+
'/home',
|
|
146
|
+
]);
|
|
147
|
+
expect([...links].map((link) => link.getAttribute('aria-label'))).toEqual(['Shop', 'Shop']);
|
|
148
|
+
expect(links[1].textContent?.trim()).toBe('S');
|
|
149
|
+
}
|
|
150
|
+
finally {
|
|
151
|
+
unmount(component);
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
it('renders a logo and keeps default branding unlinked without homeHref', async () => {
|
|
155
|
+
const component = mount(AdminShell, {
|
|
156
|
+
target: container,
|
|
157
|
+
props: {
|
|
158
|
+
title: 'Shop',
|
|
159
|
+
logoSrc: '/shop.svg',
|
|
160
|
+
logoAlt: 'Shop mark',
|
|
161
|
+
children: textSnippet('main'),
|
|
162
|
+
},
|
|
163
|
+
});
|
|
164
|
+
try {
|
|
165
|
+
await tick();
|
|
166
|
+
expect(container.querySelector('img')?.getAttribute('src')).toBe('/shop.svg');
|
|
167
|
+
expect(container.querySelector('img')?.getAttribute('alt')).toBe('Shop mark');
|
|
168
|
+
expect(container.querySelector('.smrt-admin-shell__brand-link')).toBeNull();
|
|
169
|
+
}
|
|
170
|
+
finally {
|
|
171
|
+
unmount(component);
|
|
172
|
+
}
|
|
173
|
+
});
|
|
174
|
+
it('passes full and compact context to custom brand snippets', async () => {
|
|
175
|
+
const brand = createRawSnippet((context) => ({
|
|
176
|
+
render: () => `<span>${context().compact ? 'Compact mark' : 'Full mark'}</span>`,
|
|
177
|
+
}));
|
|
178
|
+
const component = mount(AdminShell, {
|
|
179
|
+
target: container,
|
|
180
|
+
props: {
|
|
181
|
+
title: 'Shop',
|
|
182
|
+
homeHref: '/',
|
|
183
|
+
brand,
|
|
184
|
+
children: textSnippet('main'),
|
|
185
|
+
},
|
|
186
|
+
});
|
|
187
|
+
try {
|
|
188
|
+
await tick();
|
|
189
|
+
expect(container.textContent).toContain('Full mark');
|
|
190
|
+
expect(container.textContent).toContain('Compact mark');
|
|
191
|
+
}
|
|
192
|
+
finally {
|
|
193
|
+
unmount(component);
|
|
194
|
+
}
|
|
195
|
+
});
|
|
25
196
|
it('renders the four edge shell and body content', () => {
|
|
26
197
|
const component = mount(AdminShell, {
|
|
27
198
|
target: container,
|
|
@@ -11,6 +11,72 @@ afterEach(() => {
|
|
|
11
11
|
container.remove();
|
|
12
12
|
});
|
|
13
13
|
describe('TenantNav', () => {
|
|
14
|
+
it('renders named groups alongside flat links and keeps grouped active children', () => {
|
|
15
|
+
const component = mount(TenantNav, {
|
|
16
|
+
target: container,
|
|
17
|
+
props: {
|
|
18
|
+
'aria-label': 'Shop navigation',
|
|
19
|
+
currentHref: '/orders/open',
|
|
20
|
+
items: [{ href: '/home', label: 'Home' }],
|
|
21
|
+
groups: [
|
|
22
|
+
{
|
|
23
|
+
heading: 'Floor',
|
|
24
|
+
items: [
|
|
25
|
+
{
|
|
26
|
+
href: '/orders',
|
|
27
|
+
label: 'Orders',
|
|
28
|
+
children: [{ href: '/orders/open', label: 'Open orders' }],
|
|
29
|
+
},
|
|
30
|
+
],
|
|
31
|
+
},
|
|
32
|
+
{ heading: 'Office', items: [] },
|
|
33
|
+
],
|
|
34
|
+
},
|
|
35
|
+
});
|
|
36
|
+
try {
|
|
37
|
+
expect(container.querySelector('nav')).toHaveAttribute('aria-label', 'Shop navigation');
|
|
38
|
+
expect(container.querySelector('details[aria-label="Floor"]')).not.toBeNull();
|
|
39
|
+
expect(container.querySelectorAll('details')).toHaveLength(2);
|
|
40
|
+
expect(container.querySelector('a[href="/orders/open"]')).toHaveAttribute('aria-current', 'page');
|
|
41
|
+
expect(container.querySelectorAll('a')).toHaveLength(3);
|
|
42
|
+
}
|
|
43
|
+
finally {
|
|
44
|
+
unmount(component);
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
it('keeps collapsed group summaries named and independent of navigation links', () => {
|
|
48
|
+
const component = mount(TenantNav, {
|
|
49
|
+
target: container,
|
|
50
|
+
props: {
|
|
51
|
+
items: [],
|
|
52
|
+
collapsed: true,
|
|
53
|
+
groups: [
|
|
54
|
+
{ heading: 'Floor', items: [{ href: '/tasks', label: 'Tasks' }] },
|
|
55
|
+
],
|
|
56
|
+
},
|
|
57
|
+
});
|
|
58
|
+
try {
|
|
59
|
+
const summary = container.querySelector('summary');
|
|
60
|
+
expect(summary?.textContent).toContain('Floor');
|
|
61
|
+
expect(container.querySelector('details')).toHaveAttribute('open');
|
|
62
|
+
expect(container.querySelectorAll('a')).toHaveLength(1);
|
|
63
|
+
}
|
|
64
|
+
finally {
|
|
65
|
+
unmount(component);
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
it('accepts per-instance touch density while leaving the default inherited', () => {
|
|
69
|
+
const component = mount(TenantNav, {
|
|
70
|
+
target: container,
|
|
71
|
+
props: { density: 'touch', items: [{ href: '/tasks', label: 'Tasks' }] },
|
|
72
|
+
});
|
|
73
|
+
try {
|
|
74
|
+
expect(container.querySelector('nav')).toHaveAttribute('data-density', 'touch');
|
|
75
|
+
}
|
|
76
|
+
finally {
|
|
77
|
+
unmount(component);
|
|
78
|
+
}
|
|
79
|
+
});
|
|
14
80
|
it('renders custom icon components for nav items', () => {
|
|
15
81
|
const component = mount(TenantNav, {
|
|
16
82
|
target: container,
|
|
@@ -8,6 +8,12 @@ const tenants = [
|
|
|
8
8
|
{ id: 'tenant-b', label: 'Beta', roleLabel: 'Member' },
|
|
9
9
|
];
|
|
10
10
|
describe('WorkspaceAccountMenu', () => {
|
|
11
|
+
it('exposes typed touch density without changing account actions', () => {
|
|
12
|
+
const { container } = render(WorkspaceAccountMenu, {
|
|
13
|
+
props: { userName: 'Dana', density: 'touch', onSignOut: vi.fn() },
|
|
14
|
+
});
|
|
15
|
+
expect(container.querySelector('.smrt-workspace-account-menu')).toHaveAttribute('data-density', 'touch');
|
|
16
|
+
});
|
|
11
17
|
it('switches tenant and signs out through app-owned callbacks', async () => {
|
|
12
18
|
const user = userEvent.setup();
|
|
13
19
|
const onTenantSelect = vi.fn();
|
|
@@ -28,6 +28,31 @@ function mountMutationEffect(shell, run) {
|
|
|
28
28
|
};
|
|
29
29
|
}
|
|
30
30
|
describe('ShellState', () => {
|
|
31
|
+
it('persists runtime hide/show and restores hidden preferences on hydration', async () => {
|
|
32
|
+
const write = vi.fn();
|
|
33
|
+
const shell = createShellState({
|
|
34
|
+
settingsAdapter: { read: () => null, write },
|
|
35
|
+
});
|
|
36
|
+
shell.setPanelState('left', 'hidden');
|
|
37
|
+
expect(shell.panels.left).toBe('hidden');
|
|
38
|
+
expect(write).toHaveBeenLastCalledWith(expect.objectContaining({ panels: { left: 'hidden' } }));
|
|
39
|
+
const restored = createShellState({
|
|
40
|
+
settingsAdapter: { read: () => shell.snapshot().settings, write },
|
|
41
|
+
});
|
|
42
|
+
await restored.hydrate();
|
|
43
|
+
expect(restored.panels.left).toBe('hidden');
|
|
44
|
+
restored.setPanelState('left', 'expanded');
|
|
45
|
+
expect(restored.panels.left).toBe('expanded');
|
|
46
|
+
restored.setPanelState('left', 'collapsed');
|
|
47
|
+
expect(restored.panels.left).toBe('collapsed');
|
|
48
|
+
});
|
|
49
|
+
it('keeps app-disabled edges hidden and rejects unknown runtime panel states', () => {
|
|
50
|
+
const shell = createShellState({ config: { right: false } });
|
|
51
|
+
shell.setPanelState('right', 'expanded');
|
|
52
|
+
expect(shell.panels.right).toBe('hidden');
|
|
53
|
+
shell.setPanelState('left', 'unknown');
|
|
54
|
+
expect(shell.panels.left).toBe('collapsed');
|
|
55
|
+
});
|
|
31
56
|
it('allows panels to expand independently by default', () => {
|
|
32
57
|
const shell = createShellState();
|
|
33
58
|
shell.expandPanel('top');
|