@servicetitan/navigation 14.2.0 → 14.3.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/dist/components/titan-layout/__tests__/layout-sidebar-links-internal.test.d.ts +2 -0
- package/dist/components/titan-layout/__tests__/layout-sidebar-links-internal.test.d.ts.map +1 -0
- package/dist/components/titan-layout/__tests__/navigation-catalog-reset.test.d.ts +2 -0
- package/dist/components/titan-layout/__tests__/navigation-catalog-reset.test.d.ts.map +1 -0
- package/dist/components/titan-layout/interface.d.ts +66 -0
- package/dist/components/titan-layout/interface.d.ts.map +1 -1
- package/dist/components/titan-layout/interface.js.map +1 -1
- package/dist/components/titan-layout/layout-sidebar-links-internal.d.ts +3 -0
- package/dist/components/titan-layout/layout-sidebar-links-internal.d.ts.map +1 -1
- package/dist/components/titan-layout/layout-sidebar-links-internal.js +58 -13
- package/dist/components/titan-layout/layout-sidebar-links-internal.js.map +1 -1
- package/dist/components/titan-layout/layout-sidebar.d.ts +14 -0
- package/dist/components/titan-layout/layout-sidebar.d.ts.map +1 -1
- package/dist/components/titan-layout/layout-sidebar.js +169 -97
- package/dist/components/titan-layout/layout-sidebar.js.map +1 -1
- package/dist/components/titan-layout/layout-sidebar.module.less +230 -144
- package/dist/components/titan-layout/layout-sidebar.module.less.d.ts +16 -5
- package/dist/components/titan-layout/navigation-catalog/index.d.ts +3 -0
- package/dist/components/titan-layout/navigation-catalog/index.d.ts.map +1 -0
- package/dist/components/titan-layout/navigation-catalog/index.js +4 -0
- package/dist/components/titan-layout/navigation-catalog/index.js.map +1 -0
- package/dist/components/titan-layout/navigation-catalog/navigation-catalog-footer.d.ts +8 -0
- package/dist/components/titan-layout/navigation-catalog/navigation-catalog-footer.d.ts.map +1 -0
- package/dist/components/titan-layout/navigation-catalog/navigation-catalog-footer.js +88 -0
- package/dist/components/titan-layout/navigation-catalog/navigation-catalog-footer.js.map +1 -0
- package/dist/components/titan-layout/navigation-catalog/navigation-catalog-trigger.d.ts +21 -0
- package/dist/components/titan-layout/navigation-catalog/navigation-catalog-trigger.d.ts.map +1 -0
- package/dist/components/titan-layout/navigation-catalog/navigation-catalog-trigger.js +138 -0
- package/dist/components/titan-layout/navigation-catalog/navigation-catalog-trigger.js.map +1 -0
- package/dist/components/titan-layout/navigation-catalog/navigation-catalog-trigger.module.less +89 -0
- package/dist/components/titan-layout/navigation-catalog/navigation-catalog-trigger.module.less.d.ts +9 -0
- package/dist/components/titan-layout/navigation-catalog/navigation-catalog.d.ts +14 -0
- package/dist/components/titan-layout/navigation-catalog/navigation-catalog.d.ts.map +1 -0
- package/dist/components/titan-layout/navigation-catalog/navigation-catalog.js +382 -0
- package/dist/components/titan-layout/navigation-catalog/navigation-catalog.js.map +1 -0
- package/dist/components/titan-layout/navigation-catalog/navigation-catalog.module.less +436 -0
- package/dist/components/titan-layout/navigation-catalog/navigation-catalog.module.less.d.ts +24 -0
- package/dist/components/titan-layout/navigation-scroll-indicators.d.ts +12 -0
- package/dist/components/titan-layout/navigation-scroll-indicators.d.ts.map +1 -0
- package/dist/components/titan-layout/navigation-scroll-indicators.js +48 -0
- package/dist/components/titan-layout/navigation-scroll-indicators.js.map +1 -0
- package/dist/components/titan-layout/titan-layout.d.ts +29 -1
- package/dist/components/titan-layout/titan-layout.d.ts.map +1 -1
- package/dist/components/titan-layout/titan-layout.js +46 -6
- package/dist/components/titan-layout/titan-layout.js.map +1 -1
- package/dist/components/titan-layout/titan-layout.module.less +2 -1
- package/dist/components/titan-layout/use-navigation-scroll.d.ts +20 -0
- package/dist/components/titan-layout/use-navigation-scroll.d.ts.map +1 -0
- package/dist/components/titan-layout/use-navigation-scroll.js +140 -0
- package/dist/components/titan-layout/use-navigation-scroll.js.map +1 -0
- package/dist/utils/__tests__/navigation-notifications.test.d.ts +2 -0
- package/dist/utils/__tests__/navigation-notifications.test.d.ts.map +1 -0
- package/dist/utils/__tests__/submenu-state.test.d.ts +2 -0
- package/dist/utils/__tests__/submenu-state.test.d.ts.map +1 -0
- package/dist/utils/navigation-notifications.d.ts +21 -0
- package/dist/utils/navigation-notifications.d.ts.map +1 -0
- package/dist/utils/navigation-notifications.js +39 -0
- package/dist/utils/navigation-notifications.js.map +1 -0
- package/dist/utils/side-nav.js +3 -1
- package/dist/utils/side-nav.js.map +1 -1
- package/dist/utils/submenu-state.d.ts +3 -0
- package/dist/utils/submenu-state.d.ts.map +1 -0
- package/dist/utils/submenu-state.js +15 -0
- package/dist/utils/submenu-state.js.map +1 -0
- package/package.json +2 -2
- package/src/components/titan-layout/__tests__/layout-sidebar-links-internal.test.tsx +225 -0
- package/src/components/titan-layout/__tests__/navigation-catalog-reset.test.tsx +186 -0
- package/src/components/titan-layout/interface.ts +73 -0
- package/src/components/titan-layout/layout-sidebar-links-internal.tsx +86 -14
- package/src/components/titan-layout/layout-sidebar.module.less +230 -144
- package/src/components/titan-layout/layout-sidebar.module.less.d.ts +16 -5
- package/src/components/titan-layout/layout-sidebar.tsx +193 -117
- package/src/components/titan-layout/navigation-catalog/index.ts +2 -0
- package/src/components/titan-layout/navigation-catalog/navigation-catalog-footer.tsx +88 -0
- package/src/components/titan-layout/navigation-catalog/navigation-catalog-trigger.module.less +89 -0
- package/src/components/titan-layout/navigation-catalog/navigation-catalog-trigger.module.less.d.ts +9 -0
- package/src/components/titan-layout/navigation-catalog/navigation-catalog-trigger.tsx +170 -0
- package/src/components/titan-layout/navigation-catalog/navigation-catalog.module.less +436 -0
- package/src/components/titan-layout/navigation-catalog/navigation-catalog.module.less.d.ts +24 -0
- package/src/components/titan-layout/navigation-catalog/navigation-catalog.tsx +458 -0
- package/src/components/titan-layout/navigation-scroll-indicators.tsx +80 -0
- package/src/components/titan-layout/titan-layout.module.less +2 -1
- package/src/components/titan-layout/titan-layout.tsx +81 -5
- package/src/components/titan-layout/use-navigation-scroll.ts +145 -0
- package/src/utils/__tests__/navigation-notifications.test.ts +70 -0
- package/src/utils/__tests__/submenu-state.test.ts +39 -0
- package/src/utils/navigation-notifications.ts +59 -0
- package/src/utils/side-nav.ts +1 -1
- package/src/utils/submenu-state.ts +16 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@servicetitan/navigation",
|
|
3
|
-
"version": "14.
|
|
3
|
+
"version": "14.3.0",
|
|
4
4
|
"description": "Navigation components",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -35,5 +35,5 @@
|
|
|
35
35
|
"less": true,
|
|
36
36
|
"webpack": false
|
|
37
37
|
},
|
|
38
|
-
"gitHead": "
|
|
38
|
+
"gitHead": "e3bffa6cdb9f250d97a5559bd20fb08a30819906"
|
|
39
39
|
}
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import { ButtonHTMLAttributes, useState } from 'react';
|
|
2
|
+
import { Root, createRoot } from 'react-dom/client';
|
|
3
|
+
import { act } from 'react-dom/test-utils';
|
|
4
|
+
import { NavLinkComponentProps } from '../../../utils/navigation-context';
|
|
5
|
+
import {
|
|
6
|
+
InternalSideNavigationGroup,
|
|
7
|
+
InternalSideNavigationGroupLink,
|
|
8
|
+
} from '../layout-sidebar-links-internal';
|
|
9
|
+
|
|
10
|
+
jest.mock('@servicetitan/anvil2', () => ({
|
|
11
|
+
Button: ({
|
|
12
|
+
appearance,
|
|
13
|
+
size,
|
|
14
|
+
icon,
|
|
15
|
+
...props
|
|
16
|
+
}: ButtonHTMLAttributes<HTMLButtonElement> & {
|
|
17
|
+
appearance?: string;
|
|
18
|
+
size?: string;
|
|
19
|
+
icon?: unknown;
|
|
20
|
+
}) => <button {...props} />,
|
|
21
|
+
Icon: () => null,
|
|
22
|
+
}));
|
|
23
|
+
|
|
24
|
+
jest.mock('../with-tooltip', () => ({ withTooltip: (element: unknown) => element }));
|
|
25
|
+
|
|
26
|
+
let container: HTMLDivElement;
|
|
27
|
+
let root: Root;
|
|
28
|
+
const actEnvironment = globalThis as typeof globalThis &
|
|
29
|
+
Record<'IS_REACT_ACT_ENVIRONMENT', boolean | undefined>;
|
|
30
|
+
const previousActEnvironment = actEnvironment.IS_REACT_ACT_ENVIRONMENT;
|
|
31
|
+
|
|
32
|
+
beforeAll(() => {
|
|
33
|
+
actEnvironment.IS_REACT_ACT_ENVIRONMENT = true;
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
afterAll(() => {
|
|
37
|
+
actEnvironment.IS_REACT_ACT_ENVIRONMENT = previousActEnvironment;
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
beforeEach(() => {
|
|
41
|
+
container = document.createElement('div');
|
|
42
|
+
document.body.appendChild(container);
|
|
43
|
+
root = createRoot(container);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
afterEach(() => {
|
|
47
|
+
act(() => root.unmount());
|
|
48
|
+
container.remove();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
function click(element: Element) {
|
|
52
|
+
act(() => {
|
|
53
|
+
element.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function pressKey(element: Element, key: string) {
|
|
58
|
+
const event = new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true });
|
|
59
|
+
act(() => {
|
|
60
|
+
element.dispatchEvent(event);
|
|
61
|
+
});
|
|
62
|
+
return event;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const getChildLink = () =>
|
|
66
|
+
container.querySelector('[data-cy="navigation-item-pricebook--services"]');
|
|
67
|
+
|
|
68
|
+
function renderGroup(to: string | undefined = '/pricebook', initialOpened = false) {
|
|
69
|
+
const navigate = jest.fn();
|
|
70
|
+
const closeDrawer = jest.fn();
|
|
71
|
+
const onSubmenuOpen = jest.fn();
|
|
72
|
+
|
|
73
|
+
// Like a host router, this link owns its click handler and does not consult defaultPrevented.
|
|
74
|
+
const NavigationLink = ({
|
|
75
|
+
to: destination,
|
|
76
|
+
isActive,
|
|
77
|
+
activeClassName,
|
|
78
|
+
...props
|
|
79
|
+
}: NavLinkComponentProps) => (
|
|
80
|
+
<a
|
|
81
|
+
{...props}
|
|
82
|
+
href={destination}
|
|
83
|
+
onClick={event => {
|
|
84
|
+
event.preventDefault();
|
|
85
|
+
navigate(destination);
|
|
86
|
+
}}
|
|
87
|
+
/>
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
function Fixture() {
|
|
91
|
+
const [opened, setOpened] = useState(initialOpened);
|
|
92
|
+
return (
|
|
93
|
+
<div onClick={closeDrawer}>
|
|
94
|
+
<InternalSideNavigationGroup
|
|
95
|
+
id="pricebook"
|
|
96
|
+
to={to}
|
|
97
|
+
title="Pricebook"
|
|
98
|
+
isActive={undefined}
|
|
99
|
+
icon={undefined}
|
|
100
|
+
iconActive={undefined}
|
|
101
|
+
className={undefined}
|
|
102
|
+
tag={undefined}
|
|
103
|
+
submenuOpened={opened}
|
|
104
|
+
onSubmenuOpen={(id, nextOpened) => {
|
|
105
|
+
onSubmenuOpen(id, nextOpened);
|
|
106
|
+
setOpened(nextOpened);
|
|
107
|
+
}}
|
|
108
|
+
navigationComponent={NavigationLink}
|
|
109
|
+
>
|
|
110
|
+
<InternalSideNavigationGroupLink
|
|
111
|
+
id="services"
|
|
112
|
+
parentId="pricebook"
|
|
113
|
+
to="/pricebook/services"
|
|
114
|
+
title="Services"
|
|
115
|
+
isActive={undefined}
|
|
116
|
+
className={undefined}
|
|
117
|
+
tag={undefined}
|
|
118
|
+
navigationComponent={NavigationLink}
|
|
119
|
+
/>
|
|
120
|
+
</InternalSideNavigationGroup>
|
|
121
|
+
</div>
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
act(() => root.render(<Fixture />));
|
|
126
|
+
const moduleTrigger = container.querySelector<HTMLElement>(
|
|
127
|
+
'[data-cy="navigation-item-pricebook"]'
|
|
128
|
+
)!;
|
|
129
|
+
return { moduleTrigger, navigate, closeDrawer, onSubmenuOpen };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
it.each(['/pricebook', '/pricebook/materials'])(
|
|
133
|
+
'opens on the module name and navigates to the configured %p route',
|
|
134
|
+
to => {
|
|
135
|
+
const { moduleTrigger, navigate, closeDrawer, onSubmenuOpen } = renderGroup(to);
|
|
136
|
+
|
|
137
|
+
click(moduleTrigger);
|
|
138
|
+
expect(getChildLink()).toBeTruthy();
|
|
139
|
+
expect(moduleTrigger.getAttribute('href')).toBe(to);
|
|
140
|
+
expect(moduleTrigger.getAttribute('role')).toBeNull();
|
|
141
|
+
expect(moduleTrigger.getAttribute('aria-expanded')).toBe('true');
|
|
142
|
+
expect(document.getElementById(moduleTrigger.getAttribute('aria-controls')!)).toBeTruthy();
|
|
143
|
+
|
|
144
|
+
// The chevron is the only control that collapses an open module.
|
|
145
|
+
click(container.querySelector('button[aria-label="Collapse Pricebook submenu"]')!);
|
|
146
|
+
expect(getChildLink()).toBeNull();
|
|
147
|
+
expect(moduleTrigger.getAttribute('aria-expanded')).toBe('false');
|
|
148
|
+
|
|
149
|
+
click(container.querySelector('button[aria-label="Expand Pricebook submenu"]')!);
|
|
150
|
+
expect(getChildLink()).toBeTruthy();
|
|
151
|
+
expect(onSubmenuOpen.mock.calls).toEqual([
|
|
152
|
+
['pricebook', true],
|
|
153
|
+
['pricebook', false],
|
|
154
|
+
['pricebook', true],
|
|
155
|
+
]);
|
|
156
|
+
expect(navigate.mock.calls).toEqual([[to]]);
|
|
157
|
+
expect(closeDrawer).not.toHaveBeenCalled();
|
|
158
|
+
}
|
|
159
|
+
);
|
|
160
|
+
|
|
161
|
+
it.each(['/pricebook', '/pricebook/materials'])(
|
|
162
|
+
'keeps an open module open and still navigates to %p from its name',
|
|
163
|
+
to => {
|
|
164
|
+
const { moduleTrigger, navigate, onSubmenuOpen } = renderGroup(to, true);
|
|
165
|
+
|
|
166
|
+
expect(getChildLink()).toBeTruthy();
|
|
167
|
+
|
|
168
|
+
click(moduleTrigger);
|
|
169
|
+
expect(getChildLink()).toBeTruthy();
|
|
170
|
+
expect(moduleTrigger.getAttribute('aria-expanded')).toBe('true');
|
|
171
|
+
expect(navigate.mock.calls).toEqual([[to]]);
|
|
172
|
+
expect(onSubmenuOpen).not.toHaveBeenCalled();
|
|
173
|
+
|
|
174
|
+
click(moduleTrigger);
|
|
175
|
+
expect(getChildLink()).toBeTruthy();
|
|
176
|
+
expect(navigate.mock.calls).toEqual([[to], [to]]);
|
|
177
|
+
expect(onSubmenuOpen).not.toHaveBeenCalled();
|
|
178
|
+
}
|
|
179
|
+
);
|
|
180
|
+
|
|
181
|
+
it.each(['Enter', ' '])('opens and navigates to the module destination with the %p key', key => {
|
|
182
|
+
const { moduleTrigger, navigate, closeDrawer, onSubmenuOpen } = renderGroup();
|
|
183
|
+
|
|
184
|
+
moduleTrigger.focus();
|
|
185
|
+
expect(pressKey(moduleTrigger, key).defaultPrevented).toBe(true);
|
|
186
|
+
expect(getChildLink()).toBeTruthy();
|
|
187
|
+
expect(document.activeElement).toBe(moduleTrigger);
|
|
188
|
+
|
|
189
|
+
pressKey(moduleTrigger, key);
|
|
190
|
+
expect(getChildLink()).toBeTruthy();
|
|
191
|
+
expect(onSubmenuOpen).toHaveBeenCalledTimes(1);
|
|
192
|
+
expect(navigate.mock.calls).toEqual([['/pricebook'], ['/pricebook']]);
|
|
193
|
+
expect(closeDrawer).not.toHaveBeenCalled();
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it('still navigates and closes the drawer when a child page is selected', () => {
|
|
197
|
+
const { moduleTrigger, navigate, closeDrawer, onSubmenuOpen } = renderGroup();
|
|
198
|
+
|
|
199
|
+
click(moduleTrigger);
|
|
200
|
+
navigate.mockClear();
|
|
201
|
+
click(getChildLink()!);
|
|
202
|
+
|
|
203
|
+
expect(navigate).toHaveBeenCalledWith('/pricebook/services');
|
|
204
|
+
expect(navigate).toHaveBeenCalledTimes(1);
|
|
205
|
+
expect(closeDrawer).toHaveBeenCalledTimes(1);
|
|
206
|
+
expect(onSubmenuOpen).toHaveBeenCalledTimes(1);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
// A group with no destination has nothing to navigate to, so its name stays a plain toggle.
|
|
210
|
+
it('still lets groups without a destination toggle from their name', () => {
|
|
211
|
+
const { moduleTrigger, onSubmenuOpen } = renderGroup('');
|
|
212
|
+
|
|
213
|
+
expect(moduleTrigger.tabIndex).toBe(0);
|
|
214
|
+
click(moduleTrigger);
|
|
215
|
+
|
|
216
|
+
expect(getChildLink()).toBeTruthy();
|
|
217
|
+
expect(onSubmenuOpen).toHaveBeenCalledTimes(1);
|
|
218
|
+
|
|
219
|
+
click(moduleTrigger);
|
|
220
|
+
expect(getChildLink()).toBeNull();
|
|
221
|
+
expect(onSubmenuOpen.mock.calls).toEqual([
|
|
222
|
+
['pricebook', true],
|
|
223
|
+
['pricebook', false],
|
|
224
|
+
]);
|
|
225
|
+
});
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import { ComponentPropsWithoutRef, PropsWithChildren } from 'react';
|
|
2
|
+
import { Root, createRoot } from 'react-dom/client';
|
|
3
|
+
import { act } from 'react-dom/test-utils';
|
|
4
|
+
import {
|
|
5
|
+
NavigationCatalog,
|
|
6
|
+
NavigationCatalogProps,
|
|
7
|
+
} from '../navigation-catalog/navigation-catalog';
|
|
8
|
+
|
|
9
|
+
// Keep the real catalog/confirmation logic; substitute only Anvil's browser-dependent primitives.
|
|
10
|
+
jest.mock('@servicetitan/anvil2', () => {
|
|
11
|
+
const { forwardRef } = jest.requireActual<typeof import('react')>('react');
|
|
12
|
+
const Box = ({ children }: PropsWithChildren) => <div>{children}</div>;
|
|
13
|
+
|
|
14
|
+
return {
|
|
15
|
+
Dialog: Object.assign(({ children }: PropsWithChildren) => <div>{children}</div>, {
|
|
16
|
+
Content: Box,
|
|
17
|
+
Footer: Box,
|
|
18
|
+
}),
|
|
19
|
+
Text: Box,
|
|
20
|
+
Icon: () => null,
|
|
21
|
+
Tooltip: Object.assign(Box, { Trigger: Box, Content: () => null }),
|
|
22
|
+
Button: forwardRef<
|
|
23
|
+
HTMLButtonElement,
|
|
24
|
+
ComponentPropsWithoutRef<'button'> & {
|
|
25
|
+
appearance?: string;
|
|
26
|
+
size?: string;
|
|
27
|
+
icon?: unknown;
|
|
28
|
+
}
|
|
29
|
+
>(function MockButton({ appearance, size, icon, ...props }, ref) {
|
|
30
|
+
return <button ref={ref} {...props} />;
|
|
31
|
+
}),
|
|
32
|
+
SearchField: forwardRef<
|
|
33
|
+
HTMLInputElement,
|
|
34
|
+
Omit<ComponentPropsWithoutRef<'input'>, 'size'> & {
|
|
35
|
+
size?: string;
|
|
36
|
+
onClear?: () => void;
|
|
37
|
+
}
|
|
38
|
+
>(function MockSearchField({ size, onClear, ...props }, ref) {
|
|
39
|
+
return <input ref={ref} {...props} />;
|
|
40
|
+
}),
|
|
41
|
+
};
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
let container: HTMLDivElement;
|
|
45
|
+
let root: Root;
|
|
46
|
+
const actEnvironment = globalThis as typeof globalThis &
|
|
47
|
+
Record<'IS_REACT_ACT_ENVIRONMENT', boolean | undefined>;
|
|
48
|
+
const previousActEnvironment = actEnvironment.IS_REACT_ACT_ENVIRONMENT;
|
|
49
|
+
|
|
50
|
+
beforeAll(() => {
|
|
51
|
+
actEnvironment.IS_REACT_ACT_ENVIRONMENT = true;
|
|
52
|
+
});
|
|
53
|
+
afterAll(() => {
|
|
54
|
+
actEnvironment.IS_REACT_ACT_ENVIRONMENT = previousActEnvironment;
|
|
55
|
+
});
|
|
56
|
+
beforeEach(() => {
|
|
57
|
+
container = document.createElement('div');
|
|
58
|
+
document.body.appendChild(container);
|
|
59
|
+
root = createRoot(container);
|
|
60
|
+
});
|
|
61
|
+
afterEach(() => {
|
|
62
|
+
act(() => root.unmount());
|
|
63
|
+
container.remove();
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
const pinned = ['marketing', 'overview'];
|
|
67
|
+
const groups: NavigationCatalogProps['groups'] = () => [
|
|
68
|
+
{
|
|
69
|
+
title: 'Demand',
|
|
70
|
+
items: [
|
|
71
|
+
{
|
|
72
|
+
id: 'marketing',
|
|
73
|
+
title: 'Marketing',
|
|
74
|
+
to: '/marketing',
|
|
75
|
+
icon: undefined,
|
|
76
|
+
iconActive: undefined,
|
|
77
|
+
submenu: {
|
|
78
|
+
groups: [
|
|
79
|
+
{
|
|
80
|
+
title: 'Analytics',
|
|
81
|
+
links: [
|
|
82
|
+
{ id: 'overview', title: 'Overview', to: '/marketing/overview' },
|
|
83
|
+
{ id: 'campaigns', title: 'Campaigns', to: '/marketing/campaigns' },
|
|
84
|
+
],
|
|
85
|
+
},
|
|
86
|
+
],
|
|
87
|
+
},
|
|
88
|
+
},
|
|
89
|
+
],
|
|
90
|
+
},
|
|
91
|
+
];
|
|
92
|
+
|
|
93
|
+
const renderCatalog = (overrides: Partial<NavigationCatalogProps> = {}) => {
|
|
94
|
+
const props = {
|
|
95
|
+
open: true,
|
|
96
|
+
onClose: jest.fn(),
|
|
97
|
+
groups,
|
|
98
|
+
pinnedIds: pinned,
|
|
99
|
+
onPinnedIdsChange: jest.fn(),
|
|
100
|
+
onResetToDefaults: jest.fn(),
|
|
101
|
+
...overrides,
|
|
102
|
+
};
|
|
103
|
+
act(() => root.render(<NavigationCatalog {...props} />));
|
|
104
|
+
return props;
|
|
105
|
+
};
|
|
106
|
+
const button = (text: string) =>
|
|
107
|
+
[...container.querySelectorAll('button')].find(element => element.textContent === text);
|
|
108
|
+
const click = (element: Element) => {
|
|
109
|
+
act(() => element.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true })));
|
|
110
|
+
};
|
|
111
|
+
const marketingStar = () => container.querySelector('button[aria-label*="Marketing"]')!;
|
|
112
|
+
|
|
113
|
+
it('reports only a confirmed reset, without proposing a list of its own', () => {
|
|
114
|
+
const props = renderCatalog();
|
|
115
|
+
click(button('Restore Default Favorites')!);
|
|
116
|
+
expect(props.onResetToDefaults).not.toHaveBeenCalled();
|
|
117
|
+
expect(document.activeElement).toBe(button('Cancel'));
|
|
118
|
+
|
|
119
|
+
click(button('Cancel')!);
|
|
120
|
+
expect(props.onResetToDefaults).not.toHaveBeenCalled();
|
|
121
|
+
expect(props.onPinnedIdsChange).not.toHaveBeenCalled();
|
|
122
|
+
expect(document.activeElement).toBe(button('Restore Default Favorites'));
|
|
123
|
+
|
|
124
|
+
click(button('Restore Default Favorites')!);
|
|
125
|
+
click(button('Restore')!);
|
|
126
|
+
expect(props.onResetToDefaults).toHaveBeenCalledTimes(1);
|
|
127
|
+
expect(props.onResetToDefaults).toHaveBeenCalledWith();
|
|
128
|
+
expect(props.onPinnedIdsChange).not.toHaveBeenCalled();
|
|
129
|
+
expect(document.activeElement).toBe(container.querySelector('input'));
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('leaves favorites untouched until the host applies the reset', () => {
|
|
133
|
+
const props = renderCatalog();
|
|
134
|
+
click(button('Restore Default Favorites')!);
|
|
135
|
+
click(button('Restore')!);
|
|
136
|
+
// Fully controlled: the catalog has proposed nothing, so the rows still read as they did.
|
|
137
|
+
expect(marketingStar().getAttribute('aria-pressed')).toBe('true');
|
|
138
|
+
expect(props.onPinnedIdsChange).not.toHaveBeenCalled();
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('keeps star edits on onPinnedIdsChange without calling the reset handler', () => {
|
|
142
|
+
const props = renderCatalog();
|
|
143
|
+
click(marketingStar());
|
|
144
|
+
expect(props.onPinnedIdsChange).toHaveBeenCalledWith([]);
|
|
145
|
+
expect(props.onResetToDefaults).not.toHaveBeenCalled();
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it('hides restore when the host does not offer one', () => {
|
|
149
|
+
renderCatalog({ onResetToDefaults: undefined });
|
|
150
|
+
expect(button('Restore Default Favorites')).toBeUndefined();
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it('drops a pending confirmation when the host withdraws the handler', () => {
|
|
154
|
+
const props = renderCatalog();
|
|
155
|
+
click(button('Restore Default Favorites')!);
|
|
156
|
+
renderCatalog({ ...props, onResetToDefaults: undefined });
|
|
157
|
+
expect(button('Restore')).toBeUndefined();
|
|
158
|
+
expect(props.onResetToDefaults).not.toHaveBeenCalled();
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it('calls the handler the host supplies now, not the one it confirmed against', () => {
|
|
162
|
+
const previous = renderCatalog();
|
|
163
|
+
click(button('Restore Default Favorites')!);
|
|
164
|
+
const current = renderCatalog({ ...previous, onResetToDefaults: jest.fn() });
|
|
165
|
+
click(button('Restore')!);
|
|
166
|
+
expect(current.onResetToDefaults).toHaveBeenCalledTimes(1);
|
|
167
|
+
expect(previous.onResetToDefaults).not.toHaveBeenCalled();
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it('reports reset intent while search hides all destinations, preserving the query', () => {
|
|
171
|
+
const props = renderCatalog();
|
|
172
|
+
const input = container.querySelector('input')!;
|
|
173
|
+
act(() => {
|
|
174
|
+
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set!.call(
|
|
175
|
+
input,
|
|
176
|
+
'no matching page'
|
|
177
|
+
);
|
|
178
|
+
input.dispatchEvent(new Event('input', { bubbles: true }));
|
|
179
|
+
});
|
|
180
|
+
expect(container.textContent).toContain('No navigation items match');
|
|
181
|
+
click(button('Restore Default Favorites')!);
|
|
182
|
+
click(button('Restore')!);
|
|
183
|
+
expect(props.onResetToDefaults).toHaveBeenCalledTimes(1);
|
|
184
|
+
expect(props.onPinnedIdsChange).not.toHaveBeenCalled();
|
|
185
|
+
expect(input.value).toBe('no matching page');
|
|
186
|
+
});
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { IconProps } from '@servicetitan/anvil2';
|
|
2
2
|
import { ComponentPropsWithoutRef, FC, ReactNode } from 'react';
|
|
3
3
|
import { CounterTagData, CounterTagValue } from '../../utils/counter-tag';
|
|
4
|
+
import { NavigationItemData } from '../../utils/navigation';
|
|
4
5
|
import { TitanLayoutSidebarContextType } from './layout-context';
|
|
5
6
|
|
|
6
7
|
export interface TitanLayoutLinkWrapperProps {
|
|
@@ -11,6 +12,78 @@ export interface TitanLayoutLinkWrapperProps {
|
|
|
11
12
|
|
|
12
13
|
export type TitanLayoutLinkWrapper = FC<TitanLayoutLinkWrapperProps>;
|
|
13
14
|
|
|
15
|
+
/** A titled section of the navigation catalog. */
|
|
16
|
+
export interface NavigationCatalogGroup {
|
|
17
|
+
/** section heading */
|
|
18
|
+
title: string;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The destinations in this section.
|
|
22
|
+
*
|
|
23
|
+
* `id` is treated as an opaque pin id and is compared verbatim against `pinnedIds`, so the host
|
|
24
|
+
* is responsible for handing over ids in whatever form it stores them. Any `submenu` links are
|
|
25
|
+
* offered as separately pinnable pages beneath their item, and their `id`s are pin ids too.
|
|
26
|
+
*/
|
|
27
|
+
items: NavigationItemData[];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Turns the navigation catalog on.
|
|
32
|
+
*
|
|
33
|
+
* Supplying this renders a catalog trigger in the sidebar and the dialog behind it; omitting it
|
|
34
|
+
* leaves the navigation exactly as it was. The layout owns the dialog, its open state and the
|
|
35
|
+
* keyboard shortcut, and nothing else about pinning: it proposes a new list, or reports that the
|
|
36
|
+
* user confirmed a reset, and the host decides what to do with either.
|
|
37
|
+
*/
|
|
38
|
+
export interface PinnedNavigationConfig {
|
|
39
|
+
/**
|
|
40
|
+
* Every destination on offer, resolved on demand.
|
|
41
|
+
*
|
|
42
|
+
* A function rather than an array because resolving a catalog is not free for every host: one
|
|
43
|
+
* whose submenus are dynamic pays a request per module to enumerate its pages. The dialog is
|
|
44
|
+
* mounted only while open, so taking the groups as a thunk keeps that cost — and any failure
|
|
45
|
+
* in it — inside the dialog instead of on every page load of the shell.
|
|
46
|
+
*
|
|
47
|
+
* Called once per open, and again only if the function's identity changes, so a host should
|
|
48
|
+
* memoize it against whatever its resolution actually depends on.
|
|
49
|
+
*/
|
|
50
|
+
groups: () => NavigationCatalogGroup[];
|
|
51
|
+
|
|
52
|
+
/** The pinned ids, in display order. */
|
|
53
|
+
pinnedIds: string[];
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Ids the user may not pin or unpin.
|
|
57
|
+
*
|
|
58
|
+
* A locked destination is always present in the navigation whether or not it appears in
|
|
59
|
+
* `pinnedIds`, so the catalog shows a lock where the pin control would be and never proposes a
|
|
60
|
+
* list that adds or removes one.
|
|
61
|
+
*/
|
|
62
|
+
lockedIds?: string[];
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The list the user just asked for through a star control, in full.
|
|
66
|
+
*
|
|
67
|
+
* Fully controlled: nothing changes until `pinnedIds` comes back different. A host that
|
|
68
|
+
* declines the proposal simply does not apply it, and the control returns to its previous
|
|
69
|
+
* state on its own. Confirming a reset calls `onResetToDefaults` instead.
|
|
70
|
+
*/
|
|
71
|
+
onPinnedIdsChange: (pinnedIds: string[]) => void;
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Called once the user confirms Restore Default Favorites.
|
|
75
|
+
*
|
|
76
|
+
* The catalog reports the intent and proposes nothing: what the defaults are, whether locked
|
|
77
|
+
* destinations survive and how the reset is persisted are all the host's. Favourites move only
|
|
78
|
+
* when the host sends new `pinnedIds` down, and the catalog does not also call
|
|
79
|
+
* `onPinnedIdsChange`.
|
|
80
|
+
*
|
|
81
|
+
* Omit it to hide the restore control — including when the host offers defaults but the user
|
|
82
|
+
* has not strayed from them, which only the host can know.
|
|
83
|
+
*/
|
|
84
|
+
onResetToDefaults?: () => void;
|
|
85
|
+
}
|
|
86
|
+
|
|
14
87
|
export interface TitanLayoutLinkExtraProps {
|
|
15
88
|
/** link's title should be shown (for extra links we show icon only by default) */
|
|
16
89
|
showTitle?: boolean;
|