@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.
Files changed (89) hide show
  1. package/dist/components/titan-layout/__tests__/layout-sidebar-links-internal.test.d.ts +2 -0
  2. package/dist/components/titan-layout/__tests__/layout-sidebar-links-internal.test.d.ts.map +1 -0
  3. package/dist/components/titan-layout/__tests__/navigation-catalog-reset.test.d.ts +2 -0
  4. package/dist/components/titan-layout/__tests__/navigation-catalog-reset.test.d.ts.map +1 -0
  5. package/dist/components/titan-layout/interface.d.ts +66 -0
  6. package/dist/components/titan-layout/interface.d.ts.map +1 -1
  7. package/dist/components/titan-layout/interface.js.map +1 -1
  8. package/dist/components/titan-layout/layout-sidebar-links-internal.d.ts +3 -0
  9. package/dist/components/titan-layout/layout-sidebar-links-internal.d.ts.map +1 -1
  10. package/dist/components/titan-layout/layout-sidebar-links-internal.js +58 -13
  11. package/dist/components/titan-layout/layout-sidebar-links-internal.js.map +1 -1
  12. package/dist/components/titan-layout/layout-sidebar.d.ts +14 -0
  13. package/dist/components/titan-layout/layout-sidebar.d.ts.map +1 -1
  14. package/dist/components/titan-layout/layout-sidebar.js +169 -97
  15. package/dist/components/titan-layout/layout-sidebar.js.map +1 -1
  16. package/dist/components/titan-layout/layout-sidebar.module.less +230 -144
  17. package/dist/components/titan-layout/layout-sidebar.module.less.d.ts +16 -5
  18. package/dist/components/titan-layout/navigation-catalog/index.d.ts +3 -0
  19. package/dist/components/titan-layout/navigation-catalog/index.d.ts.map +1 -0
  20. package/dist/components/titan-layout/navigation-catalog/index.js +4 -0
  21. package/dist/components/titan-layout/navigation-catalog/index.js.map +1 -0
  22. package/dist/components/titan-layout/navigation-catalog/navigation-catalog-footer.d.ts +8 -0
  23. package/dist/components/titan-layout/navigation-catalog/navigation-catalog-footer.d.ts.map +1 -0
  24. package/dist/components/titan-layout/navigation-catalog/navigation-catalog-footer.js +88 -0
  25. package/dist/components/titan-layout/navigation-catalog/navigation-catalog-footer.js.map +1 -0
  26. package/dist/components/titan-layout/navigation-catalog/navigation-catalog-trigger.d.ts +21 -0
  27. package/dist/components/titan-layout/navigation-catalog/navigation-catalog-trigger.d.ts.map +1 -0
  28. package/dist/components/titan-layout/navigation-catalog/navigation-catalog-trigger.js +138 -0
  29. package/dist/components/titan-layout/navigation-catalog/navigation-catalog-trigger.js.map +1 -0
  30. package/dist/components/titan-layout/navigation-catalog/navigation-catalog-trigger.module.less +89 -0
  31. package/dist/components/titan-layout/navigation-catalog/navigation-catalog-trigger.module.less.d.ts +9 -0
  32. package/dist/components/titan-layout/navigation-catalog/navigation-catalog.d.ts +14 -0
  33. package/dist/components/titan-layout/navigation-catalog/navigation-catalog.d.ts.map +1 -0
  34. package/dist/components/titan-layout/navigation-catalog/navigation-catalog.js +382 -0
  35. package/dist/components/titan-layout/navigation-catalog/navigation-catalog.js.map +1 -0
  36. package/dist/components/titan-layout/navigation-catalog/navigation-catalog.module.less +436 -0
  37. package/dist/components/titan-layout/navigation-catalog/navigation-catalog.module.less.d.ts +24 -0
  38. package/dist/components/titan-layout/navigation-scroll-indicators.d.ts +12 -0
  39. package/dist/components/titan-layout/navigation-scroll-indicators.d.ts.map +1 -0
  40. package/dist/components/titan-layout/navigation-scroll-indicators.js +48 -0
  41. package/dist/components/titan-layout/navigation-scroll-indicators.js.map +1 -0
  42. package/dist/components/titan-layout/titan-layout.d.ts +29 -1
  43. package/dist/components/titan-layout/titan-layout.d.ts.map +1 -1
  44. package/dist/components/titan-layout/titan-layout.js +46 -6
  45. package/dist/components/titan-layout/titan-layout.js.map +1 -1
  46. package/dist/components/titan-layout/titan-layout.module.less +2 -1
  47. package/dist/components/titan-layout/use-navigation-scroll.d.ts +20 -0
  48. package/dist/components/titan-layout/use-navigation-scroll.d.ts.map +1 -0
  49. package/dist/components/titan-layout/use-navigation-scroll.js +140 -0
  50. package/dist/components/titan-layout/use-navigation-scroll.js.map +1 -0
  51. package/dist/utils/__tests__/navigation-notifications.test.d.ts +2 -0
  52. package/dist/utils/__tests__/navigation-notifications.test.d.ts.map +1 -0
  53. package/dist/utils/__tests__/submenu-state.test.d.ts +2 -0
  54. package/dist/utils/__tests__/submenu-state.test.d.ts.map +1 -0
  55. package/dist/utils/navigation-notifications.d.ts +21 -0
  56. package/dist/utils/navigation-notifications.d.ts.map +1 -0
  57. package/dist/utils/navigation-notifications.js +39 -0
  58. package/dist/utils/navigation-notifications.js.map +1 -0
  59. package/dist/utils/side-nav.js +3 -1
  60. package/dist/utils/side-nav.js.map +1 -1
  61. package/dist/utils/submenu-state.d.ts +3 -0
  62. package/dist/utils/submenu-state.d.ts.map +1 -0
  63. package/dist/utils/submenu-state.js +15 -0
  64. package/dist/utils/submenu-state.js.map +1 -0
  65. package/package.json +2 -2
  66. package/src/components/titan-layout/__tests__/layout-sidebar-links-internal.test.tsx +225 -0
  67. package/src/components/titan-layout/__tests__/navigation-catalog-reset.test.tsx +186 -0
  68. package/src/components/titan-layout/interface.ts +73 -0
  69. package/src/components/titan-layout/layout-sidebar-links-internal.tsx +86 -14
  70. package/src/components/titan-layout/layout-sidebar.module.less +230 -144
  71. package/src/components/titan-layout/layout-sidebar.module.less.d.ts +16 -5
  72. package/src/components/titan-layout/layout-sidebar.tsx +193 -117
  73. package/src/components/titan-layout/navigation-catalog/index.ts +2 -0
  74. package/src/components/titan-layout/navigation-catalog/navigation-catalog-footer.tsx +88 -0
  75. package/src/components/titan-layout/navigation-catalog/navigation-catalog-trigger.module.less +89 -0
  76. package/src/components/titan-layout/navigation-catalog/navigation-catalog-trigger.module.less.d.ts +9 -0
  77. package/src/components/titan-layout/navigation-catalog/navigation-catalog-trigger.tsx +170 -0
  78. package/src/components/titan-layout/navigation-catalog/navigation-catalog.module.less +436 -0
  79. package/src/components/titan-layout/navigation-catalog/navigation-catalog.module.less.d.ts +24 -0
  80. package/src/components/titan-layout/navigation-catalog/navigation-catalog.tsx +458 -0
  81. package/src/components/titan-layout/navigation-scroll-indicators.tsx +80 -0
  82. package/src/components/titan-layout/titan-layout.module.less +2 -1
  83. package/src/components/titan-layout/titan-layout.tsx +81 -5
  84. package/src/components/titan-layout/use-navigation-scroll.ts +145 -0
  85. package/src/utils/__tests__/navigation-notifications.test.ts +70 -0
  86. package/src/utils/__tests__/submenu-state.test.ts +39 -0
  87. package/src/utils/navigation-notifications.ts +59 -0
  88. package/src/utils/side-nav.ts +1 -1
  89. package/src/utils/submenu-state.ts +16 -0
