ember-freestyle 0.22.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 CHANGED
@@ -6,6 +6,15 @@ 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
+
9
18
  ## v0.22.0 (2025-03-18)
10
19
 
11
20
  ## v0.21.0 (2024-09-27)
@@ -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
- <ul class="FreestyleMenu">
2
- {{#if this.includeAllOption}}
3
- <li class="FreestyleMenu-item">
4
- <LinkTo @query={{hash s=null ss=null f=null}} class="FreestyleMenu-itemLink">
5
- All
6
- </LinkTo>
7
- </li>
8
- {{/if}}
9
- {{#each this.menu as |section|}}
10
- <li class="FreestyleMenu-item">
11
- <LinkTo @query={{hash f=null s=section.name ss=null}} class="FreestyleMenu-itemLink">
12
- {{section.name}}
13
- </LinkTo>
14
- {{#if section.subsections.length}}
15
- <ul class="FreestyleMenu-submenu">
16
- {{#each section.subsections as |subsection|}}
17
- <li class="FreestyleMenu-submenuItem">
18
- <LinkTo @query={{hash f=null s=section.name ss=subsection.name}} class="FreestyleMenu-submenuItemLink">
19
- {{subsection.name}}
20
- </LinkTo>
21
- </li>
22
- {{/each}}
23
- </ul>
24
- {{/if}}
25
- </li>
26
- {{/each}}
27
- </ul>
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
  }
@@ -1,5 +1,6 @@
1
1
  <div
2
2
  class="FreestyleSection {{if this.show 'FreestyleSection--showing' 'FreestyleSection--hidden'}}"
3
+ data-section={{@name}}
3
4
  ...attributes
4
5
  >
5
6
  {{#if this.show}}
@@ -1,4 +1,6 @@
1
1
  <div class="FreestyleSubsection {{if this.show 'is-showing' 'is-hidden'}}"
2
+ data-section={{@section}}
3
+ data-subsection={{@name}}
2
4
  ...attributes
3
5
  >
4
6
  {{#if this.show}}
@@ -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-left: 1rem;
7
+ padding: 0;
8
+ margin: 0;
5
9
 
6
- &-item,
7
- &-submenuItem {
8
- padding-top: 0.1rem;
10
+ &-search {
11
+ padding: 0 0.75rem 0.75rem;
9
12
  }
10
13
 
11
- &-itemLink,
12
- &-submenuItemLink {
13
- border-radius: 6px;
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.3rem 0.3rem 0.3rem 0.5rem;
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: white;
115
+ background-color: rgba($FreestyleGuide-color--primary, 0.18);
116
+ color: $FreestyleMenu-color--active;
22
117
  text-decoration: none;
23
- font-weight: bold;
118
+ font-weight: 600;
24
119
  }
25
120
 
26
121
  &:hover {
27
- background-color: $FreestyleGuide-color--primary;
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-left: 1rem;
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.22.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",
@@ -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 {};
@@ -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-left: 1rem;
327
+ padding: 0;
328
+ margin: 0;
328
329
  }
329
- .FreestyleMenu-item, .FreestyleMenu-submenuItem {
330
- padding-top: 0.1rem;
330
+ .FreestyleMenu-search {
331
+ padding: 0 0.75rem 0.75rem;
331
332
  }
332
- .FreestyleMenu-itemLink, .FreestyleMenu-submenuItemLink {
333
- border-radius: 6px;
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.3rem 0.3rem 0.3rem 0.5rem;
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, .FreestyleMenu-submenuItemLink.active {
340
- background-color: #00bcd4;
341
- color: white;
414
+ .FreestyleMenu-itemLink.active {
415
+ background-color: rgba(0, 188, 212, 0.18);
416
+ color: #008a9e;
342
417
  text-decoration: none;
343
- font-weight: bold;
418
+ font-weight: 600;
344
419
  }
345
- .FreestyleMenu-itemLink:hover, .FreestyleMenu-submenuItemLink:hover {
346
- background-color: #00bcd4;
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-left: 1rem;
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 {