ember-freestyle 0.21.0 → 0.23.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/CHANGELOG.md +11 -0
- package/addon/components/freestyle-guide/index.hbs +1 -1
- package/addon/components/freestyle-menu/index.hbs +57 -27
- package/addon/components/freestyle-menu/index.ts +311 -2
- package/addon/components/freestyle-section/index.hbs +1 -0
- package/addon/components/freestyle-subsection/index.hbs +2 -0
- package/addon/modifiers/freestyle-scroll-spy.ts +154 -0
- package/addon/services/ember-freestyle.ts +11 -1
- package/addon/template-registry.ts +2 -0
- package/app/modifiers/freestyle-scroll-spy.js +1 -0
- package/app/styles/components/freestyle-menu.scss +163 -14
- package/components/freestyle-menu/index.d.ts +54 -2
- package/docs/freestyle-generated.png +0 -0
- package/modifiers/freestyle-scroll-spy.d.ts +20 -0
- package/package.json +3 -2
- package/services/ember-freestyle.d.ts +4 -2
- package/template-registry.d.ts +2 -0
- package/types/glimmer-tracking-cached.d.ts +10 -0
- package/vendor/ember-freestyle.css +128 -14
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,17 @@ All notable changes to this project will be documented in this file.
|
|
|
6
6
|
|
|
7
7
|
|
|
8
8
|
|
|
9
|
+
|
|
10
|
+
## v0.23.0 (2026-03-22)
|
|
11
|
+
|
|
12
|
+
#### :rocket: Enhancement
|
|
13
|
+
* [#1025](https://github.com/chrislopresto/ember-freestyle/pull/1025) feat: enhanced FreestyleMenu with collapsible sections, search, keyboard nav, and scroll spy ([@mattmcmanus](https://github.com/mattmcmanus))
|
|
14
|
+
|
|
15
|
+
#### Committers: 1
|
|
16
|
+
- Matt McManus ([@mattmcmanus](https://github.com/mattmcmanus))
|
|
17
|
+
|
|
18
|
+
## v0.22.0 (2025-03-18)
|
|
19
|
+
|
|
9
20
|
## v0.21.0 (2024-09-27)
|
|
10
21
|
|
|
11
22
|
#### :boom: Breaking Change
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
</button>
|
|
23
23
|
</header>
|
|
24
24
|
<main class="FreestyleGuide-body">
|
|
25
|
-
<article class="FreestyleGuide-content">
|
|
25
|
+
<article class="FreestyleGuide-content" {{freestyle-scroll-spy}}>
|
|
26
26
|
{{yield}}
|
|
27
27
|
{{#if (and (not this.allowRenderingAllSections) (not this.isSectionSelected))}}
|
|
28
28
|
<div class="FreestyleGuide-chooseSectionMessage" data-test-choose-section>
|
|
@@ -1,27 +1,57 @@
|
|
|
1
|
-
<
|
|
2
|
-
|
|
3
|
-
<
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
1
|
+
<nav class="FreestyleMenu" aria-label="Component navigation">
|
|
2
|
+
<div class="FreestyleMenu-search">
|
|
3
|
+
<input
|
|
4
|
+
type="text"
|
|
5
|
+
class="FreestyleMenu-searchInput"
|
|
6
|
+
placeholder="Filter components..."
|
|
7
|
+
aria-label="Filter components"
|
|
8
|
+
value={{this.filterText}}
|
|
9
|
+
{{on "input" this.onFilterInput}}
|
|
10
|
+
{{on "keydown" this.handleKeydown}}
|
|
11
|
+
/>
|
|
12
|
+
</div>
|
|
13
|
+
<ul class="FreestyleMenu-list">
|
|
14
|
+
{{#if this.includeAllOption}}
|
|
15
|
+
<li class="FreestyleMenu-item">
|
|
16
|
+
<LinkTo @query={{hash s=null ss=null f=null}} class="FreestyleMenu-itemLink">
|
|
17
|
+
All
|
|
18
|
+
</LinkTo>
|
|
19
|
+
</li>
|
|
20
|
+
{{/if}}
|
|
21
|
+
{{#each this.filteredMenu as |entry|}}
|
|
22
|
+
<li class="FreestyleMenu-item {{if entry.isExpanded 'is-expanded'}} {{if entry.isSectionActive 'is-active'}}">
|
|
23
|
+
<div class="FreestyleMenu-itemHeader">
|
|
24
|
+
<LinkTo @query={{hash f=null s=entry.section.name ss=null}} class="FreestyleMenu-itemLink" {{on "click" (fn this.expandSection entry.section.name)}}>
|
|
25
|
+
{{#if entry.subsections.length}}
|
|
26
|
+
<span class="FreestyleMenu-chevron {{if entry.isExpanded 'is-expanded'}}"></span>
|
|
27
|
+
{{/if}}
|
|
28
|
+
{{entry.section.name}}
|
|
29
|
+
</LinkTo>
|
|
30
|
+
{{#if entry.subsections.length}}
|
|
31
|
+
<button
|
|
32
|
+
type="button"
|
|
33
|
+
class="FreestyleMenu-collapseToggle"
|
|
34
|
+
{{on "click" (fn this.toggleSection entry.section.name)}}
|
|
35
|
+
aria-label="Toggle {{entry.section.name}}"
|
|
36
|
+
aria-expanded="{{if entry.isExpanded 'true' 'false'}}"
|
|
37
|
+
></button>
|
|
38
|
+
{{/if}}
|
|
39
|
+
</div>
|
|
40
|
+
{{#if (and entry.subsections.length entry.isExpanded)}}
|
|
41
|
+
<ul class="FreestyleMenu-submenu">
|
|
42
|
+
{{#each entry.subsections as |subsection|}}
|
|
43
|
+
<li
|
|
44
|
+
class="FreestyleMenu-submenuItem {{if subsection.isHighlighted 'is-highlighted'}} {{if subsection.isActive 'is-active'}}"
|
|
45
|
+
id="{{this.elementIdPrefix}}-item-{{subsection.flatIndex}}"
|
|
46
|
+
>
|
|
47
|
+
<LinkTo @query={{hash f=null s=entry.section.name ss=subsection.name}} class="FreestyleMenu-submenuItemLink">
|
|
48
|
+
{{subsection.name}}
|
|
49
|
+
</LinkTo>
|
|
50
|
+
</li>
|
|
51
|
+
{{/each}}
|
|
52
|
+
</ul>
|
|
53
|
+
{{/if}}
|
|
54
|
+
</li>
|
|
55
|
+
{{/each}}
|
|
56
|
+
</ul>
|
|
57
|
+
</nav>
|
|
@@ -3,9 +3,36 @@ import Component from '@glimmer/component';
|
|
|
3
3
|
import { inject as service } from '@ember/service';
|
|
4
4
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
5
5
|
import { reads } from 'macro-decorators';
|
|
6
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
7
|
+
import { action } from '@ember/object';
|
|
8
|
+
import { tracked, cached } from '@glimmer/tracking';
|
|
9
|
+
import { schedule } from '@ember/runloop';
|
|
10
|
+
import { guidFor } from '@ember/object/internals';
|
|
11
|
+
import type RouterService from '@ember/routing/router-service';
|
|
6
12
|
import type EmberFreestyleService from '../../services/ember-freestyle';
|
|
7
|
-
import { type Section } from '../../services/ember-freestyle';
|
|
8
|
-
import { TrackedArray } from 'tracked-built-ins';
|
|
13
|
+
import { type Section, type Subsection } from '../../services/ember-freestyle';
|
|
14
|
+
import { TrackedArray, TrackedSet } from 'tracked-built-ins';
|
|
15
|
+
|
|
16
|
+
// ── Types ──────────────────────────────────────────────────────────────────
|
|
17
|
+
|
|
18
|
+
export interface FilteredSubsection {
|
|
19
|
+
name: string;
|
|
20
|
+
flatIndex: number;
|
|
21
|
+
isHighlighted: boolean;
|
|
22
|
+
isActive: boolean;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface FilteredSection {
|
|
26
|
+
section: Section;
|
|
27
|
+
subsections: FilteredSubsection[];
|
|
28
|
+
isExpanded: boolean;
|
|
29
|
+
isSectionActive: boolean;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
interface FlatSubsectionItem {
|
|
33
|
+
sectionName: string;
|
|
34
|
+
subsectionName: string;
|
|
35
|
+
}
|
|
9
36
|
|
|
10
37
|
interface Signature {
|
|
11
38
|
Args: {
|
|
@@ -13,8 +40,290 @@ interface Signature {
|
|
|
13
40
|
};
|
|
14
41
|
}
|
|
15
42
|
|
|
43
|
+
// ── DOM helpers ────────────────────────────────────────────────────────────
|
|
44
|
+
|
|
45
|
+
function subsectionSelector(
|
|
46
|
+
sectionName: string,
|
|
47
|
+
subsectionName: string,
|
|
48
|
+
): string {
|
|
49
|
+
return `.FreestyleSubsection[data-section="${sectionName}"][data-subsection="${subsectionName}"]`;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function scrollElementIntoView(
|
|
53
|
+
el: Element | null,
|
|
54
|
+
behavior: ScrollBehavior = 'auto',
|
|
55
|
+
block: ScrollLogicalPosition = 'nearest',
|
|
56
|
+
): void {
|
|
57
|
+
if (el) {
|
|
58
|
+
el.scrollIntoView({ behavior, block });
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// ── Component ──────────────────────────────────────────────────────────────
|
|
63
|
+
|
|
16
64
|
export default class FreestyleMenu extends Component<Signature> {
|
|
17
65
|
@service declare emberFreestyle: EmberFreestyleService;
|
|
66
|
+
@service declare router: RouterService;
|
|
18
67
|
@reads('args.includeAllOption', true) declare includeAllOption: boolean;
|
|
19
68
|
@reads('emberFreestyle.menu') declare menu: TrackedArray<Section>;
|
|
69
|
+
|
|
70
|
+
expandedSections = new TrackedSet<string>();
|
|
71
|
+
userCollapsedSections = new TrackedSet<string>();
|
|
72
|
+
@tracked filterText = '';
|
|
73
|
+
@tracked highlightedIndex = -1;
|
|
74
|
+
|
|
75
|
+
/** Stable prefix for element IDs to avoid collisions */
|
|
76
|
+
elementIdPrefix = `freestyle-menu-${guidFor(this)}`;
|
|
77
|
+
|
|
78
|
+
// ── Derived state ──────────────────────────────────────────────────────
|
|
79
|
+
|
|
80
|
+
get isFiltering(): boolean {
|
|
81
|
+
return this.filterText.length > 0;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
get activeDescendantId(): string | undefined {
|
|
85
|
+
if (this.highlightedIndex >= 0) {
|
|
86
|
+
return `${this.elementIdPrefix}-item-${this.highlightedIndex}`;
|
|
87
|
+
}
|
|
88
|
+
return undefined;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Sections filtered by search text, with expansion state resolved.
|
|
93
|
+
* Does NOT include highlight/active enrichment — that's in `filteredMenu`.
|
|
94
|
+
*/
|
|
95
|
+
@cached
|
|
96
|
+
get visibleSections(): {
|
|
97
|
+
section: Section;
|
|
98
|
+
subsections: Subsection[];
|
|
99
|
+
isExpanded: boolean;
|
|
100
|
+
}[] {
|
|
101
|
+
const filter = this.filterText.toLowerCase();
|
|
102
|
+
const spySection = this.emberFreestyle.scrollSpySection;
|
|
103
|
+
const hasKeyboardHighlight = this.highlightedIndex >= 0;
|
|
104
|
+
|
|
105
|
+
return this.menu
|
|
106
|
+
.map((section: Section) => {
|
|
107
|
+
if (!filter) {
|
|
108
|
+
let isExpanded = this.isSectionExpanded(section.name);
|
|
109
|
+
|
|
110
|
+
if (
|
|
111
|
+
!isExpanded &&
|
|
112
|
+
spySection === section.name &&
|
|
113
|
+
!hasKeyboardHighlight &&
|
|
114
|
+
section.subsections.length > 0 &&
|
|
115
|
+
!this.userCollapsedSections.has(section.name)
|
|
116
|
+
) {
|
|
117
|
+
isExpanded = true;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return {
|
|
121
|
+
section,
|
|
122
|
+
subsections: section.subsections,
|
|
123
|
+
isExpanded,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const sectionMatches = section.name.toLowerCase().includes(filter);
|
|
128
|
+
const matchingSubs = sectionMatches
|
|
129
|
+
? section.subsections
|
|
130
|
+
: section.subsections.filter((sub: Subsection) =>
|
|
131
|
+
sub.name.toLowerCase().includes(filter),
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
if (!sectionMatches && matchingSubs.length === 0) {
|
|
135
|
+
return null;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return { section, subsections: matchingSubs, isExpanded: true };
|
|
139
|
+
})
|
|
140
|
+
.filter(Boolean) as {
|
|
141
|
+
section: Section;
|
|
142
|
+
subsections: Subsection[];
|
|
143
|
+
isExpanded: boolean;
|
|
144
|
+
}[];
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Enriches `visibleSections` with highlight/active state and flat indices
|
|
149
|
+
* for keyboard navigation and scroll spy. Also schedules sidebar
|
|
150
|
+
* auto-scroll when scroll spy is active.
|
|
151
|
+
*/
|
|
152
|
+
get filteredMenu(): FilteredSection[] {
|
|
153
|
+
const spySection = this.emberFreestyle.scrollSpySection;
|
|
154
|
+
const spySubsection = this.emberFreestyle.scrollSpySubsection;
|
|
155
|
+
const hasKeyboardHighlight = this.highlightedIndex >= 0;
|
|
156
|
+
let flatIndex = 0;
|
|
157
|
+
|
|
158
|
+
const result = this.visibleSections.map(
|
|
159
|
+
({ section, subsections, isExpanded }) => {
|
|
160
|
+
const enrichedSubs = isExpanded
|
|
161
|
+
? subsections.map((sub: Subsection) => {
|
|
162
|
+
const idx = flatIndex++;
|
|
163
|
+
return {
|
|
164
|
+
name: sub.name,
|
|
165
|
+
flatIndex: idx,
|
|
166
|
+
isHighlighted: idx === this.highlightedIndex,
|
|
167
|
+
isActive:
|
|
168
|
+
!hasKeyboardHighlight &&
|
|
169
|
+
spySection === section.name &&
|
|
170
|
+
spySubsection === sub.name,
|
|
171
|
+
};
|
|
172
|
+
})
|
|
173
|
+
: subsections.map((sub: Subsection) => ({
|
|
174
|
+
name: sub.name,
|
|
175
|
+
flatIndex: -1,
|
|
176
|
+
isHighlighted: false,
|
|
177
|
+
isActive: false,
|
|
178
|
+
}));
|
|
179
|
+
|
|
180
|
+
return {
|
|
181
|
+
section,
|
|
182
|
+
subsections: enrichedSubs,
|
|
183
|
+
isExpanded,
|
|
184
|
+
isSectionActive: !hasKeyboardHighlight && spySection === section.name,
|
|
185
|
+
};
|
|
186
|
+
},
|
|
187
|
+
);
|
|
188
|
+
|
|
189
|
+
// Clean up collapsed overrides for sections no longer in scroll spy
|
|
190
|
+
if (spySection) {
|
|
191
|
+
for (const name of this.userCollapsedSections) {
|
|
192
|
+
if (name !== spySection) {
|
|
193
|
+
schedule('afterRender', () =>
|
|
194
|
+
this.userCollapsedSections.delete(name),
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
schedule('afterRender', this, this.scrollActiveItemIntoView);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
return result;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
get flatSubsectionItems(): FlatSubsectionItem[] {
|
|
205
|
+
const items: FlatSubsectionItem[] = [];
|
|
206
|
+
for (const entry of this.filteredMenu) {
|
|
207
|
+
if (entry.isExpanded) {
|
|
208
|
+
for (const sub of entry.subsections) {
|
|
209
|
+
items.push({
|
|
210
|
+
sectionName: entry.section.name,
|
|
211
|
+
subsectionName: sub.name,
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
return items;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
isSectionExpanded(sectionName: string): boolean {
|
|
220
|
+
const currentSection = this.emberFreestyle.section;
|
|
221
|
+
if (currentSection && currentSection === sectionName) {
|
|
222
|
+
return true;
|
|
223
|
+
}
|
|
224
|
+
return this.expandedSections.has(sectionName);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// ── Actions ────────────────────────────────────────────────────────────
|
|
228
|
+
|
|
229
|
+
@action
|
|
230
|
+
onFilterInput(event: Event): void {
|
|
231
|
+
this.filterText = (event.target as HTMLInputElement).value;
|
|
232
|
+
this.highlightedIndex = -1;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
@action
|
|
236
|
+
handleKeydown(event: KeyboardEvent): void {
|
|
237
|
+
if (event.key === 'ArrowDown') {
|
|
238
|
+
event.preventDefault();
|
|
239
|
+
this.moveHighlight(1);
|
|
240
|
+
} else if (event.key === 'ArrowUp') {
|
|
241
|
+
event.preventDefault();
|
|
242
|
+
this.moveHighlight(-1);
|
|
243
|
+
} else if (event.key === 'Enter') {
|
|
244
|
+
event.preventDefault();
|
|
245
|
+
this.navigateToHighlighted();
|
|
246
|
+
} else if (event.key === 'Escape') {
|
|
247
|
+
this.filterText = '';
|
|
248
|
+
this.highlightedIndex = -1;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
@action
|
|
253
|
+
toggleSection(sectionName: string): void {
|
|
254
|
+
if (this.expandedSections.has(sectionName)) {
|
|
255
|
+
this.expandedSections.delete(sectionName);
|
|
256
|
+
this.userCollapsedSections.add(sectionName);
|
|
257
|
+
} else {
|
|
258
|
+
this.expandedSections.add(sectionName);
|
|
259
|
+
this.userCollapsedSections.delete(sectionName);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
@action
|
|
264
|
+
expandSection(sectionName: string): void {
|
|
265
|
+
this.expandedSections.add(sectionName);
|
|
266
|
+
this.userCollapsedSections.delete(sectionName);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// ── Navigation ─────────────────────────────────────────────────────────
|
|
270
|
+
|
|
271
|
+
moveHighlight(direction: 1 | -1): void {
|
|
272
|
+
const items = this.flatSubsectionItems;
|
|
273
|
+
const nextIndex = this.highlightedIndex + direction;
|
|
274
|
+
|
|
275
|
+
if (nextIndex < 0 || nextIndex >= items.length) return;
|
|
276
|
+
|
|
277
|
+
this.highlightedIndex = nextIndex;
|
|
278
|
+
schedule('afterRender', this, this.scrollHighlightedIntoView);
|
|
279
|
+
|
|
280
|
+
const item = items[nextIndex];
|
|
281
|
+
if (item) {
|
|
282
|
+
this.scrollToSubsection(item.sectionName, item.subsectionName);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
navigateToHighlighted(): void {
|
|
287
|
+
const items = this.flatSubsectionItems;
|
|
288
|
+
if (this.highlightedIndex < 0 || this.highlightedIndex >= items.length) {
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
const item = items[this.highlightedIndex];
|
|
292
|
+
if (!item) return;
|
|
293
|
+
|
|
294
|
+
this.expandedSections.add(item.sectionName);
|
|
295
|
+
this.router.transitionTo({
|
|
296
|
+
queryParams: {
|
|
297
|
+
f: null,
|
|
298
|
+
s: item.sectionName,
|
|
299
|
+
ss: item.subsectionName,
|
|
300
|
+
},
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
schedule('afterRender', () =>
|
|
304
|
+
this.scrollToSubsection(item.sectionName, item.subsectionName),
|
|
305
|
+
);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// ── Scroll helpers ─────────────────────────────────────────────────────
|
|
309
|
+
|
|
310
|
+
scrollToSubsection(sectionName: string, subsectionName: string): void {
|
|
311
|
+
const el = document.querySelector(
|
|
312
|
+
subsectionSelector(sectionName, subsectionName),
|
|
313
|
+
);
|
|
314
|
+
scrollElementIntoView(el, 'smooth', 'start');
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
scrollHighlightedIntoView(): void {
|
|
318
|
+
const el = document.getElementById(
|
|
319
|
+
`${this.elementIdPrefix}-item-${this.highlightedIndex}`,
|
|
320
|
+
);
|
|
321
|
+
scrollElementIntoView(el);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
scrollActiveItemIntoView(): void {
|
|
325
|
+
scrollElementIntoView(
|
|
326
|
+
document.querySelector('.FreestyleMenu-submenuItem.is-active'),
|
|
327
|
+
);
|
|
328
|
+
}
|
|
20
329
|
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import Modifier from 'ember-modifier';
|
|
2
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
3
|
+
import { inject as service } from '@ember/service';
|
|
4
|
+
import type EmberFreestyleService from '../services/ember-freestyle';
|
|
5
|
+
|
|
6
|
+
const SELECTOR =
|
|
7
|
+
'.FreestyleSubsection.is-showing, .FreestyleSection.FreestyleSection--showing[data-section]';
|
|
8
|
+
|
|
9
|
+
export default class FreestyleScrollSpy extends Modifier {
|
|
10
|
+
@service declare emberFreestyle: EmberFreestyleService;
|
|
11
|
+
|
|
12
|
+
private intersectionObserver: IntersectionObserver | null = null;
|
|
13
|
+
private mutationObserver: MutationObserver | null = null;
|
|
14
|
+
private visibleElements = new Set<Element>();
|
|
15
|
+
private observedElements = new Set<Element>();
|
|
16
|
+
private _isDestroyed = false;
|
|
17
|
+
private _service: EmberFreestyleService | null = null;
|
|
18
|
+
private _pendingMutationFrame: number | null = null;
|
|
19
|
+
|
|
20
|
+
modify(element: HTMLElement): void {
|
|
21
|
+
if (this.intersectionObserver) {
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Eagerly resolve service while the owner is still alive
|
|
26
|
+
this._service = this.emberFreestyle;
|
|
27
|
+
|
|
28
|
+
this.intersectionObserver = new IntersectionObserver(
|
|
29
|
+
(entries) => {
|
|
30
|
+
if (this._isDestroyed) return;
|
|
31
|
+
|
|
32
|
+
for (const entry of entries) {
|
|
33
|
+
if (entry.isIntersecting) {
|
|
34
|
+
this.visibleElements.add(entry.target);
|
|
35
|
+
} else {
|
|
36
|
+
this.visibleElements.delete(entry.target);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
this.updateActive();
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
root: null,
|
|
43
|
+
threshold: 0,
|
|
44
|
+
// Shrink the effective viewport by 50px top so elements with just
|
|
45
|
+
// a sliver visible at the very top don't count as "topmost"
|
|
46
|
+
rootMargin: '-50px 0px 0px 0px',
|
|
47
|
+
},
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
// Debounce MutationObserver with rAF to coalesce rapid DOM changes
|
|
51
|
+
// (e.g. Glimmer's own rendering)
|
|
52
|
+
this.mutationObserver = new MutationObserver(() => {
|
|
53
|
+
if (this._isDestroyed) return;
|
|
54
|
+
if (this._pendingMutationFrame !== null) return;
|
|
55
|
+
this._pendingMutationFrame = requestAnimationFrame(() => {
|
|
56
|
+
this._pendingMutationFrame = null;
|
|
57
|
+
if (this._isDestroyed) return;
|
|
58
|
+
this.syncObservedElements(element);
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
this.mutationObserver.observe(element, {
|
|
63
|
+
childList: true,
|
|
64
|
+
subtree: true,
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
this.syncObservedElements(element);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Diff-based re-observation: only observe/unobserve elements that changed,
|
|
72
|
+
* preserving the IntersectionObserver's internal state and visibleElements set.
|
|
73
|
+
*/
|
|
74
|
+
private syncObservedElements(container: HTMLElement): void {
|
|
75
|
+
if (!this.intersectionObserver) return;
|
|
76
|
+
|
|
77
|
+
const currentElements = new Set(container.querySelectorAll(SELECTOR));
|
|
78
|
+
|
|
79
|
+
// Unobserve elements that are no longer in the DOM
|
|
80
|
+
for (const el of this.observedElements) {
|
|
81
|
+
if (!currentElements.has(el)) {
|
|
82
|
+
this.intersectionObserver.unobserve(el);
|
|
83
|
+
this.observedElements.delete(el);
|
|
84
|
+
this.visibleElements.delete(el);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Observe newly added elements
|
|
89
|
+
for (const el of currentElements) {
|
|
90
|
+
if (!this.observedElements.has(el)) {
|
|
91
|
+
this.intersectionObserver.observe(el);
|
|
92
|
+
this.observedElements.add(el);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
private updateActive(): void {
|
|
98
|
+
if (this._isDestroyed || !this._service) return;
|
|
99
|
+
|
|
100
|
+
let topmostSubsection: Element | null = null;
|
|
101
|
+
let topmostSubsectionTop = Infinity;
|
|
102
|
+
let topmostSection: Element | null = null;
|
|
103
|
+
let topmostSectionTop = Infinity;
|
|
104
|
+
|
|
105
|
+
for (const el of this.visibleElements) {
|
|
106
|
+
const isSubsection =
|
|
107
|
+
el.classList.contains('FreestyleSubsection') &&
|
|
108
|
+
el.classList.contains('is-showing');
|
|
109
|
+
const isSection =
|
|
110
|
+
el.classList.contains('FreestyleSection') &&
|
|
111
|
+
el.classList.contains('FreestyleSection--showing');
|
|
112
|
+
|
|
113
|
+
if (!isSubsection && !isSection) continue;
|
|
114
|
+
|
|
115
|
+
const rect = el.getBoundingClientRect();
|
|
116
|
+
|
|
117
|
+
if (isSubsection && rect.top < topmostSubsectionTop) {
|
|
118
|
+
topmostSubsectionTop = rect.top;
|
|
119
|
+
topmostSubsection = el;
|
|
120
|
+
}
|
|
121
|
+
if (isSection && rect.top < topmostSectionTop) {
|
|
122
|
+
topmostSectionTop = rect.top;
|
|
123
|
+
topmostSection = el;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Prefer subsections; fall back to section-level for sections without children
|
|
128
|
+
const topmost = topmostSubsection || topmostSection;
|
|
129
|
+
if (topmost) {
|
|
130
|
+
const section = topmost.getAttribute('data-section');
|
|
131
|
+
const subsection = topmost.getAttribute('data-subsection') || null;
|
|
132
|
+
this._service.setScrollSpyActive(section, subsection);
|
|
133
|
+
} else {
|
|
134
|
+
this._service.setScrollSpyActive(null, null);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
willDestroy(): void {
|
|
139
|
+
this._isDestroyed = true;
|
|
140
|
+
this._service = null;
|
|
141
|
+
if (this._pendingMutationFrame !== null) {
|
|
142
|
+
cancelAnimationFrame(this._pendingMutationFrame);
|
|
143
|
+
this._pendingMutationFrame = null;
|
|
144
|
+
}
|
|
145
|
+
const io = this.intersectionObserver;
|
|
146
|
+
const mo = this.mutationObserver;
|
|
147
|
+
this.intersectionObserver = null;
|
|
148
|
+
this.mutationObserver = null;
|
|
149
|
+
this.visibleElements.clear();
|
|
150
|
+
this.observedElements.clear();
|
|
151
|
+
if (io) io.disconnect();
|
|
152
|
+
if (mo) mo.disconnect();
|
|
153
|
+
}
|
|
154
|
+
}
|
|
@@ -10,7 +10,7 @@ export interface Section {
|
|
|
10
10
|
subsections: Subsection[];
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
-
interface Subsection {
|
|
13
|
+
export interface Subsection {
|
|
14
14
|
name: string;
|
|
15
15
|
}
|
|
16
16
|
|
|
@@ -31,6 +31,16 @@ export default class EmberFreestyleService extends Service {
|
|
|
31
31
|
@tracked subsection = null;
|
|
32
32
|
@tracked focus: string | null = null;
|
|
33
33
|
|
|
34
|
+
@tracked scrollSpySection: string | null = null;
|
|
35
|
+
@tracked scrollSpySubsection: string | null = null;
|
|
36
|
+
|
|
37
|
+
@action
|
|
38
|
+
setScrollSpyActive(section: string | null, subsection: string | null): void {
|
|
39
|
+
if (this.isDestroyed || this.isDestroying) return;
|
|
40
|
+
this.scrollSpySection = section;
|
|
41
|
+
this.scrollSpySubsection = subsection;
|
|
42
|
+
}
|
|
43
|
+
|
|
34
44
|
get notFocused(): boolean {
|
|
35
45
|
return !this.focus;
|
|
36
46
|
}
|
|
@@ -34,6 +34,7 @@ import type FreestyleUsageString from 'ember-freestyle/components/freestyle/usag
|
|
|
34
34
|
|
|
35
35
|
// modifiers
|
|
36
36
|
import type FreestyleHighlight from 'ember-freestyle/modifiers/freestyle-highlight';
|
|
37
|
+
import type FreestyleScrollSpy from 'ember-freestyle/modifiers/freestyle-scroll-spy';
|
|
37
38
|
import EnsureFreestyleTheme from './modifiers/ensure-freestyle-theme';
|
|
38
39
|
|
|
39
40
|
export default interface EmberFreestyleRegistry {
|
|
@@ -105,5 +106,6 @@ export default interface EmberFreestyleRegistry {
|
|
|
105
106
|
|
|
106
107
|
// modifiers
|
|
107
108
|
'freestyle-highlight': typeof FreestyleHighlight;
|
|
109
|
+
'freestyle-scroll-spy': typeof FreestyleScrollSpy;
|
|
108
110
|
'ensure-freestyle-theme': typeof EnsureFreestyleTheme;
|
|
109
111
|
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default } from 'ember-freestyle/modifiers/freestyle-scroll-spy';
|
|
@@ -1,37 +1,186 @@
|
|
|
1
|
+
// Darker variant of primary for active text (avoids deprecated darken())
|
|
2
|
+
$FreestyleMenu-color--active: #008a9e;
|
|
3
|
+
|
|
1
4
|
.FreestyleMenu {
|
|
2
5
|
font-size: 14px;
|
|
3
6
|
list-style: none;
|
|
4
|
-
padding
|
|
7
|
+
padding: 0;
|
|
8
|
+
margin: 0;
|
|
5
9
|
|
|
6
|
-
&-
|
|
7
|
-
|
|
8
|
-
padding-top: 0.1rem;
|
|
10
|
+
&-search {
|
|
11
|
+
padding: 0 0.75rem 0.75rem;
|
|
9
12
|
}
|
|
10
13
|
|
|
11
|
-
&-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
+
&-searchInput {
|
|
15
|
+
box-sizing: border-box;
|
|
16
|
+
width: 100%;
|
|
17
|
+
padding: 0.5rem 0.75rem;
|
|
18
|
+
border: none;
|
|
19
|
+
border-radius: 100px;
|
|
20
|
+
font-size: 13px;
|
|
21
|
+
font-family: inherit;
|
|
22
|
+
outline: none;
|
|
23
|
+
background-color: rgba($FreestyleGuide-color--foreground, 0.06);
|
|
24
|
+
color: $FreestyleGuide-color--foreground;
|
|
25
|
+
|
|
26
|
+
&::placeholder {
|
|
27
|
+
color: rgba($FreestyleGuide-color--foreground, 0.5);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
&:focus {
|
|
31
|
+
background-color: rgba($FreestyleGuide-color--foreground, 0.1);
|
|
32
|
+
box-shadow: 0 0 0 2px rgba($FreestyleGuide-color--primary, 0.3);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
&-list {
|
|
37
|
+
list-style: none;
|
|
38
|
+
padding: 0;
|
|
39
|
+
margin: 0;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Top-level menu items (section groups)
|
|
43
|
+
&-item {
|
|
44
|
+
list-style: none;
|
|
45
|
+
margin: 0 0.5rem;
|
|
46
|
+
border-radius: 12px;
|
|
47
|
+
transition: background-color 0.2s ease;
|
|
48
|
+
|
|
49
|
+
// Expanded group: tinted background
|
|
50
|
+
&.is-expanded {
|
|
51
|
+
background-color: rgba($FreestyleGuide-color--primary, 0.06);
|
|
52
|
+
margin-bottom: 2px;
|
|
53
|
+
padding-bottom: 2px;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Section-level scroll spy active (sections without children)
|
|
57
|
+
&.is-active:not(.is-expanded) {
|
|
58
|
+
background-color: rgba($FreestyleGuide-color--primary, 0.1);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
&-itemHeader {
|
|
63
|
+
position: relative;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
&-chevron {
|
|
67
|
+
display: inline-block;
|
|
68
|
+
width: 0;
|
|
69
|
+
height: 0;
|
|
70
|
+
border-top: 3.5px solid transparent;
|
|
71
|
+
border-bottom: 3.5px solid transparent;
|
|
72
|
+
border-left: 5px solid currentColor;
|
|
73
|
+
margin-right: 0.3rem;
|
|
74
|
+
transition: transform 0.2s ease;
|
|
75
|
+
vertical-align: middle;
|
|
76
|
+
opacity: 0.5;
|
|
77
|
+
|
|
78
|
+
&.is-expanded {
|
|
79
|
+
transform: rotate(90deg);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Toggle overlay on chevron area
|
|
84
|
+
&-collapseToggle {
|
|
85
|
+
position: absolute;
|
|
86
|
+
left: 0;
|
|
87
|
+
top: 0;
|
|
88
|
+
width: 2rem;
|
|
89
|
+
height: 100%;
|
|
90
|
+
background: none;
|
|
91
|
+
border: none;
|
|
92
|
+
cursor: pointer;
|
|
93
|
+
padding: 0;
|
|
94
|
+
|
|
95
|
+
&:focus-visible {
|
|
96
|
+
outline: 2px solid $FreestyleGuide-color--primary;
|
|
97
|
+
outline-offset: -2px;
|
|
98
|
+
border-radius: 4px;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Section header links — padding-left accounts for chevron width so
|
|
103
|
+
// items with and without chevrons align their text at the same position
|
|
104
|
+
&-itemLink {
|
|
105
|
+
border-radius: 100px;
|
|
14
106
|
color: $FreestyleGuide-color--foreground;
|
|
15
107
|
display: block;
|
|
16
|
-
padding: 0.
|
|
108
|
+
padding: 0.45rem 0.75rem 0.45rem 1.4rem;
|
|
17
109
|
text-decoration: none;
|
|
110
|
+
font-weight: 500;
|
|
111
|
+
letter-spacing: 0.01em;
|
|
18
112
|
|
|
113
|
+
// Ember active route
|
|
19
114
|
&.active {
|
|
20
|
-
background-color: $FreestyleGuide-color--primary;
|
|
21
|
-
color:
|
|
115
|
+
background-color: rgba($FreestyleGuide-color--primary, 0.18);
|
|
116
|
+
color: $FreestyleMenu-color--active;
|
|
22
117
|
text-decoration: none;
|
|
23
|
-
font-weight:
|
|
118
|
+
font-weight: 600;
|
|
24
119
|
}
|
|
25
120
|
|
|
26
121
|
&:hover {
|
|
27
|
-
background-color: $FreestyleGuide-color--
|
|
28
|
-
color: white;
|
|
122
|
+
background-color: rgba($FreestyleGuide-color--foreground, 0.06);
|
|
29
123
|
text-decoration: none;
|
|
30
124
|
}
|
|
125
|
+
|
|
126
|
+
// Bold when section is expanded
|
|
127
|
+
.FreestyleMenu-item.is-expanded > .FreestyleMenu-itemHeader > & {
|
|
128
|
+
font-weight: 600;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Bold when section is scroll-spy active (no children)
|
|
132
|
+
.FreestyleMenu-item.is-active > .FreestyleMenu-itemHeader > & {
|
|
133
|
+
font-weight: 600;
|
|
134
|
+
}
|
|
31
135
|
}
|
|
32
136
|
|
|
137
|
+
// Child item list
|
|
33
138
|
&-submenu {
|
|
34
139
|
list-style: none;
|
|
35
|
-
padding
|
|
140
|
+
padding: 0 0 0.15rem;
|
|
141
|
+
margin: 0;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Child item links
|
|
145
|
+
&-submenuItemLink {
|
|
146
|
+
border-radius: 100px;
|
|
147
|
+
color: rgba($FreestyleGuide-color--foreground, 0.7);
|
|
148
|
+
display: block;
|
|
149
|
+
padding: 0.3rem 0.75rem 0.3rem 1.75rem;
|
|
150
|
+
text-decoration: none;
|
|
151
|
+
font-size: 12px;
|
|
152
|
+
font-weight: 400;
|
|
153
|
+
letter-spacing: 0.01em;
|
|
154
|
+
|
|
155
|
+
// Ember active route
|
|
156
|
+
&.active {
|
|
157
|
+
background-color: rgba($FreestyleGuide-color--primary, 0.18);
|
|
158
|
+
color: $FreestyleMenu-color--active;
|
|
159
|
+
text-decoration: none;
|
|
160
|
+
font-weight: 600;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
&:hover {
|
|
164
|
+
background-color: rgba($FreestyleGuide-color--foreground, 0.06);
|
|
165
|
+
color: $FreestyleGuide-color--foreground;
|
|
166
|
+
text-decoration: none;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Keyboard highlight
|
|
170
|
+
.FreestyleMenu-submenuItem.is-highlighted > & {
|
|
171
|
+
background-color: rgba($FreestyleGuide-color--primary, 0.18);
|
|
172
|
+
color: $FreestyleMenu-color--active;
|
|
173
|
+
font-weight: 600;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Scroll spy active: bold text (section group already has tint)
|
|
177
|
+
.FreestyleMenu-submenuItem.is-active > & {
|
|
178
|
+
color: $FreestyleGuide-color--foreground;
|
|
179
|
+
font-weight: 700;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
&-submenuItem {
|
|
184
|
+
padding: 0 0.35rem;
|
|
36
185
|
}
|
|
37
186
|
}
|
|
@@ -1,7 +1,24 @@
|
|
|
1
1
|
import Component from '@glimmer/component';
|
|
2
|
+
import type RouterService from '@ember/routing/router-service';
|
|
2
3
|
import type EmberFreestyleService from '../../services/ember-freestyle';
|
|
3
|
-
import { type Section } from '../../services/ember-freestyle';
|
|
4
|
-
import { TrackedArray } from 'tracked-built-ins';
|
|
4
|
+
import { type Section, type Subsection } from '../../services/ember-freestyle';
|
|
5
|
+
import { TrackedArray, TrackedSet } from 'tracked-built-ins';
|
|
6
|
+
export interface FilteredSubsection {
|
|
7
|
+
name: string;
|
|
8
|
+
flatIndex: number;
|
|
9
|
+
isHighlighted: boolean;
|
|
10
|
+
isActive: boolean;
|
|
11
|
+
}
|
|
12
|
+
export interface FilteredSection {
|
|
13
|
+
section: Section;
|
|
14
|
+
subsections: FilteredSubsection[];
|
|
15
|
+
isExpanded: boolean;
|
|
16
|
+
isSectionActive: boolean;
|
|
17
|
+
}
|
|
18
|
+
interface FlatSubsectionItem {
|
|
19
|
+
sectionName: string;
|
|
20
|
+
subsectionName: string;
|
|
21
|
+
}
|
|
5
22
|
interface Signature {
|
|
6
23
|
Args: {
|
|
7
24
|
includeAllOption?: boolean;
|
|
@@ -9,7 +26,42 @@ interface Signature {
|
|
|
9
26
|
}
|
|
10
27
|
export default class FreestyleMenu extends Component<Signature> {
|
|
11
28
|
emberFreestyle: EmberFreestyleService;
|
|
29
|
+
router: RouterService;
|
|
12
30
|
includeAllOption: boolean;
|
|
13
31
|
menu: TrackedArray<Section>;
|
|
32
|
+
expandedSections: TrackedSet<string>;
|
|
33
|
+
userCollapsedSections: TrackedSet<string>;
|
|
34
|
+
filterText: string;
|
|
35
|
+
highlightedIndex: number;
|
|
36
|
+
/** Stable prefix for element IDs to avoid collisions */
|
|
37
|
+
elementIdPrefix: string;
|
|
38
|
+
get isFiltering(): boolean;
|
|
39
|
+
get activeDescendantId(): string | undefined;
|
|
40
|
+
/**
|
|
41
|
+
* Sections filtered by search text, with expansion state resolved.
|
|
42
|
+
* Does NOT include highlight/active enrichment — that's in `filteredMenu`.
|
|
43
|
+
*/
|
|
44
|
+
get visibleSections(): {
|
|
45
|
+
section: Section;
|
|
46
|
+
subsections: Subsection[];
|
|
47
|
+
isExpanded: boolean;
|
|
48
|
+
}[];
|
|
49
|
+
/**
|
|
50
|
+
* Enriches `visibleSections` with highlight/active state and flat indices
|
|
51
|
+
* for keyboard navigation and scroll spy. Also schedules sidebar
|
|
52
|
+
* auto-scroll when scroll spy is active.
|
|
53
|
+
*/
|
|
54
|
+
get filteredMenu(): FilteredSection[];
|
|
55
|
+
get flatSubsectionItems(): FlatSubsectionItem[];
|
|
56
|
+
isSectionExpanded(sectionName: string): boolean;
|
|
57
|
+
onFilterInput(event: Event): void;
|
|
58
|
+
handleKeydown(event: KeyboardEvent): void;
|
|
59
|
+
toggleSection(sectionName: string): void;
|
|
60
|
+
expandSection(sectionName: string): void;
|
|
61
|
+
moveHighlight(direction: 1 | -1): void;
|
|
62
|
+
navigateToHighlighted(): void;
|
|
63
|
+
scrollToSubsection(sectionName: string, subsectionName: string): void;
|
|
64
|
+
scrollHighlightedIntoView(): void;
|
|
65
|
+
scrollActiveItemIntoView(): void;
|
|
14
66
|
}
|
|
15
67
|
export {};
|
|
Binary file
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import Modifier from 'ember-modifier';
|
|
2
|
+
import type EmberFreestyleService from '../services/ember-freestyle';
|
|
3
|
+
export default class FreestyleScrollSpy extends Modifier {
|
|
4
|
+
emberFreestyle: EmberFreestyleService;
|
|
5
|
+
private intersectionObserver;
|
|
6
|
+
private mutationObserver;
|
|
7
|
+
private visibleElements;
|
|
8
|
+
private observedElements;
|
|
9
|
+
private _isDestroyed;
|
|
10
|
+
private _service;
|
|
11
|
+
private _pendingMutationFrame;
|
|
12
|
+
modify(element: HTMLElement): void;
|
|
13
|
+
/**
|
|
14
|
+
* Diff-based re-observation: only observe/unobserve elements that changed,
|
|
15
|
+
* preserving the IntersectionObserver's internal state and visibleElements set.
|
|
16
|
+
*/
|
|
17
|
+
private syncObservedElements;
|
|
18
|
+
private updateActive;
|
|
19
|
+
willDestroy(): void;
|
|
20
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ember-freestyle",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.23.0",
|
|
4
4
|
"description": "Create a living styleguide for your Ember app.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ember-addon"
|
|
@@ -39,6 +39,7 @@
|
|
|
39
39
|
"@glimmer/component": "^1.1.2",
|
|
40
40
|
"@glimmer/tracking": "^1.1.2",
|
|
41
41
|
"ember-auto-import": "^2.4.3",
|
|
42
|
+
"ember-cached-decorator-polyfill": "^1.0.2",
|
|
42
43
|
"ember-cli-babel": "^8.2.0",
|
|
43
44
|
"ember-cli-htmlbars": "^6.3.0",
|
|
44
45
|
"ember-cli-typescript": "^5.1.1",
|
|
@@ -49,7 +50,7 @@
|
|
|
49
50
|
"json-formatter-js": "^2.3.4",
|
|
50
51
|
"macro-decorators": "^0.1.2",
|
|
51
52
|
"strip-indent": "^3.0.0",
|
|
52
|
-
"tracked-built-ins": "^3.1.0"
|
|
53
|
+
"tracked-built-ins": "^3.1.0 || ^4.0.0"
|
|
53
54
|
},
|
|
54
55
|
"devDependencies": {
|
|
55
56
|
"@ember/optional-features": "^2.0.0",
|
|
@@ -4,7 +4,7 @@ export interface Section {
|
|
|
4
4
|
name: string;
|
|
5
5
|
subsections: Subsection[];
|
|
6
6
|
}
|
|
7
|
-
interface Subsection {
|
|
7
|
+
export interface Subsection {
|
|
8
8
|
name: string;
|
|
9
9
|
}
|
|
10
10
|
export default class EmberFreestyleService extends Service {
|
|
@@ -19,6 +19,9 @@ export default class EmberFreestyleService extends Service {
|
|
|
19
19
|
section: null;
|
|
20
20
|
subsection: null;
|
|
21
21
|
focus: string | null;
|
|
22
|
+
scrollSpySection: string | null;
|
|
23
|
+
scrollSpySubsection: string | null;
|
|
24
|
+
setScrollSpyActive(section: string | null, subsection: string | null): void;
|
|
22
25
|
get notFocused(): boolean;
|
|
23
26
|
shouldShowSection(sectionName: string): boolean;
|
|
24
27
|
shouldShowSubsection(sectionName: string, subsectionName: string): boolean;
|
|
@@ -40,4 +43,3 @@ declare module '@ember/service' {
|
|
|
40
43
|
'ember-freestyle': EmberFreestyleService;
|
|
41
44
|
}
|
|
42
45
|
}
|
|
43
|
-
export {};
|
package/template-registry.d.ts
CHANGED
|
@@ -31,6 +31,7 @@ import type FreestyleUsageObject from 'ember-freestyle/components/freestyle/usag
|
|
|
31
31
|
import type FreestyleUsageStringControl from 'ember-freestyle/components/freestyle/usage/string/control';
|
|
32
32
|
import type FreestyleUsageString from 'ember-freestyle/components/freestyle/usage/string';
|
|
33
33
|
import type FreestyleHighlight from 'ember-freestyle/modifiers/freestyle-highlight';
|
|
34
|
+
import type FreestyleScrollSpy from 'ember-freestyle/modifiers/freestyle-scroll-spy';
|
|
34
35
|
import EnsureFreestyleTheme from './modifiers/ensure-freestyle-theme';
|
|
35
36
|
export default interface EmberFreestyleRegistry {
|
|
36
37
|
'freestyle-annotation': typeof FreestyleAnnotation;
|
|
@@ -98,5 +99,6 @@ export default interface EmberFreestyleRegistry {
|
|
|
98
99
|
'freestyle/usage/string': typeof FreestyleUsageString;
|
|
99
100
|
'Freestyle::Usage::String': typeof FreestyleUsageString;
|
|
100
101
|
'freestyle-highlight': typeof FreestyleHighlight;
|
|
102
|
+
'freestyle-scroll-spy': typeof FreestyleScrollSpy;
|
|
101
103
|
'ensure-freestyle-theme': typeof EnsureFreestyleTheme;
|
|
102
104
|
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// Type augmentation for @cached (provided at runtime by ember-cached-decorator-polyfill)
|
|
2
|
+
import '@glimmer/tracking';
|
|
3
|
+
|
|
4
|
+
declare module '@glimmer/tracking' {
|
|
5
|
+
export function cached(
|
|
6
|
+
target: object,
|
|
7
|
+
propertyKey: string,
|
|
8
|
+
descriptor: PropertyDescriptor,
|
|
9
|
+
): PropertyDescriptor;
|
|
10
|
+
}
|
|
@@ -324,32 +324,146 @@ END-FREESTYLE-USAGE */
|
|
|
324
324
|
.FreestyleMenu {
|
|
325
325
|
font-size: 14px;
|
|
326
326
|
list-style: none;
|
|
327
|
-
padding
|
|
327
|
+
padding: 0;
|
|
328
|
+
margin: 0;
|
|
328
329
|
}
|
|
329
|
-
.FreestyleMenu-
|
|
330
|
-
padding
|
|
330
|
+
.FreestyleMenu-search {
|
|
331
|
+
padding: 0 0.75rem 0.75rem;
|
|
331
332
|
}
|
|
332
|
-
.FreestyleMenu-
|
|
333
|
-
|
|
333
|
+
.FreestyleMenu-searchInput {
|
|
334
|
+
box-sizing: border-box;
|
|
335
|
+
width: 100%;
|
|
336
|
+
padding: 0.5rem 0.75rem;
|
|
337
|
+
border: none;
|
|
338
|
+
border-radius: 100px;
|
|
339
|
+
font-size: 13px;
|
|
340
|
+
font-family: inherit;
|
|
341
|
+
outline: none;
|
|
342
|
+
background-color: rgba(33, 33, 33, 0.06);
|
|
343
|
+
color: #212121;
|
|
344
|
+
}
|
|
345
|
+
.FreestyleMenu-searchInput::placeholder {
|
|
346
|
+
color: rgba(33, 33, 33, 0.5);
|
|
347
|
+
}
|
|
348
|
+
.FreestyleMenu-searchInput:focus {
|
|
349
|
+
background-color: rgba(33, 33, 33, 0.1);
|
|
350
|
+
box-shadow: 0 0 0 2px rgba(0, 188, 212, 0.3);
|
|
351
|
+
}
|
|
352
|
+
.FreestyleMenu-list {
|
|
353
|
+
list-style: none;
|
|
354
|
+
padding: 0;
|
|
355
|
+
margin: 0;
|
|
356
|
+
}
|
|
357
|
+
.FreestyleMenu-item {
|
|
358
|
+
list-style: none;
|
|
359
|
+
margin: 0 0.5rem;
|
|
360
|
+
border-radius: 12px;
|
|
361
|
+
transition: background-color 0.2s ease;
|
|
362
|
+
}
|
|
363
|
+
.FreestyleMenu-item.is-expanded {
|
|
364
|
+
background-color: rgba(0, 188, 212, 0.06);
|
|
365
|
+
margin-bottom: 2px;
|
|
366
|
+
padding-bottom: 2px;
|
|
367
|
+
}
|
|
368
|
+
.FreestyleMenu-item.is-active:not(.is-expanded) {
|
|
369
|
+
background-color: rgba(0, 188, 212, 0.1);
|
|
370
|
+
}
|
|
371
|
+
.FreestyleMenu-itemHeader {
|
|
372
|
+
position: relative;
|
|
373
|
+
}
|
|
374
|
+
.FreestyleMenu-chevron {
|
|
375
|
+
display: inline-block;
|
|
376
|
+
width: 0;
|
|
377
|
+
height: 0;
|
|
378
|
+
border-top: 3.5px solid transparent;
|
|
379
|
+
border-bottom: 3.5px solid transparent;
|
|
380
|
+
border-left: 5px solid currentColor;
|
|
381
|
+
margin-right: 0.3rem;
|
|
382
|
+
transition: transform 0.2s ease;
|
|
383
|
+
vertical-align: middle;
|
|
384
|
+
opacity: 0.5;
|
|
385
|
+
}
|
|
386
|
+
.FreestyleMenu-chevron.is-expanded {
|
|
387
|
+
transform: rotate(90deg);
|
|
388
|
+
}
|
|
389
|
+
.FreestyleMenu-collapseToggle {
|
|
390
|
+
position: absolute;
|
|
391
|
+
left: 0;
|
|
392
|
+
top: 0;
|
|
393
|
+
width: 2rem;
|
|
394
|
+
height: 100%;
|
|
395
|
+
background: none;
|
|
396
|
+
border: none;
|
|
397
|
+
cursor: pointer;
|
|
398
|
+
padding: 0;
|
|
399
|
+
}
|
|
400
|
+
.FreestyleMenu-collapseToggle:focus-visible {
|
|
401
|
+
outline: 2px solid #00bcd4;
|
|
402
|
+
outline-offset: -2px;
|
|
403
|
+
border-radius: 4px;
|
|
404
|
+
}
|
|
405
|
+
.FreestyleMenu-itemLink {
|
|
406
|
+
border-radius: 100px;
|
|
334
407
|
color: #212121;
|
|
335
408
|
display: block;
|
|
336
|
-
padding: 0.
|
|
409
|
+
padding: 0.45rem 0.75rem 0.45rem 1.4rem;
|
|
337
410
|
text-decoration: none;
|
|
411
|
+
font-weight: 500;
|
|
412
|
+
letter-spacing: 0.01em;
|
|
338
413
|
}
|
|
339
|
-
.FreestyleMenu-itemLink.active
|
|
340
|
-
background-color:
|
|
341
|
-
color:
|
|
414
|
+
.FreestyleMenu-itemLink.active {
|
|
415
|
+
background-color: rgba(0, 188, 212, 0.18);
|
|
416
|
+
color: #008a9e;
|
|
342
417
|
text-decoration: none;
|
|
343
|
-
font-weight:
|
|
418
|
+
font-weight: 600;
|
|
344
419
|
}
|
|
345
|
-
.FreestyleMenu-itemLink:hover
|
|
346
|
-
background-color:
|
|
347
|
-
color: white;
|
|
420
|
+
.FreestyleMenu-itemLink:hover {
|
|
421
|
+
background-color: rgba(33, 33, 33, 0.06);
|
|
348
422
|
text-decoration: none;
|
|
349
423
|
}
|
|
424
|
+
.FreestyleMenu-item.is-expanded > .FreestyleMenu-itemHeader > .FreestyleMenu-itemLink {
|
|
425
|
+
font-weight: 600;
|
|
426
|
+
}
|
|
427
|
+
.FreestyleMenu-item.is-active > .FreestyleMenu-itemHeader > .FreestyleMenu-itemLink {
|
|
428
|
+
font-weight: 600;
|
|
429
|
+
}
|
|
350
430
|
.FreestyleMenu-submenu {
|
|
351
431
|
list-style: none;
|
|
352
|
-
padding
|
|
432
|
+
padding: 0 0 0.15rem;
|
|
433
|
+
margin: 0;
|
|
434
|
+
}
|
|
435
|
+
.FreestyleMenu-submenuItemLink {
|
|
436
|
+
border-radius: 100px;
|
|
437
|
+
color: rgba(33, 33, 33, 0.7);
|
|
438
|
+
display: block;
|
|
439
|
+
padding: 0.3rem 0.75rem 0.3rem 1.75rem;
|
|
440
|
+
text-decoration: none;
|
|
441
|
+
font-size: 12px;
|
|
442
|
+
font-weight: 400;
|
|
443
|
+
letter-spacing: 0.01em;
|
|
444
|
+
}
|
|
445
|
+
.FreestyleMenu-submenuItemLink.active {
|
|
446
|
+
background-color: rgba(0, 188, 212, 0.18);
|
|
447
|
+
color: #008a9e;
|
|
448
|
+
text-decoration: none;
|
|
449
|
+
font-weight: 600;
|
|
450
|
+
}
|
|
451
|
+
.FreestyleMenu-submenuItemLink:hover {
|
|
452
|
+
background-color: rgba(33, 33, 33, 0.06);
|
|
453
|
+
color: #212121;
|
|
454
|
+
text-decoration: none;
|
|
455
|
+
}
|
|
456
|
+
.FreestyleMenu-submenuItem.is-highlighted > .FreestyleMenu-submenuItemLink {
|
|
457
|
+
background-color: rgba(0, 188, 212, 0.18);
|
|
458
|
+
color: #008a9e;
|
|
459
|
+
font-weight: 600;
|
|
460
|
+
}
|
|
461
|
+
.FreestyleMenu-submenuItem.is-active > .FreestyleMenu-submenuItemLink {
|
|
462
|
+
color: #212121;
|
|
463
|
+
font-weight: 700;
|
|
464
|
+
}
|
|
465
|
+
.FreestyleMenu-submenuItem {
|
|
466
|
+
padding: 0 0.35rem;
|
|
353
467
|
}
|
|
354
468
|
|
|
355
469
|
.FreestyleSource-title {
|