@servicetitan/navigation 14.2.0 → 14.4.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 +343 -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 +379 -0
- package/dist/components/titan-layout/navigation-catalog/navigation-catalog.module.less.d.ts +26 -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__/catalog-groups.test.d.ts +2 -0
- package/dist/utils/__tests__/catalog-groups.test.d.ts.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/catalog-groups.d.ts +11 -0
- package/dist/utils/catalog-groups.d.ts.map +1 -0
- package/dist/utils/catalog-groups.js +66 -0
- package/dist/utils/catalog-groups.js.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 +190 -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 +379 -0
- package/src/components/titan-layout/navigation-catalog/navigation-catalog.module.less.d.ts +26 -0
- package/src/components/titan-layout/navigation-catalog/navigation-catalog.tsx +430 -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__/catalog-groups.test.ts +97 -0
- package/src/utils/__tests__/navigation-notifications.test.ts +70 -0
- package/src/utils/__tests__/submenu-state.test.ts +39 -0
- package/src/utils/catalog-groups.ts +57 -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
|
@@ -0,0 +1,430 @@
|
|
|
1
|
+
import { Button, Dialog, Icon, SearchField, Text, Tooltip } from '@servicetitan/anvil2';
|
|
2
|
+
import IconLock from '@servicetitan/anvil2/assets/icons/material/round/lock.svg';
|
|
3
|
+
import IconStar from '@servicetitan/anvil2/assets/icons/material/round/star.svg';
|
|
4
|
+
import IconStarBorder from '@servicetitan/anvil2/assets/icons/material/round/star_border.svg';
|
|
5
|
+
import classNames from 'classnames';
|
|
6
|
+
import { FC, Fragment, MouseEvent, useMemo, useRef, useState } from 'react';
|
|
7
|
+
import {
|
|
8
|
+
filterCatalogGroups,
|
|
9
|
+
getCatalogChildren,
|
|
10
|
+
getCatalogSubmenuGroups,
|
|
11
|
+
} from '../../../utils/catalog-groups';
|
|
12
|
+
import { NavigationItemData } from '../../../utils/navigation';
|
|
13
|
+
import { getCounterTag } from '../../../utils/side-nav';
|
|
14
|
+
import { BadgeTag } from '../../badge-tag';
|
|
15
|
+
import { PinnedNavigationConfig } from '../interface';
|
|
16
|
+
import { useTitanLayoutContext } from '../layout-context';
|
|
17
|
+
import { NavigationCatalogFooter } from './navigation-catalog-footer';
|
|
18
|
+
import * as styles from './navigation-catalog.module.less';
|
|
19
|
+
|
|
20
|
+
/** Shown in place of the pin control for a destination the user may not unpin. */
|
|
21
|
+
const LockedPin: FC = () => (
|
|
22
|
+
<Tooltip>
|
|
23
|
+
<Tooltip.Trigger>
|
|
24
|
+
<Icon svg={IconLock} className={styles.lock} />
|
|
25
|
+
</Tooltip.Trigger>
|
|
26
|
+
<Tooltip.Content style={{ pointerEvents: 'none' }}>
|
|
27
|
+
Cannot be removed from Favorites
|
|
28
|
+
</Tooltip.Content>
|
|
29
|
+
</Tooltip>
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
interface PinToggleProps {
|
|
33
|
+
/** Mixed modules stay highlighted but use an outline until all pages are pinned. */
|
|
34
|
+
pinned: boolean | 'mixed';
|
|
35
|
+
label: string;
|
|
36
|
+
onToggle: () => void;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const PinToggle: FC<PinToggleProps> = ({ pinned, label, onToggle }) => {
|
|
40
|
+
const toggle = (event: MouseEvent) => {
|
|
41
|
+
// The control sits inside the row but is not a navigation affordance.
|
|
42
|
+
event.preventDefault();
|
|
43
|
+
event.stopPropagation();
|
|
44
|
+
onToggle();
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
return (
|
|
48
|
+
<Tooltip delay={300}>
|
|
49
|
+
<Tooltip.Trigger>
|
|
50
|
+
<Button
|
|
51
|
+
appearance="ghost"
|
|
52
|
+
size="small"
|
|
53
|
+
icon={pinned === true ? IconStar : IconStarBorder}
|
|
54
|
+
className={classNames(styles.star, pinned && styles.starActive)}
|
|
55
|
+
aria-label={label}
|
|
56
|
+
aria-pressed={pinned}
|
|
57
|
+
onClick={toggle}
|
|
58
|
+
/>
|
|
59
|
+
</Tooltip.Trigger>
|
|
60
|
+
<Tooltip.Content style={{ pointerEvents: 'none' }}>
|
|
61
|
+
{pinned === 'mixed'
|
|
62
|
+
? 'Add this module to Favorites'
|
|
63
|
+
: pinned
|
|
64
|
+
? 'Remove from Favorites'
|
|
65
|
+
: 'Add to Favorites'}
|
|
66
|
+
</Tooltip.Content>
|
|
67
|
+
</Tooltip>
|
|
68
|
+
);
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
interface CatalogRowsProps {
|
|
72
|
+
item: NavigationItemData;
|
|
73
|
+
/**
|
|
74
|
+
* Every id the module's own control covers, taken from the unfiltered item.
|
|
75
|
+
*
|
|
76
|
+
* `item` may have been narrowed by the search, so deriving this from it would let the control
|
|
77
|
+
* act on only the pages that happen to be visible — silently stranding the rest.
|
|
78
|
+
*/
|
|
79
|
+
scopeIds: string[];
|
|
80
|
+
isPinned: (id: string) => boolean;
|
|
81
|
+
isLocked: (id: string) => boolean;
|
|
82
|
+
/** `ownerId` is the module the page belongs to; omitted for the module's own row. */
|
|
83
|
+
onTogglePin: (id: string, ownerId?: string) => void;
|
|
84
|
+
onToggleAll: (ids: string[], shouldPin: boolean) => void;
|
|
85
|
+
onNavigate: () => void;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const CatalogRows: FC<CatalogRowsProps> = ({
|
|
89
|
+
item,
|
|
90
|
+
scopeIds,
|
|
91
|
+
isPinned,
|
|
92
|
+
isLocked,
|
|
93
|
+
onTogglePin,
|
|
94
|
+
onToggleAll,
|
|
95
|
+
onNavigate,
|
|
96
|
+
}) => {
|
|
97
|
+
const { NavigationComponent } = useTitanLayoutContext();
|
|
98
|
+
const submenuGroups = getCatalogSubmenuGroups(item);
|
|
99
|
+
|
|
100
|
+
/*
|
|
101
|
+
* Minus anything locked — locked destinations are always present, so the module's control
|
|
102
|
+
* neither reports on them nor acts on them.
|
|
103
|
+
*/
|
|
104
|
+
const everything = scopeIds.filter(id => !isLocked(id));
|
|
105
|
+
const anyPinned = everything.some(isPinned);
|
|
106
|
+
const allPinned = everything.length > 0 && everything.every(isPinned);
|
|
107
|
+
const tag = getCounterTag(item.counter, item.tag);
|
|
108
|
+
|
|
109
|
+
return (
|
|
110
|
+
<div className={styles.moduleGroup}>
|
|
111
|
+
<div className={styles.row}>
|
|
112
|
+
<NavigationComponent
|
|
113
|
+
to={item.to}
|
|
114
|
+
className={styles.link}
|
|
115
|
+
onClick={onNavigate}
|
|
116
|
+
aria-label={item.title}
|
|
117
|
+
>
|
|
118
|
+
{item.icon && (
|
|
119
|
+
<Icon svg={item.icon} size="medium" className={styles.moduleIcon} />
|
|
120
|
+
)}
|
|
121
|
+
<Text inline size="small" className={styles.moduleTitle}>
|
|
122
|
+
{item.title}
|
|
123
|
+
</Text>
|
|
124
|
+
{/*
|
|
125
|
+
* The same counter the rail shows, so a destination that is asking for
|
|
126
|
+
* attention still says so here — the catalog is the only place an unfavourited
|
|
127
|
+
* one is visible at all.
|
|
128
|
+
*/}
|
|
129
|
+
{!!tag && <BadgeTag data={tag} className={styles.badge} />}
|
|
130
|
+
</NavigationComponent>
|
|
131
|
+
{/*
|
|
132
|
+
* One control for the whole module: favouriting it takes its pages with it, and
|
|
133
|
+
* unfavouriting it takes them away again.
|
|
134
|
+
*
|
|
135
|
+
* A gold outline marks a partially favourited module. Fill the star only when
|
|
136
|
+
* its full, unfiltered scope is favourited, including pages hidden by search.
|
|
137
|
+
* Clicking the outline adds the remaining pages; clicking the filled star removes all.
|
|
138
|
+
*/}
|
|
139
|
+
{isLocked(item.id) ? (
|
|
140
|
+
<LockedPin />
|
|
141
|
+
) : (
|
|
142
|
+
<PinToggle
|
|
143
|
+
pinned={allPinned ? true : anyPinned ? 'mixed' : false}
|
|
144
|
+
label={
|
|
145
|
+
allPinned
|
|
146
|
+
? `Unpin ${item.title} from the navigation`
|
|
147
|
+
: `Pin ${item.title} to the navigation`
|
|
148
|
+
}
|
|
149
|
+
onToggle={() => onToggleAll(everything, !allPinned)}
|
|
150
|
+
/>
|
|
151
|
+
)}
|
|
152
|
+
</div>
|
|
153
|
+
{submenuGroups.map(group => (
|
|
154
|
+
<div key={group.links[0].id}>
|
|
155
|
+
{group.title && (
|
|
156
|
+
<Text variant="eyebrow" size="small" className={styles.submenuTitle}>
|
|
157
|
+
{group.title}
|
|
158
|
+
</Text>
|
|
159
|
+
)}
|
|
160
|
+
{group.links.map(child => {
|
|
161
|
+
/*
|
|
162
|
+
* Page titles are only unique within their item — "Settings" and "Reports" recur
|
|
163
|
+
* across the catalog — and the row that tells them apart is a separate element, so
|
|
164
|
+
* it is not part of any of these names. Anything reading the rows on their own, a
|
|
165
|
+
* screen reader running through the links or a voice command naming one, would
|
|
166
|
+
* otherwise be given the same name several times.
|
|
167
|
+
*/
|
|
168
|
+
const qualifiedTitle = `${item.title}: ${child.title}`;
|
|
169
|
+
const childPinned = isPinned(child.id);
|
|
170
|
+
const childTag = getCounterTag(child.counter, child.tag);
|
|
171
|
+
|
|
172
|
+
return (
|
|
173
|
+
<div key={child.id} className={styles.row}>
|
|
174
|
+
<NavigationComponent
|
|
175
|
+
to={child.to}
|
|
176
|
+
className={classNames(styles.link, styles.childLink)}
|
|
177
|
+
onClick={onNavigate}
|
|
178
|
+
aria-label={qualifiedTitle}
|
|
179
|
+
>
|
|
180
|
+
<Text inline className={styles.childTitle}>
|
|
181
|
+
{child.title}
|
|
182
|
+
</Text>
|
|
183
|
+
{!!childTag && (
|
|
184
|
+
<BadgeTag data={childTag} className={styles.badge} />
|
|
185
|
+
)}
|
|
186
|
+
</NavigationComponent>
|
|
187
|
+
{isLocked(child.id) ? (
|
|
188
|
+
<LockedPin />
|
|
189
|
+
) : (
|
|
190
|
+
<PinToggle
|
|
191
|
+
pinned={childPinned}
|
|
192
|
+
label={
|
|
193
|
+
childPinned
|
|
194
|
+
? `Unpin ${qualifiedTitle} from the navigation`
|
|
195
|
+
: `Pin ${qualifiedTitle} to the navigation`
|
|
196
|
+
}
|
|
197
|
+
onToggle={() => onTogglePin(child.id, item.id)}
|
|
198
|
+
/>
|
|
199
|
+
)}
|
|
200
|
+
</div>
|
|
201
|
+
);
|
|
202
|
+
})}
|
|
203
|
+
</div>
|
|
204
|
+
))}
|
|
205
|
+
</div>
|
|
206
|
+
);
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
export interface NavigationCatalogProps extends PinnedNavigationConfig {
|
|
210
|
+
open: boolean;
|
|
211
|
+
onClose: () => void;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Every destination the host offers, grouped into sections, with a star on every row.
|
|
216
|
+
*
|
|
217
|
+
* The catalog shows what it is given and nothing more — the host has already resolved what this
|
|
218
|
+
* user may reach, so the catalog cannot offer a destination they do not have.
|
|
219
|
+
*/
|
|
220
|
+
export const NavigationCatalog: FC<NavigationCatalogProps> = ({
|
|
221
|
+
open,
|
|
222
|
+
onClose,
|
|
223
|
+
groups,
|
|
224
|
+
pinnedIds,
|
|
225
|
+
lockedIds,
|
|
226
|
+
onPinnedIdsChange,
|
|
227
|
+
onResetToDefaults,
|
|
228
|
+
}) => {
|
|
229
|
+
const [query, setQuery] = useState('');
|
|
230
|
+
const isSearching = query.trim().length > 0;
|
|
231
|
+
const searchRef = useRef<HTMLInputElement>(null);
|
|
232
|
+
|
|
233
|
+
/*
|
|
234
|
+
* Resolved here rather than by the host, so a catalog that costs requests to enumerate is paid
|
|
235
|
+
* for on opening the dialog and not on every page load. This component is mounted only while
|
|
236
|
+
* open, so the call happens once per open unless the host changes the function.
|
|
237
|
+
*/
|
|
238
|
+
const resolvedGroups = useMemo(() => groups(), [groups]);
|
|
239
|
+
|
|
240
|
+
const visibleGroups = useMemo(
|
|
241
|
+
() => filterCatalogGroups(resolvedGroups, query),
|
|
242
|
+
[resolvedGroups, query]
|
|
243
|
+
);
|
|
244
|
+
|
|
245
|
+
/*
|
|
246
|
+
* The pin scope of every item, keyed by id and built before the search narrows anything, so a
|
|
247
|
+
* module's control still covers the pages a query is currently hiding.
|
|
248
|
+
*/
|
|
249
|
+
const scopeById = useMemo(
|
|
250
|
+
() =>
|
|
251
|
+
new Map(
|
|
252
|
+
resolvedGroups
|
|
253
|
+
.flatMap(group => group.items)
|
|
254
|
+
.map(item => [
|
|
255
|
+
item.id,
|
|
256
|
+
[item.id, ...getCatalogChildren(item).map(child => child.id)],
|
|
257
|
+
])
|
|
258
|
+
),
|
|
259
|
+
[resolvedGroups]
|
|
260
|
+
);
|
|
261
|
+
const pinnedSet = useMemo(() => new Set(pinnedIds), [pinnedIds]);
|
|
262
|
+
const lockedSet = useMemo(() => new Set(lockedIds ?? []), [lockedIds]);
|
|
263
|
+
|
|
264
|
+
const isPinned = (id: string) => pinnedSet.has(id);
|
|
265
|
+
const isLocked = (id: string) => lockedSet.has(id);
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Favourites or unfavourites one row, carrying its module with it.
|
|
269
|
+
*
|
|
270
|
+
* A page is reached through its module, so the two travel together: favouriting a page brings
|
|
271
|
+
* the module along when it is not already there, and unfavouriting the last one takes the
|
|
272
|
+
* module with it. Leaving the module behind on its own would not read as an emptier
|
|
273
|
+
* navigation — it would put the module back carrying every page it has.
|
|
274
|
+
*
|
|
275
|
+
* Locked ids never reach a proposal. The controls that would move them are not rendered, so
|
|
276
|
+
* that part is belt and braces — but the host's list is the thing that gets persisted, and a
|
|
277
|
+
* locked destination has no business appearing in or disappearing from it.
|
|
278
|
+
*/
|
|
279
|
+
const togglePin = (id: string, ownerId?: string) => {
|
|
280
|
+
if (lockedSet.has(id)) {
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** The module, when it is a separate row that this page can legitimately move. */
|
|
285
|
+
const owner = ownerId !== id && ownerId && !lockedSet.has(ownerId) ? ownerId : undefined;
|
|
286
|
+
|
|
287
|
+
if (pinnedSet.has(id)) {
|
|
288
|
+
const removing = new Set([id]);
|
|
289
|
+
|
|
290
|
+
if (owner) {
|
|
291
|
+
const siblings = (scopeById.get(owner) ?? []).filter(
|
|
292
|
+
scoped => scoped !== owner && scoped !== id
|
|
293
|
+
);
|
|
294
|
+
|
|
295
|
+
if (!siblings.some(sibling => pinnedSet.has(sibling))) {
|
|
296
|
+
removing.add(owner);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
onPinnedIdsChange(pinnedIds.filter(pinned => !removing.has(pinned)));
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// Module first, so it reads ahead of its own page in the saved order.
|
|
305
|
+
const additions = owner && !pinnedSet.has(owner) ? [owner, id] : [id];
|
|
306
|
+
|
|
307
|
+
onPinnedIdsChange([...pinnedIds, ...additions]);
|
|
308
|
+
};
|
|
309
|
+
|
|
310
|
+
/*
|
|
311
|
+
* One proposal for the whole set rather than one per row, so a host that persists on every
|
|
312
|
+
* change writes once, and every intermediate list — states the user never asked for — never
|
|
313
|
+
* exists.
|
|
314
|
+
*/
|
|
315
|
+
const toggleAll = (ids: string[], shouldPin: boolean) => {
|
|
316
|
+
const actionable = ids.filter(id => !lockedSet.has(id));
|
|
317
|
+
|
|
318
|
+
if (!shouldPin) {
|
|
319
|
+
const removing = new Set(actionable);
|
|
320
|
+
|
|
321
|
+
onPinnedIdsChange(pinnedIds.filter(pinned => !removing.has(pinned)));
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
const additions = actionable.filter(id => !pinnedSet.has(id));
|
|
326
|
+
|
|
327
|
+
if (additions.length > 0) {
|
|
328
|
+
onPinnedIdsChange([...pinnedIds, ...additions]);
|
|
329
|
+
}
|
|
330
|
+
};
|
|
331
|
+
|
|
332
|
+
const close = () => {
|
|
333
|
+
setQuery('');
|
|
334
|
+
onClose();
|
|
335
|
+
};
|
|
336
|
+
|
|
337
|
+
return (
|
|
338
|
+
<Dialog
|
|
339
|
+
open={open}
|
|
340
|
+
onClose={close}
|
|
341
|
+
size="xlarge"
|
|
342
|
+
aria-label="All Navigation"
|
|
343
|
+
className={styles.dialog}
|
|
344
|
+
data-cy="navigation-catalog-dialog"
|
|
345
|
+
>
|
|
346
|
+
<Dialog.Content className={styles.content}>
|
|
347
|
+
<div className={styles.body}>
|
|
348
|
+
<SearchField
|
|
349
|
+
ref={searchRef}
|
|
350
|
+
size="small"
|
|
351
|
+
className={styles.search}
|
|
352
|
+
placeholder="Go to ..."
|
|
353
|
+
value={query}
|
|
354
|
+
onChange={event => setQuery(event.target.value)}
|
|
355
|
+
onClear={() => setQuery('')}
|
|
356
|
+
aria-label="Go to"
|
|
357
|
+
/>
|
|
358
|
+
<div
|
|
359
|
+
className={classNames(styles.results, isSearching && styles.searchResults)}
|
|
360
|
+
>
|
|
361
|
+
{visibleGroups.length === 0 ? (
|
|
362
|
+
<div className={styles.empty}>
|
|
363
|
+
{/*
|
|
364
|
+
* Two different nothings: a search that matched no row, and a host
|
|
365
|
+
* that offered no rows at all. The second is a real state — the
|
|
366
|
+
* catalog is reachable whether or not there is anything in it — and
|
|
367
|
+
* quoting an empty query at the user does not describe it.
|
|
368
|
+
*/}
|
|
369
|
+
<Text subdued>
|
|
370
|
+
{isSearching ? (
|
|
371
|
+
<Fragment>
|
|
372
|
+
No navigation items match “{query}”.
|
|
373
|
+
</Fragment>
|
|
374
|
+
) : (
|
|
375
|
+
'There are no navigation items to show.'
|
|
376
|
+
)}
|
|
377
|
+
</Text>
|
|
378
|
+
</div>
|
|
379
|
+
) : (
|
|
380
|
+
<div className={isSearching ? undefined : styles.columns}>
|
|
381
|
+
{visibleGroups.map(group => (
|
|
382
|
+
<div
|
|
383
|
+
key={group.title}
|
|
384
|
+
className={isSearching ? undefined : styles.group}
|
|
385
|
+
>
|
|
386
|
+
{/*
|
|
387
|
+
* Browse uses titled columns. Search stacks matches at full
|
|
388
|
+
* width, keeping only each module's own submenu headings.
|
|
389
|
+
*/}
|
|
390
|
+
{!isSearching && (
|
|
391
|
+
<Text
|
|
392
|
+
variant="eyebrow"
|
|
393
|
+
size="medium"
|
|
394
|
+
className={styles.groupTitle}
|
|
395
|
+
>
|
|
396
|
+
{group.title}
|
|
397
|
+
</Text>
|
|
398
|
+
)}
|
|
399
|
+
{group.items.map(item => (
|
|
400
|
+
<CatalogRows
|
|
401
|
+
key={item.id}
|
|
402
|
+
item={item}
|
|
403
|
+
scopeIds={scopeById.get(item.id) ?? [item.id]}
|
|
404
|
+
isPinned={isPinned}
|
|
405
|
+
isLocked={isLocked}
|
|
406
|
+
onTogglePin={togglePin}
|
|
407
|
+
onToggleAll={toggleAll}
|
|
408
|
+
onNavigate={close}
|
|
409
|
+
/>
|
|
410
|
+
))}
|
|
411
|
+
</div>
|
|
412
|
+
))}
|
|
413
|
+
</div>
|
|
414
|
+
)}
|
|
415
|
+
</div>
|
|
416
|
+
</div>
|
|
417
|
+
</Dialog.Content>
|
|
418
|
+
<NavigationCatalogFooter
|
|
419
|
+
onRestore={
|
|
420
|
+
onResetToDefaults &&
|
|
421
|
+
(() => {
|
|
422
|
+
onResetToDefaults();
|
|
423
|
+
// The control disappears with the host's reset; keep focus in the dialog.
|
|
424
|
+
searchRef.current?.focus();
|
|
425
|
+
})
|
|
426
|
+
}
|
|
427
|
+
/>
|
|
428
|
+
</Dialog>
|
|
429
|
+
);
|
|
430
|
+
};
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { Button } from '@servicetitan/anvil2';
|
|
2
|
+
import SvgArrowDown from '@servicetitan/anvil2/assets/icons/material/round/arrow_downward.svg';
|
|
3
|
+
import SvgArrowUp from '@servicetitan/anvil2/assets/icons/material/round/arrow_upward.svg';
|
|
4
|
+
import classNames from 'classnames';
|
|
5
|
+
import { Fragment, RefObject } from 'react';
|
|
6
|
+
import * as Styles from './layout-sidebar.module.less';
|
|
7
|
+
|
|
8
|
+
export function NavigationScrollIndicators({
|
|
9
|
+
expanded,
|
|
10
|
+
above,
|
|
11
|
+
below,
|
|
12
|
+
hasContentAbove,
|
|
13
|
+
hasContentBelow,
|
|
14
|
+
aboveIndicatorRef,
|
|
15
|
+
belowIndicatorRef,
|
|
16
|
+
onReveal,
|
|
17
|
+
}: {
|
|
18
|
+
expanded: boolean;
|
|
19
|
+
above: number;
|
|
20
|
+
below: number;
|
|
21
|
+
hasContentAbove: boolean;
|
|
22
|
+
hasContentBelow: boolean;
|
|
23
|
+
aboveIndicatorRef: RefObject<HTMLDivElement>;
|
|
24
|
+
belowIndicatorRef: RefObject<HTMLDivElement>;
|
|
25
|
+
onReveal(direction: 'above' | 'below'): void;
|
|
26
|
+
}) {
|
|
27
|
+
return (
|
|
28
|
+
<Fragment>
|
|
29
|
+
{(['above', 'below'] as const).map(direction => {
|
|
30
|
+
const isAbove = direction === 'above';
|
|
31
|
+
const count = isAbove ? above : below;
|
|
32
|
+
const hasContent = isAbove ? hasContentAbove : hasContentBelow;
|
|
33
|
+
const label = `${count} ${count === 1 ? 'notification' : 'notifications'}`;
|
|
34
|
+
return (
|
|
35
|
+
<Fragment key={direction}>
|
|
36
|
+
{/*
|
|
37
|
+
* Its own element rather than the pill's background: the pill's height
|
|
38
|
+
* insets the rail's counted area, and this gradient hides nothing, so it
|
|
39
|
+
* must not. See `useNavigationScroll`.
|
|
40
|
+
*/}
|
|
41
|
+
{hasContent && (
|
|
42
|
+
<div
|
|
43
|
+
className={classNames(
|
|
44
|
+
Styles.scrollShadow,
|
|
45
|
+
isAbove ? Styles.scrollShadowAbove : Styles.scrollShadowBelow
|
|
46
|
+
)}
|
|
47
|
+
/>
|
|
48
|
+
)}
|
|
49
|
+
{count > 0 && (
|
|
50
|
+
<div
|
|
51
|
+
ref={isAbove ? aboveIndicatorRef : belowIndicatorRef}
|
|
52
|
+
className={classNames(
|
|
53
|
+
Styles.scrollIndicator,
|
|
54
|
+
isAbove
|
|
55
|
+
? Styles.scrollIndicatorAbove
|
|
56
|
+
: Styles.scrollIndicatorBelow
|
|
57
|
+
)}
|
|
58
|
+
>
|
|
59
|
+
<Button
|
|
60
|
+
appearance="secondary"
|
|
61
|
+
size="xsmall"
|
|
62
|
+
icon={{ before: isAbove ? SvgArrowUp : SvgArrowDown }}
|
|
63
|
+
className={Styles.notificationButton}
|
|
64
|
+
data-cy={`navigation-notifications-${direction}`}
|
|
65
|
+
aria-label={`${label} ${direction}. Show nearest notification`}
|
|
66
|
+
onClick={event => {
|
|
67
|
+
event.stopPropagation();
|
|
68
|
+
onReveal(direction);
|
|
69
|
+
}}
|
|
70
|
+
>
|
|
71
|
+
{expanded ? label : count >= 100 ? '99+' : count}
|
|
72
|
+
</Button>
|
|
73
|
+
</div>
|
|
74
|
+
)}
|
|
75
|
+
</Fragment>
|
|
76
|
+
);
|
|
77
|
+
})}
|
|
78
|
+
</Fragment>
|
|
79
|
+
);
|
|
80
|
+
}
|
|
@@ -15,7 +15,9 @@ import {
|
|
|
15
15
|
} from 'react';
|
|
16
16
|
import { NavigationItemData } from '../../utils/navigation';
|
|
17
17
|
import { DefaultNavLinkComponent, NavLinkComponentProps } from '../../utils/navigation-context';
|
|
18
|
+
import { updateOpenSubmenus } from '../../utils/submenu-state';
|
|
18
19
|
import { useTitanBreakpoint } from '../../utils/use-breakpoint';
|
|
20
|
+
import { PinnedNavigationConfig } from './interface';
|
|
19
21
|
import {
|
|
20
22
|
LayoutContext,
|
|
21
23
|
LayoutPlacementContext,
|
|
@@ -50,6 +52,12 @@ export type TitanLayoutProps = Omit<ComponentPropsWithoutRef<'div'>, 'children'
|
|
|
50
52
|
/** data for main navigation links */
|
|
51
53
|
navigationMainItems?: NavigationItemData[];
|
|
52
54
|
|
|
55
|
+
/**
|
|
56
|
+
* Turns the navigation catalog on: renders a trigger in the sidebar and the dialog behind it.
|
|
57
|
+
* Omit it and the navigation is unchanged.
|
|
58
|
+
*/
|
|
59
|
+
pinnedNavigation?: PinnedNavigationConfig;
|
|
60
|
+
|
|
53
61
|
/** data for overflow navigation links (used only with top variant) */
|
|
54
62
|
navigationOverflowItems?: NavigationItemData[];
|
|
55
63
|
|
|
@@ -65,6 +73,13 @@ export type TitanLayoutProps = Omit<ComponentPropsWithoutRef<'div'>, 'children'
|
|
|
65
73
|
openSubmenus?: string[];
|
|
66
74
|
/** submenu accordion open state change handler */
|
|
67
75
|
onOpenSubmenuChange?: (id: string, opened: boolean) => void;
|
|
76
|
+
/**
|
|
77
|
+
* Receives the complete next set of open modules for individual and bulk actions.
|
|
78
|
+
* Preferred over onOpenSubmenuChange when provided, so bulk updates are atomic.
|
|
79
|
+
* Required for the rail's `Expand All` / `Collapse All` control, which is hidden
|
|
80
|
+
* for hosts that supply only the per-module `onOpenSubmenuChange`.
|
|
81
|
+
*/
|
|
82
|
+
onOpenSubmenusChange?: (ids: string[]) => void;
|
|
68
83
|
|
|
69
84
|
/** navigation link click handler */
|
|
70
85
|
onNavigationLinkClick?: (itemId: string, submenuItemId?: string) => void;
|
|
@@ -78,6 +93,23 @@ export type TitanLayoutProps = Omit<ComponentPropsWithoutRef<'div'>, 'children'
|
|
|
78
93
|
/** top links for side navigation */
|
|
79
94
|
sideTop?: ReactElement[];
|
|
80
95
|
|
|
96
|
+
/**
|
|
97
|
+
* Destinations pinned above the sidebar's divider, ahead of `sideTop`.
|
|
98
|
+
*
|
|
99
|
+
* Rendered with the same machinery as `navigationMainItems`, so one with pages beneath it is
|
|
100
|
+
* an expandable group rather than a bare link. `sideTop` is unchanged and still takes elements
|
|
101
|
+
* the host builds itself; the two can be used together.
|
|
102
|
+
*/
|
|
103
|
+
sideTopItems?: NavigationItemData[];
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Whether the sidebar shows the scroll and overflow-notification indicators.
|
|
107
|
+
*
|
|
108
|
+
* Defaults on, so a host that says nothing keeps them. Navigation versions that predate the
|
|
109
|
+
* indicators pass `false`.
|
|
110
|
+
*/
|
|
111
|
+
navigationScrollIndicators?: boolean;
|
|
112
|
+
|
|
81
113
|
/**
|
|
82
114
|
* profile element for layout
|
|
83
115
|
* @see ProfileDropdown
|
|
@@ -165,7 +197,9 @@ function TitanLayoutComponent({
|
|
|
165
197
|
logo,
|
|
166
198
|
onExpandedChange,
|
|
167
199
|
onOpenSubmenuChange,
|
|
200
|
+
onOpenSubmenusChange,
|
|
168
201
|
navigationMainItems,
|
|
202
|
+
pinnedNavigation,
|
|
169
203
|
navigationOverflowItems,
|
|
170
204
|
onNavigationLinkClick,
|
|
171
205
|
extraLinks,
|
|
@@ -174,6 +208,8 @@ function TitanLayoutComponent({
|
|
|
174
208
|
minContentWidth,
|
|
175
209
|
noResponsive,
|
|
176
210
|
sideTop,
|
|
211
|
+
sideTopItems,
|
|
212
|
+
navigationScrollIndicators = true,
|
|
177
213
|
}: TitanLayoutProps) {
|
|
178
214
|
const breakpoint = useTitanBreakpoint(noResponsive);
|
|
179
215
|
const context: TitanLayoutContextType = useMemo(
|
|
@@ -194,7 +230,10 @@ function TitanLayoutComponent({
|
|
|
194
230
|
const hasSideBar =
|
|
195
231
|
!contentOnly &&
|
|
196
232
|
(navVariant === 'left' || (navVariant === 'top' && isMobile)) &&
|
|
197
|
-
(!!navigationMainItems?.length ||
|
|
233
|
+
(!!navigationMainItems?.length ||
|
|
234
|
+
!!sideTop?.length ||
|
|
235
|
+
!!sideTopItems?.length ||
|
|
236
|
+
!!pinnedNavigation);
|
|
198
237
|
const hasTopBar = !contentOnly;
|
|
199
238
|
|
|
200
239
|
useEffect(() => {
|
|
@@ -230,9 +269,26 @@ function TitanLayoutComponent({
|
|
|
230
269
|
);
|
|
231
270
|
const onSubmenuOpenChange = useCallback(
|
|
232
271
|
(id: string, opened: boolean) => {
|
|
233
|
-
|
|
272
|
+
if (onOpenSubmenusChange) {
|
|
273
|
+
onOpenSubmenusChange(updateOpenSubmenus(openSubmenus ?? [], [id], opened));
|
|
274
|
+
} else {
|
|
275
|
+
onOpenSubmenuChange?.(id, opened);
|
|
276
|
+
}
|
|
277
|
+
},
|
|
278
|
+
[onOpenSubmenuChange, onOpenSubmenusChange, openSubmenus]
|
|
279
|
+
);
|
|
280
|
+
/*
|
|
281
|
+
* Bulk actions require `onOpenSubmenusChange`, which takes the whole next set at once.
|
|
282
|
+
* They are deliberately not emulated with `onOpenSubmenuChange`: that contract is one
|
|
283
|
+
* call per user action, so a host deriving its next list from the current one would see
|
|
284
|
+
* the same stale list on every call of a loop and keep only the last module. `Expand All`
|
|
285
|
+
* is hidden entirely when a host supplies only the per-module callback.
|
|
286
|
+
*/
|
|
287
|
+
const onSubmenusOpenChange = useCallback(
|
|
288
|
+
(ids: string[], opened: boolean) => {
|
|
289
|
+
onOpenSubmenusChange?.(updateOpenSubmenus(openSubmenus ?? [], ids, opened));
|
|
234
290
|
},
|
|
235
|
-
[
|
|
291
|
+
[onOpenSubmenusChange, openSubmenus]
|
|
236
292
|
);
|
|
237
293
|
const sidebarNavItems = useMemo(() => {
|
|
238
294
|
if (!hasSideBar) {
|
|
@@ -246,10 +302,24 @@ function TitanLayoutComponent({
|
|
|
246
302
|
return [...(navigationMainItems ?? []), ...(navigationOverflowItems ?? [])];
|
|
247
303
|
}, [hasSideBar, navigationMainItems, navigationOverflowItems, navVariant]);
|
|
248
304
|
|
|
305
|
+
/*
|
|
306
|
+
* The burger badge speaks for the whole drawer, so it has to count what sits above the
|
|
307
|
+
* divider as well. A permanent destination asking for attention would otherwise go unseen
|
|
308
|
+
* while the drawer is closed.
|
|
309
|
+
*/
|
|
310
|
+
const badgeNavItems = useMemo(
|
|
311
|
+
/*
|
|
312
|
+
* Gated on `hasSideBar`, not `sidebarNavItems`: on the left variant a rail that exists
|
|
313
|
+
* only for `sideTopItems` still has `sidebarNavItems` (i.e. `navigationMainItems`) undefined.
|
|
314
|
+
*/
|
|
315
|
+
() => (hasSideBar ? [...(sideTopItems ?? []), ...(sidebarNavItems ?? [])] : undefined),
|
|
316
|
+
[hasSideBar, sideTopItems, sidebarNavItems]
|
|
317
|
+
);
|
|
318
|
+
|
|
249
319
|
const hasMenuNotifications = useMemo(() => {
|
|
250
320
|
try {
|
|
251
321
|
return (
|
|
252
|
-
|
|
322
|
+
badgeNavItems?.some(item => {
|
|
253
323
|
if (item.counter || item.tag?.value) {
|
|
254
324
|
return true;
|
|
255
325
|
} else if (item.submenu) {
|
|
@@ -263,7 +333,7 @@ function TitanLayoutComponent({
|
|
|
263
333
|
} catch {
|
|
264
334
|
return false;
|
|
265
335
|
}
|
|
266
|
-
}, [
|
|
336
|
+
}, [badgeNavItems]);
|
|
267
337
|
|
|
268
338
|
const limitContentWidth = useMemo(() => {
|
|
269
339
|
if (view.isAnvil2 || !minContentWidth) {
|
|
@@ -395,9 +465,15 @@ function TitanLayoutComponent({
|
|
|
395
465
|
onBarExpandChange={onBarExpandChange}
|
|
396
466
|
submenusOpened={openSubmenus}
|
|
397
467
|
onSubmenuOpenChange={onSubmenuOpenChange}
|
|
468
|
+
onSubmenusOpenChange={
|
|
469
|
+
onOpenSubmenusChange ? onSubmenusOpenChange : undefined
|
|
470
|
+
}
|
|
398
471
|
drawerOpened={mobileDrawerOpened}
|
|
399
472
|
onDrawerOpenChange={setMobileDrawerOpened}
|
|
400
473
|
top={sideTop}
|
|
474
|
+
topItems={sideTopItems}
|
|
475
|
+
scrollIndicators={navigationScrollIndicators}
|
|
476
|
+
pinnedNavigation={pinnedNavigation}
|
|
401
477
|
mainItems={sidebarNavItems}
|
|
402
478
|
navigationComponent={context.NavigationComponent}
|
|
403
479
|
onNavigationLinkClick={onNavigationLinkClick}
|