@@ -0,0 +1,170 @@
1
+ import { Button, Chip, Flex, Text, ThemeProvider } from '@servicetitan/anvil2';
2
+ import IconSearch from '@servicetitan/anvil2/assets/icons/material/round/search.svg';
3
+ import classNames from 'classnames';
4
+ import { FC, Fragment, ReactNode, useCallback, useEffect, useRef, useState } from 'react';
5
+ import { PinnedNavigationConfig } from '../interface';
6
+ import { NavigationCatalog } from './navigation-catalog';
7
+
8
+ import * as styles from './navigation-catalog-trigger.module.less';
9
+
10
+ /**
11
+ * True when the keystroke should open the catalog rather than reach the focused control.
12
+ *
13
+ * The shortcut is deliberately a modifier chord: a bare key would be swallowed by, or would steal
14
+ * from, every text field on the page.
15
+ */
16
+ const isCatalogShortcut = (event: KeyboardEvent): boolean =>
17
+ (event.metaKey || event.ctrlKey) && !event.altKey && event.key.toLowerCase() === 'k';
18
+
19
+ /**
20
+ * The chord as the user's own keyboard prints it.
21
+ *
22
+ * The listener accepts either modifier on every platform, so this only decides which one to
23
+ * advertise. `platform` is deprecated but is still the only reliable signal here; the user agent is
24
+ * the fallback and a wrong guess costs nothing but a mislabelled badge.
25
+ */
26
+ const shortcutLabel = (): string => {
27
+ const platform =
28
+ typeof window.navigator === 'undefined'
29
+ ? ''
30
+ : window.navigator.platform || window.navigator.userAgent;
31
+
32
+ return /Mac|iPod|iPhone|iPad/.test(platform) ? '⌘ K' : 'Ctrl K';
33
+ };
34
+
35
+ export interface NavigationCatalogTriggerProps extends PinnedNavigationConfig {
36
+ /** Whether the rail is expanded; the compact trigger keeps its label below the icon. */
37
+ expanded: boolean;
38
+ /** Optional sidebar action beside the expanded search trigger. */
39
+ trailingAction?: ReactNode;
40
+ }
41
+
42
+ /**
43
+ * Sidebar entry point for the navigation catalog.
44
+ *
45
+ * Presented as a labelled control rather than a bare icon: the catalog is the only route to an
46
+ * unpinned destination, so the affordance has to read as "search everything" at a glance. It is a
47
+ * button and not an input — the real field lives in the dialog and takes focus on open, and two
48
+ * inputs would mean typing in one and getting results in the other.
49
+ *
50
+ * The catalog is never opened automatically; an unpinned navigation is a deliberate state, and this
51
+ * trigger plus its shortcut are what keep the user from being stranded there.
52
+ */
53
+ export const NavigationCatalogTrigger: FC<NavigationCatalogTriggerProps> = ({
54
+ expanded,
55
+ trailingAction,
56
+ ...catalog
57
+ }) => {
58
+ const [isOpen, setIsOpen] = useState(false);
59
+ const triggerRef = useRef<HTMLButtonElement>(null);
60
+ const wasOpen = useRef(false);
61
+
62
+ const close = useCallback(() => setIsOpen(false), []);
63
+ const toggle = useCallback(() => setIsOpen(open => !open), []);
64
+
65
+ useEffect(() => {
66
+ // The dialog unmounts on close, so restore focus after its modal content is removed.
67
+ if (wasOpen.current && !isOpen) {
68
+ triggerRef.current?.focus();
69
+ }
70
+ wasOpen.current = isOpen;
71
+ }, [isOpen]);
72
+
73
+ useEffect(() => {
74
+ const handler = (event: KeyboardEvent) => {
75
+ if (!isCatalogShortcut(event)) {
76
+ return;
77
+ }
78
+
79
+ event.preventDefault();
80
+ toggle();
81
+ };
82
+
83
+ window.addEventListener('keydown', handler);
84
+ return () => window.removeEventListener('keydown', handler);
85
+ }, [toggle]);
86
+
87
+ /*
88
+ * Labelled explicitly so the name stays "Go to" whether or not the text is rendered, and so the
89
+ * shortcut badge never reads out as part of it.
90
+ */
91
+ const buttonProps = {
92
+ /*
93
+ * Not `navigation-item-*`: that prefix belongs to the rail's links, and this is a control
94
+ * that opens a dialog rather than a destination, so sharing the namespace invites a
95
+ * selector written for a link to match it.
96
+ */
97
+ 'data-cy': 'navigation-catalog-trigger',
98
+ 'data-pendo': 'navigation-catalog-trigger',
99
+ 'appearance': 'secondary',
100
+ 'size': 'small',
101
+ 'aria-label': 'Go to',
102
+ 'aria-haspopup': 'dialog',
103
+ 'aria-expanded': isOpen,
104
+ 'aria-keyshortcuts': 'Meta+K Control+K',
105
+ 'onClick': toggle,
106
+ } as const;
107
+
108
+ return (
109
+ <Fragment>
110
+ <div className={classNames(styles.container, !expanded && styles.containerCollapsed)}>
111
+ {expanded ? (
112
+ <Button
113
+ {...buttonProps}
114
+ ref={triggerRef}
115
+ icon={{ before: IconSearch }}
116
+ className={styles.trigger}
117
+ >
118
+ <Flex alignItems="center" justifyContent="space-between" gap={1} grow={1}>
119
+ {/* The ellipsis is part of the label, matching the field in the dialog. */}
120
+ <Text inline size="small" className={styles.triggerLabel}>
121
+ Go to...
122
+ </Text>
123
+ {/* Decorative: the chord is already on the button as aria-keyshortcuts. */}
124
+ <Chip
125
+ label={shortcutLabel()}
126
+ size="small"
127
+ className={styles.shortcut}
128
+ />
129
+ </Flex>
130
+ </Button>
131
+ ) : (
132
+ <Button
133
+ {...buttonProps}
134
+ ref={triggerRef}
135
+ appearance="ghost"
136
+ icon={IconSearch}
137
+ className={styles.triggerCollapsed}
138
+ >
139
+ <span className={styles.collapsedLabel}>Go to...</span>
140
+ </Button>
141
+ )}
142
+ {expanded && trailingAction}
143
+ </div>
144
+ {/*
145
+ * Mounted only while open, so a host that resolves its catalog lazily is not asked for
146
+ * it on every page load, and a second navigation tree is not held inside the sidebar's
147
+ * subtree for a dialog nobody opened.
148
+ *
149
+ * Dark, because the catalog belongs to the rail rather than to the page. The rail paints
150
+ * itself dark whatever the application's mode is, and this is the rail's own menu — the
151
+ * same `ThemeProvider` the rail uses on its own sections.
152
+ *
153
+ * A `ThemeProvider` rather than a second `AnvilProvider`: the latter is the
154
+ * application-level provider, and mounting a nested one alongside the host's meant its
155
+ * unmount tore down state the outer one was still relying on, so closing the catalog
156
+ * left the rail unstyled.
157
+ *
158
+ * This only sets the mode, which is what makes the *text* tokens light. The surface is
159
+ * matched to the rail separately, in `navigation-catalog.module.less` — anvil2's dark is
160
+ * a neutral grey and the rail's is a navy, so the mode alone would leave two visibly
161
+ * different darks.
162
+ */}
163
+ {isOpen && (
164
+ <ThemeProvider mode="dark">
165
+ <NavigationCatalog open onClose={close} {...catalog} />
166
+ </ThemeProvider>
167
+ )}
168
+ </Fragment>
169
+ );
170
+ };
@@ -0,0 +1,436 @@
1
+ @import (reference) '@servicetitan/tokens/core/tokens.less';
2
+
3
+ /*
4
+ * Scoped to `.anvil2.anvil2` so these rules outrank anvil2's own.
5
+ *
6
+ * anvil2 nests every component rule under `.anvil2`, giving it two classes of specificity,
7
+ * which a bare class here loses to. Doubling the scope wins outright and leaves the selectors
8
+ * inside it plain, rather than each one having to defend itself with `!important`.
9
+ */
10
+ :global(.anvil2.anvil2) {
11
+ /*
12
+ * The catalog's column geometry.
13
+ *
14
+ * `@catalog-column` is the *minimum* a column may be, not its width — the columns share out the
15
+ * dialog evenly and end up wider than this. Its job is to set how many there are: at `xlarge`
16
+ * (70rem) four of these plus their gaps fit and a fifth does not. The columns land at 248px, which
17
+ * still clears the widest row the catalog renders (229px including its star), so nothing clips.
18
+ *
19
+ * Width rather than `column-count: 4`, so a dialog narrowed by a small viewport drops to three
20
+ * columns and then two instead of squeezing four ever thinner. A fixed count gave 208px per column
21
+ * at a 768px viewport, under that 229px, which turned the longest labels into ellipses —
22
+ * "Batch/Export Transactions" and "Recurring Service Events" among them.
23
+ */
24
+ @catalog-column: 15rem;
25
+ @catalog-gap: 1.5rem;
26
+
27
+ // The dialog's own horizontal inset, shared by the search field's row and the footer.
28
+ @catalog-padding: @spacing-2;
29
+
30
+ /*
31
+ * The rail's own background, from `@bg-color` in `@servicetitan/navigation`'s
32
+ * `layout-sidebar.module.less`, its hover on top of it, and the trigger chip this catalog opens
33
+ * from.
34
+ *
35
+ * Repeated as literals because they are LESS variables private to that package, not custom
36
+ * properties, so there is nothing to reference at runtime. The rail paints them unconditionally and
37
+ * has done since it was written, so the risk of drift is low and the failure is cosmetic.
38
+ *
39
+ * They are here because the catalog is the rail's menu and should read as part of it. anvil2's dark
40
+ * mode is a neutral grey (#1a1a1a) and the rail is a navy, so taking the mode alone would put two
41
+ * different darks side by side on screen.
42
+ *
43
+ * Translucent white rather than a mixed-down navy throughout: a translucent layer tints whatever it
44
+ * covers, so these keep the surface's hue and only lift it, which is what makes them read as part of
45
+ * the same surface rather than as patches on it.
46
+ */
47
+ @rail-surface: #0f1d26;
48
+ @rail-hover: rgba(255, 255, 255, 0.08);
49
+ @rail-field: rgba(255, 255, 255, 0.06);
50
+ @rail-field-border: rgba(255, 255, 255, 0.16);
51
+
52
+ /**
53
+ * Repaints the dialog in the rail's navy instead of anvil2's neutral dark, and lightens its
54
+ * backdrop instead of anvil2's default near-black scrim.
55
+ *
56
+ * `--a2-dialog-background-color`, `--a2-dialog-border-color` and `--a2-dialog-backdrop-color` are
57
+ * the hooks anvil2's Dialog reads inside its own `var()` chains, ahead of the tokens they otherwise
58
+ * fall back to. Setting those rather than `background-color`/`::backdrop` directly means no
59
+ * specificity contest — nothing else defines these properties, they are only read — and the
60
+ * background one reaches the header and content panels too, which paint from the same variable.
61
+ *
62
+ * The border goes with the background. Left alone it resolves to a flat `#404040`, which is the
63
+ * last neutral grey on the surface and outlines the whole dialog in it. The same translucent white
64
+ * the field's edge uses puts the outline back in the surface's own colour.
65
+ *
66
+ * `--a2-color-neutral-0` for the backdrop is the same override anvil2's own `fullScreen` variant
67
+ * uses in place of its default `neutral-900` — a supported swap, not a hack.
68
+ */
69
+ .dialog {
70
+ --a2-dialog-background-color: @rail-surface;
71
+ --a2-dialog-border-color: @rail-field-border;
72
+ --a2-dialog-backdrop-color: var(--a2-color-neutral-0, #ffffff);
73
+
74
+ /*
75
+ * Pins the dialog's size to the viewport instead of its content, so opening it, searching, and
76
+ * clearing the search all leave it exactly where it was.
77
+ *
78
+ * anvil2 centres the dialog by giving it `inset: 0` and `margin: auto`; overriding `top` and
79
+ * `height` here (with the matching margins zeroed so they cannot re-introduce the auto-centring
80
+ * they replace) fixes the vertical position and size, while `left`/`right` stay on the anvil2
81
+ * default and keep centring horizontally. `--nav-offset-top` is the rail's own top-nav height
82
+ * custom property, already threaded through this package for the same purpose.
83
+ *
84
+ * `> *` reaches anvil2's own content wrapper, which otherwise sizes to its content rather than
85
+ * to this now-fixed height — there is no class hook for it, but the dialog only ever has that
86
+ * one child.
87
+ */
88
+ top: calc(var(--nav-offset-top) + 10px);
89
+ bottom: auto;
90
+ height: calc(100vh - 112px);
91
+ margin-top: @spacing-0;
92
+ margin-bottom: @spacing-0;
93
+
94
+ > * {
95
+ height: 100%;
96
+ }
97
+ }
98
+
99
+ /**
100
+ * Stacks the search above the columns.
101
+ *
102
+ * `Dialog.Content` is `display: flex` with the default row direction, so its children line up
103
+ * side by side. Without this the search field became a flex item next to the column block and sat
104
+ * in the left third of the dialog, reading as a fourth column.
105
+ */
106
+ .body {
107
+ display: flex;
108
+ flex: 1;
109
+ min-width: 0;
110
+ flex-direction: column;
111
+ }
112
+
113
+ /**
114
+ * Lets the results area, rather than the dialog's own scroller, take the overflow.
115
+ *
116
+ * `Dialog.Content` is a flex item of that scroller, and a flex item will not shrink below its
117
+ * content by default. Without this the scroller is the thing that overflows, which scrolls the
118
+ * search field off the top along with the results.
119
+ */
120
+ .content {
121
+ min-height: 0;
122
+ }
123
+
124
+ // Results scroll above the full-width footer; allow its content to wrap on narrow screens.
125
+ .footer {
126
+ box-sizing: border-box;
127
+ min-height: 56px;
128
+ flex: none;
129
+ flex-wrap: wrap;
130
+ align-items: center;
131
+ justify-content: center;
132
+ gap: @spacing-1 @spacing-3;
133
+ padding: 12px @catalog-padding;
134
+ background-color: var(--a2-background-color-secondary);
135
+ text-align: center;
136
+ }
137
+
138
+ .footerActions {
139
+ display: flex;
140
+ flex: none;
141
+ gap: @spacing-1;
142
+ }
143
+
144
+ /*
145
+ * Matched to the rail's own "Go to" field, which is what the user clicked to get here — the same
146
+ * control, so it should not change colour on the way.
147
+ *
148
+ * anvil2's field is an opaque neutral (#1a1a1a on a #545454 border) which, like the row hover, is
149
+ * the right lightness but the wrong hue against the navy. The rail's translucent white gives the
150
+ * same lift while keeping the surface's colour, and these are its exact values.
151
+ */
152
+ .search {
153
+ --a2-text-field-input-wrapper-background-color: @rail-field;
154
+ --a2-text-field-input-wrapper-border-color-default: @rail-field-border;
155
+
156
+ width: 100%;
157
+ margin-bottom: 1.5rem;
158
+ }
159
+
160
+ /*
161
+ * Fills whatever the now fixed-height dialog leaves under the search field, instead of sizing to
162
+ * its own content, so browse, a narrowed search, and the empty state all hold the same height.
163
+ *
164
+ * `.columns` stays at `height: auto` regardless — a multi-column box given a fixed height becomes a
165
+ * fragmentainer and spills overflow sideways into further columns instead of growing downwards, which
166
+ * is a horizontal scrollbar rather than the vertical one wanted here.
167
+ */
168
+ .results {
169
+ flex: 1;
170
+ min-height: 0;
171
+ overflow-y: auto;
172
+
173
+ /*
174
+ * Reserve the scrollbar's width whether or not it is showing, so a query that stops the results
175
+ * overflowing does not shift the columns sideways as the scrollbar disappears.
176
+ */
177
+ scrollbar-gutter: stable;
178
+ }
179
+
180
+ /**
181
+ * Column width rather than column count, so a narrow dialog drops a column instead of squeezing all
182
+ * three.
183
+ *
184
+ * The dialog tracks the viewport below its cap, so `column-count: 3` had to divide whatever it was
185
+ * given by three. At a 768px viewport that was 208px per column, narrower than the 229px the widest
186
+ * category wants, and `max-width: 100%` on the category turned the overflow into ellipses — "Batch/
187
+ * Export Transactions" and "Recurring Service Events" among them.
188
+ *
189
+ * Giving the width instead lets the count fall out of it: three columns at the cap, then two, then
190
+ * one, and never a clipped label at any width. It cannot reach four, because a fourth column and its
191
+ * gap need more room than the cap ever offers, which is what keeps the reference's three-column
192
+ * layout at every desktop size.
193
+ *
194
+ * Multi-column rather than grid so categories pack against one another and flow into the next column,
195
+ * instead of leaving a ragged row of half-empty grid cells whenever one category is much taller than
196
+ * its neighbours.
197
+ */
198
+
199
+ /*
200
+ * `column-rule` rather than a border on the categories, because the line belongs to the boundary
201
+ * between columns and not to any category. Multi-column draws it once per gap, only where a gap
202
+ * exists, and at the full height of the column block — so it disappears on its own when the dialog
203
+ * narrows to a single column, and a short last column still gets the full-height line to its left
204
+ * rather than one that stops where its content does.
205
+ *
206
+ * A translucent white rather than the `subdued` border token, for the same reason as the row hover:
207
+ * the token is a flat neutral grey, and a grey line on a navy surface reads as a slightly dirty
208
+ * seam. At 12% it lands about where the token's grey would in lightness, but tinted by the navy
209
+ * underneath it, so it belongs to the surface it divides.
210
+ */
211
+ .columns {
212
+ columns: @catalog-column;
213
+ column-gap: @catalog-gap;
214
+ column-rule: 1px solid rgba(255, 255, 255, 0.12);
215
+ }
216
+
217
+ /*
218
+ * A category and its heading stay in the same column.
219
+ *
220
+ * Without this a tall category flowed across a column boundary and its remainder arrived at the top
221
+ * of the next column with no heading over it, reading as though those destinations belonged to
222
+ * whatever was above them. `break-after` on the heading only keeps the heading with its first row;
223
+ * it says nothing about the rest.
224
+ *
225
+ * This is a preference, not a guarantee: a category taller than the column still has to break
226
+ * somewhere, and the browser will split it rather than overflow. That degrades to today's behaviour
227
+ * for the one or two largest categories and fixes it for the rest.
228
+ */
229
+
230
+ /*
231
+ * Full column width, so every star in the catalog lands on the column's right edge just inside the
232
+ * divider. The rows are flex containers with `flex: 1` on the link, so the star is pushed to
233
+ * whatever the category's right edge is; letting that edge be the column's puts all of them on one
234
+ * line down the whole column rather than one line per category.
235
+ */
236
+ .group {
237
+ break-inside: avoid;
238
+ margin-bottom: 1.5rem;
239
+ }
240
+
241
+ /*
242
+ * Level 1 of three. It shares its size with the module rows below it and is told apart by case,
243
+ * tracking and colour rather than by being bigger or smaller — the standard eyebrow treatment, and
244
+ * the one place a plain grey belongs.
245
+ *
246
+ * `break-after` keeps a heading from being stranded at the foot of a column.
247
+ *
248
+ * The colour is spelled out rather than taken from `--a2-foreground-color-subdued`, which resolves
249
+ * to `#c4c4c4` in dark mode: close enough to white that the heading competed with the destinations
250
+ * under it. This is `--a2-color-neutral-400`, a genuine mid grey, and it is the same value the rail
251
+ * headings use.
252
+ *
253
+ * Note that anvil2's colour tokens are spelled `foreground`, not `text`; a `--a2-text-color-*` name
254
+ * resolves to nothing and drops silently to the fallback. That matters here because this
255
+ * repository's LESS `@color-neutral` ramp runs dark-to-light, opposite to anvil2's, so a wrong
256
+ * guess lands on near-black text against a near-black dialog.
257
+ */
258
+ .groupTitle {
259
+ display: block;
260
+ break-after: avoid;
261
+ padding: @spacing-0 0.5rem;
262
+ margin-bottom: 0.5rem;
263
+ color: light-dark(
264
+ var(--a2-color-neutral-500, #707070),
265
+ var(--a2-color-neutral-400, #8b8b8b)
266
+ );
267
+ text-transform: uppercase;
268
+ letter-spacing: 0.06em;
269
+ }
270
+
271
+ /* A module and its pages are one unit and stay in the same column. */
272
+ .module-group {
273
+ break-inside: avoid;
274
+ margin-bottom: 0.75rem;
275
+ }
276
+
277
+ .row {
278
+ display: flex;
279
+ align-items: center;
280
+ gap: 0.25rem;
281
+ }
282
+
283
+ .link {
284
+ display: flex;
285
+ flex: 1;
286
+ min-width: 0;
287
+ align-items: center;
288
+ gap: 0.5rem;
289
+ padding: 0.25rem 0.5rem;
290
+ border-radius: var(--a2-border-radius-medium, 4px);
291
+ text-decoration: none;
292
+
293
+ /*
294
+ * The rail's hover, not anvil2's. The token resolves to a flat neutral grey in dark mode, which
295
+ * on this navy replaces the surface's hue instead of lifting it. A translucent white tints
296
+ * whatever it covers, so the hovered row stays the same colour, just brighter — and it is the
297
+ * same value the rail uses on its own rows, which is what the user is comparing it against.
298
+ */
299
+ &:hover,
300
+ &:focus-visible {
301
+ background-color: @rail-hover;
302
+ text-decoration: none;
303
+ }
304
+ }
305
+
306
+ .moduleIcon {
307
+ flex: none;
308
+ }
309
+
310
+ /*
311
+ * Level 2 of three, and the anchor of the scale: full-strength foreground at the body size, which
312
+ * everything else is set against. The destination is the usable part of the row, so it carries the
313
+ * weight and the brighter colour; the category heading above it is deliberately quieter.
314
+ */
315
+ .moduleTitle {
316
+ flex: 1;
317
+ min-width: 0;
318
+ overflow: hidden;
319
+ text-overflow: ellipsis;
320
+ white-space: nowrap;
321
+ color: var(--a2-foreground-color-default, light-dark(#040404, #ffffff));
322
+ font-weight: var(--a2-font-weight-semibold, 600);
323
+ }
324
+
325
+ /* Indented to sit under the module's title rather than its icon. */
326
+ .childLink {
327
+ padding-left: 2rem;
328
+ }
329
+
330
+ /*
331
+ * Level 3 of three: below the module row, above the category heading, at regular weight and at the
332
+ * same full-strength foreground. It is still a destination, so it is not dimmed — size and weight
333
+ * are what place it under its module. Only the category heading is greyed.
334
+ *
335
+ * 13px is deliberately off the token scale, which jumps 0.75rem → 0.875rem with nothing in between.
336
+ * At 0.75rem this tied with the category heading and read as another label rather than as somewhere
337
+ * to go. It has to clear the heading and stay under the module row, and the gap between those two is
338
+ * one token wide, so the size is spelled out. It matches the rail's pinned pages, which are sized
339
+ * the same way and for the same reason.
340
+ */
341
+ .childTitle {
342
+ flex: 1;
343
+ min-width: 0;
344
+ overflow: hidden;
345
+ text-overflow: ellipsis;
346
+ white-space: nowrap;
347
+ color: var(--a2-foreground-color-default, light-dark(#040404, #ffffff));
348
+ font-size: 0.8125rem;
349
+ }
350
+
351
+ /*
352
+ * anvil2 positions a Badge absolutely with a translate, because its usual job is to overlay the
353
+ * corner of an icon. Here it is a sibling in a row and has to sit in the flow, so the position
354
+ * and the transform are both undone — the same override the rail applies to its own submenu
355
+ * counters in `layout-sidebar.module.less`.
356
+ *
357
+ * No `!important` needed: anvil2 nests its rule as `.anvil2 ._badge_x`, and the enclosing
358
+ * `.anvil2.anvil2` scope already outranks that.
359
+ *
360
+ * `flex: none` because the title beside it takes the slack — without it a long name would
361
+ * squeeze the count instead of truncating itself, and a truncated count is worse than a
362
+ * truncated name.
363
+ */
364
+ .badge[data-anv][data-anv] {
365
+ position: relative;
366
+ transform: none;
367
+ flex: none;
368
+ }
369
+
370
+ // 2rem to match the star button's box, so both align down the column. Always visible, unlike
371
+ // the star, and #707070 to match the Pantheon frame.
372
+ .lock {
373
+ display: inline-flex;
374
+ width: 2rem;
375
+ height: 2rem;
376
+ flex: none;
377
+ align-items: center;
378
+ justify-content: center;
379
+ color: var(--a2-color-neutral-500, #707070);
380
+ }
381
+
382
+ .star {
383
+ flex: none;
384
+ opacity: 0;
385
+
386
+ &:focus-visible {
387
+ opacity: 1;
388
+ }
389
+ }
390
+
391
+ .row:hover .star {
392
+ opacity: 0.5;
393
+
394
+ &:hover {
395
+ opacity: 1;
396
+ }
397
+ }
398
+
399
+ .row:focus-within .star {
400
+ opacity: 0.5;
401
+ }
402
+
403
+ /*
404
+ * After the hover rules, which match at the same specificity, so a pinned star stays lit.
405
+ *
406
+ * The token's dark value, `yellow-300`, is a pale gold that clears the surface below at 13.5:1.
407
+ * Note that the light value on the other side of this token is `yellow-500` (#ffbe00), which only
408
+ * manages 1.66:1 on white — under the 3:1 WCAG 1.4.11 asks of a graphical control. That branch is
409
+ * unreachable while the catalog follows the rail, but it is the thing to fix first if this surface
410
+ * is ever made light.
411
+ */
412
+ .starActive,
413
+ .row:hover .starActive,
414
+ .row:focus-within .starActive {
415
+ color: var(--a2-status-color-warning, light-dark(#ffbe00, #ffe278));
416
+ opacity: 1;
417
+ }
418
+
419
+ /*
420
+ * Centred in the fixed-height results area rather than left stranded against its top edge.
421
+ *
422
+ * `border-box` so the padding is counted inside the `100%`. Under the default `content-box` the
423
+ * vertical padding is added to it, and the message ended up 64px taller than the area it sits in —
424
+ * enough to raise a scrollbar next to a single line of text.
425
+ */
426
+ .empty {
427
+ display: flex;
428
+ box-sizing: border-box;
429
+ height: 100%;
430
+ align-items: center;
431
+ justify-content: center;
432
+ padding: 2rem 0.5rem;
433
+ text-align: center;
434
+ color: var(--a2-foreground-color-subdued, light-dark(#707070, #c4c4c4));
435
+ }
436
+ }
@@ -0,0 +1,24 @@
1
+ export const __esModule: true;
2
+ export const badge: string;
3
+ export const body: string;
4
+ export const childLink: string;
5
+ export const childTitle: string;
6
+ export const columns: string;
7
+ export const content: string;
8
+ export const dialog: string;
9
+ export const empty: string;
10
+ export const footer: string;
11
+ export const footerActions: string;
12
+ export const group: string;
13
+ export const groupTitle: string;
14
+ export const link: string;
15
+ export const lock: string;
16
+ export const moduleGroup: string;
17
+ export const moduleIcon: string;
18
+ export const moduleTitle: string;
19
+ export const results: string;
20
+ export const row: string;
21
+ export const search: string;
22
+ export const star: string;
23
+ export const starActive: string;
24
+