@happyvertical/smrt-svelte 0.38.26 → 0.39.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 +22 -0
- package/README.md +9 -7
- package/dist/components/forms/Form.svelte +112 -2
- package/dist/components/forms/Form.svelte.d.ts +12 -1
- package/dist/components/forms/Form.svelte.d.ts.map +1 -1
- package/dist/components/settings/SettingsCatalog.svelte +283 -0
- package/dist/components/settings/SettingsCatalog.svelte.d.ts +52 -0
- package/dist/components/settings/SettingsCatalog.svelte.d.ts.map +1 -0
- package/dist/components/settings/__tests__/SettingsCatalog.test.js +69 -0
- package/dist/components/settings/__tests__/catalog.test.js +46 -0
- package/dist/components/settings/catalog.d.ts +11 -0
- package/dist/components/settings/catalog.d.ts.map +1 -0
- package/dist/components/settings/catalog.js +40 -0
- package/dist/components/settings/index.d.ts +4 -0
- package/dist/components/settings/index.d.ts.map +1 -0
- package/dist/components/settings/index.js +2 -0
- package/dist/components/settings/types.d.ts +25 -0
- package/dist/components/settings/types.d.ts.map +1 -0
- package/dist/components/settings/types.js +1 -0
- package/dist/components/workspace/README.md +11 -1
- package/dist/components/workspace/__tests__/AdminShell.test.js +82 -0
- package/dist/components/workspace/__tests__/WorkspaceAccountMenu.test.js +73 -0
- package/dist/components/workspace/admin-shell/AdminShell.svelte +66 -15
- package/dist/components/workspace/admin-shell/AdminShell.svelte.d.ts +5 -2
- package/dist/components/workspace/admin-shell/AdminShell.svelte.d.ts.map +1 -1
- package/dist/components/workspace/admin-shell/WorkspaceAccountMenu.svelte +188 -0
- package/dist/components/workspace/admin-shell/WorkspaceAccountMenu.svelte.d.ts +19 -0
- package/dist/components/workspace/admin-shell/WorkspaceAccountMenu.svelte.d.ts.map +1 -0
- package/dist/components/workspace/admin-shell/types.d.ts +7 -0
- package/dist/components/workspace/admin-shell/types.d.ts.map +1 -1
- package/dist/components/workspace/index.d.ts +2 -1
- package/dist/components/workspace/index.d.ts.map +1 -1
- package/dist/components/workspace/index.js +1 -0
- package/dist/hooks/useTheme.d.ts +1 -1
- package/dist/hooks/useTheme.d.ts.map +1 -1
- package/dist/hooks/useTheme.js +1 -1
- package/dist/i18n/strings.settings.d.ts +12 -0
- package/dist/i18n/strings.settings.d.ts.map +1 -0
- package/dist/i18n/strings.settings.js +12 -0
- package/dist/i18n/strings.workspace.d.ts +3 -0
- package/dist/i18n/strings.workspace.d.ts.map +1 -1
- package/dist/i18n/strings.workspace.js +3 -0
- package/dist/state/form-context.d.ts +19 -0
- package/dist/state/form-context.d.ts.map +1 -1
- package/dist/web/__tests__/update-available-harness.svelte +4 -1
- package/dist/web/__tests__/update-available-harness.svelte.d.ts.map +1 -1
- package/package.json +23 -16
- package/LICENSE +0 -7
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { expectNoA11yViolations } from '@happyvertical/smrt-ui/test-support/a11y';
|
|
2
|
+
import { render, screen } from '@testing-library/svelte';
|
|
3
|
+
import { createRawSnippet } from 'svelte';
|
|
4
|
+
import { describe, expect, it } from 'vitest';
|
|
5
|
+
import SettingsCatalog from '../SettingsCatalog.svelte';
|
|
6
|
+
const first = {
|
|
7
|
+
id: 'first',
|
|
8
|
+
label: 'First setting',
|
|
9
|
+
description: 'First description',
|
|
10
|
+
eyebrow: 'General',
|
|
11
|
+
status: 'Default',
|
|
12
|
+
template: 'First template',
|
|
13
|
+
};
|
|
14
|
+
const second = {
|
|
15
|
+
id: 'second',
|
|
16
|
+
label: 'Second setting',
|
|
17
|
+
description: 'Second description',
|
|
18
|
+
eyebrow: 'Advanced',
|
|
19
|
+
status: 'Override',
|
|
20
|
+
template: 'Second template',
|
|
21
|
+
};
|
|
22
|
+
function detailSnippet() {
|
|
23
|
+
return createRawSnippet((context) => ({
|
|
24
|
+
render: () => `<h2>${context().item.label} editor</h2><p>${context().item.template}</p>`,
|
|
25
|
+
}));
|
|
26
|
+
}
|
|
27
|
+
describe('SettingsCatalog', () => {
|
|
28
|
+
it('renders a compact page, selected detail, and query-preserving links', () => {
|
|
29
|
+
const { container } = render((SettingsCatalog), {
|
|
30
|
+
props: {
|
|
31
|
+
baseUrl: '/settings/prompts',
|
|
32
|
+
detail: detailSnippet(),
|
|
33
|
+
preservedParams: { locale: 'fr-CA' },
|
|
34
|
+
page: {
|
|
35
|
+
items: [first, second],
|
|
36
|
+
selected: second,
|
|
37
|
+
query: 'tenant copy',
|
|
38
|
+
page: 2,
|
|
39
|
+
pageSize: 2,
|
|
40
|
+
total: 6,
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
});
|
|
44
|
+
expect(screen.getByRole('searchbox')).toHaveValue('tenant copy');
|
|
45
|
+
expect(screen.getByRole('heading', { name: 'Second setting editor' })).toBeInTheDocument();
|
|
46
|
+
expect(container.querySelector('a[aria-current="true"]')).toHaveAttribute('href', '/settings/prompts?locale=fr-CA&q=tenant+copy&page=2&selected=second');
|
|
47
|
+
expect(screen.getByRole('link', { name: 'Previous' })).toHaveAttribute('href', '/settings/prompts?locale=fr-CA&q=tenant+copy');
|
|
48
|
+
expect(screen.getByRole('link', { name: 'Next' })).toHaveAttribute('href', '/settings/prompts?locale=fr-CA&q=tenant+copy&page=3');
|
|
49
|
+
});
|
|
50
|
+
it('renders an empty state and remains axe-clean', async () => {
|
|
51
|
+
const { container } = render((SettingsCatalog), {
|
|
52
|
+
props: {
|
|
53
|
+
baseUrl: '/settings/prompts',
|
|
54
|
+
detail: detailSnippet(),
|
|
55
|
+
page: {
|
|
56
|
+
items: [],
|
|
57
|
+
selected: null,
|
|
58
|
+
query: 'missing',
|
|
59
|
+
page: 1,
|
|
60
|
+
pageSize: 50,
|
|
61
|
+
total: 0,
|
|
62
|
+
},
|
|
63
|
+
},
|
|
64
|
+
});
|
|
65
|
+
expect(screen.getByText('No settings match this search.')).toBeInTheDocument();
|
|
66
|
+
expect(screen.getByText('Select a setting to edit.')).toBeInTheDocument();
|
|
67
|
+
await expectNoA11yViolations(container);
|
|
68
|
+
});
|
|
69
|
+
});
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { paginateSettingsCatalog } from '../catalog.js';
|
|
3
|
+
const items = Array.from({ length: 125 }, (_, index) => ({
|
|
4
|
+
id: `setting-${index + 1}`,
|
|
5
|
+
label: `Setting ${index + 1}`,
|
|
6
|
+
description: index % 2 === 0 ? 'Alpha group' : 'Beta group',
|
|
7
|
+
}));
|
|
8
|
+
describe('paginateSettingsCatalog', () => {
|
|
9
|
+
it('filters before paging and keeps the DOM-sized slice bounded', () => {
|
|
10
|
+
const page = paginateSettingsCatalog(items, {
|
|
11
|
+
query: 'alpha',
|
|
12
|
+
page: 2,
|
|
13
|
+
pageSize: 20,
|
|
14
|
+
});
|
|
15
|
+
expect(page.total).toBe(63);
|
|
16
|
+
expect(page.items).toHaveLength(20);
|
|
17
|
+
expect(page.page).toBe(2);
|
|
18
|
+
expect(page.items.every((item) => item.description === 'Alpha group')).toBe(true);
|
|
19
|
+
});
|
|
20
|
+
it('clamps invalid pages and page sizes', () => {
|
|
21
|
+
const page = paginateSettingsCatalog(items, {
|
|
22
|
+
page: 999,
|
|
23
|
+
pageSize: 1_000,
|
|
24
|
+
});
|
|
25
|
+
expect(page.pageSize).toBe(100);
|
|
26
|
+
expect(page.page).toBe(2);
|
|
27
|
+
expect(page.items).toHaveLength(25);
|
|
28
|
+
});
|
|
29
|
+
it('selects an explicit item or falls back to the first visible item', () => {
|
|
30
|
+
expect(paginateSettingsCatalog(items, { selectedId: 'setting-73' }).selected?.id).toBe('setting-73');
|
|
31
|
+
expect(paginateSettingsCatalog(items).selected?.id).toBe('setting-1');
|
|
32
|
+
});
|
|
33
|
+
it('keeps a very large code-first registry to one bounded result page', () => {
|
|
34
|
+
const largeRegistry = Array.from({ length: 10_000 }, (_, index) => ({
|
|
35
|
+
id: `large-${index}`,
|
|
36
|
+
label: `Large setting ${index}`,
|
|
37
|
+
}));
|
|
38
|
+
const page = paginateSettingsCatalog(largeRegistry, {
|
|
39
|
+
page: 100,
|
|
40
|
+
pageSize: 50,
|
|
41
|
+
});
|
|
42
|
+
expect(page.total).toBe(10_000);
|
|
43
|
+
expect(page.items).toHaveLength(50);
|
|
44
|
+
expect(page.items[0]?.id).toBe('large-4950');
|
|
45
|
+
});
|
|
46
|
+
});
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { PaginateSettingsCatalogOptions, SettingsCatalogItem, SettingsCatalogPage } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Search and page an in-memory settings registry before it reaches the DOM.
|
|
4
|
+
*
|
|
5
|
+
* Applications backed by a database can construct {@link SettingsCatalogPage}
|
|
6
|
+
* directly from their server-side query instead. This helper is for code-first
|
|
7
|
+
* registries that already exist in memory, such as prompt and language
|
|
8
|
+
* definitions.
|
|
9
|
+
*/
|
|
10
|
+
export declare function paginateSettingsCatalog<T extends SettingsCatalogItem>(source: readonly T[], options?: PaginateSettingsCatalogOptions<T>): SettingsCatalogPage<T>;
|
|
11
|
+
//# sourceMappingURL=catalog.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"catalog.d.ts","sourceRoot":"","sources":["../../../src/components/settings/catalog.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,8BAA8B,EAC9B,mBAAmB,EACnB,mBAAmB,EACpB,MAAM,YAAY,CAAC;AAKpB;;;;;;;GAOG;AACH,wBAAgB,uBAAuB,CAAC,CAAC,SAAS,mBAAmB,EACnE,MAAM,EAAE,SAAS,CAAC,EAAE,EACpB,OAAO,GAAE,8BAA8B,CAAC,CAAC,CAAM,GAC9C,mBAAmB,CAAC,CAAC,CAAC,CA4BxB"}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
const DEFAULT_PAGE_SIZE = 50;
|
|
2
|
+
const MAX_PAGE_SIZE = 100;
|
|
3
|
+
/**
|
|
4
|
+
* Search and page an in-memory settings registry before it reaches the DOM.
|
|
5
|
+
*
|
|
6
|
+
* Applications backed by a database can construct {@link SettingsCatalogPage}
|
|
7
|
+
* directly from their server-side query instead. This helper is for code-first
|
|
8
|
+
* registries that already exist in memory, such as prompt and language
|
|
9
|
+
* definitions.
|
|
10
|
+
*/
|
|
11
|
+
export function paginateSettingsCatalog(source, options = {}) {
|
|
12
|
+
const query = options.query?.trim() ?? '';
|
|
13
|
+
const needle = query.toLocaleLowerCase();
|
|
14
|
+
const getSearchText = options.getSearchText ?? defaultSearchText;
|
|
15
|
+
const filtered = needle
|
|
16
|
+
? source.filter((item) => getSearchText(item).toLocaleLowerCase().includes(needle))
|
|
17
|
+
: [...source];
|
|
18
|
+
const pageSize = clampInteger(options.pageSize, 1, MAX_PAGE_SIZE, DEFAULT_PAGE_SIZE);
|
|
19
|
+
const total = filtered.length;
|
|
20
|
+
const totalPages = Math.max(1, Math.ceil(total / pageSize));
|
|
21
|
+
const page = clampInteger(options.page, 1, totalPages, 1);
|
|
22
|
+
const offset = (page - 1) * pageSize;
|
|
23
|
+
const items = filtered.slice(offset, offset + pageSize);
|
|
24
|
+
const selected = (options.selectedId
|
|
25
|
+
? filtered.find((item) => item.id === options.selectedId)
|
|
26
|
+
: null) ??
|
|
27
|
+
items[0] ??
|
|
28
|
+
null;
|
|
29
|
+
return { items, selected, query, page, pageSize, total };
|
|
30
|
+
}
|
|
31
|
+
function defaultSearchText(item) {
|
|
32
|
+
return [item.label, item.description, item.eyebrow, item.status]
|
|
33
|
+
.filter(Boolean)
|
|
34
|
+
.join(' ');
|
|
35
|
+
}
|
|
36
|
+
function clampInteger(value, minimum, maximum, fallback) {
|
|
37
|
+
if (!Number.isFinite(value))
|
|
38
|
+
return fallback;
|
|
39
|
+
return Math.min(maximum, Math.max(minimum, Math.trunc(value)));
|
|
40
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export { paginateSettingsCatalog } from './catalog.js';
|
|
2
|
+
export { default as SettingsCatalog } from './SettingsCatalog.svelte';
|
|
3
|
+
export type { PaginateSettingsCatalogOptions, SettingsCatalogItem, SettingsCatalogPage, } from './types.js';
|
|
4
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/components/settings/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,uBAAuB,EAAE,MAAM,cAAc,CAAC;AACvD,OAAO,EAAE,OAAO,IAAI,eAAe,EAAE,MAAM,0BAA0B,CAAC;AACtE,YAAY,EACV,8BAA8B,EAC9B,mBAAmB,EACnB,mBAAmB,GACpB,MAAM,YAAY,CAAC"}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export interface SettingsCatalogItem {
|
|
2
|
+
id: string;
|
|
3
|
+
label: string;
|
|
4
|
+
description?: string;
|
|
5
|
+
eyebrow?: string;
|
|
6
|
+
status?: string;
|
|
7
|
+
}
|
|
8
|
+
export interface SettingsCatalogPage<T extends SettingsCatalogItem, D extends {
|
|
9
|
+
id: string;
|
|
10
|
+
} = T> {
|
|
11
|
+
items: T[];
|
|
12
|
+
selected: D | null;
|
|
13
|
+
query: string;
|
|
14
|
+
page: number;
|
|
15
|
+
pageSize: number;
|
|
16
|
+
total: number;
|
|
17
|
+
}
|
|
18
|
+
export interface PaginateSettingsCatalogOptions<T extends SettingsCatalogItem> {
|
|
19
|
+
query?: string | null;
|
|
20
|
+
page?: number | null;
|
|
21
|
+
pageSize?: number | null;
|
|
22
|
+
selectedId?: string | null;
|
|
23
|
+
getSearchText?: (item: T) => string;
|
|
24
|
+
}
|
|
25
|
+
//# sourceMappingURL=types.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../../src/components/settings/types.ts"],"names":[],"mappings":"AAAA,MAAM,WAAW,mBAAmB;IAClC,EAAE,EAAE,MAAM,CAAC;IACX,KAAK,EAAE,MAAM,CAAC;IACd,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,WAAW,mBAAmB,CAClC,CAAC,SAAS,mBAAmB,EAC7B,CAAC,SAAS;IAAE,EAAE,EAAE,MAAM,CAAA;CAAE,GAAG,CAAC;IAE5B,KAAK,EAAE,CAAC,EAAE,CAAC;IACX,QAAQ,EAAE,CAAC,GAAG,IAAI,CAAC;IACnB,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,EAAE,MAAM,CAAC;IACb,QAAQ,EAAE,MAAM,CAAC;IACjB,KAAK,EAAE,MAAM,CAAC;CACf;AAED,MAAM,WAAW,8BAA8B,CAAC,CAAC,SAAS,mBAAmB;IAC3E,KAAK,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACtB,IAAI,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACrB,QAAQ,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACzB,UAAU,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC3B,aAAa,CAAC,EAAE,CAAC,IAAI,EAAE,CAAC,KAAK,MAAM,CAAC;CACrC"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -20,11 +20,21 @@ SvelteKit route APIs; apps pass data, endpoints, and permission-filtered nav in.
|
|
|
20
20
|
- `AdminShell`
|
|
21
21
|
- `ShellState` / `createShellState`
|
|
22
22
|
- `ShellSettingsPanel`, `HotkeyInput`, `ShortcutsOverlay`
|
|
23
|
-
- `ShellDockTool`, `TenantNav`
|
|
23
|
+
- `ShellCorner`, `ShellDockTool`, `TenantNav`, `WorkspaceAccountMenu`
|
|
24
24
|
- `ActivityBadge`, `ActivityList`, `ActivityItem`, `ActivityToasts`
|
|
25
25
|
- `AppScopePanel`, `SystemStatusChips`, `SystemScopePanel`
|
|
26
26
|
- `tenantNavFromManifest`
|
|
27
27
|
|
|
28
|
+
Use `AdminShell`'s `tenantFooter` snippet with `WorkspaceAccountMenu` to keep a
|
|
29
|
+
single-line tenant/account control pinned below scrolling tenant navigation.
|
|
30
|
+
Tenant switching and sign-out stay app-owned callbacks, so the workspace core
|
|
31
|
+
remains independent of SvelteKit and `smrt-users` route conventions.
|
|
32
|
+
|
|
33
|
+
Keep the `appBar` snippet for content in the center header band. Side-aligned
|
|
34
|
+
identity and action content belongs in `topLeftCorner` / `topRightCorner`,
|
|
35
|
+
wrapped in `ShellCorner side="left"` or `ShellCorner side="right"` so it follows
|
|
36
|
+
the corresponding shell track as that edge expands and collapses.
|
|
37
|
+
|
|
28
38
|
## Migration
|
|
29
39
|
|
|
30
40
|
See [MIGRATION.md](./MIGRATION.md) for the first-generation workspace migration
|
|
@@ -1,12 +1,18 @@
|
|
|
1
1
|
import { createRawSnippet, flushSync, mount, tick, unmount } from 'svelte';
|
|
2
2
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
3
3
|
import AdminShell from '../admin-shell/AdminShell.svelte';
|
|
4
|
+
import adminShellSource from '../admin-shell/AdminShell.svelte?raw';
|
|
4
5
|
import { createShellState } from '../admin-shell/state.svelte.js';
|
|
5
6
|
function textSnippet(text) {
|
|
6
7
|
return createRawSnippet(() => ({
|
|
7
8
|
render: () => `<span>${text}</span>`,
|
|
8
9
|
}));
|
|
9
10
|
}
|
|
11
|
+
function activeToolSnippet() {
|
|
12
|
+
return createRawSnippet((context) => ({
|
|
13
|
+
render: () => `<span>${context().tool?.id ?? 'none'} panel</span>`,
|
|
14
|
+
}));
|
|
15
|
+
}
|
|
10
16
|
let container;
|
|
11
17
|
beforeEach(() => {
|
|
12
18
|
container = document.createElement('div');
|
|
@@ -34,6 +40,9 @@ describe('AdminShell', () => {
|
|
|
34
40
|
unmount(component);
|
|
35
41
|
}
|
|
36
42
|
});
|
|
43
|
+
it('pins the shell to the viewport so long workspace content scrolls inside main', () => {
|
|
44
|
+
expect(adminShellSource).toMatch(/\.smrt-admin-shell\s*\{[^}]*\bblock-size:\s*100svh\s*;/);
|
|
45
|
+
});
|
|
37
46
|
it('toggles a physical-code panel hotkey', async () => {
|
|
38
47
|
const component = mount(AdminShell, {
|
|
39
48
|
target: container,
|
|
@@ -53,6 +62,24 @@ describe('AdminShell', () => {
|
|
|
53
62
|
unmount(component);
|
|
54
63
|
}
|
|
55
64
|
});
|
|
65
|
+
it('hides hotkey badges when shell hotkeys are disabled', () => {
|
|
66
|
+
const state = createShellState({
|
|
67
|
+
settings: { hotkeysEnabled: false },
|
|
68
|
+
});
|
|
69
|
+
const component = mount(AdminShell, {
|
|
70
|
+
target: container,
|
|
71
|
+
props: {
|
|
72
|
+
state,
|
|
73
|
+
children: textSnippet('main work'),
|
|
74
|
+
},
|
|
75
|
+
});
|
|
76
|
+
try {
|
|
77
|
+
expect(container.querySelector('.smrt-admin-shell__edge-toggle-kbd')).toBeNull();
|
|
78
|
+
}
|
|
79
|
+
finally {
|
|
80
|
+
unmount(component);
|
|
81
|
+
}
|
|
82
|
+
});
|
|
56
83
|
it('keeps the focus rail visible when the right panel is expanded', () => {
|
|
57
84
|
const state = createShellState({
|
|
58
85
|
config: {
|
|
@@ -82,6 +109,61 @@ describe('AdminShell', () => {
|
|
|
82
109
|
unmount(component);
|
|
83
110
|
}
|
|
84
111
|
});
|
|
112
|
+
it('renders the active registered focus tool after switching tools', async () => {
|
|
113
|
+
const state = createShellState({
|
|
114
|
+
config: { right: { initial: 'expanded' } },
|
|
115
|
+
});
|
|
116
|
+
state.registerFocusTool({
|
|
117
|
+
id: 'usage',
|
|
118
|
+
label: 'Usage',
|
|
119
|
+
});
|
|
120
|
+
state.registerFocusTool({
|
|
121
|
+
id: 'tasks',
|
|
122
|
+
label: 'Tasks',
|
|
123
|
+
});
|
|
124
|
+
const component = mount(AdminShell, {
|
|
125
|
+
target: container,
|
|
126
|
+
props: {
|
|
127
|
+
state,
|
|
128
|
+
children: textSnippet('main work'),
|
|
129
|
+
focusPanel: activeToolSnippet(),
|
|
130
|
+
},
|
|
131
|
+
});
|
|
132
|
+
try {
|
|
133
|
+
const panel = container.querySelector('.smrt-admin-shell__panel--right');
|
|
134
|
+
expect(panel?.textContent).toContain('usage panel');
|
|
135
|
+
state.openFocusTool('tasks');
|
|
136
|
+
await tick();
|
|
137
|
+
expect(panel?.textContent).toContain('tasks panel');
|
|
138
|
+
expect(panel?.textContent).not.toContain('usage panel');
|
|
139
|
+
}
|
|
140
|
+
finally {
|
|
141
|
+
unmount(component);
|
|
142
|
+
}
|
|
143
|
+
});
|
|
144
|
+
it('pins a tenant footer below the scrolling tenant panel', () => {
|
|
145
|
+
const state = createShellState({
|
|
146
|
+
config: { left: { initial: 'expanded' } },
|
|
147
|
+
});
|
|
148
|
+
const component = mount(AdminShell, {
|
|
149
|
+
target: container,
|
|
150
|
+
props: {
|
|
151
|
+
state,
|
|
152
|
+
children: textSnippet('main work'),
|
|
153
|
+
tenantPanel: textSnippet('tenant navigation'),
|
|
154
|
+
tenantFooter: textSnippet('account menu'),
|
|
155
|
+
},
|
|
156
|
+
});
|
|
157
|
+
try {
|
|
158
|
+
expect(container.querySelector('.smrt-admin-shell__tenant-content')
|
|
159
|
+
?.textContent).toContain('tenant navigation');
|
|
160
|
+
expect(container.querySelector('.smrt-admin-shell__tenant-footer')
|
|
161
|
+
?.textContent).toContain('account menu');
|
|
162
|
+
}
|
|
163
|
+
finally {
|
|
164
|
+
unmount(component);
|
|
165
|
+
}
|
|
166
|
+
});
|
|
85
167
|
it('publishes collapsed side sizes for stable top and bottom chrome', () => {
|
|
86
168
|
const state = createShellState({
|
|
87
169
|
config: {
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { expectNoA11yViolations } from '@happyvertical/smrt-ui/test-support/a11y';
|
|
2
|
+
import { render, screen } from '@testing-library/svelte';
|
|
3
|
+
import userEvent from '@testing-library/user-event';
|
|
4
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
5
|
+
import WorkspaceAccountMenu from '../admin-shell/WorkspaceAccountMenu.svelte';
|
|
6
|
+
const tenants = [
|
|
7
|
+
{ id: 'tenant-a', label: 'Acme', roleLabel: 'Owner' },
|
|
8
|
+
{ id: 'tenant-b', label: 'Beta', roleLabel: 'Member' },
|
|
9
|
+
];
|
|
10
|
+
describe('WorkspaceAccountMenu', () => {
|
|
11
|
+
it('switches tenant and signs out through app-owned callbacks', async () => {
|
|
12
|
+
const user = userEvent.setup();
|
|
13
|
+
const onTenantSelect = vi.fn();
|
|
14
|
+
const onSignOut = vi.fn();
|
|
15
|
+
render(WorkspaceAccountMenu, {
|
|
16
|
+
props: {
|
|
17
|
+
userName: 'Ada Lovelace',
|
|
18
|
+
userLabel: 'ada@example.com',
|
|
19
|
+
tenantLabel: 'Acme',
|
|
20
|
+
roleLabel: 'Owner',
|
|
21
|
+
currentTenantId: 'tenant-a',
|
|
22
|
+
tenants,
|
|
23
|
+
onTenantSelect,
|
|
24
|
+
onSignOut,
|
|
25
|
+
},
|
|
26
|
+
});
|
|
27
|
+
const trigger = screen.getByRole('button', {
|
|
28
|
+
name: /Open account menu ada@example.com Acme · Owner/,
|
|
29
|
+
});
|
|
30
|
+
await user.click(trigger);
|
|
31
|
+
expect(screen.getByRole('menuitem', { name: 'Acme — current — Owner' })).toBeDisabled();
|
|
32
|
+
await user.click(screen.getByRole('menuitem', { name: 'Beta — Member' }));
|
|
33
|
+
expect(onTenantSelect).toHaveBeenCalledWith('tenant-b');
|
|
34
|
+
await user.click(trigger);
|
|
35
|
+
await user.click(screen.getByRole('menuitem', { name: 'Sign out' }));
|
|
36
|
+
expect(onSignOut).toHaveBeenCalledOnce();
|
|
37
|
+
});
|
|
38
|
+
it('keeps tenant switching out of the menu when only one tenant is available', async () => {
|
|
39
|
+
const user = userEvent.setup();
|
|
40
|
+
render(WorkspaceAccountMenu, {
|
|
41
|
+
props: {
|
|
42
|
+
userName: 'Ada Lovelace',
|
|
43
|
+
userLabel: 'ada@example.com',
|
|
44
|
+
tenantLabel: 'Acme',
|
|
45
|
+
roleLabel: 'Owner',
|
|
46
|
+
currentTenantId: 'tenant-a',
|
|
47
|
+
tenants: tenants.slice(0, 1),
|
|
48
|
+
onTenantSelect: vi.fn(),
|
|
49
|
+
onSignOut: vi.fn(),
|
|
50
|
+
},
|
|
51
|
+
});
|
|
52
|
+
const trigger = screen.getByRole('button', {
|
|
53
|
+
name: /Open account menu ada@example.com Acme · Owner/,
|
|
54
|
+
});
|
|
55
|
+
await user.click(trigger);
|
|
56
|
+
expect(screen.queryByRole('menuitem', { name: /Acme/ })).toBeNull();
|
|
57
|
+
expect(screen.getByRole('menuitem', { name: 'Sign out' })).toBeVisible();
|
|
58
|
+
});
|
|
59
|
+
it('is axe-clean', async () => {
|
|
60
|
+
const { container } = render(WorkspaceAccountMenu, {
|
|
61
|
+
props: {
|
|
62
|
+
userName: 'Ada Lovelace',
|
|
63
|
+
userLabel: 'ada@example.com',
|
|
64
|
+
tenantLabel: 'Acme',
|
|
65
|
+
currentTenantId: 'tenant-a',
|
|
66
|
+
tenants,
|
|
67
|
+
onTenantSelect: vi.fn(),
|
|
68
|
+
onSignOut: vi.fn(),
|
|
69
|
+
},
|
|
70
|
+
});
|
|
71
|
+
await expectNoA11yViolations(container);
|
|
72
|
+
});
|
|
73
|
+
});
|
|
@@ -62,8 +62,9 @@ function buildLayoutStyle(shell: ModuleShellState): string {
|
|
|
62
62
|
appPanel?: Snippet;
|
|
63
63
|
tenantRail?: Snippet;
|
|
64
64
|
tenantPanel?: Snippet;
|
|
65
|
+
tenantFooter?: Snippet;
|
|
65
66
|
focusRail?: Snippet;
|
|
66
|
-
focusPanel?: Snippet
|
|
67
|
+
focusPanel?: Snippet<[{ tool: ShellFocusTool | null }]>;
|
|
67
68
|
systemBar?: Snippet;
|
|
68
69
|
systemPanel?: Snippet;
|
|
69
70
|
topLeftCorner?: Snippet;
|
|
@@ -86,6 +87,7 @@ function buildLayoutStyle(shell: ModuleShellState): string {
|
|
|
86
87
|
appPanel,
|
|
87
88
|
tenantRail,
|
|
88
89
|
tenantPanel,
|
|
90
|
+
tenantFooter,
|
|
89
91
|
focusRail,
|
|
90
92
|
focusPanel,
|
|
91
93
|
systemBar,
|
|
@@ -112,11 +114,13 @@ function buildLayoutStyle(shell: ModuleShellState): string {
|
|
|
112
114
|
setAdminShell(shell);
|
|
113
115
|
|
|
114
116
|
let shortcutsOpen = $state(false);
|
|
115
|
-
|
|
116
|
-
|
|
117
|
+
function resolveActiveFocusTool(): ShellFocusTool | null {
|
|
118
|
+
return (
|
|
119
|
+
shell.focusTools.find((tool) => tool.id === shell.activeFocusToolId) ??
|
|
117
120
|
shell.focusTools[0] ??
|
|
118
|
-
null
|
|
119
|
-
|
|
121
|
+
null
|
|
122
|
+
);
|
|
123
|
+
}
|
|
120
124
|
const shortcutEdges: PanelEdge[] = ['top', 'left', 'bottom', 'right'];
|
|
121
125
|
|
|
122
126
|
onMount(() => {
|
|
@@ -171,6 +175,13 @@ function buildLayoutStyle(shell: ModuleShellState): string {
|
|
|
171
175
|
);
|
|
172
176
|
}
|
|
173
177
|
|
|
178
|
+
function showsHotkeyFor(edge: PanelEdge): boolean {
|
|
179
|
+
return (
|
|
180
|
+
shell.settings.hotkeysEnabled !== false &&
|
|
181
|
+
resolveHotkey(edge, shell.config.panels[edge], shell.settings) !== null
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
|
|
174
185
|
// Focus containment for the modal shortcuts dialog. Runs on mount of the
|
|
175
186
|
// dialog node and tears down when it unmounts (Escape / close), restoring
|
|
176
187
|
// focus to whatever opened it. Keeps `aria-modal="true"` honest.
|
|
@@ -226,14 +237,16 @@ function buildLayoutStyle(shell: ModuleShellState): string {
|
|
|
226
237
|
onclick={() => shell.togglePanel(edge)}
|
|
227
238
|
>
|
|
228
239
|
<span>{labelFor(edge)}</span>
|
|
229
|
-
|
|
240
|
+
{#if showsHotkeyFor(edge)}
|
|
241
|
+
<kbd class="smrt-admin-shell__edge-toggle-kbd">{hotkeyFor(edge)}</kbd>
|
|
242
|
+
{/if}
|
|
230
243
|
<ActivityBadge {edge} />
|
|
231
244
|
</Button>
|
|
232
245
|
{/snippet}
|
|
233
246
|
|
|
234
247
|
{#snippet focusContent(tool: ShellFocusTool | null)}
|
|
235
248
|
{#if focusPanel}
|
|
236
|
-
{@render focusPanel()}
|
|
249
|
+
{@render focusPanel({ tool })}
|
|
237
250
|
{:else if tool?.render}
|
|
238
251
|
{@render tool.render({ tool })}
|
|
239
252
|
{:else if tool?.component}
|
|
@@ -312,13 +325,22 @@ function buildLayoutStyle(shell: ModuleShellState): string {
|
|
|
312
325
|
>
|
|
313
326
|
<div class="smrt-admin-shell__rail">
|
|
314
327
|
{#if edgeExpanded('left')}
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
328
|
+
<div class="smrt-admin-shell__tenant-stack">
|
|
329
|
+
<div class="smrt-admin-shell__tenant-content">
|
|
330
|
+
{#if tenantPanel}
|
|
331
|
+
{@render tenantPanel()}
|
|
332
|
+
{:else if tenantRail}
|
|
333
|
+
{@render tenantRail()}
|
|
334
|
+
{:else}
|
|
335
|
+
{@render edgeToggle('left')}
|
|
336
|
+
{/if}
|
|
337
|
+
</div>
|
|
338
|
+
{#if tenantFooter}
|
|
339
|
+
<div class="smrt-admin-shell__tenant-footer">
|
|
340
|
+
{@render tenantFooter()}
|
|
341
|
+
</div>
|
|
342
|
+
{/if}
|
|
343
|
+
</div>
|
|
322
344
|
{:else if tenantRail}
|
|
323
345
|
{@render tenantRail()}
|
|
324
346
|
{:else}
|
|
@@ -349,7 +371,9 @@ function buildLayoutStyle(shell: ModuleShellState): string {
|
|
|
349
371
|
</div>
|
|
350
372
|
{#if edgeExpanded('right')}
|
|
351
373
|
<div class="smrt-admin-shell__panel smrt-admin-shell__panel--right">
|
|
352
|
-
{
|
|
374
|
+
{#key shell.activeFocusToolId}
|
|
375
|
+
{@render focusContent(resolveActiveFocusTool())}
|
|
376
|
+
{/key}
|
|
353
377
|
</div>
|
|
354
378
|
{/if}
|
|
355
379
|
</aside>
|
|
@@ -457,6 +481,7 @@ function buildLayoutStyle(shell: ModuleShellState): string {
|
|
|
457
481
|
var(--smrt-admin-shell-top-track) minmax(0, 1fr)
|
|
458
482
|
var(--smrt-admin-shell-bottom-track);
|
|
459
483
|
min-block-size: 100svh;
|
|
484
|
+
block-size: 100svh;
|
|
460
485
|
background: var(--smrt-color-surface);
|
|
461
486
|
color: var(--smrt-color-on-surface);
|
|
462
487
|
overflow: hidden;
|
|
@@ -561,6 +586,32 @@ function buildLayoutStyle(shell: ModuleShellState): string {
|
|
|
561
586
|
padding: var(--smrt-spacing-3);
|
|
562
587
|
}
|
|
563
588
|
|
|
589
|
+
.smrt-admin-shell__edge--left[data-state='expanded']
|
|
590
|
+
.smrt-admin-shell__rail {
|
|
591
|
+
overflow: hidden;
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
.smrt-admin-shell__tenant-stack {
|
|
595
|
+
display: grid;
|
|
596
|
+
grid-template-rows: minmax(0, 1fr) auto;
|
|
597
|
+
gap: var(--smrt-spacing-3);
|
|
598
|
+
min-width: 0;
|
|
599
|
+
min-height: 0;
|
|
600
|
+
block-size: 100%;
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
.smrt-admin-shell__tenant-content {
|
|
604
|
+
min-width: 0;
|
|
605
|
+
min-height: 0;
|
|
606
|
+
overflow: auto;
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
.smrt-admin-shell__tenant-footer {
|
|
610
|
+
min-width: 0;
|
|
611
|
+
padding-block-start: var(--smrt-spacing-3);
|
|
612
|
+
border-block-start: 1px solid var(--smrt-color-outline-variant);
|
|
613
|
+
}
|
|
614
|
+
|
|
564
615
|
.smrt-admin-shell__edge--right[data-state='expanded']
|
|
565
616
|
.smrt-admin-shell__rail {
|
|
566
617
|
grid-column: 2;
|
|
@@ -1,14 +1,17 @@
|
|
|
1
1
|
import type { Snippet } from 'svelte';
|
|
2
2
|
import { type ShellState } from './state.svelte.js';
|
|
3
|
-
import type { AdminShellProps } from './types.js';
|
|
3
|
+
import type { AdminShellProps, ShellFocusTool } from './types.js';
|
|
4
4
|
interface Props extends AdminShellProps {
|
|
5
5
|
state?: ShellState;
|
|
6
6
|
appBar?: Snippet;
|
|
7
7
|
appPanel?: Snippet;
|
|
8
8
|
tenantRail?: Snippet;
|
|
9
9
|
tenantPanel?: Snippet;
|
|
10
|
+
tenantFooter?: Snippet;
|
|
10
11
|
focusRail?: Snippet;
|
|
11
|
-
focusPanel?: Snippet
|
|
12
|
+
focusPanel?: Snippet<[{
|
|
13
|
+
tool: ShellFocusTool | null;
|
|
14
|
+
}]>;
|
|
12
15
|
systemBar?: Snippet;
|
|
13
16
|
systemPanel?: Snippet;
|
|
14
17
|
topLeftCorner?: Snippet;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"AdminShell.svelte.d.ts","sourceRoot":"","sources":["../../../../src/components/workspace/admin-shell/AdminShell.svelte.ts"],"names":[],"mappings":"AA2CA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,QAAQ,CAAC;AAStC,OAAO,EAAoB,KAAK,UAAU,EAAE,MAAM,mBAAmB,CAAC;AACtE,OAAO,KAAK,EACR,eAAe,
|
|
1
|
+
{"version":3,"file":"AdminShell.svelte.d.ts","sourceRoot":"","sources":["../../../../src/components/workspace/admin-shell/AdminShell.svelte.ts"],"names":[],"mappings":"AA2CA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,QAAQ,CAAC;AAStC,OAAO,EAAoB,KAAK,UAAU,EAAE,MAAM,mBAAmB,CAAC;AACtE,OAAO,KAAK,EACR,eAAe,EAEf,cAAc,EACf,MAAM,YAAY,CAAC;AAGpB,UAAU,KAAM,SAAQ,eAAe;IACrC,KAAK,CAAC,EAAE,UAAU,CAAC;IACnB,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB,WAAW,CAAC,EAAE,OAAO,CAAC;IACtB,YAAY,CAAC,EAAE,OAAO,CAAC;IACvB,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,UAAU,CAAC,EAAE,OAAO,CAAC,CAAC;QAAE,IAAI,EAAE,cAAc,GAAG,IAAI,CAAA;KAAE,CAAC,CAAC,CAAC;IACxD,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,WAAW,CAAC,EAAE,OAAO,CAAC;IACtB,aAAa,CAAC,EAAE,OAAO,CAAC;IACxB,cAAc,CAAC,EAAE,OAAO,CAAC;IACzB,gBAAgB,CAAC,EAAE,OAAO,CAAC;IAC3B,iBAAiB,CAAC,EAAE,OAAO,CAAC;IAC5B,gBAAgB,CAAC,EAAE,OAAO,CAAC;IAC3B,QAAQ,EAAE,OAAO,CAAC;CACnB;AAiWH,QAAA,MAAM,UAAU,2CAAwC,CAAC;AACzD,KAAK,UAAU,GAAG,UAAU,CAAC,OAAO,UAAU,CAAC,CAAC;AAChD,eAAe,UAAU,CAAC"}
|